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

161 lines
4.7 KiB
Go

package keyserver
import (
"crypto/ed25519"
"encoding/json"
"fmt"
"strings"
"github.com/dariuszjaniak/warp/server/internal/matrix/canonicaljson"
"github.com/dariuszjaniak/warp/server/internal/matrix/signing"
"github.com/dariuszjaniak/warp/server/internal/storage"
)
// The shapes of the keys a client uploads, as far as the server reads
// them. Everything else in a key is kept as it was sent: a key is a signed
// object, and the server re-encoding a field it did not understand would
// break the signature.
// deviceKeysObject is a device's identity keys.
type deviceKeysObject struct {
UserID string `json:"user_id"`
DeviceID string `json:"device_id"`
Algorithms []string `json:"algorithms"`
Keys map[string]string `json:"keys"`
}
// crossSigningKeyObject is a cross-signing key.
type crossSigningKeyObject struct {
UserID string `json:"user_id"`
Usage []string `json:"usage"`
Keys map[string]string `json:"keys"`
}
// canonical re-encodes a key in canonical JSON, which is what the store
// compares when it decides whether an upload is the same key again.
func canonical(raw json.RawMessage) ([]byte, error) {
out, err := canonicaljson.Encode(raw)
if err != nil {
return nil, invalidParamf("a key is not valid JSON: %v", err)
}
return out, nil
}
// object decodes a JSON object into its fields.
func object(raw []byte) (map[string]json.RawMessage, error) {
var obj map[string]json.RawMessage
if err := json.Unmarshal(raw, &obj); err != nil || obj == nil {
return nil, invalidParamf("a key must be a JSON object")
}
return obj, nil
}
// withoutField drops one field from a JSON object, in canonical form.
func withoutField(raw []byte, field string) ([]byte, error) {
obj, err := object(raw)
if err != nil {
return nil, err
}
delete(obj, field)
return canonicaljson.Marshal(obj)
}
// signaturesOf reads a key's signatures block.
func signaturesOf(raw []byte) (signing.Signatures, error) {
sigs, err := signing.ExtractSignatures(raw)
if err != nil {
return nil, invalidParamf("a key's signatures are malformed: %v", err)
}
return sigs, nil
}
// withSignatures merges stored signatures into a key, and optionally sets
// its unsigned.device_display_name. Signatures the key already carries are
// kept; a stored one under the same signer and key ID wins, since it is
// the more recent.
func withSignatures(raw []byte, sigs []storage.KeySignature, displayName string) (json.RawMessage, error) {
if len(sigs) == 0 && displayName == "" {
return raw, nil
}
obj, err := object(raw)
if err != nil {
return nil, err
}
if len(sigs) > 0 {
merged, err := signaturesOf(raw)
if err != nil {
return nil, err
}
if merged == nil {
merged = signing.Signatures{}
}
for _, sig := range sigs {
if merged[sig.SignerUserID] == nil {
merged[sig.SignerUserID] = map[string]string{}
}
merged[sig.SignerUserID][sig.SignerKeyID] = sig.Signature
}
if obj["signatures"], err = json.Marshal(merged); err != nil {
return nil, err
}
}
if displayName != "" {
unsigned := map[string]json.RawMessage{}
if existing, ok := obj["unsigned"]; ok {
_ = json.Unmarshal(existing, &unsigned)
}
if unsigned["device_display_name"], err = json.Marshal(displayName); err != nil {
return nil, err
}
if obj["unsigned"], err = json.Marshal(unsigned); err != nil {
return nil, err
}
}
return canonicaljson.Marshal(obj)
}
// ed25519Key returns the one ed25519 key of a cross-signing key, by its
// unpadded base64 public key, which is also its key ID.
func (o crossSigningKeyObject) ed25519Key() (string, bool) {
if len(o.Keys) != 1 {
return "", false
}
for keyID, value := range o.Keys {
algorithm, name, ok := signing.SplitKeyID(keyID)
if !ok || algorithm != "ed25519" || name != value {
return "", false
}
return value, true
}
return "", false
}
// publicKey decodes an unpadded base64 ed25519 public key.
func publicKey(encoded string) (ed25519.PublicKey, error) {
raw, err := signing.DecodeBase64(encoded)
if err != nil || len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("%q is not an ed25519 public key", encoded)
}
return ed25519.PublicKey(raw), nil
}
// verify checks that a key carries a valid signature by userID's key
// "ed25519:"+keyName, whose public key is pub.
func verify(raw []byte, userID, keyName, pub string) error {
key, err := publicKey(pub)
if err != nil {
return invalidSignaturef("%v", err)
}
if err := signing.VerifyJSON(userID, "ed25519:"+keyName, key, raw); err != nil {
return invalidSignaturef("%v", err)
}
return nil
}
// validKeyID reports whether a one-time key ID has the "algorithm:id"
// shape.
func validKeyID(keyID string) bool {
algorithm, name, ok := strings.Cut(keyID, ":")
return ok && algorithm != "" && name != ""
}