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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-17 10:34:08 +02:00
1 parent 92836f8dcd
commit fc49f56339
45 files changed
+6050 -63

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
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
}
+25 -6
View File
@@ -61,11 +61,22 @@ type Response struct {
ToDevice ToDeviceList `json:"to_device"`
// DeviceLists reports whose device list changed, which a client needs
// in order to re-fetch their keys. The keyserver is PLAN.md phase 7,
// so both lists are always empty; the block is here all the same,
// because a client that finds no device_lists treats the response as
// malformed rather than as "nothing changed".
// in order to re-fetch their keys, and whose it can stop tracking.
// Both lists are present even when empty: a client that finds no
// device_lists treats the response as malformed rather than as
// "nothing changed".
DeviceLists DeviceLists `json:"device_lists"`
// DeviceOneTimeKeysCount is how many one-time keys the syncing device
// has left on the server, by algorithm, which is how it knows to
// upload more.
DeviceOneTimeKeysCount map[string]int `json:"device_one_time_keys_count,omitempty"`
// DeviceUnusedFallbackKeyTypes lists the algorithms the device has an
// unused fallback key for. It is a pointer so that an empty list goes
// out as [] rather than not at all: a client reads the field's
// absence as a server without fallback keys, and never uploads one.
DeviceUnusedFallbackKeyTypes *[]string `json:"device_unused_fallback_key_types,omitempty"`
}
// Rooms is the room half of a sync, split by the user's membership.
@@ -302,7 +313,6 @@ func (s *API) build(ctx context.Context, req SyncRequest, filter Filter, since T
Presence: PresenceList{Events: []presenceEvent{}},
AccountData: AccountDataList{Events: []AccountDataEvent{}},
ToDevice: ToDeviceList{Events: []toDeviceEvent{}},
// Both lists are empty until Phase 7 and both have to be present.
DeviceLists: DeviceLists{Changed: []string{}, Left: []string{}},
}
@@ -346,6 +356,13 @@ func (s *API) build(ctx context.Context, req SyncRequest, filter Filter, since T
}
resp.Presence.Events = presence
if resp.DeviceLists, err = s.deviceLists(ctx, req.User, since, to, rooms); err != nil {
return Response{}, false, err
}
if err := s.deviceKeyCounts(ctx, req, &resp); err != nil {
return Response{}, false, err
}
resp.NextBatch = next.String()
return resp, isInteresting(resp, since), nil
}
@@ -369,7 +386,9 @@ func isInteresting(resp Response, since Token) bool {
len(resp.Rooms.Knock) > 0 ||
len(resp.AccountData.Events) > 0 ||
len(resp.ToDevice.Events) > 0 ||
len(resp.Presence.Events) > 0
len(resp.Presence.Events) > 0 ||
len(resp.DeviceLists.Changed) > 0 ||
len(resp.DeviceLists.Left) > 0
}
// buildRooms fills the four room sections.
+22 -6
View File
@@ -16,18 +16,20 @@
// What this package owns instead is everything the roomserver has no
// opinion about:
//
// - Stream tokens: a position in each of the five streams a response
// - Stream tokens: a position in each of the six streams a response
// carries, in one opaque string (token.go).
// - The notifier: waking a client blocked in a long poll the moment
// something concerns it, rather than when its timeout expires
// (notifier.go).
// - Filters: what a client asked to be left out (filter.go).
// - The ephemeral and device streams: typing, receipts, presence and
// to-device messages, none of which are events in a room.
// - The ephemeral and device streams: typing, receipts, presence,
// to-device messages and device list changes, none of which are
// events in a room.
//
// Phase 4 of PLAN.md. What it does not do yet is notification counts, which
// need push rules (Phase 8), and device list tracking, which needs the
// keyserver (Phase 7); both are named in docs/sync.md as the gaps they are.
// Phase 4 of PLAN.md, with device lists from Phase 7: the keyserver writes
// the device list stream, and this package works out which of its changes
// concern whom (devicelists.go). What it does not do yet is notification
// counts, which need push rules (Phase 8); docs/sync.md names that gap.
package syncapi
import (
@@ -72,11 +74,20 @@ type Accounts interface {
Devices(ctx context.Context, localpart string) ([]storage.Device, error)
}
// Keys is the part of the keyserver a sync reads: a device's key counts,
// and the device list stream. *keyserver.API satisfies it.
type Keys interface {
OneTimeKeyCounts(ctx context.Context, userID, deviceID string) (map[string]int, error)
UnusedFallbackKeyTypes(ctx context.Context, userID, deviceID string) ([]string, error)
DeviceListChanges(ctx context.Context, userIDs []string, from, to int64) ([]storage.DeviceListChange, error)
}
// API is the syncapi component.
type API struct {
serverName string
rooms *roomserver.API
accounts Accounts
keys Keys
store storage.SyncStore
notifier *Notifier
typing *typingTracker
@@ -111,6 +122,10 @@ type Options struct {
// Accounts is the userapi.
Accounts Accounts
// Keys is the keyserver. Nil leaves the key counts out of a sync and
// both device lists empty.
Keys Keys
// Store is the sync half of storage.
Store storage.SyncStore
@@ -190,6 +205,7 @@ func New(opts Options) (*API, error) {
serverName: opts.ServerName,
rooms: opts.Rooms,
accounts: opts.Accounts,
keys: opts.Keys,
store: opts.Store,
notifier: opts.Notifier,
typing: newTypingTracker(opts.TypingTimeout, opts.Notifier, opts.Now),
+15 -7
View File
@@ -18,10 +18,12 @@ import (
//
// The wire form is
//
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}
// s{pdu}_{receipt}_{to_device}_{account_data}_{presence}_{device_list}
//
// with trailing zeroes left off, so a client that has only ever seen room
// events carries "s12" rather than "s12_0_0_0_0". The leading "s" is there
// events carries "s12" rather than "s12_0_0_0_0_0". The device list
// position came with Phase 7 and was added at the end, which is what lets
// every token handed out before it go on parsing. The leading "s" is there
// to make a token recognisable in a log and to leave room for a second
// token shape later — Simplified Sliding Sync (PLAN.md phase 10) has its
// own — without any ambiguity about which is which.
@@ -35,8 +37,12 @@ type Token struct {
ToDevice int64
AccountData int64
Presence int64
DeviceList int64
}
// tokenPositions is how many positions a token carries.
const tokenPositions = 6
// tokenPrefix marks a classic /sync token.
const tokenPrefix = "s"
@@ -46,7 +52,7 @@ func (t Token) IsInitial() bool { return t == Token{} }
// String renders the wire form.
func (t Token) String() string {
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence}
parts := []int64{t.PDU, t.Receipt, t.ToDevice, t.AccountData, t.Presence, t.DeviceList}
// Trailing zeroes carry no information, and leaving them off keeps the
// common token short.
last := 0
@@ -88,12 +94,12 @@ func ParseToken(raw string) (Token, error) {
}
fields := strings.Split(body, "_")
if len(fields) > 5 {
return Token{}, fmt.Errorf("%q is not a sync token: it has %d positions, this server writes at most 5",
raw, len(fields))
if len(fields) > tokenPositions {
return Token{}, fmt.Errorf("%q is not a sync token: it has %d positions, this server writes at most %d",
raw, len(fields), tokenPositions)
}
var positions [5]int64
var positions [tokenPositions]int64
for i, field := range fields {
value, err := strconv.ParseInt(field, 10, 64)
// The PDU position alone may be negative: /messages hands out
@@ -110,6 +116,7 @@ func ParseToken(raw string) (Token, error) {
ToDevice: positions[2],
AccountData: positions[3],
Presence: positions[4],
DeviceList: positions[5],
}, nil
}
@@ -122,6 +129,7 @@ func tokenAt(positions storage.StreamPositions) Token {
ToDevice: positions.ToDevice,
AccountData: positions.AccountData,
Presence: positions.Presence,
DeviceList: positions.DeviceList,
}
}
+9 -9
View File
@@ -17,8 +17,8 @@ func TestTokenRoundTrips(t *testing.T) {
{"events and receipts", syncapi.Token{PDU: 12, Receipt: 3}, "s12_3"},
{
"every stream",
syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5},
"s1_2_3_4_5",
syncapi.Token{PDU: 1, Receipt: 2, ToDevice: 3, AccountData: 4, Presence: 5, DeviceList: 6},
"s1_2_3_4_5_6",
},
{
// /messages pages back into history backfilled from other
@@ -81,13 +81,13 @@ func TestParseTokenAcceptsAShortToken(t *testing.T) {
func TestParseTokenRejectsNonsense(t *testing.T) {
for _, raw := range []string{
"nonsense", // no prefix
"t1_2", // a prefix this build does not write
"s", // a prefix and nothing else
"s1_-1", // a negative position outside the event stream
"sx", // not a number
"s1_", // a trailing separator
"s1_2_3_4_5_6", // more positions than there are streams
"nonsense", // no prefix
"t1_2", // a prefix this build does not write
"s", // a prefix and nothing else
"s1_-1", // a negative position outside the event stream
"sx", // not a number
"s1_", // a trailing separator
"s1_2_3_4_5_6_7", // more positions than there are streams
"s1.5",
} {
if _, err := syncapi.ParseToken(raw); err == nil {