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>
323 lines
11 KiB
Go
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)
|
|
}
|