Open Free and open source — read the code

Flamenet Messenger — End-to-End Encryption Spec

Status: DRAFT / Phase 1. Defines wire protocol versions 1 to 3; v3 is current (§13). 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. All three engines implement rotation, retention and spk_id selection: web, iOS (E2EInbox.currentSPK), and Android (DeviceIdentity.rotateSignedPreKey, signedPreKeyFor, and a retired-key ring with its own retention window).

⚠️ This said "Android implements none of it" until 2026-08-29, and the advice attached to it — ship selection before rotation — had already been followed. The order matters and is worth keeping written down: a responder that rotates without spk_id selection silently loses every session already in flight against the old key.

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 usually an image attachment addressed to every buddy (a story with no attachment at all is §6.21): 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 three 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.
  • an optional top-level gname — what the group is called. Senders SHOULD put it on every group envelope; receivers MUST ignore it unless the sender is a member of gid per the registry.

The group roster lives in a server-side registry (§8.9) in plaintext, and that is a real leak (§22). The name does not, and no longer did as of 2026-08-30: it was stored in a column the relay never read, which made it the single most revealing string the server held for a field that bought it nothing. Members learn it from each other over the pairwise ratchets group traffic already uses, and hold it locally.

Two deliveries carry it, and both are needed:

  • an announcement at creation and on adding a member, sent as a delivery_key envelope (§14.4) carrying gid and gname. That kind rather than a new one because a new control kind renders as raw JSON on builds that predate §6.10's ctl flag; delivery_key is a kind they already consume in silence and already ignore unknown fields on.
  • gname on ordinary group envelopes, which repairs the case the announcement cannot reach: a device set up later, or one that was offline. There is no server copy to ask for, so without this a member could be permanently without the name.

Cost: gname makes group envelopes bigger. AEAD ciphertext is about the length of its plaintext, so repeating the name on every group message adds roughly len(name) bytes to each one, systematically, for as long as the group exists. §8.9 says the relay cannot tell a group message from a 1:1 message; that was always carrying weight it barely deserved — fan-out already produces N envelopes at one instant — and this makes the size difference a slightly stronger signal than it was. It is not a distinguisher on a single envelope, since message lengths vary far more than a name does, but a relay clustering envelope sizes for one recipient over time has marginally more to work with.

Accepted rather than padded: the alternative is a member who can never learn what their own group is called, because there is no longer a server to ask. Padding group envelopes to a fixed size would close it and is not specified here.

Groups that predate this lose their name once. The relay's copy was deleted by the migration and no client had ever stored one, so there is nothing left to recover it from — every such group reads as the placeholder on every device until a member renames it. Renaming is therefore part of the change rather than a nicety: it sets the name locally and announces it exactly as creation does. Before this, a group's name was fixed at creation and no client could change it.

Known gap: the creator's own other devices. Fan-out excludes the sender's own account, so a second device belonging to the person who named the group is not told by either delivery, and shows the placeholder until any other member sends something — which does reach it, because fan-out is per device. This is the same self-healing shape as a missed announcement, and closing it properly needs a sender-to-own-devices copy, which this protocol does not have.

GET /e2e/groups still returns a name, and it is a fixed placeholder — the same word for every group — kept so clients that predate the change do not render a blank row. Clients from here on MUST ignore it.

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).

6.9 Message ids, replies, and reactions

A reply or a reaction has to name an earlier message, and until this section there was nothing to name it by: message ids were per-device counters assigned on receipt, so two devices held different numbers for the same message.

mid. A sender MAY put an id on a message it sends, in the content envelope:

{ "fnc": 1, "kind": "text", "mid": "<uuid>", "caption": "hello" }

It MUST be unique within the conversation; a UUID is the obvious choice. A message without a mid is still valid — plain UTF-8 with no envelope remains a legal message — but nothing can refer to it, so it cannot be reacted to. Implementations SHOULD put a mid on every message they send.

re — what a message is about. Present on a reply and on a reaction:

{ "mid": "<the id being answered>", "author": 12, "preview": "what they said" }

preview is REQUIRED and carries the sender's own copy of the quoted text, truncated. This is deliberate redundancy. A recipient device may have joined the conversation after the quoted message, or cleared its history, and would then have nothing to render; a reply that shows > [missing] is worse than one that shows what was actually said. The preview is what the sender saw, which is the only copy either party can be sure of. A receiving client MUST render the preview as supplied and MUST NOT treat it as authenticated content attributable to the quoted author — it is the replier's assertion about what was said, and a replier can lie about it. Clients SHOULD render it as a quote attributed to the replier's view, not as the original author's words.

For the same reason, a client that lets a reader tap a quote to reach the message it answers MUST select that target by mid alone. Matching on preview — or falling back to it when the mid is unknown — would let a crafted reply send the reader to a message of the sender's choosing, and would do it invisibly, because the quote itself would look correct. A mid the receiver does not hold has no target: the correct behaviour is to render the preview and offer no jump, not to guess.

Reactions. kind is "react", caption is the emoji, re names the target:

{ "fnc": 1, "kind": "react", "caption": "👍", "re": { "mid": "<target>", "author": 12, "preview": "" } }

An empty caption removes this sender's reaction from that message. Removal is a message rather than an absence because the other device has already rendered the first one and there is no other way to reach it.

A receiving client MUST NOT render a reaction as a message. It is folded onto the target and, if the target is unknown, dropped. It MUST NOT raise a notification: being buzzed for a thumbs-up is the thing people turn a messenger off over. One reaction per sender per message — a second one replaces the first.

gid applies as elsewhere: a reaction on a group message carries the group id and routes to the group thread.

Status. Implemented in all three engines. iOS and JavaScript are checked against each other in both directions by test/interop.mjs — including a preview containing quotes and a newline, which is where two hand-ordered JSON writers drift apart — and the Kotlin engine carries the same fields with its own coverage in ContentTest.

⚠️ This paragraph said "the Kotlin engine does not implement this section, along with most of the rest of the protocol" until 2026-08-29, long after it did. See §10.

6.10 Unknown kinds

Two failure modes pull in opposite directions, and the difference between them cannot be guessed by a receiver:

  • Unknown content — a kind carrying something a person sent. Dropping it silently loses a message, which is the one failure a messenger must never have. Receivers MUST render a placeholder; both apps show [unsupported attachment].
  • Unknown control — a kind meant for a layer, not a person. Rendering it puts a bubble in the conversation that the user can neither read nor act on.

Control envelopes are otherwise recognised by name, and a name is exactly what a receiver older than the sender does not have. So a control envelope whose kind is not one of the original four (delivery_key, kt_head, sent, avatar) MUST set:

{"fnc":1,"kind":"…","ctl":1}

A receiver MUST treat any envelope carrying ctl as control traffic and consume it silently, whether or not it knows the kind. Without the flag, every control kind added after a given release renders as a placeholder bubble in that release — a bug shipped retroactively to every install, by a change made later somewhere else.

⚠️ Builds predating the flag still show the placeholder. Nothing fixes those; the flag stops the set of affected releases from growing.

🔴 The flag is the number 1, and a receiver MUST also accept true. The Kotlin engine wrote "ctl":true until 2026-08-30, and those builds are on phones and cannot be recalled. The cost of the disagreement was far larger than the field: the Swift engine decodes it as a number through a synthesised Codable, so a boolean did not fail to set the flag — it failed the whole envelope's decode and dropped the message onto the plain-text path. An Android user turning on disappearing messages sent an iPhone a bubble containing raw JSON, and the timer was never applied at that end.

Nothing caught it because each engine parsed what it had written itself, the piece-level tests passed on both sides, and no vector covers this envelope. It is the standing argument for the interop harness: agreement between two implementations that share an author is not agreement.

6.11 Disappearing messages

A conversation MAY carry a timer. Both sides then delete each message once it has been on the device that long, and the announcement rides the ratchet like any other content:

