Files
Warp/server/internal/syncapi/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

199 lines
6.0 KiB
Go

package syncapi
import (
"context"
"slices"
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
)
// Device list tracking, the reading side: whose end-to-end keys a client
// should fetch again, and whose it can forget.
//
// A client encrypting for a room needs the keys of every device of every
// member, and caches them. The cache goes stale in two ways, and the two
// lists of a sync's device_lists are one each:
//
// - changed: a user who shares a room with the client has a new device,
// lost one, or changed their cross-signing keys — or has only just come
// to share a room with the client, whose cache of their keys, if it has
// one at all, is from before.
// - left: a user who no longer shares any room with the client, whose
// keys it can stop tracking.
//
// The first kind comes from the keyserver's device list stream, which
// moves for local users when they upload or delete keys and for remote
// users when their server says so. The second kind comes from membership:
// whose membership moved in a room this user is or was in, and whether
// the two still share a room once it did. Both are read only on an
// incremental sync; an initial one is the client's cue to fetch every key
// it needs anyway.
// deviceLists works out the device_lists of a sync between since and to.
func (s *API) deviceLists(ctx context.Context, userID string, since, to Token, rooms []roomserver.MembershipAt) (DeviceLists, error) {
out := DeviceLists{Changed: []string{}, Left: []string{}}
if s.keys == nil || since.IsInitial() {
return out, nil
}
keysMoved := to.DeviceList > since.DeviceList
roomsMoved := to.PDU > since.PDU
if !keysMoved && !roomsMoved {
return out, nil
}
sharing, err := s.rooms.UsersSharingRooms(ctx, userID)
if err != nil {
return DeviceLists{}, err
}
shares := func(u string) bool { return slices.Contains(sharing, u) }
changed := map[string]bool{}
left := map[string]bool{}
if keysMoved {
// The user's own devices are reported too: another of their
// devices has to hear about the new one to start encrypting for
// it.
watched := append(slices.Clone(sharing), userID)
moves, err := s.keys.DeviceListChanges(ctx, watched, since.DeviceList, to.DeviceList)
if err != nil {
return DeviceLists{}, err
}
for _, m := range moves {
changed[m.UserID] = true
}
}
if roomsMoved {
if err := s.membershipDeviceLists(ctx, userID, since, to, rooms, shares, changed, left); err != nil {
return DeviceLists{}, err
}
}
for u := range changed {
out.Changed = append(out.Changed, u)
delete(left, u)
}
for u := range left {
out.Left = append(out.Left, u)
}
slices.Sort(out.Changed)
slices.Sort(out.Left)
return out, nil
}
// membershipDeviceLists adds the users whose sharing of a room with userID
// began or ended inside the window.
func (s *API) membershipDeviceLists(
ctx context.Context,
userID string,
since, to Token,
rooms []roomserver.MembershipAt,
shares func(string) bool,
changed, left map[string]bool,
) error {
consider := func(other string) {
switch {
case other == userID:
case shares(other):
changed[other] = true
default:
left[other] = true
}
}
// A room whose membership of this user moved in the window: joined,
// everybody in it is new to share with; left, everybody in it may no
// longer be.
for _, room := range rooms {
if room.Position <= since.PDU || room.Position > to.PDU {
continue
}
if room.Membership != event.MembershipJoin && !isDeparted(room.Membership) {
continue
}
members, err := s.rooms.JoinedUserIDs(ctx, room.RoomID)
if err != nil {
return err
}
for _, member := range members {
consider(member)
}
}
// Everybody else whose membership moved in a room this user is or was
// in. An invite or a knock is not a change of who shares the room, and
// is skipped: telling a client to forget the keys of a user it was
// encrypting for as an invitee would have it stop.
changes, err := s.rooms.MemberChanges(ctx, roomIDsOf(rooms), since.PDU, to.PDU)
if err != nil {
return err
}
for _, c := range changes {
if c.Membership == event.MembershipJoin || isDeparted(c.Membership) {
consider(c.UserID)
}
}
return nil
}
// KeyChanges answers GET /keys/changes: whose device lists changed between
// two tokens, for a client that missed some syncs, or is catching up with
// the history of a room it was not syncing.
func (s *API) KeyChanges(ctx context.Context, userID, from, to string) (DeviceLists, error) {
since, err := ParseToken(from)
if err != nil {
return DeviceLists{}, errInvalidTokenf("from: %v", err)
}
var until Token
if to == "" {
positions, err := s.store.StreamPositions(ctx)
if err != nil {
return DeviceLists{}, err
}
until = tokenAt(positions)
} else if until, err = ParseToken(to); err != nil {
return DeviceLists{}, errInvalidTokenf("to: %v", err)
}
if since.IsInitial() {
// From the beginning of time is every device list there is, which
// is the client fetching everybody's keys: what it would do after
// an initial sync anyway, and what since=0 means everywhere else.
// Reported as every user it shares a room with.
sharing, err := s.rooms.UsersSharingRooms(ctx, userID)
if err != nil {
return DeviceLists{}, err
}
if sharing == nil {
sharing = []string{}
}
return DeviceLists{Changed: sharing, Left: []string{}}, nil
}
rooms, err := s.rooms.UserMemberships(ctx, userID, nil)
if err != nil {
return DeviceLists{}, err
}
return s.deviceLists(ctx, userID, since, until, rooms)
}
// deviceKeyCounts reads the key counts of the syncing device.
func (s *API) deviceKeyCounts(ctx context.Context, req SyncRequest, resp *Response) error {
if s.keys == nil || req.Device == "" {
return nil
}
counts, err := s.keys.OneTimeKeyCounts(ctx, req.User, req.Device)
if err != nil {
return err
}
unused, err := s.keys.UnusedFallbackKeyTypes(ctx, req.User, req.Device)
if err != nil {
return err
}
resp.DeviceOneTimeKeysCount = counts
resp.DeviceUnusedFallbackKeyTypes = &unused
return nil
}