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>
161 lines
4.7 KiB
Go
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 != ""
|
|
}
|