{"fnc":1,"kind":"ttl","ctl":1,"secs":86400}

secs is the lifetime in seconds; 0 turns the timer off. The envelope sets ctl (§6.10), so a build that does not recognise ttl stays quiet instead of rendering it, and receivers MUST NOT show a ttl envelope in the conversation.

The timer belongs to the conversation, not to a message, and it is symmetric. Either side may change it; the change applies on both devices, to messages already delivered as well as to new ones. A one-sided timer would be a worse promise than no promise at all — the interface would say a message had disappeared while a copy stayed on the other phone.

The relay is not told. ttl is a content envelope inside the ciphertext (§6.6), so a timed conversation is indistinguishable at the server from any other; the server's own retention (§9) is a separate mechanism and is unchanged by it.

⚠️ Ages MUST be measured from the time the receiving device recorded the message, never from a timestamp the sender supplied. A sender who chose the age could backdate a message so it vanished from the recipient's phone before it was read, or forward-date one so it never expired at all. The device's own record time is the only clock in the exchange that the other party cannot set.

⚠️ A message carrying no usable date MUST be kept. Those predate the field, and an unknown age is not evidence of an old one. Deleting somebody's history on a guess cannot be undone.

Attachments MUST be purged with the message that referenced them. A bubble that vanished while its blob stayed in the cache is precisely the failure the feature exists to prevent.

⚠️ Expiry runs when the conversation is read, not on a schedule. Nothing wakes the device to delete a message, so one past its lifetime can remain on disk until something next opens that thread. A periodic sweep would narrow that window without closing it, since it too leaves data between runs. What is guaranteed is narrower and worth stating exactly: an expired message is never rendered, and is removed the moment the thread is touched.

🔴 The timer is not enforcement against the recipient. They hold the plaintext — they can screenshot it, photograph the screen, or run a build that ignores secs entirely. Disappearing messages limit what accumulates on a device that is later lost, seized, or handed to somebody else. They do not make a message unrecoverable by the person it was sent to, and the interface MUST NOT imply that they do.

A message containing a URL MAY carry a preview of it:

{"fnc":1,"kind":"text","caption":"look at this https://example.com/x",
 "lnk":{"url":"https://example.com/x","title":"…","desc":"…"}}

🔴 The SENDER fetches the page, and the preview travels inside the ciphertext. The obvious implementation — each recipient fetches the URL to draw a card — is the one that must not be built. It tells the site the recipient's IP address, that they received a link, and roughly when they read it; on a group message it does so once per member. A messenger that hides the message and then has every reader announce its contents to a third party has given away the thing it was protecting.

Receivers MUST NOT fetch anything to render a preview. A preview that is absent is a message with a plain URL in it, which is a fine thing to be.

⚠️ Sender-side fetching is a smaller leak, not no leak. It tells the site that somebody is about to share that URL, from the sender's address, before the message is sent. Clients MUST offer a way to turn previews off, and MUST NOT fetch for a URL the user has not chosen to send.

⚠️ Fields are advisory and attacker-controlled: title and desc come from a page the sender did not write. Receivers MUST render them as text, never as markup, and MUST bound their length. url is what the user sees and what a tap follows — a preview whose title says one destination while url points at another is a phishing primitive, so clients SHOULD show the host.

Everything here is optional. A client that does not implement §6.12 shows the message text, URL included, and loses nothing but the card.

6.13 Embedded live streams in stories

A story MAY carry a live stream URL alongside its blob:

{"fnc":1,"kind":"story","att":{…},"caption":"live now",
 "stream":"https://live.example.com/s.m3u8"}

The blob is then a poster — a still the viewer sees before deciding — and the stream is what plays if they choose to.

🔴 This is the one media path where the viewer must contact a third party, and the design cannot avoid it. A photo or a video story is a sealed blob: the relay stores ciphertext and nobody else is involved. A live stream is continuous and hosted elsewhere, so there is nothing to seal — watching it means the viewer's device connects to that host, which learns their IP address and that they watched. §6.12's answer (have the sender fetch it) does not exist here, because a stream is not a thing that can be fetched once.

So the rule is disclosure rather than prevention:

  • The URL travels inside the ciphertext, so the relay never learns what is being watched or by whom.
  • Clients MUST NOT autoplay. Playback is an explicit action.
  • Before connecting, the client MUST name the host and say what it learns. "This will play a video" is a button; a disclosure names the destination, because nobody can make a decision about "somewhere on the internet".
  • Clients MUST refuse a URL that is not http(s) or that has no host. A file: URL arriving from another person is not something to hand a media player, and a destination that cannot be named cannot be consented to.

⚠️ The host shown MUST be derived from the URL, never from the caption. A story captioned "youtube.com" pointing somewhere else is the same phishing shape §6.12 guards against.

6.14 Urgent envelopes, and what they cost

A sender MAY mark an envelope urgent:

{"to": 12, "from_device": "…", "urgent": true, "messages": [ … ]}

🔴 This exists because a ringing phone cannot be built any other way, and it is not free. The relay holds ciphertext it cannot read, so it cannot tell a call offer from a birthday message — and iOS will only ring a locked, suspended app for a VoIP push, which Apple requires to result in a reported call. Sending one for every message would be abusive and gets an app's VoIP privileges revoked; sending one for none means calls do not ring.

So the sender says. The cost is stated plainly rather than buried: the relay learns that a particular envelope is time-critical, which in practice means "this is probably a call". It does not learn who is calling on a sealed submission, what was said, or whether the call was answered — but it learns more than it did before this flag existed, and that is a real change to the metadata this design leaks.

Rules:

  • Clients MUST set urgent only for call signalling. Marking ordinary messages urgent spends the same metadata for nothing and, on iOS, risks the app's ability to ring at all.
  • Relays SHOULD deliver an urgent envelope with a VoIP push where the platform has one, and an ordinary push otherwise.
  • urgent is advisory and unauthenticated: a relay MUST NOT treat it as proof of anything, and a client MUST NOT rely on it arriving.
  • ⚠️ Absent means false. Clients that predate this field keep working, and their calls keep failing to ring a locked iPhone — which is the behaviour they have today, not a regression.

Android needs none of this. It holds its own connection and rings from its own foreground service, so an Android client MAY set the flag for interop but gains nothing from it. The asymmetry is Apple's process model, not a design choice, and it belongs on the privacy page for the same reason the push dependency does.

6.15 Editing a message that was already sent

A sender MAY replace the text of a message it sent earlier. kind is "edit", re names the target, and caption carries the new wording:

{ "fnc": 1, "kind": "edit", "caption": "meet at 9", "re": { "mid": "<target>", "author": 0, "preview": "" } }

The receiver decides authorship, and MUST NOT read it from the envelope. re.author is a claim: any member of a group can put any user id there, and a 1:1 peer can put yours. A receiving client MUST apply an edit only to a message it already holds and already attributes to the sender of the edit. An implementation that trusted re.author would let one envelope rewrite what somebody else said, inside the reader's own transcript. Senders SHOULD write 0 in that field, as this specification's reference implementations do, precisely so that nothing is tempted to believe it.

An edit MUST be visible as an edit. A receiving client MUST show that the message was changed after it was sent. This is not a courtesy: an edit the reader cannot detect puts new words into a bubble they have already read and trusted, and removes any trace that the original was different — which is a forgery primitive, and it is the reason this section exists at all rather than the feature being a local convenience.

Further requirements:

  • A receiving client MUST NOT render an edit as a message. It is folded onto the target and, if the target is unknown, dropped — the same rule as a reaction (§6.9), and for the same reason: an unrecognised edit drawn as text appears as a duplicate of the message it was meant to replace.
  • An edit MUST NOT raise a notification. Being buzzed a second time because somebody fixed a typo is the behaviour people turn a messenger off over.
  • An edit MUST NOT empty a message. Retracting a message is a different operation with different honesty requirements, and reusing an edit for it leaves a blank bubble marked "edited", which explains nothing.
  • Edits are monotonic. Two edits can arrive out of order, because a relay orders by when it received an envelope rather than by when it was written. A receiving client MUST NOT let an edit written earlier replace one written later; the relay's timestamp for the envelope is the ordering key.
  • A message with no mid cannot be edited, for the same reason it cannot be reacted to: there is nothing to name it by.

