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>
237 lines
8.5 KiB
Go
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() }
|