Open Free and open source, read the code Get it Donate App help Contact
Messenger
The app
Developers
Project
Legal

A flamenet.io account is not a Flamenet Messenger account. The two sign-ins are separate.

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_id selection: web, iOS (E2EInbox.currentSPK), and Android (DeviceIdentity.rotateSignedPreKey, signedPreKeyFor, and a retired-key ring with its own retention window).

Ship spk_id selection 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.

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

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


6. Double Ratchet

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

6.1 State (per session = per remote device)

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

6.2 KDF functions

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

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

6.3 Message key → AEAD parameters

From each 32-byte message key MK:

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

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

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

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

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

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

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

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 integer fnc field, it is a content envelope. Anything else is a plain text message, byte-for-byte (this preserves every message sent before this section existed).

{
  "fnc": 1,                       // content-envelope version
  "kind": "image",                // "image" | "audio" (voice note)
  "caption": "optional text",     // optional; may be absent or empty
  "att": {
    "id":     "<attachment id from POST /e2e/attachments>",
    "key":    "<b64 32-byte AES-256 key>",
    "digest": "<b64 SHA-256 of the uploaded ciphertext blob>",
    "bytes":  123456,             // 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, event and game are renderable. Each carries mid (§6.9), and in a group gid and gname as 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, gend and unpost are control traffic and set "ctl":1 (§6.10). A card, event or game that carries ctl is consumed as control and never drawn. An envelope carries ctl when it has the key with any value but 0, false or null.
  • The other flags (fwd, st, cancel) are on when they are 1 or true, and off for 0, 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 gmove that also carries an r is still a move. An optional field with no value is left out, never written as null.
  • 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 JSONEncoder escapes them with slashes left alone: " and \ are escaped, control characters become \b, \f, \n, \r, \t or \u00xx in 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.json holds 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; caption carries the body and att is absent (att is required for every other kind). image/audio envelopes may also carry gid, so group photos and voice notes work unchanged.
  • an optional top-level gname: what the group is called. Senders SHOULD put it on every group envelope; receivers MUST ignore it unless the sender is a member of gid per the registry.

The group roster lives in a server-side registry (§8.9) in plaintext, and that is a real leak (§22). The name does not: 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_key envelope (§14.4) carrying gid and gname. That kind rather than a new one because a new control kind renders as raw JSON on builds that predate §6.10's ctl flag; delivery_key is a kind they already consume in silence and already ignore unknown fields on.
  • gname on ordinary group envelopes, which repairs the case the announcement cannot reach: a device set up later, or one that was offline. There is no server copy to ask for, so without this a member could be permanently without the name.

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

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.

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. A file: URL arriving from another person is not something to hand a media player, and a destination that cannot be named cannot be consented to.

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

6.14 Urgent envelopes, and what they cost

A sender MAY mark an envelope urgent:

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

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

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

Rules:

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

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

6.15 Editing a message that was already sent

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

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

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

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

Further requirements:

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

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

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.
  • secs on a kind: "ttl" envelope means something else entirely: it sets the whole conversation (§6.11). A receiver MUST distinguish them by kind.

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

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

6.18 Mentions

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

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

This key was written ment here until 2026-09-11, and no client has ever sent that. Both shipped clients emit and read men, 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 ment as naming anyone who is not a member.
  • ment MUST be bounded and deduplicated on receipt. A sender can name ten thousand ids in one envelope, and the cost of that lands on every recipient's device rather than theirs. The reference implementation caps at 256, far above any real roster.
  • A client MUST NOT notify for a mention of somebody else, and SHOULD NOT let a person mention themselves, the only thing that achieves is making your own phone buzz.
  • Reading the conversation beats a mention. A banner about a message already on screen is no less absurd for naming you.
  • Omitted entirely when there are no mentions, which is nearly every message.

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

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

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

6.19 Pinning a message

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

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

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

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

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

Further requirements:

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

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

Status. Implemented on Android for both one-to-one and group conversations; iOS implements the one-to-one case. This paragraph previously said a group pin could not resolve, because a group transcript did not record a mid for each message. That stopped being true: Session.sendGroup mints a mid and records it, and GroupPinResolvesTest (Android) 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.

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.

  1. 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.
  2. The taking device builds a fresh connection and sends the peer a call-offer under the same call.id.
  3. 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.
  4. The taking device sends call-handoff-taken to 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 " and MUST NOT show 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 " sent you a card", or " sent you a card to open on " while it is sealed. A notification MUST NOT carry a card's words, sealed or not.

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 " invited you: ". A revision notifies "<title> changed", in the title the reader had before it, and a cancellation "<title> was cancelled", after which the card reads "Cancelled: <title>". A rev 1 that settles or replaces a stand-in organizer, a duplicate and a stale revision do not notify, and answers never do. When the organizer's zone has a different UTC offset from the reader's at the start, the card also shows the organizer's time, with the weekday when the organizer's date differs ("Fri 7:00 PM New York time"). An event that lasts all day never shows it.</p> <p>Add to calendar opens the system's own screen for adding an event. Nothing is read from the calendar.</p> <p>A forwarded event (§6.16) is a new event: a fresh <code>ev.id</code>, rev 1, <code>"fwd":1</code>, no answers, and the person forwarding it as its organizer. A client MUST NOT offer to forward a cancelled event.</p> <p>In a backup the message carries <code>ev</code>, the wire object of the latest accepted revision, and beside it <code>org</code>, the organizer's user id as an integer, and <code>rsvp</code>, <code>{"<user id>":{"r":"yes","n":3}}</code>. Its <code>text</code> is the empty string (§16.2.1).</p> <p><strong>What the relay learns: nothing new.</strong> Events are submitted and announced as texts are, and answers are submitted <code>silent</code> (§8.4), as other control traffic is.</p> <p><strong>Status.</strong> Built in both apps for 0.32, not released (§18.1).</p> <h3 id="633-games-in-a-chat">6.33 Games in a chat</h3> <p>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.</p> <pre class="json"><code>{"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"}}</code></pre> <p><code>game</code> invites the other person and is renderable: the board is drawn on that message, so it carries <code>mid</code>. <code>gmove</code> and <code>gend</code> are control traffic. All three follow the rules §6.6 gives for the kinds added in 0.32.</p> <table> <thead> <tr> <th>Key</th> <th>Meaning</th> </tr> </thead> <tbody> <tr> <td><code>id</code></td> <td>The game. A non-empty string, the same in every envelope of the game.</td> </tr> <tr> <td><code>t</code></td> <td><code>chess</code>, <code>checkers</code>, <code>reversi</code> or <code>tictactoe</code>.</td> </tr> <tr> <td><code>mine</code></td> <td>The side the sender of this envelope plays: <code>first</code> or <code>second</code>. First is white in chess, dark in checkers, black in reversi and X in tic-tac-toe.</td> </tr> <tr> <td><code>log</code></td> <td>Every ply of the game so far, in order, in the notation below. Required and not empty on <code>gmove</code>; required on <code>gend</code>, where it may be empty. A <code>game</code> carries none.</td> </tr> <tr> <td><code>r</code></td> <td>On <code>gend</code> only: <code>resign</code>, <code>draw-offer</code>, <code>draw-accept</code>, <code>draw-decline</code>, <code>decline</code> or <code>cancel</code>.</td> </tr> </tbody> </table> <p>Keys are written in the order <code>id</code>, <code>t</code>, <code>mine</code>, <code>log</code>, <code>r</code>. A <code>game</code> whose object does not read (no <code>id</code> or an empty one, a <code>t</code> or a <code>mine</code> outside the lists, a <code>log</code> that is not a list of strings, a <code>game</code> that is not an object) draws the placeholder. A <code>gmove</code> or <code>gend</code> that does not read is dropped, and so is a <code>gmove</code> without a <code>log</code> or with an empty one, and a <code>gend</code> without a <code>log</code> or with an <code>r</code> outside the list. Games are one-to-one in this release: a <code>game</code> carrying <code>gid</code> draws the placeholder, a <code>gmove</code> or <code>gend</code> carrying <code>gid</code> is dropped, and a client does not offer a game in a group. A game is never forwarded.</p> <p><code>resign</code> ends the game, lost by the side that sent it. <code>decline</code> is the invitee saying no before their first move, and <code>cancel</code> is the starter withdrawing before the invitee's first move. <code>draw-offer</code> offers a draw, <code>draw-accept</code> takes one and ends the game drawn, and <code>draw-decline</code> 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.</p> <p><strong>Receiving a <code>gmove</code> or a <code>gend</code>.</strong> In this order:</p> <ol type="1"> <li><p>A game the conversation does not know is built from the envelope: its <code>t</code>, the sender's side from <code>mine</code>, and its log replayed from the start. The <code>game</code> may have been lost, or the device restored. A log that does not replay still makes the card, already out of step. A <code>decline</code> for a game not known makes the receiver the starter, since only the invitee can decline.</p></li> <li><p>A game that has ended, or is out of step, takes nothing more.</p></li> <li><p>The sender must play the other side of the same game. A <code>t</code> or a <code>mine</code> that disagrees with the game held puts it out of step.</p></li> <li><p>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 <code>gend</code>'s ending is still judged. A log that is not a prefix puts the game out of step.</p></li> <li><p>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.</p></li> <li><p>A <code>gend</code>'s ending is then judged at the log it carried, which is by now a prefix of the log held:</p> <ul> <li><code>resign</code> counts.</li> <li><code>draw-offer</code> counts 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.</li> <li><code>draw-accept</code> and <code>draw-decline</code> count only against an open offer from the receiver, and <code>draw-accept</code> only at the receiver's whole log, so an acceptance cannot race a move.</li> <li><code>decline</code> counts only from the invitee and <code>cancel</code> only from the starter, and each only while the log it carried holds no move by the invitee.</li> </ul> <p>An ending that does not count is ignored. Moves the same <code>gend</code> carried still count.</p></li> </ol> <p>Out of step is final for that game. The card says "Your boards disagree" and offers "Start a new game", which sends a fresh <code>game</code> of the same type with the same sides, and anything that arrives later for the old game is ignored.</p> <p>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 <code>pass</code> as an ordinary <code>gmove</code>, 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. <code>gmove</code> and <code>gend</code> 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 <code>game</code> itself is queued like any message.</p> <p><strong>Notation and rules.</strong> Both apps' engines must give the result <code>test/vectors/games-v1.json</code> gives for every case in it, and must match its perft counts, which are the published ones.</p> <ul> <li><strong>chess</strong>: UCI long algebraic notation, the promotion piece in lower case (<code>e7e8q</code>), castling as the king's move (<code>e1g1</code>). 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.</li> <li><strong>checkers</strong>: 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 <code>11-15</code> (a step), <code>15x24</code> (a jump) or <code>15x24x31</code> (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.</li> <li><strong>reversi</strong>: 8 by 8, files <code>a</code> to <code>h</code> left 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 (<code>d3</code>), or <code>pass</code>, 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.</li> <li><strong>tictactoe</strong>: cells <code>1</code> to <code>9</code> by rows from the top left. The first player is X. The game ends at three in a row or a full board.</li> </ul> <p>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.</p> <p>With previews on, an invitation is "<name> invited you to play chess", with a sound, and it counts as unread. A move is "Your move: chess with <name>", without a sound, and it does not count as unread. A draw offer is "<name> offers a draw: Chess", without a sound. An ending is "Chess with <name>: <result>", for example "Chess with Sam: They resigned. You won". A declined draw offer does not notify. <code>game</code>, <code>gmove</code> and <code>gend</code> 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".</p> <p>Chess, checkers and reversi turn round for the second player. Tic-tac-toe never does, so its cells keep their places on both phones.</p> <p>In a backup, the message that started the game carries <code>"game":{"id","t","me","log","r","rby"}</code>: <code>me</code> is the side this person plays, <code>log</code> the whole log, <code>r</code> the ending a <code>gend</code> brought (<code>resign</code>, <code>draw-accept</code>, <code>decline</code> or <code>cancel</code>) and <code>rby</code> the side that sent it, written only with <code>r</code>. An open draw offer and an out-of-step game are not carried: a restored game is replayed from its log. The message's <code>text</code> is the empty string (§16.2.1).</p> <p><strong>What the relay learns.</strong> 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).</p> <p><strong>Status.</strong> Built in both apps for 0.32, not released (§18.1).</p> <h3 id="634-hello-links">6.34 Hello links</h3> <p>A link a person can publish so that whoever opens it can ask to message them:</p> <pre><code>https://flamenetmessenger.com/hi#fnid:<52 characters>@<relay host></code></pre> <p>After the <code>#</code> 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.</p> <p><strong>Why the fragment.</strong> A browser does not send the part of a link after <code>#</code> 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.</p> <p>A receiver accepts exactly these forms:</p> <ul> <li><code>https://flamenetmessenger.com/hi#<address></code> and <code>https://flamenetmessenger.com/es/hi#<address></code>, the published links;</li> <li><code>flamenetmessenger://hi#<address></code>, the app's own scheme, which the page links to;</li> <li>in a scanner or a paste, a bare <code><address></code>, or <code>flamenet:<address></code> as older address cards hold it, ignoring anything from a <code>?</code> on.</li> </ul> <p>Before the <code>#</code>, the scheme and the host are compared without regard to case. The path is exactly <code>/hi</code> or <code>/es/hi</code>, in lower case and with no trailing slash, and the app link has no path at all, so <code>flamenetmessenger://hi/#…</code> 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.</p> <p>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 <code>fnid:</code> and 52 characters of <code>0-9</code>, <code>a-h</code>, <code>j</code>, <code>k</code>, <code>m</code>, <code>n</code>, <code>p-t</code> and <code>v-z</code>, then one <code>@</code>, then a host made of the ASCII letters <code>a</code> to <code>z</code>, digits, hyphens and dots, with at least one dot. There is no space anywhere in an address, around the <code>@</code> included. The host is lowercased by ASCII rules alone: <code>A</code> to <code>Z</code> become <code>a</code> to <code>z</code> 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 <code>k</code> 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.</p> <p><code>test/vectors/hello-link-v1.json</code> gives inputs in each form and the address or refusal each must give.</p> <p>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 <code>?u=</code> on an older card.</p> <p><strong>The page.</strong> <code>GET /hi</code> and <code>GET /es/hi</code> 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 <code>location.hash</code>, checks it by the rule above, and shows the address with "Open in Flamenet Messenger" (<code>flamenetmessenger://hi#<address></code>), 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.</p> <p>The address card shows the hello link with copy and share, and its QR code holds the <code>https</code> link.</p> <p><strong>What it gives away.</strong> 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.</p> <p><strong>Status.</strong> Built in both apps and on the relay for 0.32, not released (§18.1). The vector file's <code>history</code> records the inputs on which an app's feature branch did not follow this section.</p> <h3 id="635-taking-a-story-down">6.35 Taking a story down</h3> <p>Specified for 0.32; implementation in progress.</p> <p>The author of a story MAY take it down for everyone it went to:</p> <pre class="json"><code>{"fnc":1,"kind":"unpost","ctl":1,"st":1,"sid":"<story id>"} {"fnc":1,"kind":"unpost","ctl":1,"st":1,"set":"<set id>"}</code></pre> <p><code>sid</code> 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 <code>mid</code> of a text story. It is the value a backup calls the story's <code>key</code> (§16.2.1). A story of several items (§6.23) comes down whole, with <code>set</code> in place of <code>sid</code>; in this envelope <code>set</code> is the set's id as a string, not §6.23's object. An <code>unpost</code> carries exactly one of the two, and one carrying both or neither is dropped. It carries no <code>gid</code>, 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.</p> <p>A receiver applies it only to a story it holds and attributes to the sender of the <code>unpost</code>, 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 <code>set</code>, every item of that set from that author goes. An <code>unpost</code> naming a story the receiver does not hold is dropped. It never notifies. The author's own copy goes at once.</p> <p><strong>What it does not do.</strong> 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.</p> <h2 id="7-safety-numbers-verification">7. Safety numbers (verification)</h2> <p>For a local identity <code>IK_sig_local_pub</code> and remote <code>IK_sig_remote_pub</code> (raw 32-byte Ed25519):</p> <pre><code>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</code></pre> <p>The displayed <strong>safety number</strong> is <code>sorted([fingerprint_local, fingerprint_remote])</code> 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).</p> <p><strong>With the post-quantum identities (§20).</strong> When a client holds both its own <code>ik_pq</code> and a pinned <code>ik_pq</code> 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 <code>pq = SHA-256(ik_pq)</code>:</p> <pre><code>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) )</code></pre> <p>concatenated in the order of the <strong>Ed25519</strong> keys, as above. When either <code>pq</code> is missing the number is the Ed25519 one. <code>test/vectors/protocol-v1.json</code> pins both (<code>safetyNumber</code>, <code>safetyNumberPQ</code>).</p> <p>A client pins a device's <code>ik_pq</code> 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.</p> <hr /> <h2 id="8-server-rest-contract">8. Server REST contract</h2> <p>Base URL: <code>https://<relay>/e2e</code>. The routes are also mounted under <code>https://<relay>/api/flamenet/v1/e2e</code> as a compatibility alias; the proof signature commits to the <code>/e2e/*</code> path either way. Namespace <code>flamenet/v1</code>. All routes below are under <code>/e2e</code>.</p> <p><strong>Auth:</strong> 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 <strong>either</strong> <code>Authorization: Bearer <token></code>, a short-lived token scoped to one device and one relay, (web client). No subscription tier is required. The authenticated user is "me".</p> <p>All request bodies are JSON. All responses are JSON. Base64 fields are standard base64.</p> <h3 id="81-post-e2edevices-register--update-my-device">8.1 <code>POST /e2e/devices</code>, register / update my device</h3> <p>Request:</p> <pre class="json"><code>{ "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 }</code></pre> <p>Upserts the device for the current user (keyed by <code>device_id</code>; a device_id may only ever belong to one user). Stores/replaces the identity + signed prekey, and inserts any supplied one-time prekeys. Response: <code>{ "ok": true, "device_id": "...", "opk_remaining": 100 }</code>.</p> <h3 id="82-get-e2ekeysuser_id-fetch-prekey-bundles-to-start-sessions">8.2 <code>GET /e2e/keys/{user_id}</code>, fetch prekey bundles to start sessions</h3> <blockquote> <p><strong>Rate limited.</strong> 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 (<code>FN_BUNDLE_RATE</code>, 600 by default) and per requester-target pair (<code>FN_BUNDLE_RATE_TARGET</code>, 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 <strong>not</strong> 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.</p> <p>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.</p> <p>From stage C1 a first contact gets its bundles from <code>POST /e2e/intro</code> (§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, <strong>atomically pops</strong> one unused one-time prekey (marks it used) and returns the bundle. Response:</p> </blockquote> <pre class="json"><code>{ "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 } ] }</code></pre> <h3 id="83-get-e2edevicesuser_id-list-identity-keys-no-prekey-consumption">8.3 <code>GET /e2e/devices/{user_id}</code>, list identity keys (no prekey consumption)</h3> <p>For addressing + safety-number recompute + key-change detection. Response:</p> <pre class="json"><code>{ "user_id": 12, "devices": [ { "device_id": "...", "ik_dh": "<b64>", "ik_sig": "<b64>" } ] }</code></pre> <p>The relay also returns <code>fnid</code>, and per device <code>proto</code> and <code>caps_sig</code>. Until stage D1 it also returns <code>created_at</code> and <code>last_seen</code> 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 <code>GET /e2e/devices/me</code> (§8.13) is the route for them.</p> <h3 id="83a-delete-e2edevicesdevice_id-revoke-one-of-my-own-devices">8.3a <code>DELETE /e2e/devices/{device_id}</code>, revoke one of my own devices</h3> <p>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 <code>403</code>, not a silent no-op. <code>404</code> when no such device exists. Response: <code>{ "ok": true, "device_id": "..." }</code>.</p> <p>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.</p> <p>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.</p> <h3 id="84-post-e2emessages-submit-sealed-envelopes">8.4 <code>POST /e2e/messages</code>, submit sealed envelopes</h3> <p>The client encrypts the same logical message once per recipient device and submits them together. Request:</p> <pre class="json"><code>{ "to": 12, "messages": [ { "to_device": "<uuid>", "payload": "<b64-envelope>" }, ... ] }</code></pre> <p>Server validates <code>to</code> is a real user, then stores one row per envelope. It does <strong>not</strong> inspect <code>payload</code>. Response: <code>{ "ok": true, "ids": [101, 102] }</code>.</p> <p>Each <code>payload</code> 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 <code>400</code> and the code <code>unpadded</code> (§23.11).</p> <p>A submission MAY also carry <code>urgent</code> (§6.14) or <code>silent</code>:</p> <pre class="json"><code>{ "to": 12, "silent": true, "messages": [ ... ] }</code></pre> <p><code>silent</code> 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. <code>urgent</code> wins when both are set. Like <code>urgent</code>, 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).</p> <h3 id="85-get-e2emessagesdevice_iduuidafteridlimitn-poll-my-inbox">8.5 <code>GET /e2e/messages?device_id={uuid}&after={id}&limit={n}</code>, poll my inbox</h3> <p>Returns undelivered envelopes addressed to <code>device_id</code> with <code>id > after</code>, oldest first, marks them delivered, and returns a cursor. Response:</p> <pre class="json"><code>{ "messages": [ { "id": 101, "from_user": 7, "from_device": "<uuid>", "payload": "<b64-envelope>", "created_at": "2026-06-05T12:00:00Z" } ], "cursor": 101 }</code></pre> <p>The client persists <code>cursor</code> and passes it as <code>after</code> next poll.</p> <p><strong>Byte cap.</strong> A relay MAY return fewer than <code>limit</code> envelopes when more would pass a size cap (RECOMMENDED: 2 MiB of payload), and then adds <code>"more": true</code>. The field is present only when the poll stopped early, and absent, never <code>false</code>, 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 <code>more</code> loses nothing; it gets the rest on its next poll.</p> <h3 id="86-post-e2eprekeys-replenish-one-time-prekeys">8.6 <code>POST /e2e/prekeys</code>, replenish one-time prekeys</h3> <p>Request: <code>{ "device_id": "<uuid>", "prekeys": [ { "id": 101, "pub": "<b64>" }, ... ] }</code>. Response: <code>{ "ok": true, "opk_remaining": 118 }</code>.</p> <h3 id="87-post-e2eattachments-upload-a-sealed-attachment-blob">8.7 <code>POST /e2e/attachments</code>, upload a sealed attachment blob</h3> <p>Body is the <strong>raw ciphertext blob</strong> (<code>Content-Type: application/octet-stream</code>), not JSON, the <code>nonce || ct || tag</code> 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.</p> <ul> <li>Size cap: <strong>10 MiB</strong> per blob (larger → <code>413</code>). That is also the top of the attachment ladder, so a padded blob is always one of the ladder's nine lengths (§23.9).</li> <li>Rate limit: rolling per-user hourly caps on count and total bytes.</li> </ul> <p>Response: <code>{ "ok": true, "id": "<64-hex token>", "bytes": 123456 }</code>. The <code>id</code> is a server-generated random token, possession of the id is the download capability, which is why it only ever travels inside the ratchet.</p> <h3 id="88-get-e2eattachmentsid-download-a-sealed-attachment-blob">8.8 <code>GET /e2e/attachments/{id}</code>, download a sealed attachment blob</h3> <p>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 <code>application/octet-stream</code>. <code>404</code> after retention expiry (§9). The blob comes back as it was uploaded, tail included; the receiver opens the first <code>att.clen</code> bytes (§23.9).</p> <h3 id="89b-sealed-group-rosters">8.9b Sealed group rosters</h3> <p>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.</p> <p>Storage, per group:</p> <table> <thead> <tr> <th>column</th> <th>holds</th> </tr> </thead> <tbody> <tr> <td><code>gid</code></td> <td>the group id</td> </tr> <tr> <td><code>owner_key</code></td> <td>Ed25519 public key. The owner proves by signature; no user id.</td> </tr> <tr> <td><code>roster_blob</code></td> <td>opaque bytes. The member list, encrypted under the group key.</td> </tr> <tr> <td><code>roster_version</code></td> <td>monotonic counter, to refuse rollback</td> </tr> </tbody> </table> <p>Plus <code>group_tags(gid, tag)</code>, one opaque 32-byte token per member, which is the access control. No user id appears in any group table.</p> <p>The blob's plaintext is padded to a fixed size (§23.10), so its length says nothing about the group. The number of <code>group_tags</code> rows still does: one per member.</p> <ul> <li><p><strong><code>POST /e2e/groups/sealed</code></strong>: create. Request: <code>{ "gid": "...", "owner_key": "...", "roster_blob": "...", "tags": ["..."], "sig": "..." }</code>. The signature covers <code>fngroup-v1|<gid>|<version>|<blob></code>.</p> <p><strong>The client chooses <code>gid</code></strong>, 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.</p></li> <li><p><strong><code>POST /e2e/groups/fetch</code></strong>: request the blobs for the tags you hold: <code>{ "tags": ["..."] }</code>. Returns only groups where a tag matches. A client keeps its own <code>gid → tag</code> map; it never asks "what groups am I in", because the relay cannot answer that.</p></li> <li><p><strong><code>POST /e2e/groups/{gid}/roster</code></strong>: 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.</p></li> <li><p><strong><code>POST /e2e/groups/{gid}/leave</code></strong>: presents a tag, which is deleted. The last tag out deletes the group.</p></li> </ul> <p><strong>Reachability moved to the client.</strong> 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.</p> <h4 id="what-this-earns-and-what-it-does-not">What this earns, and what it does not</h4> <p>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 <em>stored</em>, not what is <em>observable</em>.</p> <p>The claim this earns is precisely <strong>"a database dump does not reveal your groups"</strong>, 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.</p> <p>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.</p> <blockquote> <p><strong>Shipped, and demonstrated rather than asserted</strong> (§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 <code>e90ba1b</code>, Android <code>0006d82</code>.</p> </blockquote> <p><strong>Losing your tags loses your groups.</strong> 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.</p> <p>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 <code>owner_key</code> 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.</p> <h3 id="89c-sealed-contact-lists">8.9c Sealed contact lists</h3> <p>The relay held a plaintext contact graph, <code>buddies(user_id, buddy_id)</code>, 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.</p> <p>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.</p> <p>Storage:</p> <table> <thead> <tr> <th>column</th> <th>holds</th> </tr> </thead> <tbody> <tr> <td><code>user_id</code></td> <td>whose list this is. The subject, never the contact.</td> </tr> <tr> <td><code>digest</code></td> <td>base64 SHA-256 of a 32-byte token. No user id, no handle.</td> </tr> </tbody> </table> <ul> <li><p>The subject generates one random 32-byte token per contact and sends it to that contact over the encrypted channel, riding the <code>delivery_key</code> control envelope (§14.4), which is already per-contact.</p></li> <li><p><strong><code>POST /messenger/roster/tags</code></strong>: <code>{ "digests": ["..."] }</code>. 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.</p></li> <li><p><strong><code>POST /messenger/presence</code></strong>: <code>{ "tokens": ["..."] }</code>. The relay hashes each token and looks for the digest. The answer is keyed by <strong>the token that was presented</strong>, never by an account, so the route names nobody.</p></li> <li><p><strong><code>GET /messenger/buddies</code></strong> gains <code>roster_on_server</code>, a boolean. A relay that no longer keeps a contact graph answers with an empty list AND this set false.</p> <p>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.</p> <p><strong>Absent reads as true.</strong> 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.</p></li> </ul> <p>A token that matches nothing gets <code>offline</code>, 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.</p> <p><strong>Holding the token is the permission.</strong> 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.</p> <p><strong>The old path stays until nothing uses it.</strong> An account that has published no tokens is still answered from <code>buddies</code>, 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. <code>buddies</code> is dropped in a later release, once no client reads it.</p> <h4 id="what-this-earns-and-what-it-does-not-1">What this earns, and what it does not</h4> <p>A dump of <code>roster_tags</code> 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: <strong>a database dump does not reveal your contacts</strong>, not "the server cannot see who you talk to".</p> <p>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 <em>stored</em>, not what is <em>observable</em>. §22 is what the second claim would need.</p> <p>The contact <strong>count</strong> 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.</p> <p>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 <code>buddies</code> 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.</p> <blockquote> <p><strong>Two devices on one account overwrite each other's tags (2026-10-07).</strong> <code>POST /messenger/roster/tags</code> replaces 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 <strong>and device</strong>, and says so with <code>features.perDeviceDigests</code> in its descriptor (§17.6). A client that sees it sends <code>device_id</code> with 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.</p> </blockquote> <h3 id="89d-contact-state-the-relay-no-longer-holds">8.9d Contact state the relay no longer holds</h3> <p>§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.</p> <p><strong>Accepted conversations.</strong> 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 <code>acc</code> (§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.</p> <p><strong>Starred and archived conversations.</strong> 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 <code>conv/…</code> sync registers (§17.6) and in backups as <code>convFlags</code> (§16.2.1). From stage 2-A the relay stops writing them and clears what it holds, and <code>POST /api/flamenet/v1/messenger/conversation</code> answers a request that sets either with <code>409</code> and the code <code>kept_on_device</code>. 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.</p> <p><strong>Blocks.</strong> 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:</p> <ul> <li>the client holds its own block list, travels it in backups as <code>blocked</code> (§16.2.1), and uses it for display and when adopting state on a new device;</li> <li>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;</li> <li>the relay keeps enforcing blocks on unsealed submissions until a per-account <code>sealed_inbound_only</code> setting 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;</li> <li>only then does the relay's block table go.</li> </ul> <p>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.</p> <p><strong>Buddy-add notifications.</strong> 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.</p> <p><strong>Adding a contact.</strong> With the contact list off the relay (<code>roster_on_server</code> 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.</p> <p><strong>Legacy groups.</strong> §8.9's registry is deprecated; its retirement is described there.</p> <p><strong>Invitation provenance.</strong> 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.</p> <p><strong>A new device.</strong> A backup (§16) restores the roster, <code>acc</code>, <code>blocked</code>, <code>convFlags</code>, 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.</p> <p><strong>Self-hosted relays</strong> opt in to the drops with <code>FN_DROP_CONTACT_GRAPH=1</code>. Without it a relay keeps the old tables, so an operator who has not read this is not surprised by it.</p> <p><strong>Order.</strong> No step that destroys data comes before the clients that replace it, and each destructive step is its own deploy:</p> <table> <thead> <tr> <th>Stage</th> <th>What</th> <th>Reversible</th> </tr> </thead> <tbody> <tr> <td>2-A</td> <td>Relay: invitation provenance cleared at redemption and backfilled; notification retention; star and archive no longer stored, and cleared.</td> <td>the code, yes; the cleared values were read by nothing</td> </tr> <tr> <td>2-B</td> <td>Both apps: local <code>acc</code>, 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.</td> <td>yes</td> </tr> <tr> <td>2-C</td> <td>Relay switches, a week apart: the contact list off the relay, then legacy groups read-only, then off.</td> <td>yes</td> </tr> <tr> <td>2-D</td> <td>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.</td> <td>no</td> </tr> <tr> <td>2-E</td> <td>Blocks: rotation on block, <code>sealed_inbound_only</code>, a first-contact path under it, then the block table.</td> <td>the last step, no</td> </tr> </tbody> </table> <p>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 <code>buddies</code>.</p> <h4 id="what-this-earns-and-what-it-does-not-2">What this earns, and what it does not</h4> <p>The same claim as §8.9b and §8.9c, for more of the tables: <strong>a database dump does not reveal your contacts</strong>. 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.</p> <h3 id="89-group-registry">8.9 Group registry</h3> <blockquote> <p><strong>Deprecated.</strong> 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 (<code>FN_LEGACY_GROUPS</code> = <code>on</code>, <code>readonly</code>, <code>off</code>), 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.</p> </blockquote> <p>Plaintext metadata only (§6.7), the server never relays a group message differently from a 1:1 message and cannot tell them apart.</p> <ul> <li><p><strong><code>POST /e2e/groups</code></strong>: create. Request: <code>{ "members": [12, 34] }</code>. The creator is always a member and the owner. Cap: <strong>32 members</strong> including the creator (fan-out cost is per-member; see §6.7).</p> <p>A <code>name</code> MAY 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).</p> <p>Every member must be <strong>reachable</strong>: 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 <em>was</em> 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: <code>{ "ok": true, "gid": "<64-hex>", "name": "Group", "members": [...] }</code>, where <code>name</code> is the fixed placeholder described below.</p></li> <li><p><strong><code>GET /e2e/groups</code></strong>: list every group I am a member of, with rosters: <code>{ "groups": [ { "gid": "...", "name": "Group", "owner": 7, "members": [ { "user_id": 7 }, ... ] } ] }</code>.</p> <blockquote> <p><code>name</code> is 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.</p> <p>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).</p> </blockquote> <blockquote> <p><strong>Deviation, deliberate.</strong> The earlier implementation also returned a <code>name</code> per member, joined from its user table. It could do that because it <em>was</em> 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.</p> </blockquote></li> <li><p><strong><code>POST /e2e/groups/{gid}/members</code></strong>: owner only. Request: <code>{ "add": [56], "remove": [34] }</code>. The owner cannot remove themselves (use leave, which transfers or dissolves). Response: the updated group object.</p></li> <li><p><strong><code>POST /e2e/groups/{gid}/leave</code></strong>: remove myself. If the owner leaves, ownership passes to the longest-standing remaining member; the last member leaving deletes the group. Response: <code>{ "ok": true }</code>.</p></li> </ul> <p>Senders fan out to the roster as of send time. Receivers attribute by envelope <code>from_user</code>, which the transport already authenticates via the ratchet session.</p> <h3 id="810-get-e2eturn-turn-relay-credentials">8.10 <code>GET /e2e/turn</code>, TURN relay credentials</h3> <p>Mints time-limited credentials for the coturn relay using the standard REST-secret scheme (<code>username = <unix expiry>:<opaque></code>, <code>credential = base64(HMAC-SHA1(secret, username))</code>). 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:</p> <pre class="json"><code>{ "urls": [ "stun:turn.flamenet.io:3478", "turn:turn.flamenet.io:3478?transport=udp" ], "username": "1787260000:9f86d081884c7d659a2feaa0c55ad015", "credential": "<b64>", "ttl": 7200 }</code></pre> <p>The relay never sees plaintext, it forwards DTLS-SRTP ciphertext between peers that authenticated each other through the ratchet (§6.8).</p> <h3 id="811-post-e2epushtoken-waking-an-iphone">8.11 <code>POST /e2e/push/token</code>, waking an iPhone</h3> <p>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.</p> <pre class="json"><code>{ "device_id": "<uuid>", "platform": "apns", "token": "<APNs device token>", "voip_token": "<PushKit token>" }</code></pre> <ul> <li><code>device_id</code> MUST be the caller's own device. A request naming another device is refused.</li> <li><code>platform</code> is <code>apns</code>, the only platform stored. Anything else is <code>400</code>.</li> <li><code>voip_token</code> is optional. It is the token that can ring a locked phone for a call (§6.14).</li> <li>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.</li> <li>An empty <code>token</code> with no <code>voip_token</code> forgets the device. The app sends this when it signs out.</li> </ul> <p>Response: <code>{ "ok": true }</code>.</p> <p><strong>What the relay sends.</strong> On each submission (§8.4) to a device it holds a row for:</p> <ul> <li><code>urgent</code> (§6.14): a VoIP push to <code>voip_token</code>, which rings the phone. Each attempt is recorded for seven days, with the account, the device, the time and the outcome.</li> <li><code>silent</code> (§8.4): a background push that wakes the app and shows nothing.</li> <li>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).</li> </ul> <p>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.</p> <p><strong>What it stores and reveals.</strong> 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.</p> <p><strong>Status.</strong> Implemented on the relay and in the iPhone app.</p> <h3 id="812-get-todayjson-the-today-file">8.12 <code>GET /today.json</code>, the Today file</h3> <p>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 <code>/e2e</code>.</p> <p>The envelope:</p> <pre class="json"><code>{"v":1,"kid":"a9a94e74","body":"<base64 of the body's UTF-8 JSON>","sig":"<base64 Ed25519 signature over the body bytes>"}</code></pre> <p><code>kid</code> 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 <code>o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA=</code> and kid <code>a9a94e74</code>, 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.</p> <p>The body:</p> <pre class="json"><code>{"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"]}</code></pre> <ul> <li><code>date</code>, a day written <code>yyyy-MM-dd</code>, and <code>made</code>, 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.</li> <li>A headline needs <code>t</code> and <code>u</code>; its <code>u</code> is the story's page on flamenet.io and <code>src</code> names the publisher. A market needs <code>name</code> and <code>last</code>, and <code>sym</code> is optional. A city needs <code>city</code>. A day needs <code>d</code>, a real day written <code>yyyy-MM-dd</code>, and <code>hi</code> and <code>lo</code>. <code>site</code> and <code>post</code> need <code>t</code> and <code>u</code>. An entry without what it needs is left out, and so is one whose link is not <code>http</code> or <code>https</code> with a host.</li> <li>Bounds: 20 headlines, 12 markets, 150 cities, 5 days to a city and 5 notes. <code>t</code> is drawn to at most 200 characters, <code>d</code> and the site's <code>note</code> to 300, each of <code>notes</code> to 200, <code>src</code>, <code>by</code>, <code>name</code> and <code>city</code> to 80, and <code>sym</code>, <code>last</code>, <code>chg</code> and <code>pct</code> to 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.</li> <li><code>cc</code> is two ASCII letters, and is ignored otherwise. <code>tz</code> is 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. <code>lat</code> and <code>lon</code> are optional, and are ignored when out of range.</li> <li><code>sky</code> is <code>clear</code>, <code>partly</code>, <code>cloudy</code>, <code>fog</code>, <code>drizzle</code>, <code>rain</code>, <code>snow</code> or <code>storm</code>, and another value draws no sky. Temperatures are whole degrees Celsius, shown in °C or °F by the device's locale.</li> <li>Prices are strings exactly as they are to be shown. The app does no arithmetic on them.</li> <li><code>note</code> labels <code>site.d</code>, saying whose words it is. <code>post</code> is absent until FlameNet's own blog has a post.</li> <li>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.</li> </ul> <p><strong>Checking it.</strong> A client refuses a file for one of six reasons, checked in this order:</p> <ul> <li><code>too-large</code>: the envelope is over 512 KB.</li> <li><code>malformed</code>: it is not a JSON object, the envelope has no <code>v</code> (of any type), its <code>kid</code>, <code>body</code> or <code>sig</code> is missing or not a string, or its <code>body</code> is not base64.</li> <li><code>unknown-version</code>: a <code>v</code> is present and is not exactly the integer 1, in the envelope or in the body.</li> <li><code>unknown-key</code>: no pinned key has that <code>kid</code>.</li> <li><code>bad-signature</code>: <code>sig</code> is not base64 of 64 bytes, or does not verify over the body bytes.</li> <li><code>bad-body</code>: the body is not a JSON object, has no <code>v</code>, or has no real day written <code>yyyy-MM-dd</code> in <code>date</code> or no integer <code>made</code>.</li> </ul> <p>The reasons are for logs and tests; a person sees one plain line whatever the reason. A client never takes a verified file whose <code>made</code> 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.</p> <p><code>test/vectors/today-v1.json</code> holds the sample FlameNet's builder signed with the development key, and a signed case for each refusal.</p> <p><strong>The route.</strong> <code>GET /today.json</code> serves the last envelope the relay verified, its bytes unchanged:</p> <ul> <li><code>200</code>, with <code>Content-Type: application/json</code>, <code>Cache-Control: public, max-age=900</code> and an <code>ETag</code> that is the quoted lower-case hex SHA-256 of the bytes served. A request whose <code>If-None-Match</code> holds that tag, or <code>*</code>, gets <code>304</code>.</li> <li><code>404</code> and <code>{"error":"not_offered"}</code> when the relay offers no Today file. <code>503</code> and <code>{"error":"not_ready"}</code> when it offers one and nothing has verified yet. Both carry <code>Cache-Control: no-store</code>.</li> </ul> <p>The route reads no credential, no cookie and no query string, and the relay writes no log line for a request.</p> <p>The relay reads the file from <code>FN_TODAY_FILE</code>, a path it reads again whenever the file changes, or fetches it from <code>FN_TODAY_SOURCE</code>, an <code>http</code> or <code>https</code> 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 <code>FN_TODAY_KEYS</code> 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 <code>FN_TODAY_SOURCE</code> at it. The relay's configuration document has the rest.</p> <p><strong>Fetching it.</strong> An app asks only its home relay, over the transport it uses for everything else, a bridge included, with no <code>Authorization</code>, 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 <code>404</code>, the app shows only the directory, with the line "Today's page is not offered by this relay."</p> <p>A client sends <code>If-None-Match</code> 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.</p> <p><strong>What the relay learns.</strong> 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.</p> <p><strong>Status.</strong> 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.</p> <h3 id="813-anonymous-reads">8.13 Anonymous reads</h3> <p>Status: <strong>planned</strong>, stage D1 on the relay and D2 in the apps. Nothing here exists yet.</p> <p>Every read in §8.2 and §8.3 is authenticated and names its target in the URL (<code>/e2e/keys/{user_id}</code>, <code>/e2e/devices/{user_id}</code>), 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 <code>/metadata</code> page has the current figures). And <code>GET /e2e/devices/{user_id}</code> 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).</p> <p>So the reads a client makes about other people move to <strong>unauthenticated POST routes with the target in the body</strong>:</p> <table> <thead> <tr> <th>Route</th> <th>Body</th> <th>Answers</th> <th>Authorised by</th> </tr> </thead> <tbody> <tr> <td><code>POST /e2e/anon/presence</code></td> <td><code>{"tokens": [≤ 8 tokens]}</code></td> <td>§8.9c's presence answer, keyed by token</td> <td>holding the tokens; nothing else. A ninth token is refused</td> </tr> <tr> <td><code>POST /e2e/anon/devices</code></td> <td><code>{"user_id": 12, "cap": "<b64>"?}</code></td> <td>identity keys only: <code>device_id</code>, <code>ik_dh</code>, <code>ik_sig</code>, <code>proto</code>, <code>caps_sig</code>; never <code>created_at</code> or <code>last_seen</code></td> <td><code>cap</code>, a presence token the subject issued the caller, which becomes the meter; without one, a per-IP meter</td> </tr> <tr> <td><code>POST /e2e/anon/bundles</code></td> <td><code>{"user_id": 12, "cap": "<b64>"?}</code></td> <td>§8.2's bundles, popping prekeys as that route does</td> <td>as for devices, with a per-IP and per-(IP, account) meter when there is no <code>cap</code></td> </tr> <tr> <td><code>POST /e2e/intro</code></td> <td><code>{"handle": …, "challenge": …, "nonce": …}</code> or <code>{"link": …}</code></td> <td>§14.5</td> <td>for a handle, a meter per IP and per network and a proof of work; for a link, the link (decisions DD1, DD2)</td> </tr> </tbody> </table> <p>A <code>cap</code> 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.</p> <p><strong>The proof of work</strong> on an introduction by handle is the relay's signup challenge, reused (stage C1), with its own secret. <code>GET /e2e/intro/challenge</code> answers <code>{"challenge": "<expiry>.<salt>.<mac>", "bits": n, "expires_in": 600}</code>; the client finds a decimal <code>nonce</code> for which <code>SHA-256(challenge || "." || nonce)</code> begins with at least <code>n</code> zero <strong>bits</strong>, and sends both with the handle. A challenge is the relay's own (it carries a MAC), expires, works once, and is refused when <code>bits</code> are not met, with the code <code>challenge_failed</code>. A relay sets <code>bits</code> with <code>FN_INTRO_POW_BITS</code> (16 by default; 0 turns the work off). An introduction by link needs none: the link is already a secret.</p> <p>A client's own devices, with their registration and last-poll times, are read from <code>GET /e2e/devices/me</code>, which is authenticated and answers about the caller only.</p> <p>The client side is part of the design, not a detail. Requests to these routes go through a <strong>separate HTTP client</strong> with no <code>Authorization</code> header, no cookies, its own connection pool, so a connection that carried an authenticated request never carries an anonymous one, and a generic <code>User-Agent</code>. 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.</p> <p><strong>What the relay still learns.</strong> 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 <strong>stored</strong>: 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.</p> <p>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).</p> <h3 id="814-unlisted-accounts-and-contact-links">8.14 Unlisted accounts and contact links</h3> <p>Status: <strong>planned</strong>, stages B1 to B4; it depends on §14.5's intro capabilities. Nothing here exists yet.</p> <p>A username is how a stranger finds you. Today it is also the only way, and five routes turn a username into an account:</p> <ol type="1"> <li><code>GET /api/flamenet/v1/messenger/users?slug=</code> (authenticated, 20 lookups per caller per window);</li> <li><code>POST /api/flamenet/v1/messenger/buddies</code>, adding by name;</li> <li><code>GET /e2e/account/identity/{handle}</code> (unauthenticated, 300 per IP per hour);</li> <li><code>GET /api/flamenet/v1/messenger/users/{id}</code>, which walks sequential ids to names;</li> <li>signup, which says a name is taken, or too like one that is.</li> </ol> <p><strong>Listed and Unlisted</strong> (decision DB1). A <strong>listed</strong> account answers all five as it does now. An <strong>unlisted</strong> account answers doors 1 to 4, and <code>POST /e2e/intro</code> by handle, <strong>exactly as for a name that does not exist</strong>: 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).</p> <p>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 <strong>"people cannot find or add you by your username"</strong>, 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.</p> <p>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 <code>sealed_only</code> (DB5); the sender already knows the account exists, so naming the reason costs nothing.</p> <p><strong>Contact links</strong> (DB3) are how anybody reaches an unlisted account, and a listed one without a username search:</p> <ul> <li>a <strong>one-time</strong> link, usually a QR code shown in person, carries a <strong>delivery token</strong> (§21.4) the recipient minted for it. The recipient uploads its hash with <code>POST /e2e/delivery-tokens</code>, among its other tokens, so the relay holds nothing that marks it as a link. The person who scans it presents it as <code>delivery_token</code> on their first message, which spends it; no introduction is involved, and they read the bundles from <code>POST /e2e/anon/bundles</code> (§8.13). Revoking an unused one is revoking that token.</li> <li>a <strong>reusable</strong> link: 32 random bytes, good until revoked, at most five at a time, registered with <code>POST /e2e/contact-links</code> <code>{"add": [digests], "revoke": [digests]}</code>. The relay stores only <code>SHA-256</code> of each, against the account, with no creation time and no record of who used one. A sixth is refused with <code>409</code>, 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.</li> </ul> <p>A link travels in one of two forms (DB6):</p> <pre><code>https://<relay>/add#u=<user id>&t=<token>&h=<handle> flamenet:<fnid>@<relay>?u=<user id>&t=<token>&h=<handle></code></pre> <p><code>t</code> is a one-time link's delivery token and <code>l</code> replaces it for a reusable link; exactly one of the two is present. Byte strings are base64url without padding, and <code>h</code> is percent-encoded. <code>h</code> 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 <code>u</code> differs from the account the intro answers for is refused. A one-time link involves no intro, so nothing vouches for <code>h</code> 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.</p> <p>The web form keeps the payload in the <strong>fragment</strong>, after <code>#</code>, which a browser never sends: the relay's <code>/add</code> page, and anything logging requests in front of it, receives <code>GET /add</code> and nothing else, and the page's script hands the fragment to the app. The <code>flamenet:</code> form is for QR codes, and extends the federated contact card (<code>flamenet:<fnid>@<relay></code>) so an older app that reads only the address still gets one; an account with no fnid writes nothing before the <code>@</code>, which an older app refuses rather than misreads. <code>test/vectors/links-v1.json</code> pins both forms, what a reader must accept, and what it must refuse.</p> <h3 id="error-shape">Error shape</h3> <p>Standard WP REST error: <code>{ "code": "...", "message": "...", "data": { "status": 4xx } }</code>, with <code>retry_after</code> in seconds on a <code>429</code>. Two answers have another shape: a path the relay has no route for answers <code>{"error": true, "reason": "Not Found"}</code>, and <code>GET /today.json</code> (§8.12) refuses with <code>{"error": "not_offered"}</code> or <code>{"error": "not_ready"}</code>.</p> <hr /> <h2 id="9-storage-server-tables-all-opaque">9. Storage (server tables, all opaque)</h2> <ul> <li><p><code>{p}fnmsg_e2e_devices</code>, one row per device (identity + current signed prekey).</p></li> <li><p><code>{p}fnmsg_e2e_prekeys</code>, one-time prekey pool, <code>used</code> flag.</p></li> <li><p><code>{p}fnmsg_e2e_envelopes</code>, sealed message queue, <code>delivered</code> flag. 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></li> <li><p><code>{p}fnmsg_e2e_attachments</code>, sealed blob index: <code>token</code>, uploader, size, file path, <code>created_at</code>. Once attachment padding is on, the size is a ladder value (§23.9). The blob bytes live on disk under <code>uploads/fnmsg-e2e/</code>, direct web access denied; the REST route is the only reader.</p></li> <li><p><code>devices</code>, one row per device (identity + current signed prekey).</p></li> <li><p><code>prekeys</code> and <code>pq_prekeys</code>, the one-time prekey pools, each with a <code>used</code> flag.</p></li> <li><p><code>envelopes</code>, the sealed message queue, with a <code>delivered</code> flag and an <code>announced</code> one (below).</p></li> <li><p><code>attachments</code>, the sealed blob index: <code>token</code>, <code>bytes</code> and <code>created_at</code>, and no uploader. The blob bytes live in files in the relay's attachments directory (<code>FN_ATTACHMENTS_DIR</code>), with no direct web access; the REST route is the only reader.</p></li> <li><p><code>push_tokens</code>, one row per iPhone that asked to be woken (§8.11).</p></li> </ul> <p>These are the tables the message path uses.</p> <p>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 <strong>30 days</strong> regardless of download state, a receiver that wants to keep an image keeps the decrypted copy locally, not the server blob.</p> <p><strong>One bit on each waiting envelope: was it announced.</strong> Each waiting envelope also records whether its submission was announced, which is the reverse of §8.4's <code>silent</code>. 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 <code>silent</code> 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.</p> <p>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.</p> <hr /> <h2 id="10-cross-engine-interop-status">10. Cross-engine interop status</h2> <p><strong>Verified</strong> means one engine's output was opened by another. Self-consistency, an engine agreeing with itself, is not interop and is recorded separately below.</p> <h3 id="verified">Verified</h3> <ul> <li><strong>iOS (CryptoKit) ↔︎ Web (WebCrypto)</strong>, both directions, <strong>v1 and v2</strong>: one engine seals a <code>prekey</code> envelope, the other runs X3DH respond + ratchet decrypt and replies, and the first opens the reply. Also covered: identical associated data, the v2 last-resort prekey path, tamper rejection on both the body and the KEM ciphertext, and safety-number agreement on shared keys. Harness: <code>test/interop.mjs</code>, whose Swift half is compiled from the app's own <code>Sources/E2E/*.swift</code> rather than being a reimplementation.</li> <li><strong>Web ↔︎ relay</strong>, 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: <code>test/relay.mjs</code>, which drives the compiled Swift relay rather than a stand-in.</li> <li><strong>iOS ↔︎ live server</strong>, v1 only: a real <code>register → send → poll → decrypt</code> round trip against production.</li> <li>Each of the <strong>iOS</strong> and <strong>Web</strong> engines separately passes X3DH, Double Ratchet, out-of-order (skipped keys), GCM tamper rejection and safety numbers.</li> </ul> <h3 id="android-verified">Android, verified</h3> <p>The Android engine exists and is exchanged with the web engine. It lives in <a href="https://git.extant2000.com/extant2000/flamenet-messenger-android">flamenet-messenger-android</a> <code>engine/</code>, 18 Kotlin files, and a plain JVM library with no <code>android.*</code> imports, which is exactly the shape this section predicted it would need to be testable at all.</p> <p><code>test/interop-kotlin.mjs</code> drives it against the web engine over a stdin/stdout protocol: <strong>43 assertions, passing</strong>: 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).</p> <p>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.</p> <p>Android does post-quantum too, and it has been run. Its engine carries ML-KEM through BouncyCastle (<code>engine/.../PQKEM.kt</code>) and <code>E2ESession</code> negotiates v3 as responder as well as initiator. Verified by <code>test/interop-swift-kotlin.mjs</code>, the Swift driver compiled from the iOS app's own <code>Sources/E2E</code>, the Kotlin side driven through <code>session_*</code> so the negotiation decision belongs to <code>E2ESession</code> and not to the harness. <strong>77 assertions, 0 failures.</strong></p> <p>The rule for this section: establish what has been run. <code>PQKEM.kt</code> 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.</p> <blockquote> <p>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.</p> </blockquote> <hr /> <h2 id="11-threat-model--limits-v1">11. Threat model / limits (v1)</h2> <blockquote> <p>The "harvest now, decrypt later" limit below is what <strong>§12</strong> addresses. Everything else in this section applies unchanged to v2.</p> </blockquote> <ul> <li>Server can see <strong>metadata</strong>: who messages whom, when, message sizes, device counts. (§23 reduces sizes to one of two steps.) (Sealed-sender is out of scope for v1.)</li> <li>Server could attempt a <strong>key-swap MITM</strong>; safety numbers (§7) are the defense, clients MUST surface identity-key changes.</li> <li>No multi-device history sync; no encrypted backups; no group messaging (all deferred).</li> <li>One-time prekey exhaustion degrades a session's initial forward secrecy slightly (X3DH without <code>OPK</code>) but does not break confidentiality.</li> <li><strong>No post-quantum protection in v1.</strong> A passive adversary who records envelopes now and breaks X25519 later recovers <code>SK</code> and, through the ratchet, the entire session. <strong>§12</strong> specifies the hybrid ML-KEM-768 key agreement that closes this.</li> </ul> <hr /> <h2 id="12-protocol-v2-hybrid-post-quantum-x3dh-pqx3dh">12. Protocol v2, hybrid post-quantum X3DH (PQX3DH)</h2> <p>Status: <strong>IMPLEMENTED</strong> on the web engine, iOS and Android, and carried by the relay. The web engine (<code>assets/js/e2e.js</code>) remains the reference implementation, per the convention in §10.</p> <p>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 <code>MLKEM768</code> needs it; on Android the PQ signed prekey is published and consumed alongside the X25519 one.</p> <p>This section is a <strong>delta against v1</strong>. Everything in §§1–11 still holds for <code>"v": 1</code> 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 <strong>unchanged</strong>.</p> <h3 id="121-motivation-and-scope">12.1 Motivation and scope</h3> <p>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 <code>SK</code>, hence the whole session, the "harvest now, decrypt later" attack. Because the ratchet chains <code>RK_{n+1} = KDF(RK_n, DH_n)</code>, recovering <code>RK_0</code> unrolls the entire conversation.</p> <p>v2 mixes an <strong>ML-KEM-768 (FIPS 203)</strong> shared secret into the X3DH IKM. The construction is <strong>hybrid</strong>: the classical DHs are retained unchanged and concatenated with the KEM secret before the KDF. <code>SK</code> is therefore secure if <strong>either</strong> X25519 <strong>or</strong> ML-KEM-768 is secure. This is deliberate and is the load-bearing property of the design, see §12.9.</p> <p><strong>What v2 does buy.</strong> Confidentiality of a session against a <em>passive</em> adversary who records now and breaks X25519 later. Once <code>RK_0</code> 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.</p> <p><strong>What v2 does not buy.</strong> Authentication is still Ed25519. An adversary with a quantum computer <em>at the time of the exchange</em> can forge <code>SPK_sig</code> 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.</p> <h3 id="122-added-primitive">12.2 Added primitive</h3> <table> <thead> <tr> <th>Purpose</th> <th>Algorithm</th> <th>Sizes</th> </tr> </thead> <tbody> <tr> <td>KEM</td> <td><strong>ML-KEM-768</strong> (FIPS 203)</td> <td>encapsulation key 1184 B · ciphertext 1088 B · shared secret 32 B · seed 64 B</td> </tr> </tbody> </table> <p>Key material is stored and transported as the <strong>64-byte FIPS 203 seed</strong> (<code>d ‖ z</code>), 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).</p> <p>Implementations MUST use <code>crypto.subtle</code> / platform <code>ML-KEM-768</code> where available and fall back to a vetted library otherwise. Nobody writes ML-KEM by hand.</p> <h3 id="123-added-key-material-per-device">12.3 Added key material per device</h3> <table> <thead> <tr> <th>Key</th> <th>Type</th> <th>Lifetime</th> <th>Published</th> </tr> </thead> <tbody> <tr> <td><code>PQSPK</code></td> <td>ML-KEM-768</td> <td>rotated with <code>SPK</code></td> <td>public ek + signature</td> </tr> <tr> <td><code>PQSPK_sig</code></td> <td>Ed25519 sig</td> <td>n/a</td> <td>see below</td> </tr> <tr> <td><code>PQOPK_i</code></td> <td>ML-KEM-768</td> <td>one-time</td> <td>pool of public eks, each signed</td> </tr> <tr> <td><code>PQOPK_sig_i</code></td> <td>Ed25519 sig</td> <td>n/a</td> <td>see below</td> </tr> </tbody> </table> <p><code>PQSPK</code> is the <strong>last-resort</strong> PQ prekey: always present, never consumed, so a session can always be established with PQ protection even when the one-time pool is drained.</p> <p>Signatures are <strong>domain-separated</strong>, and the one-time signature binds the id:</p> <pre><code>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)</code></pre> <p>Both prefixes are ASCII, no terminator. Binding <code>id</code> stops the server relabelling a prekey (a denial of service, not a break, but free to prevent).</p> <blockquote> <p>v1's <code>SPK_sig</code> signs the raw 32-byte key with <strong>no</strong> domain prefix (§3). That asymmetry is intentional and load-bearing for compatibility with shipped clients. Do not "fix" it.</p> </blockquote> <p><strong>Pool size.</strong> 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 (<code>opk_remaining < 20</code>). Equal sizing is a deliberate invariant: if the PQ pool were smaller it would drain first and sessions would silently fall back to <code>PQSPK</code>, losing per-session PQ forward secrecy with no signal. They must exhaust together or not at all.</p> <p><strong>Cost of that choice.</strong> 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 <code>POST /e2e/devices</code> (the relay caps request bodies). Replenishment via <code>POST /e2e/prekeys</code> is incremental and small.</p> <h3 id="124-version-advertisement-and-capability-signature">12.4 Version advertisement and capability signature</h3> <p>Each device row gains <code>proto</code>, the highest protocol version that device implements, plus a signature proving the device really made that claim:</p> <pre><code>caps_sig = Ed25519_sign(IK_sig_priv, "FlamenetE2E_Caps_v2" ‖ uint8(proto))</code></pre> <p><code>proto</code> and <code>caps_sig</code> are relayed by the server but <strong>authenticated by the device</strong>. A server that rewrites <code>proto</code> downward cannot produce a matching <code>caps_sig</code>; it can only omit the pair entirely. §12.7 is what closes that remaining gap.</p> <h3 id="125-pqx3dh-initiating-a-session">12.5 PQX3DH, initiating a session</h3> <p>Alice initiates to Bob's device <code>D</code>. Steps that differ from §5 are marked <strong>NEW</strong>.</p> <ol type="1"> <li>Alice fetches the bundle (§12.11). It now carries <code>proto</code>, <code>caps_sig</code>, the PQ signed prekey, and one popped PQ one-time prekey (or <code>null</code>).</li> <li>Alice MUST verify <code>Ed25519_verify(ik_sig, spk_sig, spk)</code>, unchanged. Abort on failure.</li> <li><strong>NEW.</strong> Alice MUST decide the protocol version by the rules in §12.7. If v1, run §5 unchanged and stop here.</li> <li><strong>NEW.</strong> Alice selects the PQ target: the one-time <code>PQOPK</code> if the bundle supplied one, otherwise <code>PQSPK</code>. She records <code>pq_kind ∈ {"opk", "spk"}</code> and <code>pq_id</code>.</li> <li><strong>NEW.</strong> 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.</li> <li><strong>NEW.</strong> Encapsulate: <code>(PQ_CT, SS) = ML-KEM-768.Encaps(PQ_target_pub)</code>.</li> <li>Alice generates an ephemeral X25519 keypair <code>EK</code> and computes <code>DH1..DH4</code> exactly as §5.</li> <li><strong>NEW.</strong> The IKM appends <code>SS</code> <strong>last</strong>: <pre><code>IKM = DH1 ‖ DH2 ‖ DH3 ‖ DH4 ‖ SS // DH4 omitted if opk == null SK = HKDF-SHA256(IKM, salt = 0x00*32, info = "FlamenetE2E_X3DH_v2", L = 32)</code></pre> <blockquote> <p>The info string <strong>MUST</strong> change from v1. Without it, <code>DH1‖DH2‖DH3‖DH4</code> (v1, with OPK) and <code>DH1‖DH2‖DH3‖SS</code> (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.</p> </blockquote></li> <li><strong>NEW.</strong> The associated data binds the KEM ciphertext: <pre><code>PQAD = SHA-256("FlamenetE2E_PQAD_v2" ‖ PQ_CT) // 32 bytes AD = IK_sig_A_pub ‖ IK_sig_B_pub ‖ PQAD // 96 bytes</code></pre> ML-KEM's implicit rejection already makes a tampered <code>PQ_CT</code> yield a different <code>SS</code> and therefore a failed AEAD open; this binding makes that explicit and permanent, since <code>AD</code> prefixes the AAD of <strong>every</strong> message in the session (§6.3).</li> <li>Ratchet initialisation is unchanged: sender, <code>RK = SK</code>, <code>DH_remote = SPK_B_pub</code>.</li> <li>The first envelope is <code>type: "prekey"</code> with <code>"v": 2</code> and the added fields in §12.6.</li> </ol> <p>Bob runs the symmetric procedure: same DHs with roles reversed (§5), then <code>SS = ML-KEM-768.Decaps(PQ_CT, sk)</code> where <code>sk</code> is selected by <code>pq_kind</code>/<code>pq_id</code>. If <code>pq_kind == "opk"</code> the prekey is <strong>consumed</strong> (deleted) exactly like a classical OPK; if <code>pq_kind == "spk"</code> it is not. A <code>pq_id</code> Bob does not hold is a hard error (<code>unknownPQPreKey</code>), he MUST NOT fall back to <code>PQSPK</code>.</p> <blockquote> <p>ML-KEM decapsulation <strong>never fails</strong>. 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.</p> </blockquote> <h3 id="126-envelope-changes">12.6 Envelope changes</h3> <p>The <code>prekey</code> envelope gains a <code>pq</code> object and bumps <code>v</code>. <code>msg</code> envelopes are <strong>byte-identical to v1</strong>, the PQ material appears exactly once, in the initial message.</p> <pre class="json"><code>{ "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>" }</code></pre> <p><code>header_bytes</code> (§6.4) is <strong>unchanged</strong>: the <code>pq</code> object is not part of the ratchet header and is not in the AAD directly; it is bound through <code>PQAD</code> in <code>AD</code> instead. This is what keeps <code>msg</code> envelopes and the entire ratchet identical across versions.</p> <p>A receiver MUST reject <code>"v": 2</code> with <code>type: "prekey"</code> and a missing or malformed <code>pq</code> object rather than treating it as v1. The classical <code>opk_id</code> and the PQ <code>pq.id</code> are <strong>independent id spaces</strong>; they may coincide numerically and MUST NOT be assumed equal.</p> <h3 id="127-version-selection-and-downgrade-resistance">12.7 Version selection and downgrade resistance</h3> <p>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.</p> <p>Clients hold a policy <code>pqMode</code>:</p> <ul> <li><strong><code>"optional"</code></strong> (rollout): use v2 when the bundle carries <code>proto ≥ 2</code> with a valid <code>caps_sig</code> <strong>and</strong> a valid PQ prekey; otherwise v1.</li> <li><strong><code>"required"</code></strong> (target): refuse to establish any v1 session. Peers not yet on v2 become unreachable, so this flips only once the fleet has migrated.</li> </ul> <p><strong>Per-device pinning is the actual defence,</strong> and it reuses the trust store that already holds identity pins (§11, <code>Trust</code>):</p> <blockquote> <p>The first time a client observes a device with a valid <code>caps_sig</code> at <code>proto ≥ 2</code>, it MUST persist <code>minProto = 2</code> for that device beside the identity pin. Thereafter a bundle for that device <strong>without</strong> 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.</p> </blockquote> <p><code>minProto</code> MUST be monotonic (never lowered by anything the server says) and MUST survive the same lifecycle as the identity pin, including <code>acceptIdentityChange</code>, accepting a new identity key does <strong>not</strong> reset <code>minProto</code>.</p> <p><strong>Residual risk.</strong> 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 <code>IK_sig</code> only and are identical for a v1 and a v2 session. The only complete fix is <code>pqMode: "required"</code>. Any user-facing claim about post-quantum protection MUST NOT be made while the fleet is on <code>"optional"</code>.</p> <h3 id="128-server-contract-changes">12.8 Server contract changes</h3> <p><code>POST /e2e/devices</code> (§8.1), request gains:</p> <pre class="json"><code>{ "proto": 2, "caps_sig": "<b64>", "pqspk_id": 3, "pqspk": "<b64>", "pqspk_sig": "<b64>", "pq_prekeys": [ { "id": 1, "pub": "<b64>", "sig": "<b64>" } ] }</code></pre> <p>All PQ fields are OPTIONAL, so v1 clients keep registering unchanged. If any is present all of <code>proto</code>, <code>caps_sig</code>, <code>pqspk_id</code>, <code>pqspk</code>, <code>pqspk_sig</code> MUST be present. The server MUST reject a <code>pqspk</code> that is not exactly 1184 bytes and a signature that is not 64 bytes, in the same manner as <code>valid_b64key</code> in v1, it cannot verify signatures (it holds no private key) but it MUST enforce lengths.</p> <p><code>GET /e2e/keys/{user_id}</code> (§8.2), each device object gains:</p> <pre class="json"><code>{ "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 }</code></pre> <p>Each pop is individually atomic (a conditional <code>UPDATE ... WHERE used = 0</code>, as v1 already does), but the two are <strong>not</strong> 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 <code>opk</code> and no <code>pqopk</code>, which is the ordinary exhaustion case §12.3 already covers.</p> <p>The invariant that actually matters is <strong>equal pool sizes and paired replenishment</strong>. Both depths are therefore reported on every registration and replenishment response so a client can see the pools diverging:</p> <pre class="json"><code>{ "ok": true, "device_id": "...", "proto": 2, "opk_remaining": 100, "pq_opk_remaining": 100 }</code></pre> <p><code>pq_opk_remaining</code> is <code>null</code>, not <code>0</code>, 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.</p> <blockquote> <p>This route already pops a prekey <strong>per device, per call</strong>. 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 <code>SPK</code> + <code>PQSPK</code>. Prefer <code>GET /e2e/devices</code> when not establishing a session. Rate limiting this route is an open item inherited from v1, not introduced here.</p> </blockquote> <p><code>POST /e2e/prekeys</code> (§8.6), accepts a <code>pq_prekeys</code> array of the same shape.</p> <p><code>GET /e2e/devices/{user_id}</code> (§8.3), gains <code>proto</code> and <code>caps_sig</code> so a client can learn a peer's capability, and pin it, without consuming prekeys.</p> <p><strong>New table</strong> <code>{p}fnmsg_e2e_pq_prekeys</code>: <code>device_id</code>, <code>prekey_id</code>, <code>pub</code> (1184 B base64), <code>sig</code> (64 B base64), <code>used</code> flag, mirroring <code>{p}fnmsg_e2e_prekeys</code>. <code>{p}fnmsg_e2e_devices</code> gains <code>proto</code>, <code>caps_sig</code>, <code>pqspk_id</code>, <code>pqspk</code>, <code>pqspk_sig</code>, all NULLable. Additive only; no v1 column changes, and a v1 client registers exactly as before.</p> <p><strong>Retention.</strong> 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.</p> <p>Re-registration must not strip a v2 advertisement. <code>POST /e2e/devices</code> 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 <code>minProto = 2</code> would refuse to send to it, the device would go dark for exactly the users who had verified it.</p> <p>A partial stored block MUST be suppressed entirely when serving a bundle. If <code>pqspk</code>, <code>pqspk_sig</code> or <code>caps_sig</code> 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 <code>minProto = 2</code>.</p> <h3 id="129-why-hybrid-and-why-that-is-not-a-hedge">12.9 Why hybrid, and why that is not a hedge</h3> <p>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 <code>SS</code> is concatenated with the classical DHs before a single KDF, an ML-KEM implementation bug, wrong shared secret, biased sampling, a broken NTT, degrades <code>SK</code> to exactly v1's classical security. It cannot make v2 weaker than v1. The converse also holds. Only a break of <strong>both</strong> loses the session.</p> <p>This means the correct rollout order is PQ-additive first, PQ-only never.</p> <h3 id="1210-conformance-requirements-for-this-section">12.10 Conformance requirements for this section</h3> <p>An implementation conforms to §12 only if it demonstrates, not merely implements:</p> <ol type="1"> <li><strong>Seed agreement</strong>: native and fallback ML-KEM produce byte-identical <code>ek</code> from the same 64-byte seed, and each decapsulates the other's ciphertext to the same <code>SS</code>.</li> <li><strong>Hybrid KAT</strong>: a fixed <code>(IK, SPK, OPK, EK, PQ seed)</code> vector produces a fixed <code>SK</code> and <code>AD</code>, checked in against the spec so drift is caught by a test rather than by a user.</li> <li><strong>Cross-engine</strong>: a v2 prekey envelope sealed by one engine opens on another, both directions, as §10 requires for v1.</li> <li><strong>Downgrade refusal</strong>: a device pinned at <code>minProto = 2</code> refuses a bundle with the PQ fields stripped, and the refusal reaches the UI.</li> <li><strong>Tamper rejection</strong>: flipping one byte of <code>pq.ct</code> fails the AEAD open, proving <code>PQAD</code> is bound.</li> <li><strong>Exhaustion path</strong>: with the PQ one-time pool empty, the session establishes against <code>PQSPK</code> and still round-trips.</li> </ol> <p>§10's warning applies with full force: <strong>do not write these claims into the spec until the runs have actually happened.</strong> v1 asserted interop for months that was impossible.</p> <p><strong>Status</strong>: what has actually been run, and on what:</p> <table> <thead> <tr> <th></th> <th>Status</th> <th>Where</th> </tr> </thead> <tbody> <tr> <td>1. Seed agreement</td> <td>verified, both directions, 8 random seeds</td> <td><code>test/conformance.mjs</code></td> </tr> <tr> <td>2. Hybrid KAT</td> <td>locked vector, replayed on both backends</td> <td><code>test/vectors/pqx3dh-v2.json</code></td> </tr> <tr> <td>3. Cross-engine</td> <td><strong>partial</strong>: 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 <code>Sources/E2E/*.swift</code>, not a reimplementation. JS ↔︎ <strong>relay</strong> also verified end to end over HTTP. Kotlin ↔︎ Web verified: 43 assertions, <code>test/interop-kotlin.mjs</code> (§10).</td> <td><code>test/interop.mjs</code>, <code>interop-kotlin.mjs</code>, <code>relay.mjs</code></td> </tr> <tr> <td>4. Downgrade refusal</td> <td>verified through the client, a pinned device refuses a PQ-stripped bundle with <code>ProtocolDowngradeError</code>; the pin is monotonic, survives a reload, and is <strong>not</strong> cleared by accepting an identity change</td> <td><code>test/client.mjs</code></td> </tr> <tr> <td>5. Tamper rejection</td> <td>verified, one flipped byte of <code>pq.ct</code> fails the AEAD open</td> <td><code>test/conformance.mjs</code></td> </tr> <tr> <td>6. Exhaustion path</td> <td>verified through the real server's popping path</td> <td><code>test/relay.mjs</code> §8.2</td> </tr> </tbody> </table> <p>Client wiring has 289 tests (<code>test/client.mjs</code>) run against a deliberately <strong>hostile</strong> 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 (<code>test/relay.mjs</code>), driven against the running relay rather than a stub.</p> <p>Cross-engine interop has 98 tests (<code>test/interop.mjs</code>) driving the Swift engine against the JS engine in both directions.</p> <p><strong><code>pqMode</code> is still <code>"optional"</code>, and both of the reasons it was have now gone.</strong> 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 <strong>26.0</strong>, so every supported iPhone has <code>MLKEM768</code>.</p> <p>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.</p> <h3 id="1211-implementation-status">12.11 Implementation status</h3> <table> <thead> <tr> <th>Layer</th> <th>State</th> </tr> </thead> <tbody> <tr> <td>Reference engine (<code>src/e2e.js</code>, <code>mlkem.js</code>)</td> <td><strong>implemented</strong></td> </tr> <tr> <td>Relay (Swift, <a href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay</a>)</td> <td><strong>implemented</strong></td> </tr> <tr> <td>Web client wiring (<code>e2e-client.js</code>, <code>e2e-bootstrap.js</code>)</td> <td><strong>implemented</strong></td> </tr> <tr> <td>iOS (<code>Sources/E2E/</code>, CryptoKit)</td> <td><strong>implemented</strong></td> </tr> <tr> <td>Android (<code>engine/</code>, Bouncy Castle)</td> <td><strong>implemented</strong></td> </tr> </tbody> </table> <p>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 <code>ensureV2Published()</code>; peers are pinned at their proven version and a stripped bundle raises <code>ProtocolDowngradeError</code> rather than falling back.</p> <p>iOS is complete and no longer gated. CryptoKit gained <code>MLKEM768</code> in <strong>iOS 26</strong>, 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 <code>PQKEM.isSupported</code> against an iOS 16 floor, which meant <strong>a device on iOS 16–25 got no post-quantum protection at all</strong>. 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.</p> <p><strong>The floor is load-bearing.</strong> Lowering <code>IPHONEOS_DEPLOYMENT_TARGET</code> without restoring the <code>#available</code> gates would not fail to compile, <code>MLKEM768</code> would simply be missing at runtime on the devices that gained support, which is a crash rather than a downgrade.</p> <p><strong>Android is complete</strong>, over Bouncy Castle's low-level <code>pqc.crypto.mlkem</code>, the same route <code>Ed25519.kt</code> already takes for <code>rfc8032</code>, 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 <code>pqx3dh-v2.json</code> vector rather than against another run of our own code.</p> <p><code>pqMode</code> stays <code>"optional"</code>, 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 <code>proto: 1</code>. Flipping to <code>"required"</code> would make those contacts unreachable rather than classically protected, which is the worse failure while any are in the field.</p> <h3 id="1212-open-items">12.12 Open items</h3> <ul> <li><strong>PQ authentication.</strong> 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 <code>ik_pq</code> and is authenticated classically, and safety numbers still cover the Ed25519 identity only.</li> <li><strong>Older clients in the field.</strong> Both apps do post-quantum key agreement from this tree, but Android builds already installed register <code>proto: 1</code>, so <code>pqMode</code> cannot leave <code>"optional"</code> until they have turned over. A session with one of them is classical at both ends and says so; it is not silently downgraded.</li> <li><strong>Prekey-pool drain.</strong> Inherited from v1, now doubled in impact. Rate limiting <code>GET /e2e/keys</code> is the fix. <code>GET /e2e/devices</code> now carries <code>proto</code> + <code>caps_sig</code> so a client can pin a peer's version without consuming a prekey, which removes discovery as a drain vector but not session establishment.</li> <li><strong>The web delivery problem.</strong> 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.</li> </ul> <hr /> <h2 id="13-protocol-v3-post-quantum-ratchet">13. Protocol v3, post-quantum ratchet</h2> <p>Status: <strong>IMPLEMENTED and in use</strong>. Delta against §12. Everything in §§1–12 still holds for <code>"v": 2</code> sessions, and a v3 client MUST still speak v2 and v1 to peers that do not advertise v3.</p> <p>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 <code>envelope v3 pqdh=true pqct=true sessionVersion=3</code>. iOS holds the version in <code>E2EProtocol.version</code>, and it is 3.</p> <h3 id="131-what-v2-left-open">13.1 What v2 left open</h3> <p>§12 makes the <strong>initial</strong> key agreement post-quantum. Because the ratchet chains <code>RK_{n+1} = KDF(RK_n, DH_n)</code>, an adversary who cannot recover <code>RK_0</code> cannot recover any later root key either, so v2 already protects a whole session against harvest-now-decrypt-later.</p> <p>What v2 does <strong>not</strong> give is post-quantum <strong>post-compromise security</strong>. If an endpoint is compromised and <code>RK_n</code> 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.</p> <p>v3 mixes a fresh ML-KEM-768 secret into <strong>every ratchet step</strong>, so healing is post-quantum too.</p> <h3 id="132-construction">13.2 Construction</h3> <p>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.</p> <p>Added state per session:</p> <pre><code>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</code></pre> <p>The root-key KDF takes both secrets:</p> <pre><code>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])</code></pre> <blockquote> <p>The info string MUST change from <code>_v1</code>. <code>dh_out</code> is 32 bytes and <code>ss</code> is 32 bytes, so a v3 IKM is 64 bytes where a v1 IKM is 32, they cannot collide by length, but the version string is what makes the domain separation explicit rather than incidental.</p> </blockquote> <p><strong>Initiator</strong>, at session start. <code>PQr</code> is initialised to the PQ prekey chosen by X3DH (§12.5), the same key the X3DH encapsulation targeted:</p> <pre><code>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</code></pre> <p><strong>Responder</strong> mirrors it: <code>PQs</code> is the PQ prekey the initiator used (so it can decapsulate), and <code>PQr</code> is adopted from the first header.</p> <p><strong>Every DH ratchet step</strong> then performs a PQ step in lockstep:</p> <pre><code>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</code></pre> <p>Decapsulation uses the key the peer encapsulated to, which is our PQ key from <strong>before</strong> we adopt theirs. Doing those two in the wrong order silently derives the wrong root key.</p> <h3 id="133-header-and-message-size">13.3 Header and message size</h3> <p>The header gains two optional fields:</p> <pre class="json"><code>{ "dh": "<b64>", "pn": 0, "n": 0, "pqdh": "<b64 1184B>", "pqct": "<b64 1088B>" }</code></pre> <p><code>header_bytes</code> for the AAD is the canonical compact JSON with keys in <strong>exactly</strong> this order, omitting <code>pqdh</code>/<code>pqct</code> entirely when they are absent:</p> <pre><code>{"dh":"...","pn":N,"n":N} // no PQ step {"dh":"...","pn":N,"n":N,"pqdh":"...","pqct":"..."} // PQ step</code></pre> <p>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.</p> <p>The cost is <strong>~3.0 KB of base64 per message</strong> 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.</p> <p>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.</p> <h3 id="134-negotiation">13.4 Negotiation</h3> <p>Unchanged from §12.4 and §12.7, which already generalise: a device advertises <code>proto: 3</code> with a capability signature over <code>"FlamenetE2E_Caps_v2" ‖ uint8(3)</code>, peers pin <code>minProto = 3</code> on first proof, and the pin is monotonic. A v3 device talking to a v2 peer runs v2; the <code>pqdh</code>/<code>pqct</code> fields are simply absent and <code>KDF_RK</code> stays on <code>_v1</code>.</p> <p>A <code>"v": 3</code> message whose header lacks <code>pqdh</code>/<code>pqct</code> while the DH key changed MUST be rejected, not treated as a v2 ratchet step, that would be a downgrade inside an established session.</p> <h3 id="135-what-v3-still-does-not-do">13.5 What v3 still does not do</h3> <ul> <li><strong>Authentication is still Ed25519.</strong> A quantum adversary present at the exchange can forge <code>SPK_sig</code> and MITM. PQ signatures (ML-DSA) remain unimplemented, and this is the last purely-cryptographic gap against Signal.</li> <li>Nothing here changes metadata exposure. See §14.</li> </ul> <hr /> <h2 id="14-sealed-sender">14. Sealed sender</h2> <p>Status: <strong>IMPLEMENTED</strong> on the web engine, the server, the web client, iOS and Android. §15 records what was actually run for each.</p> <p>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.</p> <p>A transport feature, not a protocol version: it changes what the relay <em>learns</em>, never how <code>SK</code> is derived. It composes with v1, v2 and v3 unchanged.</p> <h3 id="141-the-problem">14.1 The problem</h3> <p>§11 concedes that the relay sees who messages whom. Today that is worse than a concession, it is recorded. <code>POST /e2e/messages</code> is authenticated, so the server knows the sender by construction, and <code>{p}fnmsg_e2e_envelopes</code> stores <code>from_user_id</code> and <code>from_device</code> on every row. Even with delivered envelopes purged after a day, the relay is a live social-graph feed.</p> <p>This is the axis on which the project loses hardest to Signal, and unlike PQ or audits it is entirely within our control.</p> <h3 id="142-what-sealed-sender-does-and-does-not-achieve">14.2 What sealed sender does and does not achieve</h3> <p><strong>Achieves:</strong> the relay no longer learns <em>who sent</em> a message. It sees the recipient, the time, and an opaque blob.</p> <p><strong>Does not achieve:</strong> the relay still sees the <strong>recipient</strong>, <strong>timing</strong>, a <strong>size bucket</strong>, and the <strong>sender's IP address</strong>. 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".</p> <h3 id="143-construction">14.3 Construction</h3> <p>The sender identity moves <em>inside</em> 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 <code>IK_dh</code>:</p> <pre><code>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)</code></pre> <p>Wire (<code>payload</code> field of <code>POST /e2e/messages</code>, base64 of the JSON):</p> <pre class="json"><code>{ "s": 1, "eph": "<b64 32B>", "ct": "<b64>" }</code></pre> <p>A sender that pads (§23) writes this object in exactly one form, <code>{"s":1,"eph":"…","ct":"…"}</code>: keys in that order, no spaces, <code>/</code> 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 <code>pad</code> is ignored (§23.8).</p> <p>The recipient trial-decrypts with each of its devices' <code>IK_dh</code> private keys. <code>AAD</code> 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.</p> <blockquote> <p>The seal is confidentiality only, not authentication. Anyone holding the recipient's public <code>IK_dh</code> can produce a well-formed seal claiming any <code>from_user</code>. The claim is worth nothing until the <strong>inner envelope</strong> is processed: the ratchet and the identity pin (§11) are what authenticate the sender, exactly as before. A client MUST NOT display, notify on, or record a sealed message's claimed sender before the inner envelope decrypts. Treating <code>from_user</code> as trusted would hand an attacker free sender-spoofing, a strictly worse outcome than the metadata leak this feature removes.</p> </blockquote> <h3 id="144-delivery-tokens">14.4 Delivery tokens</h3> <p>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 <strong>delivery key</strong>; the server stores only <code>SHA-256(delivery_key)</code>.</p> <ul> <li><p><code>POST /e2e/delivery-key</code>, publish <code>SHA-256(delivery_key)</code> for the calling user.</p></li> <li><p>The delivery key is distributed to contacts <strong>inside E2E messages</strong>, so the relay never sees it in the clear from the owner. It travels as a content envelope (§6.6) with <code>kind: "delivery_key"</code>:</p> <pre class="json"><code>{ "fnc": 1, "kind": "delivery_key", "key": "<b64 32B>" }</code></pre> <p>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: <code>tokens</code> (§21.4), <code>ptok</code> (§8.9c) and, on a first message, <code>hc</code>, the sender's handle certificate (§14.5).</p></li> <li><p><strong>Rotation is pushed.</strong> 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.</p></li> <li><p>A sealed submission presents the raw delivery key. The server hashes it, compares against the recipient's stored digest, and accepts on match, <strong>without authenticating the sender</strong>.</p></li> </ul> <p>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.</p> <h3 id="145-server-contract">14.5 Server contract</h3> <p><code>POST /e2e/messages</code> gains a <strong>sealed mode</strong>, selected by presenting an authorisation instead of relying on the session. There are three, tried by the sender in this order:</p> <pre class="json"><code>{ "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</code></pre> <p>each <code>messages</code> entry being <code>{ "to_device": "<uuid>", "payload": "<b64 sealed blob>" }</code>.</p> <p>In sealed mode the server MUST:</p> <ul> <li>accept the request <strong>without</strong> an authenticated user, and MUST NOT record one;</li> <li>store <code>from_user_id = 0</code> and <code>from_device = ''</code>, not the session's values, and not NULL, so a sealed row is indistinguishable from any other sealed row;</li> <li>rate-limit by <strong>recipient</strong> and by IP rather than by sender, since there is no sender;</li> <li>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.</li> </ul> <p><code>GET /e2e/messages</code> returns <code>from_user: 0</code>, <code>from_device: ""</code> for sealed rows. The client learns the real values from the inner JSON after decryption.</p> <p><strong>First contact, sealed.</strong> 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 <strong>intro capability</strong> for the first message:</p> <ul> <li><code>POST /e2e/intro</code> with <code>{"link": "<base64url>"}</code>, the capability carried by a reusable contact link (§8.14), which the intro does not spend; or</li> <li><code>POST /e2e/intro</code> with <code>{"handle": "ada", "challenge": …, "nonce": …}</code>, for <strong>listed</strong> accounts only, after a proof of work (§8.13).</li> </ul> <p>A one-time link needs no intro: it carries a delivery token, and the first message presents that (§8.14).</p> <p>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:</p> <pre class="json"><code>{ "user_id": 12, "fnid": "fnid:…", "handle_cert": { … }, "devices": [ … ], "intro": "<b64 32B>", "exp": 1787260600 }</code></pre> <p>The relay stores only <code>SHA-256(intro)</code> with the account and the expiry, ten minutes ahead. An intro is good for <strong>one</strong> sealed submission to that account before <code>exp</code>, 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).</p> <p>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.</p> <p><strong>Handle certificates</strong> replace asking the relay "who is user 12?", a question the relay answers to anyone and can log (<code>GET /messenger/users/{id}</code>, retired at stage C5). A certificate is the relay's signature over:</p> <pre><code>"fnhandle-v1|" || audience || "|" || decimal(user_id) || "|" || handle || "|" || decimal(exp)</code></pre> <p>UTF-8, <code>exp</code> in unix seconds, Ed25519 under the key the relay signs its log heads with (§19.3), which clients already pin. On the wire:</p> <pre class="json"><code>{ "v": 1, "aud": "relay.example.org", "uid": 12, "h": "ada", "exp": 1790000000, "sig": "<b64 64B>" }</code></pre> <p>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 <code>exp</code>, and one with <code>|</code> in any field. No pinned key means nothing can be verified, and nothing is introduced. A client gets its own from <code>GET /e2e/handle-cert</code>, valid for a relay-chosen period of at most 30 days, and carries it as <code>hc</code> in the <code>delivery_key</code> prelude of a first message. <code>test/vectors/handle-cert-v1.json</code> pins the bytes, with a throwaway key.</p> <p><strong>Requests.</strong> A sealed message from somebody the recipient has not accepted is filed in a <strong>Requests</strong> 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.</p> <p><strong>Nothing goes out named.</strong> 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 <strong>one</strong> 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. <name> needs to message you or send you a new link." A message that cannot go sealed is not sent at all. Until C3, clients SHOULD show which mode a conversation is using, and a person who has turned on sealed sending SHOULD be told when a message went named.</p> <p><strong>Own-device sends are sealed too</strong>, under the account delivery key (§17.6).</p> <p><strong>The relay stops taking named sends in stages</strong>, set by <code>FN_NAMED_SENDS</code>: <code>accept</code> (today), then <code>log</code>, which accepts and counts named submissions by app version without naming an account, then <code>refuse</code>, which answers them with <code>400</code> and the code <code>named_send_refused</code>. <code>refuse</code> 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 <code>from_user_id</code> and <code>from_device</code> columns, and retires <code>GET /messenger/users/{id}</code>.</p> <p>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 <strong>refused</strong> 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).</p> <h3 id="146-open">14.6 Open</h3> <ul> <li><strong>Sender IP.</strong> Unaddressed and unaddressable at this layer; a relay operator correlating IPs defeats sealed sender for a targeted user.</li> <li><strong>Recipient still visible.</strong> Signal's private contact discovery and group sending have no analogue here.</li> <li><strong>Attachments are not sealed, the blob is.</strong> <code>POST /e2e/attachments</code> and <code>GET /e2e/attachments/{id}</code> 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.</li> <li><strong>Spam.</strong> 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.</li> <li><strong>The intro is a moment the relay sees.</strong> <code>POST /e2e/intro</code> names 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.</li> <li><strong>Size, down to a step.</strong> §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).</li> </ul> <hr /> <h2 id="15-implementation-status-for-13-and-14">15. Implementation status for §13 and §14</h2> <p>Recorded the same way as §12.10: what has actually been run.</p> <table> <thead> <tr> <th></th> <th>Web engine</th> <th>Server</th> <th>Web client wiring</th> <th>iOS</th> <th>Android</th> </tr> </thead> <tbody> <tr> <td>§13 PQ ratchet</td> <td>yes</td> <td>n/a, no server change needed</td> <td>yes</td> <td>verified cross-engine</td> <td>verified cross-engine</td> </tr> <tr> <td>§14 sealed sender</td> <td>yes</td> <td>sealed mode + delivery keys</td> <td>wired end to end</td> <td>wired end to end</td> <td>wired end to end</td> </tr> </tbody> </table> <p>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.</p> <ul> <li><p><strong>§13 on Android</strong> has been verified as of <code>cc51854</code>. Thirteen assertions in <code>test/interop-kotlin.mjs</code> drive a v3 session between the Kotlin and web engines in both directions. The load-bearing one is the web engine opening Kotlin's reply: sending it required decapsulating with the prekey seed, adopting the peer's <code>pqdh</code>, 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.</p> <p>That run is also what caught a defect in the shipped code. The engine was attaching <code>pqdh</code>/<code>pqct</code> to messages for any peer that published a PQ prekey, which every v2 device does. A build with that would have broken every conversation with the released 0.6 client, different root key AND different AAD, so the first message never opens. Sixteen Kotlin unit tests did not catch it and could not: both sides of a self-test share the author's reading. Recorded because it is the argument for this table's whole discipline.</p> <p>Kotlin against Swift directly has been run: <code>test/interop-swift-kotlin.mjs</code>, 22 assertions, both directions, no web engine anywhere in the path. This closes an inference that was never valid, two engines each agreeing with a third agree with each other only if the third pins every byte they could differ on, and the pairing a person actually has (an Android phone and an iPhone in one conversation) was the pairing nothing exercised. Both halves are the real app engines: the Swift driver compiles from the iOS app's own <code>Sources/E2E</code>, and the Kotlin side is driven through <code>session_*</code>, so the negotiation decision belongs to <code>E2ESession</code> rather than to the harness.</p> <p>The harness was checked against a deliberate break before being trusted: changing <code>FlamenetE2E_Ratchet_v3</code> to <code>…_v3X</code> in the Kotlin engine turned both directions red (<code>authenticationFailure</code> one way, <code>DecryptionFailed</code> the other) rather than passing on. A green cross-engine test that cannot go red is the exact shape of the iOS CI gate this project already shipped once.</p> <p>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 <code>Scripts/selfhost-smoke.sh</code>: <code>npm run test:interop:apps</code>.</p></li> <li><p><strong>§14 on Android is now verified</strong>, 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 <em>wired end to end</em> was unproven, because no recorded run showed an Android client sealing and unsealing against a relay, and it warned that <code>LiveSealedSenderTest</code> was not that evidence, because it returned early when <code>FN_LIVE_FLEET</code> was unset and JUnit recorded the early return as <strong>passed</strong>.</p> <p>Both halves are settled. The suite now announces a skip instead of passing through, and it has been run against a relay.</p> <p><strong>The discriminator, since this is exactly the claim that reads as proven when it is not.</strong> Two runs, same command, same day:</p> <table> <thead> <tr> <th></th> <th>result</th> </tr> </thead> <tbody> <tr> <td><code>FN_LIVE_FLEET</code> unset</td> <td><code>tests=2 skipped=2 failures=0</code>, reported <strong>SKIPPED</strong></td> </tr> <tr> <td>pointed at <code>relay1.flamenet.io</code></td> <td><code>tests=2 skipped=0 failures=0 time=8.604s</code>, <strong>PASSED</strong></td> </tr> </tbody> </table> <p>A no-op returns in milliseconds and reports zero skips; this took eight and a half seconds and the unconfigured run is visibly a skip. The two assertions are <em>a sealed message arrives and the relay records no sender</em> and <em>rotating the delivery key refuses submissions holding the old one</em>.</p></li> </ul> <p><strong>§13</strong> is complete and verified end to end on the web: 35 tests in <code>test/ratchet.mjs</code> 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 <strong>server required no change at all</strong>: 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.</p> <p>§14 is wired end to end on the web, covered by <code>test/client.mjs</code>. A client mints and publishes a delivery-key digest, hands the key itself to each contact as a <code>kind: "delivery_key"</code> content envelope on first message, and seals automatically once it holds a peer's key. Verified: the relay stores <code>from_user_id = 0</code> and <code>from_device = ""</code>, 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.</p> <p><strong>First contact.</strong> 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.</p> <p>The web client implements §14.5's client half now: <code>introduce()</code> 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 <code>requireSealed</code>, which refuses rather than sending named and retries a token, the key and one fresh intro first. <code>test/client.mjs</code> covers it against the in-process fake, which plays <code>POST /e2e/intro</code>, the challenge, <code>GET /e2e/handle-cert</code>, <code>POST /e2e/contact-links</code>, the <code>/e2e/anon/</code> routes and the <code>intro</code> 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 <code>test/relay.mjs</code> covers the same routes. <code>canSealTo()</code> and <code>lastSendWasSealed</code> exist so a client can show which mode was used rather than degrading silently.</p> <p>Still true regardless: the relay sees the <strong>recipient</strong>, <strong>timing</strong>, <strong>size</strong> and the sender's <strong>IP</strong>. Sealed sender is not anonymity.</p> <p><strong>iOS implements both.</strong> Verified cross-engine in <code>test/interop.mjs</code>: 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 <strong>iOS 26</strong> floor, where CryptoKit has both <code>MLKEM768</code> and <code>MLDSA65</code>, so none of this is gated any more, and §12.11's carve-out for older devices no longer exists.</p> <p>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 <code>test/interop-swift-kotlin.mjs</code> (§13). What has not been shown is a sealed envelope leaving the Android app itself.</p> <hr /> <h2 id="16-encrypted-backups">16. Encrypted backups</h2> <p>Status: <strong>DRAFT / proposed</strong>.</p> <h3 id="161-what-a-backup-may-contain-and-why-that-is-the-whole-design">16.1 What a backup may contain, and why that is the whole design</h3> <p>The obvious backup, serialise the vault, encrypt it, restore it, is <strong>wrong here</strong>, 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 <strong>same message keys for the same message numbers</strong>. 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 <code>device_id</code> across two installs, so both race on the same inbox and the same prekey pool.</p> <p>So a backup here deliberately contains no ratchet state and no device identity:</p> <table> <thead> <tr> <th>Included</th> <th>Excluded</th> </tr> </thead> <tbody> <tr> <td>decrypted message history</td> <td>ratchet sessions (<code>rk</code>, chain keys, skipped keys)</td> </tr> <tr> <td>identity <strong>pins</strong> and verified flags for contacts</td> <td>our own <code>IK_dh</code> / <code>IK_sig</code></td> </tr> <tr> <td>pinned <code>minProto</code> per device</td> <td>signed and one-time prekeys</td> </tr> <tr> <td>delivery keys held for contacts</td> <td>the device id</td> </tr> <tr> <td>the <strong>account</strong> delivery key (§14.4, §17)</td> <td></td> </tr> </tbody> </table> <p>A restored device therefore keeps <strong>its own new identity</strong>, 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.</p> <p><strong>The account delivery key is carried, and was not always.</strong> 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.</p> <blockquote> <p><strong>Peers will see a new device.</strong> 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.</p> </blockquote> <h3 id="162-format">16.2 Format</h3> <pre class="json"><code>{ "v": 1, "kdf": { "alg": "PBKDF2-HMAC-SHA256", "salt": "<b64 16B>", "iters": 600000 }, "nonce": "<b64 12B>", "ct": "<b64 AES-256-GCM ciphertext+tag>" }</code></pre> <pre><code>key = PBKDF2-HMAC-SHA256(passphrase, salt, iters, 32 bytes) ct = AES-256-GCM(key, nonce, JSON(payload), AAD = canonical JSON of the header)</code></pre> <p>The AAD covers <code>v</code> and the whole <code>kdf</code> 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.</p> <p>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.</p> <h4 id="1621-the-payload-keys">16.2.1 The payload keys</h4> <p>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 <strong>everyone</strong>. 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.</p> <table> <thead> <tr> <th>Key</th> <th>Shape</th> </tr> </thead> <tbody> <tr> <td><code>v</code></td> <td>payload version, integer</td> </tr> <tr> <td><code>threads</code></td> <td>peer id as text -> array of messages</td> </tr> <tr> <td><code>groups</code></td> <td>gid -> array of messages</td> </tr> <tr> <td><code>addrThreads</code></td> <td>address -> array of messages, for federated threads</td> </tr> <tr> <td><code>stories</code></td> <td>author id as text -> array of stories</td> </tr> <tr> <td><code>storyArchive</code></td> <td>array of stories, the author's own kept past expiry</td> </tr> <tr> <td><code>storyAudience</code></td> <td><code>{"mode": "everyone" | "only" | "except", "ids": [int]}</code></td> </tr> <tr> <td><code>mutedStoryAuthors</code></td> <td>array of ints, ascending</td> </tr> <tr> <td><code>storyReactions</code></td> <td>story key -> reactor id as text -> emoji</td> </tr> <tr> <td><code>roster</code></td> <td>array of <code>{"id": int, "handle": str, "name": str}</code></td> </tr> <tr> <td><code>rosterOffServer</code></td> <td>bool</td> </tr> <tr> <td><code>calls</code></td> <td>array of <code>{"peer": int, "at": ms, "in": bool, "connected": bool}</code></td> </tr> <tr> <td><code>groupNames</code></td> <td>gid -> name</td> </tr> <tr> <td><code>groupSecrets</code></td> <td>gid -> <code>{"tag", "key", "version", "ownerPrivate"?}</code></td> </tr> <tr> <td><code>pins</code></td> <td>thread key -> <code>{"mid": str, "at": ms, "by": int}</code></td> </tr> <tr> <td><code>drafts</code></td> <td>thread key -> text</td> </tr> <tr> <td><code>polls</code></td> <td>poll id -> <code>{"gid", "q", "opts", "members", "choice"?, "didPublish", "held", "sums"}</code></td> </tr> <tr> <td><code>presenceTokensIssued</code></td> <td>contact id as text -> base64 32 bytes</td> </tr> <tr> <td><code>presenceTokensHeld</code></td> <td>contact id as text -> base64 32 bytes</td> </tr> <tr> <td><code>acc</code></td> <td>array of ints, ascending: contacts whose first message was accepted (§8.9d)</td> </tr> <tr> <td><code>blocked</code></td> <td>array of ints, ascending: contacts this account has blocked (§8.9d)</td> </tr> <tr> <td><code>convFlags</code></td> <td>thread key -> <code>{"starred": true, "archived": true}</code>, each flag written only when true (§8.9d)</td> </tr> <tr> <td><code>dk</code></td> <td>base64 32 bytes: the account delivery key (§16.1)</td> </tr> <tr> <td><code>deliveryTokensIssued</code></td> <td>contact id as text -> array of base64 32-byte tokens: the batches this account issued (§21.4), account state since §17</td> </tr> <tr> <td><code>read</code></td> <td>thread key -> ms: the read-through mark (§17.6)</td> </tr> </tbody> </table> <p>Every id is a <strong>string</strong> when it is an object key, because JSON object keys cannot be numbers, and an <strong>integer</strong> when it is a value. <code>mode</code> is lower case. <strong>Every timestamp in the payload is milliseconds</strong>, including <code>expires</code> 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 <code>storyAudience</code> as absent: absent restores as everyone, so a value it cannot read MUST fall to the narrowest reading it can justify, never the widest.</p> <p><strong>A story is identified by <code>key</code>, and <code>id</code> MUST NOT be written.</strong> Each story object carries <code>key</code>, <code>author</code>, <code>mine</code>, <code>caption</code>, <code>at</code>, and optionally <code>expires</code>, <code>att</code>, <code>gid</code>, <code>stream</code> and <code>set</code>. <code>key</code> 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.</p> <p><code>id</code> 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 <code>key</code>; the other put that identity straight in <code>id</code>. 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 <code>id</code> as the key for a file written before this was settled, and only when it is a string, because a numeric <code>id</code> is some other device's local handle and identifies nothing. <strong>Never put a local handle in a shared shape.</strong></p> <p>An attachment's <code>kind</code> follows where it hangs, not what its bytes are, so a story's attachment is always <code>story</code>.</p> <p><code>addrThreads</code> is keyed by the <strong>address itself</strong>, <code>fnid@relay</code>, 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 <code>addr:ada@relay.example</code>, 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 <code>stories[].id</code> above, and it is stated twice because it was got wrong twice: <strong>an internal storage detail does not belong in a shared shape.</strong></p> <p><code>reStory</code> on a message says the thing it quotes is a <strong>story</strong>, 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.</p> <p><code>seen</code> 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.</p> <p>Cards, events and games (§6.31 to §6.33) ride on the message they arrived on. <code>card</code> is the card object as it travelled. <code>ev</code> is the wire object of the latest accepted revision, with <code>org</code>, the organizer's user id as an integer, and <code>rsvp</code>, <code>{"<user id>": {"r": "yes", "n": 3}}</code>, beside it on the message. <code>game</code> is <code>{"id", "t", "me", "log", "r", "rby"}</code> on the message that started the game: <code>me</code> is the side this person plays, <code>r</code> an ending a <code>gend</code> brought, and <code>rby</code> the side that sent it, written only with <code>r</code>. Each of these messages carries <code>"text": ""</code>, so a sealed card's words are never written outside the card. The <code>canonical</code> set in <code>test/vectors/backup-payload-v1.json</code> carries a card, two events, a game and a group card.</p> <p>An engine MUST carry a field it does not itself use. This app's pins show only which message is pinned and not <code>at</code> or <code>by</code>, 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.</p> <p><code>test/vectors/backup-payload-v1.json</code> is the normative fixture, generated by <code>test/vectors/generate-backup-payload.mjs</code>. Its <code>canonical</code> object is the subset both engines carry <strong>without loss</strong>, and the bar is exact: decode it, encode it again, and the result must equal what is pinned. Its <code>divergences</code> 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.</p> <p>A field belongs in <code>canonical</code> 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. <code>storyArchive</code> and the story objects themselves are still out, because <code>stories[].id</code> is a number on one engine and a name on the other; see the divergence entry.</p> <p>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. <code>addrThreads</code> and <code>pins</code> 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.</p> <p><code>acc</code>, <code>blocked</code>, <code>convFlags</code>, <code>dk</code>, <code>deliveryTokensIssued</code> and <code>read</code> are specified ahead of either engine carrying them. Their shape is pinned in the fixture's <code>pending</code> object, not <code>canonical</code>, and each moves into <code>canonical</code> when both engines read and write it; until then both apps' round-trip tests are untouched by them. A restore merges <code>acc</code>, <code>blocked</code> and <code>convFlags</code> 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. <code>read</code> merges as a maximum per thread, <code>deliveryTokensIssued</code> per contact with the device's own entry winning, and <code>dk</code> as §16.1 says.</p> <p>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.</p> <h3 id="163-rules">16.3 Rules</h3> <ul> <li>A backup file is <strong>as sensitive as the plaintext history</strong>, because that is what it is.</li> <li>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.</li> <li>Restoring MUST NOT resurrect a <code>device_id</code>. The restoring device registers fresh.</li> <li>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.</li> <li>A backup MUST carry <strong>everything the sealed store keeps</strong> 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.</li> <li>The <strong>outbox is the one deliberate exception</strong>. 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.</li> <li>Story reactions merge <strong>per reactor, not per story</strong>: two devices can hold different halves of one story's tally, and taking a story's map wholesale drops the other half.</li> </ul> <hr /> <h2 id="17-multi-device">17. Multi-device</h2> <p>Status: <strong>DRAFT / planned.</strong> 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 <code>test/client.mjs</code>; 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.</p> <h3 id="171-what-is-already-solved-and-what-is-not">17.1 What is already solved, and what is not</h3> <p>Senders encrypt to <strong>every device</strong> of a recipient (§1), so <strong>inbound messages already reach all of a user's devices</strong>. Nothing needs to be built for that.</p> <p>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 <strong>linked</strong> devices that carries those, and nothing else ever travels on it.</p> <p>Two things are not solved by it, and are stated here so nobody reads them in:</p> <ul> <li><strong>No server-side history.</strong> 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.</li> <li><strong>Prekey pools are per device</strong>, 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).</li> </ul> <h3 id="172-sent-copies">17.2 Sent-copies</h3> <p>When a device sends anything to a person or a group, it also sends a copy to each of its account's <strong>other linked</strong> devices:</p> <pre class="json"><code>{ "fnc": 1, "kind": "sent", "ctl": 1, "to": 12, "body": "…", "at": 1787260000000, "mid": "m-1" }</code></pre> <ul> <li><code>to</code> names the person, or <code>gid</code> the group instead; never both.</li> <li><code>body</code> is the <strong>exact plaintext</strong> 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 inside <code>body</code>; the blob is never sent twice.</li> <li><code>at</code> is milliseconds. <code>mid</code> repeats the message id when there is one, so a device can de-duplicate without parsing <code>body</code>.</li> <li><code>ctl</code> is set although <code>sent</code> is 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 a <code>sent</code> without it.</li> </ul> <p>Receivers MUST render a <code>sent</code> copy as an outgoing message in the thread with <code>to</code> or <code>gid</code>, and MUST NOT notify on it.</p> <p>Not copied: control preludes (<code>delivery_key</code>, <code>kt_head</code>), traffic of this section itself, and anything sent <strong>off the record</strong> (§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.</p> <h3 id="173-linking-a-new-device">17.3 Linking a new device</h3> <p><strong>A new device of your own is cryptographically indistinguishable from one an attacker planted under your account.</strong> 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.</p> <p><strong>Joining.</strong> 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 <strong>not linked</strong>. Then a person links it from a device that already is:</p> <ol type="1"> <li><p>The new device shows a QR code:</p> <pre><code>flamenet:link?d=<device id>&k=<IK_sig, base64url>&s=<secret, base64url></code></pre> <p><code>s</code> is 16 random bytes, valid for ten minutes, replaced each time the code is shown. Byte strings are base64url without padding. <code>test/vectors/links-v1.json</code> pins the form.</p></li> <li><p>The existing device scans it, and before anything else checks three things against the relay: that <code>d</code> is a device of this account in the relay's own list (§8.3), that the relay lists it under exactly the key <code>k</code>, and that the key log holds no <code>revoke</code> or <code>expire</code> entry 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.</p></li> <li><p>It sends the new device, through an ordinary ratchet session, a <code>link_offer</code> (§17.6) carrying <code>s</code>, the linked set and the account delivery key, then a <code>sync_state</code> snapshot, then, if the person wants it, history (§17.4).</p></li> <li><p>The new device adopts the offer only if <code>s</code> matches 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.</p></li> </ol> <p>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.</p> <p>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.</p> <p><strong>The numeric fallback</strong>, 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 <code>link_offer</code> without <code>s</code>. The new device holds that offer and adopts nothing until the person confirms the same number <strong>on the new device too</strong>. A confirmation on one side only links nothing.</p> <p><strong>What crosses, and what never does.</strong> At link time the new device receives:</p> <ul> <li>the <strong>linked set</strong>, as <code>link/<device id></code> registers (§17.6);</li> <li>the <strong>account delivery key</strong> (§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;</li> <li>a <code>sync_state</code> snapshot 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);</li> <li>optionally, history (§17.4).</li> </ul> <p>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 <strong>pins</strong> 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.</p> <p><strong>Tokens a contact issued this account are not shared.</strong> 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 <strong>disjoint slice</strong> 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.</p> <p><strong>The linked set follows the key log.</strong> A device stops being linked when the person unlinks it, and also when the key log gains a <code>revoke</code> or <code>expire</code> 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 <code>revoke</code>, and for <code>expire</code> that <strong>the server</strong> 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).</p> <p><strong>The device cap.</strong> A relay limits how many devices one account may have registered (<code>FN_MAX_DEVICES</code>: 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 <code>expire</code> 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.</p> <p><strong>Contacts see a new device as a notice, not a refusal.</strong> A contact's client compares the keys the directory offers against the keys it pinned:</p> <table> <thead> <tr> <th>Offered against pinned</th> <th>Meaning</th> <th>What the client does</th> </tr> </thead> <tbody> <tr> <td>a new key, and none missing</td> <td>a device was added</td> <td>pin it, show a notice once, clear the verified mark</td> </tr> <tr> <td>a key missing, and none new</td> <td>a device was removed</td> <td>pin the smaller set, keep the verified mark</td> </tr> <tr> <td>a new key AND a missing one</td> <td>a key was <strong>substituted</strong></td> <td>refuse to send until the person accepts (§11)</td> </tr> </tbody> </table> <p>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 (<code>deviceAdded</code>). Android treats any new key as a change and refuses; it is aligned at stage A1 (decision DA2).</p> <p>From stage A5 an added device can also carry a <strong>link attestation</strong>, so a contact who had verified this account keeps the mark. The linking device signs, with its <code>IK_sig</code>:</p> <pre><code>"fnlink-v1|" || audience || "|" || user_id || "|" || new device_id || "|" || base64(new IK_sig) || "|" || decimal(at ms)</code></pre> <p>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 <strong>not</strong> 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.</p> <p><strong>Own-device traffic goes to linked devices only.</strong> 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.</p> <h3 id="174-history-backfill">17.4 History backfill</h3> <p>At link time, and only then, the existing device MAY send history to the new one:</p> <pre class="json"><code>{ "fnc": 1, "kind": "history", "ctl": 1, "seq": 1, "of": 12, "items": [ … ] }</code></pre> <p>Each item is <code>{"t": <thread key>, "mid", "at", "body"}</code> plus <code>"out": true</code> for this account's own messages or <code>"from": <user id></code> for anybody else's. <code>body</code> is the plaintext as §17.2 defines it. Thread keys are §16.2.1's.</p> <ul> <li><strong>Newest first</strong>, so the useful part arrives first.</li> <li><strong>At most 16 KiB of items per chunk</strong>, which always fits the larger message step (§23.3).</li> <li><strong>At most 10,000 messages</strong> in all (decision DA7). Attachments go by reference, never the bytes; a reference whose blob has expired at the relay is shown as expired.</li> <li><strong>Best effort.</strong> 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.</li> </ul> <h3 id="175-removing-a-device-and-a-lost-one">17.5 Removing a device, and a lost one</h3> <p>Unlinking takes a device out of the set; revoking (§8.3a) takes it off the account. Neither retracts what the device already received.</p> <p>A device that was <strong>lost or stolen</strong> knew everything this section shares, so retiring it for that reason rotates what it knew (decision DA8):</p> <ul> <li><p>the account delivery key, pushed to every contact that is not blocked (§14.4);</p></li> <li><p>every delivery-token batch this account issued (§21.4), revoked and re-issued;</p></li> <li><p>the presence tokens this account issued (§8.9c), replaced;</p></li> <li><p>sealed groups this account owns (§8.9b), re-keyed;</p></li> <li><p>contact links (§8.14), revoked;</p></li> <li><p>and to every contact, a <code>rekey_request</code> naming the device:</p> <pre class="json"><code>{ "fnc": 1, "kind": "rekey_request", "ctl": 1, "dev": "<device id>", "at": 1787260000000 }</code></pre> <p>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.</p></li> </ul> <p>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.</p> <p><strong>The one-device case.</strong> 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).</p> <h3 id="176-sync-ops">17.6 Sync ops</h3> <p>Every envelope in this section is a content envelope (§6.6) with <code>ctl: 1</code>, sent through ordinary ratchet sessions to this account's own linked devices and to no other, and submitted <strong>sealed to ourselves</strong> 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 <code>link_offer</code>, which it treats as §17.3 says.</p> <table> <thead> <tr> <th>Kind</th> <th>Carries</th> </tr> </thead> <tbody> <tr> <td><code>link_offer</code></td> <td><code>v: 1</code>; <code>s</code>, the QR secret, absent in the numeric fallback; <code>set</code>, the linked set as <code>[{"d": <device id>, "k": <base64 IK_sig>}]</code>, sender included; <code>dk</code>, the account delivery key; <code>at</code></td> </tr> <tr> <td><code>sync</code></td> <td>one op: <code>op</code>, <code>key</code>, <code>val</code> (absent for a removal), <code>at</code>, <code>dev</code></td> </tr> <tr> <td><code>sync_state</code></td> <td><code>seq</code>, <code>of</code>, <code>regs</code>: key -> <code>{op, val?, at, dev}</code>; <code>want: 1</code> asks for the receiver's own in return</td> </tr> <tr> <td><code>sent</code></td> <td>§17.2</td> </tr> <tr> <td><code>history</code></td> <td>§17.4</td> </tr> <tr> <td><code>rekey_request</code></td> <td>§17.5; sent to contacts, not to own devices</td> </tr> </tbody> </table> <p><strong>Registers.</strong> Each device keeps state as registers, one per key, each holding the last op it accepted for that key: <code>{op, val, at, dev}</code>, where <code>at</code> is the writing device's clock in milliseconds and <code>dev</code> its device id. The key's family, the text before its first <code>/</code>, decides which ops it takes and how they merge:</p> <table> <thead> <tr> <th>Family</th> <th>Ops</th> <th>Value</th> <th>Merge</th> </tr> </thead> <tbody> <tr> <td><code>link/<device id></code></td> <td><code>add</code>, <code>rm</code></td> <td>base64 <code>IK_sig</code></td> <td>as a register</td> </tr> <tr> <td><code>roster/<user id></code></td> <td><code>add</code>, <code>rm</code></td> <td><code>{"h": handle, "n": name}</code></td> <td>as a register</td> </tr> <tr> <td><code>blocked/<user id></code></td> <td><code>add</code>, <code>rm</code></td> <td>none</td> <td>as a register, but a block wins a tie</td> </tr> <tr> <td><code>acc/<user id></code></td> <td><code>add</code>, <code>rm</code></td> <td>none</td> <td>as a register</td> </tr> <tr> <td><code>muted/<user id></code></td> <td><code>add</code>, <code>rm</code></td> <td>none</td> <td>as a register</td> </tr> <tr> <td><code>read/<thread key></code></td> <td><code>max</code></td> <td>epoch ms of the newest message read</td> <td>the greater value</td> </tr> <tr> <td><code>conv/<thread key></code></td> <td><code>set</code>, <code>del</code></td> <td><code>{"starred": true, "archived": true}</code>, flags only when true</td> <td>as a register</td> </tr> <tr> <td><code>gname/<gid></code>, <code>gsecret/<gid></code></td> <td><code>set</code>, <code>del</code></td> <td>as §16.2.1's <code>groupNames</code>, <code>groupSecrets</code></td> <td>as a register</td> </tr> <tr> <td><code>pin/<thread key></code></td> <td><code>set</code>, <code>del</code></td> <td>as §16.2.1's <code>pins</code></td> <td>as a register</td> </tr> <tr> <td><code>audience</code></td> <td><code>set</code></td> <td>as §16.2.1's <code>storyAudience</code></td> <td>as a register</td> </tr> <tr> <td><code>pdk/<user id></code></td> <td><code>set</code>, <code>del</code></td> <td>a contact's delivery key</td> <td>as a register</td> </tr> <tr> <td><code>tok/<user id></code></td> <td><code>set</code>, <code>del</code></td> <td>the batch this account issued that contact</td> <td>as a register</td> </tr> <tr> <td><code>ptok/i/<user id></code>, <code>ptok/h/<user id></code></td> <td><code>set</code>, <code>del</code></td> <td>presence tokens issued and held</td> <td>as a register</td> </tr> <tr> <td><code>dk</code></td> <td><code>set</code></td> <td>the account delivery key, after a rotation</td> <td>as a register</td> </tr> <tr> <td>anything else</td> <td><code>set</code>, <code>del</code></td> <td>anything</td> <td>as a register</td> </tr> </tbody> </table> <p><strong>The merge rule.</strong> An incoming op replaces the register when:</p> <ol type="1"> <li>its <code>at</code> is later; or</li> <li>its <code>at</code> is equal, the key is <code>blocked/…</code>, and it is the block (<code>add</code>) where the register holds the unblock: two devices disagreeing in the same millisecond leave the person blocked; or</li> <li>its <code>at</code> is equal and its <code>dev</code> is greater, compared as text.</li> </ol> <p>Equal <code>at</code> and equal <code>dev</code> is the same op seen twice, because a device MUST NOT write one key twice in a millisecond (it moves <code>at</code> on by one instead), and the register keeps what it has. <code>read/…</code> is the exception to all of this: the greater value wins whatever its <code>at</code>, 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 <code>at</code> is not a non-negative integer, or whose <code>dev</code> is empty, is ignored, never half applied. A device keeps, and passes on in snapshots, registers for keys it does not understand.</p> <p>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. <code>test/vectors/sync-merge-v1.json</code> pins it, with a case for every tie, checked in every order.</p> <p><strong>Clocks.</strong> 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.</p> <p><strong>Removals are remembered for 90 days.</strong> An <code>rm</code> or <code>del</code> 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 <code>add</code> for something removed and since forgotten, and offering it would bring the removed thing back. So a device that has not polled for <strong>90 days or more</strong> discards its own registers (keeping its <code>link/…</code> ones) and rebuilds from its linked devices' snapshots, offering only its <code>link/…</code> registers until it has.</p> <p><strong>Snapshots.</strong> A <code>sync_state</code> 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 <strong>7 days or more</strong> sends its own with <code>want: 1</code>, 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.</p> <p><strong>Read marks are coalesced</strong> (decision DA6): a device sends at most one <code>read/…</code> op per thread per 30 seconds, and the latest when the window passes. Reading a long thread is otherwise an op per message.</p> <p><strong>Per-device digests on the relay.</strong> 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 <strong>account and device</strong> 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:</p> <pre class="json"><code>{ "features": { "perDeviceDigests": true } }</code></pre> <p>A client that sees <code>perDeviceDigests</code> MUST republish its delivery-key digest once, and from then on send <code>device_id</code> 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.</p> <h3 id="177-stages">17.7 Stages</h3> <table> <thead> <tr> <th>Stage</th> <th>What</th> <th>Reversible</th> </tr> </thead> <tbody> <tr> <td>A0</td> <td>This section, <code>sync-merge-v1.json</code>, the pending content cases, the backup keys; the web client.</td> <td>yes</td> </tr> <tr> <td>A1</td> <td>Both apps: own-device traffic to linked devices only; Android shows an added device as iOS does.</td> <td>yes</td> </tr> <tr> <td>A2</td> <td>Linking (QR and numeric), the account delivery key adopted at link time; the relay keys digests by device (<code>perDeviceDigests</code>).</td> <td>yes</td> </tr> <tr> <td>A3</td> <td>Sent-copies and ongoing sync; per-device token slices; presence tags as the union of the devices'.</td> <td>yes</td> </tr> <tr> <td>A4</td> <td>History backfill.</td> <td>yes</td> </tr> <tr> <td>A5</td> <td>Lost-device rotations, link attestations, the scheduled export.</td> <td>the code, yes</td> </tr> </tbody> </table> <p>Nothing in any stage deletes a key-log row (§19.1).</p> <hr /> <h2 id="18-implementation-status-for-16-and-17">18. Implementation status for §16 and §17</h2> <table> <thead> <tr> <th></th> <th>Web engine</th> <th>Web client</th> <th>Server</th> <th>iOS</th> <th>Android</th> </tr> </thead> <tbody> <tr> <td>§16 encrypted backups</td> <td><code>createBackup</code> / <code>openBackup</code></td> <td><code>exportBackup</code> / <code>importBackup</code></td> <td>n/a, no server route, by design</td> <td>export and import, in Settings</td> <td>export and import, in Settings</td> </tr> <tr> <td>§17 multi-device</td> <td><code>sync.js</code>: the register merge and the link code</td> <td>QR and numeric linking, sync ops and snapshots, sent-copies of every kind, history, lost-device rotation</td> <td>per-device digests from stage A2; attestations from A5; nothing that brokers a link</td> <td>no</td> <td>no</td> </tr> </tbody> </table> <p><strong>The iOS cell for §16 read <code>no</code> until 2026-09-21 and had stopped being true.</strong> <code>Sources/Views/Settings/DataStorageView.swift</code> offers "Export chat history…" and "Import chat history…", the file is sealed with a passphrase the person chooses, and <code>createBackup</code> has two call sites. Nothing continuous and no server copy, which is §16 as written rather than a lesser version of it.</p> <p><strong>The Android cell read <code>no</code> until 2026-09-29, and had been wrong since 2026-08-28</strong> (<code>6a0f642</code> in flamenet-messenger-android). Since 2026-09-10 (<code>5a5e41b</code>, released in v0.30) it writes and reads the shared payload of §16.2.1, the same one iOS does, carrying the roster, <code>rosterOffServer</code>, group secrets and names, and the presence tokens issued and held. Settings has a <code>HistoryBackupSection</code> that exports and imports, through <code>MessengerViewModel.exportHistory</code> / <code>importHistory</code>, over the engine's <code>Backup.create</code> / <code>Backup.open</code>, and <code>BackupVectorTest</code> checks that engine against <code>test/vectors/backup-v1.json</code> from this repository.</p> <p>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 <code>no</code> needs checking exactly as hard as <code>yes</code>.</p> <p>§17 on iOS was checked at the same time and is correctly <code>no</code>: 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 <strong>every</strong> 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.</p> <p>§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.</p> <p>Covered by <code>test/client.mjs</code>, including the three failure modes that make these features dangerous if implemented naively:</p> <ul> <li>a backup payload containing <strong>no ratchet state and no identity</strong> (§16.1);</li> <li>a restore whose local pin disagrees with the backup <strong>reporting a conflict and keeping the local pin</strong>, rather than silently letting a stale backup re-trust a rejected key;</li> <li>a sent-copy, a sync op or a <code>link_offer</code> from an own-device that was <strong>never linked</strong> being ignored or held for a person, even though the ratchet authenticates it as ours;</li> <li>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.</li> </ul> <p>The web client's linking was, until 2026-10, a flag a caller set by hand (<code>linkDevice(deviceId, true)</code>): 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 <code>test/vectors/sync-merge-v1.json</code> in <code>test/conformance.mjs</code>, 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.</p> <p>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 <code>perDeviceDigests</code> (it publishes no roster tags).</p> <p><strong>iOS and Android have backups, as the table says, and neither has sent-copies.</strong> Nothing about either is negotiated. A <code>sent</code> copy written as §17.2 now specifies carries <code>ctl: 1</code>, 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.</p> <h3 id="181-messenger-032">18.1 Messenger 0.32</h3> <table> <thead> <tr> <th></th> <th>Web engine</th> <th>Relay</th> <th>iOS</th> <th>Android</th> </tr> </thead> <tbody> <tr> <td>§6.12 a site from the directory</td> <td>none</td> <td>n/a</td> <td>built</td> <td>built</td> </tr> <tr> <td>§6.15 a story's caption, §6.35 taking a story down</td> <td>none</td> <td>n/a</td> <td>in progress</td> <td>in progress</td> </tr> <tr> <td>§6.31 greeting cards</td> <td>none</td> <td>n/a</td> <td>built</td> <td>built</td> </tr> <tr> <td>§6.32 events and answers</td> <td>none</td> <td>n/a</td> <td>built</td> <td>built</td> </tr> <tr> <td>§6.33 games in a chat</td> <td>none</td> <td>n/a</td> <td>built</td> <td>built</td> </tr> <tr> <td>§6.34 hello links</td> <td>none</td> <td>the <code>/hi</code> page, built</td> <td>built</td> <td>built</td> </tr> <tr> <td>§8.12 the Today file</td> <td>none</td> <td>built</td> <td>built</td> <td>built</td> </tr> <tr> <td>§9 the announced bit</td> <td>n/a</td> <td>built</td> <td>n/a</td> <td>n/a</td> </tr> <tr> <td>§16.2.1 cards, events and games in a backup</td> <td>none</td> <td>n/a</td> <td>built</td> <td>built</td> </tr> </tbody> </table> <p>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. <code>test/conformance.mjs</code> checks their vectors with readers written from the spec, and checks the tic-tac-toe and reversi cases with a referee of its own.</p> <p>Where an app's 0.32 feature branch did not follow these sections, the vector file records the input under <code>history</code>, 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. <code>test/relay-today.mjs</code> drives <code>/today.json</code> on a relay built with §8.12, and skips until a relay answers that route.</p> <h2 id="19-key-transparency">19. Key transparency</h2> <p>Status: <strong>IMPLEMENTED</strong> on the web engine, the relay and iOS. <strong>NOT on Android</strong>, which carries <code>KeyLog.kt</code> and calls it from nowhere. §19.5 records what has been run for each.</p> <p>§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.</p> <p>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.</p> <h3 id="191-the-log">19.1 The log</h3> <p>An append-only log in the RFC 6962 shape. Leaves are hashed under <code>0x00</code> and interior nodes under <code>0x01</code>, 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.</p> <p>Every device key the relay serves is appended <strong>before</strong> 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.</p> <h3 id="192-leaf-contents">19.2 Leaf contents</h3> <pre><code>leaf = SHA-256( 0x00 || "v2|" || kind || "|" || subject || "|" || device_id || "|" || ik_sig || "|" || ik_dh )</code></pre> <p><code>kind</code> is <code>publish</code>, <code>revoke</code> or <code>expire</code>. <code>revoke</code> is a person removing a device; <code>expire</code> 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 <code>expire</code> 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.</p> <p><strong><code>subject</code> replaced <code>user_id</code> in v2.</strong> A relay's <code>user_id</code> 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.</p> <p>A subject is one of two shapes, and which one it is is readable from the string:</p> <table> <thead> <tr> <th>Form</th> <th>Meaning</th> </tr> </thead> <tbody> <tr> <td><code>fnid:<crockford-base32></code></td> <td>A global account identity: the account's Ed25519 public key. Portable, the same person at another relay produces the same subject.</td> </tr> <tr> <td><code>local:<audience>/<user_id></code></td> <td>A relay-local row number, qualified by the relay that assigned it.</td> </tr> </tbody> </table> <p>The <code>local:</code> 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.</p> <p>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.</p> <h3 id="193-routes">19.3 Routes</h3> <p>All four are <strong>unauthenticated</strong>. 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.</p> <table> <tbody> <tr> <td><code>GET /e2e/log/head</code></td> <td><code>{size, root, signature, audience}</code></td> </tr> <tr> <td><code>GET /e2e/log/inclusion?index=&size=</code></td> <td>audit path for one leaf</td> </tr> <tr> <td><code>GET /e2e/log/consistency?from=&to=</code></td> <td>proof that <code>from</code> is a prefix of <code>to</code></td> </tr> <tr> <td><code>GET /e2e/log/device/{device_id}</code></td> <td>every entry for a device, with its index</td> </tr> </tbody> </table> <p>The head is signed with the relay's issuer key over <code>"fnkt-v1|" || size || "|" || base64(root)</code>. The signature is what makes a fork <em>evidence</em>: two heads of the same size with different roots, both validly signed, are a statement the relay cannot retract or blame on a client.</p> <p><code>size</code> 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.</p> <h3 id="194-what-this-does-and-does-not-buy">19.4 What this does and does not buy</h3> <p><strong>Does.</strong> 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.</p> <p><strong>Does not, on its own.</strong> 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 <em>between</em> clients: §19.4.</p> <p><strong>And the signature is not a second opinion.</strong> §19.3 says the head signature is what makes a fork <em>evidence</em>, 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 <code>FN_ISSUER_PRIVATE_KEY</code>, 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.</p> <p>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 <strong>not deployed and not specified here</strong>. The relay serves a <code>witnesses</code> field on <code>GET /e2e/log/head</code>, 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.</p> <h3 id="194a-gossip">19.4a Gossip</h3> <p>Clients exchange the head each was last shown as a control envelope (<code>kind: "kt_head"</code>) <strong>inside the ratchet</strong>. 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.</p> <p>On receiving a peer's head:</p> <ul> <li><strong>Equal size, equal root</strong>: agreement, nothing to do.</li> <li><strong>Equal size, different root</strong>: a fork on its face. No proof is requested, because none could exist.</li> <li><strong>Different sizes</strong>: ask the relay to prove the shorter is a prefix of the longer. Detection works precisely because <em>the relay is the only party that could produce that proof</em>, and if it served two branches, it cannot. A refusal and a bad proof are treated identically.</li> </ul> <p>Once a longer head is proven to extend ours, it is adopted.</p> <p>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.</p> <p>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.</p> <p>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 <em>cannot</em> answer, never for skipping the ones that can. Two rules keep that distinction from becoming a downgrade the relay controls:</p> <ol type="1"> <li><p>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 <em>middle</em> 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.</p></li> <li><p>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 <em>contact</em> 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.</p></li> </ol> <p><code>requireKeyLog</code> 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.</p> <p>Both subject forms are accepted for a leaf. A leaf names its subject as <code>fnid:</code> once that account has claimed an address and <code>local:<audience>/<id></code> 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 <em>that person</em>, which needs the peer's address pinned in the contact record.</p> <h3 id="195-implementation-status">19.5 Implementation status</h3> <table> <thead> <tr> <th></th> <th>Relay</th> <th>Web engine</th> <th>iOS</th> <th>Android</th> </tr> </thead> <tbody> <tr> <td>§19.1–19.3 log, proofs, signed head</td> <td>yes</td> <td><code>src/keylog.js</code></td> <td><code>KeyLog.swift</code></td> <td><code>KeyLog.kt</code></td> </tr> <tr> <td>Client demands a proof before first use</td> <td>n/a</td> <td>automatic</td> <td>automatic</td> <td>automatic</td> </tr> <tr> <td>Checks the head signature against a pinned issuer key</td> <td>n/a</td> <td>yes</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>Refuses a relay that serves no log</td> <td>n/a</td> <td>opt-in</td> <td>opt-in</td> <td>opt-in</td> </tr> <tr> <td>A relay that stops answering reads as a fork</td> <td>n/a</td> <td>yes</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>Append-only pinning across sessions</td> <td>n/a</td> <td>yes</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>§19.4 gossip, consumes a peer's head</td> <td>n/a</td> <td>yes</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>§19.4 gossip, sends its own head</td> <td>n/a</td> <td>yes</td> <td>yes</td> <td>yes</td> </tr> </tbody> </table> <p>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.</p> <p>The Android call sites are in <code>client/Trust.kt</code>, reached from <code>client/Conversations.kt</code>: <code>enforceKeyLogIfServed</code> per served bundle, <code>verifyLogConsistencyIfDue</code> on a timer, <code>headPayload</code> to send a head and <code>gossip</code> to consume one. The pin and the served flag are per relay host in the vault, so both survive a restart.</p> <p>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 <code>engine/</code>, but its clients live in <code>client/</code>, and a search of <code>engine/</code> and <code>app/</code> alone finds none of the call sites above and reports a working implementation as absent. §15 records the same error made the other way.</p> <p>Verification is checked across engines and against the running relay, not transitively: <code>test/interop.mjs</code> proves the two engines compute the same leaf hash and both reject a reversed audit path, and <code>test/relay.mjs</code> drives the iOS verifier against proofs the real relay produced. Transitive agreement would leave a shared misreading of the wire format invisible.</p> <p>The web engine recomputes the leaf <strong>from the served bundle</strong> rather than trusting the leaf the relay returns, echoing back its own bytes would otherwise satisfy the check, and refuses with <code>KeyNotLoggedError</code> or <code>KeyLogForkError</code>. The head is pinned on first sight and every later fetch must prove consistency with it.</p> <p>The head signature is a separate row because inclusion and consistency both reduce to <code>root</code>, and <code>root</code> 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 <code>/.well-known/flamenet/relay</code>, 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.</p> <p><strong>Audit-path ordering.</strong> 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. <code>test/relay.mjs</code> verifies <strong>every</strong> 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.</p> <h2 id="20-post-quantum-authentication">20. Post-quantum authentication</h2> <p>Status: <strong>IMPLEMENTED</strong> 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.</p> <p>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.</p> <p>§12 makes key <em>agreement</em> 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.</p> <h3 id="201-construction">20.1 Construction</h3> <p>A device MAY publish an <strong>ML-DSA-65</strong> identity public key (<code>ik_pq</code>, 1952 bytes) alongside its Ed25519 one, and sign its signed prekey with both:</p> <pre><code>spk_sig = Ed25519-Sign(ik_sig_priv, spk) # §3, unchanged spk_pq_sig = ML-DSA-65-Sign(ik_pq_priv, spk) # 3309 bytes</code></pre> <p>Both cover <strong>the same bytes</strong>. A verifier that holds <code>ik_pq</code> MUST check both and accept only if both pass.</p> <p>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.</p> <h3 id="202-optional-by-design">20.2 Optional by design</h3> <p><code>ik_pq</code> is absent on a device that has not published one, and a verifier treats absence as <em>classical only</em> rather than as an error. A required field would make every existing device unreachable the day it shipped.</p> <p>This is deliberately <strong>independent of the §12 v2 block</strong>: post-quantum authentication and post-quantum key agreement are separate capabilities, and a device may have either without the other. The relay therefore serves <code>ik_pq</code> whether or not the device has a complete v2 block.</p> <h3 id="203-key-derivation-and-storage">20.3 Key derivation and storage</h3> <p>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.</p> <h3 id="204-relay-behaviour">20.4 Relay behaviour</h3> <p>The relay does not verify identity signatures, it never has, and §8 is explicit that verification is the client's job. It validates <em>lengths</em> 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.</p> <h3 id="205-downgrade">20.5 Downgrade</h3> <p>A relay that strips <code>ik_pq</code> 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 <strong>downgrade and must be refused</strong>, exactly as §12.9 requires for the v2 block.</p> <p>The web engine pins the <strong>fingerprint</strong> 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 <em>with</em> one may never afterwards appear without it.</p> <p>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.</p> <h3 id="206-implementation-status">20.6 Implementation status</h3> <table> <thead> <tr> <th></th> <th>Relay</th> <th>Web engine</th> <th>iOS</th> <th>Android</th> </tr> </thead> <tbody> <tr> <td>§20.1 publish and serve</td> <td>yes</td> <td>yes</td> <td>yes</td> <td>yes, 0.29</td> </tr> <tr> <td>§20.1 verify both signatures</td> <td>n/a, not the relay's job</td> <td>yes</td> <td>yes</td> <td>yes, 0.29</td> </tr> <tr> <td>§20.5 downgrade + substitution pinning</td> <td>n/a</td> <td>yes</td> <td>yes</td> <td>yes, 0.29</td> </tr> </tbody> </table> <p>Android shipped this in <strong>0.29</strong>, published 2026-09-21. Both halves: <code>DeviceIdentity</code> mints and signs, and <code>client/Conversations.kt</code> calls <code>PQIdentity.check</code> from <code>sessionFor</code>, which is the new-session path §20.5 names and the only moment a downgrade can be attempted.</p> <p>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 <code>ik_pq</code> 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.</p> <p>What has been run for Android, as of 2026-09-21:</p> <ul> <li><code>PQSign</code> is ML-DSA-65 on BouncyCastle 1.79. <code>PQSignVectorTest</code> derives the published seed's public key and compares it byte for byte against <code>protocol-v1.json</code>, which is the cross-engine agreement FIPS 204 keygen determinism allows.</li> <li><strong>Bidirectional interop with the web engine was run directly</strong>, 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.</li> <li><code>DeviceIdentityPQAuthTest</code> pins the wiring: the identity is minted, survives an archive round trip under the web engine's <code>ikPQSeed</code> key, and <code>spk_pq_sig</code> covers exactly the bytes <code>spk_sig</code> covers. It also pins §20.2, that <code>ik_pq</code> is published even when the v2 block is not.</li> <li><code>PQIdentityPinTest</code> covers §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.</li> </ul> <p><strong>Run on a device, 2026-09-21, after the release.</strong> The published 0.29 APK was installed on an emulator, pointed at relay1, and used to create an account. The relay's <code>devices</code> row for it carries <code>ik_pq</code> at 1952 bytes and <code>spk_pq_sig</code> at 3309 bytes, the ML-DSA-65 sizes, and <strong>the web engine verifies the signature that phone produced</strong>, refuses it once a byte is flipped, and refuses it over any other <code>spk</code>. That is the cross-implementation check the unit tests cannot make: a device signed, and an independent implementation believed it.</p> <p>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.</p> <p><strong>One behaviour to know about.</strong> A device restored from an archive written before §20 is <strong>not</strong> 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.</p> <p>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.</p> <p>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 <code>PQDowngradeError</code>, <code>PQVerificationError</code> or <code>PQIdentityChangedError</code> rather than falling back quietly. Covered by <code>test/client.mjs</code> (a stripped identity and a forged signature are both refused) and <code>test/relay.mjs</code> (the signature is verified over a bundle the real relay served).</p> <p><strong>iOS does all of it too.</strong> <code>PQSign</code> (ML-DSA-65 on CryptoKit) mints and verifies, <code>E2EKeys</code> signs the prekey, and <code>E2EInbox</code> throws the same three refusals as the web engine: <code>PQDowngradeError</code> when a pinned identity goes missing, <code>PQVerificationError</code> when the signature fails, and <code>PQIdentityChangedError</code> on substitution. <code>ProtocolVectorTests</code> 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.</p> <p><strong>Android does all of it too</strong>, as of 0.29. <code>PQSign</code> is ML-DSA-65 on BouncyCastle, <code>DeviceIdentity</code> publishes and signs, and <code>Conversations</code> enforces the same three refusals on the new-session path.</p> <h2 id="21-anonymous-sender-credentials">21. Anonymous sender credentials</h2> <h3 id="211-what-exists-and-what-it-does-not-do">21.1 What exists, and what it does not do</h3> <p>Sealed submission (§14) is gated by a <strong>delivery key</strong>: 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.</p> <p>The limitation is precise: the key is a <strong>shared</strong> 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.</p> <h3 id="212-why-the-obvious-fix-is-a-trade-not-a-win">21.2 Why the obvious fix is a trade, not a win</h3> <p>The natural repair is a <strong>distinct delivery key per contact</strong>: revoke one digest, and only that contact is cut off. It needs no new cryptography.</p> <p>It also makes the metadata worse. Today the relay sees <em>"somebody holding Bob's key wrote to Bob."</em> With per-contact keys it sees <em>"the holder of Bob's key #3 wrote to Bob"</em>, 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.</p> <p>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 <strong>not</strong> implemented for that reason, §21.4 takes a third option that gets the revocation without the pseudonym.</p> <h3 id="213-the-construction-that-gets-both">21.3 The construction that gets both</h3> <p>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.</p> <p>Sketch, in the shape this protocol would need:</p> <ol type="1"> <li><strong>Issuance.</strong> 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 <code>(sender_blinding, recipient_id)</code>.</li> <li><strong>Presentation.</strong> 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.</li> <li><strong>Revocation.</strong> Bob rotates only the attribute that scopes Alice's credential; everyone else's continues to verify.</li> </ol> <p>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.</p> <h3 id="214-what-is-implemented-single-use-delivery-capabilities">21.4 What is implemented: single-use delivery capabilities</h3> <p>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.</p> <p><strong>Issuance.</strong> The recipient mints a batch of random 32-byte tokens per contact, uploads only their <code>SHA-256</code> hashes to <code>POST /e2e/delivery-tokens</code>, and hands the tokens themselves to that contact inside the ratchet. The relay stores <code>(user_id, token_hash)</code> and <strong>nothing else, no row names a sender, and none may.</strong> 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 <code>tok/<user id></code> registers (§17.6), so any of them can count and revoke.</p> <p><strong>One slice per device.</strong> 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.</p> <p><strong>Presentation.</strong> A sealed submission carries <code>delivery_token</code> instead of <code>delivery_key</code>. 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.</p> <p><strong>Revocation.</strong> 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.</p> <p><strong>Replenishment</strong> 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.</p> <p>The batch rides the existing <code>delivery_key</code> control envelope, as an extra <code>tokens</code> 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 <code>ctl</code> flag only for kinds it has never heard of, and the Android build in the field reads that flag with <code>optBoolean</code>, 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.</p> <h4 id="what-it-buys-over-144">What it buys over §14.4</h4> <ul> <li><strong>Per-contact revocation</strong>, which is the whole complaint of §21.1.</li> <li><strong>Per-message unlinkability.</strong> 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.</li> </ul> <h4 id="what-it-does-not-buy">What it does not buy</h4> <ol type="1"> <li><strong>A relay that records when rows were inserted can group a batch, and a batch is one sender.</strong> 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.</li> <li>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.</li> <li><strong>Revocation moves a sender back onto the shared key; it does not silence them.</strong> A complete cut-off is revoking the batch <em>and</em> 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.</li> </ol> <p>None of that is closed without the algebraic MAC and zero-knowledge proof of §21.3, whose prerequisites are unchanged and listed below.</p> <p><strong>Blocking is both doors.</strong> 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 <code>blocked/<user id></code> op, which wins a tie (§17.6).</p> <p>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).</p> <h3 id="215-why-the-credential-scheme-is-still-not-implemented">21.5 Why the credential scheme is still not implemented</h3> <p>Implementing algebraic MACs and their proofs correctly is not a weekend of work, and a subtly wrong zero-knowledge proof fails <em>open</em>, 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).</p> <p>The prerequisites, in order:</p> <ul> <li>a vetted Ristretto255 implementation with the constant-time properties the scheme assumes (the vendored <code>@noble/curves</code> provides the group; the credential scheme is what is missing);</li> <li>a written proof transcript specification, so two implementations agree on what is being proven;</li> <li>review by someone who has implemented this class of protocol before.</li> </ul> <p>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.</p> <h2 id="22-private-group-membership">22. Private group membership</h2> <h3 id="221-what-exists">22.1 What exists</h3> <p>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.</p> <p>This is the largest remaining metadata leak in the protocol. Message <em>contents</em> in a group are end-to-end encrypted; <em>who is in it</em> is not protected at all.</p> <p>The group's <strong>name</strong> 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.</p> <h3 id="222-what-it-would-take">22.2 What it would take</h3> <p>The same primitive as §21, applied to membership rather than sending:</p> <ol type="1"> <li>On joining, a member receives a <strong>group membership credential</strong>: an algebraic MAC over a commitment to <code>(group_id, member_blinding)</code>.</li> <li>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.</li> <li>The relay stores an opaque group state it cannot enumerate, and applies changes it can verify are authorised without learning by whom.</li> </ol> <h3 id="223-the-part-that-does-not-follow-from-the-credential">22.3 The part that does not follow from the credential</h3> <p>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.</p> <p>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.</p> <h3 id="224-status">22.4 Status</h3> <p>Not implemented, not partially implemented, and not planned. That last word is a decision rather than a delay, and this is why.</p> <p>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.</p> <p>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.</p> <p>So the position is: this is a <strong>limit of the protocol as built</strong>, recorded here and disclosed on the site's metadata page.</p> <p>What §8.9b changed, and what it did not. Sealing the roster (§8.9b) means the relay no longer <em>stores</em> who is in a group: there is no <code>gid -> user_id</code> 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 <strong>running</strong> relay can <em>observe</em>, 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.</p> <p>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.</p> <p>The group's <em>name</em> 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.</p> <hr /> <h2 id="23-length-hiding">23. Length hiding</h2> <p>Status: <strong>SPECIFIED, rollout stage 1.0.</strong> The web engine implements it; the relay and both apps do not yet. §23.11 records exactly what exists.</p> <h3 id="231-what-this-hides-and-what-it-does-not">23.1 What this hides, and what it does not</h3> <p>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.</p> <p>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).</p> <p>It does not hide:</p> <ul> <li>how many envelopes a device sends and receives, and when;</li> <li>the recipient, whether a row is sealed, and the sender's IP address (§14.2, §14.6);</li> <li>which step a payload landed in. A 64 KiB payload says "long", which is coarser than the length but not nothing;</li> <li><strong>the unpadded length of an unsealed envelope.</strong> The relay can read an unsealed envelope's JSON, so it can read the length of <code>ct</code> directly, 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.</li> </ul> <h3 id="232-where-the-pad-lives">23.2 Where the pad lives</h3> <p>At the transport layer, not in the content envelope (§6.6). Three reasons:</p> <ul> <li>Not every message has a content envelope. One client sends plain text as bare plaintext with no <code>fnc</code> and no <code>mid</code> (§6.9), and padding only what has an envelope would leave those exactly as long as they are.</li> <li>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.</li> <li>JSON writers disagree about escaping <code>/</code>. Swift's <code>JSONEncoder</code> and <code>org.json</code> write <code>\/</code>; JavaScript writes <code>/</code>. The base64 inside an envelope is full of slashes, so a padder working inside the ratchet cannot predict the length the wire will see.</li> </ul> <p>So the pad goes in the outermost JSON object each sender writes itself, where the length it controls is the length that is sent:</p> <ul> <li><strong>sealed sends</strong> (§14.3): a <code>pad</code> key in the seal's inner JSON, under the seal's encryption;</li> <li><strong>unsealed sends</strong>: a <code>pad</code> key in the ratchet envelope (§6.4), beside <code>ct</code>, outside the ratchet's AEAD. Nothing authenticates it and nothing reads it.</li> </ul> <p>Clients in the field ignore an unknown key in both places: iOS decodes the seal's inner JSON and the envelope with synthesized <code>Codable</code>, unchanged since 0.25.0, and Android reads both with <code>optX</code>. 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.</p> <h3 id="233-the-message-ladder">23.3 The message ladder</h3> <pre><code>PAD_MESSAGE_STEP_1 = 16384 PAD_MESSAGE_STEP_2 = 65536 PAD_MESSAGE_LADDER = [PAD_MESSAGE_STEP_1, PAD_MESSAGE_STEP_2]</code></pre> <p>Lengths are in characters of the <code>payload</code> 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 <code>=</code> padding and the arithmetic in §23.5 is exact.</p> <p><code>PAD_MESSAGE_STEP_1</code> 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.</p> <p>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.</p> <h3 id="234-the-pad">23.4 The pad</h3> <p><code>pad</code> is a JSON string of characters drawn uniformly from <code>[A-Za-z0-9]</code> 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.</p> <p><code>pad</code> MAY be empty. Senders write it as the last key; receivers MUST NOT depend on that.</p> <h3 id="235-sealed-payloads">23.5 Sealed payloads</h3> <p>For a step <code>B</code>:</p> <pre><code>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)</code></pre> <p>For 16384 that is E = 12288, C = 12220, I = 9149. For 65536 it is E = 49152, C = 49084, I = 36797.</p> <p>The 68 is the canonical outer form, which a padding sender MUST write byte for byte, by template rather than with a JSON library:</p> <pre><code>{"s":1,"eph":"<44 characters>","ct":"<C characters>"}</code></pre> <p>Keys in this order, no spaces, <code>/</code> never escaped. <code>eph</code> is always 44 characters, 32 bytes of base64. A writer that escapes <code>/</code> adds a character for every slash in <code>eph</code> and <code>ct</code>, about one in 32, and the length misses the step. §14.3's wire shape is unchanged; this pins how it is written.</p> <p>The inner JSON is §14.3's <code>from_user</code>, <code>from_device</code> and <code>envelope</code>, plus <code>pad</code>, padded to <code>I</code>. The envelope inside it carries no pad of its own.</p> <h3 id="236-unsealed-payloads">23.6 Unsealed payloads</h3> <p>The ratchet envelope's JSON (§6.4) plus <code>pad</code>, padded to <code>E = 3B / 4</code>: 12288 or 49152 bytes. Its base64 is then exactly <code>B</code> characters. The pad is not part of <code>header_bytes</code> and not part of the AAD.</p> <h3 id="237-the-algorithm-the-length-check-and-the-refusal">23.7 The algorithm, the length check, and the refusal</h3> <p>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 <code>T(B)</code> is the target for step <code>B</code> from §23.5 (sealed) or §23.6 (unsealed):</p> <ol type="1"> <li>Serialize the object without <code>pad</code>, with the writer that will write the payload. Call its UTF-8 length <code>L0</code>.</li> <li>For each <code>B</code> in the ladder, ascending, let <code>n = T(B) - L0 - 9</code>. The 9 is what <code>,"pad":""</code> costs. Take the first <code>B</code> with <code>n >= 0</code>.</li> <li>If there is none, refuse (below).</li> <li>Serialize again with <code>pad</code> set to <code>n</code> random characters (§23.4).</li> <li>Encode: for a sealed send, seal it and write the canonical outer form; then base64.</li> <li><strong>Check that the <code>payload</code> string is exactly <code>B</code> characters.</strong> 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.</li> </ol> <p>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.</p> <p><strong>Too long to send.</strong> 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.</p> <p>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.</p> <p>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.</p> <h3 id="238-receivers">23.8 Receivers</h3> <p>Receivers MUST ignore <code>pad</code> 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.</p> <h3 id="239-attachments">23.9 Attachments</h3> <p>Attachment blobs (§6.6, §8.7) have their own ladder, in bytes:</p> <pre><code>PAD_ATTACHMENT_LADDER = 64 KiB x 2^k for k = 0..7, then 10 MiB = 65536, 131072, 262144, ..., 8388608, 10485760</code></pre> <p>The top step is the relay's blob cap (§8.7), so nothing that fits today stops fitting.</p> <p>A sender padding an attachment:</p> <ol type="1"> <li>seals the media exactly as §6.6 says, <code>nonce || ciphertext || tag</code>, and calls its length <code>clen</code>;</li> <li>takes the smallest step at or above <code>clen</code>, and refuses above 10 MiB as the relay would;</li> <li>appends <code>step - clen</code> bytes from the cryptographic random source, and uploads the whole blob;</li> <li>sets <code>att.digest</code> to SHA-256 over the whole uploaded blob, tail included, <code>att.bytes</code> to its length, and <code>att.clen</code> to <code>clen</code>.</li> </ol> <p>The tail MUST be random. Zeros would show anyone holding the blob where the ciphertext ends.</p> <p>A receiver verifies <code>digest</code> over the whole blob first, as §6.6 already requires, then opens only the first <code>clen</code> bytes. A reference with no <code>clen</code> is the format before this section, and the whole blob is opened. A <code>clen</code> that is not an integer, is less than 28, or is larger than the blob MUST make the attachment undisplayable without decrypting.</p> <p><strong>This part is not backward compatible</strong>, and the switch exists because of it. A receiver that predates <code>clen</code> opens the whole blob, tail included, and the AEAD fails. Therefore:</p> <ul> <li>receivers learn <code>clen</code> before any sender pads (stage 1.2);</li> <li>a sender MUST NOT pad an attachment unless the relay descriptor, <code>GET /.well-known/flamenet/relay</code>, carries <code>"features": {"padAttachments": true}</code>. Absent, false, or a descriptor that cannot be fetched all mean off. The key is camelCase, like every other key in the descriptor (<code>audience</code>, <code>issuerPublicKey</code>, <code>keyLogHead</code>);</li> <li>the operator turns it on once no device older than the <code>clen</code> release has been seen for 30 days.</li> </ul> <p>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).</p> <p>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.</p> <p>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.</p> <h3 id="2310-sealed-group-rosters">23.10 Sealed group rosters</h3> <p>A sealed roster blob (§8.9b) is as long as its member list. Its plaintext JSON MUST be padded with the same <code>pad</code> key and the same steps 1 to 6 of §23.7, to a fixed target:</p> <pre><code>ROSTER_PAD_TARGET = 8192 bytes of plaintext JSON</code></pre> <p>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 <code>pad</code>, and old readers already do: iOS decodes <code>SealedRoster.Contents</code> with synthesized <code>Codable</code>, and Android with <code>optX</code>.</p> <p>This hides the count from the blob and not from the table. <code>group_tags</code> (§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.</p> <h3 id="2311-the-relay-the-rollout-and-status">23.11 The relay, the rollout, and status</h3> <p><strong>The relay</strong> never reads inside a payload. The length of the <code>payload</code> string is the only property of one it may look at.</p> <ul> <li><code>FN_PAD_ENFORCE</code> is <code>off</code> (the default), <code>log</code> or <code>enforce</code>. In <code>log</code> the relay counts payloads whose length is not a ladder value, by app version, and changes nothing. In <code>enforce</code>, <code>POST /e2e/messages</code> refuses a batch containing one with <code>400</code> and the code <code>unpadded</code>. 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.</li> <li>Poll responses are capped in bytes (§8.5), because padding makes the average envelope several times larger.</li> <li>The descriptor's <code>features.padAttachments</code> (§23.9) comes from <code>FN_PAD_ATTACHMENTS</code>, default false.</li> </ul> <p><strong>The rollout.</strong> No step is irreversible.</p> <table> <thead> <tr> <th>Stage</th> <th>What</th> </tr> </thead> <tbody> <tr> <td>1.0</td> <td>This section, <code>test/vectors/padding-v1.json</code>, the web engine.</td> </tr> <tr> <td>1.1</td> <td>Relay groundwork: the poll byte cap, count-only ladder metrics, <code>FN_PAD_ENFORCE=off</code>, <code>padAttachments</code> false. The histogram settles <code>PAD_MESSAGE_STEP_1</code>.</td> </tr> <tr> <td>1.2</td> <td>Both apps pad messages and rosters and read <code>clen</code>. Released only after both pass the vectors, the three-way interop driver (§10), and an old-engine check against the oldest builds still reporting.</td> </tr> <tr> <td>1.3</td> <td><code>padAttachments</code> on, 30 days after the last device older than 1.2 was seen. A device that never reported a version counts as old.</td> </tr> <tr> <td>1.4</td> <td><code>FN_PAD_ENFORCE=log</code> for two weeks, then <code>enforce</code>.</td> </tr> <tr> <td>1.5</td> <td>The switch is removed. Senders MUST pad messages and attachments.</td> </tr> </tbody> </table> <p><strong>What it costs.</strong> 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.</p> <p><strong>Conformance.</strong> <code>test/vectors/padding-v1.json</code>, generated by <code>test/vectors/generate-padding.mjs</code>, 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. <code>test/conformance.mjs</code> checks the web engine against it and against every length across the range; <code>test/client.mjs</code> checks that the client pads every send, refuses before spending anything, still reads unpadded payloads, and pads attachments only when the relay says so.</p> <p><strong>Status</strong>, as of 2026-10-07:</p> <table> <thead> <tr> <th></th> <th>Web engine</th> <th>Relay</th> <th>iOS</th> <th>Android</th> </tr> </thead> <tbody> <tr> <td>Messages padded</td> <td>yes, <code>sealedPayload</code> and <code>paddedEnvelopePayload</code></td> <td>n/a</td> <td>no</td> <td>no</td> </tr> <tr> <td>Unpadded payloads read</td> <td>yes, tested</td> <td>n/a</td> <td>yes, as before</td> <td>yes, as before</td> </tr> <tr> <td>Padded payloads read by today's engines</td> <td>yes, tested</td> <td>n/a</td> <td>yes: a padded seal, and a padded envelope through its decoder (<code>test/interop.mjs</code>)</td> <td>yes: a padded seal (<code>test/interop-kotlin.mjs</code>); a padded unsealed envelope not yet run</td> </tr> <tr> <td>Attachment <code>clen</code> read</td> <td>yes, <code>openAttachment</code></td> <td>n/a</td> <td>no</td> <td>no</td> </tr> <tr> <td>Attachments padded behind the switch</td> <td>yes, <code>sealAttachment</code></td> <td>descriptor field: no</td> <td>no</td> <td>no</td> </tr> <tr> <td>Roster padding</td> <td>n/a, the engine has no roster code</td> <td>n/a</td> <td>no</td> <td>no</td> </tr> <tr> <td>Counting and enforcement</td> <td>n/a</td> <td>no</td> <td>n/a</td> <td>n/a</td> </tr> </tbody> </table> <hr /> <h2 id="24-obfs4-bridges-and-the-bridge-offer">24. obfs4 bridges and the bridge offer</h2> <p>Status: <strong>IMPLEMENTED</strong> in both apps from the bridge work (flamenet-messenger-ios and flamenet-messenger-android, branch <code>bridge/f2f-apps</code>): 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.</p> <h3 id="241-what-a-bridge-is-for-and-what-it-is-not">24.1 What a bridge is for, and what it is not</h3> <p>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.</p> <p>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.</p> <p>A bridge is configured by pasting a <strong>bridge line</strong>. It is off unless a line is set; no build ships with one.</p> <h3 id="242-the-bridge-line">24.2 The bridge line</h3> <pre><code>obfs4 198.51.100.7:8443 FBEFBEFFFFFF000102030405060708090A0B0C0D cert=++++////AAECAwQFBgcICQoLDA2FIPAJiTCnVHSLfdy0PvdaDb86DSY4GvTrpKmOqptOag iat-mode=0</code></pre> <p>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:</p> <table> <thead> <tr> <th>Field</th> <th>Shape</th> <th>Meaning</th> </tr> </thead> <tbody> <tr> <td>transport</td> <td><code>obfs4</code></td> <td>Optional. Parsers ignore it. Senders SHOULD include it, since every other tool expects it first.</td> </tr> <tr> <td>address</td> <td>the first field containing <code>:</code> and no <code>=</code></td> <td><code>host:port</code>. The port is the text after the <strong>last</strong> colon, so a bracketed IPv6 literal (<code>[2001:db8::7]:443</code>) 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.</td> </tr> <tr> <td>fingerprint</td> <td>40 hex characters</td> <td>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.</td> </tr> <tr> <td><code>cert=</code></td> <td>standard base64 (<code>+</code> and <code>/</code>), padding optional</td> <td>Decodes to exactly <strong>52 bytes</strong>: 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 <code>==</code>, and MUST NOT accept the URL-safe alphabet.</td> </tr> <tr> <td><code>iat-mode=</code></td> <td><code>0</code>, <code>1</code> or <code>2</code></td> <td>Inter-arrival timing obfuscation. Clients implement <code>0</code>. A line asking for <code>1</code> or <code>2</code> is still usable, because the bridge does not enforce it: the client says so beside the line (a caveat), and does not refuse it.</td> </tr> </tbody> </table> <p>Any other <code>key=value</code> field is ignored.</p> <p>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:</p> <ol type="1"> <li><strong>Empty</strong>: no fields.</li> <li><strong>NoAddress</strong>: no address field, or an empty host.</li> <li><strong>BadPort</strong>: the port is not digits in range.</li> <li><strong>NoCert</strong>: no <code>cert=</code> field.</li> <li><strong>CertNotReadable</strong>: not base64. Almost always a stray space or line break picked up while copying.</li> <li><strong>CertWrongLength</strong>: 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.</li> </ol> <p>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 (<code>messages match the ones iOS shows</code>) holds its copy to the iOS one.</p> <h3 id="243-the-offer">24.3 The offer</h3> <p>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.</p> <pre class="json"><code>{"fnc":1,"kind":"bridge","ctl":1,"line":"obfs4 198.51.100.7:8443 … iat-mode=0"}</code></pre> <table> <thead> <tr> <th>Key</th> <th>Type</th> <th>Meaning</th> </tr> </thead> <tbody> <tr> <td><code>fnc</code></td> <td>integer <code>1</code></td> <td>Content envelope (§6.6).</td> </tr> <tr> <td><code>kind</code></td> <td><code>"bridge"</code></td> <td></td> </tr> <tr> <td><code>ctl</code></td> <td><code>1</code></td> <td>Control traffic (§6.10). Receivers also accept <code>true</code>.</td> </tr> <tr> <td><code>line</code></td> <td>string</td> <td>The bridge line, as the sender uses it.</td> </tr> </tbody> </table> <p>No other key is defined at <code>fnc</code> 1. In particular an offer carries no <code>gid</code>, no <code>mid</code> and no timer.</p> <p><strong>Writing it.</strong> 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 <code>/</code>. Android 0.31 and earlier used the platform org.json, which writes every <code>/</code> as <code>\/</code>. Android 0.32 and later use the engine's own writer, which escapes only what JSON requires and never <code>/</code>. The iOS builder, like org.json on the JVM, escapes <code>/</code> only after <code><</code>. 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.</p> <p><strong>Sending it.</strong></p> <ul> <li>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).</li> <li>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.</li> <li>Never automatically, and never in reply to anything a peer sends.</li> <li>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.</li> </ul> <h3 id="244-size-and-padding">24.4 Size and padding</h3> <p>The <code>line</code> MUST NOT be longer than <strong>1024 bytes</strong> 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 <code>\uXXXX</code>, 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.</p> <p>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.</p> <p>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 <code>ct</code> 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.</p> <h3 id="245-receiving-an-offer">24.5 Receiving an offer</h3> <p>An envelope is an offer when all of these hold:</p> <ol type="1"> <li><code>fnc</code> is 1 and <code>kind</code> is <code>"bridge"</code>. The kind alone decides it; a missing <code>ctl</code> does not stop an offer from being one.</li> <li><code>line</code> is a string, and is not empty or whitespace after trimming.</li> <li>There is no <code>gid</code>. An offer that arrived through a group is dropped.</li> <li><code>line</code> is at most 1024 bytes (§24.4).</li> <li>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.</li> </ol> <p>An envelope that fails any of these is consumed silently, because it carries <code>ctl</code> (§6.10). It is never shown as a message.</p> <p>An offer is <strong>never applied</strong>. Arriving changes no setting and opens no connection. It is stored for the person to look at:</p> <ul> <li>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;</li> <li>what is kept is who offered it, the line exactly as received, and when it arrived by the receiver's clock;</li> <li>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.</li> </ul> <p>It is shown in the app's bridge settings, never in the conversation and never as a notification. For each offer:</p> <ul> <li>who offered it: their display name, else their handle, else the account number, never "someone";</li> <li>the whole line, as received, in a fixed-width face. It is not hidden behind the accept button;</li> <li>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;</li> <li><strong>Use this bridge</strong>, enabled only when the line reads as a bridge, and <strong>Dismiss</strong>.</li> </ul> <h3 id="246-accepting-and-several-lines-with-failover">24.6 Accepting, and several lines with failover</h3> <p>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.</p> <p>A client MAY hold up to <strong>8</strong> 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.</p> <ul> <li><strong>Order of attempts.</strong> 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.</li> <li><strong>What counts as failure.</strong> 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.</li> <li><strong>When every line fails, stop.</strong> 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.</li> <li><strong>Status reads the live state</strong>, the tunnel that is up now, never the stored list.</li> <li>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.</li> <li>Nothing about which lines failed is sent anywhere.</li> </ul> <p><strong>Nothing goes around the bridge.</strong> 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.</p> <p><strong>How the apps try lines.</strong> 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.</p> <h3 id="247-removing-a-line">24.7 Removing a line</h3> <ul> <li><strong>The person removes it.</strong> 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.</li> <li><strong>An offer</strong> goes when it is accepted, when it is dismissed, or when the same contact sends a newer one.</li> <li><strong>There is no retraction on the wire.</strong> 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.</li> </ul> <h3 id="248-what-each-party-learns">24.8 What each party learns</h3> <table> <thead> <tr> <th>Party</th> <th>Learns</th> <th>Does not learn</th> </tr> </thead> <tbody> <tr> <td>The relay</td> <td>That a control payload of some size reached a device (§24.4). Sealed, not who sent it.</td> <td>The line.</td> </tr> <tr> <td>The contact who receives an offer</td> <td>That the sender uses a bridge, and which one.</td> <td>Anything else about the sender's network.</td> </tr> <tr> <td>The bridge's operator</td> <td>Each client's address, when it connects and how much it sends.</td> <td>Content: the tunnel carries TLS to the relay.</td> </tr> <tr> <td>A network observer near the client</td> <td>Traffic to the bridge's address that matches no listed protocol.</td> <td>That it reaches the relay, unless the bridge's address is known to be one.</td> </tr> </tbody> </table> <p>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.</p> <h3 id="249-implementation-status">24.9 Implementation status</h3> <table> <thead> <tr> <th></th> <th>Web engine</th> <th>iOS</th> <th>Android</th> </tr> </thead> <tbody> <tr> <td>Builds the envelope</td> <td>no</td> <td><code>E2EInbox.bridgeEnvelope</code>; none for a blank or oversized line</td> <td><code>Content.bridgeOffer</code>; null for a blank or oversized line</td> </tr> <tr> <td>Sends one</td> <td>no</td> <td>Network settings, "Share it with", through <code>E2EInbox.shareBridge</code></td> <td>Network settings, "Share it with", through <code>Conversations.shareBridge</code></td> </tr> <tr> <td>Recognises one</td> <td>no: handed to the caller as plaintext</td> <td><code>E2EAttachment.parse</code> sets <code>bridgeLine</code>; route <code>bridgeOffer</code></td> <td><code>Content.bridgeLine</code>, consumed in <code>Conversations.consumeControl</code></td> </tr> <tr> <td>Stores it</td> <td>no</td> <td><code>Obfs4Settings.record(offer:)</code>, from <code>E2EInbox.keepBridgeOffer</code></td> <td><code>Vault.saveBridgeOffer</code></td> </tr> <tr> <td>Shows it</td> <td>no</td> <td>Network settings, "Offered to you"</td> <td>Network settings, "Offered to you"</td> </tr> <tr> <td>Drops an offer carrying <code>gid</code></td> <td>n/a</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>Size limit (§24.4)</td> <td>n/a</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>Reads a padded <code>cert=</code></td> <td>n/a</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>Hosts it can dial</td> <td>n/a</td> <td>IPv4, IPv6, names</td> <td>IPv4, IPv6, names</td> </tr> <tr> <td>Several lines, failover (§24.6)</td> <td>n/a</td> <td>yes, up to 8</td> <td>yes, up to 8</td> </tr> <tr> <td>Bridge screen before sign-in</td> <td>n/a</td> <td>yes</td> <td>yes</td> </tr> <tr> <td>Nothing around the bridge (§24.6)</td> <td>n/a</td> <td>yes</td> <td>yes</td> </tr> </tbody> </table> <p>An envelope whose known key has the wrong type (<code>"line":5</code>) 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.</p> <h3 id="2410-vectors">24.10 Vectors</h3> <p><code>test/vectors/bridge-offer-v1.json</code>, generated by <code>test/vectors/generate-bridge-offer.mjs</code>. Eighteen offers built with the apps' template, each as Android 0.31 and earlier write it (<code>envelope</code>), as org.json on the JVM and iOS write it (<code>envelope_jvm</code>), and as Android 0.32 and later write it (<code>envelope_plain</code>), 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.</p> <p>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. <code>envelope_plain</code> transcribes the Android engine's writer, and the Android vector test holds its output to those bytes. <code>test/conformance.mjs</code> checks every case against the rules above, and <code>test/interop.mjs</code> runs every envelope, in all three encodings, through the iOS app's own parser.</p> </div> </article> </main> </div> <div id="sidebar-column"> <div class="sidebar-widget dl-box"> <div class="widget-title">Get the app</div> <div class="widget-body"> <p class="dl-lede">Android today. The iPhone build is a TestFlight beta.</p> <a class="btn btn-primary" href="/get">Get it</a> <p class="dl-meta">Free, and free of ads.</p> </div> </div> <div class="sidebar-widget"> <div class="widget-title">Before you install</div> <div class="widget-body"> <ul class="widget-links"> <li><a href="/comparison">How this compares</a></li> <li><a href="/metadata">What we keep</a></li> <li><a href="/security">Security</a></li> <li><a href="/spec">Specification</a></li> <li><a href="/status">Status</a></li> </ul> </div> </div> <!-- The cross link back to the portal, which is the other half of the relationship: flamenet.io carries this site in its nav. --> <div class="sidebar-widget"> <div class="widget-title">Elsewhere on FlameNet</div> <div class="widget-body"> <ul class="widget-links"> <li><a href="https://flamenet.io/">FlameNet</a></li> <li><a href="https://flamenet.io/forum/">Forums</a></li> <li><a href="https://flamenet.io/directory/">Directory</a></li> <li><a href="https://flamenet.io/chatrooms/">Chatrooms</a></li> </ul> <p class="dl-meta">A flamenet.io account is not a Flamenet Messenger account. The two sign-ins are separate.</p> </div> </div> <div class="sidebar-widget"> <div class="widget-title">Run your own server</div> <div class="widget-body"> <p class="dl-lede">Two containers and a config file. Nothing to sign up for, nothing that phones home.</p> <a class="btn" href="/self-hosting">Self-host</a> <a class="btn" href="/spec">Read the details</a> </div> </div> <div class="sidebar-widget"> <div class="widget-title">Donate</div> <div class="widget-body"> <p class="dl-lede">Nothing here is sold, so donations are what pay for the relay.</p> <a class="btn btn-primary" href="/donate">Donate</a> </div> </div> </div> </div> <div id="site-stats-bar">Free and open source · No ads · No phone number · Your own server if you want one</div> <div id="site-footer"> <div id="footer-links"> <a href="/">Messenger</a> <a href="/get">Get it</a> <a href="/messages">Messages</a> <a href="/files">Files</a> <a href="/wallet">Wallet</a> <a href="/comparison">How this compares</a> <a href="/about">About</a> <a href="/support">App help</a> <a href="/safety">Getting help</a> <a href="/contact">Contact</a> <a href="/releases">What's new</a> <a href="/terms">Terms of Use</a> <a href="/privacy">Privacy Policy</a> <a href="/licenses">Licenses</a> <a href="/.well-known/security.txt">security.txt</a> <a href="https://flamenet.io/">FlameNet</a> </div> <div id="footer-separate"> <strong>Flamenet Messenger is a separate app from <a href="https://flamenet.io/" rel="noopener">FlameNet.io</a>.</strong> It lives on its own domain, is end-to-end encrypted, and has its own sign-in: a FlameNet.io account does not work here, and a Flamenet Messenger account does not work there. </div> <div id="footer-copyright"> <strong>Flamenet Messenger is free software, published under the MIT licence.</strong><br> Signal is a trademark of Signal Messenger, LLC; this project is not affiliated with or endorsed by them.<br> Flamenet is a registered trade name of Extant 2000 LLC. Proudly hosted at <a href="https://hitthosting.com">Hitt Hosting</a> <span class="heart" aria-hidden="true">♥</span> </div> </div> </div> <script src="/landing.js?v=00c83f29"></script> </body> </html>