Files
Warp/server/internal/storage/sync.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

323 lines
11 KiB
Go

package storage
import (
"context"
"errors"
)
// Errors the sync store returns for cases a caller has to tell apart.
var (
// ErrNoSuchFilter is returned for a filter ID the user has not
// uploaded.
ErrNoSuchFilter = errors.New("no such filter")
)
// StreamPositions is the top of every stream a sync token points into, read
// in one call.
//
// A sync response is assembled from several independent streams, and the
// token it returns has to name a position in each. Reading them one at a
// time would let a stream move between two of the reads, and the client
// would then be handed a token that skips whatever landed in the gap.
type StreamPositions struct {
// PDU is the room event stream: the highest stream ordering in
// roomserver_events.
PDU int64
Receipt int64
ToDevice int64
AccountData int64
Presence int64
// DeviceList is the keyserver's device list stream: the users whose
// end-to-end keys a client should fetch again.
DeviceList int64
}
// TimelineDirection is which way a timeline read walks the stream.
type TimelineDirection string
// The two directions, spelled as the Client-Server API spells them in
// /messages' `dir` parameter.
const (
// DirectionForward reads oldest first, which is the order /sync hands
// events to a client.
DirectionForward TimelineDirection = "f"
// DirectionBackward reads newest first, which is how /messages pages
// back through a room's history.
DirectionBackward TimelineDirection = "b"
)
// TimelineRequest is one window of a room's event stream.
//
// The window is named by its two ends rather than by a direction and a
// starting point, because the two are independent: which end the limit
// trims, and which order the rows come back in, is Direction's business,
// and nothing else about the request changes with it. Spelling the bounds
// as "from" and "to" instead would mean each of them being the low bound in
// one direction and the high bound in the other, which is exactly the
// off-by-one every pagination bug is made of.
//
// Low is exclusive and High inclusive, in both directions. That is what
// lets a token a client was handed be passed straight back without
// re-reading the event it names: a position is somewhere a client has got
// to, not an event it still wants.
//
// Positions can be negative: history backfilled from other servers is
// numbered downwards from -1, below everything that was already here (see
// RoomStore.WriteBackfill). Zero is never a position, which is what lets
// it go on meaning "unbounded" here — and why the position just below an
// event is PositionBefore and not a plain subtraction.
type TimelineRequest struct {
RoomID string
// Low is an exclusive lower bound. Zero means unbounded: the start of
// the room, backfilled history included.
Low int64
// High is an inclusive upper bound. Zero means the top of the stream,
// and goes on meaning that as the stream grows.
High int64
// Direction decides which end of the window the limit trims — the
// bottom when reading backwards, the top when reading forwards — and
// the order the rows come back in.
Direction TimelineDirection
// Limit caps the rows returned. It is required: an unbounded timeline
// read is a way to ask the server to serialise a whole room.
Limit int
// Filter narrows which events come back. The store applies it in SQL
// because a filter applied after the limit would return fewer events
// than the client asked for and then look like the end of the room.
Filter TimelineFilter
}
// PositionBefore is the position just below pos: the bound that names
// "everything up to, but not including, the event at pos".
//
// It is pos-1 except at 1, whose predecessor is -1. Zero is not a
// position — it is the "unbounded" bound of every window — so a token
// below the first event this server ever persisted has to skip it, or
// handing that token back as a High bound would read the whole room again
// instead of the backfilled history beneath it.
func PositionBefore(pos int64) int64 {
if pos == 1 {
return -1
}
return pos - 1
}
// TimelineFilter is the part of a Client-Server API event filter that the
// store can apply itself.
//
// Only the fields that are a plain comparison on an indexed or stored
// column are here. The rest of a filter — `contains_url`, lazy-loading,
// `include_redundant_members` — is about what an event means or about how a
// response is assembled, and is applied by the syncapi.
type TimelineFilter struct {
// Types, when non-empty, admits only these event types. A trailing '*'
// is a wildcard, as the spec defines for filter patterns.
Types []string
// NotTypes excludes types, and wins over Types for an event matching
// both, which is what the spec requires.
NotTypes []string
// Senders, when non-empty, admits only these senders.
Senders []string
// NotSenders excludes senders, and likewise wins.
NotSenders []string
}
// IsEmpty reports whether the filter admits everything.
func (f TimelineFilter) IsEmpty() bool {
return len(f.Types) == 0 && len(f.NotTypes) == 0 &&
len(f.Senders) == 0 && len(f.NotSenders) == 0
}
// SyncMembership is a user's current membership in one room, with the
// position of the event that set it.
//
// The position is what bounds what a user who has left may see: their view
// of the room ends at their own leave event, and a room they were kicked
// out of must not keep streaming to them.
type SyncMembership struct {
RoomID string
Membership string
EventID string
// StreamOrdering is the position of the membership event.
StreamOrdering int64
}
// Receipt is one read receipt.
type Receipt struct {
RoomID string
UserID string
ReceiptType string
// ThreadID is the thread the receipt is for, or empty for the room's
// main timeline.
ThreadID string
EventID string
// TimestampMS is when the receipt was sent, which is what a client
// shows as "read at".
TimestampMS int64
// StreamPos is the receipt's position in the receipt stream.
StreamPos int64
}
// ToDeviceMessage is one message addressed to a single device.
type ToDeviceMessage struct {
// StreamPos is the message's position in the to-device stream.
StreamPos int64
Localpart string
DeviceID string
Sender string
Type string
// Content is the message body as the sender wrote it.
Content []byte
SentAtMS int64
}
// NewToDeviceMessage is one message to enqueue.
type NewToDeviceMessage struct {
Localpart string
// DeviceID is the device to deliver to. The caller has already expanded
// a wildcard addressee into one of these per device: which devices a
// user has is the userapi's business, not the store's.
DeviceID string
Sender string
Type string
Content []byte
SentAtMS int64
}
// PresenceState is a user's presence as the store holds it.
type PresenceState struct {
UserID string
// Presence is "online", "offline" or "unavailable".
Presence string
StatusMsg string
// LastActiveMS is when the user was last seen doing something.
LastActiveMS int64
// StreamPos is the position of the last change to this row.
StreamPos int64
}
// AccountDataEntry locates one account data blob that has changed. The
// content is not carried: a sync response needs to know which blobs to
// re-read, and reading them is a separate, cacheable call.
type AccountDataEntry struct {
// RoomID is empty for global account data.
RoomID string
DataType string
StreamPos int64
}
// StoredFilter is an uploaded filter.
type StoredFilter struct {
Localpart string
FilterID string
// JSON is the filter exactly as the client uploaded it.
JSON []byte
CreatedAtMS int64
}
// SyncStore persists the streams a sync response carries that are not room
// events, plus the filters clients upload: the storage half of the syncapi
// component.
//
// Typing notifications are not here on purpose. They expire in seconds and
// are worthless across a restart, so the syncapi keeps them in memory.
type SyncStore interface {
// StreamPositions reads the top of every stream in one call.
StreamPositions(ctx context.Context) (StreamPositions, error)
// SetReceipt records a read receipt, replacing any earlier one from the
// same user for the same type and thread, and returns its stream
// position.
//
// A receipt that would move backwards is not written and reports the
// current position with moved false: receipts only ever advance, and a
// client syncing on two devices would otherwise flap.
SetReceipt(ctx context.Context, r Receipt) (pos int64, moved bool, err error)
// Receipts returns the current receipts of the given rooms whose
// position is after from and at or before to. A from of 0 returns every
// current receipt, which is what an initial sync needs.
Receipts(ctx context.Context, roomIDs []string, from, to int64) ([]Receipt, error)
// ReceiptsForUser returns one user's own current receipts in the given
// rooms, whatever their position. A client needs its own read markers
// on an initial sync even when it set them long ago.
ReceiptsForUser(ctx context.Context, userID string, roomIDs []string) ([]Receipt, error)
// SendToDevice enqueues messages for delivery and returns the highest
// position it wrote. An empty batch is not an error and returns the
// current position.
SendToDevice(ctx context.Context, msgs []NewToDeviceMessage) (int64, error)
// ToDeviceMessages returns the messages queued for one device whose
// position is after from and at or before to, oldest first.
ToDeviceMessages(ctx context.Context, localpart, deviceID string, from, to int64, limit int) ([]ToDeviceMessage, error)
// DeleteToDeviceMessages removes the messages for a device at or before
// pos. A device acknowledges a batch by syncing past it, so this is
// what the next sync does with the batch before it.
DeleteToDeviceMessages(ctx context.Context, localpart, deviceID string, pos int64) error
// SetPresence records a user's presence and returns its stream
// position, reporting whether anything a peer would care about
// changed.
//
// A write that only refreshes LastActiveMS does not move the stream.
// A client says it is still online by syncing, so every sync is one of
// these — and a stream that moved each time would wake every peer's
// long poll, which would sync, which would move it again.
SetPresence(ctx context.Context, p PresenceState) (pos int64, moved bool, err error)
// Presence reads one user's presence, returning ErrNoSuchUser when
// nothing has ever been recorded for them.
Presence(ctx context.Context, userID string) (PresenceState, error)
// PresenceForUsers returns the presence of the given users whose
// position is after from and at or before to. A from of 0 returns every
// current state.
PresenceForUsers(ctx context.Context, userIDs []string, from, to int64) ([]PresenceState, error)
// AccountDataChanges lists the account data of a user that changed
// after from and at or before to.
AccountDataChanges(ctx context.Context, localpart string, from, to int64) ([]AccountDataEntry, error)
// AccountDataAt lists every account data blob a user has, as an initial
// sync needs.
AccountDataAt(ctx context.Context, localpart string, to int64) ([]AccountDataEntry, error)
// SaveFilter stores a filter under a new ID and returns it.
SaveFilter(ctx context.Context, localpart string, filter []byte, createdAtMS int64) (string, error)
// Filter reads an uploaded filter, returning ErrNoSuchFilter when the
// ID is unknown.
Filter(ctx context.Context, localpart, filterID string) ([]byte, error)
}