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>
496 lines
20 KiB
Go
496 lines
20 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
)
|
|
|
|
// Errors the room store returns for cases a caller has to tell apart.
|
|
var (
|
|
// ErrNoSuchRoom is returned for a room this server does not know.
|
|
ErrNoSuchRoom = errors.New("no such room")
|
|
|
|
// ErrRoomExists is returned by CreateRoom for a room ID already taken.
|
|
// It can only happen through a room ID collision, which for a room
|
|
// version 12 room would mean two identical create events.
|
|
ErrRoomExists = errors.New("room already exists")
|
|
|
|
// ErrNoSuchEvent is returned for an event ID the room store does not
|
|
// hold.
|
|
ErrNoSuchEvent = errors.New("no such event")
|
|
|
|
// ErrNoSuchAlias is returned for an unknown room alias.
|
|
ErrNoSuchAlias = errors.New("no such room alias")
|
|
|
|
// ErrAliasExists is returned by CreateAlias for an alias already
|
|
// pointing somewhere.
|
|
ErrAliasExists = errors.New("room alias already exists")
|
|
)
|
|
|
|
// Room is one room this server takes part in.
|
|
//
|
|
// The version is fixed when the room is created and is what every later read
|
|
// needs first: an event cannot even be parsed without knowing the rules it
|
|
// was written under.
|
|
type Room struct {
|
|
ID string
|
|
|
|
// Version is the room version identifier, e.g. "11".
|
|
Version string
|
|
|
|
// Creator is the full user ID that sent the m.room.create event.
|
|
Creator string
|
|
|
|
// Published reports whether the room is in this server's public room
|
|
// directory.
|
|
Published bool
|
|
|
|
CreatedAtMS int64
|
|
}
|
|
|
|
// NewRoom describes a room to create. The create event itself is written by
|
|
// the same Write call that appends any other event.
|
|
type NewRoom struct {
|
|
ID string
|
|
Version string
|
|
Creator string
|
|
Published bool
|
|
CreatedAtMS int64
|
|
}
|
|
|
|
// RoomProfile is the denormalised view of a room's state that a room
|
|
// listing is searched and ordered by.
|
|
//
|
|
// It is all derivable from the room's current state, and is stored anyway:
|
|
// the public room directory sorts every published room by member count and
|
|
// pages the result, which cannot be done by reading each room's state in
|
|
// turn. The roomserver recomputes it whenever one of the state events behind
|
|
// it changes and hands it to Write; the store only keeps it, because
|
|
// knowing that m.room.name means a name is the roomserver's business.
|
|
//
|
|
// JoinedMembers is the exception: the store maintains it itself, by counting
|
|
// the current-state rows whose membership is "join".
|
|
type RoomProfile struct {
|
|
Name string
|
|
Topic string
|
|
AvatarURL string
|
|
CanonicalAlias string
|
|
|
|
JoinRule string
|
|
HistoryVisibility string
|
|
GuestAccess string
|
|
|
|
// RoomType is m.room.create's content.type: empty for a normal room,
|
|
// "m.space" for a space.
|
|
RoomType string
|
|
}
|
|
|
|
// StateEntry is one piece of room state and the event that holds it.
|
|
type StateEntry struct {
|
|
Type string
|
|
StateKey string
|
|
EventID string
|
|
|
|
// Membership is the membership value of an m.room.member entry, and
|
|
// empty on every other kind of state. It travels with the entry so that
|
|
// the store can keep "who is joined to this room" answerable with an
|
|
// index instead of by parsing event JSON.
|
|
Membership string
|
|
}
|
|
|
|
// StoredEvent is an event as the room store holds it: the canonical JSON
|
|
// plus the columns worth indexing separately.
|
|
//
|
|
// JSON is the authority. The other fields are projections of it, written out
|
|
// so that the store can answer "the members of this room" or "events in
|
|
// stream order" without parsing every row.
|
|
type StoredEvent struct {
|
|
RoomID string
|
|
EventID string
|
|
Type string
|
|
|
|
// StateKey is nil for a non-state event. The empty string is a
|
|
// perfectly good state key, which is why this is a pointer.
|
|
StateKey *string
|
|
|
|
Sender string
|
|
Depth int64
|
|
OriginServerTS int64
|
|
|
|
// JSON is the event as canonical JSON, exactly as it was signed.
|
|
JSON []byte
|
|
|
|
// StreamOrdering is this server's own position for the event: the
|
|
// order /sync and /messages hand events to clients. For an event that
|
|
// arrived live it is the order it was persisted in, counting up from 1;
|
|
// for history backfilled from another server it is negative, counting
|
|
// down from -1, so that it sorts below everything that was already
|
|
// here. It is never zero.
|
|
StreamOrdering int64
|
|
|
|
// RedactedBy is the event ID of the redaction that applies to this
|
|
// event, or empty. The JSON is rewritten in place when a redaction is
|
|
// applied, so a redacted event is already stripped on the way out; this
|
|
// field is what lets unsigned.redacted_because be filled in.
|
|
RedactedBy string
|
|
|
|
// Outlier reports an event held without the room's state at it: part
|
|
// of a join's state or auth chain, an auth event fetched to judge
|
|
// another, an invite to a room nobody here is in, or an event that
|
|
// soft-failed. An outlier can be cited and read by ID, and is never in
|
|
// a timeline.
|
|
Outlier bool
|
|
}
|
|
|
|
// IsState reports whether the event is a state event.
|
|
func (e StoredEvent) IsState() bool { return e.StateKey != nil }
|
|
|
|
// TransactionRef records a client transaction ID against the event it
|
|
// produced, so that a retried PUT returns the first event rather than
|
|
// sending a second one.
|
|
type TransactionRef struct {
|
|
Localpart string
|
|
DeviceID string
|
|
TxnID string
|
|
}
|
|
|
|
// RoomWrite is one event and everything its arrival changes, written in a
|
|
// single transaction.
|
|
//
|
|
// Persisting an event is never just an insert: it moves the room's forward
|
|
// extremities, it may replace a piece of current state, and it may strip an
|
|
// earlier event. Splitting that across calls would leave a window in which
|
|
// the room's current state disagrees with its events, so the whole lot goes
|
|
// down together.
|
|
type RoomWrite struct {
|
|
// Event is the event being appended. Its StreamOrdering is assigned by
|
|
// the store and returned.
|
|
Event StoredEvent
|
|
|
|
// StateBefore is the room state the event was authorised against: the
|
|
// full state of the room as of its prev_events. It is stored as a
|
|
// deduplicated snapshot, so appending a message to a quiet room adds no
|
|
// state rows at all.
|
|
StateBefore []StateEntry
|
|
|
|
// StateDelta are the pieces of current state this event changes — for
|
|
// an ordinary state event, the single tuple it writes. It is empty for
|
|
// an event that changes no state.
|
|
StateDelta []StateEntry
|
|
|
|
// StateDeltaIsWholeState says that StateDelta is the room's entire new
|
|
// current state, and that any tuple not in it must be removed. State
|
|
// resolution across a forked room produces such a result; a single
|
|
// state event does not, and rewriting the whole of a large room's state
|
|
// on every join would make filling a room quadratic.
|
|
StateDeltaIsWholeState bool
|
|
|
|
// Profile updates the denormalised room listing columns. It is nil when
|
|
// this event changes none of them.
|
|
Profile *RoomProfile
|
|
|
|
// Extremities replaces the room's forward extremities. For a locally
|
|
// created event that is the event itself, since it descends from every
|
|
// extremity there was.
|
|
Extremities []string
|
|
|
|
// Transaction, when set, records the client transaction ID this event
|
|
// came from.
|
|
Transaction *TransactionRef
|
|
|
|
// Redacts is the event ID this event redacts, when the redaction is to
|
|
// be applied. RedactedJSON is the target's redacted form, which
|
|
// replaces the stored JSON.
|
|
Redacts string
|
|
RedactedJSON []byte
|
|
|
|
// Outlier writes the event as an outlier: no state snapshot is
|
|
// recorded for it and StateBefore is ignored. StateDelta and
|
|
// Extremities still apply when they are set, which is how an invite to
|
|
// a room nobody here is in becomes the one piece of that room's state
|
|
// this server knows.
|
|
Outlier bool
|
|
|
|
// InviteState is the stripped state an invite over federation arrived
|
|
// with, kept for the invitee until the invite is answered. Any
|
|
// m.room.member write clears what was stored for its state key first,
|
|
// so the invite's own write is the only one that leaves a row behind.
|
|
InviteState []byte
|
|
}
|
|
|
|
// BackfillEvent is one event of a room's history, fetched from another
|
|
// server after this server already held later events, with the room state
|
|
// it was authorised against.
|
|
type BackfillEvent struct {
|
|
Event StoredEvent
|
|
|
|
// StateBefore is the state of the room as of the event's prev_events,
|
|
// recorded as its state snapshot exactly as RoomWrite.StateBefore is.
|
|
// It is what lets a client paging back through backfilled history be
|
|
// told the state at any point of it.
|
|
StateBefore []StateEntry
|
|
}
|
|
|
|
// RoomAlias is a local alias and the room it points at.
|
|
type RoomAlias struct {
|
|
Alias string
|
|
RoomID string
|
|
|
|
// Creator is the full user ID that created the alias.
|
|
Creator string
|
|
|
|
CreatedAtMS int64
|
|
}
|
|
|
|
// MembershipEntry is one member of a room.
|
|
type MembershipEntry struct {
|
|
UserID string
|
|
Membership string
|
|
EventID string
|
|
}
|
|
|
|
// MemberChange is one membership event in a window of the event stream.
|
|
type MemberChange struct {
|
|
RoomID string
|
|
|
|
// UserID is the state key: the user whose membership it set.
|
|
UserID string
|
|
|
|
StreamOrdering int64
|
|
|
|
// Membership is the user's membership of the room now, which may
|
|
// have moved again since this event.
|
|
Membership string
|
|
}
|
|
|
|
// RoomFilter narrows and pages a room listing.
|
|
type RoomFilter struct {
|
|
// Search matches a substring of the room ID, name, topic or canonical
|
|
// alias, case-insensitively. Empty matches everything.
|
|
Search string
|
|
|
|
// Limit caps the returned rows; 0 means the store's own default.
|
|
Limit int
|
|
|
|
// Offset skips rows, for paging.
|
|
Offset int
|
|
}
|
|
|
|
// RoomSummary is a room as a listing shows it: the room row, its
|
|
// denormalised state and its member count.
|
|
type RoomSummary struct {
|
|
Room
|
|
RoomProfile
|
|
|
|
// JoinedMembers is how many members of the room are joined.
|
|
JoinedMembers int
|
|
}
|
|
|
|
// RoomStats are the room counts the admin dashboard shows.
|
|
type RoomStats struct {
|
|
Rooms int
|
|
Published int
|
|
Events int
|
|
}
|
|
|
|
// RoomStore persists rooms, their events and their state: the storage half
|
|
// of the roomserver component.
|
|
//
|
|
// Every method takes identifiers the caller has already validated. The store
|
|
// interprets nothing: it does not know the authorisation rules, it does not
|
|
// know which room versions exist, and it never parses an event. What it
|
|
// guarantees is that a room's events, its state and its extremities are only
|
|
// ever seen in a consistent set.
|
|
type RoomStore interface {
|
|
// CreateRoom inserts the room row, returning ErrRoomExists when the ID
|
|
// is taken.
|
|
CreateRoom(ctx context.Context, room NewRoom) error
|
|
|
|
// Room reads one room, returning ErrNoSuchRoom if it is absent.
|
|
Room(ctx context.Context, roomID string) (Room, error)
|
|
|
|
// Write appends one event and applies everything it changes. It returns
|
|
// the stream ordering the event was given.
|
|
Write(ctx context.Context, w RoomWrite) (int64, error)
|
|
|
|
// Event reads one event of a room, returning ErrNoSuchEvent if it is
|
|
// absent.
|
|
Event(ctx context.Context, roomID, eventID string) (StoredEvent, error)
|
|
|
|
// EventsByID reads events by ID within one room. Unknown IDs are left
|
|
// out rather than reported: state resolution needs exactly that
|
|
// behaviour for events this server does not have.
|
|
EventsByID(ctx context.Context, roomID string, eventIDs []string) ([]StoredEvent, error)
|
|
|
|
// CurrentState returns the room's resolved current state.
|
|
CurrentState(ctx context.Context, roomID string) ([]StateEntry, error)
|
|
|
|
// CurrentStateEvents returns the events behind the room's current
|
|
// state, which is what the authorisation rules are run against.
|
|
CurrentStateEvents(ctx context.Context, roomID string) ([]StoredEvent, error)
|
|
|
|
// CurrentStateEvent returns one piece of current state, or
|
|
// ErrNoSuchEvent when the room has no such state.
|
|
CurrentStateEvent(ctx context.Context, roomID, eventType, stateKey string) (StoredEvent, error)
|
|
|
|
// StateBefore returns the state snapshot recorded against an event: the
|
|
// state of the room as of that event's prev_events.
|
|
StateBefore(ctx context.Context, roomID, eventID string) ([]StateEntry, error)
|
|
|
|
// Extremities returns the room's forward extremities, which are the
|
|
// prev_events of the next event to be sent.
|
|
Extremities(ctx context.Context, roomID string) ([]StoredEvent, error)
|
|
|
|
// Members returns the m.room.member events of a room. memberships
|
|
// filters by membership value; empty returns every member event,
|
|
// including the users who have left.
|
|
Members(ctx context.Context, roomID string, memberships []string) ([]StoredEvent, error)
|
|
|
|
// MembershipEntries returns the memberships of a room without the event
|
|
// bodies, which is what /joined_members needs.
|
|
MembershipEntries(ctx context.Context, roomID string, memberships []string) ([]MembershipEntry, error)
|
|
|
|
// Membership returns a user's membership in a room and the event that
|
|
// set it. A user who was never in the room gets an empty membership and
|
|
// no error: "never joined" and "left" are the same answer to every rule
|
|
// that asks.
|
|
Membership(ctx context.Context, roomID, userID string) (MembershipEntry, error)
|
|
|
|
// RoomsForUser lists the rooms a user has one of the given memberships
|
|
// in, ordered by room ID.
|
|
RoomsForUser(ctx context.Context, userID string, memberships []string) ([]string, error)
|
|
|
|
// MembershipsForUser is RoomsForUser with the membership and the
|
|
// position of the event that set it: one query for everything a sync
|
|
// needs to know about where a user stands.
|
|
MembershipsForUser(ctx context.Context, userID string, memberships []string) ([]SyncMembership, error)
|
|
|
|
// UserMembershipEvents returns every m.room.member event of one user in
|
|
// one room, oldest first.
|
|
//
|
|
// It is how far back a user's own view of a room reaches: a user who
|
|
// joined, left and rejoined has several spans, and only their own
|
|
// membership events say where each one began. There is one row per
|
|
// membership change of one user in one room, so this stays small
|
|
// however busy the room is.
|
|
UserMembershipEvents(ctx context.Context, roomID, userID string) ([]StoredEvent, error)
|
|
|
|
// MaxStreamOrdering returns the top of this server's event stream, or 0
|
|
// for a server that has persisted no events.
|
|
MaxStreamOrdering(ctx context.Context) (int64, error)
|
|
|
|
// Timeline reads one window of a room's event stream. See
|
|
// TimelineRequest: the bounds are exclusive and the limit is required.
|
|
Timeline(ctx context.Context, req TimelineRequest) ([]StoredEvent, error)
|
|
|
|
// RoomsWithEvents returns the subset of roomIDs that have an event
|
|
// after from and at or before to.
|
|
//
|
|
// It is what keeps an incremental sync down to one query for the common
|
|
// case of nothing having happened: a user in two hundred rooms would
|
|
// otherwise need two hundred timeline reads to find that out.
|
|
RoomsWithEvents(ctx context.Context, roomIDs []string, from, to int64) ([]string, error)
|
|
|
|
// StateAtStream returns the room's state as of a stream position: the
|
|
// state after the last event at or before pos. The position may be a
|
|
// negative one inside backfilled history; zero has no events at or
|
|
// before it that mean anything and returns nothing.
|
|
//
|
|
// It answers the two questions current state cannot. A user who has
|
|
// left sees the room as it was when they left, and a sync with a gap in
|
|
// it has to say which state changed inside that gap.
|
|
StateAtStream(ctx context.Context, roomID string, pos int64) ([]StateEntry, error)
|
|
|
|
// TransactionEvent returns the event a client transaction ID already
|
|
// produced, or ErrNoSuchEvent when this is the first time it is seen.
|
|
TransactionEvent(ctx context.Context, ref TransactionRef) (string, error)
|
|
|
|
// CreateAlias points an alias at a room, returning ErrAliasExists when
|
|
// the alias is taken.
|
|
CreateAlias(ctx context.Context, alias RoomAlias) error
|
|
|
|
// DeleteAlias removes an alias, returning ErrNoSuchAlias if it is
|
|
// absent.
|
|
DeleteAlias(ctx context.Context, alias string) error
|
|
|
|
// Alias resolves an alias, returning ErrNoSuchAlias if it is absent.
|
|
// The creator comes back with it because the permission check on
|
|
// deleting an alias is partly about who made it.
|
|
Alias(ctx context.Context, alias string) (RoomAlias, error)
|
|
|
|
// AliasesForRoom lists the local aliases pointing at a room, sorted.
|
|
AliasesForRoom(ctx context.Context, roomID string) ([]string, error)
|
|
|
|
// SetPublished adds or removes a room from the public room directory,
|
|
// returning ErrNoSuchRoom for a room that does not exist.
|
|
SetPublished(ctx context.Context, roomID string, published bool) error
|
|
|
|
// PublishedRooms returns a page of the public room directory ordered by
|
|
// descending joined-member count, plus the total number of published
|
|
// rooms matching the filter.
|
|
PublishedRooms(ctx context.Context, filter RoomFilter) ([]RoomSummary, int, error)
|
|
|
|
// ListRooms returns a page of every room for the admin API, plus the
|
|
// total matching the filter.
|
|
ListRooms(ctx context.Context, filter RoomFilter) ([]RoomSummary, int, error)
|
|
|
|
// RoomSummary returns one room with its denormalised state, returning
|
|
// ErrNoSuchRoom if it is absent.
|
|
RoomSummary(ctx context.Context, roomID string) (RoomSummary, error)
|
|
|
|
// PurgeRoom deletes a room and everything belonging to it, returning
|
|
// ErrNoSuchRoom for a room that does not exist. It is the admin API's
|
|
// last resort and has no undo.
|
|
PurgeRoom(ctx context.Context, roomID string) error
|
|
|
|
// RoomStats returns the aggregate counts for the admin dashboard.
|
|
RoomStats(ctx context.Context) (RoomStats, error)
|
|
|
|
// WriteOutliers stores events as outliers in one transaction, skipping
|
|
// any already held. It is how a join over federation stores the room
|
|
// state and auth chain it is handed, which can run to tens of thousands
|
|
// of events: one Write per event would be one transaction per event.
|
|
WriteOutliers(ctx context.Context, roomID string, events []StoredEvent) error
|
|
|
|
// WriteBackfill stores a room's history, fetched from another server,
|
|
// below everything the room already holds, in one transaction.
|
|
//
|
|
// The events are given oldest first and keep that order: each is
|
|
// numbered by a counter that runs downwards from -1, so the whole batch
|
|
// sorts below every event this server held when it arrived, and a
|
|
// later batch sorts below this one. An event already in the timeline is
|
|
// skipped; an outlier is promoted into the timeline in place, keeping
|
|
// its stored JSON. Nothing else about the room changes — not its
|
|
// current state, not its forward extremities, not its member counts —
|
|
// because history is by definition already reflected in them.
|
|
WriteBackfill(ctx context.Context, roomID string, events []BackfillEvent) error
|
|
|
|
// InviteState returns the stripped state stored with a user's invite,
|
|
// or nil when there is none.
|
|
InviteState(ctx context.Context, roomID, userID string) ([]byte, error)
|
|
|
|
// HasJoinedMemberOn reports whether any user of a server is joined to
|
|
// a room. It is asked about every event that arrives over federation —
|
|
// is this server still in the room, is the sender's? — and answering
|
|
// it by listing the members would read every row of a room of tens of
|
|
// thousands.
|
|
HasJoinedMemberOn(ctx context.Context, roomID, serverName string) (bool, error)
|
|
|
|
// UsersSharingRooms lists the users joined to at least one room the
|
|
// given user is joined to, that user included when they are joined
|
|
// anywhere. It is whose device list changes a client is told about,
|
|
// and who is woken when this user's keys change.
|
|
UsersSharingRooms(ctx context.Context, userID string) ([]string, error)
|
|
|
|
// MemberChanges lists the m.room.member events of the given rooms
|
|
// after from and at or before to: whose membership moved in a window,
|
|
// which is what tells a sync that it now shares a room with somebody
|
|
// it did not before, or no longer does.
|
|
MemberChanges(ctx context.Context, roomIDs []string, from, to int64) ([]MemberChange, error)
|
|
|
|
// EventByID reads an event without knowing its room, returning
|
|
// ErrNoSuchEvent if it is absent. The federation API's /event names
|
|
// only the event; everything else asks within a room.
|
|
EventByID(ctx context.Context, eventID string) (StoredEvent, error)
|
|
}
|