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>
203 lines
7.3 KiB
Go
203 lines
7.3 KiB
Go
package keyserver
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Uploading keys: POST /keys/upload, and what deleting a device does to
|
|
// them.
|
|
|
|
// Algorithm names the server has to know about.
|
|
const (
|
|
// AlgorithmSignedCurve25519 is the one-time key algorithm of Olm. A
|
|
// client counts on its count being reported even at zero: it is how
|
|
// the client learns it has to upload more.
|
|
AlgorithmSignedCurve25519 = "signed_curve25519"
|
|
)
|
|
|
|
// maxKeysPerUpload caps one upload's one-time keys. A client uploads up to
|
|
// half of what its Olm account holds, which is fifty for libolm and
|
|
// vodozemac alike; a thousand is a client that has gone wrong.
|
|
const maxKeysPerUpload = 1000
|
|
|
|
// UploadRequest is the body of POST /keys/upload.
|
|
type UploadRequest struct {
|
|
DeviceKeys json.RawMessage `json:"device_keys,omitempty"`
|
|
OneTimeKeys map[string]json.RawMessage `json:"one_time_keys,omitempty"`
|
|
FallbackKeys map[string]json.RawMessage `json:"fallback_keys,omitempty"`
|
|
|
|
// UnstableFallbackKeys is MSC2732's name for fallback_keys, which
|
|
// clients older than Matrix 1.2 still send.
|
|
UnstableFallbackKeys map[string]json.RawMessage `json:"org.matrix.msc2732.fallback_keys,omitempty"`
|
|
}
|
|
|
|
// UploadKeys stores what a device uploaded and returns how many one-time
|
|
// keys it now has.
|
|
//
|
|
// The device keys are checked to be the caller's own device, and nothing
|
|
// more. Whether they are signed by the key they say they are is checked by
|
|
// every client that reads them, which is the check that matters: a server
|
|
// that verified them would only be adding a point at which a key could be
|
|
// refused and none at which it could be trusted.
|
|
func (k *API) UploadKeys(ctx context.Context, userID, deviceID string, req UploadRequest) (map[string]int, error) {
|
|
if len(req.OneTimeKeys) > maxKeysPerUpload {
|
|
return nil, invalidParamf("at most %d one-time keys can be uploaded at once", maxKeysPerUpload)
|
|
}
|
|
|
|
if len(req.DeviceKeys) > 0 && string(req.DeviceKeys) != "null" {
|
|
if err := k.uploadDeviceKeys(ctx, userID, deviceID, req.DeviceKeys); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
otks, err := oneTimeKeys(req.OneTimeKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := k.store.AddOneTimeKeys(ctx, userID, deviceID, otks); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fallback := req.FallbackKeys
|
|
if len(fallback) == 0 {
|
|
fallback = req.UnstableFallbackKeys
|
|
}
|
|
fallbackKeys, err := oneTimeKeys(fallback)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := k.store.SetFallbackKeys(ctx, userID, deviceID, fallbackKeys); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return k.OneTimeKeyCounts(ctx, userID, deviceID)
|
|
}
|
|
|
|
// uploadDeviceKeys checks and stores a device's identity keys.
|
|
func (k *API) uploadDeviceKeys(ctx context.Context, userID, deviceID string, raw json.RawMessage) error {
|
|
var keys deviceKeysObject
|
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
return invalidParamf("device_keys is malformed: %v", err)
|
|
}
|
|
switch {
|
|
case keys.UserID != userID:
|
|
return invalidParamf("device_keys.user_id must be %s", userID)
|
|
case keys.DeviceID != deviceID:
|
|
return invalidParamf("device_keys.device_id must be %s, the device the access token belongs to", deviceID)
|
|
case len(keys.Keys) == 0:
|
|
return missingParamf("device_keys.keys must not be empty")
|
|
}
|
|
|
|
encoded, err := canonical(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// A client has no business writing unsigned: the server fills it in
|
|
// on the way out, and nothing in it is covered by the signature.
|
|
if encoded, err = withoutField(encoded, "unsigned"); err != nil {
|
|
return err
|
|
}
|
|
|
|
update, err := k.store.SetDeviceKeys(ctx, storage.DeviceKeys{UserID: userID, DeviceID: deviceID, JSON: encoded})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if update.StreamPos == 0 {
|
|
return nil
|
|
}
|
|
published, err := k.publishedDeviceKeys(ctx, userID, []string{deviceID})
|
|
if err != nil {
|
|
k.log.Warn("could not read back uploaded device keys", "user_id", userID, "device_id", deviceID, "error", err)
|
|
}
|
|
k.announceDevices(ctx, userID, []storage.DeviceListUpdate{update}, published)
|
|
return nil
|
|
}
|
|
|
|
// oneTimeKeys checks and canonicalises a map of one-time or fallback keys.
|
|
func oneTimeKeys(in map[string]json.RawMessage) ([]storage.OneTimeKey, error) {
|
|
out := make([]storage.OneTimeKey, 0, len(in))
|
|
for keyID, raw := range in {
|
|
if !validKeyID(keyID) {
|
|
return nil, invalidParamf("key ID %q must have the form algorithm:id", keyID)
|
|
}
|
|
encoded, err := canonical(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, storage.OneTimeKey{KeyID: keyID, JSON: encoded})
|
|
}
|
|
// In order, so that which of two conflicting keys is reported does
|
|
// not depend on map iteration.
|
|
slices.SortFunc(out, func(a, b storage.OneTimeKey) int { return cmp.Compare(a.KeyID, b.KeyID) })
|
|
return out, nil
|
|
}
|
|
|
|
// OneTimeKeyCounts reports a device's unclaimed one-time keys by
|
|
// algorithm, with signed_curve25519 always present.
|
|
func (k *API) OneTimeKeyCounts(ctx context.Context, userID, deviceID string) (map[string]int, error) {
|
|
counts, err := k.store.OneTimeKeyCounts(ctx, userID, deviceID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := counts[AlgorithmSignedCurve25519]; !ok {
|
|
counts[AlgorithmSignedCurve25519] = 0
|
|
}
|
|
return counts, nil
|
|
}
|
|
|
|
// UnusedFallbackKeyTypes lists the algorithms a device has an unclaimed
|
|
// fallback key for.
|
|
func (k *API) UnusedFallbackKeyTypes(ctx context.Context, userID, deviceID string) ([]string, error) {
|
|
types, err := k.store.UnusedFallbackKeyTypes(ctx, userID, deviceID)
|
|
if types == nil && err == nil {
|
|
types = []string{}
|
|
}
|
|
return types, err
|
|
}
|
|
|
|
// DevicesDeleted drops the keys of deleted devices and tells everyone. It
|
|
// is the userapi's hook, called after the devices are gone; an empty
|
|
// deviceIDs means every device of the user.
|
|
//
|
|
// It runs after the deletion rather than inside it, so a crash between
|
|
// the two leaves keys behind for a device that no longer exists. Those
|
|
// keys are never served: a query reads the device list first and skips
|
|
// keys for a device that is not on it.
|
|
func (k *API) DevicesDeleted(ctx context.Context, localpart string, deviceIDs []string) {
|
|
userID := "@" + localpart + ":" + k.serverName
|
|
updates, err := k.store.DeleteDeviceKeys(ctx, userID, deviceIDs)
|
|
if err != nil {
|
|
k.log.Warn("could not delete the keys of deleted devices", "user_id", userID, "error", err)
|
|
return
|
|
}
|
|
k.announceDevices(ctx, userID, updates, nil)
|
|
}
|
|
|
|
// DeviceRenamed moves the device list of a user whose device has a new
|
|
// display name, which /keys/query reports to local users. Nothing goes
|
|
// over federation: Warp does not tell other servers what a user's devices
|
|
// are called.
|
|
func (k *API) DeviceRenamed(ctx context.Context, localpart, deviceID string) {
|
|
userID := "@" + localpart + ":" + k.serverName
|
|
keys, err := k.store.DeviceKeys(ctx, userID, []string{deviceID})
|
|
if err != nil || len(keys) == 0 {
|
|
return
|
|
}
|
|
if _, err := k.store.MarkDeviceListsChanged(ctx, []string{userID}); err != nil {
|
|
k.log.Warn("could not record a device rename", "user_id", userID, "error", err)
|
|
return
|
|
}
|
|
k.changed(ctx, userID)
|
|
}
|
|
|
|
// DeviceListChanges returns which of the given users' device lists moved
|
|
// in a window of the device list stream. It is what a sync reads.
|
|
func (k *API) DeviceListChanges(ctx context.Context, userIDs []string, from, to int64) ([]storage.DeviceListChange, error) {
|
|
return k.store.DeviceListChanges(ctx, userIDs, from, to)
|
|
}
|