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.
On the two "web" columns. The status tables below
distinguish the web engine, src/e2e.js in
this repository, from the web client, the wiring that
once consumed it in a browser. The engine is current: it is the
reference implementation, it is what the interop harnesses drive, and it
is normative where the three disagree. The client is not. It was a
WordPress plugin and it was uninstalled; there is no browser client
today, and the relay serves its own site and no messaging client
(AUDIT-SCOPE §1). A yes in a web-client column records what
that code did, not something a user can reach now. §16 and §17 are the
sections where this matters most, because the client column is the only
yes in either of them.
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: never on the server. A new device / reinstall = new identity; it cannot read messages sent before it registered unless a device the person already has links it and sends the history across (§17), or a backup is restored (§16). Losing every device and every backup 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 | n/a | 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).Ship
spk_idselection before rotation. A responder that rotates without selection silently loses every session already in flight against the old key.The two differ in rotation interval and that is fine, iOS rotates weekly, web every 30 days, and a shorter window is a smaller blast radius for a leaked key. What must NOT differ is retention, which both set to 45 days, because that is the number the server's 30-day undelivered window (§9) constrains. iOS originally retained a single previous key against a weekly rotation, giving it roughly a fortnight of coverage; an envelope queued longer than that became permanently unopenable, and the failure looked like tampering.
5. X3DH, initiating a session
This section describes protocol v1. v2 adds a hybrid ML-KEM-768 secret to the same construction and is specified as a delta in §12. A v2 client runs §5 unchanged when its peer does not advertise v2.
Alice (initiator) wants to message Bob's device D. She
has never talked to D before.
- 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.
An envelope sent unsealed carries one more key, pad,
which makes its payload a fixed length (§23.6). It is not part of
header_bytes or the AAD, nothing authenticates it, and
receivers ignore it (§23.8). A sealed send pads the seal's inner JSON
instead and leaves the envelope inside it unpadded (§23.5).
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, // uploaded blob length, tail included, for progress UI
"clen": 120028, // §23.9: how many leading bytes to open; padded blobs only
"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.
A sticker names a picture by pack and
id and never carries the picture:
{"fnc":1,"kind":"sticker","stk":{"pack":"flamenet.retro","id":"flame"}}.
Each app draws it from the packs it ships. There are several packs, and
some draw pictures where others draw a glyph. What a pack file holds,
where its pictures live, and the order a client falls back through when
it lacks a pack or an id are set out in STICKERS.md
in the iOS repository. None of it changes the wire.
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.
A blob may carry a random tail that brings it to a fixed length
(§23.9). clen then says how many leading bytes are
nonce || ciphertext || tag, and digest still
covers the whole blob, tail included. With no clen the blob
has no tail. A sender adds a tail only when the relay has switched
attachment padding on, because a receiver that predates
clen cannot open a padded 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 first clen
bytes as the sealed box with key, or the whole blob when
clen is absent. A clen that is not an integer
from 28 to the blob's length makes the attachment undisplayable without
decrypting (§23.9). 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.
Additive top-level keys. These ride beside
kind and caption, never inside
att. All are optional; a receiver that does not know one
ignores it. Each was on the wire before it was written down here, which
is the gap test/vectors/content-v1.json now closes.
| Key | Type | Meaning |
|---|---|---|
men |
array of user ids | Who a group message is addressed to (§6.18). |
fn |
string | A document's own filename, for kind: "file". |
cls |
integer | The label a document was filed under; 0 means
none. |
cmp |
array of integers | Compartments within that label. |
st |
1 or true |
This envelope is about a story rather than a message (§6.20, §6.22). |
gname |
string | The group's name, carried by the sender (§6.7). |
fn, cls and cmp are top-level
rather than part of att deliberately: they describe what
the document IS to the person receiving it, and a receiver that
understands attachments but not labels must still be able to show the
file.
st is written by at least one client as the integer
1 rather than the boolean true. Receivers MUST
accept both. This is not a style note: several JSON libraries decline to
coerce a number to a boolean, so a receiver reading only the boolean
form answers false and silently files a story reaction
against a message that does not exist. The same rule applies to
fwd (§6.16).
Kinds added in 0.32. §6.31 to §6.35 add
card, event, rsvp,
game, gmove, gend and
unpost, and these rules hold for every one of them:
card,eventandgameare renderable. Each carriesmid(§6.9), and in a groupgidandgnameas a group text does (§6.7). They can be replied to, reacted to, pinned and deleted. They follow the conversation's timer (§6.11), and a timer of their own (secs, §6.17) is ignored in 0.32. A build that predates them draws the placeholder (§6.10).rsvp,gmove,gendandunpostare control traffic and set"ctl":1(§6.10). Acard,eventorgamethat carriesctlis consumed as control and never drawn. An envelope carriesctlwhen it has the key with any value but0,falseornull.- The other flags (
fwd,st,cancel) are on when they are1ortrue, and off for0,false, absence or anything else. An integer field takes any JSON number, rounded down when it has a fraction; a boolean or a string there leaves the field absent. - Every string is plain text: drawn as text, never as markup, and cut
to its bound when drawn, never refused for its length. An unknown field
is ignored, and is never a reason to drop an envelope: a
gmovethat also carries anris still a move. An optional field with no value is left out, never written asnull. - The examples in those sections are the bytes both apps write, and
both apps' tests pin them. Keys come in the order shown, and strings are
escaped as Swift's
JSONEncoderescapes them with slashes left alone:"and\are escaped, control characters become\b,\f,\n,\r,\tor\u00xxin lower case, and every other character,/included, is written as itself. A receiver MUST NOT depend on key order or on escaping, beyond the rule above that the plaintext starts with{"fnc":.test/vectors/content-kinds-v1.jsonholds the examples and the cases a receiver has to handle. - What a client keeps for these kinds belongs to a conversation, so §6.28 applies: off the record, it is held in memory and never written down.
- None of them is sent as a sent-copy (§17.2). An envelope from the
reader's own account, its sender proven (§19), is shown as the reader's
own where it can be placed, in a group by its
gid, and is dropped where it cannot.
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: it is not 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.
This was accepted rather than padded, because the alternative is a member who can never learn what their own group is called, there being no server left to ask. §23 now pads every payload to one of two lengths, which closes it for sealed group messages: the name's bytes disappear inside the step, and only a name long enough to push a message over a step boundary still shows, as the step. An unsealed envelope still shows it, as it shows every length (§23.1).
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, four
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" | "answered-elsewhere" | "declined-elsewhere" } }
{ "fnc": 1, "kind": "call-reaction", "call": { "id": "<uuid>", "emoji": "<one emoji>" } }
Reactions during a call. call-reaction
carries one emoji for the call named by call.id, so
somebody can agree without interrupting whoever is talking. A client
accepts it only while that call is connecting or up, shows it briefly,
and records it nowhere: it never becomes a message in a thread and never
reaches the call log. emoji is absent on every other kind,
so an older build sees the envelopes it always saw, and one that does
not know the kind ignores it.
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, and the first call-answer to reach the caller
wins. The answer goes to the caller only, so the device that answers or
declines also sends call-end to its own account's other
devices, with reason answered-elsewhere or
declined-elsewhere. A device still ringing for that
call.id stops and does not file the call as missed; a
device already past ringing ignores it. These two reasons go only to the
account's own devices, never to the other party. A device whose call has
connected ignores a call-end with reason
declined, busy or timeout: those
come from one of the callee's other devices, which never picked up. The
callee's own ring timeout ends the ring locally and sends nothing.
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.
A reaction meant for one person. In a group, a reaction MAY be addressed to the author of the message alone:
{ "fnc": 1, "kind": "react", "caption": "<emoji>", "priv": 1, "gid": "<group>", "re": { "mid": "<target>", "author": 12, "preview": "" } }
It is sent to that author and to nobody else: no other member of the
group receives an envelope at all. That is the whole guarantee.
priv exists so the author's client can label what it
received, and is NOT a request to other clients to hide something,
because a request is not privacy. A client MUST keep these apart from
the public tally rather than folding them into the same count, MUST NOT
set priv on a reaction it sends to a whole thread, and MUST
treat an envelope without the key as public, which is what an older
build sends.
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.
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. An envelope carries ctl when it
has the key with any value but 0, false or
null, so a "1" written as a string makes it
control too. Early Kotlin builds wrote "ctl":true, 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: 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.
A site from the directory. Both apps ship flamenet.io's directory of small websites, and a person can send a site from it to a chat. That is an ordinary text whose words are the site's address and nothing else, with a preview built from the directory entry, so nobody fetches anything, to make the preview or to draw it:
{"fnc":1,"kind":"text","mid":"<id>","caption":"https://example.com/","lnk":{"url":"https://example.com/","title":"Example","desc":""}}
title is the site's title in the directory, at most 120
characters. desc is present and empty. The directory's
description of a site is FlameNet's words, and a preview reads as the
site's own, so the description never travels. The key itself has to be
there, because iPhone builds up to 0.31 refuse a preview without it, and
the message with it. A sender with previews turned off sends the address
alone. In 0.32 a site can be sent only to a one-to-one chat with
somebody on the same relay. The two apps order and escape this envelope
differently, which is allowed, so its vectors in
test/vectors/content-kinds-v1.json compare what it parses
to, not its bytes.
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: 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.
A story's caption. Specified for 0.32;
implementation in progress. An edit applies to a story when it carries
"st":1, with re.mid naming the story the way
§6.35 does:
{"fnc":1,"kind":"edit","st":1,"caption":"<new words>","re":{"mid":"<story id>","author":0,"preview":""}}
Everything above holds for it. It applies only to a story the receiver holds and attributes to the sender of the edit, it is shown as edited, it does not notify, and it never empties a caption: taking a story away is §6.35. A text story (§6.21) has its words edited the same way.
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.
The Android transcript records a mid for each line;
reactions and replies work there. Verified by a run, as §15 requires.
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
men:
{ "fnc": 1, "kind": "text", "gid": "<group>", "men": [12, 34], "caption": "@Ada @Adam thoughts?" }
This key was written
menthere until 2026-09-11, and no client has ever sent that. Both shipped clients emit and readmen, and have since mentions were added. The clients agreed with each other and only this document disagreed, so nothing broke and nothing could: the only thing that would have caught it is a conformance test written from the spec, and there was none. A third client built honestly from this page would have shipped mentions that silently never notified anybody.The key is now pinned by
test/vectors/content-v1.json, generated by running a client's encoder rather than by reading it. The spec was corrected to the clients rather than the other way round: they are deployed, they agree, and changing the wire to match a document would break every installed copy to fix a typo.
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 men 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) 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: a
text whose re (§6.9) names the story, carrying
"st":1 to say that re points at a story and
not at a message.
{"fnc":1,"kind":"text","mid":"<id>","re":{"mid":"<story key>","author":9,"preview":"at the beach"},"caption":"where is this?","st":1}
re.mid is the story's key as the replier received it:
the attachment id of a story with a picture or a video, or the
mid of a text story, the value a backup calls the story's
key (§16.2.1). re.author is the story's
author. 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. test/vectors/content-v1.json pins this envelope
and the reaction in §6.22. A separate sre reference object
is not part of the wire: no released build sends or reads one.
re.preview carries the first 80 characters of the
story's caption, and nothing of its picture: no blob and no thumbnail. A
story's author had the story by definition, and the only way they no
longer hold it is that it expired, so a reply that arrives after the
story has gone puts up to 80 characters of their own words back on their
phone, under the reply, after their timer removed them. Nothing else of
the story comes back.
A receiver whose copy of the story has expired or was never held MUST render the reply as an ordinary message with its quote, 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.
A story's key is meaningful only to the 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, as a story reply is (§6.20), and names the story the same
way, with re and "st":1:
{"fnc":1,"kind":"react","re":{"mid":"<story key>","author":9,"preview":""},"caption":"\ud83d\udc4d","st":1}
kind is react and caption is
the emoji, matching §6.9's reaction envelope, and
re.preview is empty. An empty caption
withdraws a reaction already sent: removal is a message because the
author's device has already rendered the first one and there is no other
way to reach it.
st decides where it lands. §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
reaction carrying "st":1 folds onto the story whose key is
re.mid, never onto a message that happens to share the id.
A receiver MUST NOT render a story reaction into the thread as a
message. st is read as 1 or true
(§6.6).
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.22.1 Reactions and the author's own devices
A reaction is addressed to every device the author has, because the sender cannot know which one the author is holding. A story, in contrast, was sent only to its audience, so an author posting from one device left every other device of theirs unaware the story existed at all. Those devices then received every reaction and had nothing to attach them to, and the person watching that device saw a story drawing no response.
An author's client MUST send each story it posts to the author's own account as well as to the audience. From stage A1 "the author's own account" means its linked devices (§17.3) and no other. The copy carries the same envelope and the same story key, so a story is one story across the account's devices and a reaction lands on it wherever it is read. The send is best effort and separate from the audience count: an account with a single device has nowhere to send it, and that is a successful post, not a partial one.
A receiver MUST file a story arriving from its own account as the account's own, and MUST require the sender to be proven (§19) before doing so. An unproven envelope asserting the receiver's own id would otherwise place a stranger's story on the receiver's own strip, where it is shown as theirs and its reactions are shown to them.
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 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.
Implementation status. iPhone only, in both directions.
| Web engine | iOS | Android | |
|---|---|---|---|
| Notices the screenshot | n/a | ChatView, on
userDidTakeScreenshotNotification |
no |
Sends shot |
n/a | E2EInbox.announceScreenshot |
Session.announceScreenshot, no call site |
| Renders an arriving one | n/a | ChatView, from
E2EInbox.screenshotNotices |
no |
Sending is a MAY and Android not sending one is within the spec.
Rendering is not: a receiver MUST show the notice, and an Android device
shows nothing. The shot kind,
Content.screenshot, Content.screenshotAt and
Content.screenshotDisclaimer are all present in
Content.kt, and Session.announceScreenshot is
complete, down to the per-peer window the paragraph above asks for.
Nothing calls any of it, and nothing registers Android's
ScreenCaptureCallback, so there is no event to announce
either.
The practical effect is one-sided and invisible to both people. An iPhone user who screenshots an Android user's disappearing message sends a notice that is never displayed. An Android user who screenshots an iPhone user's sends nothing, and §6.27 already says an absent notice means nothing, so the iPhone user cannot tell that case from a platform that could not report it.
6.28 Off the record
A conversation both ends agree not to write down. While it is on, neither client persists the messages: they live in memory for as long as the app is open and are gone when it closes. Nothing is deleted afterwards, because nothing was ever written.
{"fnc":1,"kind":"otr","ctl":1,"otr":1}
{"fnc":1,"kind":"otr","ctl":1,"otr":1,"echo":1}
{"fnc":1,"kind":"otr","ctl":1,"otr":0}
ctl is set (§6.10), so a build that does not know
otr ignores it rather than rendering it, and, critically,
never turns the mode on. That is why it is a proposal and an
echo rather than a single announcement. The proposing client
sets the mode locally only when the echo comes back. A client that
receives a proposal and supports the mode turns it on and replies with
the same envelope carrying echo, which MUST NOT itself be
echoed.
The handshake exists because the alternative is worse than the feature. If one side believed the conversation was off the record and the other was writing it to disk, the promise would be false in exactly the case somebody relied on it. So the mode is never on for one end alone: a proposal that is not echoed leaves both ends exactly as they were, and the proposing client MUST say the other end did not agree rather than showing the mode as on.
Turning it off ("otr":0) needs no echo. Either end may
stop at any time, and stopping is not something the other end can
refuse.
What it does not do. It stops this app writing the conversation to this device's storage. It does not stop a screenshot (§6.27), a photograph of the screen, a modified build, or a platform backup of anything that reached the screen. A client MUST describe it as "neither phone writes this down" and MUST NOT describe it as messages that cannot be kept.
Messages sent while it is on are ordinary envelopes. Nothing on the wire marks them, and the relay learns nothing: the mode is a rule about what the two clients do with plaintext they have already decrypted.
Implementation status. iOS and Android. The
reference engine does not implement §6.28 at all: nothing in
src/ handles the otr kind, so a client built
on it writes whatever its own storage layer writes.
The promise is about a conversation, and a conversation is more than its messages. Anything a client keeps keyed by conversation has to be filtered on the way to storage, or the mode leaks the parts nobody thought of as messages. What each client filters:
| iOS | Android | |
|---|---|---|
| The messages | persistable(threads) |
rows held in ephemeral |
| The draft | persistableKeyed(drafts) |
Vault.saveDraft |
| The pin | persistableKeyed(pins) |
ephemeralPins |
| Edits and reactions | fold into threads |
mutateHistory |
| The unread mark | persistableByUser(unreadPeers) |
Vault.saveUnreadThreads |
| The queued send | persistableOutbox(outbox) |
Vault.saveOutbox |
| Cards, events and games (0.32) | on the message, in threads |
on the row, in ephemeral |
| Answers that arrive before their event (0.32) | earlyAnswers, memory only |
heldAnswers, memory only |
The queued send is the one that carries the message text rather than metadata about it, so it is the one worth checking first in any client.
Two things are kept on purpose, and a client MUST NOT describe either as covered. Envelopes held for a peer whose identity change has not been accepted are kept: they are ciphertext this device cannot read, and dropping them loses the messages that holding exists to save. The call log is kept: a call is not a message, and a client that erased call history with the mode on would be making a promise §6.28 does not.
A client that adds state keyed by conversation adds a row to that table or a leak. The rule that catches it is to ask where an off-the-record thread's copy of the new thing goes; the answer has to be memory.
6.29 Polls nobody can attribute
A poll asked of a group where no individual vote is ever sent to anybody, including the person who created it and including the server.
Hiding a vote in the interface is not hiding it. In every messenger that offers an "anonymous" poll today, the vote reaches somebody: a server, or the poll's creator, or both. Group messages here are encrypted to each member separately, so the recipient of any envelope knows who sent it, and sending "I chose B" to anybody at all attributes it.
Secure aggregation. A vote is split into random pieces that sum to it, one piece per member. Each member is handed one piece of everybody's vote, publishes only the sum of the pieces they hold, and the published sums add up to the counts.
{"fnc":1,"kind":"poll","ctl":1,"pid":"<uuid>","q":"<question>","opts":["<a>","<b>"],"mset":"<member set>","gid":"<group>"}
{"fnc":1,"kind":"pshare","ctl":1,"pid":"<uuid>","v":[1732584193,-1732584192],"gid":"<group>"}
{"fnc":1,"kind":"psum","ctl":1,"pid":"<uuid>","v":[-604832771,604832772],"gid":"<group>"}
ctl is set on all three (§6.10), so a build that does
not know them ignores them rather than rendering a poll it cannot take
part in.
The vector. v is one signed 32-bit
integer per option, in the order opts gives. A vote for
option i is the vector that is 1 at i and 0
elsewhere. Arithmetic wraps at 32 bits, and the wrapping is what does
the hiding: every piece but the last is a uniform random integer, the
last is whatever makes the set add up to the vote, and no single piece
says anything about the vote it came from. Implementations MUST use
wrapping addition over the full 32-bit range. A language that traps on
overflow MUST mask rather than throw.
mset names the members the arithmetic
is agreed over, and every client MUST compute its pieces against that
list in that order. Two clients disagreeing about who is in the poll
produce sums that do not add up, and the result is a wrong tally rather
than an error, which is the failure this field exists to prevent.
The guarantee, exactly. Every member other than the voter would have to collude to recover one vote. That is what this provides. It is not "nobody can ever know", and a client MUST state the collusion bound rather than the shorter claim.
The bound is worth reading carefully at small sizes. Against a group of two it is empty: the other member holds a piece of your vote and sees the tally, and subtracting one from the other gives your vote with no collusion at all, because there is nobody to collude with. The arithmetic has no threshold to enforce this at, so a client MUST NOT offer the word "anonymous" for a poll of two, and SHOULD set a floor below which it does not offer one.
Who voted is visible; what they voted is not. Holding a piece of somebody's vote is how a member learns that person has voted, and a client needs that to know when the poll can proceed. The scheme hides the content of a vote, never the fact of one. A client MUST NOT present participation as secret, and MAY show who has voted, which is also the only honest way to say who a poll is still waiting on.
Everybody publishes, or there is no tally. A missing
sum does not give a smaller count. It leaves a uniform random number in
the total, so a partial result is not a partial answer but a meaningless
one. A client MUST wait for a sum from every member in mset
and MUST NOT show a result before then. It MUST say plainly that it is
waiting, and on whom.
There is deliberately no fallback to a poll that is merely hidden. A fallback that kept the word "anonymous" while quietly sending votes to somebody is the one outcome that must not ship, and offering it under another name in the same interface invites exactly that confusion.
Limits. Share traffic grows with the square of the
membership: every member sends a piece to every member. A client MUST
NOT start a poll in a group larger than it can serve and MUST say so
rather than starting one that will never complete. opts
carries at least 2 and at most 12 options.
6.30 Handing a call to another of your devices
On a call on the phone, walk to the desk, take the call on the tablet, and keep talking. The other person hears a short gap and nothing else.
This account's own devices already have a private channel during a call: the device that answers tells the others to stop ringing (§6.8). Handoff is that channel carrying more.
{"fnc":1,"kind":"call-handoff","call":{"id":"<uuid>","peer":4211,"video":false}}
{"fnc":1,"kind":"call-handoff-taken","call":{"id":"<uuid>"}}
Both go to this account's own linked devices (§17.3) and to nobody else. Neither is ever sent to the peer. A device that was never linked cannot offer to take a call or take one: taking a call from another device is exactly what a planted device would want.
The sequence.
- The device on the call offers it to this account's other devices, naming the call and the peer. Only an idle device offers to take it.
- The taking device builds a fresh connection and sends the
peer a
call-offerunder the samecall.id. - The peer treats an offer for a call it is already on as a renegotiation: it answers, stays on the call screen, and logs one call rather than two.
- The taking device sends
call-handoff-takento its own devices. The original tears down its connection and sends the peer nothing.
The peer is never hung up on. No
call-end is sent to the peer at any point during a handoff,
in either direction. An end would make the far end drop the call and
redial, which is the failure this whole sequence exists to avoid. The
original device MUST tear down locally and silently.
A handoff that half happens leaves the original working. If no device takes the call within a few seconds, the offer expires on both sides and the call stays exactly where it is. Expiry is a timeout at both ends rather than a withdrawal envelope, so a lost withdrawal cannot leave a device believing an offer is still live.
Whether the peer can take a renegotiating offer is known
before the handoff starts. A client that accepts a
renegotiating call-offer sets "rnv":1 on the
call-offer and call-answer it sends, so both
ends learn it while the call is being set up. A client MUST NOT offer a
handoff for a call whose peer did not set it. Discovering that the far
end cannot renegotiate by dropping the call is the wrong way to find
out.
A device that does not understand call-handoff ignores
it, which is correct: it does not offer to take the call, and the call
stays where it is.
The relay learns nothing new. These ride the ratchet like every other call signal, and the same envelopes to this account's own devices already exist for stopping a ring.
6.31 Greeting cards
A card is one of flamenet.io's card designs with a few lines of words, sent inside a message as a text is. Only the design's id travels; each app draws the front from its own copy.
{"fnc":1,"kind":"card","mid":"<id>","card":{"d":"birthday","to":"Sam","msg":"Have a lovely day.","from":"Pat","open":1767225600}}
| Field | Required | Meaning |
|---|---|---|
d |
yes | The design: birthday, thanks,
thinking, congrats, getwell,
love, season, hello or
sympathy. |
msg |
yes | The words inside. May be empty. Drawn to at most 2000 characters. |
to |
no | The salutation line. Drawn to at most 60 characters. |
from |
no | The signature line. Drawn to at most 60 characters. |
open |
no | Unix seconds. Until then the receiver keeps the card sealed. |
A card is renderable and follows the rules §6.6 gives for the kinds
added in 0.32. In a group it carries gid and
gname after the card, as a group text does:
{"fnc":1,"kind":"card","mid":"m1","card":{"d":"love","msg":"x"},"gid":"g1","gname":"Family"}
A card that fails to read is a message lost, so a receiver reads the
card object leniently. An unknown d draws as
hello and is kept as sent, so a forward or a backup carries
what the sender chose; a missing d is hello
and a missing msg is empty; a field of the wrong type is
absent ("to":7 has no salutation,
"open":"soon" does not seal the card). A card
that is absent or not a JSON object draws the placeholder (§6.10).
The front is a picture each app ships for each design,
cards/<d>.png, 600 by 450 pixels, the same bytes in
both apps, with the design's title drawn by the app as text: Happy
Birthday, Thank You, Thinking of You, Congratulations, Get Well Soon,
With Love, Season's Greetings, Hello!, With Sympathy. These are the
site's own titles. Nothing is fetched to draw a card.
Sealed until. A sender MAY set open to
a moment in the future, at most 400 days ahead. Until then the receiver
shows the front with "Opens on to, msg or from. At that moment
the card opens by itself, and no message is needed to open it. The
sender's own copy is never sealed, and says who it is sealed for and
until when. A receiver opens a card at the earlier of open
and 400 days after the card arrived, by the device's own record time
(§6.11), so neither a hostile value nor a wrong clock keeps a card
shut.
Sealing is a courtesy between apps that honour it. The words are on the receiver's phone from the moment the card arrives, and an older or modified build shows them at once. A client MUST describe it that way and MUST NOT call a sealed card locked or hidden. It has the same standing as a story's expiry (§6.6).
With previews on, a card notifies "
A forward (§6.16) carries a new mid,
"fwd":1, and no open:
{"fnc":1,"kind":"card","fwd":1,"mid":"m-2","card":{"d":"birthday","to":"Sam","msg":"Have a lovely day.","from":"Pat"}}
A client MUST NOT offer to forward a card that is still sealed for the person holding it.
In a backup the message carries the card object as it
travelled, and its text is the empty string, so a sealed
card's words are never written outside the card (§16.2.1).
What the relay learns: nothing new. A card is a content envelope inside the ratchet, submitted and announced as a text is.
Status. Built in both apps for 0.32, not released (§18.1).
6.32 Events and answers
An invitation to something at a time and a place, which everyone in the conversation can answer.
{"fnc":1,"kind":"event","mid":"<id>","ev":{"id":"<uuid>","rev":1,"title":"Picnic","start":1767225600,"end":1767232800,"tz":"America/New_York","place":"Riverside park, by the boathouse","note":"Bring a blanket"}}
An event that lasts all day carries day, and
days when it runs longer than one day, in place of
start, end and tz:
{"fnc":1,"kind":"event","mid":"<id>","ev":{"id":"<uuid>","rev":1,"title":"Picnic","day":"2026-12-25","days":2}}
| Field | Rule |
|---|---|
id |
Required. A non-empty string, the same in every revision. Whitespace counts as non-empty. |
rev |
Required. An integer from 1. A higher rev replaces the
whole event; an equal or lower one is ignored. |
title |
Required, not blank. Drawn to at most 120 characters. |
start, end |
Unix seconds. end is optional, and is not shown when it
is before start. |
day, days |
A day the calendar has, written yyyy-MM-dd, and how
many days, written only when more than 1. days runs 1 to
31: a larger value is shown as 31 and a smaller one as 1. |
tz |
The organizer's IANA zone. Timed events only. |
place, note |
Optional. Drawn to at most 200 and 1000 characters. Never geocoded, never fetched. |
cancel |
1 on a cancellation; true is read the
same. |
An event is renderable and follows the rules §6.6 gives for the kinds
added in 0.32. Both apps write ev's keys in the order
id, rev, title, then
start, end, tz or
day, days, then place,
note, cancel. When an ev carries
both start and day, the timed form wins.
An ev that is not an object, or lacks a non-empty string
id, an integer rev of at least 1, a title that
is not blank, or either an integer start or a valid
day, draws the placeholder (§6.10). It is never dropped:
somebody meant to invite somebody.
Revisions. Only the organizer can change an event,
and the organizer is whoever sent rev 1, by ratchet identity, never by
anything the envelope says. A revision is a whole event envelope with
the same ev.id, a higher rev and a fresh
mid. A cancellation is a revision that carries the whole
event and "cancel":1:
{"fnc":1,"kind":"event","mid":"<id2>","ev":{"id":"<uuid>","rev":2,"title":"Picnic","start":1767225600,"end":1767232800,"tz":"America/New_York","place":"Riverside park, by the boathouse","note":"Bring a blanket","cancel":1}}
A receiver folds every revision into the card it already shows, by
ev.id. The card keeps the mid it first arrived
with, so replies and reactions still find it, and a revision never adds
a second bubble. When rev 1 has not arrived, the first sender seen
stands in as organizer; a rev 1 from somebody else then replaces the
stand-in, and the event with it. Anyone else reusing a known
ev.id is ignored.
Answers. An answer is control traffic, sent to everyone the event went to:
{"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":"<uuid>","r":"yes","n":3}}
eid names the event and r is
yes, maybe or no. n
orders one person's answers: it is the unix second the answer was given,
or one more than the last n this device sent for the event
when that is already at or past the present second. A receiver keeps the
answer with the highest n from each person. On an equal
n, the r that sorts higher as a string wins
(yes, then no, then maybe), so
every device settles on the same answer whatever order they arrive in.
An answer without a non-empty eid, with an r
outside those three, or without an integer n of at least 1,
is dropped. An answer that arrives before its event is held in memory
only, never written down. In a group an answer carries gid
and no gname, as a poll's control traffic does (§6.29):
{"fnc":1,"kind":"rsvp","ctl":1,"rsvp":{"eid":"e","r":"maybe","n":1},"gid":"g1"}
Answers are not private. Every member of the conversation receives every answer, and the card shows names beside the counts. A client MUST say so where the answer is given.
With previews on, an event notifies "
Add to calendar opens the system's own screen for adding an event. Nothing is read from the calendar.
A forwarded event (§6.16) is a new event: a fresh ev.id,
rev 1, "fwd":1, no answers, and the person forwarding it as
its organizer. A client MUST NOT offer to forward a cancelled event.
In a backup the message carries ev, the wire object of
the latest accepted revision, and beside it org, the
organizer's user id as an integer, and rsvp,
{"<user id>":{"r":"yes","n":3}}. Its
text is the empty string (§16.2.1).
What the relay learns: nothing new. Events are
submitted and announced as texts are, and answers are submitted
silent (§8.4), as other control traffic is.
Status. Built in both apps for 0.32, not released (§18.1).
6.33 Games in a chat
Chess, checkers, reversi and tic-tac-toe, played in a one-to-one conversation. Only moves travel. Each phone keeps the whole game as its list of moves and works the board out from it, so a game can be rebuilt from any one envelope, and neither phone has to trust the other's board.
{"fnc":1,"kind":"game","mid":"<id>","game":{"id":"<uuid>","t":"chess","mine":"first"}}
{"fnc":1,"kind":"gmove","ctl":1,"game":{"id":"<uuid>","t":"chess","mine":"second","log":["e2e4","e7e5"]}}
{"fnc":1,"kind":"gend","ctl":1,"game":{"id":"<uuid>","t":"chess","mine":"first","log":["e2e4","e7e5"],"r":"resign"}}
game invites the other person and is renderable: the
board is drawn on that message, so it carries mid.
gmove and gend are control traffic. All three
follow the rules §6.6 gives for the kinds added in 0.32.
| Key | Meaning |
|---|---|
id |
The game. A non-empty string, the same in every envelope of the game. |
t |
chess, checkers, reversi or
tictactoe. |
mine |
The side the sender of this envelope plays: first or
second. First is white in chess, dark in checkers, black in
reversi and X in tic-tac-toe. |
log |
Every ply of the game so far, in order, in the notation below.
Required and not empty on gmove; required on
gend, where it may be empty. A game carries
none. |
r |
On gend only: resign,
draw-offer, draw-accept,
draw-decline, decline or
cancel. |
Keys are written in the order id, t,
mine, log, r. A game
whose object does not read (no id or an empty one, a
t or a mine outside the lists, a
log that is not a list of strings, a game that
is not an object) draws the placeholder. A gmove or
gend that does not read is dropped, and so is a
gmove without a log or with an empty one, and
a gend without a log or with an r
outside the list. Games are one-to-one in this release: a
game carrying gid draws the placeholder, a
gmove or gend carrying gid is
dropped, and a client does not offer a game in a group. A game is never
forwarded.
resign ends the game, lost by the side that sent it.
decline is the invitee saying no before their first move,
and cancel is the starter withdrawing before the invitee's
first move. draw-offer offers a draw,
draw-accept takes one and ends the game drawn, and
draw-decline turns one down. Endings on the board (mate, no
moves, a full board, the automatic draws) are worked out by both phones
from the log and need no message.
Receiving a gmove or a
gend. In this order:
A game the conversation does not know is built from the envelope: its
t, the sender's side frommine, and its log replayed from the start. Thegamemay have been lost, or the device restored. A log that does not replay still makes the card, already out of step. Adeclinefor a game not known makes the receiver the starter, since only the invitee can decline.A game that has ended, or is out of step, takes nothing more.
The sender must play the other side of the same game. A
tor aminethat disagrees with the game held puts it out of step.A log no longer than the one held must be a prefix of it. Then the envelope is a duplicate and is ignored, except that a
gend's ending is still judged. A log that is not a prefix puts the game out of step.A longer log must start with the log held, and every ply it adds must belong to the sender's side and be legal in turn, replayed from the start within the game's cap, so a peer cannot play the receiver's moves. Otherwise the envelope is refused and the game is out of step.
A
gend's ending is then judged at the log it carried, which is by now a prefix of the log held:resigncounts.draw-offercounts only from the side that has just moved, only at the receiver's whole log, only once the invitee has made a first move, and only when no offer is open. The next move by either side lapses it.draw-acceptanddraw-declinecount only against an open offer from the receiver, anddraw-acceptonly at the receiver's whole log, so an acceptance cannot race a move.declinecounts only from the invitee andcancelonly from the starter, and each only while the log it carried holds no move by the invitee.
An ending that does not count is ignored. Moves the same
gendcarried still count.
Out of step is final for that game. The card says "Your boards
disagree" and offers "Start a new game", which sends a fresh
game of the same type with the same sides, and anything
that arrives later for the old game is ignored.
A client offers a move only when it is legal and it is this person's
turn. In reversi a side with no placing move sends pass as
an ordinary gmove, from a Pass button that is then the only
move offered, so every move is one a person chose. "Offer a draw" is
offered only while it is the other person's move. gmove and
gend are never queued: when a submission fails for any
reason, a contact with no device yet included, the move or the ending is
taken back and the person is told. The game itself is
queued like any message.
Notation and rules. Both apps' engines must give the
result test/vectors/games-v1.json gives for every case in
it, and must match its perft counts, which are the published ones.
- chess: UCI long algebraic notation, the promotion
piece in lower case (
e7e8q), castling as the king's move (e1g1). All of FIDE's moves: castling, en passant, promotion. The game ends at checkmate, at stalemate, on insufficient material, and by automatic draw at threefold repetition and under the fifty-move rule (100 plies with no capture and no pawn move). Insufficient material is exactly four cases: king against king, king and bishop against king, king and knight against king, and king and bishop against king and bishop with both bishops on squares of one colour. A position repeats when the placement, the side to move and the castling rights are the same, and the en passant square too, counted only when an en passant capture is legal. - checkers: English draughts on 8 by 8, squares 1 to
32 in the standard PDN numbering. The first player's men (dark) start on
1 to 12 and move toward 32; the second player's men (light) start on 21
to 32. A move is written
11-15(a step),15x24(a jump) or15x24x31(a multiple jump, every landing square). Capturing is compulsory, but any capturing sequence may be chosen, not only the longest; a jump continues while the piece that moved can jump again; a man that reaches the far row is crowned and the move ends there. Kings move and jump one square diagonally in all four directions. The side to move with no legal move loses. A game is drawn automatically after 80 plies with no capture and no man moved, or at threefold repetition of placement and side to move. - reversi: 8 by 8, files
atohleft to right and ranks 1 to 8 top to bottom as drawn for the first player. The second player (white) starts on d4 and e5, the first player (black) on d5 and e4, and the first player moves first. A move is a square (d3), orpass, which is legal only when the side to move has no placing move and the other side has one. The game ends when neither side can move; the side with more discs wins, and equal discs are a draw. - tictactoe: cells
1to9by rows from the top left. The first player is X. The game ends at three in a row or a full board.
Checkmate and stalemate are judged before the automatic draws, and a checkers side with no move loses even on the 80th quiet ply. A log is capped: 600 plies in chess, 400 in checkers, 130 entries in reversi (passes included) and 9 in tic-tac-toe. A longer log is refused before any of it is played, and a receiver always rebuilds a position by replaying the log within the cap.
With previews on, an invitation is "game, gmove and gend are all
submitted announced (§8.4), so the other phone wakes. The iPhone app has
no notification extension in 0.32, so a move that arrives while it is
not running shows as the relay's standard alert, "New message" with a
sound (§8.11). Inside the app, and on Android, the notice is the quiet
"Your move".
Chess, checkers and reversi turn round for the second player. Tic-tac-toe never does, so its cells keep their places on both phones.
In a backup, the message that started the game carries
"game":{"id","t","me","log","r","rby"}: me is
the side this person plays, log the whole log,
r the ending a gend brought
(resign, draw-accept, decline or
cancel) and rby the side that sent it, written
only with r. An open draw offer and an out-of-step game are
not carried: a restored game is replayed from its log. The message's
text is the empty string (§16.2.1).
What the relay learns. Moves are sealed envelopes like any other. Because they are announced, the count on an iPhone's icon includes them until the app next runs (§9).
Status. Built in both apps for 0.32, not released (§18.1).
6.34 Hello links
A link a person can publish so that whoever opens it can ask to message them:
https://flamenetmessenger.com/hi#fnid:<52 characters>@<relay host>
After the # is the person's address as the app shows it
on the address card: their fnid, the account key in the alphabet of
§19.2, and their home relay.
Why the fragment. A browser does not send the part
of a link after # to any server, so the web server that
serves the page never learns whose link was opened. The page reads the
fragment on the device and hands it to the app.
A receiver accepts exactly these forms:
https://flamenetmessenger.com/hi#<address>andhttps://flamenetmessenger.com/es/hi#<address>, the published links;flamenetmessenger://hi#<address>, the app's own scheme, which the page links to;- in a scanner or a paste, a bare
<address>, orflamenet:<address>as older address cards hold it, ignoring anything from a?on.
Before the #, the scheme and the host are compared
without regard to case. The path is exactly /hi or
/es/hi, in lower case and with no trailing slash, and the
app link has no path at all, so flamenetmessenger://hi/#…
is refused. A link with a query, a port or a user name is not a hello
link. Whitespace around the whole text is ignored.
The fragment is at most 400 characters, as the relay's page caps it,
and is percent-decoded once. What is left must be exactly
fnid: and 52 characters of 0-9,
a-h, j, k, m,
n, p-t and v-z, then one
@, then a host made of the ASCII letters a to
z, digits, hyphens and dots, with at least one dot. There
is no space anywhere in an address, around the @ included.
The host is lowercased by ASCII rules alone: A to
Z become a to z and no other
character is changed, so a character outside that set refuses the
address and is never folded into it, a Kelvin sign that would lowercase
to k included. The fnid is never lowercased: a key in
another case is another key. A refused link changes nothing, and the app
says it holds no address it can use.
test/vectors/hello-link-v1.json gives inputs in each
form and the address or refusal each must give.
Opening one fills in the app's add-by-address screen and stops there.
Nothing is sent until the person chooses to start a conversation, and
first contact then goes as it always does. A scanner binds what it reads
by the fnid, and ignores a ?u= on an older card.
The page. GET /hi and
GET /es/hi on a relay serve a static page, marked noindex
and left out of the sitemap, that loads nothing from any other host. Its
script reads location.hash, checks it by the rule above,
and shows the address with "Open in Flamenet Messenger"
(flamenetmessenger://hi#<address>), a copy button and
the install links. The script MUST NOT send the fragment anywhere: no
request, no beacon, no image, and no link but the app link carries it.
The fragment is written into the page as text, never as markup. A
fragment that is not an address shows a plain explanation and the
install links.
The address card shows the hello link with copy and share, and its QR
code holds the https link.
What it gives away. Anyone who has a hello link can ask to message its owner, and the link names the owner's account key and home relay. Publishing it is the owner's choice, and the app says what it gives away where it offers the link. FlameNet's profile can show one, as an opt-in field: the messenger never calls flamenet.io and flamenet.io never calls a relay on a member's behalf, so neither learns the other's name for the person from it.
Status. Built in both apps and on the relay for
0.32, not released (§18.1). The vector file's history
records the inputs on which an app's feature branch did not follow this
section.
6.35 Taking a story down
Specified for 0.32; implementation in progress.
The author of a story MAY take it down for everyone it went to:
{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"}
{"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":"<set id>"}
sid names the story the way replies and reactions name
it (§6.20, §6.22): the attachment id of a story with a picture or a
video, or the mid of a text story. It is the value a backup
calls the story's key (§16.2.1). A story of several items
(§6.23) comes down whole, with set in place of
sid; in this envelope set is the set's id as a
string, not §6.23's object. An unpost carries exactly one
of the two, and one carrying both or neither is dropped. It carries no
gid, even for a story posted to a group: the receiver finds
the story by its author and its id. It is sent to everyone the story was
sent to, the author's story audience or the group's roster (§6.24), and
to the author's own devices (§6.22.1). It is control traffic and follows
the rules §6.6 gives for the kinds added in 0.32.
A receiver applies it only to a story it holds and attributes to the
sender of the unpost, the rule §6.15 applies to an edit. It
removes the story from the strip, the feed and the archive, and deletes
its cached copy and the decrypted bytes; with set, every
item of that set from that author goes. An unpost naming a
story the receiver does not hold is dropped. It never notifies. The
author's own copy goes at once.
What it does not do. It removes the story from the phones whose app receives the message and honours it. Anyone who has already seen the story may have kept a copy, and a build that predates this section keeps it until it expires. A client MUST say both when the person confirms, and MUST NOT describe taking a story down as deleting it everywhere. The relay offers no way for an uploader to delete a blob, so a story's blob stays until the retention purge (§9); it cannot be read without its key, which only the devices that held the story had.
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).
With the post-quantum identities (§20). When a
client holds both its own ik_pq and a pinned
ik_pq fingerprint for the remote device (§20.5), it MUST
cover both in the number. Covering only Ed25519 would let an adversary
who could forge ML-DSA keep the real signing key, so the digits still
matched, while substituting the identity the post-quantum layer exists
to authenticate (audit H3). With pq = SHA-256(ik_pq):
bound(IK_sig, pq) = SHA-256( "fnsn-pq-v1" || IK_sig || pq ) // UTF-8 tag, 32-byte inputs
fingerprint_local = fingerprint( bound(IK_sig_local, pq_local) )
fingerprint_remote = fingerprint( bound(IK_sig_remote, pq_remote) )
concatenated in the order of the Ed25519 keys, as
above. When either pq is missing the number is the Ed25519
one. test/vectors/protocol-v1.json pins both
(safetyNumber, safetyNumberPQ).
A client pins a device's ik_pq when it reads that
device's bundle (§20.5), which only the side that opens a session does.
Until the other side has read a bundle of its own, it shows the Ed25519
number while the first shows the covered one, and the digits differ. A
client MUST say which number it shows ("covers the post-quantum keys" or
not), so two people comparing can see why, and SHOULD read the remote
device's bundle once and pin it before showing a number it could
otherwise not cover. The comparison between two devices of one account
when linking (§17.3) uses the Ed25519 number.
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,
except where it says otherwise (sealed submissions, §14.5; the key log,
§19.3; anonymous reads, §8.13), 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 (
FN_BUNDLE_RATE, 600 by default) and per requester-target pair (FN_BUNDLE_RATE_TARGET, 60 by default), each counted in a fixed one-hour window that starts at the first request, and checked before any prekey is popped. 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.This note said 120 and 6 until 2026-10, and so do the relay's own README and configuration page. The code has defaulted to 600 and 60 since the pair limit was found to stop real conversations: a bundle is fetched per device on the far side, so at six an hour a contact with three phones could be started twice an hour, and the symptom was calls and stories quietly failing. The per-requester limit is the main defence against a drain; the pair limit is the second fence.
From stage C1 a first contact gets its bundles from
POST /e2e/intro(§14.5), metered the same way, and from stage D1 the bundles route has an anonymous form (§8.13). 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>" } ] }
The relay also returns fnid, and per device
proto and caps_sig. Until stage D1 it also
returns created_at and last_seen to any
authenticated member, about any account, unmetered: when each of
somebody's devices registered and when it last polled, a liveness record
nobody but the account needs. From D1 the times are returned only when
the account asked about is the caller's own (the apps' Devices screens
read them there), and GET /e2e/devices/me (§8.13) is the
route for them.
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] }.
Each payload is padded to one of the lengths in §23.3.
The length of the string is the only property of a payload the relay may
look at: it may count lengths that are off the ladder, and once
enforcing it refuses them with 400 and the code
unpadded (§23.11).
A submission MAY also carry urgent (§6.14) or
silent:
{ "to": 12, "silent": true, "messages": [ ... ] }
silent asks the relay to deliver without announcing: an
iPhone gets a background wake that shows nothing (§8.11), and the
envelopes stay out of the count on its icon (§9). A client sets it on
traffic that is not a message to a person: delivery keys, receipts,
answers to events (§6.32). Renderable content, and game moves and
endings (§6.33), are submitted announced. urgent wins when
both are set. Like urgent, it tells the relay something
about envelopes it cannot read, roughly whether they are housekeeping,
and the relay keeps that answer on each waiting envelope (§9).
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.
Byte cap. A relay MAY return fewer than
limit envelopes when more would pass a size cap
(RECOMMENDED: 2 MiB of payload), and then adds
"more": true. The field is present only when the poll
stopped early, and absent, never false, otherwise. A client
that sees it SHOULD poll again at once from the new cursor. Padding
(§23) makes the average envelope several times larger, and a fixed count
of them could otherwise build a response of hundreds of megabytes. A
client that ignores more loses nothing; it gets the rest on
its 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, followed by a random tail when attachment padding is on
(§23.9). The server never sees a key and cannot distinguish the blob
from noise.
- Size cap: 10 MiB per blob (larger →
413). That is also the top of the attachment ladder, so a padded blob is always one of the ladder's nine lengths (§23.9). - 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). The blob comes back as it was uploaded, tail included; the
receiver opens the first att.clen bytes (§23.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.
The blob's plaintext is padded to a fixed size (§23.10), so its
length says nothing about the group. The number of
group_tags rows still does: one per member.
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. 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.9c Sealed contact lists
The relay held a plaintext contact graph,
buddies(user_id, buddy_id), for one reason: presence is
shown only to people on your list, so the server kept the list in order
to enforce that. A copy of the database was therefore a copy of
everybody's contacts.
The same shape as §8.9b fixes it. The relay holds a set of opaque tokens per account and cannot tie any of them to a person.
Storage:
| column | holds |
|---|---|
user_id |
whose list this is. The subject, never the contact. |
digest |
base64 SHA-256 of a 32-byte token. No user id, no handle. |
The subject generates one random 32-byte token per contact and sends it to that contact over the encrypted channel, riding the
delivery_keycontrol envelope (§14.4), which is already per-contact.POST /messenger/roster/tags:{ "digests": ["..."] }. Replaces the whole set. Partial updates are not offered, because applying one would require the relay to know which contact a digest stood for, which is the thing it must not know.POST /messenger/presence:{ "tokens": ["..."] }. The relay hashes each token and looks for the digest. The answer is keyed by the token that was presented, never by an account, so the route names nobody.GET /messenger/buddiesgainsroster_on_server, a boolean. A relay that no longer keeps a contact graph answers with an empty list AND this set false.It exists because an empty list is otherwise ambiguous: an account with no contacts and a relay that holds nobody's contacts send the same thing. A client that cannot tell them apart has to refuse every empty answer to be safe, and then somebody who removes their last contact keeps seeing them on every other device. With the flag, a relay that keeps the graph is believed including when it says empty, and one that does not is ignored outright.
Absent reads as true. An older relay does keep a graph and has no way to say so, and reading its silence as "keeps none" would have every client stop believing it.
A token that matches nothing gets offline, the same
answer as somebody invisible or somebody whose last sign of life is
stale. Distinguishing them would turn the route into an oracle for
guessing tokens.
Holding the token is the permission. The presence answer does not go back through the buddy table, which is the point. Revocation is publishing a set without that digest: because tokens are per-pair rather than one secret shared with everybody, removing one contact costs nothing to the others. That is the difference from the sealed-sender delivery key (§14.4), where cutting somebody off replaces a key everybody holds.
The old path stays until nothing uses it. An account
that has published no tokens is still answered from
buddies, because a relay runs a mixed fleet of clients
through a changeover and presence going dark for everybody on an older
build would be a worse fault than the one being fixed.
buddies is dropped in a later release, once no client reads
it.
What this earns, and what it does not
A dump of roster_tags shows how many contacts an account
has and nothing about who they are. That is the whole claim, and it is
the same one §8.9b makes: a database dump does not reveal your
contacts, not "the server cannot see who you talk to".
It does not defeat the operator of a running relay. Every presence request is authenticated, so the relay sees which account asked, and asking repeatedly about the same subject rebuilds the graph from access patterns alone. Sealing the rows changes what is stored, not what is observable. §22 is what the second claim would need.
The contact count is still visible, since the rows are per account. Padding the set with decoy digests would hide it and is deliberately not specified here: it costs a client nothing to add later, and specifying it before anybody needs it would be specifying a number nobody has measured.
Besides the presence requests themselves, a running relay still sees
unsealed first contact, prekey fetches, account lookups and attachment
requests, each of which says who is interested in whom. A dump taken
before buddies is dropped still holds it, and so does any
database dump or write-ahead-log archive made earlier, until it ages out
of retention. §8.9d covers the other tables that held pairs and repeats
these limits.
Two devices on one account overwrite each other's tags (2026-10-07).
POST /messenger/roster/tagsreplaces the whole set for the account, while each device publishes the tokens it issued itself, so the second device to publish replaces the first's set, and contacts holding the first device's tokens see the account as offline. This was read from the code and not reproduced with two devices. The fix is decided: the relay keys the set, and the delivery-key digest of §14.4, by account and device, and says so withfeatures.perDeviceDigestsin its descriptor (§17.6). A client that sees it sendsdevice_idwith its tags and republishes its delivery-key digest once. Nothing in §8.9d that depends on presence tokens ships before a relay with it is deployed.
8.9d Contact state the relay no longer holds
§8.9c takes the contact list off the relay. The relay held other pairs of people besides that one, each for a feature that does not need it there, and a dump of any of them is a partial contact graph. This section is the plan that moves each to the client or drops it, in the stages at its end; as of 2026-10-07 none of it has shipped. The claim and its limits are §8.9c's, and the last subsection repeats them.
Accepted conversations. The relay kept, per pair,
whether a first message had been accepted. That moves to the client's
own roster entry and travels in backups as acc (§16.2.1). A
client seeds it once from the relay's answer, then stops asking. The
relay keeps answering until the contact list itself leaves it, because a
client that predates this reads acceptance from there.
Starred and archived conversations. The relay stored
both and never read either: they were set by a route and consulted by
nothing. They become local only, travel to linked devices as
conv/… sync registers (§17.6) and in backups as
convFlags (§16.2.1). From stage 2-A the relay stops writing
them and clears what it holds, and
POST /api/flamenet/v1/messenger/conversation answers a
request that sets either with 409 and the code
kept_on_device. A client that sees it keeps the flag
locally and MUST NOT retry or report a failure: the flag was set, just
not on the relay.
Blocks. The hard case, and the one that stays on the relay for now. To refuse an unsealed message from somebody, the relay has to know that this sender is blocked by this recipient, which is a pair, and no blinding of it survives a relay that can enumerate its own accounts. So:
- the client holds its own block list, travels it in backups as
blocked(§16.2.1), and uses it for display and when adopting state on a new device; - blocking somebody is three things, not one: the local block, revoking their §21.4 delivery tokens, and rotating the delivery key, so they can no longer send sealed;
- the relay keeps enforcing blocks on unsealed submissions until a
per-account
sealed_inbound_onlysetting exists, under which the relay refuses every unsealed message to that account and needs no pair to do it, together with a way for a stranger to make first contact under it. That way is §14.5's intro capability, and the setting is what an unlisted account gets at stage B4 (§8.14); refusing named sends relay-wide (§14.5, stage C4) is the same thing for everybody; - only then does the relay's block table go.
Dropping it sooner was considered and refused: a blocked person could still send an unsealed message, and the recipient's phone would still be woken with a "New message" notification for it.
Buddy-add notifications. The relay wrote a notification row when somebody added you, holding the adder's display name and no account id, which also meant deleting the adder's account could not find and remove it. They are replaced by a local alert on first contact. From stage 2-A read rows are kept 7 days and all rows 30; once the contact list leaves the relay, the rows are purged.
Adding a contact. With the contact list off the
relay (roster_on_server false, §8.9c), a client adds a
contact by looking the account up and starting a conversation, not
through the relay's add-a-buddy route, which would write the very pair
the setting exists to avoid.
Legacy groups. §8.9's registry is deprecated; its retirement is described there.
Invitation provenance. The relay recorded which account redeemed each invitation, a pair between the inviter and the person invited, read by nothing except a join page asking whether the invitation had been used. From stage 2-A, at redemption the relay clears both the redeemer and the issuer, keeping only the time of use, because the issuer with the time of use and the new account's creation time would rebuild the link. The join page reads the time of use. Existing rows are cleared the same way.
A new device. A backup (§16) restores the roster,
acc, blocked, convFlags, group
secrets and names, and the presence tokens issued and held. Without a
backup, contacts reappear as they write, but their presence stays dark,
because the tokens that answer for it are on the old device. The fix is
that a client re-sends the delivery-key prelude (§14.4) with the
presence token it already holds for a contact whenever that contact's
device list gains a device. That depends on the known issue in §8.9c
being fixed first. Before any of the drops below, clients show a banner
asking people to export a backup.
Self-hosted relays opt in to the drops with
FN_DROP_CONTACT_GRAPH=1. Without it a relay keeps the old
tables, so an operator who has not read this is not surprised by it.
Order. No step that destroys data comes before the clients that replace it, and each destructive step is its own deploy:
| Stage | What | Reversible |
|---|---|---|
| 2-A | Relay: invitation provenance cleared at redemption and backfilled; notification retention; star and archive no longer stored, and cleared. | the code, yes; the cleared values were read by nothing |
| 2-B | Both apps: local acc, blocks, stars and archives,
seeded from the relay; adding by lookup; local blocks used on a new
device; local first-contact alerts; owned legacy groups upgraded;
presence token re-sent to a new contact device; the backup keys; the
export banner. |
yes |
| 2-C | Relay switches, a week apart: the contact list off the relay, then legacy groups read-only, then off. | yes |
| 2-D | Relay drops, each its own deploy: notification rows, then the contact list and conversation state together, then legacy group membership, then the invitation redeemer column. | no |
| 2-E | Blocks: rotation on block, sealed_inbound_only, a
first-contact path under it, then the block table. |
the last step, no |
2-B ships only after both apps' store releases, and 2-D waits until
no device older than 2-B has been seen for 30 days, the same gate as
§8.9c's removal of buddies.
What this earns, and what it does not
The same claim as §8.9b and §8.9c, for more of the tables: a database dump does not reveal your contacts. Not "the server cannot see who you talk to". On a running relay, authenticated presence queries, unsealed first contact, prekey fetches, account lookups and attachment requests all still show who is interested in whom, and the operator can rebuild much of the graph from them. Database dumps and the write-ahead-log archive keep the old rows until they age out of retention, so the claim holds for a dump taken after that, not for one taken before. The key-transparency log (§19) is never touched: its rows must stay for clients that pin them.
8.9 Group registry
Deprecated. New groups are sealed (§8.9b). This registry holds each legacy group's owner and members in plaintext, which is the leak §8.9b exists to remove, and it is retired as follows. An owner's device upgrades each legacy group it owns to a sealed one on its own (upgrading already removes the group's membership rows, but today it is a step the owner takes by hand). A group whose owner has not been seen for 30 days passes to its longest-standing active member, by the succession rule below, so that member's device upgrades it. The relay then turns the registry read-only, and later off (
FN_LEGACY_GROUPS=on,readonly,off), and finally drops the membership table and the owner column, each a separate deploy (§8.9d). Builds old enough to create legacy groups lose the upgraded group, which is why read-only waits for those builds to drain.
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 written before it 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 once stored group names in the clear, 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>:<opaque>,
credential = base64(HMAC-SHA1(secret, username))). TTL 2
hours. The part after the colon is 32 random hex characters, fresh for
every credential, and MUST NOT be the user id: the username crosses the
network in clear in every STUN/TURN request, and coturn reads only the
expiry before the colon. Clients MUST treat the username as opaque.
Response:
{
"urls": [ "stun:turn.flamenet.io:3478", "turn:turn.flamenet.io:3478?transport=udp" ],
"username": "1787260000:9f86d081884c7d659a2feaa0c55ad015",
"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).
8.11
POST /e2e/push/token, waking an iPhone
iOS suspends an app in the background and gives it no connection of its own, so a relay that wants an iPhone to collect its mail has to ask Apple to wake it. This route tells the relay how. An Android app keeps its own connection and does not use it.
{ "device_id": "<uuid>", "platform": "apns", "token": "<APNs device token>", "voip_token": "<PushKit token>" }
device_idMUST be the caller's own device. A request naming another device is refused.platformisapns, the only platform stored. Anything else is400.voip_tokenis optional. It is the token that can ring a locked phone for a call (§6.14).- Registering again replaces what was stored, one token at a time: a token is replaced only when one is sent, so the two can arrive in either order.
- An empty
tokenwith novoip_tokenforgets the device. The app sends this when it signs out.
Response: { "ok": true }.
What the relay sends. On each submission (§8.4) to a device it holds a row for:
urgent(§6.14): a VoIP push tovoip_token, which rings the phone. Each attempt is recorded for seven days, with the account, the device, the time and the outcome.silent(§8.4): a background push that wakes the app and shows nothing.- otherwise: an alert push with fixed words, "Flamenet" and "New message", a sound, and as its badge the number of envelopes waiting for that device that were announced (§9).
No push carries anything from the envelope: no content, no sender, no conversation. The alert is the same for every announced envelope, a game move (§6.33) included. When the app next runs it collects and decrypts what arrived, and any notification it shows then uses the wording each section gives. A token Apple reports gone is forgotten.
What it stores and reveals. One row per device: the device id, the user id, the platform and the two tokens. Apple learns when a relay wakes a device, and from which relay. The relay learns nothing about an envelope by pushing it that it did not know by holding it.
Status. Implemented on the relay and in the iPhone app.
8.12
GET /today.json, the Today file
One public file from FlameNet for the apps' Today tab: headlines,
market prices, weather for a list of cities, a site from the directory
and the latest FlameNet blog post. Every reader gets the same bytes, and
asking for it carries nothing that identifies the reader. The route is
at the relay's root, not under /e2e.
The envelope:
{"v":1,"kid":"a9a94e74","body":"<base64 of the body's UTF-8 JSON>","sig":"<base64 Ed25519 signature over the body bytes>"}
kid is the first 8 hex characters, in lower case, of the
SHA-256 of the 32-byte public key that made the signature. The apps pin
FlameNet's keys by kid. The development key, public
o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA= and kid
a9a94e74, is compiled into debug builds only, so a release
build shows nothing it signed. The production key is made at release
time, and a build pins its public half then.
The body:
{"v":1,"date":"2026-10-04","made":1791116238,
"headlines":[{"t":"Title","u":"https://flamenet.io/...","src":"Publisher"}],
"markets":[{"sym":"^GSPC","name":"S&P 500","last":"6712.40","chg":"+12.10","pct":"+0.18"}],
"weather":[{"city":"New York","cc":"US","tz":"America/New_York","lat":40.71,"lon":-74.01,
"now":{"c":18,"sky":"rain"},
"days":[{"d":"2026-10-04","hi":21,"lo":14,"sky":"rain"}]}],
"site":{"t":"Name","u":"https://...","d":"One line"},
"note":"Whose words the site's line is",
"post":{"t":"Title","u":"https://flamenet.io/...","d":"First line","by":"Name"},
"notes":["Weather: Open-Meteo","Market prices are delayed"]}
date, a day writtenyyyy-MM-dd, andmade, the unix second the file was made, are required. Everything else is optional, and a section that is absent or does not read is not shown.- A headline needs
tandu; itsuis the story's page on flamenet.io andsrcnames the publisher. A market needsnameandlast, andsymis optional. A city needscity. A day needsd, a real day writtenyyyy-MM-dd, andhiandlo.siteandpostneedtandu. An entry without what it needs is left out, and so is one whose link is nothttporhttpswith a host. - Bounds: 20 headlines, 12 markets, 150 cities, 5 days to a city and 5
notes.
tis drawn to at most 200 characters,dand the site'snoteto 300, each ofnotesto 200,src,by,nameandcityto 80, andsym,last,chgandpctto 24. A list is cut to its bound and a string is cut when it is drawn. Neither is a reason to refuse the file. ccis two ASCII letters, and is ignored otherwise.tzis an IANA zone name of at most 64 characters, and is ignored otherwise; a zone the phone does not know shows the days as given.latandlonare optional, and are ignored when out of range.skyisclear,partly,cloudy,fog,drizzle,rain,snoworstorm, and another value draws no sky. Temperatures are whole degrees Celsius, shown in °C or °F by the device's locale.- Prices are strings exactly as they are to be shown. The app does no arithmetic on them.
notelabelssite.d, saying whose words it is.postis absent until FlameNet's own blog has a post.- A link opens only when a person taps it, in the system browser, with its host shown. Nothing in the file is fetched ahead of a tap, and no preview is fetched.
Checking it. A client refuses a file for one of six reasons, checked in this order:
too-large: the envelope is over 512 KB.malformed: it is not a JSON object, the envelope has nov(of any type), itskid,bodyorsigis missing or not a string, or itsbodyis not base64.unknown-version: avis present and is not exactly the integer 1, in the envelope or in the body.unknown-key: no pinned key has thatkid.bad-signature:sigis not base64 of 64 bytes, or does not verify over the body bytes.bad-body: the body is not a JSON object, has nov, or has no real day writtenyyyy-MM-ddindateor no integermade.
The reasons are for logs and tests; a person sees one plain line
whatever the reason. A client never takes a verified file whose
made is older than the one it holds, so a relay cannot wind
the page back. A refused file changes nothing, and the last good one
stays on screen.
test/vectors/today-v1.json holds the sample FlameNet's
builder signed with the development key, and a signed case for each
refusal.
The route. GET /today.json serves the
last envelope the relay verified, its bytes unchanged:
200, withContent-Type: application/json,Cache-Control: public, max-age=900and anETagthat is the quoted lower-case hex SHA-256 of the bytes served. A request whoseIf-None-Matchholds that tag, or*, gets304.404and{"error":"not_offered"}when the relay offers no Today file.503and{"error":"not_ready"}when it offers one and nothing has verified yet. Both carryCache-Control: no-store.
The route reads no credential, no cookie and no query string, and the relay writes no log line for a request.
The relay reads the file from FN_TODAY_FILE, a path it
reads again whenever the file changes, or fetches it from
FN_TODAY_SOURCE, an http or https
URL, about every fifteen minutes (a minute of jitter either way, a
10-second limit, a 512 KB cap). It checks the envelope against the keys
in FN_TODAY_KEYS before serving it, and a file that cannot
be fetched or does not verify never replaces one that did. A self-hosted
relay can mirror FlameNet's file by pointing
FN_TODAY_SOURCE at it. The relay's configuration document
has the rest.
Fetching it. An app asks only its home relay, over
the transport it uses for everything else, a bridge included, with no
Authorization, no cookie, no device or account id and no
query string. It MUST NOT follow a redirect: the file comes from the
home relay or not at all. It asks at most once an hour while the app is
in use, when the tab opens or the app comes to the front, counted from
the relay's last HTTP answer, and after a request that got no answer it
waits at least five minutes. It keeps the last verified file on the
device, which is public data, and shows it offline with the date it is
from. If the relay answers 404, the app shows only the
directory, with the line "Today's page is not offered by this
relay."
A client sends If-None-Match only when the tag it stored
is exactly the hash of the copy it holds, which every reader of that
file computes alike. Without this rule the one header that varies could
identify a phone: a relay could hand each phone a tag of its own and see
it come back. With it, a tag that is not the file's hash is never
sent.
What the relay learns. That a request came from an address at a time, with nothing in it to say whose phone made it. A phone asks at most hourly, at times when it also collects its mail, which the relay sees anyway. The request carries the User-Agent the app sends with everything else, which names the platform, and from Android the app's version. What the person reads, which cities they choose for the weather and what they tap stay on the device, and the choice of cities is never sent anywhere. Signing out and a lost-device wipe (§6.26) clear the cached file and the chosen cities.
Status. Built in both apps and on the relay for 0.32, not released (§18.1). FlameNet's builder runs on flamenet.io and is not part of this repository.
8.13 Anonymous reads
Status: planned, stage D1 on the relay and D2 in the apps. Nothing here exists yet.
Every read in §8.2 and §8.3 is authenticated and names its target in
the URL (/e2e/keys/{user_id},
/e2e/devices/{user_id}), and so do the account lookups.
That puts "this account asked about that account, at this time, from
this address" in two places that outlive the request: the relay's
rate-limit keys, which are named by account, and the access log of
whatever proxy sits in front of the relay, which records every URL with
the client's IP, for as long as that log is kept. FlameNet's relay keeps
that log about a day on the machine and 90 days in an encrypted off-site
copy (its /metadata page has the current figures). And
GET /e2e/devices/{user_id} returns, to any member, when
each of the target's devices registered and when it last polled,
unmetered: a liveness record for any account, on request (§8.3).
So the reads a client makes about other people move to unauthenticated POST routes with the target in the body:
| Route | Body | Answers | Authorised by |
|---|---|---|---|
POST /e2e/anon/presence |
{"tokens": [≤ 8 tokens]} |
§8.9c's presence answer, keyed by token | holding the tokens; nothing else. A ninth token is refused |
POST /e2e/anon/devices |
{"user_id": 12, "cap": "<b64>"?} |
identity keys only: device_id, ik_dh,
ik_sig, proto, caps_sig; never
created_at or last_seen |
cap, a presence token the subject issued the caller,
which becomes the meter; without one, a per-IP meter |
POST /e2e/anon/bundles |
{"user_id": 12, "cap": "<b64>"?} |
§8.2's bundles, popping prekeys as that route does | as for devices, with a per-IP and per-(IP, account) meter when there
is no cap |
POST /e2e/intro |
{"handle": …, "challenge": …, "nonce": …} or
{"link": …} |
§14.5 | for a handle, a meter per IP and per network and a proof of work; for a link, the link (decisions DD1, DD2) |
A cap that matches nothing is ignored, not refused: the
read falls to the per-IP meter, so the route cannot be used to test
whether a token is live.
The proof of work on an introduction by handle is
the relay's signup challenge, reused (stage C1), with its own secret.
GET /e2e/intro/challenge answers
{"challenge": "<expiry>.<salt>.<mac>", "bits": n, "expires_in": 600};
the client finds a decimal nonce for which
SHA-256(challenge || "." || nonce) begins with at least
n zero bits, and sends both with the
handle. A challenge is the relay's own (it carries a MAC), expires,
works once, and is refused when bits are not met, with the
code challenge_failed. A relay sets bits with
FN_INTRO_POW_BITS (16 by default; 0 turns the work off). An
introduction by link needs none: the link is already a secret.
A client's own devices, with their registration and last-poll times,
are read from GET /e2e/devices/me, which is authenticated
and answers about the caller only.
The client side is part of the design, not a detail. Requests to
these routes go through a separate HTTP client with no
Authorization header, no cookies, its own connection pool,
so a connection that carried an authenticated request never carries an
anonymous one, and a generic User-Agent. Presence is asked
in random batches of at most eight tokens, each after a random delay of
up to 20 seconds (decision DD3), so one request is not one person's
whole contact list.
What the relay still learns. The IP address of every request, which is usually the same address the device polls its inbox from, seconds apart, authenticated: a relay that wants to join the two can. A run of presence batches from one address in one minute is that address's contact list in pieces. A directory or bundle read authorised by a presence token tells the relay that the asker holds one of that account's tokens, and the same token each time: a stable pseudonym for one of its contacts, though not a name (§8.9c). What changes is what is stored: no target in a URL, so none in the proxy's log; no rate-limit key naming the asking account; no liveness answer for somebody else's devices. Against an operator watching a running relay the gain is small, and it is described that way.
The old authenticated routes stay until no device older than stage D2 has been seen for 30 days; then the GET routes that carry a user id in the path are retired (stage D3, decision DD4).
8.14 Unlisted accounts and contact links
Status: planned, stages B1 to B4; it depends on §14.5's intro capabilities. Nothing here exists yet.
A username is how a stranger finds you. Today it is also the only way, and five routes turn a username into an account:
GET /api/flamenet/v1/messenger/users?slug=(authenticated, 20 lookups per caller per window);POST /api/flamenet/v1/messenger/buddies, adding by name;GET /e2e/account/identity/{handle}(unauthenticated, 300 per IP per hour);GET /api/flamenet/v1/messenger/users/{id}, which walks sequential ids to names;- signup, which says a name is taken, or too like one that is.
Listed and Unlisted (decision DB1). A
listed account answers all five as it does now. An
unlisted account answers doors 1 to 4, and
POST /e2e/intro by handle, exactly as for a name
that does not exist: the same status, the same bytes, the same
cost against the caller's rate limit, and no faster, with a
response-time floor as the sealed-send refusal has (§14.5). New accounts
are listed, and signup offers the choice (DB2).
Door 5 cannot be closed while names are unique: whoever tries to sign up as you learns that the name is in use. So the setting promises "people cannot find or add you by your username", and never "your username is secret". Clients MUST word it that way. Whether the account service's sign-in challenge answers an unknown name with a salt indistinguishable from a real one has not been checked; until it is, sign-in may be a sixth door.
What keeps working for an unlisted account: existing contacts,
presence, sealed groups, and federated contact cards. A member of a
group with you still needs a link to start a direct conversation (DB4).
From stage B4, after the fleet drain, an unlisted account accepts only
sealed messages, and a named send to it is refused with the code
sealed_only (DB5); the sender already knows the account
exists, so naming the reason costs nothing.
Contact links (DB3) are how anybody reaches an unlisted account, and a listed one without a username search:
- a one-time link, usually a QR code shown in person,
carries a delivery token (§21.4) the recipient minted
for it. The recipient uploads its hash with
POST /e2e/delivery-tokens, among its other tokens, so the relay holds nothing that marks it as a link. The person who scans it presents it asdelivery_tokenon their first message, which spends it; no introduction is involved, and they read the bundles fromPOST /e2e/anon/bundles(§8.13). Revoking an unused one is revoking that token. - a reusable link: 32 random bytes, good until
revoked, at most five at a time, registered with
POST /e2e/contact-links{"add": [digests], "revoke": [digests]}. The relay stores onlySHA-256of each, against the account, with no creation time and no record of who used one. A sixth is refused with409, and so is a digest another account already holds. A person holding one asks for an introduction with it (§14.5), which does not spend it.
A link travels in one of two forms (DB6):
https://<relay>/add#u=<user id>&t=<token>&h=<handle>
flamenet:<fnid>@<relay>?u=<user id>&t=<token>&h=<handle>
t is a one-time link's delivery token and l
replaces it for a reusable link; exactly one of the two is present. Byte
strings are base64url without padding, and h is
percent-encoded. h is a hint. For a reusable link the name
the client shows afterwards is the one in the handle certificate the
intro returns (§14.5), and a link whose u differs from the
account the intro answers for is refused. A one-time link involves no
intro, so nothing vouches for h until the recipient's first
reply, whose prelude carries their own certificate; until then a client
MUST show the name as unverified. That is usually enough for a code
scanned from the other person's own screen.
The web form keeps the payload in the fragment,
after #, which a browser never sends: the relay's
/add page, and anything logging requests in front of it,
receives GET /add and nothing else, and the page's script
hands the fragment to the app. The flamenet: form is for QR
codes, and extends the federated contact card
(flamenet:<fnid>@<relay>) so an older app that
reads only the address still gets one; an account with no fnid writes
nothing before the @, which an older app refuses rather
than misreads. test/vectors/links-v1.json pins both forms,
what a reader must accept, and what it must refuse.
Error shape
Standard WP REST error:
{ "code": "...", "message": "...", "data": { "status": 4xx } },
with retry_after in seconds on a 429. Two
answers have another shape: a path the relay has no route for answers
{"error": true, "reason": "Not Found"}, and
GET /today.json (§8.12) refuses with
{"error": "not_offered"} or
{"error": "not_ready"}.
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. Every padded payload is one of two lengths (§23.3), so a row's size says which step it landed in and nothing finer. Padding roughly doubles what the table and its write-ahead log hold.{p}fnmsg_e2e_attachments, sealed blob index:token, uploader, size, file path,created_at. Once attachment padding is on, the size is a ladder value (§23.9). The blob bytes live on disk underuploads/fnmsg-e2e/, direct web access denied; the REST route is the only reader.devices, one row per device (identity + current signed prekey).prekeysandpq_prekeys, the one-time prekey pools, each with ausedflag.envelopes, the sealed message queue, with adeliveredflag and anannouncedone (below).attachments, the sealed blob index:token,bytesandcreated_at, and no uploader. The blob bytes live in files in the relay's attachments directory (FN_ATTACHMENTS_DIR), with no direct web access; the REST route is the only reader.push_tokens, one row per iPhone that asked to be woken (§8.11).
These are the tables the message path uses.
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.
One bit on each waiting envelope: was it announced.
Each waiting envelope also records whether its submission was announced,
which is the reverse of §8.4's silent. This is a relay
change made for 0.32 and not yet deployed. It is kept for the iPhone's
icon: the badge on an alert push (§8.11) counts that device's waiting
envelopes that were announced, so the delivery keys, receipts and
answers waiting beside a message do not show as more messages. It
reveals what silent already told the relay when the
envelope was submitted, roughly whether it is housekeeping, but now that
answer stays in the database for as long as the envelope waits, where a
copy of the database shows it too. It is deleted with the envelope. An
envelope another relay deposits is stored as not announced, because
nothing announced it.
The count includes game moves (§6.33), which are announced, so a run of moves raises the number on the icon until the app next runs. The app then sets the badge to its own unread count, in which moves do not count.
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. - 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
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 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.
The rule for this section: establish what has been run.
PQKEM.kt being present is not evidence; the cross-engine
run is. A spec that understates what exists sends an auditor away from a
third of the client code, and one that overstates it sends them to code
that does not do what it says.
Do not infer interop from structural similarity. An earlier version of An engine can be structurally correct and still fail to talk to the server, when the two disagree about a wire format no component test exercises. 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. (§23 reduces sizes to one of two steps.) (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: IMPLEMENTED on the web engine, iOS and
Android, and carried by the relay. The web engine
(assets/js/e2e.js) remains the reference implementation,
per the convention in §10.
This line said "DRAFT / proposed, not yet implemented on iOS or
Android" long after both had shipped it, which is worse than saying
nothing: a spec that understates what is built teaches the reader to
distrust the parts that are accurate. On iOS the deployment target is
pinned to 26.0 for exactly this, because CryptoKit's
MLKEM768 needs it; on Android the PQ signed prekey is
published and consumed alongside the X25519 one.
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 | n/a | see below |
PQOPK_i |
ML-KEM-768 | one-time | pool of public eks, each signed |
PQOPK_sig_i |
Ed25519 sig | n/a | 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. - 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. 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 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: 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 289 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 272 tests against the real
route methods (test/relay.mjs), driven against the running
relay rather than a stub.
Cross-engine interop has 98 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. Closed by §20 on all three
clients, Android as of 0.29: an ML-DSA-65 identity is published
alongside the Ed25519 one and pinned. What stays open is the tail. A
device that has not updated, or one restored from a backup older than
its platform's release, publishes no
ik_pqand is authenticated classically, and safety numbers still cover the Ed25519 identity only. - 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: IMPLEMENTED and in use. 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.
Both shipping clients announce version 3 and negotiate it with each
other: an iPhone and an Android phone establishing a session in
September 2026 recorded
envelope v3 pqdh=true pqct=true sessionVersion=3. iOS holds
the version in E2EProtocol.version, and it is 3.
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.
Those 3 KB used to be visible to the relay as a jump in size on the first message of every chain. With §23 they sit inside the first step, and a message carrying a post-quantum step is the same length as one that does not.
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: IMPLEMENTED on the web engine, the server, the web client, iOS and Android. §15 records what was actually run for each.
This line said "DRAFT / proposed" long after every cell in §15 had been filled in. That is the same failure §12 already records once: a spec that understates what is built teaches the reader to distrust the parts that are accurate.
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, a size bucket, and the sender's IP address. The bucket is one of two lengths (§23.3); before §23 it was the exact size. 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": {…},
"pad": "<[A-Za-z0-9]*>" } // pad: §23.5
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>" }
A sender that pads (§23) writes this object in exactly one form,
{"s":1,"eph":"…","ct":"…"}: keys in that order, no spaces,
/ not escaped. The padding counts its characters, and a
writer that escapes slashes adds one for each (§23.5). Receivers parse
it as JSON and accept any form. The inner pad is ignored
(§23.8).
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. Other fields ride the same envelope, for the compatibility reason §21.4 gives:
tokens(§21.4),ptok(§8.9c) and, on a first message,hc, the sender's handle certificate (§14.5).Rotation is pushed. From stage C3, a client that rotates its key sends the new one at once, as a prelude with no message after it, to every contact that held the old one and is not blocked (decision DC6), rather than whenever it next writes to each. A contact who is never written to again would otherwise hold a dead key forever and fall back to an intro, or under stage C3 not be able to write at all.
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 an authorisation instead of relying on the
session. There are three, tried by the sender in this order:
{ "to": 12, "delivery_token": "<b64 32B>", "messages": [ … ] } // §21.4, single use
{ "to": 12, "delivery_key": "<b64 32B>", "messages": [ … ] } // §14.4
{ "to": 12, "intro": "<b64 32B>", "messages": [ … ] } // first contact, below
each messages entry being
{ "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 authorisation 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 what.
GET /e2e/messages returns from_user: 0,
from_device: "" for sealed rows. The client learns the real
values from the inner JSON after decryption.
First contact, sealed. Before this, the first message to a new contact went named, because the sender held none of the three. From stage C1 a sender gets an intro capability for the first message:
POST /e2e/introwith{"link": "<base64url>"}, the capability carried by a reusable contact link (§8.14), which the intro does not spend; orPOST /e2e/introwith{"handle": "ada", "challenge": …, "nonce": …}, for listed accounts only, after a proof of work (§8.13).
A one-time link needs no intro: it carries a delivery token, and the first message presents that (§8.14).
The answer is the account, its handle certificate, its bundles (§8.2, one prekey popped per device, metered as that route is), and one capability with its expiry:
{ "user_id": 12, "fnid": "fnid:…", "handle_cert": { … }, "devices": [ … ], "intro": "<b64 32B>", "exp": 1787260600 }
The relay stores only SHA-256(intro) with the account
and the expiry, ten minutes ahead. An intro is good for
one sealed submission to that account before
exp, and the check and the delete are one statement, as for
a delivery token. A link or handle the relay does not know, an unlisted
handle, a link that was revoked, and an account with no devices all get
the same answer, the answer for an account that does not exist, held to
the same response-time floor. Requests to this route are metered per IP
and per network before anything is looked up, then per target and per
(network, target), and an account may have only so many unspent intros
waiting. By handle they also cost a proof of work (§8.13).
The first message rides the intro, and its prelude (§14.4) hands the recipient the sender's own key, a slice of tokens and the sender's own handle certificate, so the reply goes sealed too and nothing after the first message needs an intro.
Handle certificates replace asking the relay "who is
user 12?", a question the relay answers to anyone and can log
(GET /messenger/users/{id}, retired at stage C5). A
certificate is the relay's signature over:
"fnhandle-v1|" || audience || "|" || decimal(user_id) || "|" || handle || "|" || decimal(exp)
UTF-8, exp in unix seconds, Ed25519 under the key the
relay signs its log heads with (§19.3), which clients already pin. On
the wire:
{ "v": 1, "aud": "relay.example.org", "uid": 12, "h": "ada", "exp": 1790000000, "sig": "<b64 64B>" }
A client MUST verify one against the pinned key before showing its
name, and MUST refuse a certificate for another relay, for an account
other than the one it is about (for an intro, the account the intro
answered for; in a prelude, the sender the ratchet proved), one at or
past exp, and one with | in any field. No
pinned key means nothing can be verified, and nothing is introduced. A
client gets its own from GET /e2e/handle-cert, valid for a
relay-chosen period of at most 30 days, and carries it as
hc in the delivery_key prelude of a first
message. test/vectors/handle-cert-v1.json pins the bytes,
with a throwaway key.
Requests. A sealed message from somebody the recipient has not accepted is filed in a Requests folder, with the certified handle when the prelude carried one, rather than added to the conversation list (decision DC2). The person accepts, which adds them, or blocks (§21.4). Writing to somebody accepts them. Nothing in Requests notifies more loudly than a quiet badge, because anyone who can get an intro can put something there.
Nothing goes out named. From stage C3 a client MUST
NOT fall back to an authenticated submission. When a sealed submission
is refused, it tries the next token, then the delivery key, then
one fresh intro, if the conversation started from a
reusable link or a handle; if all of those fail it does not send, and
says so: "Could not send.
Own-device sends are sealed too, under the account delivery key (§17.6).
The relay stops taking named sends in stages, set by
FN_NAMED_SENDS: accept (today), then
log, which accepts and counts named submissions by app
version without naming an account, then refuse, which
answers them with 400 and the code
named_send_refused. refuse is set relay-wide
(decision DC5) only after no device older than stage C3 has been seen
for 30 days. Stage C5, separately and irreversibly, removes the named
branch and the from_user_id and from_device
columns, and retires GET /messenger/users/{id}.
What this costs: the relay can no longer meter sends per sending account, nor refuse a blocked person's message on the server, because it no longer knows who sent anything. What is left is per-recipient and per-IP metering, the meters on intros, single-use ten-minute intros, invitation codes, the proof of work, Requests, and unlisted accounts (§8.14). Metering by recipient opens a door of its own: anybody holding one valid key could spend a recipient's whole bucket and lock everybody else out. So the per-recipient bucket counts only refused attempts, and accepted ones count against a separate limit per recipient and IP. Blocking becomes a local drop, a revoke and a rotation (§21.4). A blocked person who knows a listed handle can still reach Requests, where the recipient's device drops them; the remedy is unlisting (decision DC3).
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.
- Attachments are not sealed, the blob is.
POST /e2e/attachmentsandGET /e2e/attachments/{id}are ordinary authenticated routes. The row names no uploader, deliberately, and the download writes no row, but the relay is told who is uploading and who is downloading at the moment each request arrives. For any sealed conversation carrying a photo or a voice note, an operator watching requests has both ends without touching the envelope. Fixing it means a capability-only upload and download, which is a protocol change rather than a configuration one, so it is stated here rather than implied. - Spam. Rotation is the lever, and from stage C3 a client pushes the new key to every contact it has not blocked at once (§14.4), so the redistribution costs the person nothing. Strangers land in Requests (§14.5). Neither stops somebody who can get an intro.
- The intro is a moment the relay sees.
POST /e2e/intronames its target in the body, never the URL, and stores no row naming the asker; but the relay answering it sees the asker's IP, the target, and the time, and a sealed message to that target seconds later from the same IP. Sealing the first message removes the stored record of who started a conversation, not the observation of it by an operator watching live. - Size, down to a step. §23 replaces the exact length of a sealed payload with one of two steps, and of an attachment with one of nine once the relay switches that on. How many envelopes there are, when they move, and which step each lands in remain visible, and so does the unpadded length of an unsealed envelope (§23.1).
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 | yes | n/a, no server change needed | yes | verified cross-engine | verified cross-engine |
| §14 sealed sender | yes | sealed mode + delivery keys | wired end to end | wired end to end | wired end to end |
Both Android cells were once marked as not done, and both were wrong. Understating is the opposite of the failure §10 warns about, and it is corrected the same way: by establishing what has actually been run, rather than by changing a marking because the code looks present.
§13 on Android has been verified 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 caught a defect in the shipped code. 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 been run:
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 verified, 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:
35 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, covered by
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.
First contact. Until §14.5's intro capabilities, the first message to a new contact was necessarily named: you cannot seal to someone whose delivery key you do not yet hold, and the key arrived in that first exchange, so sealed sender protected an ongoing conversation and not its first moment. That is still what the relay and both apps do today. From stage C3 the first message goes sealed under an intro (§14.5), and nothing goes named at all.
The web client implements §14.5's client half now:
introduce() by reusable contact link or by handle (with the
proof of work), a one-time link's delivery token used directly with
bundles from the anonymous route, verification of the handle certificate
against the pinned relay key, the sealed first message under the intro,
the sender's certificate in the prelude, Requests, contact links made
and revoked, and requireSealed, which refuses rather than
sending named and retries a token, the key and one fresh intro first.
test/client.mjs covers it against the in-process fake,
which plays POST /e2e/intro, the challenge,
GET /e2e/handle-cert, POST /e2e/contact-links,
the /e2e/anon/ routes and the intro field,
because no deployed relay has them yet (stage C1); so these checks say
the client follows the spec, and say nothing about the relay until
test/relay.mjs covers the same routes.
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. 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 | the device id |
| the account delivery key (§14.4, §17) |
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.
The account delivery key is carried, and was not always. It was excluded while each device minted its own: a restored copy would have been a second device presenting another device's key. Since §17 the key belongs to the account and every linked device holds the same one, so it is account state like the roster, and leaving it out has a cost: every contact holding it is refused when sealing to the restored device and falls back until they hear a new one. A restore adopts it only onto a device that holds no key of its own; a device that has one is already the account's, and keeps it. A backup holding the key lets whoever opens it send this account sealed messages, which is a reason to keep the file as safe as the history it holds, not a new one.
Peers will see a new device. Restoring is not invisible: contacts are told a device was added and lose their verified mark (§17.3), or, where the old device's key is gone and the new one appears in the same look, are asked to accept a changed key, exactly as 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.2.1 The payload keys
The decrypted payload is one JSON object. Its keys were never written down, so each engine read them off the other's source and they drifted: one wrote the story audience mode as an enum name in upper case and the other parsed only lower case, which meant an audience crossing between the two was dropped in silence and restored as everyone. A restore that widens an audience shows stories to exactly the people somebody had left out. The table is normative for that reason, spelling and case included.
| Key | Shape |
|---|---|
v |
payload version, integer |
threads |
peer id as text -> array of messages |
groups |
gid -> array of messages |
addrThreads |
address -> array of messages, for federated threads |
stories |
author id as text -> array of stories |
storyArchive |
array of stories, the author's own kept past expiry |
storyAudience |
{"mode": "everyone" | "only" | "except", "ids": [int]} |
mutedStoryAuthors |
array of ints, ascending |
storyReactions |
story key -> reactor id as text -> emoji |
roster |
array of {"id": int, "handle": str, "name": str} |
rosterOffServer |
bool |
calls |
array of
{"peer": int, "at": ms, "in": bool, "connected": bool} |
groupNames |
gid -> name |
groupSecrets |
gid ->
{"tag", "key", "version", "ownerPrivate"?} |
pins |
thread key -> {"mid": str, "at": ms, "by": int} |
drafts |
thread key -> text |
polls |
poll id ->
{"gid", "q", "opts", "members", "choice"?, "didPublish", "held", "sums"} |
presenceTokensIssued |
contact id as text -> base64 32 bytes |
presenceTokensHeld |
contact id as text -> base64 32 bytes |
acc |
array of ints, ascending: contacts whose first message was accepted (§8.9d) |
blocked |
array of ints, ascending: contacts this account has blocked (§8.9d) |
convFlags |
thread key -> {"starred": true, "archived": true},
each flag written only when true (§8.9d) |
dk |
base64 32 bytes: the account delivery key (§16.1) |
deliveryTokensIssued |
contact id as text -> array of base64 32-byte tokens: the batches this account issued (§21.4), account state since §17 |
read |
thread key -> ms: the read-through mark (§17.6) |
Every id is a string when it is an object key,
because JSON object keys cannot be numbers, and an
integer when it is a value. mode is lower
case. Every timestamp in the payload is milliseconds,
including expires on a story and on an attachment, even
where an engine keeps seconds internally: the two engines both convert
at this boundary and a reader that skips the conversion gets an expiry
off by a factor of a thousand, which reads as a story that either never
expires or expired before it was written. A reader MUST NOT treat an
unparseable storyAudience as absent: absent restores as
everyone, so a value it cannot read MUST fall to the narrowest reading
it can justify, never the widest.
A story is identified by key, and
id MUST NOT be written. Each story object carries
key, author, mine,
caption, at, and optionally
expires, att, gid,
stream and set. key is the value
the author's own devices use for the same story: the attachment token
for a picture, the message id for a text story. It is what §6.22's
reactions land on and what an import de-duplicates by, so it is
required, and an engine with nothing better MUST fall back to the post
time rather than send a story that cannot be recognised.
id is where this went wrong and is worth stating so it
is not reinvented. One engine numbers a story locally and kept the
shared identity in key; the other put that identity
straight in id. One name, two meanings: a story travelling
between the two came back under a different id and imported as a second
copy of itself. A reader MAY accept id as the key for a
file written before this was settled, and only when it is a string,
because a numeric id is some other device's local handle
and identifies nothing. Never put a local handle in a shared
shape.
An attachment's kind follows where it hangs, not what
its bytes are, so a story's attachment is always story.
addrThreads is keyed by the address
itself, fnid@relay, with no namespace prefix on
it. One engine keeps a single thread map and prefixes the keys to tell
three kinds apart; that prefix is its own business and MUST be stripped
here, exactly as the group one already was. It was not, so a backup
carried addr:ada@relay.example, which is not an address,
while the other engine wrote the bare one and neither could read the
other's federated conversations. This is the same rule as
stories[].id above, and it is stated twice because it was
got wrong twice: an internal storage detail does not belong in a
shared shape.
reStory on a message says the thing it quotes is a
story, not a message. A story key is not a message id,
so a reply without it quotes something the reader cannot resolve and the
bubble has nothing to draw. Written only when true.
seen on a story says the strip has been watched. It is
written only when true, so a story nobody has opened carries nothing
rather than a false. One engine keeps it per story and the other as a
set of story keys; both shapes are the same fact and the per-story form
is what travels.
Cards, events and games (§6.31 to §6.33) ride on the message they
arrived on. card is the card object as it travelled.
ev is the wire object of the latest accepted revision, with
org, the organizer's user id as an integer, and
rsvp,
{"<user id>": {"r": "yes", "n": 3}}, beside it on the
message. game is
{"id", "t", "me", "log", "r", "rby"} on the message that
started the game: me is the side this person plays,
r an ending a gend brought, and
rby the side that sent it, written only with
r. Each of these messages carries "text": "",
so a sealed card's words are never written outside the card. The
canonical set in
test/vectors/backup-payload-v1.json carries a card, two
events, a game and a group card.
An engine MUST carry a field it does not itself use. This app's pins
show only which message is pinned and not at or
by, and dropping those on the way through made a backup
lossy per hop: restoring an iPhone through an Android phone returned the
pins stripped. Keep what you cannot render.
test/vectors/backup-payload-v1.json is the normative
fixture, generated by
test/vectors/generate-backup-payload.mjs. Its
canonical object is the subset both engines carry
without loss, and the bar is exact: decode it, encode
it again, and the result must equal what is pinned. Its
divergences list names every field one side keeps and the
other drops, with the consequence, because a field missing from a vector
looks identical to a field nobody thought of, and that is how these two
formats drifted apart the first time.
A field belongs in canonical the moment both engines
read AND write it, and moving one there is the point at which the drift
above becomes a failing test rather than something somebody notices
while reading the other engine's source. storyArchive and
the story objects themselves are still out, because
stories[].id is a number on one engine and a name on the
other; see the divergence entry.
A key MAY be absent, and absence is ordinary rather than an error:
engines gained these at different times and not every engine has every
feature. addrThreads and pins are written by
one engine today and read by it alone. An engine MAY also write keys not
listed here, and a reader MUST ignore what it does not know rather than
refusing the file.
acc, blocked, convFlags,
dk, deliveryTokensIssued and read
are specified ahead of either engine carrying them. Their shape is
pinned in the fixture's pending object, not
canonical, and each moves into canonical when
both engines read and write it; until then both apps' round-trip tests
are untouched by them. A restore merges acc,
blocked and convFlags as a union with what the
device already has: a restore MUST NOT unblock anybody, which is the
same fall-narrow rule as the story audience. read merges as
a maximum per thread, deliveryTokensIssued per contact with
the device's own entry winning, and dk as §16.1 says.
What a reader MUST NOT do is adopt a key it half understands. An entry it cannot parse is dropped, and where dropping changes a privacy setting, §16.2.1's audience rule applies: fall narrow.
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.
- A backup MUST carry everything the sealed store keeps that is not ratchet state or device identity, not only the message history. Story reactions, per-contact presence tokens (§8.9c), the contact list and its off-server flag, group names and secrets, drafts, pins, polls in flight, muted story authors and the story archive are all state somebody set on purpose, and an export that leaves them behind restores an account that has quietly forgotten the person's choices without ever saying so. Both engines have shipped this bug, in both directions: a field added to the sealed store and not to the export is invisible until somebody changes phone.
- The outbox is the one deliberate exception. Queued sends flush within seconds of a poll cycle, so restoring them months later would deliver messages the person wrote long ago to people no longer expecting them. Losing them is the safer failure, and the one somebody can see.
- Story reactions merge per reactor, not per story: two devices can hold different halves of one story's tally, and taking a story's map wholesale drops the other half.
17. Multi-device
Status: DRAFT / planned. Neither app implements it
yet. The web client implements linking, sync ops, sent-copies, history
and the lost-device rotation of the delivery key and token batches,
covered by test/client.mjs; it has no presence tokens,
sealed groups, contact links or scheduled export, so the parts of this
section about those are not in it, and neither are attestations. Each
rule that depends on the relay or the apps says the stage it arrives in,
listed in §17.7. §18 records what has been run.
17.1 What is already solved, and what is not
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.
What is missing is everything else a person expects to follow them from one device to the next: their own outbound messages, who they have blocked or accepted, which conversations are starred or archived, how far they have read, and, at the moment a device joins, the history and the account-level secrets the other devices already hold. This section is a private channel between one person's linked devices that carries those, and nothing else ever travels on it.
Two things are not solved by it, and are stated here so nobody reads them in:
- No server-side history. A device that was never linked cannot recover messages that predate it, except from a backup (§16). The alternative is the relay holding conversation history.
- Prekey pools are per device, so each linked device registers and replenishes its own, and each keeps its own identity key, ratchet sessions and device id. Linking shares state; it never clones a device (§16.1 says why that would be catastrophic).
17.2 Sent-copies
When a device sends anything to a person or a group, it also sends a copy to each of its account's other linked devices:
{ "fnc": 1, "kind": "sent", "ctl": 1, "to": 12, "body": "…", "at": 1787260000000, "mid": "m-1" }
tonames the person, orgidthe group instead; never both.bodyis the exact plaintext the recipient received: plain text, or a whole content envelope as a string (§6.6). So a copy exists for every kind a person can send, a photo, a reaction, an edit, a poll vote, a story, and a linked device renders it exactly as the sender's device did. An attachment travels by reference insidebody; the blob is never sent twice.atis milliseconds.midrepeats the message id when there is one, so a device can de-duplicate without parsingbody.ctlis set althoughsentis one of the four original control kinds (§6.10), so a receiver that does not implement this section consumes it rather than drawing a bubble. Receivers MUST accept asentwithout it.
Receivers MUST render a sent copy as an outgoing message
in the thread with to or gid, and MUST NOT
notify on it.
Not copied: control preludes (delivery_key,
kt_head), traffic of this section itself, and anything sent
off the record (§6.28). An off-the-record conversation
exists on the two devices that held it and nowhere else, and a copy to a
third device of the sender's would break the one promise that mode
makes. Its on/off state is not synced either.
17.3 Linking a new device
A new device of your own is cryptographically indistinguishable from one an attacker planted under your account. It is the same problem as §11 and worse, because a linked device receives copies of everything. So a device is never linked by the relay, never by signing in, and never automatically. The relay brokers nothing here: linking is a decision two devices make with a person standing between them.
Joining. A new device first signs in with the account password and registers (§8.1), like any device. That makes it a device of the account, as the relay sees it, and nothing more: it is not linked. Then a person links it from a device that already is:
The new device shows a QR code:
flamenet:link?d=<device id>&k=<IK_sig, base64url>&s=<secret, base64url>sis 16 random bytes, valid for ten minutes, replaced each time the code is shown. Byte strings are base64url without padding.test/vectors/links-v1.jsonpins the form.The existing device scans it, and before anything else checks three things against the relay: that
dis a device of this account in the relay's own list (§8.3), that the relay lists it under exactly the keyk, and that the key log holds norevokeorexpireentry for it (§19). A key that differs is a relay substituting a device, and linking stops. Then it asks the person to confirm, and only then continues.It sends the new device, through an ordinary ratchet session, a
link_offer(§17.6) carryings, the linked set and the account delivery key, then async_statesnapshot, then, if the person wants it, history (§17.4).The new device adopts the offer only if
smatches the code it is showing and has not expired. A wrong or stale secret is dropped without a word. It adopts each member of the linked set only if the relay lists that device on this account under the key the offer names.
The secret is what makes a link mean "a person pointed this device at that screen": it leaves the new device only as light, and comes back only through a session with the device that read it.
Requiring the password first (decision DA1) is deliberate. A QR code alone would let anyone holding any unlocked linked device add a device to the account without ever knowing the password.
The numeric fallback, for a device without a camera:
the person picks the new device in the list on the existing one, and
both screens show the safety number of the two devices' identity keys
(§7, which works unchanged for two devices of one account). The person
compares them and confirms on the existing device, which sends a
link_offer without s. The new device holds
that offer and adopts nothing until the person confirms the same number
on the new device too. A confirmation on one side only
links nothing.
What crosses, and what never does. At link time the new device receives:
- the linked set, as
link/<device id>registers (§17.6); - the account delivery key (§14.4). Since §17 it belongs to the account, not to a device: every linked device holds the same key, so a contact holding it reaches all of them sealed;
- a
sync_statesnapshot of every register: contacts and their names, who is accepted and who is blocked, starred and archived conversations, read marks, group names and secrets, message pins, the story audience and muted authors, the delivery keys and presence tokens contacts gave this account, the presence tokens and delivery-token batches this account issued (§17.6 lists the keys); - optionally, history (§17.4).
It never receives: identity keys, prekeys, ratchet sessions, the device id, envelopes held for another device, anything off the record (§6.28), or drafts. Drafts stay on the device they were typed on (decision DA6). Identity pins for contacts and their verified marks are not synced either: each device compares numbers for itself, because a pin taken from another device would let one compromised device re-trust a key for all of them.
Tokens a contact issued this account are not shared. A contact mints one batch of delivery tokens for this account (§21.4) and, from stage A3, gives each of this account's devices a disjoint slice of it in that device's own prelude. A device spends only its own slice, so two devices never race to spend the same token, and a refusal empties only the slice of the device that was refused. Before stage A3 every device holds and spends the same batch, and one refusal wipes it for all of them.
The linked set follows the key log. A device stops
being linked when the person unlinks it, and also when the key log gains
a revoke or expire entry for it (§19.2), which
is the relay saying it is gone. A client MUST say which it was: "you
removed this device" for revoke, and for
expire that the server retired it, for
inactivity or to make room for a new sign-in. A linked device the relay
stops listing with no such entry is reported as exactly that. Revocation
only ever appends to the log; nothing here removes a leaf (§19.1).
The device cap. A relay limits how many devices one
account may have registered (FN_MAX_DEVICES: 3 by default
in the code, 6 on FlameNet's own relay). It does not refuse a sign-in at
the cap. It retires the account's least recently active device to make
room, with an expire entry in the key log and no
confirmation from any linked device: signing in on one device too many
silently signs out another, which may be a linked one. That is the
relay's choice, made so that an account whose devices are all dead
phones can still get back in, and the linked devices learn of it from
the log as above. Six devices in a 32-member group is 192 envelopes per
message.
Contacts see a new device as a notice, not a refusal. A contact's client compares the keys the directory offers against the keys it pinned:
| Offered against pinned | Meaning | What the client does |
|---|---|---|
| a new key, and none missing | a device was added | pin it, show a notice once, clear the verified mark |
| a key missing, and none new | a device was removed | pin the smaller set, keep the verified mark |
| a new key AND a missing one | a key was substituted | refuse to send until the person accepts (§11) |
Only the third is the attack this check exists for. A key that goes
away can only shrink the set of devices that can read a message; a key
that arrives beside the others is usually somebody's new laptop, and
refusing every message to them until each contact taps "accept" punishes
the ordinary case. The verified mark still goes, because the person
compared numbers for the devices they saw, not this one. This is what
iOS does (deviceAdded). Android treats any new key as a
change and refuses; it is aligned at stage A1 (decision DA2).
From stage A5 an added device can also carry a link
attestation, so a contact who had verified this account keeps
the mark. The linking device signs, with its IK_sig:
"fnlink-v1|" || audience || "|" || user_id || "|" || new device_id || "|" || base64(new IK_sig) || "|" || decimal(at ms)
The relay stores the signature beside the device row and serves it with the device's keys. A contact that had verified the signing key and finds the signature good keeps the verified mark for the new device. It is not a key-log leaf kind (decision DA3): the log records keys, not who vouched for them, and a new leaf kind would make every existing verifier reject the log.
Own-device traffic goes to linked devices only. Everything a device sends to its own account's other devices, the copies and ops of this section, the story copies of §6.22.1 and call hand-off (§6.30), MUST go to linked devices and to no other. A device signed in with the password and never linked receives none of it. The one exception is the stop-ringing signal (§6.8): every device of the account rang, because the caller fanned out to all of them, so every device is told to stop, and the signal says only that the call was answered or declined elsewhere. Both apps currently send story copies and call hand-off signals to every device on the account; that is fixed at stage A1, and until then it is a known gap.
17.4 History backfill
At link time, and only then, the existing device MAY send history to the new one:
{ "fnc": 1, "kind": "history", "ctl": 1, "seq": 1, "of": 12, "items": [ … ] }
Each item is
{"t": <thread key>, "mid", "at", "body"} plus
"out": true for this account's own messages or
"from": <user id> for anybody else's.
body is the plaintext as §17.2 defines it. Thread keys are
§16.2.1's.
- Newest first, so the useful part arrives first.
- At most 16 KiB of items per chunk, which always fits the larger message step (§23.3).
- At most 10,000 messages in all (decision DA7). Attachments go by reference, never the bytes; a reference whose blob has expired at the relay is shown as expired.
- Best effort. It costs one envelope per chunk and is not worth stranding a link over. A receiver MUST tolerate a partial backfill and MUST NOT ask for a retry forever.
17.5 Removing a device, and a lost one
Unlinking takes a device out of the set; revoking (§8.3a) takes it off the account. Neither retracts what the device already received.
A device that was lost or stolen knew everything this section shares, so retiring it for that reason rotates what it knew (decision DA8):
the account delivery key, pushed to every contact that is not blocked (§14.4);
every delivery-token batch this account issued (§21.4), revoked and re-issued;
the presence tokens this account issued (§8.9c), replaced;
sealed groups this account owns (§8.9b), re-keyed;
contact links (§8.14), revoked;
and to every contact, a
rekey_requestnaming the device:{ "fnc": 1, "kind": "rekey_request", "ctl": 1, "dev": "<device id>", "at": 1787260000000 }A contact receiving it drops its sessions with that device, revokes the batch it issued this account (the lost device held a slice of it), and sends a fresh key and batch with its next message.
A wipe of the lost device (§6.26) MAY go with it, signed with the passphrase as that section requires. Retiring a device that is merely old ("retired" rather than "lost") rotates nothing.
The one-device case. A person whose only device is lost has nothing to link from. The remedy is a backup (§16): clients SHOULD offer a weekly passphrase-sealed export to a folder the person chooses, never to a FlameNet server, and SHOULD show a banner on a single-device account with no export in the last 30 days (decision DA9).
17.6 Sync ops
Every envelope in this section is a content envelope (§6.6) with
ctl: 1, sent through ordinary ratchet sessions to this
account's own linked devices and to no other, and submitted
sealed to ourselves under the account delivery key
(§14.3), so the relay learns no more than that some device wrote to this
account. A device MUST ignore all of them from an own-device it has not
linked, except a link_offer, which it treats as §17.3
says.
| Kind | Carries |
|---|---|
link_offer |
v: 1; s, the QR secret, absent in the
numeric fallback; set, the linked set as
[{"d": <device id>, "k": <base64 IK_sig>}],
sender included; dk, the account delivery key;
at |
sync |
one op: op, key, val (absent
for a removal), at, dev |
sync_state |
seq, of, regs: key ->
{op, val?, at, dev}; want: 1 asks for the
receiver's own in return |
sent |
§17.2 |
history |
§17.4 |
rekey_request |
§17.5; sent to contacts, not to own devices |
Registers. Each device keeps state as registers, one
per key, each holding the last op it accepted for that key:
{op, val, at, dev}, where at is the writing
device's clock in milliseconds and dev its device id. The
key's family, the text before its first /, decides which
ops it takes and how they merge:
| Family | Ops | Value | Merge |
|---|---|---|---|
link/<device id> |
add, rm |
base64 IK_sig |
as a register |
roster/<user id> |
add, rm |
{"h": handle, "n": name} |
as a register |
blocked/<user id> |
add, rm |
none | as a register, but a block wins a tie |
acc/<user id> |
add, rm |
none | as a register |
muted/<user id> |
add, rm |
none | as a register |
read/<thread key> |
max |
epoch ms of the newest message read | the greater value |
conv/<thread key> |
set, del |
{"starred": true, "archived": true}, flags only when
true |
as a register |
gname/<gid>,
gsecret/<gid> |
set, del |
as §16.2.1's groupNames, groupSecrets |
as a register |
pin/<thread key> |
set, del |
as §16.2.1's pins |
as a register |
audience |
set |
as §16.2.1's storyAudience |
as a register |
pdk/<user id> |
set, del |
a contact's delivery key | as a register |
tok/<user id> |
set, del |
the batch this account issued that contact | as a register |
ptok/i/<user id>,
ptok/h/<user id> |
set, del |
presence tokens issued and held | as a register |
dk |
set |
the account delivery key, after a rotation | as a register |
| anything else | set, del |
anything | as a register |
The merge rule. An incoming op replaces the register when:
- its
atis later; or - its
atis equal, the key isblocked/…, and it is the block (add) where the register holds the unblock: two devices disagreeing in the same millisecond leave the person blocked; or - its
atis equal and itsdevis greater, compared as text.
Equal at and equal dev is the same op seen
twice, because a device MUST NOT write one key twice in a millisecond
(it moves at on by one instead), and the register keeps
what it has. read/… is the exception to all of this: the
greater value wins whatever its at, and only on equal
values does the rule above decide which register is kept, so a read mark
never moves backwards. An op whose kind the key does not take, or whose
at is not a non-negative integer, or whose dev
is empty, is ignored, never half applied. A device keeps, and passes on
in snapshots, registers for keys it does not understand.
That makes the merge commutative and idempotent: devices that have
seen the same ops, in any order and any number of times, hold the same
registers. test/vectors/sync-merge-v1.json pins it, with a
case for every tie, checked in every order.
Clocks. An op more than 24 hours ahead of the receiving device's clock is dropped: a clock a year fast would otherwise win every register it touched for a year. Age alone removes nothing; a contact added a year ago is still a contact.
Removals are remembered for 90 days. An
rm or del register older than that is
forgotten. Without the window, a removal would be kept forever; with it,
a device that was away longer than the window may hold an
add for something removed and since forgotten, and offering
it would bring the removed thing back. So a device that has not polled
for 90 days or more discards its own registers (keeping
its link/… ones) and rebuilds from its linked devices'
snapshots, offering only its link/… registers until it
has.
Snapshots. A sync_state carries
registers, and each register merges exactly as the op it records would,
so a snapshot may arrive in any number of chunks (at most 16 KiB of
registers each), in any order, with no reassembly. A device sends one at
link time, and a device that has not polled for 7 days or
more sends its own with want: 1, and each linked
device answers with its own: a week of ops costs more to replay than one
snapshot, and ops are only as durable as the relay's undelivered
retention.
Read marks are coalesced (decision DA6): a device
sends at most one read/… op per thread per 30 seconds, and
the latest when the window passes. Reading a long thread is otherwise an
op per message.
Per-device digests on the relay. Since every linked device holds the account delivery key, and each device also publishes digests (the delivery-key digest of §14.4, the roster tags of §8.9c), the relay keys those digests by account and device from stage A2 rather than by account alone. Before that a device publishing its own replaced the other devices', and contacts holding a key the relay had just forgotten fell back to unsealed sending. A relay that keys them by device says so in its descriptor:
{ "features": { "perDeviceDigests": true } }
A client that sees perDeviceDigests MUST republish its
delivery-key digest once, and from then on send device_id
with its roster tags. The republish is not optional: a device whose
digest was overwritten under the old behaviour has no other reason ever
to publish it again, so without it the fix never reaches that
device.
17.7 Stages
| Stage | What | Reversible |
|---|---|---|
| A0 | This section, sync-merge-v1.json, the pending content
cases, the backup keys; the web client. |
yes |
| A1 | Both apps: own-device traffic to linked devices only; Android shows an added device as iOS does. | yes |
| A2 | Linking (QR and numeric), the account delivery key adopted at link
time; the relay keys digests by device
(perDeviceDigests). |
yes |
| A3 | Sent-copies and ongoing sync; per-device token slices; presence tags as the union of the devices'. | yes |
| A4 | History backfill. | yes |
| A5 | Lost-device rotations, link attestations, the scheduled export. | the code, yes |
Nothing in any stage deletes a key-log row (§19.1).
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 | export and import, in Settings | export and import, in Settings |
| §17 multi-device | sync.js: the register merge and the link code |
QR and numeric linking, sync ops and snapshots, sent-copies of every kind, history, lost-device rotation | per-device digests from stage A2; attestations from A5; nothing that brokers a link | no | no |
The iOS cell for §16 read no until 2026-09-21
and had stopped being true.
Sources/Views/Settings/DataStorageView.swift offers "Export
chat history…" and "Import chat history…", the file is sealed with a
passphrase the person chooses, and createBackup has two
call sites. Nothing continuous and no server copy, which is §16 as
written rather than a lesser version of it.
The Android cell read no until 2026-09-29, and
had been wrong since 2026-08-28 (6a0f642 in
flamenet-messenger-android). Since 2026-09-10 (5a5e41b,
released in v0.30) it writes and reads the shared payload of §16.2.1,
the same one iOS does, carrying the roster,
rosterOffServer, group secrets and names, and the presence
tokens issued and held. Settings has a HistoryBackupSection
that exports and imports, through
MessengerViewModel.exportHistory /
importHistory, over the engine's Backup.create
/ Backup.open, and BackupVectorTest checks
that engine against test/vectors/backup-v1.json from this
repository.
That is the fourth time this repository has understated an app, after
§15's two Android cells, §10's interop counts and the iOS cell above.
All four were found the same way, by looking for the call site, and the
rule holds in both directions: a cell is evidence about behaviour, and
no needs checking exactly as hard as yes.
§17 on iOS was checked at the same time and is correctly
no: no device linking, no sent-copy handling, and no UI for
either. Both apps do send some traffic to their own account's other
devices, story copies (§6.22.1) and call hand-off (§6.30), and they send
it to every device on the account rather than to linked
ones, because there are no linked ones yet. §17.3 forbids that; stage A1
fixes it. Android also refuses to send to a contact who added a device
until the person accepts it, where §17.3 and iOS show a notice; also
stage A1.
§16 needed no server change and §17 needs only one, keying digests by device (§17.6), and neither should ever get more: a relay that stores backups or brokers device linking is a relay that stores everything, which contradicts §14.
Covered by 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, a sync op or a
link_offerfrom an own-device that was never linked being ignored or held for a person, even though the ratchet authenticates it as ours; - a link code whose key differs from the key the relay lists for that device being refused before anything is sent, and an offer carrying a stale secret being dropped.
The web client's linking was, until 2026-10, a flag a caller set by
hand (linkDevice(deviceId, true)): nothing scanned
anything, no secret crossed, nothing was adopted, and a "linked" device
had no account key and no state. It now runs the handshake of §17.3. The
merge is checked against test/vectors/sync-merge-v1.json in
test/conformance.mjs, every case in every rotation of its
ops, reversed, and doubled. Each new check was watched to fail against a
deliberately broken engine before it was trusted.
What the web client does not do: attestations, the scheduled export,
presence-token and sealed-group rotation (it has neither), and the
one-time republish under perDeviceDigests (it publishes no
roster tags).
iOS and Android have backups, as the table says, and neither
has sent-copies. Nothing about either is negotiated. A
sent copy written as §17.2 now specifies carries
ctl: 1, so a phone that does not implement it consumes it
rather than drawing a bubble (§6.10). This paragraph said the apps had
neither feature after the table had been corrected, which is the same
understatement as the cells above, in a second place.
18.1 Messenger 0.32
| Web engine | Relay | iOS | Android | |
|---|---|---|---|---|
| §6.12 a site from the directory | none | n/a | built | built |
| §6.15 a story's caption, §6.35 taking a story down | none | n/a | in progress | in progress |
| §6.31 greeting cards | none | n/a | built | built |
| §6.32 events and answers | none | n/a | built | built |
| §6.33 games in a chat | none | n/a | built | built |
| §6.34 hello links | none | the /hi page, built |
built | built |
| §8.12 the Today file | none | built | built | built |
| §9 the announced bit | n/a | built | n/a | n/a |
| §16.2.1 cards, events and games in a backup | none | n/a | built | built |
Built means built and tested on the 0.32 branches, and not released:
iOS 0.32.0 and Android 0.32 are in no store, and no relay runs this
code. The reference engine implements none of these sections.
test/conformance.mjs checks their vectors with readers
written from the spec, and checks the tic-tac-toe and reversi cases with
a referee of its own.
Where an app's 0.32 feature branch did not follow these sections, the
vector file records the input under history, with what each
app did and the decision that settled it, and the same input is an
ordinary case in that file. The release branches are being brought into
line with this text. test/relay-today.mjs drives
/today.json on a relay built with §8.12, and skips until a
relay answers that route.
19. Key transparency
Status: IMPLEMENTED on the web engine, the relay and
iOS. NOT on Android, which carries
KeyLog.kt and calls it from nowhere. §19.5 records what has
been run for each.
§20 gained a status line after §20.6 was found contradicting itself. This section had the same shape of defect and a worse version of it: §19.5 claimed Android verified proofs and gossiped heads, and it did neither.
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, revoke or
expire. revoke is a person removing a device;
expire is the relay removing one on its own, a silent
device past the retention window or the least recently active one making
room for a new sign-in at the device cap (§17.3). Both mean the device
is gone, and a relay MUST NOT let a device id with either be registered
again. The relay has written expire since it began retiring
devices itself; this line named only the first two until 2026-10. 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
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.
And the signature is not a second opinion. §19.3
says the head signature is what makes a fork evidence, which is
true between two clients who compare heads and false as a statement
about one client on its own. In the reference deployment the relay holds
FN_ISSUER_PRIVATE_KEY, so the party signing the log is the
party the log exists to hold accountable: against a hostile operator its
signature is not testimony, it is whatever they chose to write. The
signature binds them once somebody else has a differently-signed head of
the same size; it proves nothing to a person who has only ever seen
one.
The usual answer to that is witness co-signing, a second party
countersigning heads so a client can tell that somebody independent saw
the same tree. It is not deployed and not specified
here. The relay serves a witnesses field on
GET /e2e/log/head, and the store behind it has no
submission route and is never written, so every head is served
unwitnessed; no client reads the field. A reader should take the field
as reserved rather than as a mechanism, and take gossip (§19.4a) as the
operative check, which is what the rest of this section already says it
is.
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. It was once opt-in and off, which meant the relay offered proofs and nobody asked: a log nobody checks protects no one.
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 | yes | src/keylog.js |
KeyLog.swift |
KeyLog.kt |
| Client demands a proof before first use | n/a | automatic | automatic | automatic |
| Checks the head signature against a pinned issuer key | n/a | yes | yes | yes |
| 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 | yes | yes | yes |
| Append-only pinning across sessions | n/a | yes | yes | yes |
| §19.4 gossip, consumes a peer's head | n/a | yes | yes | yes |
| §19.4 gossip, sends its own head | n/a | yes | yes | yes |
The last row is split from the one above on purpose: a client that only consumes gossip is a client no fork can be detected against, so consuming and sending are recorded separately.
The Android call sites are in client/Trust.kt, reached
from client/Conversations.kt:
enforceKeyLogIfServed per served bundle,
verifyLogConsistencyIfDue on a timer,
headPayload to send a head and gossip to
consume one. The pin and the served flag are per relay host in the
vault, so both survive a restart.
A cell here is evidence about behaviour, not about whether a file
exists, and the check that produces one is to find the call site. It has
to be run against every module: this repository's engine is
engine/, but its clients live in client/, and
a search of engine/ and app/ alone finds none
of the call sites above and reports a working implementation as absent.
§15 records the same error made the other way.
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.
The head signature is a separate row because inclusion and
consistency both reduce to root, and root
arrives inside the head the relay served. They establish that a log is
self-consistent, not that it is the log the issuer vouches for, so a
client checking only those two has verified nothing about who it is
talking to. The web engine reads the issuer key from
/.well-known/flamenet/relay, pins it on first sight next to
the head pin, and refuses a head signed by another key, a signature
lifted from a different head, or a missing signature from a relay that
published a key. A relay publishing no issuer key is left unchecked, on
the §19.4 argument about deployments predating a section.
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
Status: IMPLEMENTED on the web engine, the relay, iOS and Android, the last of those as of 0.29. §20.6 records what has been run for each, and which devices stay classical after the release.
This section had no status line at all while §§12, 13 and 14 each had one, which is how §20.6 came to contradict itself below without anyone noticing.
§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 | yes | yes | yes | yes, 0.29 |
| §20.1 verify both signatures | n/a, not the relay's job | yes | yes | yes, 0.29 |
| §20.5 downgrade + substitution pinning | n/a | yes | yes | yes, 0.29 |
Android shipped this in 0.29, published 2026-09-21.
Both halves: DeviceIdentity mints and signs, and
client/Conversations.kt calls PQIdentity.check
from sessionFor, which is the new-session path §20.5 names
and the only moment a downgrade can be attempted.
The rollout is not the same thing as the release: a device that has
not taken the update, or one restored from a backup written before it,
publishes no ik_pq and is authenticated classically. §20.2
makes that a supported state, so nothing breaks, but a conversation is
post-quantum authenticated only where both ends have it and no client
surfaces which.
What has been run for Android, as of 2026-09-21:
PQSignis ML-DSA-65 on BouncyCastle 1.79.PQSignVectorTestderives the published seed's public key and compares it byte for byte againstprotocol-v1.json, which is the cross-engine agreement FIPS 204 keygen determinism allows.- Bidirectional interop with the web engine was run directly, outside the test suite: BouncyCastle and the vendored @noble implementation derive the same public key from the same seed, each verifies a signature the other made, and each refuses the other's tampered signature. Without that, two implementations could have agreed only on being wrong.
DeviceIdentityPQAuthTestpins the wiring: the identity is minted, survives an archive round trip under the web engine'sikPQSeedkey, andspk_pq_sigcovers exactly the bytesspk_sigcovers. It also pins §20.2, thatik_pqis published even when the v2 block is not.PQIdentityPinTestcovers §20.5 as a misbehaving relay: stripping an identity, substituting another device's, a bent signature, an identity with no signature, and a signature over a different device's prekey. Disabling the downgrade and substitution refusals turns exactly those two tests red, so the suite is known to be capable of failing.
Run on a device, 2026-09-21, after the release. The
published 0.29 APK was installed on an emulator, pointed at relay1, and
used to create an account. The relay's devices row for it
carries ik_pq at 1952 bytes and spk_pq_sig at
3309 bytes, the ML-DSA-65 sizes, and the web engine verifies the
signature that phone produced, refuses it once a byte is
flipped, and refuses it over any other spk. That is the
cross-implementation check the unit tests cannot make: a device signed,
and an independent implementation believed it.
Still not run: the instrumented suites against this build, and any two-device session where one Android phone verifies another's identity. Publishing is proven on a handset; the receive path is proven only in unit tests.
One behaviour to know about. A device restored from an archive written before §20 is not given an identity: it restores as classical and stays that way. That matches the web engine, which mints only on create. The consequence is that existing installs do not gain a post-quantum identity from the update alone, only fresh ones do. §20.5 would permit a backfill, since absent to present is not a downgrade, but diverging from the reference implementation is a decision to take deliberately rather than by accident.
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 does all of it too. PQSign
(ML-DSA-65 on CryptoKit) mints and verifies, E2EKeys signs
the prekey, and E2EInbox throws the same three refusals as
the web engine: PQDowngradeError when a pinned identity
goes missing, PQVerificationError when the signature fails,
and PQIdentityChangedError on substitution.
ProtocolVectorTests derives the published seed's public key
and checks it byte for byte against the vector, which is the
cross-engine agreement FIPS 204 keygen determinism allows. Signatures
are deliberately not pinned in the vectors because ML-DSA signing is
randomised; verification is against the public key instead.
Android does all of it too, as of 0.29.
PQSign is ML-DSA-65 on BouncyCastle,
DeviceIdentity publishes and signs, and
Conversations enforces the same three refusals on the
new-session path.
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. Since §17 it is account state, carried to linked
devices as tok/<user id> registers (§17.6), so any of
them can count and revoke.
One slice per device. A contact with several devices gets the batch split into disjoint slices, one per device, each in that device's own prelude (from stage A3). Every device of the contact used to receive, hold and spend the same tokens, so two of them raced for each one, and the first refusal wiped the batch on all of them. With slices, a device spends only its own, and a refusal empties only its own.
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, 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: 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
- 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.
Blocking is both doors. A client blocking somebody
MUST record the block locally, revoke that contact's batch, and rotate
the delivery key, in that order and as one action, and from stage C3
push the new key to every other contact at once (§14.4). Revoking alone
leaves the shared key working, which is point 3 above. A client MUST
drop anything that arrives from a blocked person, whatever the relay let
through, and the block travels to the account's linked devices as a
blocked/<user id> op, which wins a tie (§17.6).
The relay's own block list still refuses that person's named messages for now; §8.9d says why it stays. It goes only after named sends are refused relay-wide (§14.5, stage C4) and blocks have been local on every device for the drain period, because until then a named message is the one way a blocked person can still make a phone ring. Even then a blocked person who knows a listed handle can get an intro and reach Requests, where the device drops them; the relay is not asked to refuse intros for blocked pairs, because that would need the pair (decision DC3). An account that wants no strangers at all is unlisted (§8.14).
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 is not part of this leak: 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 a decision rather than a delay, and this is why.
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 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.
23. Length hiding
Status: SPECIFIED, rollout stage 1.0. The web engine implements it; the relay and both apps do not yet. §23.11 records exactly what exists.
23.1 What this hides, and what it does not
Before this section, a payload was as long as the message in it plus a near-constant overhead. A relay that cannot read a word could still tell "ok" from a paragraph, a reaction from a photo reference, a delivery-key prelude from a call offer, and the 3 KB post-quantum block on the first message of a chain (§13.3) from everything after it. Collected per recipient over time, lengths say what kind of conversation it is.
This section fixes the length of every payload the relay stores to one of two values (§23.3), the length of an attachment blob to one of nine (§23.9), and the length of a sealed group roster to one (§23.10).
It does not hide:
- how many envelopes a device sends and receives, and when;
- the recipient, whether a row is sealed, and the sender's IP address (§14.2, §14.6);
- which step a payload landed in. A 64 KiB payload says "long", which is coarser than the length but not nothing;
- the unpadded length of an unsealed envelope. The
relay can read an unsealed envelope's JSON, so it can read the length of
ctdirectly, and subtracting the pad is arithmetic. Padding an unsealed envelope makes every payload on the wire and in the table a ladder length, which is what lets the relay enforce the ladder (§23.11) and denies the length to anybody watching the connection, but it is not secrecy from the relay. The gain against the relay is for sealed sends, which is most traffic after first contact.
23.2 Where the pad lives
At the transport layer, not in the content envelope (§6.6). Three reasons:
- Not every message has a content envelope. One client sends plain
text as bare plaintext with no
fncand nomid(§6.9), and padding only what has an envelope would leave those exactly as long as they are. - The post-quantum header and the prekey block (§12.6, §13.3) sit outside the plaintext. Padding the plaintext would leave a 3 KB jump between the first message of a chain and the rest, the most distinctive size signal there is.
- JSON writers disagree about escaping
/. Swift'sJSONEncoderandorg.jsonwrite\/; JavaScript writes/. The base64 inside an envelope is full of slashes, so a padder working inside the ratchet cannot predict the length the wire will see.
So the pad goes in the outermost JSON object each sender writes itself, where the length it controls is the length that is sent:
- sealed sends (§14.3): a
padkey in the seal's inner JSON, under the seal's encryption; - unsealed sends: a
padkey in the ratchet envelope (§6.4), besidect, outside the ratchet's AEAD. Nothing authenticates it and nothing reads it.
Clients in the field ignore an unknown key in both places: iOS
decodes the seal's inner JSON and the envelope with synthesized
Codable, unchanged since 0.25.0, and Android reads both
with optX. So message padding needs no capability
negotiation, and a padded payload sent to an old client is read like any
other. The engines in both apps today, before either has any padding
code, are run opening padded payloads (§23.11). The oldest builds still
in use are checked at stage 1.2.
23.3 The message ladder
PAD_MESSAGE_STEP_1 = 16384
PAD_MESSAGE_STEP_2 = 65536
PAD_MESSAGE_LADDER = [PAD_MESSAGE_STEP_1, PAD_MESSAGE_STEP_2]
Lengths are in characters of the payload string (§8.4),
which is base64 and so ASCII. A padded payload's length MUST be a ladder
value. Every ladder value MUST be a multiple of 16, so that both base64
layers of a sealed payload come out with no = padding and
the arithmetic in §23.5 is exact.
PAD_MESSAGE_STEP_1 is a named constant because it may
change before any client pads. A count-only histogram of payload lengths
runs on the relay first (§23.11); if more than a tenth of traffic falls
between 16 and 24 KiB, the first step becomes 24576. Implementations
MUST derive every target from the ladder (§23.5, §23.6), not write the
numbers below into code, so that change stays one constant.
Two steps and a refusal above them, rather than more steps: every extra step is extra information about the messages that use it, and one large step would multiply the bandwidth of the common case. 16 KiB holds ordinary text, every control message, and a first message with its post-quantum header. 64 KiB holds long text. Past that a message is a document and belongs in an attachment (§23.7). The top step is far below the relay's 4 MiB request limit, so a fan-out batch still fits in one request.
23.4 The pad
pad is a JSON string of characters drawn uniformly from
[A-Za-z0-9] with the platform's cryptographic random
source. Senders MUST NOT use any other character: none of these is
escaped by any JSON writer, so the pad's serialized length is its
character count everywhere. Random rather than constant because an
unsealed envelope's pad is in the clear, and a constant one would
compress to nothing wherever a transport compresses.
pad MAY be empty. Senders write it as the last key;
receivers MUST NOT depend on that.
23.5 Sealed payloads
For a step B:
E = 3B / 4 UTF-8 length of the outer JSON
C = E - 68 characters of base64 in "ct"
I = 3C / 4 - 16 UTF-8 length of the inner JSON (16 is the AES-GCM tag)
For 16384 that is E = 12288, C = 12220, I = 9149. For 65536 it is E = 49152, C = 49084, I = 36797.
The 68 is the canonical outer form, which a padding sender MUST write byte for byte, by template rather than with a JSON library:
{"s":1,"eph":"<44 characters>","ct":"<C characters>"}
Keys in this order, no spaces, / never escaped.
eph is always 44 characters, 32 bytes of base64. A writer
that escapes / adds a character for every slash in
eph and ct, about one in 32, and the length
misses the step. §14.3's wire shape is unchanged; this pins how it is
written.
The inner JSON is §14.3's from_user,
from_device and envelope, plus
pad, padded to I. The envelope inside it
carries no pad of its own.
23.6 Unsealed payloads
The ratchet envelope's JSON (§6.4) plus pad, padded to
E = 3B / 4: 12288 or 49152 bytes. Its base64 is then
exactly B characters. The pad is not part of
header_bytes and not part of the AAD.
23.7 The algorithm, the length check, and the refusal
A sender SHOULD pad every payload it sends. That becomes MUST when
the rollout in §23.11 completes. A sender that pads MUST do it exactly
like this, where T(B) is the target for step B
from §23.5 (sealed) or §23.6 (unsealed):
- Serialize the object without
pad, with the writer that will write the payload. Call its UTF-8 lengthL0. - For each
Bin the ladder, ascending, letn = T(B) - L0 - 9. The 9 is what,"pad":""costs. Take the firstBwithn >= 0. - If there is none, refuse (below).
- Serialize again with
padset tonrandom characters (§23.4). - Encode: for a sealed send, seal it and write the canonical outer form; then base64.
- Check that the
payloadstring is exactlyBcharacters. If it is not, the sender MUST NOT send it, MUST NOT fall back to sending it unpadded, and MUST surface the failure as an error rather than swallow it.
The check is on the bytes, not on the arithmetic, so it catches the writer whose output the arithmetic did not predict, which is the failure §23.2 describes. A wrong length is a bug in the sender, and sending it anyway hands the relay exactly the signal this section exists to remove.
Too long to send. When the unpadded object does not fit the top step, about 20,000 characters of text in a sealed message, the client MUST refuse visibly and say why. The clients' wording is "Too long to send as a message. Send it as a file." A composer SHOULD show the limit before the person reaches it. A client MUST NOT split the message, truncate it, or send it unpadded instead. Turning it into a file attachment automatically would be allowed later and is not specified here.
A refusal SHOULD come before anything is spent on the message: no delivery token taken, no token batch minted, no ratchet step. The web engine does this by encrypting with a throwaway copy of each session first.
Every envelope a client sends goes through this: text and control messages, group fan-out (§6.7), delivery-key preludes (§14.4), call signalling (§6.8), copies to the sender's own devices (§17.2), and federated sends. Each envelope is padded on its own, so a prelude and the message it rides with may land in different steps.
23.8 Receivers
Receivers MUST ignore pad wherever it appears and MUST
NOT require it: a payload from a client that predates this section is
read exactly as before. Receivers MUST NOT check payload lengths.
Refusing an unpadded payload is the relay's decision, if anybody's
(§23.11), because a receiver that refuses one loses a message somebody
was sent.
23.9 Attachments
Attachment blobs (§6.6, §8.7) have their own ladder, in bytes:
PAD_ATTACHMENT_LADDER = 64 KiB x 2^k for k = 0..7, then 10 MiB
= 65536, 131072, 262144, ..., 8388608, 10485760
The top step is the relay's blob cap (§8.7), so nothing that fits today stops fitting.
A sender padding an attachment:
- seals the media exactly as §6.6 says,
nonce || ciphertext || tag, and calls its lengthclen; - takes the smallest step at or above
clen, and refuses above 10 MiB as the relay would; - appends
step - clenbytes from the cryptographic random source, and uploads the whole blob; - sets
att.digestto SHA-256 over the whole uploaded blob, tail included,att.bytesto its length, andatt.clentoclen.
The tail MUST be random. Zeros would show anyone holding the blob where the ciphertext ends.
A receiver verifies digest over the whole blob first, as
§6.6 already requires, then opens only the first clen
bytes. A reference with no clen is the format before this
section, and the whole blob is opened. A clen that is not
an integer, is less than 28, or is larger than the blob MUST make the
attachment undisplayable without decrypting.
This part is not backward compatible, and the switch
exists because of it. A receiver that predates clen opens
the whole blob, tail included, and the AEAD fails. Therefore:
- receivers learn
clenbefore any sender pads (stage 1.2); - a sender MUST NOT pad an attachment unless the relay descriptor,
GET /.well-known/flamenet/relay, carries"features": {"padAttachments": true}. Absent, false, or a descriptor that cannot be fetched all mean off. The key is camelCase, like every other key in the descriptor (audience,issuerPublicKey,keyLogHead); - the operator turns it on once no device older than the
clenrelease has been seen for 30 days.
A relay that never turns it on keeps attachment lengths exposed, and a client cannot tell a hostile relay from a cautious one. That is accepted for the changeover only: once the old builds are gone the switch is removed and padding becomes unconditional (§23.11).
A sender cannot know another relay's switch. Federation is closed by default, and a padded attachment reaching an old client on a federated relay is an accepted risk.
Padding costs storage: about 44% more than the unpadded size on average, and just under double at worst, for a blob one byte over a step. Operators SHOULD raise per-account attachment quotas by about half when they turn it on.
23.10 Sealed group rosters
A sealed roster blob (§8.9b) is as long as its member list. Its
plaintext JSON MUST be padded with the same pad key and the
same steps 1 to 6 of §23.7, to a fixed target:
ROSTER_PAD_TARGET = 8192 bytes of plaintext JSON
8192 holds a full 32-member roster (§8.9's cap), every member's tag,
and a group name of several hundred characters, so the blob is the same
length for every group within the cap. A roster that does not fit, which
takes an unusually long name, pads to the next multiple of 8192 rather
than being refused. The owner pads at creation and at every replacement.
Readers ignore pad, and old readers already do: iOS decodes
SealedRoster.Contents with synthesized
Codable, and Android with optX.
This hides the count from the blob and not from the table.
group_tags (§8.9b) holds one row per member, so a dump
still shows how many members a group has. Hiding that needs decoy tags,
which is the same open question §8.9c leaves for contact counts.
23.11 The relay, the rollout, and status
The relay never reads inside a payload. The length
of the payload string is the only property of one it may
look at.
FN_PAD_ENFORCEisoff(the default),logorenforce. Inlogthe relay counts payloads whose length is not a ladder value, by app version, and changes nothing. Inenforce,POST /e2e/messagesrefuses a batch containing one with400and the codeunpadded. The counts MUST NOT name an account or a conversation: a list of who sent what size would be a new record of the kind this protocol removes.- Poll responses are capped in bytes (§8.5), because padding makes the average envelope several times larger.
- The descriptor's
features.padAttachments(§23.9) comes fromFN_PAD_ATTACHMENTS, default false.
The rollout. No step is irreversible.
| Stage | What |
|---|---|
| 1.0 | This section, test/vectors/padding-v1.json, the web
engine. |
| 1.1 | Relay groundwork: the poll byte cap, count-only ladder metrics,
FN_PAD_ENFORCE=off, padAttachments false. The
histogram settles PAD_MESSAGE_STEP_1. |
| 1.2 | Both apps pad messages and rosters and read clen.
Released only after both pass the vectors, the three-way interop driver
(§10), and an old-engine check against the oldest builds still
reporting. |
| 1.3 | padAttachments on, 30 days after the last device older
than 1.2 was seen. A device that never reported a version counts as
old. |
| 1.4 | FN_PAD_ENFORCE=log for two weeks, then
enforce. |
| 1.5 | The switch is removed. Senders MUST pad messages and attachments. |
What it costs. Bandwidth roughly 1.7 to 2.5 times today's mean per message, relay storage and its write-ahead log about double, and the battery that goes with both.
Conformance.
test/vectors/padding-v1.json, generated by
test/vectors/generate-padding.mjs, pins the ladder edges
(each target, and 8, 9 and 10 under it, and over the top), a sealed
payload reproducible to the byte from fixed keys, an unsealed one, an
attachment, and the cases that must be refused, including a sealed outer
form written with escaped slashes. test/conformance.mjs
checks the web engine against it and against every length across the
range; test/client.mjs checks that the client pads every
send, refuses before spending anything, still reads unpadded payloads,
and pads attachments only when the relay says so.
Status, as of 2026-10-07:
| Web engine | Relay | iOS | Android | |
|---|---|---|---|---|
| Messages padded | yes, sealedPayload and
paddedEnvelopePayload |
n/a | no | no |
| Unpadded payloads read | yes, tested | n/a | yes, as before | yes, as before |
| Padded payloads read by today's engines | yes, tested | n/a | yes: a padded seal, and a padded envelope through its decoder
(test/interop.mjs) |
yes: a padded seal (test/interop-kotlin.mjs); a padded
unsealed envelope not yet run |
Attachment clen read |
yes, openAttachment |
n/a | no | no |
| Attachments padded behind the switch | yes, sealAttachment |
descriptor field: no | no | no |
| Roster padding | n/a, the engine has no roster code | n/a | no | no |
| Counting and enforcement | n/a | no | n/a | n/a |
24. obfs4 bridges and the bridge offer
Status: IMPLEMENTED in both apps from the bridge
work (flamenet-messenger-ios and flamenet-messenger-android, branch
bridge/f2f-apps): offers sent and received, up to 8 lines
with sequential failover, and nothing connecting around a saved bridge.
The web engine has no bridge. §24.9 records exactly what exists.
24.1 What a bridge is for, and what it is not
A bridge is a second way to reach the relay, for a network that blocks the first. It speaks obfs4: after a handshake, every byte looks random, so a censor watching the wire sees traffic to an address it has not listed, in a shape it cannot name. Inside the tunnel the app speaks exactly what it would speak without one, TLS to the relay's own hostname, so a bridge adds nothing to what the relay sees and takes nothing away from it.
Reachability, not anonymity. The bridge's operator sees the client's address. The relay sees it too where the deployment passes it through: the reference deployment forwards from the bridge to its edge proxy with a PROXY protocol v1 line, and the edge trusts that line from the bridge's fixed address only, so country rules, bans and the relay's per-IP limits all apply to each bridged client's own address, as they would without the bridge.
A bridge is configured by pasting a bridge line. It is off unless a line is set; no build ships with one.
24.2 The bridge line
obfs4 198.51.100.7:8443 FBEFBEFFFFFF000102030405060708090A0B0C0D cert=++++////AAECAwQFBgcICQoLDA2FIPAJiTCnVHSLfdy0PvdaDb86DSY4GvTrpKmOqptOag iat-mode=0
Fields are separated by any run of whitespace. A line pasted out of a chat often wrapped, so a newline between fields is ordinary and MUST be accepted. Parsers find each field by its shape, never by its position:
| Field | Shape | Meaning |
|---|---|---|
| transport | obfs4 |
Optional. Parsers ignore it. Senders SHOULD include it, since every other tool expects it first. |
| address | the first field containing : and no = |
host:port. The port is the text after the
last colon, so a bracketed IPv6 literal
([2001:db8::7]:443) keeps its own colons and its brackets
stay on the host. The host is an IPv4 address, a bracketed IPv6 address
or a hostname; the port is decimal digits, 1 to 65535. |
| fingerprint | 40 hex characters | The bridge's obfs4 node ID, upper case as bridges print it. Clients ignore it; the cert carries the same 20 bytes. Inventories and distributors key on it. |
cert= |
standard base64 (+ and /), padding
optional |
Decodes to exactly 52 bytes: the 20-byte node ID,
then the bridge's 32-byte X25519 identity public key. A bridge writes it
unpadded, 70 characters. Receivers MUST accept it with or without the
trailing ==, and MUST NOT accept the URL-safe
alphabet. |
iat-mode= |
0, 1 or 2 |
Inter-arrival timing obfuscation. Clients implement 0.
A line asking for 1 or 2 is still usable,
because the bridge does not enforce it: the client says so beside the
line (a caveat), and does not refuse it. |
Any other key=value field is ignored.
Reading a line yields either a bridge (host, port, node ID, public key, and whether there is a caveat) or one problem, checked in this order:
- Empty: no fields.
- NoAddress: no address field, or an empty host.
- BadPort: the port is not digits in range.
- NoCert: no
cert=field. - CertNotReadable: not base64. Almost always a stray space or line break picked up while copying.
- CertWrongLength: decodes, but not to 52 bytes. Short is by far the likeliest paste error, because the line wrapped and only the first part was copied, and the message MUST say the line looks cut short rather than that it is malformed.
Messages are written for the person who pasted the line. They MUST
NOT contain the words obfs4, base64 or node id. Both apps carry the same
messages, and a test in the Android app
(messages match the ones iOS shows) holds its copy to the
iOS one.
24.3 The offer
A contact abroad, or anyone already using a bridge that works, can hand it to someone inside a conversation they already have. Nothing new has to be run for this, and there is no server for a censor to block: the line travels through the ratchet like a message.
{"fnc":1,"kind":"bridge","ctl":1,"line":"obfs4 198.51.100.7:8443 … iat-mode=0"}
| Key | Type | Meaning |
|---|---|---|
fnc |
integer 1 |
Content envelope (§6.6). |
kind |
"bridge" |
|
ctl |
1 |
Control traffic (§6.10). Receivers also accept
true. |
line |
string | The bridge line, as the sender uses it. |
No other key is defined at fnc 1. In particular an offer
carries no gid, no mid and no timer.
Writing it. Both apps build the envelope by
template, keys in the order above, with the line escaped by a JSON
string writer. Three writers are in the field and they disagree about
/. Android 0.31 and earlier used the platform org.json,
which writes every / as \/. Android 0.32 and
later use the engine's own writer, which escapes only what JSON requires
and never /. The iOS builder, like org.json on the JVM,
escapes / only after <. Two certs in three
have at least one slash. All three forms are the same JSON string, so
receivers MUST decode the envelope with a JSON parser and compare
decoded strings, never bytes. The vectors (§24.10) carry all three.
Sending it.
- The line sent is the line the sender's own app uses, as stored. A client MUST NOT offer a line it cannot read (§24.2).
- To one contact, through that contact's pairwise sessions, sealed when it can be (§14) like any other message. Never through a group fan-out (§6.7), never in a story, and never to many contacts with one action: each offer is one person's deliberate choice about one other person.
- Never automatically, and never in reply to anything a peer sends.
- Before the first send the interface MUST say what the contact learns: that the sender uses a bridge, and which one. Whoever runs the bridge sees one more address connecting.
24.4 Size and padding
The line MUST NOT be longer than 1024
bytes of UTF-8, measured on the decoded string. A real line is
about 150 bytes with an IPv4 address and under 200 with an IPv6 one;
only a long hostname takes it further. The limit is generous, and it
bounds the worst case: with every byte escaped as \uXXXX,
the envelope stays under 7 KiB, well inside the second padding step
(§23.3). A sender MUST NOT send a longer line; a receiver MUST drop an
offer whose line is longer.
An offer is control traffic and takes no padding class of its own. The pad is added at the transport layer, not in the content envelope (§23.2), so an offer is padded like every other payload: a real one, about 150 bytes of line, lands in the first step with ordinary text and every other control message, and is the same length on the wire as a short message.
Until a client pads, an offer is as long as its line plus a
near-constant overhead. Bridge lines have nearly constant lengths, so an
unpadded offer has a recognisable size. Sealed (§14), the relay learns
that a payload of that size went to a device; unsealed, it can read the
length of ct directly (§23.1). Neither reveals the line.
This is the general limit §23 exists to close, stated here because an
offer is one of the few control messages whose size alone suggests what
it is.
24.5 Receiving an offer
An envelope is an offer when all of these hold:
fncis 1 andkindis"bridge". The kind alone decides it; a missingctldoes not stop an offer from being one.lineis a string, and is not empty or whitespace after trimming.- There is no
gid. An offer that arrived through a group is dropped. lineis at most 1024 bytes (§24.4).- The sender is proven: the ratchet bound the envelope to an identity the receiver already holds, the same rule as every other control message. An offer from a federated address is dropped.
An envelope that fails any of these is consumed silently, because it
carries ctl (§6.10). It is never shown as a message.
An offer is never applied. Arriving changes no setting and opens no connection. It is stored for the person to look at:
- one pending offer per contact; a newer offer from the same contact replaces the older one. A contact who rotates a bridge sends the new line, and a list of every line they ever had is a menu of dead hosts;
- what is kept is who offered it, the line exactly as received, and when it arrived by the receiver's clock;
- kept with the device's other secrets, and never in an encrypted backup (§16) or a platform backup. An offer names a contact beside the address of a bridge they use, which is the wrong thing to find on a seized phone's cloud backup.
It is shown in the app's bridge settings, never in the conversation and never as a notification. For each offer:
- who offered it: their display name, else their handle, else the account number, never "someone";
- the whole line, as received, in a fixed-width face. It is not hidden behind the accept button;
- what reading it gave (§24.2): the problem in the person's words, or where it points ("198.51.100.7 port 8443") and the caveat if there is one. An offer that cannot be used says why when it is shown, not when it is accepted;
- Use this bridge, enabled only when the line reads as a bridge, and Dismiss.
24.6 Accepting, and several lines with failover
Accepting reads the line again, stores it as a line the client uses, and removes the offer. The line joins the stored list below; it does not replace the lines already there.
A client MAY hold up to 8 lines, in the order they were added. A line pasted by the person and a line accepted from an offer are stored alike; where a line came from is kept only for display.
- Order of attempts. The line that last completed a connection first, then the rest in stored order. One at a time: dialling every bridge at once is a burst a censor can see, and it hands every stored address to an observer on the first connection.
- What counts as failure. No completed obfs4 handshake within 15 seconds, a refused or reset connection, or TLS to the relay's hostname failing inside the tunnel. A handshake alone is not success; the TLS check is what shows the bridge reaches the relay.
- When every line fails, stop. Say that no bridge could be reached, and keep trying on the usual backoff. A client with bridges turned on MUST NOT fall back to a direct connection. For someone using a bridge because the direct route is watched, a silent direct connection is the worst outcome available, worse than no connection. Direct happens only when the person turns bridges off.
- Status reads the live state, the tunnel that is up now, never the stored list.
- A line that keeps failing is not deleted automatically. It is marked with when it last worked, and the person removes it. A bridge can be blocked for a week and then reachable again.
- Nothing about which lines failed is sent anywhere.
Nothing goes around the bridge. A bridge reaches the edge in front of the Flamenet server and nothing else. While a line is stored, every connection the client makes goes through it, before sign-in included: relay discovery, the sign-up proof of work, the account service, the update check. A name is not looked up on the local network either, since the lookup alone says which service this is. A feature that has to connect to a different host, because what it fetches lives there, MUST be off while a line is stored, and the interface says so in plain words where the feature would be. In both apps that is link previews (§6.12; the sender fetches the page), calls (§6.8; media goes to the peer or a TURN server), the wallet (it asks a public chain node), and, on iOS, live streams in stories (§6.13). Opening a link in the system browser is the person's own action and leaves the app; the browser does not use the bridge.
How the apps try lines. iOS runs a local SOCKS5 listener that the app's sessions use; for each connection it dials the lines in order, and a line fails when it refuses, when no handshake completes within 15 seconds, or when it closes or stays silent with the client's first bytes unanswered, in which case those bytes are sent again through the next line. Android's resolver answers every name with one placeholder address per line, in order, and its HTTP client's own retry moves to the next line on a refused connection, a handshake timeout, or TLS failing inside the tunnel. A server address given as an IP literal skips the resolver, so an interceptor retries such a request once per line on the same failures.
24.7 Removing a line
- The person removes it. It is deleted from storage, and every connection opened through it is closed at once. A pooled HTTP connection outlives a settings change, so a removed bridge can otherwise keep carrying traffic after the screen says it is gone. If it was the last line, the client is off bridges, which is the person's choice and is the one case where it then connects directly.
- An offer goes when it is accepted, when it is dismissed, or when the same contact sends a newer one.
- There is no retraction on the wire. A sender cannot withdraw an offer already sent, and whoever retires a bridge cannot reach the copies already handed out. A retired bridge's line stops working; failover moves past it and the person removes it. This is deliberate. A message that removed bridges remotely would let anyone holding a contact's unlocked phone strip a person of the one route they have left, and the setting bridges exist for is the one where that is likely.
24.8 What each party learns
| Party | Learns | Does not learn |
|---|---|---|
| The relay | That a control payload of some size reached a device (§24.4). Sealed, not who sent it. | The line. |
| The contact who receives an offer | That the sender uses a bridge, and which one. | Anything else about the sender's network. |
| The bridge's operator | Each client's address, when it connects and how much it sends. | Content: the tunnel carries TLS to the relay. |
| A network observer near the client | Traffic to the bridge's address that matches no listed protocol. | That it reaches the relay, unless the bridge's address is known to be one. |
Handing someone a bridge line also hands it to anyone who later reads their phone. That is the same exposure as any message in the conversation, and the reason accepted lines and pending offers stay out of backups.
24.9 Implementation status
| Web engine | iOS | Android | |
|---|---|---|---|
| Builds the envelope | no | E2EInbox.bridgeEnvelope; none for a blank or oversized
line |
Content.bridgeOffer; null for a blank or oversized
line |
| Sends one | no | Network settings, "Share it with", through
E2EInbox.shareBridge |
Network settings, "Share it with", through
Conversations.shareBridge |
| Recognises one | no: handed to the caller as plaintext | E2EAttachment.parse sets bridgeLine; route
bridgeOffer |
Content.bridgeLine, consumed in
Conversations.consumeControl |
| Stores it | no | Obfs4Settings.record(offer:), from
E2EInbox.keepBridgeOffer |
Vault.saveBridgeOffer |
| Shows it | no | Network settings, "Offered to you" | Network settings, "Offered to you" |
Drops an offer carrying gid |
n/a | yes | yes |
| Size limit (§24.4) | n/a | yes | yes |
Reads a padded cert= |
n/a | yes | yes |
| Hosts it can dial | n/a | IPv4, IPv6, names | IPv4, IPv6, names |
| Several lines, failover (§24.6) | n/a | yes, up to 8 | yes, up to 8 |
| Bridge screen before sign-in | n/a | yes | yes |
| Nothing around the bridge (§24.6) | n/a | yes | yes |
An envelope whose known key has the wrong type
("line":5) is still an envelope (§6.6). iOS used to fall
back to text for it and show the raw JSON; it now consumes control kinds
silently and shows any other kind's caption, or the unknown-kind
placeholder.
24.10 Vectors
test/vectors/bridge-offer-v1.json, generated by
test/vectors/generate-bridge-offer.mjs. Eighteen offers
built with the apps' template, each as Android 0.31 and earlier write it
(envelope), as org.json on the JVM and iOS write it
(envelope_jvm), and as Android 0.32 and later write it
(envelope_plain), with the receive decision (§24.5) and the
reading of the line (§24.2) written out by hand. Seven more envelopes no
current builder writes, for the receive rules alone. Both apps decide
every case as written.
The bytes were checked on 9 October 2026 against the real libraries:
Android 15's framework org.json and org.json 20240303, each fed every
line, outputs compared byte for byte. envelope_plain
transcribes the Android engine's writer, and the Android vector test
holds its output to those bytes. test/conformance.mjs
checks every case against the rules above, and
test/interop.mjs runs every envelope, in all three
encodings, through the iOS app's own parser.