Files
warp/server/internal/storage/rooms.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

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)
}