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

566 lines
18 KiB
Go

// Package userapi owns accounts, passwords, devices, tokens and account
// data: everything that identifies a local user and their sessions.
//
// It is the component the Client-Server API handlers call for anything
// to do with who is making a request. The HTTP shapes live in clientapi;
// what is here is the part that would be the same behind any transport —
// which is also what makes `warp create-account` able to create an account
// without starting a server.
//
// Nothing in this package knows about rooms. A device's encryption keys
// are the keyserver's, which hears about a device going away through the
// DeviceObserver; its push rules arrive in Phase 8.
package userapi
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Errors callers have to tell apart. The storage sentinels are re-exported
// rather than translated: there is one error value with two names, so
// errors.Is works whichever package a caller reaches for.
var (
ErrUserExists = storage.ErrUserExists
ErrNoSuchUser = storage.ErrNoSuchUser
ErrNoSuchDevice = storage.ErrNoSuchDevice
ErrNoSuchToken = storage.ErrNoSuchToken
ErrNoSuchAccountData = storage.ErrNoSuchAccountData
// ErrTokenExpired is an access token that was valid and has timed out.
// It is distinct from ErrNoSuchToken because the spec answers it with a
// soft logout: the client may keep its encryption state and log in
// again.
ErrTokenExpired = errors.New("access token has expired")
// ErrAccountDeactivated is a token belonging to an account that has
// been deactivated.
ErrAccountDeactivated = errors.New("account has been deactivated")
// ErrWrongPassword is a failed password check. It is deliberately the
// same error whether the account exists or not, so that callers cannot
// turn it into a user-enumeration oracle by accident.
ErrWrongPassword = errors.New("invalid password")
// ErrNoPassword is an account that has no password set and so cannot
// use m.login.password.
ErrNoPassword = errors.New("account has no password")
// ErrInvalidDeviceID is a client-supplied device ID that is empty, too
// long or contains control characters.
ErrInvalidDeviceID = errors.New("invalid device ID")
)
// API is the userapi component.
type API struct {
serverName string
store storage.UserStore
log *slog.Logger
now func() time.Time
accessTokenLifetime time.Duration
refreshTokenLifetime time.Duration
// lastSeen throttles the last-seen bookkeeping; see RecordActivity.
lastSeen sync.Map
// observer hears about deleted and renamed devices; nil hears nothing.
observer DeviceObserver
}
// DeviceObserver is told about changes to devices that something else
// keeps state for: the keyserver, whose keys for a deleted device have to
// go with it. *keyserver.API satisfies it.
//
// It is called after the change has been made, on the request path, and
// must not fail it: the device is already gone.
type DeviceObserver interface {
// DevicesDeleted is called with the deleted device IDs, or with none
// when every device of the user was deleted.
DevicesDeleted(ctx context.Context, localpart string, deviceIDs []string)
// DeviceRenamed is called when a device's display name changes.
DeviceRenamed(ctx context.Context, localpart, deviceID string)
}
// SetDeviceObserver connects the observer. It is a setter because the
// keyserver is built after the userapi it reads devices from.
func (a *API) SetDeviceObserver(o DeviceObserver) { a.observer = o }
// Options configure the component.
type Options struct {
// ServerName is the Matrix server name; it becomes the domain part of
// every user ID this component hands out.
ServerName string
// Store is the persistence layer.
Store storage.UserStore
// AccessTokenLifetime expires issued access tokens. Zero issues tokens
// that never expire.
AccessTokenLifetime time.Duration
// RefreshTokenLifetime bounds how long a refresh token stays usable.
// Zero means no expiry.
RefreshTokenLifetime time.Duration
Logger *slog.Logger
// Now is the clock, so tests can control token expiry. Nil means
// time.Now.
Now func() time.Time
}
// New builds the component.
func New(opts Options) (*API, error) {
if opts.ServerName == "" {
return nil, errors.New("userapi: ServerName must not be empty")
}
if opts.Store == nil {
return nil, errors.New("userapi: Store must not be nil")
}
if opts.Logger == nil {
opts.Logger = slog.Default()
}
if opts.Now == nil {
opts.Now = time.Now
}
return &API{
serverName: opts.ServerName,
store: opts.Store,
log: opts.Logger,
now: opts.Now,
accessTokenLifetime: opts.AccessTokenLifetime,
refreshTokenLifetime: opts.RefreshTokenLifetime,
}, nil
}
// ServerName returns the server name user IDs are built with.
func (a *API) ServerName() string { return a.serverName }
// nowMS is the current time in the unit the store uses.
func (a *API) nowMS() int64 { return a.now().UnixMilli() }
// NewAccountRequest describes an account to create.
type NewAccountRequest struct {
// Localpart is normalised and validated by CreateAccount.
Localpart string
// Password may be empty, which creates an account that cannot log in
// with a password. `warp create-account` uses that for a placeholder
// account; the admin API does not offer it.
Password string
DisplayName string
Admin bool
}
// CreateAccount validates the request, hashes the password and stores the
// account. It returns ErrUserExists if the name is taken and
// ErrInvalidLocalpart if the name is not usable.
func (a *API) CreateAccount(ctx context.Context, req NewAccountRequest) (storage.Account, error) {
localpart := NormaliseLocalpart(req.Localpart)
if err := ValidateLocalpart(localpart, a.serverName); err != nil {
return storage.Account{}, err
}
var hash string
if req.Password != "" {
var err error
if hash, err = HashPassword(req.Password); err != nil {
return storage.Account{}, err
}
}
acc, err := a.store.CreateAccount(ctx, storage.NewAccount{
Localpart: localpart,
PasswordHash: hash,
DisplayName: req.DisplayName,
Admin: req.Admin,
CreatedAtMS: a.nowMS(),
})
if err != nil {
return storage.Account{}, err
}
a.log.Info("created account", "user_id", a.UserID(localpart), "admin", req.Admin)
return acc, nil
}
// Account reads an account by normalised localpart.
func (a *API) Account(ctx context.Context, localpart string) (storage.Account, error) {
return a.store.Account(ctx, localpart)
}
// AccountExists reports whether a localpart is taken. It is the answer
// behind /register/available, and deliberately does not distinguish a
// deactivated account: the name stays reserved.
func (a *API) AccountExists(ctx context.Context, localpart string) (bool, error) {
switch _, err := a.store.Account(ctx, localpart); {
case err == nil:
return true, nil
case errors.Is(err, ErrNoSuchUser):
return false, nil
default:
return false, err
}
}
// SetAdmin grants or revokes the admin flag.
func (a *API) SetAdmin(ctx context.Context, localpart string, admin bool) error {
return a.store.SetAdmin(ctx, localpart, admin)
}
// SetPassword replaces an account's password.
func (a *API) SetPassword(ctx context.Context, localpart, password string) error {
hash, err := HashPassword(password)
if err != nil {
return err
}
return a.store.SetPasswordHash(ctx, localpart, hash)
}
// CheckPassword verifies a password against an account.
//
// Every failure path costs one argon2id hash, including an account that does
// not exist or has no password, so the time a rejection takes says nothing
// about which user names are real.
func (a *API) CheckPassword(ctx context.Context, localpart, password string) error {
hash, err := a.store.PasswordHash(ctx, localpart)
switch {
case errors.Is(err, ErrNoSuchUser):
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrWrongPassword
case err != nil:
return err
}
if hash == "" {
_, _ = VerifyPassword(dummyPasswordHash, password)
return ErrNoPassword
}
ok, err := VerifyPassword(hash, password)
switch {
case errors.Is(err, ErrPasswordTooLong):
return ErrWrongPassword
case err != nil:
// A malformed hash is an operational problem, not a wrong password.
a.log.Error("stored password hash is unusable", "localpart", localpart, "error", err)
return err
case !ok:
return ErrWrongPassword
}
return nil
}
// SessionRequest asks for a new device and its tokens.
type SessionRequest struct {
// Localpart must already be normalised and must exist.
Localpart string
// DeviceID is the client's choice. Empty means generate one. Naming a
// device that already exists re-uses it and revokes its old tokens.
DeviceID string
// DisplayName is initial_device_display_name: used only when the device
// is created.
DisplayName string
// WithRefreshToken issues a refresh token alongside the access token.
WithRefreshToken bool
IP string
UserAgent string
}
// Session is the result of a login, a registration or a token refresh: what
// the client needs to start making requests.
type Session struct {
UserID string
Localpart string
DeviceID string
AccessToken string
// RefreshToken is empty unless the client asked for one.
RefreshToken string
// ExpiresInMS is how long the access token is good for, or 0 when it
// does not expire.
ExpiresInMS int64
}
// CreateSession issues a device and tokens for an existing account.
func (a *API) CreateSession(ctx context.Context, req SessionRequest) (Session, error) {
deviceID := req.DeviceID
if deviceID == "" {
var err error
if deviceID, err = GenerateDeviceID(); err != nil {
return Session{}, err
}
} else if err := ValidateDeviceID(deviceID); err != nil {
return Session{}, err
}
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
var refreshToken string
if req.WithRefreshToken {
if refreshToken, err = GenerateRefreshToken(); err != nil {
return Session{}, err
}
}
now := a.nowMS()
session := storage.NewSession{
Localpart: req.Localpart,
DeviceID: deviceID,
DisplayName: req.DisplayName,
CreatedAtMS: now,
AccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
LastSeenIP: req.IP,
UserAgent: req.UserAgent,
}
if refreshToken != "" {
session.RefreshTokenHash = HashToken(refreshToken)
}
device, err := a.store.CreateSession(ctx, session)
if err != nil {
return Session{}, err
}
return Session{
UserID: a.UserID(req.Localpart),
Localpart: req.Localpart,
DeviceID: device.ID,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// RefreshSession spends a refresh token and issues a new pair. It returns
// ErrNoSuchToken if the token is unknown or already spent.
func (a *API) RefreshSession(ctx context.Context, refreshToken string) (Session, error) {
accessToken, err := GenerateAccessToken()
if err != nil {
return Session{}, err
}
newRefreshToken, err := GenerateRefreshToken()
if err != nil {
return Session{}, err
}
now := a.nowMS()
info, err := a.store.RefreshSession(ctx, storage.RefreshedSession{
RefreshTokenHash: HashToken(refreshToken),
NewAccessTokenHash: HashToken(accessToken),
AccessTokenExpiresAtMS: expiryMS(now, a.accessTokenLifetime),
NewRefreshTokenHash: HashToken(newRefreshToken),
RefreshTokenExpiresAtMS: expiryMS(now, a.refreshTokenLifetime),
RefreshedAtMS: now,
})
if err != nil {
return Session{}, err
}
if info.Deactivated {
return Session{}, ErrAccountDeactivated
}
return Session{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
AccessToken: accessToken,
RefreshToken: newRefreshToken,
ExpiresInMS: lifetimeMS(a.accessTokenLifetime),
}, nil
}
// Authenticated is the identity behind a request: what the auth middleware
// resolves an access token into and what every authenticated handler is
// given.
type Authenticated struct {
UserID string
Localpart string
DeviceID string
Admin bool
}
// Authenticate resolves an access token.
//
// It returns ErrNoSuchToken for an unknown token, ErrTokenExpired for one
// that has timed out, and ErrAccountDeactivated for a token whose account is
// gone. The caller turns those into M_UNKNOWN_TOKEN, a soft logout, and
// M_USER_DEACTIVATED respectively.
func (a *API) Authenticate(ctx context.Context, accessToken string) (Authenticated, error) {
if accessToken == "" {
return Authenticated{}, ErrNoSuchToken
}
info, err := a.store.TokenInfo(ctx, HashToken(accessToken))
if err != nil {
return Authenticated{}, err
}
if info.ExpiresAtMS != 0 && info.ExpiresAtMS <= a.nowMS() {
return Authenticated{}, ErrTokenExpired
}
if info.Deactivated {
return Authenticated{}, ErrAccountDeactivated
}
return Authenticated{
UserID: a.UserID(info.Localpart),
Localpart: info.Localpart,
DeviceID: info.DeviceID,
Admin: info.Admin,
}, nil
}
// lastSeenInterval is how stale the last-seen record of a device may get.
// Recording it on every request would double the write load of the whole C-S
// API to keep a field nothing reads more often than a user opens their
// device list.
const lastSeenInterval = time.Minute
// RecordActivity updates a device's last-seen timestamp, address and user
// agent, at most once per lastSeenInterval per device. A failure is logged
// and swallowed: the request it belongs to has already been authorised, and
// bookkeeping must not turn into a 500.
func (a *API) RecordActivity(ctx context.Context, auth Authenticated, ip, userAgent string) {
key := auth.Localpart + "\x00" + auth.DeviceID
now := a.now()
if previous, ok := a.lastSeen.Load(key); ok {
if at, ok := previous.(time.Time); ok && now.Sub(at) < lastSeenInterval {
return
}
}
a.lastSeen.Store(key, now)
if err := a.store.TouchDevice(ctx, auth.Localpart, auth.DeviceID, ip, userAgent, now.UnixMilli()); err != nil {
a.log.Warn("recording device activity failed",
"user_id", auth.UserID, "device_id", auth.DeviceID, "error", err)
}
}
// Device reads one of a user's devices.
func (a *API) Device(ctx context.Context, localpart, deviceID string) (storage.Device, error) {
return a.store.Device(ctx, localpart, deviceID)
}
// Devices lists a user's devices.
func (a *API) Devices(ctx context.Context, localpart string) ([]storage.Device, error) {
return a.store.Devices(ctx, localpart)
}
// SetDeviceDisplayName renames a device.
func (a *API) SetDeviceDisplayName(ctx context.Context, localpart, deviceID, displayName string) error {
if err := a.store.SetDeviceDisplayName(ctx, localpart, deviceID, displayName); err != nil {
return err
}
if a.observer != nil {
a.observer.DeviceRenamed(ctx, localpart, deviceID)
}
return nil
}
// DeleteDevices revokes devices and their tokens. It is what /logout,
// /delete_devices and DELETE /devices/{id} all end up calling.
func (a *API) DeleteDevices(ctx context.Context, localpart string, deviceIDs []string) error {
if len(deviceIDs) == 0 {
return nil
}
if _, err := a.store.DeleteDevices(ctx, localpart, deviceIDs); err != nil {
return err
}
a.forgetLastSeen(localpart, deviceIDs...)
if a.observer != nil {
a.observer.DevicesDeleted(ctx, localpart, deviceIDs)
}
return nil
}
// DeleteAllDevices revokes every device of a user: /logout/all.
func (a *API) DeleteAllDevices(ctx context.Context, localpart string) error {
if _, err := a.store.DeleteAllDevices(ctx, localpart); err != nil {
return err
}
a.forgetLastSeen(localpart)
if a.observer != nil {
a.observer.DevicesDeleted(ctx, localpart, nil)
}
return nil
}
// forgetLastSeen drops the throttle entries for devices that no longer
// exist, so the map does not grow with every logout. With no device IDs it
// clears every entry of the user.
func (a *API) forgetLastSeen(localpart string, deviceIDs ...string) {
if len(deviceIDs) > 0 {
for _, id := range deviceIDs {
a.lastSeen.Delete(localpart + "\x00" + id)
}
return
}
prefix := localpart + "\x00"
a.lastSeen.Range(func(key, _ any) bool {
if s, ok := key.(string); ok && len(s) > len(prefix) && s[:len(prefix)] == prefix {
a.lastSeen.Delete(key)
}
return true
})
}
// SetDisplayName updates a user's profile display name.
func (a *API) SetDisplayName(ctx context.Context, localpart, displayName string) error {
return a.store.SetDisplayName(ctx, localpart, displayName)
}
// SetAvatarURL updates a user's profile avatar.
func (a *API) SetAvatarURL(ctx context.Context, localpart, avatarURL string) error {
return a.store.SetAvatarURL(ctx, localpart, avatarURL)
}
// AccountData reads one account data blob; roomID is empty for global data.
func (a *API) AccountData(ctx context.Context, localpart, roomID, dataType string) ([]byte, error) {
return a.store.AccountData(ctx, localpart, roomID, dataType)
}
// SetAccountData writes one account data blob.
func (a *API) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
return a.store.SetAccountData(ctx, localpart, roomID, dataType, content)
}
// ListAccounts returns a page of accounts for the admin API.
func (a *API) ListAccounts(ctx context.Context, filter storage.AccountFilter) ([]storage.Account, int, error) {
return a.store.ListAccounts(ctx, filter)
}
// Stats returns the aggregate user counts for the admin API.
func (a *API) Stats(ctx context.Context) (storage.Stats, error) {
return a.store.Stats(ctx)
}
// expiryMS turns a lifetime into an absolute deadline in the store's unit,
// or 0 for "never".
func expiryMS(nowMS int64, lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return nowMS + lifetime.Milliseconds()
}
// lifetimeMS is the expires_in_ms a client is told, or 0 for "never".
func lifetimeMS(lifetime time.Duration) int64 {
if lifetime <= 0 {
return 0
}
return lifetime.Milliseconds()
}