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:
1 parent
92836f8dcd
commit
fc49f56339
45 files changed
+6050
-63
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user