Open Free and open source — read the code

Flamenet Messenger — End-to-End Encryption Spec (v1)

Status: DRAFT / Phase 1. This document is the canonical, cross-platform contract. iOS (CryptoKit), Android (Kotlin + BouncyCastle), and Web (WebCrypto; plus a vendored ML-KEM fallback, §12.2) MUST all implement exactly what is written here, or they will not interoperate. Whether they actually do is §10 — which is a record of runs, not of intent.

The server (flamenet-messenger) is a dumb relay + public-key directory. It stores public keys and opaque ciphertext envelopes only. It never sees plaintext or any private key. There is no key escrow.


1. Design summary

  • Protocol: Signal-style X3DH (asynchronous key agreement) + Double Ratchet (per-message forward secrecy + post-compromise security).
  • Identity: per device, not per user. A user may register multiple devices (e.g. iPhone + web). Each device is its own ratchet endpoint. A sender encrypts a message once per recipient device.
  • History: none synced. A new device / reinstall = new identity; it cannot read messages sent before it registered. Losing the device key loses the history. (By design.)
  • Verification: safety numbers from day one (§7). Clients SHOULD show a key-change warning when a peer device's identity key changes.

Choice note

Wire AEAD is AES-256-GCM, not ChaCha20-Poly1305, so the browser (WebCrypto) can participate natively. If web support is ever dropped, ChaChaPoly may be substituted by bumping the protocol version.


2. Cryptographic primitives