gid applies as elsewhere: an edit of a group message carries the group id and routes to the group thread.

Status. Implemented on both clients. iOS implements this section end to end. The Android app records a mid for every line and applies an inbound edit to the message it names, refusing one that does not come from that message's author.

🔴 This paragraph said the opposite until 2026-09-04, and the reason it gave had already stopped being true — it read "the Android app cannot yet apply one, because its transcript does not record a mid for each line", and drew the conclusion that replies and reactions were blocked with it. The transcript does record a mid; reactions and replies work.

⚠️ Corrected the way §15 requires: by a run, not by reading the code. The evidence is InboundEditAppliedTest in the Android repository, which applies an edit through a real Session and a real vault and asserts the transcript changed — including the two refusals, an edit from somebody who did not write the message and an edit of this device's own message claiming another author. Both were proved by removing the author check and watching exactly those two fail.

⚠️ Before that run existed, applyEdit was implemented and wired with nothing exercising it: InboundControlTest covers the codec and says in its own header that the app-side application needs a vault and is out of scope. A status claim resting on that would have been a guess that happened to be right.

6.16 Forwarding

A sender MAY pass a message on to another conversation. A forward carries the content and nothing about where it came from:

{ "fnc": 1, "kind": "text", "fwd": 1, "mid": "<a NEW id>", "caption": "meet at 6" }

fwd follows §6.10's flag shape — 1, with true accepted on the way in.

What MUST NOT travel. A forwarded envelope MUST NOT carry re, and MUST NOT carry the mid of the message it was copied from. The first is the reply chain, whose preview is somebody else's words from a conversation the new recipient was never part of. The second matters more than it looks: a mid is unique within a conversation, so the same one appearing in two conversations is a handle the relay can use to tie those two conversations together — which is the link a forward is supposed to break. A forward MUST mint a fresh mid.

Attachments are re-uploaded, not re-referenced. A forwarded blob MUST be sealed again under a new key with a new id, rather than the original att being repeated. Repeating it would work and would be far cheaper, and it would let the relay watch one blob being fetched from two conversations — the same correlation the mid rule closes, through a different door. ⚠️ The consequence is that forwarding an attachment can fail where forwarding text cannot: the blob may have expired from the relay. That is a failure to report, not one to paper over by sending the caption alone. A forwarded attachment MUST NOT inherit the original's exp: a story hours from vanishing must not hand the new recipient a copy that vanishes with it.

What fwd says, and what it does not. It says the sender is passing on somebody else's words. It says nothing about whose. A receiving client SHOULD show that a message was forwarded, and MUST NOT present it as the sender's own composition; it has nothing to attribute it to and MUST NOT invent one. Attribution is something a person types if they want it, and this specification deliberately provides no field for it — a field would become a default, and a forward that carries its author by default is not a forward.

gid applies as elsewhere.

Status. In progress. The iOS client implements this section. The Kotlin engine implements the codec, so a forward from another client is recognised and shown as one; the Android app cannot originate a forward yet.

6.17 A timer on one message

A sender MAY put a timer on a single message rather than on the whole conversation. It rides the content envelope in secs, alongside the message it applies to:

{ "fnc": 1, "kind": "text", "mid": "<id>", "secs": 60, "caption": "meet at 6" }

The shorter timer always wins. Where a message carries secs and its conversation has a timer from §6.11, the message lives for the lesser of the two. A per-message timer may only take life away, never add it. This is not a tidiness rule: without it a sender could defeat the recipient's conversation timer by putting a long secs on every message, turning a setting the recipient chose on their own device into something the other party overrules. A receiving client MUST implement it this way.

A duration, never a deadline. secs is how long, not when. An absolute expiry would be the sender's clock, and a sender who chose it could backdate a message so it vanished from the recipient's phone on arrival, or forward-date one so it never expired. The receiver counts from the time IT recorded the message — the rule §6.11 already follows, for the same reason.

Further requirements:

  • Zero, absent, or negative all mean no timer, and MUST NOT be read as "expire immediately". A message whose sender chose nothing must not vanish on arrival.
  • A sender MUST expire its own copy too. A timer that only cleared the recipient's phone would leave the message on the device belonging to the person who set it, which is the opposite of the promise.
  • Pruning MUST run per message, not per conversation. A conversation with no timer of its own can still hold messages that carry one, and an implementation that skips such a thread keeps every timed message in it for ever — while appearing to work on the sending device.
  • ⚠️ secs on a kind: "ttl" envelope means something else entirely: it sets the whole conversation (§6.11). A receiver MUST distinguish them by kind.

What the relay learns: nothing. The content envelope is inside the ratchet, exactly where §6.11's announcement already rides, so a timed message is indistinguishable to the relay from any other. ⚠️ One honest limit, shared with §6.11 and not introduced here: an attachment's blob sits on the relay under the relay's own retention, which may outlast a short message timer. The key is gone from both devices, so the blob is unopenable — but it has not been deleted at the moment the message was.

Status. Implemented in the iOS client and in the Kotlin engine and Android app, including the combining rule and per-message pruning on both.

6.18 Mentions

A group message MAY name the members it is addressed to, in ment:

{ "fnc": 1, "kind": "text", "gid": "<group>", "ment": [12, 34], "caption": "@Ada @Adam thoughts?" }

A mention decides whether the receiver is notified, and nothing else. It confers no permission, changes no state, and is not evidence of anything. This matters because ment is a claim by the sender like every other field in the envelope: the worst a dishonest one can achieve is a notification the reader is able to turn off.

Which is the rule that makes mentions safe to have. A mention that always broke through a mute would hand every member of a group a way to interrupt somebody whenever they liked — which is how mentions become the thing people leave a group over. A mention that never broke through would make muting a busy group mean missing the one message that was actually for you. So the responsibility splits:

  • the sender decides that a message is addressed to somebody;
  • the receiver decides whether being addressed is worth a notification.

A client MUST make the second half a setting the receiving person controls, and MUST NOT let anything in an envelope override it. Defaulting that setting to "a mention breaks a mute" is reasonable and is what the reference implementation does; defaulting it to "always, with no way off" is not.

Further requirements:

  • A receiving client MUST resolve names against the group's roster and MUST NOT treat ment as naming anyone who is not a member.
  • ment MUST be bounded and deduplicated on receipt. A sender can name ten thousand ids in one envelope, and the cost of that lands on every recipient's device rather than theirs. The reference implementation caps at 256, far above any real roster.
  • A client MUST NOT notify for a mention of somebody else, and SHOULD NOT let a person mention themselves — the only thing that achieves is making your own phone buzz.
  • Reading the conversation beats a mention. A banner about a message already on screen is no less absurd for naming you.
  • Omitted entirely when there are no mentions, which is nearly every message.

⚠️ Muting is per conversation, not per author. Stated here because getting it wrong makes this section unimplementable: if a mute is keyed on whoever sent an arriving message, a group cannot be muted at all — silencing it means muting every member one at a time, and anyone who joins later is not covered. There is then no mute for a mention to break.

What the relay learns: nothing. ment is inside the ratchet like the rest of the content envelope. A relay cannot tell an addressed message from any other, and in particular cannot tell who in a group is being singled out.

Status. In progress. The iOS client implements this section. The Kotlin engine implements the codec; the Android app does not compose or act on mentions yet.

6.19 Pinning a message

A conversation MAY have one pinned message. kind is "pin" and re names it:

{ "fnc": 1, "kind": "pin", "re": { "mid": "<target>", "author": 0, "preview": "" } }

