Skip to content

Latest commit

 

History

History
231 lines (181 loc) · 11.8 KB

File metadata and controls

231 lines (181 loc) · 11.8 KB

CLIENT_CONTRACT.md — What the Flutter client must implement

This backend is a blind relay: it stores and serves key material and ciphertext as opaque bytes and verifies none of it. Every security property below therefore lives in the client. This document is the binding list of those client-side halves, written for the Flutter developer. The server-side halves are specified in ARCHITECTURE.md and the per-app API.md files; the residual-risk statement (what none of this protects) is ARCHITECTURE.md §A16.

A server-side sanity check (length, bucket, monotonic version) is never a security control. If the client ever skips a verification because "the server already checks that," the design has failed — the server is the adversary.

A. Key generation and storage

  • On first install: generate master, self-signing, and user-signing Ed25519 keys. Private keys go to platform secure storage (Keychain/Keystore) and never leave the device except inside the recovery-protected key backup blob (PUT /me/keybackup).
  • Per device: an Ed25519 device signing key, X25519 identity + prekeys, ML-KEM-768 prekeys, and the MLS credential/leaf key.
  • ik_pub carries two of those keys in one field: exactly 64 bytes, the Ed25519 device signing public key (bytes 0–31) then the X25519 identity public key (bytes 32–63). The Ed25519 half verifies spk_sig and pq_spk_sig; the X25519 half is the identity key in X3DH/PQXDH. The server treats the field as opaque bytes and enforces only a loose length range, so a peer whose ik_pub is not 64 bytes is malformed to you — reject it; nothing upstream will.
  • Libraries: use mlkem_native (FFI to the formally-verified pq-code-package mlkem-native, FIPS 203) for ML-KEM. Use cryptography or pinenacl for Ed25519/X25519. Do not use pure-Dart ML-KEM implementations such as kyber-py or mlkem — they are explicitly educational and not side-channel safe.

B. What the client signs

  • The self-signing key signs the canonical device-bundle encoding (below).
  • The master key signs the self-signing and user-signing public keys.
  • The user-signing key signs another user's master key after out-of-band verification.
  • Device-log records are hash-chained and signed by the self-signing key.

The one encoding rule

Every signature below covers an ASCII domain separator, followed by the length-prefixed concatenation of its fields: a 4-byte big-endian length before each field. The domain separator itself is not length-prefixed.

An absent optional field is still emitted — a 4-byte zero length with no content. Never skip it. Skipping would let a bundle with PQ material and one without collide onto the same signed bytes, so one cross_sig would verify for both.

Golden vectors: devices/vectors/vectors.json. Reproduce every signed_bytes_hex byte for byte and verify every signature_hex before shipping. Do not treat the tables below as sufficient on their own — a client that agrees with the prose but not with the vectors cannot talk to the other platforms, and the symptom is silently unverifiable devices.

Canonical device-bundle encoding — cross_sig

Signed by the self-signing key. Domain separator chat:v1:device-bundle, then:

# Field Encoding
1 user_id 16 bytes, UUID raw
2 device_id 16 bytes, UUID raw
3 ik_pub raw bytes (the 64-byte pair, §A)
4 spk_id 4 bytes, big-endian
5 spk_pub raw bytes
6 pq_spk_id 4 bytes, big-endian; zero-length field if absent
7 pq_spk_pub raw bytes; zero-length field if absent
8 registration_id 4 bytes, big-endian
9 bundle_version 4 bytes, big-endian

The encoding is deliberately self-contained: every signed key appears as its bytes, never as an identifier to be resolved later.

Canonical identity encoding — master_sig

Signed by the master key. Domain separator chat:v1:cross-signing-keys, then:

# Field Encoding
1 user_id 16 bytes, UUID raw
2 self_signing_pub raw bytes
3 user_signing_pub raw bytes

version is not covered. It is the server's anti-accident monotonic check, and signing it would imply the served version number carries a guarantee it does not — you must detect identity changes by comparing master_pub, never by trusting version.

Canonical prekey encodings — spk_sig and pq_spk_sig

Both signed by the Ed25519 half of ik_pub (bytes 0–31).

Signature Domain separator Fields, in order
spk_sig chat:v1:signed-prekey user_id (16 B) · spk_id (4 B BE) · spk_pub (raw)
pq_spk_sig chat:v1:pq-signed-prekey user_id (16 B) · pq_spk_id (4 B BE) · pq_spk_pub (raw)

The separate domains are what stop either signature being replayed as the other. Neither covers device_id, deliberately: device_id does not exist until registration succeeds and spk_sig is required at registration, so covering it would make the first registration impossible. Nothing is lost — the device bundle above already binds spk_pub and pq_spk_pub to a device_id under cross_sig, so that is the signature you rely on to know a prekey belongs to the device serving it. Verify both.

C. What the client verifies

  • Before encrypting to, or accepting a message from, any peer device: verify the device bundle signature chains self-signing → master, and that the master key equals the one confirmed out-of-band. Reject otherwise.
  • Verify the key bytes, never a server-supplied identifier. CVE-2022-39250 (matrix-js-sdk cross-signing identity injection, CVSS 8.6) happened because checking and signing were two separate steps, letting a malicious homeserver substitute the key in between.
  • On device-list fetch: verify the log head signature and that it extends the last-seen head. A fork is proof of server equivocation.

