Implements PLAN.md Phase 7: the server half of end-to-end encryption.
Devices upload identity, one-time and fallback keys; other devices query
and claim them, across servers too, and every one-time key is handed out
exactly once. Cross-signing, signature uploads, device list tracking and
server-side key backup complete the set.
internal/keyserver is the component. Keys are stored in canonical JSON
so that re-uploading the same key is a byte comparison, and signatures
uploaded later are kept apart from the key they sign and merged in on
read, so a key's own JSON stays what its owner uploaded. Device keys must
be for the caller's own device; cross-signing keys must be the
uploader's, carry the right usage and, for self- and user-signing keys,
be signed by the master key. Signatures are verified against the key as
stored. Replacing a master key asks for the password; a first upload or
an upload of the keys already stored does not, as the spec allows. A
signature of another user's master key is shown only to the user who
made it and never federated.
Every write that changes what /keys/query answers moves the device list
stream in the same transaction, one position per device, and wakes the
syncs of everyone sharing a room. The stream is the sixth field of a sync
token; older tokens still parse. The syncapi works out device_lists
changed and left from the stream and from membership moves in the
window, skipping invites and knocks, and reports
device_one_time_keys_count and device_unused_fallback_key_types, the
latter as [] when empty. GET /keys/changes uses the same logic.
Over federation: /user/keys/query, /user/keys/claim and
/user/devices/{userId}, and m.device_list_update and m.signing_key_update
both ways. Remote keys are not cached; an update about a remote user
sharing a room moves them in the stream so local clients re-query.
Device display names are not sent to other servers. With federation off,
remote users are reported in failures at once.
Deleting a device deletes its keys, through a new userapi
DeviceObserver. Key backup keeps the better copy of each session by the
spec's rule, never reuses a deleted version's number, and answers writes
to a stale version with M_WRONG_ROOM_KEYS_VERSION.
Storage is migration 0008 for sqlite and postgres, with a PocketBase
stub. The Complement target sets gain the key, device list, fallback,
cross-signing and backup tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
11 KiB
Go
279 lines
11 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
)
|
|
|
|
// Errors the key store returns for cases a caller has to tell apart.
|
|
var (
|
|
// ErrKeyConflict is a one-time key uploaded under an ID that already
|
|
// holds a different key. The spec has a key ID name one key for its
|
|
// whole life: a client that reuses one has lost track of what it
|
|
// uploaded, and silently replacing the key would leave a peer that
|
|
// already claimed the old one unable to decrypt.
|
|
ErrKeyConflict = errors.New("a different key is already stored under this key ID")
|
|
|
|
// ErrNoSuchBackup is a key backup version that does not exist, or has
|
|
// been deleted.
|
|
ErrNoSuchBackup = errors.New("no such key backup version")
|
|
|
|
// ErrNoSuchBackupKey is a room key that is not in a backup.
|
|
ErrNoSuchBackupKey = errors.New("no such key in the backup")
|
|
)
|
|
|
|
// The purposes a cross-signing key can have, as the spec spells its usage.
|
|
const (
|
|
CrossSigningMaster = "master"
|
|
CrossSigningSelfSigning = "self_signing"
|
|
CrossSigningUserSigning = "user_signing"
|
|
)
|
|
|
|
// DeviceKeys is the identity keys of one device, as the device uploaded and
|
|
// signed them.
|
|
type DeviceKeys struct {
|
|
UserID string
|
|
DeviceID string
|
|
|
|
// JSON is the device_keys object as uploaded, without `unsigned`: the
|
|
// server writes that itself on the way out, and anything a client put
|
|
// there is not something it signed.
|
|
JSON []byte
|
|
}
|
|
|
|
// OneTimeKey is one one-time or fallback key of a device.
|
|
type OneTimeKey struct {
|
|
// KeyID is "algorithm:id", the key's name in the map it was uploaded
|
|
// in.
|
|
KeyID string
|
|
|
|
// JSON is the key as uploaded: a bare string for an unsigned
|
|
// curve25519 key, an object with signatures for a signed one.
|
|
JSON []byte
|
|
}
|
|
|
|
// Algorithm is the part of the key ID before the colon.
|
|
func (k OneTimeKey) Algorithm() string {
|
|
for i := 0; i < len(k.KeyID); i++ {
|
|
if k.KeyID[i] == ':' {
|
|
return k.KeyID[:i]
|
|
}
|
|
}
|
|
return k.KeyID
|
|
}
|
|
|
|
// ClaimedKey is a key handed out by a claim, and whether it was a fallback
|
|
// key rather than a one-time one.
|
|
type ClaimedKey struct {
|
|
OneTimeKey
|
|
Fallback bool
|
|
}
|
|
|
|
// KeySignature is one signature of a key by another key, uploaded apart
|
|
// from the key it signs.
|
|
//
|
|
// The keys a client uploads come signed by the device or key that made
|
|
// them. Cross-signing adds signatures afterwards — a device by the user's
|
|
// self-signing key, the user's master key by a device, another user's
|
|
// master key by the user-signing key — and those arrive through
|
|
// /keys/signatures/upload, long after the key they sign. They are kept
|
|
// apart and merged in when the key is read, rather than written into it,
|
|
// so that a key's own JSON stays exactly what its owner uploaded.
|
|
type KeySignature struct {
|
|
// TargetUserID owns the signed key, and TargetKeyID names it: a device
|
|
// ID for a device key, the unpadded base64 public key for a
|
|
// cross-signing key.
|
|
TargetUserID string
|
|
TargetKeyID string
|
|
|
|
// SignerUserID made the signature with SignerKeyID, which is
|
|
// "ed25519:" and the signing key's ID.
|
|
SignerUserID string
|
|
SignerKeyID string
|
|
|
|
Signature string
|
|
}
|
|
|
|
// DeviceListUpdate is one move of a user's device list: the position this
|
|
// change was given, and the position of the change before it. A remote
|
|
// server is told both, and asks for the whole list when the previous one
|
|
// is not the last it heard of.
|
|
type DeviceListUpdate struct {
|
|
UserID string
|
|
|
|
// DeviceID is the device the change was about, or empty for a change
|
|
// to the user's cross-signing keys.
|
|
DeviceID string
|
|
|
|
StreamPos int64
|
|
|
|
// PrevPos is the user's position before this change, or 0 for their
|
|
// first.
|
|
PrevPos int64
|
|
}
|
|
|
|
// DeviceListChange is a user whose device list moved, at the position of
|
|
// the latest move.
|
|
type DeviceListChange struct {
|
|
UserID string
|
|
StreamPos int64
|
|
}
|
|
|
|
// BackupVersion is one version of a user's server-side key backup.
|
|
type BackupVersion struct {
|
|
UserID string
|
|
Version string
|
|
|
|
Algorithm string
|
|
|
|
// AuthData is the algorithm's parameters as the client wrote them: the
|
|
// backup's public key and the signatures over it.
|
|
AuthData []byte
|
|
|
|
// ETag changes every time a key in the backup does, so a client can
|
|
// tell its copy of the backup is stale without downloading it.
|
|
ETag string
|
|
|
|
// Count is how many keys the backup holds.
|
|
Count int
|
|
}
|
|
|
|
// BackupKey is one room key in a backup.
|
|
type BackupKey struct {
|
|
RoomID string
|
|
SessionID string
|
|
|
|
FirstMessageIndex int64
|
|
ForwardedCount int64
|
|
IsVerified bool
|
|
|
|
// SessionData is the encrypted key, which the server cannot read.
|
|
SessionData []byte
|
|
}
|
|
|
|
// Replaces reports whether a key should replace one already backed up for
|
|
// the same session, by the rule the spec gives: a verified key beats an
|
|
// unverified one, then the key that decrypts more of the session — the
|
|
// lower first message index — and then the one that has been forwarded
|
|
// fewer times.
|
|
func (k BackupKey) Replaces(existing BackupKey) bool {
|
|
if k.IsVerified != existing.IsVerified {
|
|
return k.IsVerified
|
|
}
|
|
if k.FirstMessageIndex != existing.FirstMessageIndex {
|
|
return k.FirstMessageIndex < existing.FirstMessageIndex
|
|
}
|
|
return k.ForwardedCount < existing.ForwardedCount
|
|
}
|
|
|
|
// KeyStore persists end-to-end encryption keys: the storage half of the
|
|
// keyserver component.
|
|
//
|
|
// Users are full user IDs here rather than localparts, because most of what
|
|
// is stored is about users of other servers too: signatures of their keys,
|
|
// and the device list stream, which records their changes as well as ours
|
|
// so that a local client knows to fetch their keys again.
|
|
//
|
|
// Every write that changes what /keys/query would answer about a user moves
|
|
// the device list stream, inside the same transaction, and reports the
|
|
// position it moved to. That is what makes "whose keys changed since this
|
|
// sync token" a question with one exact answer.
|
|
type KeyStore interface {
|
|
// SetDeviceKeys stores a device's identity keys. Storing what is
|
|
// already there changes nothing and returns a zero update; anything
|
|
// else moves the device list stream.
|
|
SetDeviceKeys(ctx context.Context, keys DeviceKeys) (DeviceListUpdate, error)
|
|
|
|
// DeviceKeys reads the identity keys of a user's devices. An empty
|
|
// deviceIDs reads every device that has uploaded keys.
|
|
DeviceKeys(ctx context.Context, userID string, deviceIDs []string) ([]DeviceKeys, error)
|
|
|
|
// DeleteDeviceKeys drops everything stored for the given devices —
|
|
// identity keys, one-time keys and fallback keys — and moves the
|
|
// device list stream once for each device that had identity keys. An
|
|
// empty deviceIDs drops every device of the user.
|
|
DeleteDeviceKeys(ctx context.Context, userID string, deviceIDs []string) ([]DeviceListUpdate, error)
|
|
|
|
// AddOneTimeKeys stores one-time keys. A key under an ID that already
|
|
// holds the same key is ignored; one under an ID holding a different
|
|
// key fails the whole batch with ErrKeyConflict.
|
|
AddOneTimeKeys(ctx context.Context, userID, deviceID string, keys []OneTimeKey) error
|
|
|
|
// OneTimeKeyCounts counts a device's unclaimed one-time keys by
|
|
// algorithm.
|
|
OneTimeKeyCounts(ctx context.Context, userID, deviceID string) (map[string]int, error)
|
|
|
|
// SetFallbackKeys stores fallback keys, replacing any earlier one of
|
|
// the same algorithm. A key the same as the one stored keeps its used
|
|
// flag: a client re-uploading its fallback key on every start must not
|
|
// make a used one look fresh.
|
|
SetFallbackKeys(ctx context.Context, userID, deviceID string, keys []OneTimeKey) error
|
|
|
|
// UnusedFallbackKeyTypes lists the algorithms a device has a fallback
|
|
// key for that has not been claimed.
|
|
UnusedFallbackKeyTypes(ctx context.Context, userID, deviceID string) ([]string, error)
|
|
|
|
// ClaimKey hands out one key of an algorithm for a device, deleting it:
|
|
// a one-time key if there is one, the fallback key otherwise, which is
|
|
// marked used and kept. The boolean is false when the device has
|
|
// neither.
|
|
ClaimKey(ctx context.Context, userID, deviceID, algorithm string) (ClaimedKey, bool, error)
|
|
|
|
// SetCrossSigningKeys stores a user's cross-signing keys by purpose and
|
|
// moves the device list stream. A purpose mapped to nil is deleted,
|
|
// which is what replacing the master key does to the keys it signed.
|
|
SetCrossSigningKeys(ctx context.Context, userID string, keys map[string][]byte) (DeviceListUpdate, error)
|
|
|
|
// CrossSigningKeys reads a user's cross-signing keys by purpose.
|
|
CrossSigningKeys(ctx context.Context, userID string) (map[string][]byte, error)
|
|
|
|
// AddSignatures stores signatures, replacing an earlier one by the same
|
|
// key of the same key, and moves the device list stream of every user
|
|
// whose key gained one.
|
|
AddSignatures(ctx context.Context, sigs []KeySignature) ([]DeviceListUpdate, error)
|
|
|
|
// Signatures reads the stored signatures of a user's keys.
|
|
Signatures(ctx context.Context, targetUserID string) ([]KeySignature, error)
|
|
|
|
// MarkDeviceListsChanged moves the device list stream for users whose
|
|
// keys changed somewhere this store does not hold them: on another
|
|
// server, which said so in an m.device_list_update.
|
|
MarkDeviceListsChanged(ctx context.Context, userIDs []string) (int64, error)
|
|
|
|
// DeviceListChanges returns which of the given users' device lists
|
|
// moved after from and at or before to.
|
|
DeviceListChanges(ctx context.Context, userIDs []string, from, to int64) ([]DeviceListChange, error)
|
|
|
|
// DeviceListPosition is a user's latest position in the device list
|
|
// stream, or 0 when their list has never moved.
|
|
DeviceListPosition(ctx context.Context, userID string) (int64, error)
|
|
|
|
// CreateBackup creates a new key backup version, which becomes the
|
|
// user's current one, and returns its version.
|
|
CreateBackup(ctx context.Context, userID, algorithm string, authData []byte) (string, error)
|
|
|
|
// Backup reads one backup version, or the current one when version is
|
|
// empty. A deleted or unknown version is ErrNoSuchBackup.
|
|
Backup(ctx context.Context, userID, version string) (BackupVersion, error)
|
|
|
|
// UpdateBackup replaces a backup version's auth data.
|
|
UpdateBackup(ctx context.Context, userID, version string, authData []byte) error
|
|
|
|
// DeleteBackup deletes a backup version and every key in it. Its
|
|
// number is not handed out again.
|
|
DeleteBackup(ctx context.Context, userID, version string) error
|
|
|
|
// PutBackupKeys stores room keys in a backup version, each replacing
|
|
// the key already there for its session only when BackupKey.Replaces
|
|
// says it should, and returns the version as it then stands.
|
|
PutBackupKeys(ctx context.Context, userID, version string, keys []BackupKey) (BackupVersion, error)
|
|
|
|
// BackupKeys reads the keys of a backup version: all of them, one
|
|
// room's (roomID set), or one session's (both set).
|
|
BackupKeys(ctx context.Context, userID, version, roomID, sessionID string) ([]BackupKey, error)
|
|
|
|
// DeleteBackupKeys deletes keys from a backup version with the same
|
|
// scoping as BackupKeys, and returns the version as it then stands.
|
|
DeleteBackupKeys(ctx context.Context, userID, version, roomID, sessionID string) (BackupVersion, error)
|
|
}
|