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

378 lines
16 KiB
Go

// Package clientapi implements the Client-Server API: the HTTP surface
// clients talk to.
//
// Phase 1 covers everything about identity — registration, login, sessions,
// devices, profiles, account data and capabilities. Rooms, messages and
// /sync arrive in Phases 3 and 4; the route table here is the place they
// will be added.
//
// Handlers are thin. They decode a request, map it onto a userapi call and
// shape the response; the rules live in userapi, and the storage in
// internal/storage. A handler that needs more than that is a sign something
// belongs further down.
package clientapi
import (
"errors"
"log/slog"
"time"
"github.com/dariuszjaniak/warp/server/internal/config"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/httputil"
"github.com/dariuszjaniak/warp/server/internal/keyserver"
"github.com/dariuszjaniak/warp/server/internal/mediaapi"
"github.com/dariuszjaniak/warp/server/internal/ratelimit"
"github.com/dariuszjaniak/warp/server/internal/roomserver"
"github.com/dariuszjaniak/warp/server/internal/settings"
"github.com/dariuszjaniak/warp/server/internal/syncapi"
"github.com/dariuszjaniak/warp/server/internal/userapi"
)
// ClientAPI serves the Client-Server API.
type ClientAPI struct {
serverName string
clientBaseURL string
users *userapi.API
rooms *roomserver.API
sync *syncapi.API
keys *keyserver.API
media *mediaapi.API
legacyMedia bool
settings *settings.Service
federation *federation.Gate
remoteProfile RemoteProfiles
registration config.Registration
log *slog.Logger
now func() time.Time
uia *uiaStore
clientIP ratelimit.KeyFunc
// One limiter per endpoint class, so a client hammering /login cannot
// spend the budget of /profile.
limitDefault *ratelimit.Limiter
limitLogin *ratelimit.Limiter
limitRegister *ratelimit.Limiter
limitRoomCreate *ratelimit.Limiter
limitJoin *ratelimit.Limiter
// limitSync is deliberately generous. A client is in /sync all the
// time, and a long poll that is refused leaves it reconnecting in a
// loop — the one place where rate limiting makes the load worse rather
// than better.
limitSync *ratelimit.Limiter
// limitMedia covers downloads and thumbnails, which a client asks for
// in bursts: every avatar in a room list at once.
limitMedia *ratelimit.Limiter
}
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name.
ServerName string
// ClientBaseURL is the URL advertised to clients, the same value
// /.well-known/matrix/client carries. A successful login repeats it so
// that a client which only knew the server name does not have to fetch
// the discovery document separately.
ClientBaseURL string
// Users is the userapi component.
Users *userapi.API
// Rooms is the roomserver component. Without it the room endpoints are
// not registered at all, which is what the Phase 1 and Phase 2 tests
// relied on and what keeps a half-wired server from offering a
// createRoom it cannot honour.
Rooms *roomserver.API
// Sync is the syncapi component. Like the roomserver it is optional,
// and for the same reason: without it /sync, /messages, /context and
// the ephemeral endpoints answer M_UNRECOGNIZED rather than
// pretending there is a stream to read.
Sync *syncapi.API
// Keys is the keyserver. Without it the end-to-end encryption
// endpoints answer M_UNRECOGNIZED, which is how a client learns it
// cannot encrypt here.
Keys *keyserver.API
// Media is the media repository. Without it the media endpoints
// answer M_UNRECOGNIZED.
Media *mediaapi.API
// LegacyMediaDownloads serves the deprecated unauthenticated download
// and thumbnail endpoints too.
LegacyMediaDownloads bool
// Settings is the runtime-settings service; the registration switch is
// read from it on every registration attempt.
Settings *settings.Service
// Federation gates the endpoints that would need a remote server.
Federation *federation.Gate
// RemoteProfiles asks another server for one of its users' profile.
// Nil means remote profiles cannot be looked up at all.
RemoteProfiles RemoteProfiles
// Registration is the file configuration of the registration flow.
Registration config.Registration
// RateLimit configures the per-endpoint buckets.
RateLimit config.RateLimit
// TrustXForwardedFor takes the client address from the proxy header.
TrustXForwardedFor bool
Logger *slog.Logger
// Now is the clock, so tests can expire sessions without waiting.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*ClientAPI, error) {
if opts.ServerName == "" {
return nil, errors.New("clientapi: ServerName must not be empty")
}
if opts.Users == nil {
return nil, errors.New("clientapi: Users must not be nil")
}
if opts.Settings == nil {
return nil, errors.New("clientapi: Settings must not be nil")
}
if opts.Federation == nil {
opts.Federation = federation.NewGate(nil, nil)
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
c := &ClientAPI{
serverName: opts.ServerName,
clientBaseURL: opts.ClientBaseURL,
users: opts.Users,
rooms: opts.Rooms,
sync: opts.Sync,
keys: opts.Keys,
media: opts.Media,
legacyMedia: opts.LegacyMediaDownloads,
settings: opts.Settings,
federation: opts.Federation,
remoteProfile: opts.RemoteProfiles,
registration: opts.Registration,
log: opts.Logger,
now: opts.Now,
uia: newUIAStore(uiaSessionTTL, opts.Now),
clientIP: ratelimit.ClientIP(opts.TrustXForwardedFor),
}
if opts.RateLimit.Enabled {
c.limitDefault = ratelimit.New(bucket(opts.RateLimit.Default))
c.limitLogin = ratelimit.New(bucket(opts.RateLimit.Login))
c.limitRegister = ratelimit.New(bucket(opts.RateLimit.Register))
// Creating a room writes a dozen events and joining one writes a
// membership event into a room everybody in it then sees, so both
// get their own, tighter bucket: the default one is sized for
// reads.
c.limitRoomCreate = ratelimit.New(bucket(opts.RateLimit.RoomCreate))
c.limitJoin = ratelimit.New(bucket(opts.RateLimit.Join))
c.limitSync = ratelimit.New(bucket(opts.RateLimit.Sync))
c.limitMedia = ratelimit.New(bucket(opts.RateLimit.Media))
} else {
c.limitDefault, c.limitLogin, c.limitRegister =
ratelimit.Disabled(), ratelimit.Disabled(), ratelimit.Disabled()
c.limitRoomCreate, c.limitJoin = ratelimit.Disabled(), ratelimit.Disabled()
c.limitSync, c.limitMedia = ratelimit.Disabled(), ratelimit.Disabled()
}
return c, nil
}
func bucket(b config.RateLimitBucket) ratelimit.Policy {
return ratelimit.Policy{Burst: b.Burst, Interval: b.Interval}
}
// Client-Server API path prefixes. Warp serves the versioned paths only:
// the r0 prefix was deprecated in Matrix 1.4, and every client that still
// sends it also understands a 404 and falls back to version negotiation
// through /_matrix/client/versions.
const (
v3 = "/_matrix/client/v3"
v1 = "/_matrix/client/v1"
)
// RegisterRoutes adds the Client-Server endpoints to the router.
func (c *ClientAPI) RegisterRoutes(r *httputil.Router) {
// Registration. The token-validity endpoint is v1 because that is the
// version the spec introduced it at.
c.public(r, "POST "+v3+"/register", c.limitRegister, c.handleRegister)
c.public(r, "GET "+v3+"/register/available", c.limitRegister, c.handleRegisterAvailable)
c.public(r, "GET "+v1+"/register/m.login.registration_token/validity",
c.limitRegister, c.handleRegistrationTokenValidity)
// Sessions.
c.public(r, "GET "+v3+"/login", c.limitDefault, c.handleLoginFlows)
c.public(r, "POST "+v3+"/login", c.limitLogin, c.handleLogin)
c.public(r, "POST "+v3+"/refresh", c.limitLogin, c.handleRefresh)
c.authed(r, "POST "+v3+"/logout", c.limitDefault, c.handleLogout)
c.authed(r, "POST "+v3+"/logout/all", c.limitDefault, c.handleLogoutAll)
// Account.
c.authed(r, "GET "+v3+"/account/whoami", c.limitDefault, c.handleWhoami)
c.authed(r, "GET "+v3+"/capabilities", c.limitDefault, c.handleCapabilities)
// Devices.
c.authed(r, "GET "+v3+"/devices", c.limitDefault, c.handleGetDevices)
c.authed(r, "GET "+v3+"/devices/{deviceID}", c.limitDefault, c.handleGetDevice)
c.authed(r, "PUT "+v3+"/devices/{deviceID}", c.limitDefault, c.handlePutDevice)
c.authed(r, "DELETE "+v3+"/devices/{deviceID}", c.limitDefault, c.handleDeleteDevice)
c.authed(r, "POST "+v3+"/delete_devices", c.limitDefault, c.handleDeleteDevices)
// Profiles are world-readable: a client showing a room member's name
// has no token for that member's server, and the spec makes the GETs
// unauthenticated for exactly that reason.
c.public(r, "GET "+v3+"/profile/{userID}", c.limitDefault, c.handleGetProfile)
c.public(r, "GET "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handleGetDisplayName)
c.public(r, "GET "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handleGetAvatarURL)
c.authed(r, "PUT "+v3+"/profile/{userID}/displayname", c.limitDefault, c.handlePutDisplayName)
c.authed(r, "PUT "+v3+"/profile/{userID}/avatar_url", c.limitDefault, c.handlePutAvatarURL)
// Account data.
c.authed(r, "GET "+v3+"/user/{userID}/account_data/{dataType}",
c.limitDefault, c.handleGetAccountData)
c.authed(r, "PUT "+v3+"/user/{userID}/account_data/{dataType}",
c.limitDefault, c.handlePutAccountData)
c.authed(r, "GET "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
c.limitDefault, c.handleGetRoomAccountData)
c.authed(r, "PUT "+v3+"/user/{userID}/rooms/{roomID}/account_data/{dataType}",
c.limitDefault, c.handlePutRoomAccountData)
c.registerRoomRoutes(r)
c.registerSyncRoutes(r)
c.registerKeyRoutes(r)
c.registerMediaRoutes(r)
}
// registerSyncRoutes adds the endpoints the syncapi serves, if there is
// one.
//
// Like the room routes they are left unregistered rather than stubbed in a
// build without the component: a client that gets M_UNRECOGNIZED from
// /sync knows the server does not have it, where an empty success would
// have it believe nothing was happening.
func (c *ClientAPI) registerSyncRoutes(r *httputil.Router) {
if c.sync == nil {
return
}
c.authed(r, "GET "+v3+"/sync", c.limitSync, c.handleSync)
c.authed(r, "GET "+v3+"/rooms/{roomID}/messages", c.limitDefault, c.handleMessages)
c.authed(r, "GET "+v3+"/rooms/{roomID}/context/{eventID}", c.limitDefault, c.handleContext)
// Filters.
c.authed(r, "POST "+v3+"/user/{userID}/filter", c.limitDefault, c.handlePostFilter)
c.authed(r, "GET "+v3+"/user/{userID}/filter/{filterID}", c.limitDefault, c.handleGetFilter)
// The ephemeral streams. Typing goes in the sync limiter's class
// rather than the default one: a client sends one per keystroke-burst,
// and it is the chattiest write in the API.
c.authed(r, "PUT "+v3+"/rooms/{roomID}/typing/{userID}", c.limitSync, c.handlePutTyping)
c.authed(r, "POST "+v3+"/rooms/{roomID}/receipt/{receiptType}/{eventID}",
c.limitSync, c.handlePostReceipt)
c.authed(r, "POST "+v3+"/rooms/{roomID}/read_markers", c.limitDefault, c.handlePostReadMarkers)
// To-device messaging. A client encrypting for a busy room sends one
// of these per device, so it shares the sync budget too.
c.authed(r, "PUT "+v3+"/sendToDevice/{eventType}/{txnID}", c.limitSync, c.handleSendToDevice)
// Presence.
c.authed(r, "GET "+v3+"/presence/{userID}/status", c.limitDefault, c.handleGetPresence)
c.authed(r, "PUT "+v3+"/presence/{userID}/status", c.limitSync, c.handlePutPresence)
}
// registerRoomRoutes adds the room endpoints, if there is a roomserver.
//
// A build without one answers them with M_UNRECOGNIZED, which is the right
// answer: a server that cannot store a room should say it has no createRoom
// rather than fail every call to it.
func (c *ClientAPI) registerRoomRoutes(r *httputil.Router) {
if c.rooms == nil {
return
}
c.authed(r, "POST "+v3+"/createRoom", c.limitRoomCreate, c.handleCreateRoom)
// Membership. The two join endpoints differ only in what they are
// addressed by: /join/{roomIdOrAlias} resolves an alias first.
c.authed(r, "POST "+v3+"/join/{roomIDOrAlias}", c.limitJoin, c.handleJoinRoom)
c.authed(r, "POST "+v3+"/rooms/{roomID}/join", c.limitJoin, c.handleJoinRoomByID)
c.authed(r, "POST "+v3+"/rooms/{roomID}/leave", c.limitDefault, c.handleLeaveRoom)
c.authed(r, "POST "+v3+"/rooms/{roomID}/invite", c.limitDefault, c.handleInvite)
c.authed(r, "POST "+v3+"/rooms/{roomID}/kick", c.limitDefault, c.handleKick)
c.authed(r, "POST "+v3+"/rooms/{roomID}/ban", c.limitDefault, c.handleBan)
c.authed(r, "POST "+v3+"/rooms/{roomID}/unban", c.limitDefault, c.handleUnban)
// Sending. The state endpoint is registered twice, because the empty
// state key is both valid and the common case and arrives in two
// spellings: ".../state/m.room.name" and ".../state/m.room.name/".
// The trailing-slash form uses a multi-segment wildcard, which is the
// only kind Go's router matches against an empty segment — and which
// also means a state key a client did not escape arrives whole rather
// than as a 404.
c.authed(r, "PUT "+v3+"/rooms/{roomID}/send/{eventType}/{txnID}",
c.limitDefault, c.handleSendEvent)
c.authed(r, "PUT "+v3+"/rooms/{roomID}/state/{eventType}",
c.limitDefault, c.handlePutState)
c.authed(r, "PUT "+v3+"/rooms/{roomID}/state/{eventType}/{stateKey...}",
c.limitDefault, c.handlePutState)
c.authed(r, "PUT "+v3+"/rooms/{roomID}/redact/{eventID}/{txnID}",
c.limitDefault, c.handleRedact)
// Reading.
c.authed(r, "GET "+v3+"/rooms/{roomID}/event/{eventID}", c.limitDefault, c.handleGetEvent)
c.authed(r, "GET "+v3+"/rooms/{roomID}/state", c.limitDefault, c.handleGetState)
c.authed(r, "GET "+v3+"/rooms/{roomID}/state/{eventType}",
c.limitDefault, c.handleGetStateEvent)
c.authed(r, "GET "+v3+"/rooms/{roomID}/state/{eventType}/{stateKey...}",
c.limitDefault, c.handleGetStateEvent)
c.authed(r, "GET "+v3+"/rooms/{roomID}/members", c.limitDefault, c.handleGetMembers)
c.authed(r, "GET "+v3+"/rooms/{roomID}/joined_members",
c.limitDefault, c.handleGetJoinedMembers)
c.authed(r, "GET "+v3+"/joined_rooms", c.limitDefault, c.handleGetJoinedRooms)
// The directory. Resolving an alias and reading the public room list
// need no token: a client handed a link has to be able to follow it
// before it decides whether to log in.
c.public(r, "GET "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handleGetAlias)
c.authed(r, "PUT "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handlePutAlias)
c.authed(r, "DELETE "+v3+"/directory/room/{roomAlias}", c.limitDefault, c.handleDeleteAlias)
c.authed(r, "GET "+v3+"/rooms/{roomID}/aliases", c.limitDefault, c.handleGetRoomAliases)
c.public(r, "GET "+v3+"/publicRooms", c.limitDefault, c.handleGetPublicRooms)
c.authed(r, "POST "+v3+"/publicRooms", c.limitDefault, c.handlePostPublicRooms)
c.public(r, "GET "+v3+"/directory/list/room/{roomID}",
c.limitDefault, c.handleGetRoomVisibility)
c.authed(r, "PUT "+v3+"/directory/list/room/{roomID}",
c.limitDefault, c.handlePutRoomVisibility)
}
// public registers an endpoint that needs no access token.
func (c *ClientAPI) public(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h httputil.Handler) {
r.Handle(pattern, limiter.Middleware(c.clientIP)(httputil.Wrap(h)))
}
// authed registers an endpoint that needs one.
//
// The rate limiter still keys on the client address rather than the user:
// it runs before the token is resolved, and a limiter that had to hit the
// database to find its own key would be a denial of service of its own.
func (c *ClientAPI) authed(r *httputil.Router, pattern string, limiter *ratelimit.Limiter, h AuthedHandler) {
r.Handle(pattern, limiter.Middleware(c.clientIP)(httputil.Wrap(c.withAuth(h))))
}