D. Verification UX

  • SAS: display a short emoji/number string derived from both parties' master keys; both users confirm out-of-band.
  • QR: encode the master key fingerprint; scanning cross-signs.
  • This is the only defense against first-contact MITM. It must be prominent, not buried.
  • Safety-number-style change warning: if a contact's master key or a device's cross-signature changes, block sending and require re-verification.

E. Behavior on verification failure

Condition Required behavior
Unsigned / invalidly-signed device Refuse to encrypt to it; show "unverified device — messages withheld"
Master key change Block the conversation pending re-verification. Never silently re-trust.
Log fork detected Global alert state; halt sensitive operations pending an out-of-band check

F. Post-quantum

  • Always use hybrid X25519 + ML-KEM-768 (PQXDH-style) for DM session establishment. If a claimed bundle lacks PQ material, either refuse or clearly flag the session as classical-only. Never silently downgrade.
  • Use an MLS PQ ciphersuite for groups. Pad KeyPackages to the current KEYPACKAGE_BUCKETS [4096, 16384].

G. History sync — changed; the server no longer stores history

  • On new-device enrollment, transfer history client-to-client, encrypted and authorized by cross-signing, over the ordinary envelope endpoint. There is no server history API.
  • A new device has no history until an existing device is online to send it.
  • The key backup blob no longer contains a history key; it carries cross-signing private key material.

H. Queue gap handling — new, load-bearing for MLS

  • GET /me/envelopes returns pruned_through. If the client's last acked seq is below pruned_through, envelopes were lost to the 7-day cap.
  • Lost envelopes may have included MLS commits. The device is then permanently desynced from affected groups and cannot self-recover — client-to-client transfer moves content, not ratchet/epoch state. It must signal peers to remove and re-add it to each group (generating a fresh Welcome).
  • Surface this to the user as a recoverable state, not a silent failure.

I. Cross-signature freshness

  • Rotating the signed prekey (spk) changes a signed field in the device bundle. The client must supply a fresh cross_sig and an incremented bundle_version in the same PUT /me/devices/{id}/prekeys call, or peers will correctly reject the device.

J. Device-list log maintenance

  • Append a signed record on every device-set change (add, remove, revoke) and on identity rotation.
  • Piggyback the latest known heads of contacts on ordinary E2EE messages; on receipt, compare against the local view and raise the fork alarm on mismatch.

K. Padding

  • Pad every ciphertext to the exact bucket lengths defined in core/buckets.py before upload. A non-bucket length is rejected with 400 bad_bucket.

L. Polling

  • No foreign push (FCM/APNs) is available. Background polling only.

M. Enrollment ordering (load-bearing — read before implementing registration)

cross_sig covers device_id, and device_id does not exist until registration succeeds. No first call can carry a valid cross-signature, so registration refuses cross_sig/bundle_version outright (400) rather than storing bytes that could only be wrong, and the device is stored uncross-signed until you supply one. Both flows below end with the same PUT /me/devices/{id}/prekeys call; until it lands, peers see cross_sig: null and correctly withhold messages.

Do not work around this by sending a placeholder. A stored-then-corrected cross_sig is a cross-signature change to any peer that polled in between, and §D requires them to block the conversation and demand re-verification over it. Null is the state that means "not yet"; there is no signature-shaped value that means the same.

First device on a new account:

  1. POST /auth/login → register-scope token (10 min; its only power is step 2).
  2. POST /me/devices, omitting cross_sig/bundle_version201 with the assigned device_id and a full-scope token pair.
  3. PUT /me/identity — publish the cross-signing identity. Required before any later device can register, so do not defer it.
  4. PUT /me/devices/{device_id}/prekeys with cross_sig (over the bundle for the device_id from step 2) + bundle_version: 1.
  5. PUT /me/keybackup — the recovery-protected blob carrying the cross-signing private keys. Skipping this strands every future device: step 3 of the flow below has no other source for the self-signing key.
  6. POST /me/devicelog — the first signed log record.

Every later device:

  1. POST /auth/login → register-scope token.
  2. POST /me/devices, omitting cross_sig/bundle_version201, device_id, full-scope tokens. The account's identity must already be published or this is 400 {"code":"identity_required"}.
  3. GET /me/keybackup (needs the full scope from step 2) → unwrap with the user's recovery secret → the account's self-signing private key. This is the only path to it; a device that cannot unwrap the backup can never be cross-signed, and the user must verify it out-of-band from an existing device instead.
  4. PUT /me/devices/{device_id}/prekeys with cross_sig + bundle_version.
  5. POST /me/devicelog — append the device-set change.

In the prekeys call, sending only one of cross_sig/bundle_version is 400: the version is what tells peers which bundle the signature covers, so half a pair is unusable. That, and the identity_required check, are completeness checks — not security controls. A modified server would skip them, so peers must verify regardless, and a device that never reaches step 4 must stay unverified in your UI forever rather than being trusted on the server's word.