Purpose Algorithm Notes
DH key agreement X25519 CryptoKit Curve25519.KeyAgreement; WebCrypto X25519; BouncyCastle X25519Agreement
Signatures Ed25519 CryptoKit Curve25519.Signing; WebCrypto Ed25519; BouncyCastle Ed25519Signer
KDF HKDF-SHA256 RFC 5869
AEAD AES-256-GCM 12-byte nonce, 16-byte tag
Hash (safety #) SHA-512
KEM (v2 only) ML-KEM-768 FIPS 203 — see §12.2

All public keys, signatures, and ciphertext blobs are transported as standard base64 (with padding) inside JSON.

Each device holds two long-term keypairs (this avoids XEdDSA, which WebCrypto/CryptoKit do not expose):

  • IK_dh — X25519, used for X3DH Diffie-Hellman.
  • IK_sig — Ed25519, used to sign prekeys and to compute the safety number. This is the identity that verification is anchored to.

device_id is a client-generated UUIDv4 string (lowercase, hyphenated).


3. Key material per device

Key Type Lifetime Published
IK_dh X25519 long-term public part
IK_sig Ed25519 long-term public part
SPK X25519 rotated (weeks) public + signature
SPK_sig Ed25519 sig Ed25519_sign(IK_sig_priv, SPK_pub)
OPK_i X25519 one-time pool of public parts
PQSPK ML-KEM-768 rotated with SPK v2 only — see §12.3
PQOPK_i ML-KEM-768 one-time v2 only — see §12.3

Private parts never leave the device (iOS Keychain / Android Keystore-wrapped / browser IndexedDB, non-extractable where the platform allows).


4. Registration (publishing the bundle)

On first run a device generates IK_dh, IK_sig, one SPK (+ signature), and an initial pool of one-time prekeys (RECOMMENDED 100), then calls POST /e2e/devices (§8.1).

Replenish the one-time pool via POST /e2e/prekeys (§8.5) whenever the server-reported opk_remaining drops below 20.

SPK rotation and retention

SPK MUST be rotated every 30 days: generate a new one, increment spk_id, re-sign, and re-publish via POST /e2e/devices with the same device_id (upsert). This was a SHOULD with no stated interval, which in practice meant no implementation ever rotated at all.

A rotating device MUST retain each retired SPK private key for 45 days after retirement, and MUST select the private key by the spk_id carried in the prekey envelope (§6.5) rather than always using its current one.

Both halves are required, and omitting either is silent message loss rather than a degraded guarantee. A sender fetches a bundle, encrypts to that spk_id, and the envelope may then sit undelivered for the server's full 30-day retention window (§9). A responder that discarded the key, or that ignored spk_id and used its current key, derives a different SK; the failure surfaces as an AEAD authentication error on the first message, which is indistinguishable from tampering. 45 days is 30 plus margin for a sender that fetched a bundle before going offline.

A spk_id of 0 or absent means the sender did not state one — every envelope produced before rotation existed — and MUST resolve to the current SPK. A spk_id that is stated but unknown MUST be rejected outright (unknownSignedPreKey); it is either a key retired past retention or a fabrication, and guessing produces a garbage shared secret.

Implementation status. Web and iOS both implement rotation, retention and spk_id selection. Android implements none of it, which is safe only for as long as it does not rotate: a responder that rotates without spk_id selection loses in-flight sessions. Ship the selection half before the rotation half there.

The two differ in rotation interval and that is fine — iOS rotates weekly, web every 30 days, and a shorter window is a smaller blast radius for a leaked key. What must NOT differ is retention, which both set to 45 days, because that is the number the server's 30-day undelivered window (§9) constrains. iOS originally retained a single previous key against a weekly rotation, giving it roughly a fortnight of coverage; an envelope queued longer than that became permanently unopenable, and the failure looked like tampering.


5. X3DH — initiating a session

This section describes protocol v1. v2 adds a hybrid ML-KEM-768 secret to the same construction and is specified as a delta in §12. A v2 client runs §5 unchanged when its peer does not advertise v2.

Alice (initiator) wants to message Bob's device D. She has never talked to D before.

  1. Alice fetches Bob/D's bundle: GET /e2e/keys/{bob_user_id} (§8.2). The server pops one one-time prekey per device and returns { ik_dh, ik_sig, spk_id, spk, spk_sig, opk } (opk may be null if the pool is exhausted — X3DH proceeds without it).
  2. Alice MUST verify Ed25519_verify(ik_sig, spk_sig, spk). Abort on failure.
  3. Alice generates an ephemeral X25519 keypair EK.
  4. Compute the four (or three) DHs:
    DH1 = X25519(IK_dh_A_priv,  SPK_B_pub)
    DH2 = X25519(EK_A_priv,     IK_dh_B_pub)
    DH3 = X25519(EK_A_priv,     SPK_B_pub)
    DH4 = X25519(EK_A_priv,     OPK_B_pub)     // omitted if opk == null
  5. SK = HKDF-SHA256(IKM = DH1 || DH2 || DH3 || DH4, salt = 0x00*32, info = "FlamenetE2E_X3DH_v1", L = 32). If opk == null, DH4 is omitted from the concatenation.
  6. The associated data for the session is AD = IK_sig_A_pub || IK_sig_B_pub (raw 32-byte Ed25519 public keys, in that order).
  7. Alice initializes a Double Ratchet (§6) as the sender, with RK = SK and Bob/D's SPK_pub as the initial remote ratchet key (DH_remote = SPK_B_pub).
  8. The first message Alice sends is a type: "prekey" envelope (§6.4) that additionally carries IK_dh_A_pub, IK_sig_A_pub, EK_A_pub, and the spk_id / opk_id she used, so Bob can run the matching X3DH.

Bob, on receiving a prekey envelope, runs the symmetric X3DH with the roles reversed (his private IK_dh, SPK, OPK matching the supplied ids; Alice's supplied publics), derives the same SK and AD, initializes his ratchet as the receiver with DH_remote = EK_A_pub... — see §6.


6. Double Ratchet

Standard Signal Double Ratchet with unencrypted headers (header encryption is out of scope for v1). Reference: Signal "The Double Ratchet Algorithm".

6.1 State (per session = per remote device)

RK            root key (32B)
CKs, CKr      sending / receiving chain keys (32B or null)
DHs           our current ratchet X25519 keypair
DHr           remote current ratchet X25519 public key
Ns, Nr        message numbers in sending / receiving chains
PN            number of messages in the previous sending chain
MKSKIPPED     map {(DHr_pub, N) -> message_key} for out-of-order / skipped messages
              (bounded; drop oldest beyond 2000 to limit memory)

6.2 KDF functions

KDF_RK(rk, dh_out):
    out = HKDF-SHA256(IKM = dh_out, salt = rk, info = "FlamenetE2E_Ratchet_v1", L = 64)
    return (RK' = out[0:32], CK = out[32:64])

KDF_CK(ck):
    MK = HMAC-SHA256(key = ck, msg = 0x01)
    CK' = HMAC-SHA256(key = ck, msg = 0x02)
    return (CK', MK)

6.3 Message key → AEAD parameters

From each 32-byte message key MK:

okm  = HKDF-SHA256(IKM = MK, salt = 0x00*32, info = "FlamenetE2E_MsgKey_v1", L = 44)
AESKey = okm[0:32]      // AES-256
Nonce  = okm[32:44]     // 12-byte GCM nonce

ciphertext = AES-256-GCM(AESKey, Nonce, plaintext, AAD) where AAD = AD || header_bytes (§6.4). The GCM tag is appended to the ciphertext (standard).

6.4 Envelope (the only thing the server stores per message)

JSON object, then base64 of the UTF-8 JSON is what goes in the payload transport field.

{
  "v": 1,
  "type": "prekey" | "msg",
  "header": {
    "dh": "<b64 DHs_pub>",
    "pn": 0,
    "n": 0
  },
  "x3dh": {                     // present iff type == "prekey"
    "ik_dh": "<b64>",
    "ik_sig": "<b64>",
    "ek": "<b64>",
    "spk_id": 7,
    "opk_id": 42                // -1 if no one-time prekey was used
  },
  "ct": "<b64 AES-GCM ciphertext+tag>"
}

A v2 prekey envelope additionally carries a pq object and sets "v": 2 (§12.6). msg envelopes are identical in both versions.

header_bytes used in AAD = the UTF-8 bytes of the canonical compact JSON of the header object exactly as serialized: {"dh":"...","pn":N,"n":N} (keys in this order, no spaces). This binds the ratchet header to the ciphertext.

6.5 Sending / receiving

Follow the canonical Double Ratchet RatchetEncrypt / RatchetDecrypt, including the DH ratchet step when header.dh != DHr, and MKSKIPPED handling for gaps. plaintext is the UTF-8 message body — either plain text, or a content envelope (§6.6) for richer types.

6.6 Content envelope (plaintext layer)

The ratchet is content-agnostic; richer message types live inside the plaintext, so the crypto and the server contract are untouched. Detection rule, applied by receivers after decryption:

If the plaintext begins with the exact bytes {"fnc": and parses as JSON with an integer fnc field, it is a content envelope. Anything else is a plain text message, byte-for-byte (this preserves every message sent before this section existed).

{
  "fnc": 1,                       // content-envelope version
  "kind": "image",                // "image" | "audio" (voice note)
  "caption": "optional text",     // optional; may be absent or empty
  "att": {
    "id":     "<attachment id from POST /e2e/attachments>",
    "key":    "<b64 32-byte AES-256 key>",
    "digest": "<b64 SHA-256 of the uploaded ciphertext blob>",
    "bytes":  123456,             // ciphertext blob length, for progress UI
    "mime":   "image/jpeg",
    "w": 1280, "h": 960,          // pixel dimensions, for layout before download
    "dur": 12.4                   // audio only: duration in seconds; w/h are 0
  }
}

Kinds defined at fnc=1: image (w/h required, dur absent), audio — a voice note (dur required, w/h sent as 0; RECOMMENDED encoding AAC in an MPEG-4 container, mime: "audio/mp4", so every platform's native decoder opens it), and story.

Stories. A story is an image attachment addressed to every buddy: the sender seals and uploads the blob once, then sends one kind: "story" envelope per buddy through that buddy's normal ratchet session. No new server surface exists — the server sees only ordinary sealed envelopes plus one blob, and cannot tell a story from a photo message. A story envelope carries a top-level exp (unix seconds, RECOMMENDED now + 24 h). Receivers MUST NOT render a story into the message thread; it belongs to a separate story strip, MUST be hidden once exp passes, and SHOULD NOT trigger a notification. Expiry is client-enforced and therefore advisory between honest clients — the blob itself dies at the server's retention purge (§9) regardless.

Attachment blob format. The sender generates a fresh random 32-byte key per attachment, seals the media bytes with AES-256-GCM (fresh random 12-byte nonce), and uploads nonce || ciphertext || tag as one opaque blob (CryptoKit's AES.GCM.SealedBox.combined layout). digest is SHA-256 over that entire uploaded blob.

Receiver rules. Download the blob by id, verify digest before decrypting (the server hands you the bytes; the ratchet only authenticated the reference), then open the sealed box with key. A digest or AEAD failure marks the attachment undisplayable; the message (and any caption) still renders.

The key travels only inside the ratchet, so the server relays a blob it cannot read and cannot link to a conversation beyond what envelope metadata already leaks (§11).

An unknown kind MUST render as a placeholder ("unsupported attachment"), not be silently dropped — the sender meant to say something.

6.7 Groups (pairwise fan-out)

Groups add no new cryptography. A group message is the same content envelope, encrypted independently to every member through the sender's existing pairwise ratchet sessions — the model WhatsApp and Signal shipped for small groups before sender keys. Fan-out cost is N encryptions per message, so groups are capped small (§8.9); in exchange, membership changes need no rekey: a removed member simply stops being encrypted to, and a new member reads nothing sent before they joined (no history sync, same as a new device, §1).

Routing rides two additions to the content envelope:

  • a top-level gid (the group's server-issued id): a receiver MUST render the message into that group's thread, keyed by the sender for attribution — never into the 1:1 thread with the sender.
  • kind: "text" — a plain group text message; caption carries the body and att is absent (att is required for every other kind). image/audio envelopes may also carry gid, so group photos and voice notes work unchanged.

Group metadata — name and roster — lives in a server-side registry (§8.9) in plaintext, exactly like the buddy list already does. The server already sees who messages whom (§11); the registry adds the grouping label, not the content. A receiver that gets a gid it does not know refreshes GET /e2e/groups; if the group still is not listed, the message MUST be dropped (the sender's roster was stale — we are not a member).


6.8 Voice calls

Calls use WebRTC for media (DTLS-SRTP encrypts the audio) and the ratchet as the signalling channel — three content-envelope kinds, all with att absent:

{ "fnc": 1, "kind": "call-offer",  "call": { "id": "<uuid>", "sdp": "<offer sdp>" } }
{ "fnc": 1, "kind": "call-answer", "call": { "id": "<uuid>", "sdp": "<answer sdp>" } }
{ "fnc": 1, "kind": "call-end",    "call": { "id": "<uuid>", "reason": "hangup" | "declined" | "busy" | "timeout" } }

Why this is end-to-end. The SDP carries the DTLS certificate fingerprint that the media channel's keys are derived against. Because the SDP travels inside the Double Ratchet, the fingerprints are authenticated end-to-end: the server relays sealed envelopes it cannot read or alter, so it cannot MITM the media keys. This is the same argument Signal makes for its calls. No per-call key agreement beyond DTLS is needed.

ICE. Offers and answers are non-trickle: the caller gathers all candidates (including TURN relay candidates) before sending one complete SDP, because the signalling channel is a polled queue, not a socket. Clients SHOULD poll at ~1 s while a call is being set up (call.id outstanding) and MAY return to their normal cadence once connected or ended.

Ringing. There is no push channel; an incoming call rings only while the recipient's app is polling. The caller SHOULD give up with call-end / timeout after ~45 s. A call-offer for a call already ended, or arriving while another call is active, is answered with call-end / busy. Multi-device: every device of the callee receives the offer; the first call-answer wins and other devices stop ringing when they see the winner's answer come back through their own poll (or the call-end).

TURN. GET /e2e/turn (§8.10) returns time-limited credentials for the coturn relay. Clients put both STUN and TURN entries in their ICE configuration; media flows peer-to-peer when NATs allow and falls back to the relay (which sees only DTLS-SRTP ciphertext).

7. Safety numbers (verification)

For a local identity IK_sig_local_pub and remote IK_sig_remote_pub (raw 32-byte Ed25519):

fingerprint(pub, user_id):
    h = pub
    for i in range(5200):                     // iterated hash, Signal "version 1" style
        h = SHA-512( h || pub )[0:32]
    // first 30 bytes -> six 5-digit groups
    take 30 bytes; for each 5-byte chunk: int(big-endian) % 100000, zero-pad to 5 digits

The displayed safety number is sorted([fingerprint_local, fingerprint_remote]) concatenated (local+remote ordered by the raw key bytes, ascending), rendered as twelve 5-digit groups. Two devices that compute the same string are talking to each other with no MITM. Clients MUST provide a screen to compare this (and SHOULD support a QR encoding of the two raw keys for scan-to-verify).


8. Server REST contract

Base URL: https://<relay>/e2e. The routes are also mounted under https://<relay>/api/flamenet/v1/e2e as a compatibility alias; the proof signature commits to the /e2e/* path either way. Namespace flamenet/v1. All routes below are under /e2e.

Auth: every route requires an authenticated user, satisfied by either Authorization: Bearer <token> — a short-lived token scoped to one device and one relay, (web client). No subscription tier is required. The authenticated user is "me".

All request bodies are JSON. All responses are JSON. Base64 fields are standard base64.

8.1 POST /e2e/devices — register / update my device

Request:

{
  "device_id": "<uuid>",
  "ik_dh": "<b64>",
  "ik_sig": "<b64>",
  "spk_id": 7,
  "spk": "<b64>",
  "spk_sig": "<b64>",
  "prekeys": [ { "id": 1, "pub": "<b64>" }, ... ]   // optional on update
}

Upserts the device for the current user (keyed by device_id; a device_id may only ever belong to one user). Stores/replaces the identity + signed prekey, and inserts any supplied one-time prekeys. Response: { "ok": true, "device_id": "...", "opk_remaining": 100 }.

8.2 GET /e2e/keys/{user_id} — fetch prekey bundles to start sessions

Rate limited. This route pops a one-time prekey per device on every call, so an unmetered version is a prekey-pool drain: fetch a target repeatedly and every later correspondent falls back to the no-OPK path, losing initial forward secrecy, until that target's client replenishes. Metered per requester (120/hour) and per requester-target pair (6/hour); a client needs one bundle per target device to establish and should use §8.3, which pops nothing, for everything after that. Deliberately not metered per target alone — that would let one abuser make a popular account unstartable for everyone, trading a forward-secrecy downgrade for a denial of service. For each of the target user's devices, atomically pops one unused one-time prekey (marks it used) and returns the bundle. Response:

{
  "user_id": 12,
  "devices": [
    {
      "device_id": "...", "ik_dh": "<b64>", "ik_sig": "<b64>",
      "spk_id": 7, "spk": "<b64>", "spk_sig": "<b64>",
      "opk": { "id": 42, "pub": "<b64>" }      // or null if pool exhausted
    }
  ]
}

8.3 GET /e2e/devices/{user_id} — list identity keys (no prekey consumption)

For addressing + safety-number recompute + key-change detection. Response:

{ "user_id": 12, "devices": [ { "device_id": "...", "ik_dh": "<b64>", "ik_sig": "<b64>" } ] }

8.3a DELETE /e2e/devices/{device_id} — revoke one of my own devices

Removes the device, both of its prekey pools, and any envelopes still queued for it. Scoped to the caller: revoking a device owned by another account is 403, not a silent no-op. 404 when no such device exists. Response: { "ok": true, "device_id": "..." }.

Per-device identity means a lost or retired device otherwise stays a valid ratchet endpoint forever — senders keep fanning out to it and whoever holds it keeps decrypting. Listing devices was possible from the start; removing one was not.

Peers observe a revocation the same way they observe any key change: the device stops appearing in §8.3. There is deliberately no revocation certificate — a relay willing to hide a revocation could withhold a certificate just as easily, so it would buy nothing against the threat it appears to address.

8.4 POST /e2e/messages — submit sealed envelopes

The client encrypts the same logical message once per recipient device and submits them together. Request:

{
  "to": 12,
  "messages": [ { "to_device": "<uuid>", "payload": "<b64-envelope>" }, ... ]
}

Server validates to is a real user, then stores one row per envelope. It does not inspect payload. Response: { "ok": true, "ids": [101, 102] }.

8.5 GET /e2e/messages?device_id={uuid}&after={id}&limit={n} — poll my inbox

Returns undelivered envelopes addressed to device_id with id > after, oldest first, marks them delivered, and returns a cursor. Response:

{
  "messages": [
    { "id": 101, "from_user": 7, "from_device": "<uuid>",
      "payload": "<b64-envelope>", "created_at": "2026-06-05T12:00:00Z" }
  ],
  "cursor": 101
}

The client persists cursor and passes it as after next poll.

8.6 POST /e2e/prekeys — replenish one-time prekeys

Request: { "device_id": "<uuid>", "prekeys": [ { "id": 101, "pub": "<b64>" }, ... ] }. Response: { "ok": true, "opk_remaining": 118 }.

8.7 POST /e2e/attachments — upload a sealed attachment blob

Body is the raw ciphertext blob (Content-Type: application/octet-stream), not JSON — the nonce || ct || tag bytes of §6.6, already sealed client-side. The server never sees a key and cannot distinguish the blob from noise.

  • Size cap: 10 MiB per blob (larger → 413).
  • Rate limit: rolling per-user hourly caps on count and total bytes.

Response: { "ok": true, "id": "<64-hex token>", "bytes": 123456 }. The id is a server-generated random token — possession of the id is the download capability, which is why it only ever travels inside the ratchet.

8.8 GET /e2e/attachments/{id} — download a sealed attachment blob

Requires an authenticated user (any member — the unguessable id is the gate, mirroring the Signal CDN model; the server cannot know the intended recipient of a sealed reference). Streams the blob back as application/octet-stream. 404 after retention expiry (§9).

8.9 Group registry

Plaintext metadata only (§6.7) — the server never relays a group message differently from a 1:1 message and cannot tell them apart.

  • POST /e2e/groups — create. Request: { "name": "...", "members": [12, 34] }. The creator is always a member and the owner. Caps: name ≤ 80 chars, 32 members including the creator (fan-out cost is per-member; see §6.7).

    Every member must be reachable: at least one registered device. The relay cannot answer "is this a real account" — that is the identity provider's question, and the earlier implementation could only answer it because it was the identity provider. Reachability is the stricter check anyway: it rejects a typo'd id, and it also rejects a real account that has never set up encryption, which for an encrypted group is the honest answer rather than a silent black hole (fan-out is per device, so such a member would receive nothing). Response: { "ok": true, "gid": "<64-hex>", "name": "...", "members": [...] }.

  • GET /e2e/groups — list every group I am a member of, with rosters: { "groups": [ { "gid": "...", "name": "...", "owner": 7, "members": [ { "user_id": 7 }, ... ] } ] }.

    Deviation, deliberate. The earlier implementation also returned a name per member, joined from its user table. It could do that because it was the identity provider. The standalone relay has no user table and must not grow one — profile data staying with the IdP is the boundary the split is about, and a relay that caches display names is a relay that leaks them. Clients resolve names against the portal, where they already do for buddy lists. Nothing consumed this field at the time it was removed.

  • POST /e2e/groups/{gid}/members — owner only. Request: { "add": [56], "remove": [34] }. The owner cannot remove themselves (use leave, which transfers or dissolves). Response: the updated group object.

  • POST /e2e/groups/{gid}/leave — remove myself. If the owner leaves, ownership passes to the longest-standing remaining member; the last member leaving deletes the group. Response: { "ok": true }.

Senders fan out to the roster as of send time. Receivers attribute by envelope from_user, which the transport already authenticates via the ratchet session.

8.10 GET /e2e/turn — TURN relay credentials

Mints time-limited credentials for the coturn relay using the standard REST-secret scheme (username = <unix expiry>:<user id>, credential = base64(HMAC-SHA1(secret, username))). TTL 2 hours. Response:

{
  "urls": [ "stun:turn.flamenet.io:3478", "turn:turn.flamenet.io:3478?transport=udp" ],
  "username": "1787260000:12",
  "credential": "<b64>",
  "ttl": 7200
}

The relay never sees plaintext — it forwards DTLS-SRTP ciphertext between peers that authenticated each other through the ratchet (§6.8).

Error shape

Standard WP REST error: { "code": "...", "message": "...", "data": { "status": 4xx } }.


9. Storage (server tables, all opaque)

  • {p}fnmsg_e2e_devices — one row per device (identity + current signed prekey).
  • {p}fnmsg_e2e_prekeys — one-time prekey pool, used flag.
  • {p}fnmsg_e2e_envelopes — sealed message queue, delivered flag.
  • {p}fnmsg_e2e_attachments — sealed blob index: token, uploader, size, file path, created_at. The blob bytes live on disk under uploads/fnmsg-e2e/, direct web access denied; the REST route is the only reader.

No table contains plaintext or a private key. Envelope retention: delivered envelopes MAY be purged by a cron after 7 days; undelivered are retained until fetched. Attachment blobs are purged after 30 days regardless of download state — a receiver that wants to keep an image keeps the decrypted copy locally, not the server blob.


10. Cross-engine interop status

Verified means one engine's output was opened by another. Self-consistency — an engine agreeing with itself — is not interop and is recorded separately below.

Verified

  • iOS (CryptoKit) ↔︎ Web (WebCrypto), both directions, v1 and v2: one engine seals a prekey envelope, the other runs X3DH respond + ratchet decrypt and replies, and the first opens the reply. Also covered: identical associated data, the v2 last-resort prekey path, tamper rejection on both the body and the KEM ciphertext, and safety-number agreement on shared keys. Harness: engine/test/interop.mjs, whose Swift half is compiled from the app's own Sources/E2E/*.swift rather than being a reimplementation.
  • Web ↔︎ relay, end to end over HTTP against the running binary: registration, bundle assembly, prekey popping and pool exhaustion, with every signature the server returns verified client-side. Harness: engine/test/relay.mjs, which drives the compiled Swift relay rather than a stand-in.
  • iOS ↔︎ live server, v1 only: a real register → send → poll → decrypt round trip against production (2026-08-11).
  • Each of the iOS and Web engines separately passes X3DH, Double Ratchet, out-of-order (skipped keys), GCM tamper rejection and safety numbers.

Not verified

🔴 There is no Android engine in this repository. An earlier version of this section described one at android/…/data/e2e/E2E.kt (Kotlin + BouncyCastle) with three JVM tests in E2EEngineTest.kt that "cannot currently be run" for want of a gradlew wrapper. No such files are present here, and README.md states plainly that an Android engine does not exist yet. The two documents contradicted each other for as long as both were published.

Whether that code exists in some other checkout is not something this repository can answer, and a spec that names file paths a reader cannot open is worse than one that says nothing. So: Android is absent, not merely unverified. When it is written, it is a plain JVM library — Kotlin and BouncyCastle, no Android APIs — which means it can be exchanged with the web engine through engine/test/interop.mjs from its first commit. Doing that before it is trusted is the whole lesson of this section.

⚠️ Do not infer interop from structural similarity. An earlier version of this section did exactly that, and it was wrong for months: the iOS engine was structurally correct and still could not talk to the server, because the two disagreed about a wire format no component test exercised. Byte-identical constructions are necessary and nowhere near sufficient. Until an Android fixture is exchanged with another engine, treat Android interop as unknown, not as likely.


11. Threat model / limits (v1)

The "harvest now, decrypt later" limit below is what §12 addresses. Everything else in this section applies unchanged to v2.

  • Server can see metadata: who messages whom, when, message sizes, device counts. (Sealed-sender is out of scope for v1.)
  • Server could attempt a key-swap MITM; safety numbers (§7) are the defense — clients MUST surface identity-key changes.
  • No multi-device history sync; no encrypted backups; no group messaging (all deferred).
  • One-time prekey exhaustion degrades a session's initial forward secrecy slightly (X3DH without OPK) but does not break confidentiality.
  • No post-quantum protection in v1. A passive adversary who records envelopes now and breaks X25519 later recovers SK and, through the ratchet, the entire session. §12 specifies the hybrid ML-KEM-768 key agreement that closes this.

12. Protocol v2 — hybrid post-quantum X3DH (PQX3DH)

Status: DRAFT / proposed. Not yet implemented on iOS or Android. The web engine (assets/js/e2e.js) is the reference implementation, per the convention in §10.

This section is a delta against v1. Everything in §§1–11 still holds for "v": 1 sessions; a v2 client MUST continue to speak v1 to peers that do not advertise v2. The Double Ratchet (§6), content envelope (§6.6), safety numbers (§7), and all AEAD constructions are unchanged.

12.1 Motivation and scope

v1's session key rests entirely on X25519. An adversary who records envelopes today and obtains a cryptographically relevant quantum computer later can recover every DH, hence SK, hence the whole session — the "harvest now, decrypt later" attack. Because the ratchet chains RK_{n+1} = KDF(RK_n, DH_n), recovering RK_0 unrolls the entire conversation.

v2 mixes an ML-KEM-768 (FIPS 203) shared secret into the X3DH IKM. The construction is hybrid: the classical DHs are retained unchanged and concatenated with the KEM secret before the KDF. SK is therefore secure if either X25519 or ML-KEM-768 is secure. This is deliberate and is the load-bearing property of the design — see §12.9.

What v2 does buy. Confidentiality of a session against a passive adversary who records now and breaks X25519 later. Once RK_0 is PQ-secure, no later ratchet step is recoverable from broken DHs alone, so the protection covers the whole session, not just the first message.

What v2 does not buy. Authentication is still Ed25519. An adversary with a quantum computer at the time of the exchange can forge SPK_sig and mount an active MITM; PQ signatures (ML-DSA) are a separate, later change. The ratchet's ongoing DH steps stay classical. Device compromise is unaffected. This is the same posture as Signal's PQXDH, and it should be described that way and no more strongly.

12.2 Added primitive

Purpose Algorithm Sizes
KEM ML-KEM-768 (FIPS 203) encapsulation key 1184 B · ciphertext 1088 B · shared secret 32 B · seed 64 B

Key material is stored and transported as the 64-byte FIPS 203 seed (d ‖ z), not the 2400-byte expanded decapsulation key. Both a compliant native implementation and the vendored fallback derive byte-identical keys from the same seed (verified — §12.10).

Implementations MUST use crypto.subtle / platform ML-KEM-768 where available and fall back to a vetted library otherwise. Nobody writes ML-KEM by hand.

12.3 Added key material per device

Key Type Lifetime Published
PQSPK ML-KEM-768 rotated with SPK public ek + signature
PQSPK_sig Ed25519 sig see below
PQOPK_i ML-KEM-768 one-time pool of public eks, each signed
PQOPK_sig_i Ed25519 sig see below

PQSPK is the last-resort PQ prekey: always present, never consumed, so a session can always be established with PQ protection even when the one-time pool is drained.

Signatures are domain-separated, and the one-time signature binds the id:

PQSPK_sig  = Ed25519_sign(IK_sig_priv, "FlamenetE2E_PQSPK_v2" ‖ PQSPK_pub)
PQOPK_sig  = Ed25519_sign(IK_sig_priv, "FlamenetE2E_PQOPK_v2" ‖ uint32be(id) ‖ PQOPK_pub)

Both prefixes are ASCII, no terminator. Binding id stops the server relabelling a prekey (a denial of service, not a break, but free to prevent).

⚠️ v1's SPK_sig signs the raw 32-byte key with no domain prefix (§3). That asymmetry is intentional and load-bearing for compatibility with shipped clients. Do not "fix" it.

Pool size. The PQ one-time pool MUST be the same size as the classical one-time pool (RECOMMENDED 100) and MUST be replenished on the same trigger (opk_remaining < 20). Equal sizing is a deliberate invariant: if the PQ pool were smaller it would drain first and sessions would silently fall back to PQSPK, losing per-session PQ forward secrecy with no signal. They must exhaust together or not at all.

Cost of that choice. A full registration payload carries 100 × (1184 + 64 + 4) ≈ 125 KB raw, ≈ 167 KB base64. Servers MUST accept a body of at least 256 KB on POST /e2e/devices (the relay caps request bodies). Replenishment via POST /e2e/prekeys is incremental and small.

12.4 Version advertisement and capability signature

Each device row gains proto — the highest protocol version that device implements — plus a signature proving the device really made that claim:

caps_sig = Ed25519_sign(IK_sig_priv, "FlamenetE2E_Caps_v2" ‖ uint8(proto))

proto and caps_sig are relayed by the server but authenticated by the device. A server that rewrites proto downward cannot produce a matching caps_sig; it can only omit the pair entirely. §12.7 is what closes that remaining gap.

12.5 PQX3DH — initiating a session

Alice initiates to Bob's device D. Steps that differ from §5 are marked NEW.

  1. Alice fetches the bundle (§12.11). It now carries proto, caps_sig, the PQ signed prekey, and one popped PQ one-time prekey (or null).
  2. Alice MUST verify Ed25519_verify(ik_sig, spk_sig, spk) — unchanged. Abort on failure.
  3. NEW. Alice MUST decide the protocol version by the rules in §12.7. If v1, run §5 unchanged and stop here.
  4. NEW. Alice selects the PQ target: the one-time PQOPK if the bundle supplied one, otherwise PQSPK. She records pq_kind ∈ {"opk", "spk"} and pq_id.
  5. NEW. Alice MUST verify the PQ prekey's signature with the matching domain string from §12.3. Abort on failure. A v2 bundle whose PQ signature does not verify is a hard error, never a silent fallback to v1.
  6. NEW. Encapsulate: (PQ_CT, SS) = ML-KEM-768.Encaps(PQ_target_pub).
  7. Alice generates an ephemeral X25519 keypair EK and computes DH1..DH4 exactly as §5.4.
  8. NEW. The IKM appends SS last:
    IKM = DH1 ‖ DH2 ‖ DH3 ‖ DH4 ‖ SS        // DH4 omitted if opk == null
    SK  = HKDF-SHA256(IKM, salt = 0x00*32, info = "FlamenetE2E_X3DH_v2", L = 32)

    The info string MUST change from v1. Without it, DH1‖DH2‖DH3‖DH4 (v1, with OPK) and DH1‖DH2‖DH3‖SS (v2, no OPK) are both 128 bytes and would be indistinguishable inputs to the same KDF. The version string is the domain separator that makes the concatenation unambiguous.

  9. NEW. The associated data binds the KEM ciphertext:
    PQAD = SHA-256("FlamenetE2E_PQAD_v2" ‖ PQ_CT)          // 32 bytes
    AD   = IK_sig_A_pub ‖ IK_sig_B_pub ‖ PQAD               // 96 bytes
    ML-KEM's implicit rejection already makes a tampered PQ_CT yield a different SS and therefore a failed AEAD open; this binding makes that explicit and permanent, since AD prefixes the AAD of every message in the session (§6.3).
  10. Ratchet initialisation is unchanged: sender, RK = SK, DH_remote = SPK_B_pub.
  11. The first envelope is type: "prekey" with "v": 2 and the added fields in §12.6.

Bob runs the symmetric procedure: same DHs with roles reversed (§5), then SS = ML-KEM-768.Decaps(PQ_CT, sk) where sk is selected by pq_kind/pq_id. If pq_kind == "opk" the prekey is consumed (deleted) exactly like a classical OPK; if pq_kind == "spk" it is not. A pq_id Bob does not hold is a hard error (unknownPQPreKey) — he MUST NOT fall back to PQSPK.

ML-KEM decapsulation never fails. FIPS 203 implicit rejection returns a pseudorandom shared secret for a malformed or substituted ciphertext. The failure therefore surfaces one layer up, as an AEAD authentication failure on the first message. Implementations MUST NOT treat "decaps succeeded" as any kind of validation.

12.6 Envelope changes

The prekey envelope gains a pq object and bumps v. msg envelopes are byte-identical to v1 — the PQ material appears exactly once, in the initial message.

{
  "v": 2,
  "type": "prekey",
  "header": { "dh": "<b64>", "pn": 0, "n": 0 },
  "x3dh": { "ik_dh": "<b64>", "ik_sig": "<b64>", "ek": "<b64>", "spk_id": 7, "opk_id": 42 },
  "pq":   { "kind": "opk", "id": 42, "ct": "<b64 1088-byte ML-KEM ciphertext>" },
  "ct":   "<b64 AES-GCM ciphertext+tag>"
}

header_bytes (§6.4) is unchanged — the pq object is not part of the ratchet header and is not in the AAD directly; it is bound through PQAD in AD instead. This is what keeps msg envelopes and the entire ratchet identical across versions.

A receiver MUST reject "v": 2 with type: "prekey" and a missing or malformed pq object rather than treating it as v1. The classical opk_id and the PQ pq.id are independent id spaces; they may coincide numerically and MUST NOT be assumed equal.

12.7 Version selection and downgrade resistance

This is the part of v2 most likely to be got wrong. The threat is a malicious or compromised relay stripping the PQ fields from a bundle so both honest parties negotiate v1 and the adversary harvests as before.

Clients hold a policy pqMode:

  • "optional" (rollout): use v2 when the bundle carries proto ≥ 2 with a valid caps_sig and a valid PQ prekey; otherwise v1.
  • "required" (target): refuse to establish any v1 session. Peers not yet on v2 become unreachable, so this flips only once the fleet has migrated.

Per-device pinning is the actual defence, and it reuses the trust store that already holds identity pins (§11, Trust):

The first time a client observes a device with a valid caps_sig at proto ≥ 2, it MUST persist minProto = 2 for that device beside the identity pin. Thereafter a bundle for that device without valid v2 material is a downgrade attempt: the client MUST refuse to send and MUST surface it to the user through the same path as an identity-key change.

minProto MUST be monotonic (never lowered by anything the server says) and MUST survive the same lifecycle as the identity pin, including acceptIdentityChange — accepting a new identity key does not reset minProto.

Residual risk, stated plainly. First contact with a device the client has never seen is still downgradable: with nothing pinned, there is nothing to compare against. Safety numbers (§7) do not close this — they cover IK_sig only and are identical for a v1 and a v2 session. The only complete fix is pqMode: "required". Any user-facing claim about post-quantum protection MUST NOT be made while the fleet is on "optional".

12.8 Server contract changes

POST /e2e/devices (§8.1) — request gains:

{ "proto": 2, "caps_sig": "<b64>",
  "pqspk_id": 3, "pqspk": "<b64>", "pqspk_sig": "<b64>",
  "pq_prekeys": [ { "id": 1, "pub": "<b64>", "sig": "<b64>" } ] }

All PQ fields are OPTIONAL, so v1 clients keep registering unchanged. If any is present all of proto, caps_sig, pqspk_id, pqspk, pqspk_sig MUST be present. The server MUST reject a pqspk that is not exactly 1184 bytes and a signature that is not 64 bytes, in the same manner as valid_b64key in v1 — it cannot verify signatures (it holds no private key) but it MUST enforce lengths.

GET /e2e/keys/{user_id} (§8.2) — each device object gains:

{ "proto": 2, "caps_sig": "<b64>",
  "pqspk_id": 3, "pqspk": "<b64>", "pqspk_sig": "<b64>",
  "pqopk": { "id": 42, "pub": "<b64>", "sig": "<b64>" }   // or null if pool exhausted
}

Each pop is individually atomic (a conditional UPDATE ... WHERE used = 0, as v1 already does), but the two are not wrapped in a shared transaction, and should not be. There is no cross-pool state to corrupt: if the PQ pop returns null while the classical one succeeded, the caller simply gets a bundle with an opk and no pqopk, which is the ordinary exhaustion case §12.3 already covers.

The invariant that actually matters is equal pool sizes and paired replenishment. Both depths are therefore reported on every registration and replenishment response so a client can see the pools diverging:

{ "ok": true, "device_id": "...", "proto": 2,
  "opk_remaining": 100, "pq_opk_remaining": 100 }

pq_opk_remaining is null — not 0 — for a device that has never published PQ material. "v1 device" and "v2 device with a drained pool" are different states and must not be conflated by the client's refill logic.

⚠️ This route already pops a prekey per device, per call. That drain hazard is unchanged by v2 and now applies to the PQ pool as well — an unauthenticated-ish caller can exhaust both pools with 100 fetches, degrading later sessions to SPK + PQSPK. Prefer GET /e2e/devices when not establishing a session. Rate limiting this route is an open item inherited from v1, not introduced here.

POST /e2e/prekeys (§8.6) — accepts a pq_prekeys array of the same shape.

GET /e2e/devices/{user_id} (§8.3) — gains proto and caps_sig so a client can learn a peer's capability, and pin it, without consuming prekeys.

New table {p}fnmsg_e2e_pq_prekeys: device_id, prekey_id, pub (1184 B base64), sig (64 B base64), used flag — mirroring {p}fnmsg_e2e_prekeys. {p}fnmsg_e2e_devices gains proto, caps_sig, pqspk_id, pqspk, pqspk_sig, all NULLable. Additive only; no v1 column changes, and a v1 client registers exactly as before.

Retention. Consumed PQ prekey rows are purged after 7 days by the existing daily cron. The classical pool has no equivalent sweep and needs none — a spent X25519 row is 44 characters, where an ML-KEM-768 row is 1580 plus an 88-character signature, so a device cycling 100 prekeys leaves ~160 KB behind per refill.

Re-registration must not strip a v2 advertisement. POST /e2e/devices is an upsert, and a body with no PQ block MUST leave the stored one intact rather than nulling it. Otherwise a v1-era build of the same device could silently retract its own capability, and every peer that had pinned minProto = 2 would refuse to send to it — the device would go dark for exactly the users who had verified it.

A partial stored block MUST be suppressed entirely when serving a bundle. If pqspk, pqspk_sig or caps_sig is missing for any reason, the server emits no v2 fields at all; half a block reads as a downgrade to a peer pinned at minProto = 2.

12.9 Why hybrid, and why that is not a hedge

ML-KEM-768 is young, and every JavaScript and Swift implementation of it is younger. The hybrid construction is what makes deploying it responsible: because SS is concatenated with the classical DHs before a single KDF, an ML-KEM implementation bug — wrong shared secret, biased sampling, a broken NTT — degrades SK to exactly v1's classical security. It cannot make v2 weaker than v1. The converse also holds. Only a break of both loses the session.

This means the correct rollout order is PQ-additive first, PQ-only never.

12.10 Conformance requirements for this section

An implementation conforms to §12 only if it demonstrates, not merely implements:

  1. Seed agreement — native and fallback ML-KEM produce byte-identical ek from the same 64-byte seed, and each decapsulates the other's ciphertext to the same SS.
  2. Hybrid KAT — a fixed (IK, SPK, OPK, EK, PQ seed) vector produces a fixed SK and AD, checked in against the spec so drift is caught by a test rather than by a user.
  3. Cross-engine — a v2 prekey envelope sealed by one engine opens on another, both directions, as §10 requires for v1.
  4. Downgrade refusal — a device pinned at minProto = 2 refuses a bundle with the PQ fields stripped, and the refusal reaches the UI.
  5. Tamper rejection — flipping one byte of pq.ct fails the AEAD open, proving PQAD is genuinely bound.
  6. Exhaustion path — with the PQ one-time pool empty, the session establishes against PQSPK and still round-trips.

§10's warning applies with full force: do not write these claims into the spec until the runs have actually happened. v1 asserted interop for months that was impossible.

Status as of 2026-08-19 — what has actually been run, and on what:

Status Where
1. Seed agreement ✅ verified, both directions, 8 random seeds engine/test/conformance.mjs
2. Hybrid KAT ✅ locked vector, replayed on both backends engine/test/vectors/pqx3dh-v2.json
3. Cross-engine ⚠️ partialiOS (CryptoKit) ↔︎ Web (WebCrypto) verified in both directions, including identical AD, the last-resort path, tamper rejection and safety numbers; the Swift driver is compiled from the app's own Sources/E2E/*.swift, not a reimplementation. JS ↔︎ relay also verified end to end over HTTP. There is no Android engine in this repository (§10). engine/test/interop.mjs, relay.mjs
4. Downgrade refusal ✅ verified through the client — a pinned device refuses a PQ-stripped bundle with ProtocolDowngradeError; the pin is monotonic, survives a reload, and is not cleared by accepting an identity change engine/test/client.mjs
5. Tamper rejection ✅ verified — one flipped byte of pq.ct fails the AEAD open engine/test/conformance.mjs
6. Exhaustion path ✅ verified through the real server's popping path engine/test/relay.mjs §8.2

Client wiring has 50 tests (engine/test/client.mjs) run against a deliberately hostile in-process relay — one that strips PQ fields and forges capability signatures, which the real server will not do. Server behaviour has 16 tests against the real route methods (engine/test/relay.mjs), driven against the running relay rather than a stub.

Cross-engine interop has 24 tests (engine/test/interop.mjs) driving the Swift engine against the JS engine in both directions.

🔴 pqMode MUST stay "optional", for two reasons that are now specific rather than general: Android is v1 only, and iOS devices below 26 cannot do post-quantum at all (§12.11). Until both are closed, first contact with an unpinned device remains downgradable and no post-quantum claim may be made to users. See §12.7.

12.11 Implementation status

Layer State
Web engine (engine/src/e2e.js, mlkem.js) implemented
Relay (Swift, server/) implemented
Web client wiring (e2e-client.js, e2e-bootstrap.js) implemented
iOS (Sources/E2E/, CryptoKit) implemented — iOS 26+ only, see below
Android absent — not in this repository (§10)

The web stack is complete end to end. A new device registers both prekey pools; a device that registered before v2 upgrades itself on next load via ensureV2Published(); peers are pinned at their proven version and a stripped bundle raises ProtocolDowngradeError rather than falling back.

iOS is complete but gated. CryptoKit gained MLKEM768 in iOS 26, while the app deploys to iOS 16, so the primitive sits behind PQKEM.isSupported. A device on iOS 26+ publishes PQ prekeys, pins peers and refuses downgrades exactly as the web client does; a device on iOS 16–25 gets no post-quantum protection at all and registers as a v1 client. That is visible rather than hidden — E2EInbox never advertises proto: 2 for a device that cannot honour it.

Closing the gap needs either a deployment target of iOS 26 (the app has never shipped, so there are no users to break — but it cuts off a large share of devices) or a bundled implementation for older systems. Both are product decisions, not engine ones.

🔴 pqMode stays "optional" because iOS and Android are still v1. Flipping it to "required" today would make every native client unreachable. Until then, first contact with an unpinned device remains downgradable, and no post-quantum claim may be made to users — see §12.7.

12.12 Open items

  • PQ authentication. Identity remains Ed25519; a quantum adversary present at the exchange can still MITM. ML-DSA identity keys are the next step and would change safety numbers, which is a migration of its own.
  • Android. No post-quantum support. BouncyCastle has ML-KEM (MLKEMKeyPairGenerator), so the primitive is available; the work is the engine, the wire fields and the pinning. Before that, Android needs a fixture exchange with another engine — it has never had one, and adding v2 on top of an unverified v1 compounds the risk rather than reducing it.
  • iOS below 26. CryptoKit's MLKEM768 needs iOS 26; the app deploys to iOS 16, so older devices are v1 (§12.11). Until Android and this are both closed, pqMode cannot leave "optional".
  • Prekey-pool drain. Inherited from v1, now doubled in impact. Rate limiting GET /e2e/keys is the fix. GET /e2e/devices now carries proto + caps_sig so a client can pin a peer's version without consuming a prekey, which removes discovery as a drain vector but not session establishment.
  • The web delivery problem. Unchanged and unaddressed by v2: the relay still serves the JavaScript that performs the encapsulation. PQ key agreement does not make the web client trustworthy against its own operator.

13. Protocol v3 — post-quantum ratchet

Status: DRAFT / proposed. Delta against §12. Everything in §§1–12 still holds for "v": 2 sessions, and a v3 client MUST still speak v2 and v1 to peers that do not advertise v3.

13.1 What v2 left open

§12 makes the initial key agreement post-quantum. Because the ratchet chains RK_{n+1} = KDF(RK_n, DH_n), an adversary who cannot recover RK_0 cannot recover any later root key either, so v2 already protects a whole session against harvest-now-decrypt-later.

What v2 does not give is post-quantum post-compromise security. If an endpoint is compromised and RK_n leaks, recovery in v2 depends entirely on subsequent X25519 ratchet steps — which a quantum adversary can break. The session never heals against that attacker. Signal moved past PQXDH for exactly this reason.

v3 mixes a fresh ML-KEM-768 secret into every ratchet step, so healing is post-quantum too.

13.2 Construction

The PQ ratchet mirrors the DH ratchet exactly. Each party holds a current ML-KEM keypair and knows the peer's current ML-KEM public key.

Added state per session:

PQs           our current ML-KEM keypair (64-byte seed + 1184-byte public key)
PQr           the peer's current ML-KEM public key
PQpending     {pub, ct} to attach to outgoing messages until the peer answers

The root-key KDF takes both secrets:

KDF_RK_v3(rk, dh_out, ss):
    out = HKDF-SHA256(IKM = dh_out || ss, salt = rk,
                      info = "FlamenetE2E_Ratchet_v3", L = 64)
    return (RK' = out[0:32], CK = out[32:64])

The info string MUST change from _v1. dh_out is 32 bytes and ss is 32 bytes, so a v3 IKM is 64 bytes where a v1 IKM is 32 — they cannot collide by length, but the version string is what makes the domain separation explicit rather than incidental.

Initiator, at session start. PQr is initialised to the PQ prekey chosen by X3DH (§12.5) — the same key the X3DH encapsulation targeted:

DHs = X25519_generate()
PQs = MLKEM768_generate()
(ct, ss) = MLKEM768.Encaps(PQr)
RK, CKs = KDF_RK_v3(RK, X25519(DHs, DHr), ss)
attach {pqdh: PQs.pub, pqct: ct} to outgoing messages

Responder mirrors it: PQs is the PQ prekey the initiator used (so it can decapsulate), and PQr is adopted from the first header.

Every DH ratchet step then performs a PQ step in lockstep:

on receiving a header with a new DHr:
    PN = Ns; Ns = 0; Nr = 0
    ss_recv = MLKEM768.Decaps(header.pqct, PQs.seed)   // peer encapsulated to our current PQs
    DHr = header.dh
    RK, CKr = KDF_RK_v3(RK, X25519(DHs, DHr), ss_recv)

    PQr = header.pqdh                                   // adopt the peer's new PQ key
    DHs = X25519_generate()
    PQs = MLKEM768_generate()
    (ct, ss_send) = MLKEM768.Encaps(PQr)
    RK, CKs = KDF_RK_v3(RK, X25519(DHs, DHr), ss_send)
    attach {pqdh: PQs.pub, pqct: ct} to outgoing messages

Decapsulation uses the key the peer encapsulated to, which is our PQ key from before we adopt theirs. Doing those two in the wrong order silently derives the wrong root key.

13.3 Header and message size

The header gains two optional fields:

{ "dh": "<b64>", "pn": 0, "n": 0, "pqdh": "<b64 1184B>", "pqct": "<b64 1088B>" }

header_bytes for the AAD is the canonical compact JSON with keys in exactly this order, omitting pqdh/pqct entirely when they are absent:

{"dh":"...","pn":N,"n":N}                            // no PQ step
{"dh":"...","pn":N,"n":N,"pqdh":"...","pqct":"..."}  // PQ step

The PQ fields are repeated on every message of a sending chain until the peer answers. Sending them only on the chain's first message would mean that losing that one message strands the entire chain: the receiver would never get the material to advance its root key, and every later message in the chain would be undecryptable. This is the same reasoning that keeps the prekey block attached until answered (§12.6), and it uses the same mechanism.

The cost is ~3.0 KB of base64 per message while a chain is unanswered, falling to zero once the peer replies. In a normal back-and-forth that is one inflated message per turn. This is a deliberate trade against Signal's sparse/erasure-coded approach, which chunks the PQ material across many messages to keep headers small: that complexity buys bandwidth this transport does not need, and every chunking scheme adds reassembly state that can strand a session. A relay over HTTP can afford 3 KB.

13.4 Negotiation

Unchanged from §12.4 and §12.7, which already generalise: a device advertises proto: 3 with a capability signature over "FlamenetE2E_Caps_v2" ‖ uint8(3), peers pin minProto = 3 on first proof, and the pin is monotonic. A v3 device talking to a v2 peer runs v2; the pqdh/pqct fields are simply absent and KDF_RK stays on _v1.

A "v": 3 message whose header lacks pqdh/pqct while the DH key changed MUST be rejected, not treated as a v2 ratchet step — that would be a downgrade inside an established session.

13.5 What v3 still does not do

  • Authentication is still Ed25519. A quantum adversary present at the exchange can forge SPK_sig and MITM. PQ signatures (ML-DSA) remain unimplemented, and this is the last purely-cryptographic gap against Signal.
  • Nothing here changes metadata exposure. See §14.

14. Sealed sender

Status: DRAFT / proposed. A transport feature, not a protocol version: it changes what the relay learns, never how SK is derived. It composes with v1, v2 and v3 unchanged.

14.1 The problem

§11 concedes that the relay sees who messages whom. Today that is worse than a concession — it is recorded. POST /e2e/messages is authenticated, so the server knows the sender by construction, and {p}fnmsg_e2e_envelopes stores from_user_id and from_device on every row. Even with delivered envelopes purged after a day, the relay is a live social-graph feed.

This is the axis on which the project loses hardest to Signal, and unlike PQ or audits it is entirely within our control.

14.2 What sealed sender does and does not achieve

Achieves: the relay no longer learns who sent a message. It sees the recipient, the time, and an opaque blob.

Does not achieve: the relay still sees the recipient, timing, size, and the sender's IP address. Sealed sender is not anonymity — Signal's has the same limits. Do not describe it as "the server knows nothing".

14.3 Construction

The sender identity moves inside the ciphertext. Because a prekey message arrives before any session exists, the outer layer cannot use the ratchet; it uses a one-shot seal to the recipient device's published IK_dh:

eph        = X25519_generate()
shared     = X25519(eph_priv, IK_dh_recipient_pub)
K          = HKDF-SHA256(IKM = shared, salt = 0x00*32,
                         info = "FlamenetE2E_SealedSender_v1", L = 44)
key, nonce = K[0:32], K[32:44]
inner      = UTF-8 JSON { "from_user": <int>, "from_device": "<uuid>", "envelope": {…} }
sealed_ct  = AES-256-GCM(key, nonce, inner, AAD = to_device ‖ eph_pub)

Wire (payload field of POST /e2e/messages, base64 of the JSON):

{ "s": 1, "eph": "<b64 32B>", "ct": "<b64>" }

The recipient trial-decrypts with each of its devices' IK_dh private keys. AAD binds the seal to the destination device and the ephemeral key, so a relay cannot replay one user's sealed blob into another user's inbox.

⚠️ The seal is confidentiality only, not authentication. Anyone holding the recipient's public IK_dh can produce a well-formed seal claiming any from_user. The claim is worth nothing until the inner envelope is processed: the ratchet and the identity pin (§11) are what authenticate the sender, exactly as before. A client MUST NOT display, notify on, or record a sealed message's claimed sender before the inner envelope decrypts. Treating from_user as trusted would hand an attacker free sender-spoofing — a strictly worse outcome than the metadata leak this feature removes.

14.4 Delivery tokens

The relay must still refuse traffic to people who do not want it, without learning who is sending. Each user holds a random 32-byte delivery key; the server stores only SHA-256(delivery_key).

  • POST /e2e/delivery-key — publish SHA-256(delivery_key) for the calling user.

  • The delivery key is distributed to contacts inside E2E messages, so the relay never sees it in the clear from the owner. It travels as a content envelope (§6.6) with kind: "delivery_key":

    { "fnc": 1, "kind": "delivery_key", "key": "<b64 32B>" }

    Receivers MUST store it against the sender and MUST NOT render it in the thread. It is sent on the first message to a contact and again whenever the key rotates. Because it rides an ordinary ratchet session, the relay cannot distinguish it from a photo message.

  • A sealed submission presents the raw delivery key. The server hashes it, compares against the recipient's stored digest, and accepts on match — without authenticating the sender.

The delivery key is an authorization capability, not an identity. Anyone the user has ever messaged can send sealed; rotating the key is how a user cuts off a spammer, and rotation must therefore be cheap and re-distributable.

14.5 Server contract

POST /e2e/messages gains a sealed mode, selected by presenting delivery_key instead of relying on the session:

{ "to": 12, "delivery_key": "<b64 32B>",
  "messages": [ { "to_device": "<uuid>", "payload": "<b64 sealed blob>" } ] }

In sealed mode the server MUST:

  • accept the request without an authenticated user, and MUST NOT record one;
  • store from_user_id = 0 and from_device = '' — not the session's values, and not NULL, so a sealed row is indistinguishable from any other sealed row;
  • rate-limit by recipient and by IP rather than by sender, since there is no sender;
  • reject if the digest does not match, with the same generic error and timing as any other rejection, so the route cannot be used to probe who has published a delivery key.

GET /e2e/messages returns from_user: 0, from_device: "" for sealed rows. The client learns the real values from the inner JSON after decryption.

⚠️ The authenticated path must not be silently preferred. If a client falls back to authenticated submission whenever a delivery key is missing, the metadata leak returns with no signal. Clients SHOULD surface which mode a conversation is using, and a user who has enabled sealed sending SHOULD be told when a message could not be sent sealed.

14.6 Open

  • Sender IP. Unaddressed and unaddressable at this layer; a relay operator correlating IPs defeats sealed sender for a targeted user.
  • Recipient still visible. Signal's private contact discovery and group sending have no analogue here.
  • Spam. Rotation is the only lever, and it costs a redistribution to every contact.

15. Implementation status for §13 and §14

Recorded the same way as §12.10: what has actually been run.

Web engine Server Web client wiring iOS Android
§13 PQ ratchet n/a — no server change needed ✅ verified cross-engine
§14 sealed sender ✅ sealed mode + delivery keys ✅ wired end to end ✅ wired end to end

§13 is complete and verified end to end on the web: 32 tests in engine/test/ratchet.mjs covering lockstep advance with distinct keys per step, repetition until the chain is answered, out-of-order delivery, refusal of a stripped ratchet step, and survival of a reload. The server required no change at all — it validates lengths and relays whatever version the device signed for, which the cross-layer suite confirms by running v3 traffic through the unmodified relay routes. A v3 client and a v2 peer negotiate v2, verified against the real iOS engine.

§14 is wired end to end on the web (27 of the 77 tests in engine/test/client.mjs). A client mints and publishes a delivery-key digest, hands the key itself to each contact as a kind: "delivery_key" content envelope on first message, and seals automatically once it holds a peer's key. Verified: the relay stores from_user_id = 0 and from_device = "", the real sender is recovered from inside the ciphertext, a forged sender claim produces no message and no session, a wrong delivery key is refused with the same generic error as an unknown recipient, and rotation invalidates keys already handed out.

⚠️ The first message to a new contact is necessarily unsealed — you cannot seal to someone whose delivery key you do not yet hold, and the key arrives in that first exchange. So sealed sender protects an ongoing conversation, not its first moment. canSealTo() and lastSendWasSealed exist so a client can show which mode was used rather than degrading silently.

⚠️ Still true regardless: the relay sees the recipient, timing, size and the sender's IP. Sealed sender is not anonymity.

iOS implements both. Verified cross-engine in engine/test/interop.mjs: six alternating turns of the PQ ratchet, each with a distinct ML-KEM key decapsulated by the other engine; a Swift seal opened by JS and a JS seal opened by Swift; a seal replayed at a different device refused; and delivery-key digests agreeing. The whole iOS tree typechecks against the iOS 16 floor, so the availability gating still holds — but §12.11 still applies: a device below iOS 26 gets none of this, because CryptoKit has no MLKEM768 there.

🔴 Android remains v1 and has still never been checked against another engine (§10).


16. Encrypted backups

Status: DRAFT / proposed.

16.1 What a backup may contain, and why that is the whole design

The obvious backup — serialise the vault, encrypt it, restore it — is wrong here, and dangerously so. The vault holds live ratchet state. Restore it onto a second device while the first is still running and both devices derive the same message keys for the same message numbers. Two AES-GCM encryptions under one key with one nonce is a catastrophic failure, not a degraded one: it leaks the XOR of both plaintexts and destroys the authentication guarantee. It would also duplicate one device_id across two installs, so both race on the same inbox and the same prekey pool.

So a backup here deliberately contains no ratchet state and no device identity:

Included Excluded
decrypted message history ratchet sessions (rk, chain keys, skipped keys)
identity pins and verified flags for contacts our own IK_dh / IK_sig
pinned minProto per device signed and one-time prekeys
delivery keys held for contacts our own delivery key

A restored device therefore keeps its own new identity, registers as a new device, and re-establishes sessions from scratch. What it recovers is the conversation history and the trust the user had already built — which is what people actually lose when a device dies.

⚠️ Peers will see a new device. Restoring is not invisible: contacts get an identity-change prompt, exactly as they would for a reinstall. That is correct and must not be suppressed — §11 exists precisely so a new key is never silently accepted.

16.2 Format

{
  "v": 1,
  "kdf": { "alg": "PBKDF2-HMAC-SHA256", "salt": "<b64 16B>", "iters": 600000 },
  "nonce": "<b64 12B>",
  "ct": "<b64 AES-256-GCM ciphertext+tag>"
}
key   = PBKDF2-HMAC-SHA256(passphrase, salt, iters, 32 bytes)
ct    = AES-256-GCM(key, nonce, JSON(payload), AAD = canonical JSON of the header)

The AAD covers v and the whole kdf object, so the iteration count and salt cannot be edited without invalidating the tag — a restore therefore cannot be tricked into deriving a key with weakened parameters.

PBKDF2 is a compromise, stated rather than hidden. It is not memory-hard; Argon2id would be materially better against GPU cracking. It is used because WebCrypto and CommonCrypto both provide it natively on every target platform, and shipping a hand-rolled or vendored Argon2 to browsers is a worse trade than a high iteration count. 600 000 iterations is the current OWASP guidance for PBKDF2-HMAC-SHA256. A weak passphrase is the real limit here, and any UI MUST say so rather than implying the backup is safe by construction.

16.3 Rules

  • A backup file is as sensitive as the plaintext history, because that is what it is.
  • Implementations MUST NOT upload backups anywhere by default. There is no server route for them and none should be added: a relay that stores backups is a relay that stores everything, which contradicts §14 entirely.
  • Restoring MUST NOT resurrect a device_id. The restoring device registers fresh.
  • Restoring MUST merge, never blindly overwrite: a pin that already exists locally and disagrees with the backup is an identity change and must be surfaced, not silently replaced by whichever copy is older.

17. Multi-device

Status: DRAFT / proposed.

17.1 What is already solved

Senders encrypt to every device of a recipient (§1), so inbound messages already reach all of a user's devices. Nothing needs to be built for that, and it is worth stating plainly because it means multi-device is a much smaller problem here than it looks.

What is missing is the other half: a user's own outbound messages exist only on the device that sent them.

17.2 Sent-copies

When a device sends a message, it also encrypts a copy to each of the user's other devices through an ordinary ratchet session, as a content envelope (§6.6):

{ "fnc": 1, "kind": "sent", "to": 12, "body": "…", "at": 1787260000 }

No new server surface: these are ordinary envelopes addressed to devices of the sending user. Receivers MUST render a sent copy as an outgoing message in the thread with to, and MUST NOT notify on it.

17.3 Linking a new device — the part that must not be automated

🔴 A new device of your own is cryptographically indistinguishable from one an attacker planted under your account. This is the same problem as §11, and it is more dangerous here, because a device the user's other devices trust receives sent-copies of everything.

Therefore:

  • A new own-device MUST NOT be trusted automatically, however convenient that would be.
  • Linking MUST require explicit confirmation on an already-trusted device, and the confirmation SHOULD be a safety-number comparison between the two own devices (§7 works unchanged — they are just two identity keys).
  • Until confirmed, the new device MUST NOT receive sent-copies and MUST NOT receive a history backfill.
  • A client MUST surface the full list of linked devices and allow removing one.

⚠️ Adding a device is an identity change to your contacts. They will see a key they have never accepted and be prompted, exactly as for a reinstall (§11). This is correct and MUST NOT be suppressed to make linking feel smoother — the prompt is the only thing standing between a user and a planted device. Clients SHOULD warn the person doing the linking that their contacts will be asked to re-verify.

17.4 History backfill

On linking, an existing device MAY stream history to the new one as content envelopes:

{ "fnc": 1, "kind": "history", "seq": 3, "of": 12, "items": [ … ] }

Backfill is best-effort and bounded — it costs one envelope per chunk per device and is not worth stranding a link over. A client SHOULD send newest-first so the useful part arrives first, and MUST tolerate a partial backfill rather than retrying forever.

17.5 What this does not give

  • No server-side history. A device that was never linked cannot recover messages that predate it, except from a backup (§16). This is a design choice, not an omission: the alternative is the relay holding conversation history.
  • Prekey pools are per device, so each linked device registers and replenishes its own.
  • Removing a device does not retract what it already received.

18. Implementation status for §16 and §17

Web engine Web client Server iOS Android
§16 encrypted backups createBackup / openBackup exportBackup / importBackup n/a — no server route, by design
§17 multi-device ✅ reuses the ratchet unchanged ✅ linking, sent-copies n/a — no server change

Neither needed a server change, and neither should ever get one: a relay that stores backups or brokers device linking is a relay that stores everything, which contradicts §14.

Covered by 51 of the 113 tests in engine/test/client.mjs, including the three failure modes that make these features dangerous if implemented naively:

  • a backup payload containing no ratchet state and no identity (§16.1);
  • a restore whose local pin disagrees with the backup reporting a conflict and keeping the local pin, rather than silently letting a stale backup re-trust a rejected key;
  • a sent-copy from an own-device that was never linked being ignored, even though the ratchet authenticates it as ours.

🔴 Not implemented: history backfill on link (§17.4). A newly linked device receives sent-copies from that moment on, but gets no history. importBackup is the only way to recover older messages today.

🔴 iOS and Android have neither feature. Backups and sent-copies are per-client; nothing about them is negotiated, so a v3 iOS device simply has no backup export and ignores sent content envelopes it does not implement.

19. Key transparency

The relay is the sole distributor of prekey bundles, so a dishonest one can hand out an identity key it controls. Safety numbers (§7) detect this, but only if both people compare a string by hand. This section makes substitution leave evidence instead.

19.1 The log

An append-only log in the RFC 6962 shape. Leaves are hashed under 0x00 and interior nodes under 0x01, so a leaf can never be reinterpreted as a node. The tree is split at the largest power of two strictly below the size, which is what makes its shape a function of its size alone — two verifiers agree without exchanging structure.

Every device key the relay serves is appended before it can be served, in the same database transaction as the write it records. A republication that does not change the identity key (prekey replenishment, SPK rotation) appends nothing: entries that mean something would otherwise be buried under entries that do not.

19.2 Leaf contents

leaf = SHA-256( 0x00 || "v1|" || kind || "|" || user_id || "|" || device_id
                     || "|" || ik_sig || "|" || ik_dh )

kind is publish or revoke. Built by concatenation rather than by a JSON encoder: a leaf hash must be reproducible by every independent verifier forever, and an encoder is free to change key order, spacing or escaping between releases.

19.3 Routes

All four are unauthenticated. A log only its own users can read is not a transparency log; the value is that anyone — a researcher, a monitor, the other party — can fetch a head and check it. They expose commitments to public keys, which were already public.

GET /e2e/log/head {size, root, signature, audience}
GET /e2e/log/inclusion?index=&size= audit path for one leaf
GET /e2e/log/consistency?from=&to= proof that from is a prefix of to
GET /e2e/log/device/{device_id} every entry for a device, with its index

The head is signed with the relay's issuer key over "fnkt-v1|" || size || "|" || base64(root). The signature is what makes a fork evidence: two heads of the same size with different roots, both validly signed, are a statement the relay cannot retract or blame on a client.

size on the inclusion route defaults to the current head but accepts an older one, so a client can verify against a head it already pinned rather than one it is being handed now — which is the entire point of pinning.

19.4 What this does and does not buy

Does. The relay cannot serve a key it has not committed to. It cannot rewrite history: a consistency proof between any two heads it has signed would fail. A client can confirm that the key it was handed is the key in the log, and that its own device's entries are the ones it published.

Does not, on its own. A relay that forks the log — showing each victim a self-consistent branch — is invisible to inclusion and consistency proofs, which each victim can verify perfectly against the branch they were given. Detection requires the heads to be compared between clients: §19.4.

19.4a Gossip

Clients exchange the head each was last shown as a control envelope (kind: "kt_head") inside the ratchet. In-band is the point: the relay is the party being audited, so it must not see which head each client holds and must not be able to rewrite one in transit. A head exchanged in the clear lets a forked relay answer each client with the branch that client already believes.

On receiving a peer's head:

  • Equal size, equal root — agreement, nothing to do.
  • Equal size, different root — a fork on its face. No proof is requested, because none could exist.
  • Different sizes — ask the relay to prove the shorter is a prefix of the longer. Detection works precisely because the relay is the only party that could produce that proof, and if it served two branches, it cannot. A refusal and a bad proof are treated identically.

Once a longer head is proven to extend ours, it is adopted.

This detects a fork between any two people who talk to each other. It does not detect one against a user who talks to nobody, and a relay that forks consistently per social component still evades it. That is inherent to gossip and is why key transparency is described here as raising the cost of substitution rather than eliminating it.

Also does not. Nothing here is enforced on the client yet: the relay offers proofs, and no shipped client demands one. A log nobody checks is a log.

19.5 Implementation status

Relay Web engine iOS Android
§19.1–19.3 log, proofs, signed head src/keylog.js KeyLog.swift
Client demands a proof before first use n/a ✅ opt-in ✅ opt-in
Append-only pinning across sessions n/a
§19.4 gossip between clients n/a ✅ consumes kt_head

Verification is checked across engines and against the running relay, not transitively: test/interop.mjs proves the two engines compute the same leaf hash and both reject a reversed audit path, and test/relay.mjs drives the iOS verifier against proofs the real relay produced. Transitive agreement would leave a shared misreading of the wire format invisible.

The web engine recomputes the leaf from the served bundle rather than trusting the leaf the relay returns — echoing back its own bytes would otherwise satisfy the check — and refuses with KeyNotLoggedError or KeyLogForkError. The head is pinned on first sight and every later fetch must prove consistency with it.

requireKeyLog is opt-in because a relay predating the log has none, and refusing every peer on a deployment that cannot answer is a worse failure than the one being prevented.

⚠️ Audit-path ordering. RFC 6962 audit paths are ordered leaf-first. A top-down reading of the same array produces the correct root for any left-spine index, so an implementation that reverses it passes any test that only checks index 0. test/relay.mjs verifies every index in the log for this reason — this was a real bug in the first verifier written here, and it is exactly the kind that a happy-path test certifies as correct.

20. Post-quantum authentication

§12 makes key agreement hybrid, so traffic recorded today is not readable once a quantum computer exists. Identity keys are still Ed25519, so an adversary with one can forge a bundle signature in real time and be believed as anybody. Harvest-now-decrypt-later is addressed; impersonate-later is not. This section addresses it.

20.1 Construction

A device MAY publish an ML-DSA-65 identity public key (ik_pq, 1952 bytes) alongside its Ed25519 one, and sign its signed prekey with both:

spk_sig     = Ed25519-Sign(ik_sig_priv, spk)          — §3, unchanged
spk_pq_sig  = ML-DSA-65-Sign(ik_pq_priv, spk)         — 3309 bytes

Both cover the same bytes. A verifier that holds ik_pq MUST check both and accept only if both pass.

Hybrid rather than a replacement, for the same reason §12 is hybrid: an attacker must break both schemes rather than whichever turns out to be weaker, and a flaw later found in ML-DSA leaves the device no worse off than it is today. ML-DSA is the newer of the two and has had the less adversarial decade.

20.2 Optional by design

ik_pq is absent on a device that has not published one, and a verifier treats absence as classical only rather than as an error. A required field would make every existing device unreachable the day it shipped.

This is deliberately independent of the §12 v2 block: post-quantum authentication and post-quantum key agreement are separate capabilities, and a device may have either without the other. The relay therefore serves ik_pq whether or not the device has a complete v2 block.

20.3 Key derivation and storage

The signing key is derived from a 32-byte seed, so a device stores 32 bytes and rederives the 4032-byte secret key on demand. Storing the expanded key would have meant a new vault format; storing the seed did not.

20.4 Relay behaviour

The relay does not verify identity signatures — it never has, and §8 is explicit that verification is the client's job. It validates lengths at publication (1952 and 3309), because a wrong-length key can only ever fail on every peer, and failing once at publication names the problem better than failing silently per correspondent.

20.5 Downgrade

A relay that strips ik_pq from a served bundle presents a device as classical when it is not. Clients MUST pin: once a peer device has been seen with a post-quantum identity, a later bundle for that device without one is a downgrade and must be refused, exactly as §12.9 requires for the v2 block.

The web engine pins the fingerprint of the identity, not merely the fact that one existed: a relay that substituted its own ML-DSA key would satisfy a boolean pin. A device seen without an identity is pinned as classical and may publish one later; a device seen with one may never afterwards appear without it.

The pin is consulted when a session is established, which is the only moment a downgrade can be attempted — an existing session never refetches a bundle.

20.6 Implementation status

Relay Web engine iOS Android
§20.1 publish and serve ✅ iOS 26+
§20.1 verify both signatures n/a — not the relay's job ✅ iOS 26+
§20.5 downgrade + substitution pinning n/a

iOS below 26 has no MLDSA65, so it publishes no identity and cannot check one. Such a device treats every peer as classical and records no pin — accepting a signature it cannot verify would be worse than admitting the limit. It is otherwise unaffected, because §20.2 makes an absent identity a supported state.

CryptoKit and the vendored @noble implementation were checked against each other: they derive the same public key from the same seed (FIPS 204 keygen is deterministic in the seed) and each verifies the other's signatures. Had they not, a device restoring on the other platform would have published a different identity and every contact would have read it as a substitution.

The web engine mints a §20 seed at device creation, publishes the identity on registration, verifies the signature before establishing a session, and refuses with PQDowngradeError, PQVerificationError or PQIdentityChangedError rather than falling back quietly. Covered by test/client.mjs (a stripped identity and a forged signature are both refused) and test/relay.mjs (the signature is verified over a bundle the real relay served).

iOS and Android implement none of this. They are unaffected — ik_pq is optional and absent devices verify classically — but a conversation is only post-quantum-authenticated where both ends are web.

21. Anonymous sender credentials

21.1 What exists, and what it does not do

Sealed submission (§14) is gated by a delivery key: a 32-byte secret the recipient issues, whose SHA-256 the relay stores. A sender presents the raw key, the relay hashes and compares, and accepts without learning who sent. Rotating the key is how a recipient cuts off a sender.

The limitation is precise: the key is a shared secret. One key goes to every contact, so cutting off one person means rotating for everyone and redistributing to all the rest. In practice that means it is rarely done, which means the anti-abuse lever is theoretical.

21.2 Why the obvious fix is a trade, not a win

The natural repair is a distinct delivery key per contact: revoke one digest, and only that contact is cut off. It needs no new cryptography.

It also makes the metadata worse. Today the relay sees "somebody holding Bob's key wrote to Bob." With per-contact keys it sees "the holder of Bob's key #3 wrote to Bob" — a stable pseudonym per sender, which lets it distinguish senders it currently cannot, and link every message from one sender to that recipient over time.

That is a real regression in exchange for a real improvement, and it should be recorded as such rather than shipped as an upgrade. It is not implemented for that reason.

21.3 The construction that gets both

Keyed-verification anonymous credentials (KVAC) over Ristretto255 — an algebraic MAC the issuer can verify with its own key, plus a zero-knowledge proof of possession. This is the mechanism Signal uses for the same problem.

Sketch, in the shape this protocol would need:

  1. Issuance. Alice's client asks the relay for a credential scoped to Bob, presenting Bob's authorisation. The relay issues an algebraic MAC over a commitment to (sender_blinding, recipient_id).
  2. Presentation. Sending sealed, Alice proves in zero knowledge that she holds a valid credential for that recipient — without revealing which credential, and therefore without being linkable to her other sends.
  3. Revocation. Bob rotates only the attribute that scopes Alice's credential; everyone else's continues to verify.

The relay verifies with a key it holds, so no pairing and no public-key signature scheme is required — which is why KVAC is the right family here rather than blind signatures.

21.4 Why it is not implemented

Implementing algebraic MACs and their proofs correctly is not a weekend of work, and a subtly wrong zero-knowledge proof fails open — it verifies, and nobody notices until someone looks. This project has already produced one bug of that shape in code far simpler (§19.5, an audit path read in the wrong direction that a happy-path test certified as correct).

The prerequisites, in order:

  • a vetted Ristretto255 implementation with the constant-time properties the scheme assumes (the vendored @noble/curves provides the group; the credential scheme is what is missing);
  • a written proof transcript specification, so two implementations agree on what is being proven;
  • review by someone who has implemented this class of protocol before.

Until those exist, §14's shared delivery key stands, with its limitation stated here rather than implied to be solved.

22. Private group membership

22.1 What exists

The group registry (§8.9) is server-side and authoritative. The relay stores the roster and enforces it, so it knows exactly who is in every group. Fan-out is per device, so it also sees the shape and size of every group over time.

This is the largest remaining metadata leak in the protocol. Message contents in a group are end-to-end encrypted; who is in it is not protected at all.

22.2 What it would take

The same primitive as §21, applied to membership rather than sending:

  1. On joining, a member receives a group membership credential — an algebraic MAC over a commitment to (group_id, member_blinding).
  2. Operations against the group (fetching the roster for fan-out, posting, leaving) are authorised by a zero-knowledge proof of holding a valid credential, rather than by the relay looking the member up in a table.
  3. The relay stores an opaque group state it cannot enumerate, and applies changes it can verify are authorised without learning by whom.

22.3 The part that does not follow from the credential

Fan-out. Envelopes are addressed to devices (§8.4), so even a relay that cannot enumerate a roster still observes which devices receive a group's traffic and can reconstruct membership by correlation. A private roster without addressing the delivery pattern buys less than it appears to.

Closing that requires either per-recipient sealed fan-out with padding and delay, or moving group delivery onto a different addressing scheme entirely. Both are larger than the credential work and neither is designed here.

22.4 Status

Not implemented, and not partially implemented. §8.9 documents the current behaviour as a deliberate deviation rather than an oversight, and this section records what the alternative actually costs.