An empty mid takes the pin down. Removal is a message rather than an absence, for the same reason a reaction's removal is (§6.9): the other device has already drawn it.

A pin is an id, and a receiving client MUST NOT store a copy of the message. This is the requirement the section exists for. A pin that carried the text would keep a message readable after its own timer had removed it (§6.11, §6.17) — pinning would become a way to defeat disappearing messages, silently, from either side of a conversation. The pin is resolved through the thread each time it is drawn; when the message is gone the pin shows nothing and the banner goes with it.

Pins are monotonic. A relay orders envelopes by when it received them, not by when they were written, so two people pinning at once can arrive in either order. A receiving client MUST NOT let a pin written earlier replace one written later, and MUST apply the same rule to an unpin — otherwise a late-arriving unpin from before somebody pinned something new silently clears it. Unlike a message, a pin is shared state both people are looking at, so an inconsistency here does not resolve itself.

Further requirements:

  • A pin MUST NOT render as a message, and MUST NOT raise a notification. Somebody pinning an address you both already have is not news.
  • A pin naming a mid the receiver does not hold shows nothing, rather than an empty banner claiming something is pinned.
  • re.author is not read, exactly as in §6.15. Senders SHOULD write 0.
  • gid applies as elsewhere: a pin in a group carries the group id and pins there.

⚠️ One pin, not a list. A set of pins needs an ordering, a cap, and a rule for what happens when two people add the tenth at once — three more pieces of shared state to keep consistent across devices that cannot talk to each other directly. One pin has exactly one rule, above.

Status. Implemented on Android for both one-to-one and group conversations; iOS implements the one-to-one case. This paragraph previously said a group pin could not resolve, because a group transcript did not record a mid for each message. That stopped being true: Session.sendGroup mints a mid and records it, and GroupPinResolvesTest (Android, 2026-09-04) pins a message in a group thread and reads the message back out of the transcript by the id the pin names.

⚠️ The pin stores an id and never a copy of the text, so it follows an edit rather than quoting words that are no longer in the conversation, and it does not outlive the message being deleted. The same test covers that, because a stored copy is the shortcut that looks identical until the message changes.

6.20 Story replies

A story has no comment thread and no viewer list, and MUST NOT grow one. A reply to a story is an ordinary direct message to its author, carrying a reference to the story it answers:

{"fnc":1,"kind":"text","caption":"where is this?","sre":{"sid":"<story id>"}}

sre.sid is the story's id as the replier received it. The envelope is a normal text message in every other respect: it rides the pairwise ratchet to the author, lands in the existing conversation, and the relay sees nothing it does not already see for any message.

🔴 sre carries no copy of the story — no caption, no blob, no thumbnail. This diverges from re in §6.9, and the reason the two differ is worth stating. A quoted message may be one the recipient never had: they joined late or cleared their history, so §6.9 sends a preview and accepts that it is the replier's assertion. A story's author, by definition, had the story — the only way they no longer hold it is that it EXPIRED. Sending a copy back would therefore do exactly one thing: resurrect content the author's own timer had already removed, at a moment they did not choose. That is the trap §6.19 avoids for pins, and it is the same trap here.

A receiver whose copy of sid has expired or was never held MUST render the reply as an ordinary message, and MAY note that it answers a story that is no longer available. It MUST NOT render a placeholder that implies the content could be recovered.

⚠️ Replying tells the author you watched, and that is a disclosure the replier makes. Stories deliberately have no viewer list: the author cannot otherwise learn who opened one. A reply necessarily reveals it — there is no way to answer somebody without their knowing. The disclosure is therefore acceptable, but it MUST be a decision and not a side effect: a client MUST tell the user, before the first story reply that account sends, that replying reveals they watched. A client MUST NOT send any automatic acknowledgement — a read receipt, a delivery marker, a typing indicator — in response to a story being opened.

⚠️ sid is a local identifier, not a global one. It is meaningful to the pair of devices that exchanged the story and MUST NOT be treated as a handle the relay or any third party can resolve.

A story MAY carry no attachment. The caption is then the whole story:

{"fnc":1,"kind":"story","caption":"back in the city on Thursday","exp":1788000000,
 "bg":3}

att is absent, caption is REQUIRED and MUST NOT be empty, and exp rides at the top level exactly as it does for a story with a blob. bg is an OPTIONAL integer naming one of the client's own background styles; it is a rendering hint with no meaning on the wire, and a receiver that does not recognise the value MUST fall back to its default rather than refuse the story.

A link story is a text story whose caption contains a URL and which carries a §6.12 lnk preview. Everything §6.12 says applies unchanged and is the reason it is not a separate kind: the sender fetches the preview, and it rides inside the ciphertext. A receiver MUST NOT fetch the URL to draw the card — doing so would tell the site the address of every viewer, once per viewer, which for a story is the whole audience rather than one correspondent.

🔴 A client that predates this section renders a text story into the conversation. Its decoder requires att for kind: "story", fails that guard, and falls through to the path that shows caption as an ordinary message. The result is a story appearing as a chat bubble that never expires — the two things §6.6 says a story must not do. This is a real cost and it is stated here rather than discovered: senders SHOULD NOT post text stories until the recipients' builds are known to understand them, and a client MAY choose to send a text story as a rendered image to unknown builds. The alternative — a new kind — would degrade no better, since §6.10 makes an unknown kind invisible rather than legible, and a story silently vanishing is not obviously the kinder failure.

⚠️ A text story carries no blob, so there is nothing for the server's retention purge (§9) to collect. Its entire lifetime is the exp that honest clients enforce, which makes the advisory nature of story expiry stated in §6.6 the only mechanism here rather than a backstop to one.

6.22 Story reactions

A one-tap acknowledgement of a story. It is a sealed direct message to the author, exactly as a story reply is (§6.20), and carries the same reference:

{"fnc":1,"kind":"react","caption":"\ud83d\udc4d","sre":{"sid":"<story id>"}}

kind is react and caption is the emoji, matching §6.9's reaction envelope; sre names the story, matching §6.20. An empty caption withdraws a reaction already sent — removal must be a message rather than an absence, because the author's device has already rendered the first one and there is no other way to reach it.

🔴 re is absent and MUST be. §6.9's reaction names a message by mid and folds onto it; a story is not in the conversation and has no mid there, so a story reaction that carried re would fold onto whatever message happened to share the id — or onto nothing. A receiver MUST route on sre and MUST NOT render a story reaction into the thread as a message.

⚠️ The disclosure is the same as §6.20's, and MUST be made separately. A reaction reveals that you watched, exactly as a reply does. What differs is the deliberation behind it: a reply is typed, and a reaction is one tap on a story that is already filling the screen. A client that has disclosed the reply case MUST NOT treat that as covering this one — the first reaction an account sends MUST be preceded by its own statement that the author will learn they were watched.

⚠️ A client MUST NOT offer a reaction control that sends on the same gesture that advances or dismisses the story. An acknowledgement delivered by a mis-tap is a disclosure the person did not make.

6.23 Multi-item stories

Several pictures posted as one sequence. Each item is an ordinary story envelope (§6.6) with one extra field naming the set it belongs to:

{"fnc":1,"kind":"story","att":{…},"exp":1788000000,
 "set":{"id":"<set id>","i":0,"n":3}}

set.id is an opaque identifier the sender mints, i is the item's position from zero, and n is how many items the set has. A client SHOULD group items sharing a set.id from the same author and present them in i order.

🔴 One envelope per item, not one envelope carrying many. An array of attachments would be the obvious encoding and it degrades badly: a client that predates this section requires a single att, so it would fail the guard and render the whole set as an ordinary message in the conversation (§6.21 documents that failure). Separate envelopes degrade to exactly what they are — several stories from one person, in order, which is what the sender meant and what every existing build already knows how to draw.

