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>
466 lines
14 KiB
Go
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
|
|
}
|