Files
Warp/server/internal/keyserver/upload.go
T
tajniak81andClaude Opus 5.5 fc49f56339 Phase 7: E2EE support
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>
2026-10-17 10:34:08 +02:00

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)
}