⚠️ Expiry is per SET, not per item, and every item MUST carry the same exp. This is a decision rather than a detail. Per-item expiry makes a sequence decay: after some hours the set holds items 0 and 2, which is both confusing to look at and a disclosure — the gaps reveal the order the author added things and roughly when. A receiver that finds items of one set carrying different exp values MUST treat the EARLIEST as the expiry of the whole set, so a sender cannot use the difference to keep part of a sequence alive after the rest has gone.

⚠️ n is the sender's claim and MAY be wrong — items are separate envelopes and any of them can fail to arrive. A client MUST render what it has rather than waiting for n items, and MUST NOT show a set as incomplete. A missing item is indistinguishable from one never sent.

⚠️ The cost is the sender's: n items is n blobs uploaded and n envelopes per recipient, so a set of ten to twenty contacts is two hundred sends. Clients SHOULD bound the number of items they will post in one set.

6.24 Stories posted to a group

A story MAY be addressed to a group rather than to contacts. It is an ordinary story envelope (§6.6) carrying the group it belongs to, fanned out pairwise to the members exactly as a group message is (§6.7):

{"fnc":1,"kind":"story","att":{…},"exp":1788000000,"gid":"<group id>"}

Receivers MUST render it in the story surface, not in the group's message thread — a story is not a message, and gid here names the audience rather than the destination thread.

🔴 The group's roster is the audience, and the sender's story audience does NOT apply. These are two different lists with two different rules, and this is the whole hazard of the feature. §6.6's audience is a statement about who may see this person's stories; a group roster is a statement about who is in a conversation. They will disagree, and when they do the roster wins, because a story addressed to a group that silently dropped some members would be a group message that some of the group cannot see — a worse failure than the one being avoided.

⚠️ A client MUST say so before the first group story an account posts, and MUST name the consequence concretely: that it goes to everyone in the group, including members this account has excluded from its stories, and including members who are not contacts at all. A sender who believes their story audience still applies is being misled by the interface, not by the protocol.

⚠️ exp still governs, and is not extended by group membership. A group story expires on the same terms as any other; a client MUST NOT keep it because it also happens to be addressed to a conversation that keeps history.

⚠️ A group story MUST NOT be forwarded into the contacts story surface of members who are not in that group, and MUST NOT be treated as evidence that its author is a contact. Group membership and the contact list are separate facts, and a story is not an introduction.

6.25 Reporting content

Blocking needs no protocol: it is a local decision plus the relay's existing block list, and nothing about the reported content leaves the reporter's device. Reporting is different, and the difference is the whole of this section.

🔴 A report is the one place where a user deliberately breaks their own confidentiality. The relay stores ciphertext it cannot read, so it cannot examine a reported message or story on its own account, and no amount of server-side work changes that. A report that carried only an accusation — "this account sent me something abusive" — gives an operator nothing to act on but the assertion, which makes moderation either credulous or useless. So a report MUST carry the reporter's own decrypted copy of the content being reported, and that copy is readable by the operator.

⚠️ Therefore consent, stated in those terms, before the report is sent. A client MUST tell the reporter, in the moment and not in a policy document, that sending the report gives the operator a readable copy of the content and of who sent it. "Report" on its own is a button whose consequence is invisible and irreversible. A client MUST NOT report anything the user has not explicitly confirmed, MUST NOT batch a report with any other action, and MUST NOT include surrounding conversation the user did not choose to include.

The submission is an ordinary authenticated request, not a content envelope — it is addressed to the operator rather than to a person:

POST /moderation/report
{ "about": 12, "kind": "story", "reason": "abuse",
  "content": "<the reporter's decrypted copy>", "note": "<optional, the reporter's words>" }

about is the account being reported. kind distinguishes a story from a message so an operator knows what surface it came from. reason is a short enumerated value. content is what the reporter saw, and MAY be empty when the reporter declines to include it — a report with no content is weaker, and a client SHOULD say so rather than sending it silently.

⚠️ A report MUST NOT be presented as, or accompanied by, a claim that the operator can see the conversation. They cannot. They see exactly what this one request carried, because the reporter sent it, and a report from one side is one side's account.

⚠️ Reporting MUST NOT notify the reported account, and the relay MUST NOT expose reports to anyone but the operator. A report that told the subject who filed it would make reporting dangerous for exactly the people most likely to need it.

⚠️ A client SHOULD offer blocking alongside reporting and SHOULD make clear they are separate: blocking takes effect immediately and is the reporter's own decision; a report is a request to somebody else, and nothing may happen.

6.26 Wiping a lost device

Revoking a device stops it receiving new mail. Everything already on it stays readable, so a phone that is lost rather than retired is still a copy of the conversation. A device MAY be instructed to erase what it holds, and the instruction MUST be signed by the account key (§20), not merely served by the relay:

{"wipe":1,"device":"<device id>","at":1788000000,
 "sig":"<b64 Ed25519 over the canonical bytes below>"}

The signed bytes are "fnwipe:v1:" || device_id || ":" || decimal(at), exactly, with no whitespace. The verifying device already holds what it needs: its own account address is fnid@relay, and the fnid IS the account public key (§20.3), saved at sign-in.

🔴 The relay must not be able to cause a wipe, and this is the whole point of the section. The obvious implementation is to erase when the relay says the device is gone — an HTTP status on the next poll. That hands an untrusted server the power to destroy every user's history at will, irreversibly. It cannot read anything, and it must not be able to delete anything either. A client MUST NOT erase local data on the strength of any relay response that carries no signature: a device told by an unsigned mechanism that it has been removed SHOULD sign out and stop polling, and MUST leave stored messages alone.

⚠️ Signing requires the passphrase, and that is deliberate. The account key is derived, not stored (§20.3), so issuing a wipe means re-entering the passphrase. A device able to wipe the account's other devices without one would turn any unlocked phone into a weapon against its owner's other phones.

⚠️ Best effort, and clients MUST describe it that way. A device that is off, offline, or wiped-and-reset never sees the instruction. at bounds nothing on its own — a device that reappears in a year still honours a year-old instruction, which is correct, because the instruction has not stopped being true. What a client MUST NOT say is that the data is gone: it is gone from devices that have since connected, and unknown elsewhere.

⚠️ A wipe instruction MUST be scoped to one device, and a device MUST ignore one naming any device but itself. "Wipe everything" is a different and much more dangerous operation; a client wanting it issues one instruction per device so each is separately auditable.

⚠️ An account with no account key — one that only ever signed in against a single relay and never derived an fnid — cannot use this. Such a client MUST offer revocation without claiming a wipe, rather than falling back to an unsigned mechanism.

6.27 Screenshot notices

A disappearing-message timer (§6.11) promises the message goes away. A screenshot breaks that promise silently, and the sender never learns. It cannot be prevented on either platform. It can be reported, and a client MAY tell the sender when it notices one:

{"fnc":1,"kind":"shot","ctl":1,"at":1788000000}

ctl is set (§6.10), so a build that does not know shot stays quiet rather than rendering it. Receivers MUST render it as a notice in the conversation, never as a message, and MUST NOT allow it to reset or extend any timer.

🔴 This is a courtesy from the taker's own client, and nothing else. The device that took the screenshot is the device that reports it. A modified build, a second phone photographing the screen, or the platform's own screen recorder all produce no notice at all. That makes one direction of the inference completely invalid: a notice arriving means one was probably taken; no notice arriving means nothing whatsoever. A client MUST NOT present the absence of notices as evidence that a conversation was not captured, and MUST NOT offer any summary — "no screenshots taken", a count of zero, a green tick — that would be read that way.

⚠️ The platforms differ and the interface MUST NOT imply otherwise. iOS reports a screenshot to the foreground app reliably. Android has no equivalent before API 34, and its callback there covers screenshots but not screen recording. So a client MUST describe this as "you are told when the other person's app can tell", never as "you are told when somebody screenshots". Claiming a symmetry that does not exist is worse than the gap, because somebody would rely on it.

