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>
199 lines
6.0 KiB
Go
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
|
|
}
|