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.
- 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_pubcarries 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 verifiesspk_sigandpq_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 whoseik_pubis 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. Usecryptographyorpinenaclfor Ed25519/X25519. Do not use pure-Dart ML-KEM implementations such askyber-pyormlkem— they are explicitly educational and not side-channel safe.
- 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.
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.
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.
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.
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.
- 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.
- 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.
| 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 |
- 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].
- 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.
GET /me/envelopesreturnspruned_through. If the client's last acked seq is belowpruned_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.
- Rotating the signed prekey (
spk) changes a signed field in the device bundle. The client must supply a freshcross_sigand an incrementedbundle_versionin the samePUT /me/devices/{id}/prekeyscall, or peers will correctly reject the device.
- 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.
- Pad every ciphertext to the exact bucket lengths defined in
core/buckets.pybefore upload. A non-bucket length is rejected with400 bad_bucket.
- No foreign push (FCM/APNs) is available. Background polling only.
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:
POST /auth/login→ register-scope token (10 min; its only power is step 2).POST /me/devices, omittingcross_sig/bundle_version→201with the assigneddevice_idand a full-scope token pair.PUT /me/identity— publish the cross-signing identity. Required before any later device can register, so do not defer it.PUT /me/devices/{device_id}/prekeyswithcross_sig(over the bundle for thedevice_idfrom step 2) +bundle_version: 1.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.POST /me/devicelog— the first signed log record.
Every later device:
POST /auth/login→ register-scope token.POST /me/devices, omittingcross_sig/bundle_version→201,device_id, full-scope tokens. The account's identity must already be published or this is400 {"code":"identity_required"}.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.PUT /me/devices/{device_id}/prekeyswithcross_sig+bundle_version.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.