⚠️ Only in conversations with a timer set. Sending it everywhere would make it a general surveillance feature rather than the narrow one it is: the promise a screenshot breaks is the one §6.11 made, and where no promise was made there is nothing to report against.

⚠️ A client SHOULD send at most one notice per conversation per short window. A screenshot often produces several platform events, and a person who took one screenshot should not appear to have taken four.

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.9b Sealed group rosters

The relay does not route group messages. Fan-out is client-side and pairwise (§6.7), so the roster is not a routing table the server needs to read — it is a document the members happen to keep there. That is what makes sealing it possible at all.

Storage, per group:

column holds
gid the group id
owner_key Ed25519 public key. The owner proves by signature; no user id.
roster_blob opaque bytes. The member list, encrypted under the group key.
roster_version monotonic counter, to refuse rollback

Plus group_tags(gid, tag) — one opaque 32-byte token per member, which is the access control. No user id appears in any group table.

  • POST /e2e/groups/sealed — create. Request: { "gid": "...", "owner_key": "...", "roster_blob": "...", "tags": ["..."], "sig": "..." }. The signature covers fngroup-v1|<gid>|<version>|<blob>.

    🔴 The client chooses gid, and must: the signature binds it, so a signed roster cannot be replayed into another group — and a relay-minted id cannot be signed over, because the client does not know it when it signs. The relay checks the shape and refuses one already in use.

  • POST /e2e/groups/fetch — request the blobs for the tags you hold: { "tags": ["..."] }. Returns only groups where a tag matches. A client keeps its own gid → tag map; it never asks "what groups am I in", because the relay cannot answer that.

  • POST /e2e/groups/{gid}/roster — replace the roster. Owner only, proven by a signature over the new version and blob. A version that does not increase is refused, so a captured older roster cannot be replayed to re-add somebody who was removed.

  • POST /e2e/groups/{gid}/leave — presents a tag, which is deleted. The last tag out deletes the group.

Reachability moved to the client. The relay used to check every member had a registered device, which required knowing who the members were. The client checks it before creating, and it is the client that suffers the failure anyway: fan-out is per device, so an unreachable member receives nothing whoever checked.

What this earns, and what it does not

🔴 It defeats a database dump. It does not defeat the operator. Every fetch is authenticated, so a running relay still sees which account asks about which group, and can rebuild the membership graph from access patterns alone. Sealing the rows changes what is stored, not what is observable.

The claim this earns is precisely "a database dump does not reveal your groups" — not "the server cannot see who you talk to". §22 is what the second claim would need, and §22.4 explains why even that would not deliver it while fan-out is per device.

⚠️ A second, real gain: the membership check stops trusting the relay. Clients refuse group messages from non-members (§6.7). That check used to ask the relay who the members were, so a hostile relay could answer with anyone it liked. The blob is encrypted and its version is signed by the owner, so the answer now comes from the group, not from the server.

Shipped, and demonstrated rather than asserted (§10). On both apps the group list prefers the sealed roster and the receive-path gate resolves from the blob. Each is covered by a test that was watched to FAIL with the change reverted — the relay naming an extra account as a member, and its roster replacing the one out of the blob. iOS e90ba1b, Android 0006d82.

⚠️ Losing your tags loses your groups. The relay cannot tell you what you are a member of, which is the point. Tags ride the encrypted history export and the group announcement (§6.7), the same paths group names use.

