Files
Warp/server/internal/keyserver/devicelists.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

163 lines
5.1 KiB
Go

package keyserver
import (
"context"
"encoding/json"
"slices"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The writing side of device list tracking: telling everyone who shares a
// room with a user that the user's keys changed.
//
// Locally that is a wake-up. The change is already in the device list
// stream, moved in the same transaction as the keys themselves, and every
// sync works out from the stream whose changes concern it; all a sync
// blocked in a long poll needs is to be told to look. Remotely it is an
// EDU to every server with a user in one of those rooms, which is how a
// server that has the user's keys cached knows to drop them.
// sharers is who shares a room with a user, the user included.
func (k *API) sharers(ctx context.Context, userID string) []string {
out := []string{userID}
if k.rooms == nil {
return out
}
users, err := k.rooms.UsersSharingRooms(ctx, userID)
if err != nil {
// A missed wake-up costs a client one sync timeout, and a missed
// EDU costs a remote server one stale key until the next change;
// neither is worth failing the upload that caused it.
k.log.Warn("could not read who shares a room with a user", "user_id", userID, "error", err)
return out
}
for _, u := range users {
if !slices.Contains(out, u) {
out = append(out, u)
}
}
return out
}
// changed reacts to a move of a user's device list: it wakes the local
// syncs it concerns and returns the other servers that have to hear of
// it.
func (k *API) changed(ctx context.Context, userID string) []string {
users := k.sharers(ctx, userID)
var (
local []string
servers []string
)
for _, u := range users {
server, err := id.ServerNameOf(u)
switch {
case err != nil:
continue
case server == k.serverName:
local = append(local, u)
case !slices.Contains(servers, server):
servers = append(servers, server)
}
}
if k.notifier != nil {
k.notifier.NotifyUsers(local)
}
slices.Sort(servers)
return servers
}
// federating reports whether there is a running queue to send EDUs to.
func (k *API) federating() bool {
return k.outbound != nil && k.outbound.Running()
}
// announceDevices tells everyone about moves of a local user's devices:
// keys uploaded, changed or deleted. keys holds the current keys of the
// devices that still have them; a device missing from it is announced as
// deleted.
func (k *API) announceDevices(ctx context.Context, userID string, updates []storage.DeviceListUpdate, keys map[string]json.RawMessage) {
if len(updates) == 0 {
return
}
servers := k.changed(ctx, userID)
if len(servers) == 0 || !k.federating() {
return
}
for _, u := range updates {
content := federation.DeviceListUpdateContent{
UserID: userID,
DeviceID: u.DeviceID,
StreamID: u.StreamPos,
}
if u.PrevPos > 0 {
content.PrevID = []int64{u.PrevPos}
}
if raw, ok := keys[u.DeviceID]; ok {
content.Keys = raw
} else {
content.Deleted = true
}
encoded, err := json.Marshal(content)
if err != nil {
continue
}
k.outbound.QueueEDU(ctx, servers, federation.EDU{Type: federation.EDUDeviceListUp, Content: encoded})
}
}
// announceSigningKeys tells everyone a local user's cross-signing keys
// changed: the master key, or a signature on it, or the self-signing key.
// The user-signing key is not announced — nobody but its owner ever sees
// it.
func (k *API) announceSigningKeys(ctx context.Context, userID string) {
servers := k.changed(ctx, userID)
if len(servers) == 0 || !k.federating() {
return
}
master, selfSigning, _, err := k.crossSigningKeys(ctx, userID, "")
if err != nil {
k.log.Warn("could not read cross-signing keys to announce them", "user_id", userID, "error", err)
return
}
encoded, err := json.Marshal(federation.SigningKeyUpdateContent{
UserID: userID, MasterKey: master, SelfSigningKey: selfSigning,
})
if err != nil {
return
}
k.outbound.QueueEDU(ctx, servers, federation.EDU{Type: federation.EDUSigningKeys, Content: encoded})
}
// RemoteKeysChanged takes in an m.device_list_update or
// m.signing_key_update from another server: one of its users' keys
// changed.
//
// Warp does not cache other servers' keys — a query for them is answered
// by asking their server — so there is nothing to invalidate. What there
// is to do is tell local clients, which do cache them, by moving the
// user's position in the device list stream. That only happens when a
// local user shares a room with them: an update about a stranger concerns
// nobody here, and recording it would let any server grow the table with
// users of its own invention.
func (k *API) RemoteKeysChanged(ctx context.Context, userID string) error {
if k.isLocal(userID) || k.rooms == nil {
return nil
}
users, err := k.rooms.UsersSharingRooms(ctx, userID)
if err != nil {
return err
}
if !slices.ContainsFunc(users, k.isLocal) {
return nil
}
if _, err := k.store.MarkDeviceListsChanged(ctx, []string{userID}); err != nil {
return err
}
k.changed(ctx, userID)
return nil
}