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

205 lines
6.9 KiB
Go

package keyserver
import (
"context"
"encoding/json"
"errors"
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// Server-side key backup: a user's room keys, encrypted to a backup key
// only the user holds, so that a new device can read history the user's
// other devices received.
//
// The server stores what it is given and decides one thing: when two
// copies of the same session's key arrive, which to keep. Everything else
// about a backup — what the algorithm is, what the auth data says, whether
// the backup key is trusted — is the clients' business.
// BackupInfo is a backup version as GET /room_keys/version reports it.
type BackupInfo struct {
Algorithm string `json:"algorithm"`
AuthData json.RawMessage `json:"auth_data"`
Count int `json:"count"`
ETag string `json:"etag"`
Version string `json:"version"`
}
// KeyBackupData is one backed-up session key on the wire.
type KeyBackupData struct {
FirstMessageIndex int64 `json:"first_message_index"`
ForwardedCount int64 `json:"forwarded_count"`
IsVerified bool `json:"is_verified"`
SessionData json.RawMessage `json:"session_data"`
}
// RoomKeyBackup is one room's keys: session ID to key.
type RoomKeyBackup struct {
Sessions map[string]KeyBackupData `json:"sessions"`
}
// BackupKeysResult is what a write or delete of keys answers with.
type BackupKeysResult struct {
Count int `json:"count"`
ETag string `json:"etag"`
}
func infoOf(v storage.BackupVersion) BackupInfo {
return BackupInfo{
Algorithm: v.Algorithm,
AuthData: v.AuthData,
Count: v.Count,
ETag: v.ETag,
Version: v.Version,
}
}
// authDataOf checks auth_data is an object and canonicalises it.
func authDataOf(raw json.RawMessage) ([]byte, error) {
if len(raw) == 0 {
return nil, missingParamf("auth_data is required")
}
if _, err := object(raw); err != nil {
return nil, invalidParamf("auth_data must be an object")
}
out, err := canonicaljson.Encode(raw)
if err != nil {
return nil, invalidParamf("auth_data is not valid JSON: %v", err)
}
return out, nil
}
// CreateBackup creates a new backup version, which becomes the current
// one.
func (k *API) CreateBackup(ctx context.Context, userID, algorithm string, authData json.RawMessage) (string, error) {
if algorithm == "" {
return "", missingParamf("algorithm is required")
}
encoded, err := authDataOf(authData)
if err != nil {
return "", err
}
return k.store.CreateBackup(ctx, userID, algorithm, encoded)
}
// Backup reads a backup version, or the current one when version is empty.
func (k *API) Backup(ctx context.Context, userID, version string) (BackupInfo, error) {
v, err := k.store.Backup(ctx, userID, version)
if err != nil {
return BackupInfo{}, err
}
return infoOf(v), nil
}
// UpdateBackup replaces a version's auth data. The algorithm cannot
// change — every key in the version is encrypted with it — and a version
// in the body has to be the one in the path.
func (k *API) UpdateBackup(ctx context.Context, userID, version, algorithm, bodyVersion string, authData json.RawMessage) error {
if bodyVersion != "" && bodyVersion != version {
return invalidParamf("the version in the body, %s, is not the one in the path, %s", bodyVersion, version)
}
existing, err := k.store.Backup(ctx, userID, version)
if err != nil {
return err
}
if algorithm != existing.Algorithm {
return invalidParamf("a backup version's algorithm cannot change from %s", existing.Algorithm)
}
encoded, err := authDataOf(authData)
if err != nil {
return err
}
return k.store.UpdateBackup(ctx, userID, version, encoded)
}
// DeleteBackup deletes a backup version and its keys.
func (k *API) DeleteBackup(ctx context.Context, userID, version string) error {
return k.store.DeleteBackup(ctx, userID, version)
}
// PutBackupKeys stores keys in a version, which has to be the current one:
// a client writing into an old version after another device replaced it
// would be backing up keys nobody can restore from.
func (k *API) PutBackupKeys(ctx context.Context, userID, version string, rooms map[string]RoomKeyBackup) (BackupKeysResult, error) {
if version == "" {
return BackupKeysResult{}, missingParamf("the version parameter is required")
}
current, err := k.store.Backup(ctx, userID, "")
switch {
case errors.Is(err, storage.ErrNoSuchBackup):
return BackupKeysResult{}, ErrNoSuchBackup
case err != nil:
return BackupKeysResult{}, err
case current.Version != version:
if _, err := k.store.Backup(ctx, userID, version); errors.Is(err, storage.ErrNoSuchBackup) {
return BackupKeysResult{}, ErrNoSuchBackup
}
return BackupKeysResult{}, &WrongBackupVersionError{Current: current.Version}
}
var keys []storage.BackupKey
for roomID, room := range rooms {
for sessionID, data := range room.Sessions {
if len(data.SessionData) == 0 {
return BackupKeysResult{}, missingParamf("session_data is required for %s in %s", sessionID, roomID)
}
sessionData, err := canonicaljson.Encode(data.SessionData)
if err != nil {
return BackupKeysResult{}, invalidParamf("session_data of %s is not valid JSON: %v", sessionID, err)
}
keys = append(keys, storage.BackupKey{
RoomID: roomID, SessionID: sessionID,
FirstMessageIndex: data.FirstMessageIndex,
ForwardedCount: data.ForwardedCount,
IsVerified: data.IsVerified,
SessionData: sessionData,
})
}
}
v, err := k.store.PutBackupKeys(ctx, userID, version, keys)
if err != nil {
return BackupKeysResult{}, err
}
return BackupKeysResult{Count: v.Count, ETag: v.ETag}, nil
}
// BackupKeys reads keys of a version, grouped by room.
func (k *API) BackupKeys(ctx context.Context, userID, version, roomID, sessionID string) (map[string]RoomKeyBackup, error) {
if version == "" {
return nil, missingParamf("the version parameter is required")
}
keys, err := k.store.BackupKeys(ctx, userID, version, roomID, sessionID)
if err != nil {
return nil, err
}
out := map[string]RoomKeyBackup{}
for _, key := range keys {
room, ok := out[key.RoomID]
if !ok {
room = RoomKeyBackup{Sessions: map[string]KeyBackupData{}}
out[key.RoomID] = room
}
room.Sessions[key.SessionID] = KeyBackupData{
FirstMessageIndex: key.FirstMessageIndex,
ForwardedCount: key.ForwardedCount,
IsVerified: key.IsVerified,
SessionData: key.SessionData,
}
}
return out, nil
}
// DeleteBackupKeys deletes keys from a version.
func (k *API) DeleteBackupKeys(ctx context.Context, userID, version, roomID, sessionID string) (BackupKeysResult, error) {
if version == "" {
return BackupKeysResult{}, missingParamf("the version parameter is required")
}
v, err := k.store.DeleteBackupKeys(ctx, userID, version, roomID, sessionID)
if err != nil {
return BackupKeysResult{}, err
}
return BackupKeysResult{Count: v.Count, ETag: v.ETag}, nil
}