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

237 lines
8.5 KiB
Go

// Package syncapi is the read model: it answers "what has happened that I
// have not seen", which is the question a Matrix client spends its whole
// life asking.
//
// Everything a client learns after it has logged in arrives through here —
// /sync for the live stream, /messages for a room's history, /context for
// one event and what surrounds it. The component writes almost nothing:
// read receipts, to-device messages and presence are its own, and
// everything else it serves is the roomserver's, read through the roomserver
// rather than copied. That is the one real architectural choice in this
// package, and it is a departure from Dendrite, which gives its syncapi its
// own tables and feeds them from the output stream. In one process the copy
// buys nothing and costs the hardest bug in a homeserver: a read model that
// disagrees with the room.
//
// What this package owns instead is everything the roomserver has no
// opinion about:
//
// - 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,
// to-device messages and device list changes, none of which are
// events in a room.
//
// 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 (
"context"
"errors"
"log/slog"
"time"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart. The storage and roomserver sentinels
// are re-exported rather than translated, so that errors.Is works whichever
// package a caller reaches for.
var (
ErrNoSuchRoom = storage.ErrNoSuchRoom
ErrNoSuchEvent = storage.ErrNoSuchEvent
// ErrForbidden is a read the caller is not entitled to: a room they are
// not in, or somebody else's filter.
ErrForbidden = roomserver.ErrForbidden
// ErrPresenceDisabled is a presence operation on a server that has
// presence switched off.
ErrPresenceDisabled = errors.New("presence is disabled on this server")
// ErrInvalidParam is a request parameter this server will not accept:
// a receipt type it does not know, a presence state that is not one.
ErrInvalidParam = errors.New("invalid parameter")
// ErrInvalidToken is a ?since= or ?from= that is not a sync token.
ErrInvalidToken = errors.New("invalid sync token")
)
// Accounts is the part of the userapi the sync API needs: account data to
// put in a response, and the devices of a user to address a to-device
// message to. *userapi.API satisfies it as it stands.
type Accounts interface {
AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error)
SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error
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
log *slog.Logger
now func() time.Time
// presenceEnabled is the config switch of PLAN.md open question 3.
// With presence off the endpoints answer M_FORBIDDEN and the presence
// section of a sync is empty, rather than the endpoints disappearing:
// a client that finds no /presence assumes an old server and stops
// asking, where a clear refusal tells it this one has it turned off.
presenceEnabled bool
// outbound passes local typing, receipts and remote to-device
// messages to the federation queue; nil without federation.
outbound Outbound
// longPollTimeout caps how long a /sync may block, whatever ?timeout=
// the client asked for. A client asking for ten minutes would hold a
// connection and a database handle for ten minutes.
longPollTimeout time.Duration
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name.
ServerName string
// Rooms is the roomserver, which every room read goes through.
Rooms *roomserver.API
// 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
// Notifier wakes blocked syncs. It is the same object the roomserver
// was given as its Notifier; nil builds one, which is only useful in a
// test where nothing is being written.
Notifier *Notifier
// PresenceEnabled turns the presence stream on.
PresenceEnabled bool
// TypingTimeout is how long a typing notification lasts without being
// renewed, whatever timeout the client asked for.
TypingTimeout time.Duration
// LongPollTimeout caps a /sync's ?timeout=.
LongPollTimeout time.Duration
Logger *slog.Logger
// Now is the clock, so tests can expire a typing notification without
// waiting for it.
Now func() time.Time
}
// Defaults for the timeouts.
const (
// DefaultTypingTimeout is the spec's own suggestion, and what every
// client sends.
DefaultTypingTimeout = 30 * time.Second
// DefaultLongPollTimeout is the timeout clients ask for. Holding a
// connection longer buys nothing: the notifier wakes a sync the moment
// anything happens, so the timeout only decides how often an idle
// client re-establishes its request.
DefaultLongPollTimeout = 30 * time.Second
// MaxTypingTimeout caps what a client may ask for. A typing
// notification that outlived its sender's session would leave a room
// permanently claiming somebody was typing.
MaxTypingTimeout = 2 * time.Minute
)
// New builds the component.
func New(opts Options) (*API, error) {
switch {
case opts.ServerName == "":
return nil, errors.New("syncapi: ServerName must not be empty")
case opts.Rooms == nil:
return nil, errors.New("syncapi: Rooms must not be nil")
case opts.Store == nil:
return nil, errors.New("syncapi: Store must not be nil")
case opts.Accounts == nil:
return nil, errors.New("syncapi: Accounts must not be nil")
}
if opts.Notifier == nil {
opts.Notifier = NewNotifier()
}
if opts.TypingTimeout <= 0 {
opts.TypingTimeout = DefaultTypingTimeout
}
if opts.TypingTimeout > MaxTypingTimeout {
opts.TypingTimeout = MaxTypingTimeout
}
if opts.LongPollTimeout <= 0 {
opts.LongPollTimeout = DefaultLongPollTimeout
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
return &API{
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),
log: opts.Logger,
now: opts.Now,
presenceEnabled: opts.PresenceEnabled,
longPollTimeout: opts.LongPollTimeout,
}, nil
}
// Notifier returns the notifier, which the roomserver is wired to as its
// emit stage.
func (s *API) Notifier() *Notifier { return s.notifier }
// PresenceEnabled reports whether the presence stream is on, which
// /capabilities and the admin API both report.
func (s *API) PresenceEnabled() bool { return s.presenceEnabled }
// LongPollTimeout is the cap a /sync's ?timeout= is held to.
func (s *API) LongPollTimeout() time.Duration { return s.longPollTimeout }
// nowMS is the current time in the unit the API uses on the wire.
func (s *API) nowMS() int64 { return s.now().UnixMilli() }
// Close releases what the component started. The typing tracker holds a
// timer per typing user, and a server shutting down should not wait for
// them.
func (s *API) Close() { s.typing.close() }