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

466 lines
14 KiB
Go

package keyserver
import (
"context"
"encoding/json"
"errors"
"slices"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/federation"
"github.com/dariuszjaniak/warp/server/internal/matrix/id"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Reading keys: POST /keys/query and /keys/claim, and their federation
// counterparts.
// QueryRequest is the body of POST /keys/query.
type QueryRequest struct {
// DeviceKeys is user ID to the device IDs wanted; an empty list asks
// for every device.
DeviceKeys map[string][]string
// Timeout is how long to wait for other servers.
Timeout time.Duration
}
// QueryResponse is the answer to POST /keys/query.
type QueryResponse struct {
DeviceKeys map[string]map[string]json.RawMessage `json:"device_keys"`
Failures map[string]any `json:"failures"`
MasterKeys map[string]json.RawMessage `json:"master_keys"`
SelfSigningKeys map[string]json.RawMessage `json:"self_signing_keys"`
UserSigningKeys map[string]json.RawMessage `json:"user_signing_keys"`
}
// ClaimRequest is the body of POST /keys/claim.
type ClaimRequest struct {
// OneTimeKeys is user ID, then device ID, then the algorithm wanted.
OneTimeKeys map[string]map[string]string
Timeout time.Duration
}
// ClaimResponse is the answer to POST /keys/claim.
type ClaimResponse struct {
OneTimeKeys map[string]map[string]map[string]json.RawMessage `json:"one_time_keys"`
Failures map[string]any `json:"failures"`
}
// remoteFailure is what a server that could not be asked is reported as,
// in the shape other homeservers use.
func remoteFailure(err error) map[string]any {
status := 503
var httpErr *federation.HTTPError
if errors.As(err, &httpErr) {
status = httpErr.Status
}
return map[string]any{"status": status, "message": err.Error()}
}
// --- reading keys ---------------------------------------------------------------
// publishedDeviceKeys reads the keys of a user's devices with every stored
// signature merged in. For a local user only devices that still exist are
// returned, and names maps them to their display names when the caller
// wants those.
func (k *API) publishedDeviceKeys(ctx context.Context, userID string, deviceIDs []string) (map[string]json.RawMessage, error) {
return k.deviceKeys(ctx, userID, deviceIDs, false)
}
func (k *API) deviceKeys(ctx context.Context, userID string, deviceIDs []string, withNames bool) (map[string]json.RawMessage, error) {
stored, err := k.store.DeviceKeys(ctx, userID, deviceIDs)
if err != nil {
return nil, err
}
out := map[string]json.RawMessage{}
if len(stored) == 0 {
return out, nil
}
names := map[string]string{}
if localpart, ok := k.localpartOf(userID); ok {
devices, err := k.devices.Devices(ctx, localpart)
if err != nil {
return nil, err
}
for _, d := range devices {
names[d.ID] = d.DisplayName
}
}
sigs, err := k.store.Signatures(ctx, userID)
if err != nil {
return nil, err
}
for _, d := range stored {
name, exists := names[d.DeviceID]
if k.isLocal(userID) && !exists {
continue
}
var mine []storage.KeySignature
for _, sig := range sigs {
if sig.TargetKeyID == d.DeviceID {
mine = append(mine, sig)
}
}
if !withNames {
name = ""
}
merged, err := withSignatures(d.JSON, mine, name)
if err != nil {
return nil, err
}
out[d.DeviceID] = merged
}
return out, nil
}
// crossSigningKeys reads a user's cross-signing keys as requester is
// allowed to see them.
//
// The master key carries the signatures of the user's own devices, which
// everybody may see, and the signature of requester's user-signing key if
// they made one: who a user has verified is between them and their own
// devices. The user-signing key is only ever shown to its owner.
func (k *API) crossSigningKeys(ctx context.Context, userID, requester string) (master, selfSigning, userSigning json.RawMessage, err error) {
keys, err := k.store.CrossSigningKeys(ctx, userID)
if err != nil || len(keys) == 0 {
return nil, nil, nil, err
}
sigs, err := k.store.Signatures(ctx, userID)
if err != nil {
return nil, nil, nil, err
}
read := func(purpose string) (json.RawMessage, error) {
raw, ok := keys[purpose]
if !ok {
return nil, nil
}
var key crossSigningKeyObject
if err := json.Unmarshal(raw, &key); err != nil {
return nil, err
}
pub, _ := key.ed25519Key()
var visible []storage.KeySignature
for _, sig := range sigs {
if sig.TargetKeyID != pub {
continue
}
if sig.SignerUserID == userID || (requester != "" && sig.SignerUserID == requester) {
visible = append(visible, sig)
}
}
return withSignatures(raw, visible, "")
}
if master, err = read(storage.CrossSigningMaster); err != nil {
return nil, nil, nil, err
}
if selfSigning, err = read(storage.CrossSigningSelfSigning); err != nil {
return nil, nil, nil, err
}
if requester == userID {
if userSigning, err = read(storage.CrossSigningUserSigning); err != nil {
return nil, nil, nil, err
}
}
return master, selfSigning, userSigning, nil
}
// --- /keys/query ----------------------------------------------------------------
// QueryKeys answers POST /keys/query for requester: local users from the
// store, remote ones by asking their servers, all at once.
func (k *API) QueryKeys(ctx context.Context, requester string, req QueryRequest) (QueryResponse, error) {
resp := QueryResponse{
DeviceKeys: map[string]map[string]json.RawMessage{},
Failures: map[string]any{},
MasterKeys: map[string]json.RawMessage{},
SelfSigningKeys: map[string]json.RawMessage{},
UserSigningKeys: map[string]json.RawMessage{},
}
remote := map[string]map[string][]string{}
for userID, deviceIDs := range req.DeviceKeys {
server, err := id.ServerNameOf(userID)
if err != nil {
continue
}
if server != k.serverName {
if remote[server] == nil {
remote[server] = map[string][]string{}
}
remote[server][userID] = deviceIDs
continue
}
devices, err := k.deviceKeys(ctx, userID, deviceIDs, true)
if err != nil {
return QueryResponse{}, err
}
resp.DeviceKeys[userID] = devices
master, selfSigning, userSigning, err := k.crossSigningKeys(ctx, userID, requester)
if err != nil {
return QueryResponse{}, err
}
setIfPresent(resp.MasterKeys, userID, master)
setIfPresent(resp.SelfSigningKeys, userID, selfSigning)
setIfPresent(resp.UserSigningKeys, userID, userSigning)
}
k.queryRemote(ctx, requester, remote, remoteTimeout(req.Timeout), &resp)
return resp, nil
}
// queryRemote asks each server for its users' keys in parallel and merges
// the answers in. A server that does not answer in time is a failure, not
// an error: the keys of everybody else are still worth returning.
func (k *API) queryRemote(ctx context.Context, requester string, byServer map[string]map[string][]string, timeout time.Duration, resp *QueryResponse) {
if len(byServer) == 0 {
return
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var (
mu sync.Mutex
wg sync.WaitGroup
)
for server, users := range byServer {
if k.remote == nil {
resp.Failures[server] = remoteFailure(errors.New("federation is not available on this server"))
continue
}
wg.Go(func() {
answer, err := k.remote.QueryUserKeys(ctx, server, federation.UserKeysQueryRequest{DeviceKeys: users})
mu.Lock()
defer mu.Unlock()
if err != nil {
resp.Failures[server] = remoteFailure(err)
return
}
for userID, devices := range answer.DeviceKeys {
// A server answers for its own users and nobody else's.
if _, asked := users[userID]; asked && devices != nil {
resp.DeviceKeys[userID] = devices
}
}
for userID, master := range answer.MasterKeys {
if _, asked := users[userID]; asked {
resp.MasterKeys[userID] = k.withRequesterSignature(ctx, userID, requester, master)
}
}
for userID, selfSigning := range answer.SelfSigningKeys {
if _, asked := users[userID]; asked {
resp.SelfSigningKeys[userID] = selfSigning
}
}
})
}
wg.Wait()
}
// withRequesterSignature adds requester's own signature of a remote user's
// master key, which is stored here and nowhere else.
func (k *API) withRequesterSignature(ctx context.Context, userID, requester string, master json.RawMessage) json.RawMessage {
if requester == "" {
return master
}
sigs, err := k.store.Signatures(ctx, userID)
if err != nil || len(sigs) == 0 {
return master
}
var key crossSigningKeyObject
if json.Unmarshal(master, &key) != nil {
return master
}
pub, _ := key.ed25519Key()
var mine []storage.KeySignature
for _, sig := range sigs {
if sig.TargetKeyID == pub && sig.SignerUserID == requester {
mine = append(mine, sig)
}
}
merged, err := withSignatures(master, mine, "")
if err != nil {
return master
}
return merged
}
func setIfPresent(m map[string]json.RawMessage, key string, value json.RawMessage) {
if len(value) > 0 {
m[key] = value
}
}
// QueryLocalKeys answers another server's POST /user/keys/query: the keys
// of this server's users, without device display names, which Warp keeps
// to itself.
func (k *API) QueryLocalKeys(ctx context.Context, req federation.UserKeysQueryRequest) (federation.UserKeysQueryResponse, error) {
resp := federation.UserKeysQueryResponse{
DeviceKeys: map[string]map[string]json.RawMessage{},
MasterKeys: map[string]json.RawMessage{},
SelfSigningKeys: map[string]json.RawMessage{},
}
for userID, deviceIDs := range req.DeviceKeys {
if !k.isLocal(userID) {
continue
}
devices, err := k.publishedDeviceKeys(ctx, userID, deviceIDs)
if err != nil {
return federation.UserKeysQueryResponse{}, err
}
resp.DeviceKeys[userID] = devices
master, selfSigning, _, err := k.crossSigningKeys(ctx, userID, "")
if err != nil {
return federation.UserKeysQueryResponse{}, err
}
setIfPresent(resp.MasterKeys, userID, master)
setIfPresent(resp.SelfSigningKeys, userID, selfSigning)
}
return resp, nil
}
// UserDevices answers another server's GET /user/devices/{userId}: a local
// user's whole device list, and its position in the device list stream,
// which is the stream ID the server's later updates count from.
func (k *API) UserDevices(ctx context.Context, userID string) (federation.UserDevicesResponse, error) {
resp := federation.UserDevicesResponse{UserID: userID, Devices: []federation.UserDevice{}}
if !k.isLocal(userID) {
return resp, invalidParamf("%s is not a user of this server", userID)
}
pos, err := k.store.DeviceListPosition(ctx, userID)
if err != nil {
return resp, err
}
resp.StreamID = pos
devices, err := k.publishedDeviceKeys(ctx, userID, nil)
if err != nil {
return resp, err
}
for _, deviceID := range sortedKeys(devices) {
resp.Devices = append(resp.Devices, federation.UserDevice{DeviceID: deviceID, Keys: devices[deviceID]})
}
resp.MasterKey, resp.SelfSigningKey, _, err = k.crossSigningKeys(ctx, userID, "")
return resp, err
}
// --- /keys/claim ----------------------------------------------------------------
// ClaimKeys answers POST /keys/claim.
func (k *API) ClaimKeys(ctx context.Context, req ClaimRequest) (ClaimResponse, error) {
resp := ClaimResponse{
OneTimeKeys: map[string]map[string]map[string]json.RawMessage{},
Failures: map[string]any{},
}
local := map[string]map[string]string{}
remote := map[string]map[string]map[string]string{}
for userID, devices := range req.OneTimeKeys {
server, err := id.ServerNameOf(userID)
switch {
case err != nil:
continue
case server == k.serverName:
local[userID] = devices
default:
if remote[server] == nil {
remote[server] = map[string]map[string]string{}
}
remote[server][userID] = devices
}
}
claimed, err := k.ClaimLocalKeys(ctx, local)
if err != nil {
return ClaimResponse{}, err
}
for userID, devices := range claimed {
resp.OneTimeKeys[userID] = devices
}
k.claimRemote(ctx, remote, remoteTimeout(req.Timeout), &resp)
return resp, nil
}
// ClaimLocalKeys claims keys of this server's users' devices, for a local
// client or another server. A device with no key of the algorithm asked
// for is left out, which is how the spec says "none left".
func (k *API) ClaimLocalKeys(ctx context.Context, want map[string]map[string]string) (map[string]map[string]map[string]json.RawMessage, error) {
out := map[string]map[string]map[string]json.RawMessage{}
for userID, devices := range want {
if !k.isLocal(userID) {
continue
}
for deviceID, algorithm := range devices {
key, ok, err := k.store.ClaimKey(ctx, userID, deviceID, algorithm)
if err != nil {
return nil, err
}
if !ok {
continue
}
if out[userID] == nil {
out[userID] = map[string]map[string]json.RawMessage{}
}
out[userID][deviceID] = map[string]json.RawMessage{key.KeyID: key.JSON}
}
}
return out, nil
}
// claimRemote claims keys from other servers in parallel.
func (k *API) claimRemote(ctx context.Context, byServer map[string]map[string]map[string]string, timeout time.Duration, resp *ClaimResponse) {
if len(byServer) == 0 {
return
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var (
mu sync.Mutex
wg sync.WaitGroup
)
for server, users := range byServer {
if k.remote == nil {
resp.Failures[server] = remoteFailure(errors.New("federation is not available on this server"))
continue
}
wg.Go(func() {
answer, err := k.remote.ClaimUserKeys(ctx, server, federation.UserKeysClaimRequest{OneTimeKeys: users})
mu.Lock()
defer mu.Unlock()
if err != nil {
resp.Failures[server] = remoteFailure(err)
return
}
for userID, devices := range answer.OneTimeKeys {
if _, asked := users[userID]; asked {
resp.OneTimeKeys[userID] = devices
}
}
})
}
wg.Wait()
}
// sortedKeys is a map's keys in order.
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for key := range m {
out = append(out, key)
}
slices.Sort(out)
return out
}