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_idselection: 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_idselection 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.
- 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 }(opkmay benullif the pool is exhausted — X3DH proceeds without it). - Alice MUST verify
Ed25519_verify(ik_sig, spk_sig, spk). Abort on failure. - Alice generates an ephemeral X25519 keypair
EK. - 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 SK = HKDF-SHA256(IKM = DH1 || DH2 || DH3 || DH4, salt = 0x00*32, info = "FlamenetE2E_X3DH_v1", L = 32). Ifopk == null,DH4is omitted from the concatenation.- 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). - Alice initializes a Double Ratchet (§6) as the sender, with
RK = SKand Bob/D'sSPK_pubas the initial remote ratchet key (DH_remote = SPK_B_pub). - The first message Alice sends is a
type: "prekey"envelope (§6.4) that additionally carriesIK_dh_A_pub,IK_sig_A_pub,EK_A_pub, and thespk_id/opk_idshe 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 integerfncfield, 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;captioncarries the body andattis absent (attis required for every other kind).image/audioenvelopes may also carrygid, 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 ofgidper 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_keyenvelope (§14.4) carryinggidandgname. That kind rather than a new one because a new control kind renders as raw JSON on builds that predate §6.10'sctlflag;delivery_keyis a kind they already consume in silence and already ignore unknown fields on. gnameon 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.
6.12 Link previews
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. Afile: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
urgentonly 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.
urgentis 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
midcannot 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.
- ⚠️
secson akind: "ttl"envelope means something else entirely: it sets the whole conversation (§6.11). A receiver MUST distinguish them bykind.
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
mentas naming anyone who is not a member. mentMUST 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
midthe receiver does not hold shows nothing, rather than an empty banner claiming something is pinned. re.authoris not read, exactly as in §6.15. Senders SHOULD write0.gidapplies 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.
6.21 Text and link stories
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 coversfngroup-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 owngid → tagmap; 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, Android0006d82.
⚠️ 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
nameMAY 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": [...] }, wherenameis 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 }, ... ] } ] }.nameis 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
nameper 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,usedflag.{p}fnmsg_e2e_envelopes— sealed message queue,deliveredflag.{p}fnmsg_e2e_attachments— sealed blob index:token, uploader, size, file path,created_at. The blob bytes live on disk underuploads/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
prekeyenvelope, 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 ownSources/E2E/*.swiftrather 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 → decryptround 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
SKand, 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_sigsigns 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.
- Alice fetches the bundle (§12.11). It now carries
proto,caps_sig, the PQ signed prekey, and one popped PQ one-time prekey (ornull). - Alice MUST verify
Ed25519_verify(ik_sig, spk_sig, spk)— unchanged. Abort on failure. - NEW. Alice MUST decide the protocol version by the rules in §12.7. If v1, run §5 unchanged and stop here.
- NEW. Alice selects the PQ target: the one-time
PQOPKif the bundle supplied one, otherwisePQSPK. She recordspq_kind ∈ {"opk", "spk"}andpq_id. - 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.
- NEW. Encapsulate:
(PQ_CT, SS) = ML-KEM-768.Encaps(PQ_target_pub). - Alice generates an ephemeral X25519 keypair
EKand computesDH1..DH4exactly as §5.4. - NEW. The IKM appends
SSlast: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) andDH1‖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. - NEW. The associated data binds the KEM ciphertext:
ML-KEM's implicit rejection already makes a tamperedPQAD = SHA-256("FlamenetE2E_PQAD_v2" ‖ PQ_CT) // 32 bytes AD = IK_sig_A_pub ‖ IK_sig_B_pub ‖ PQAD // 96 bytesPQ_CTyield a differentSSand therefore a failed AEAD open; this binding makes that explicit and permanent, sinceADprefixes the AAD of every message in the session (§6.3). - Ratchet initialisation is unchanged: sender,
RK = SK,DH_remote = SPK_B_pub. - The first envelope is
type: "prekey"with"v": 2and 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 carriesproto ≥ 2with a validcaps_sigand 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_sigatproto ≥ 2, it MUST persistminProto = 2for 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. PreferGET /e2e/deviceswhen 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:
- Seed agreement — native and fallback ML-KEM produce
byte-identical
ekfrom the same 64-byte seed, and each decapsulates the other's ciphertext to the sameSS. - Hybrid KAT — a fixed
(IK, SPK, OPK, EK, PQ seed)vector produces a fixedSKandAD, checked in against the spec so drift is caught by a test rather than by a user. - Cross-engine — a v2 prekey envelope sealed by one engine opens on another, both directions, as §10 requires for v1.
- Downgrade refusal — a device pinned at
minProto = 2refuses a bundle with the PQ fields stripped, and the refusal reaches the UI. - Tamper rejection — flipping one byte of
pq.ctfails the AEAD open, provingPQADis genuinely bound. - Exhaustion path — with the PQ one-time pool empty,
the session establishes against
PQSPKand 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 | ⚠️ partial — iOS (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, sopqModecannot 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/keysis the fix.GET /e2e/devicesnow carriesproto+caps_sigso 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_outis 32 bytes andssis 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_sigand 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_dhcan produce a well-formed seal claiming anyfrom_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. Treatingfrom_useras 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— publishSHA-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 = 0andfrom_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 intest/interop-kotlin.mjsdrive 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'spqdh, 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/pqctto 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 ownSources/E2E, and the Kotlin side is driven throughsession_*, so the negotiation decision belongs toE2ESessionrather than to the harness.⚠️ The harness was checked against a deliberate break before being trusted: changing
FlamenetE2E_Ratchet_v3to…_v3Xin the Kotlin engine turned both directions red (authenticationFailureone way,DecryptionFailedthe 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
LiveSealedSenderTestwas not that evidence, because it returned early whenFN_LIVE_FLEETwas 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_FLEETunsettests=2 skipped=2 failures=0— reported SKIPPEDpointed at relay1.flamenet.iotests=2 skipped=0 failures=0 time=8.604s— PASSEDA 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:
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.
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:
- 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). - 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.
- 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
- 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.
- 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.
- 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/curvesprovides 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:
- On joining, a member receives a group membership
credential — an algebraic MAC over a commitment to
(group_id, member_blinding). - 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.
- 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.