⚠️ An owner who leaves without transferring freezes the roster. Ownership is a key, and the relay cannot reassign one it does not hold. The owner transfers by publishing a new owner_key signed by the old one. Without that the group keeps working for messaging and cannot change membership — an honest degradation of a succession rule that previously worked because the server picked the successor.

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: { "members": [12, 34] }. The creator is always a member and the owner. Cap: 32 members including the creator (fan-out cost is per-member; see §6.7).

    A name MAY be present and MUST be ignored: it is accepted only so clients written before 2026-08-30 keep working, and the relay neither reads nor stores it. Clients from here on MUST omit the field and tell members the name over the ratchet (§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": "Group", "members": [...] }, where name is the fixed placeholder described below.

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

    name is a placeholder, not a name. The relay stored group names in the clear until 2026-08-30, in a column nothing on the relay ever read — no push, no enforcement, no lookup. "Chemotherapy support" or "Union organising" sitting in a server table is a sentence about somebody that can be dumped or subpoenaed, bought for nothing. The column is dropped, and the names that were already in it were cleared by the same migration; removing only the writes would have left the whole existing corpus where it was.

    The same word is served for every group so that clients predating the change do not draw a blank row. Members learn the real name from each other (§6.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: 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: 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.

Android — verified as of 2026-08-29

The Android engine exists and is exchanged with the web engine. It lives in flamenet-messenger-android engine/ — 18 Kotlin files, and a plain JVM library with no android.* imports, which is exactly the shape this section predicted it would need in order to be testable at all.

test/interop-kotlin.mjs drives it against the web engine over a stdin/stdout protocol: 43 assertions, passing — X3DH in both directions, the ratchet including out-of-order delivery, archive/restore across a restart, safety numbers agreeing from either side, byte-identical canonical log leaves, and sealed-sender seals made by one engine and opened by the other (including a seal addressed elsewhere correctly refusing to open).

The harness calls the engine rather than reimplementing it. That constraint is the point: a driver carrying its own HKDF would prove the driver agrees with the web engine, which is worthless, because the driver is not what runs on a phone.

🟢 Android does post-quantum too, and it has been run. Its engine carries ML-KEM through BouncyCastle (engine/.../PQKEM.kt) and E2ESession negotiates v3 as responder as well as initiator. Verified by test/interop-swift-kotlin.mjs — the Swift driver compiled from the iOS app's own Sources/E2E, the Kotlin side driven through session_* so the negotiation decision belongs to E2ESession and not to the harness. 77 assertions, 0 failures, re-run 2026-09-07.

⚠️ This said "Android is v1 only. It has no ML-KEM" until 2026-09-07 — the third time this section has been wrong about Android, and the second time in the understating direction. The rule that keeps catching it: establish what has been RUN. PQKEM.kt being present was never the evidence; the cross-engine run is.

⚠️ This section said "Android is absent, not merely unverified" until 2026-08-29, long after the engine shipped and the interop harness was written — the same class of error, in the opposite direction, as the one the warning below describes. A spec that understates what exists sends an auditor away from a third of the client code.

⚠️ 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. What makes the Android claim above sayable is the fixture exchange, not the code reading correctly.


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 test/conformance.mjs
2. Hybrid KAT ✅ locked vector, replayed on both backends 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. Kotlin ↔︎ Web verified: 43 assertions, test/interop-kotlin.mjs (§10). test/interop.mjs, interop-kotlin.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 test/client.mjs
5. Tamper rejection ✅ verified — one flipped byte of pq.ct fails the AEAD open test/conformance.mjs
6. Exhaustion path ✅ verified through the real server's popping path test/relay.mjs §8.2

Client wiring has 50 tests (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 (test/relay.mjs), driven against the running relay rather than a stub.

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

⚠️ pqMode is still "optional", and both of the reasons it was have now gone. They were: Android is v1 only, and iOS devices below 26 cannot do post-quantum at all. Android negotiates v3 and it has been run cross-engine (§13); the iOS deployment target is 26.0, so every supported iPhone has MLKEM768.

🔴 That does not make tightening it a docs change. Requiring post-quantum would refuse first contact with any peer that cannot answer — including a shipped Android 0.6 in somebody's hand — so it is a rollout decision with a compatibility floor attached, taken deliberately and not as a side effect of correcting this paragraph. Until it is taken, first contact with an unpinned device remains downgradable. See §12.7.

12.11 Implementation status

Layer State
Reference engine (src/e2e.js, mlkem.js) implemented
Relay (Swift, flamenet-relay) implemented
Web client wiring (e2e-client.js, e2e-bootstrap.js) implemented
iOS (Sources/E2E/, CryptoKit) implemented
Android (engine/, Bouncy Castle) implemented

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 and no longer gated. CryptoKit gained MLKEM768 in iOS 26, and the app now deploys there, so the primitive is unconditional — every iPhone running this build publishes PQ prekeys, pins peers and refuses downgrades exactly as the web client does. It was previously gated behind PQKEM.isSupported against an iOS 16 floor, which meant a device on iOS 16–25 got no post-quantum protection at all. The alternative to raising the floor was bundling our own implementation of a lattice KEM, which is a serious thing to take on for a messenger and worse than asking for a newer OS.

⚠️ The floor is load-bearing. Lowering IPHONEOS_DEPLOYMENT_TARGET without restoring the #available gates would not fail to compile — MLKEM768 would simply be missing at runtime on the devices that gained support, which is a crash rather than a downgrade.

Android is complete, over Bouncy Castle's low-level pqc.crypto.mlkem — the same route Ed25519.kt already takes for rfc8032, and no new dependency. Keys are stored as the 64-byte FIPS 203 seed, which is the representation iOS persists, and the seed path is checked against the published pqx3dh-v2.json vector rather than against another run of our own code.

🔴 pqMode stays "optional", and the reason moved rather than went away. Every client built from this tree does post-quantum key agreement — but a session is only post-quantum when both ends are, and the fleet still contains Android builds registering proto: 1. Flipping to "required" would make those contacts unreachable rather than classically protected, which is the worse failure while any are in the field.

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.
  • Older clients in the field. Both apps do post-quantum key agreement from this tree, but Android builds already installed register proto: 1, so pqMode cannot leave "optional" until they have turned over. A session with one of them is classical at both ends and says so; it is not silently downgraded.
  • 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 ✅ verified cross-engine
§14 sealed sender ✅ sealed mode + delivery keys ✅ wired end to end ✅ wired end to end ✅ wired end to end

🔴 Both Android cells said ❌ and both were wrong — understating, which is the opposite of the failure §10 warns about but is corrected the same way: by establishing what has been run, not by flipping a symbol because the code looks present.

  • §13 on Android is ✅ as of cc51854. Thirteen assertions in test/interop-kotlin.mjs drive a v3 session between the Kotlin and web engines in both directions. The load-bearing one is the web engine opening Kotlin's reply: sending it required decapsulating with the prekey seed, adopting the peer's pqdh, and encapsulating a fresh key, and doing those three in the wrong order (§13.2) derives a different root key with no error anywhere. Also pinned: a distinct ML-KEM key per step, so a reused one — which still decrypts, and silently gives up the healing §13.1 exists for — fails.

    🔴 That run is also what found the shipped code was wrong. The engine was attaching pqdh/pqct to messages for any peer that published a PQ prekey, which every v2 device does. A build with that would have broken every conversation with the released 0.6 client — different root key AND different AAD, so the first message never opens. Sixteen Kotlin unit tests did not catch it and could not: both sides of a self-test share the author's reading. Recorded because it is the argument for this table's whole discipline.

    🟢 Kotlin against Swift directly has now been run, as of 2026-08-31: test/interop-swift-kotlin.mjs, 22 assertions, both directions, no web engine anywhere in the path. This closes an inference that was never valid — two engines each agreeing with a third agree with each other only if the third pins every byte they could differ on, and the pairing a person actually has (an Android phone and an iPhone in one conversation) was the pairing nothing exercised. Both halves are the real app engines: the Swift driver compiles from the iOS app's own Sources/E2E, and the Kotlin side is driven through session_*, so the negotiation decision belongs to E2ESession rather than to the harness.

    ⚠️ The harness was checked against a deliberate break before being trusted: changing FlamenetE2E_Ratchet_v3 to …_v3X in the Kotlin engine turned both directions red (authenticationFailure one way, DecryptionFailed the other) rather than passing on. A green cross-engine test that cannot go red is the exact shape of the iOS CI gate this project already shipped once.

    ⚠️ It runs only on a Mac with a JDK and an iOS checkout, so CI does not run it — CI is Linux. Release-time check, same standing as Scripts/selfhost-smoke.sh: npm run test:interop:apps.

  • §14 on Android is now ✅, and the caveat that used to sit here was right about what was missing. It said the engine half was verified cross-engine but that wired end to end was unproven, because no recorded run showed an Android client sealing and unsealing against a relay — and it warned that LiveSealedSenderTest was not that evidence, because it returned early when FN_LIVE_FLEET was unset and JUnit recorded the early return as passed.

    Both halves are settled. The suite now announces a skip instead of passing through, and it has been run against a relay.

    ⚠️ The discriminator, since this is exactly the claim that reads as proven when it is not. Two runs, same command, same day:

    result
    FN_LIVE_FLEET unset tests=2 skipped=2 failures=0 — reported SKIPPED
    pointed at relay1.flamenet.io tests=2 skipped=0 failures=0 time=8.604sPASSED

    A no-op returns in milliseconds and reports zero skips; this took eight and a half seconds and the unconfigured run is visibly a skip. The two assertions are a sealed message arrives and the relay records no sender and rotating the delivery key refuses submissions holding the old one.

§13 is complete and verified end to end on the web: 32 tests in 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 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 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 26 floor, where CryptoKit has both MLKEM768 and MLDSA65 — so none of this is gated any more, and §12.11's carve-out for older devices no longer exists.

⚠️ Android's sealed sender is engine-verified and not wired end to end. That is the remaining gap here, and it is narrower than what this line used to say: it read "Android remains v1 and has still never been checked against another engine", which was true of neither by 2026-09-07. The ratchet is v3 and the cross-engine run is test/interop-swift-kotlin.mjs (§13). What has not been shown is a sealed envelope leaving the Android app itself.


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 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 || "v2|" || kind || "|" || subject || "|" || 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.

subject replaced user_id in v2. A relay's user_id is a row number it assigned to itself. It made a leaf unverifiable by exactly the third-party monitors this section exists to enable — they have no way to learn what user 12 means — and it made two relays unable to agree that two leaves describe the same person, which is the first thing federation needs.

A subject is one of two shapes, and which one it is is readable from the string:

Form Meaning
fnid:<crockford-base32> A global account identity: the account's Ed25519 public key. Portable — the same person at another relay produces the same subject.
local:<audience>/<user_id> A relay-local row number, qualified by the relay that assigned it.

The local: form exists because a device may be registered before its account key has been claimed. Refusing to log those devices would leave a hole in the one record that must not have holes; logging them unqualified would produce leaves that mean different things depending on their origin, with nothing in the leaf to say so. Qualifying it states plainly that the leaf is relay-scoped.

⚠️ A v1 leaf must never be verified under v2 rules. The version prefix is inside the hashed bytes precisely so the two cannot be confused — a verifier that recomputes a v1 leaf with a v2 template gets a different hash and correctly fails, rather than silently accepting a leaf about a different subject. Relays that ran v1 keep those leaves, hashes untouched, so consistency proofs across the format change still hold: the log is append-only, and a format change is not a licence to rewrite history.

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.

Enforcement is automatic wherever the relay can answer. A client asks the head route once; if it answers, every bundle must prove inclusion, and there is no setting to find first. Until 2026-08-30 this was opt-in and off, which meant the relay offered proofs and essentially nobody asked — a log nobody checks is a log, and the protection shipped without reaching anyone.

The reason it had been opt-in — a relay predating this section keeps no log, and refusing every peer there is a worse failure than the substitution being prevented — argued for skipping relays that cannot answer, never for skipping the ones that can. Two rules keep that distinction from becoming a downgrade the relay controls:

  1. The head is fetched separately from enforcement. Verification reads the head, then the device's leaves, then an inclusion proof. A relay that wanted to skip the check would otherwise only have to 404 the middle request to make a substituted key look like a deployment without transparency. So "does this relay keep a log" is asked once, of the head route alone, and every later 404 means what it has always meant: this key is not in the log. Only a 404 or 501 counts — a timeout, a 500 or a 429 is a relay having a bad day, and reading those as an absence would make enforcement skippable by anybody able to make one request fail.

  2. A relay that has answered cannot stop. Each client records that it once read a head from this relay, persistently and separately from the head pin (which the gossip path can write from a head a contact sent). A relay that served a head before and 404s now has not lost its log — logs do not disappear — so that is a fork, not an absence. Without this the downgrade is one line of configuration: publish a log until clients are talking, then stop.

requireKeyLog survives as the stricter question underneath: refuse a peer even when the relay keeps no log at all. It stays off by default because the failure it prevents is rarer than the one it causes — on a pre-§19 relay it makes every contact unreachable, and somebody locked out of their messages moves to something with no ratchet at all.

Both subject forms are accepted for a leaf. A leaf names its subject as fnid: once that account has claimed an address and local:<audience>/<id> before, and the leaf hash committed to whichever string was true when it was written — so a contact who registered a device and then claimed an address has older leaves under one form and newer ones under the other. A verifier computing a single subject would refuse half the network on the day claiming turned on. This concedes nothing to the relay: either candidate still requires it to have appended a leaf binding the exact key it served, in a tree it has committed to and must prove inclusion against. What it gives up is the stronger property of checking that the key is logged under that person, which needs the peer's address pinned in the contact record.

19.5 Implementation status

Relay Web engine iOS Android
§19.1–19.3 log, proofs, signed head src/keylog.js KeyLog.swift KeyLog.kt
Client demands a proof before first use n/a ✅ automatic ✅ automatic ✅ automatic
Refuses a relay that serves no log n/a ✅ opt-in ✅ opt-in ✅ opt-in
A relay that stops answering reads as a fork n/a
Append-only pinning across sessions n/a
§19.4 gossip — consumes a peer's head n/a
§19.4 gossip — sends its own head n/a

⚠️ The last row was ❌ for both apps until 2026-08-29, and the table did not say so — it recorded iOS as implementing §19.4 on the strength of the half it had. Both apps built the envelope and never sent it, so gossip functioned web→app and in no other direction, while every other row above was honestly green. Split it in two: a client that only consumes is a client no fork can be detected against, and that is worth a row of its own rather than a footnote on someone else's.

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.

⚠️ 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
§20.1 verify both signatures n/a — not the relay's job
§20.5 downgrade + substitution pinning n/a

Android publishes no ik_pq yet, so a peer sees no post-quantum identity for it and treats it as classical, recording no pin. §20.2 makes an absent identity a supported state rather than an error, so nothing else is affected — and a peer that has once been seen WITH one and later without is a downgrade, which §20.5 refuses.

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.4 takes a third option that gets the revocation without the pseudonym.

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 What is implemented: single-use delivery capabilities

Not the credential scheme. A construction that solves the product problem with primitives already in use, and gives up one property the credential would keep.

Issuance. The recipient mints a batch of random 32-byte tokens per contact, uploads only their SHA-256 hashes to POST /e2e/delivery-tokens, and hands the tokens themselves to that contact inside the ratchet. The relay stores (user_id, token_hash) and nothing else — no row names a sender, and none may. The map from contact to batch lives on the recipient's device, because the recipient is the only party that should hold it.

Presentation. A sealed submission carries delivery_token instead of delivery_key. The relay hashes it, deletes the row, and accepts. The delete and the check are one statement: two senders presenting the same captured token in the same instant must not both be accepted.

Revocation. Dropping one batch's hashes. Nobody else is affected and nothing is redistributed — which is the entire point, because §21.1's shared key made revocation so expensive that it never happened.

Replenishment is counted, not requested. The holder cannot ask the relay how many it has left; the relay does not know whose are whose. But the issuer can count, because every message it receives from a contact spent one of their tokens, and it mints a fresh batch at half. The count is a lower bound — sends that failed, or arrived while the issuer was offline, are not seen — so it runs somewhat behind, and running dry is safe: the sender falls back to §14.4.

The batch rides the existing delivery_key control envelope, as an extra tokens field, rather than in a new control kind. That is a compatibility decision and worth recording: a receiver recognises control traffic by kind first and falls back to the ctl flag only for kinds it has never heard of — and the Android build in the field reads that flag with optBoolean, which does not coerce the number §6.10 calls for. A new kind would have arrived on those phones as a bubble full of raw JSON.

What it buys over §14.4

  • Per-contact revocation, which is the whole complaint of §21.1.
  • Per-message unlinkability. The relay sees a fresh value per message, so two messages from one sender are no more linkable than messages from two. Under the shared key every sender presents the same constant for as long as it lives, so this is strictly better, not a trade.

What it does not buy, stated plainly

  1. A relay that records when rows were inserted can group a batch, and a batch is one sender. Nothing here prevents that. It requires the relay to keep metadata it is not asked for, which is a weaker guarantee than a proof carrying no such structure at all.
  2. Revoking tells the relay the dropped hashes were one group. Acceptable — the sender is being cut off — but information the credential would not give up.
  3. Revocation moves a sender back onto the shared key; it does not silence them. A complete cut-off is revoking the batch and rotating the key. Every other contact recovers from that automatically now, because the new key rides the same prelude the batch does — so §14.4's redistribution cost is paid by the protocol rather than by the user. Two doors; closing one does not close the other, and a client that implied otherwise would be lying about the one thing this section is for.

None of that is closed without the algebraic MAC and zero-knowledge proof of §21.3, whose prerequisites are unchanged and listed below.

21.5 Why the credential scheme is still 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, §21.4 stands: it fixes the revocation problem and improves unlinkability, and the two leaks it does not close are recorded above 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.

The group's name was part of this leak and is not any more: as of 2026-08-30 it is not stored on the relay at all (§6.7, §8.9). That was the cheap half — the name was a column nothing read, so removing it cost a compatibility placeholder and nothing else. The roster is the expensive half, and it is still open; the rest of this section is about that.

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, not partially implemented, and not planned. That last word is new and is a decision rather than a delay, so it is worth saying why it was taken instead of leaving the item sitting on a roadmap indefinitely.

The credential of §22.2 is buildable. On its own it would not deliver what it appears to deliver, for the reason §22.3 gives: envelopes are addressed to devices, so a relay that cannot enumerate a roster still observes which devices receive a group's traffic and can reconstruct membership by correlation. Shipping the credential alone would move the leak from a table the operator can read directly to one they can derive — a smaller change than it sounds, and one that reads to a user as though the problem were solved.

Closing it properly needs the delivery pattern addressed as well, which §22.3 sketches two routes to and designs neither. That is a larger piece of work than the credential, it has no design, and committing to it on a public roadmap without one would be a promise made on nothing.

So the position is: this is a limit of the protocol as built, recorded here and disclosed on the site's metadata page.

🔴 What §8.9b changed, and what it did not. Sealing the roster (§8.9b) means the relay no longer stores who is in a group: there is no gid -> user_id row left to read, and a stolen or subpoenaed database does not reveal your groups. That is a real change and it has shipped. It is not this section. §22 is about what a running relay can observe, and every word above still holds — every fetch is authenticated, envelopes are addressed to devices, and membership is reconstructible by correlation whether or not a table exists to read.

The distinction is the whole point of keeping both sections. "Not stored" and "not observable" sound alike, and only the first is true. A reader who takes §8.9b as closing §22 has been misled, and so has one who takes §22 as meaning §8.9b bought nothing.

The group's name is no longer part of the disclosure either: it was never needed for routing and is not stored any more (§6.7, §8.9). §8.9 documents the current behaviour as a deliberate deviation rather than an oversight. If the addressing work is ever designed, this section is where the credential half already is.