Nygma Chat · Protocol
The Nygma Chat protocol, in the open.
Everything Nygma Chat does to keep a conversation private is written down here, from the QR code to the last padded byte — including the limits and every problem our reviews found. We checked the key agreement in ProVerif; the models are below so you can check it again.
Specification and report: CC BY 4.0. Models: MIT. Draft 0.6 is the version in the shipping apps; it has not yet been reviewed by an outside firm. Found a flaw? Please tell us before you publish it: privacy@genie9.com.
In short
Run the models yourself
You need Docker. The first build installs ProVerif and takes a few minutes; then every query runs and prints its result. The Connected-tier model is a negative control: its authentication queries are meant to fail, because a one-time link travels over a channel an attacker may control.
unzip ncp-proverif-models.zip && cd ncp-proverif-models docker build -t ncp-proverif . ./run-all.sh
The specification
NCP — Nygma Channel Protocol, version 1, draft 0.6. Published 30 October 2026.
Draft 0.5.3 changelog (2026-10-16, product decision; design record internal design record): one new carrier form and UI rules — no change to keys, KDFs, the handshake, the ratchet, envelopes, the attachment format or the server protocol. §5.3b (new) — the in-person QR carries the claim code (
ncp:i#‖ base64url(s) ‖.‖ code): the scanning phone sends it with its first fetch, so pairing in person has no code step. This changes S1 ("the code never travels with the invitation") for that QR only — a photo of it lets someone else join while it is valid — with normative mitigations: single use, at most 10 minutes, revoked when the inviter's screen closes, a "show only to the person in front of you" warning, and "Someone joined as …" with "Not them? Remove this chat". Links are unchanged: no link form can carry a code; §5.3's sequencing and 3-strike burn stand. §4 / §5.2 / §5.3a / §5.4 — verification is optional: no verify step in either flow (QR₂ is no longer shown after joining), no verify nudge or "Connected" label in the chat; Verified (green badge) is reached through Chat info → Verify security (§5.5 SAS and the §5.2 QR₂ check, unchanged). §4 / §11.4 — key change: after an unattended reset the peer shows ONE quiet system line ("<name>'s security code changed (new phone or reinstall)") instead of the Check-again banner and blocked composer; a Verified channel returns to Connected and its line says so. §10: the attachment limit is the sender's plan's largest file (100 MB … 2 GB, applied by the app); presigned chunk URLs are re-requested during long transfers.
Draft 0.6 changelog (2026-10-20, product decision — message actions): additive inside the ratchet AEAD, no key schedule, envelope or crypto change; existing golden vectors byte-identical, seven appended. §8 every content payload carries a sender-chosen
mid(16 random bytes, the one message identity both phones share) and may carryre(a reply: target mid +o, never a copy of the target's content); new typesreact,edit,del; control kindsprotect,screenshot; receipts name mids (m), seqs kept for 0.5 peers; theignforward-compatibility rule; optional extension fields decode leniently. §8b the receiver's rules (edit = author's own text ≤ 15 min, delete for everyone = author's own messages ≤ 48 h with no trace, reactions one per person; windows on the author's clock + an arrival bound). §9.2x-ncp-quiet(no push poke for receipts / edit / delete) and the sender's early blob delete (DELETE blob/{id}with a delete token, §10.5). §11.2 blind mid index; one durable FIFO send queue per channel on the client. §11.3a "Protect this chat" (per-person flag, strictest wins, both phones) and the screenshot notice. §11.3's "block screenshots by default" line corrected to the 2026-10-16 decision.
Draft 0.5.2 changelog (2026-10-10, product decision): additive, no crypto change. §9.2
recvreturns, outside the ciphertext, a coarse server receive timeatper envelope and the server clocknow; the receiver shows a message at that arrival time on its own clock (WhatsApp-like) instead of the sender's payloadts(sender-clock skew showed messages hours in the past). Payloadtsis unchanged.
Draft 0.5.1 changelog (chat feature round, 2026-09-23): additive, no key schedule or envelope change. §8 payload catalogue gains
location(static, signed micro-degrees, §8a) and the forward-compatibility rule (a strict codec still rejects unknownt/kind; the client renders a placeholder and advances the ratchet instead of dropping the message). §10.3 media pointer gains optionaldur(seconds, voice notes). §12.14 documents the client-side, off-by-default map preview and exactly what it leaks. Multi-item sends are N ordinarymediamessages grouped on display only — no album payload (product decision).
Draft 0.5 changelog (Round-4 independent review, 2026-09-18; see §16 and internal review notes): the July core (§6, §7.1–§7.3, §11.4 reset) is unchanged. Changes: §7.5
pq_rekey(new, v1): periodic ML-KEM-768 re-injection carried in the encrypted DR-HE header on every message of a chain, mixed into the root step keyed by that chain'sdh_pub; fixed header sizeHDR_CAP(§7.2, §8). §5.3 claim code (peppered HMAC gate, 3-strikeBURNED, possessionpoll_tok,OPENED= blob fetched) and the corrected "detectable by the joiner, not the inviter" wording; commitment-QR form not adopted (bootstrap deadlock),formfield added; link hostsnygma.genie9.com. §9.2rotate_otok, client-chosen queuelabelechoed in pokes, quota as per-account create-rate (blind tokens v1.x), no separate relay service. §11 rewritten for the two-key model:K_wrap/K_work/K_storelayering (hardware-wrapped, not credential-gated — the PIN gate is app-enforced), PIN-first Chats gate reusing the existing duress PIN slot, session-wide duress state, decoy Chats = first-run state, content-free notification default, fixed-slot padded working store, §11.1a lease (record_version, approved vs unattended take-over,control:superseded, loud alert), fixed-sizencp-channelscontainer per drive, strong mode per drive. §12 honest P9 rewrite, §12.9 PQ claim corrected (HNDL already defeated for the session;pq_rekey= PQ post-compromise healing), §12.6 deniability (content yes, participation no), new items 13–16. §14 corrected (JS ML-KEM;expo-crypto; SQLite; camera; App Links).Draft 0.4 changelog (Round-3 formal verification; see §16): the two new key-agreement paths were machine-checked in ProVerif (Dolev-Yao model). The Verified handshake+wrap and the reset's anti-replay, KCI-resistance, forward-secrecy, and PQ-hybrid properties all verified. The check found one real gap: the draft-0.3 reset had only a one-way (A→B) key confirmation, so A was not authenticated to B (an attacker substituting
reset_respcould impersonate B to a reinstalling device). Fixed by adding a mandatory B→A key confirmation (confirm_Bfolded intoreset_resp) — mutual key confirmation, re-verified in ProVerif (both-direction injective agreement now holds). Models committed under the published ProVerif models (ncp-proverif-models.zip); report the formal verification report (published with this spec).Draft 0.3 changelog (Round-2 review; see §16 for the full finding→fix log): the §6.3 identity-hiding wrap is confirmed sound; applied its mechanical fixes —
wk_jremoved from the §6.2 transcript (a relay could otherwise flip its top bit to silently desync SK and burn the single-use invitation),kem_ctnow bound into the wrap-keyinfo(ML-KEM non-binding hardening),ss_pqzeroization made explicit, and theq_reply.srvallowlist check generalized to every written queue. The §11.4 session-reset was REDESIGNED — the draft-0.2 one-sided static-authenticated message had no forward secrecy, no anti-replay, no KCI resistance, and dropped the PQ leg; it is now a two-message, mutually-ephemeral, hybrid-PQ, epoch-bound, key-confirmed re-handshake with its own transcript + domain labels. SAS reconciled everywhere to 8 words / 2⁸⁸. Added disclosures (persistent-qid cross-reinstall device linkage; reinstall reset-burst vs duress) and a re-vault invariant for rotated write tokens. Two RN production landmines flagged normative: a CSPRNG health-check gate and native (not pure-JS) ML-KEM decapsulation.Draft 0.2 (superseded): fixed a fatal Double-Ratchet init contradiction (C1); joiner sends first; inviter retains its ephemeral to the first ratchet step; JoinMsg wrap added; DR-HE header keys + length-hiding padding pinned; P7/P8/P9 claims reworded with real mitigations.
1. Goals and security properties
NCP establishes and operates a channel: a long-lived, 1:1, end-to-end-encrypted messaging relationship between two devices, bootstrapped exclusively out-of-band (no server key directory exists), delivered through an untrusted store-and-forward mailbox server.
Targeted properties (with honest scope — see §12):
| # | Property | Mechanism | Honest limit |
|---|---|---|---|
| P1 | Confidentiality & integrity of content and metadata payloads | AEAD under per-message keys | endpoint compromise out of scope |
| P2 | Server cannot MITM key establishment (structural, not detect-after-the-fact) | key material never transits the server readable/substitutable; OOB bootstrap (§5) | Connected-tier link replacement on the carrying channel (§12.2) |
| P3 | Forward secrecy per message | Double Ratchet symmetric ratchet (§7) | skipped-key cache window (§7.3) |
| P4 | Post-compromise security | Double Ratchet DH ratchet (§7) | needs an unobserved round-trip (§12.4) |
| P5 | Harvest-now-decrypt-later resistance; post-quantum post-compromise healing | hybrid X25519 + ML-KEM-768 initial agreement (§6) and reset (§11.4); every root key inherits the KEM secret; periodic ML-KEM re-injection pq_rekey (§7.5) | between two pq_rekey epochs a quantum adversary holding a state snapshot can read (§12.9) |
| P6 | Offline deniability | no signatures anywhere; auth via DH/KEM possession only (§6.5) | — |
| P7 | Social-graph minimization (not structural avoidance) | per-direction pseudonymous queues; account-less send path; blind quota tokens (§9.1) | a malicious/compelled server correlates via IP+timing (§12.1); Tor closes it (roadmap) |
| P8 | Traffic-shape minimization | fixed-size 16 KiB envelopes, plaintext padded before AEAD (§8); Padmé attachments (§10) | count/timing still visible |
| P9 | Plausible deniability under device coercion | PIN-first Chats gate reusing the duress PIN; a duress session shows the first-run Chats state and suppresses notifications (§11.3); per-drive strong mode keeps chats cryptographically locked behind the drive password (§11.2) | default mode hides, it does not lock: working state is decryptable by anyone who can run the app on the unlocked device; OS notification history when "name only" was opted in; artifact-count leaks bounded by fixed-size containers (§11.1) — all disclosed (§12.3, §12.13) |
Non-goals (v1): group messaging (v2 = MLS); multi-device identity (each device pairs independently); voice/video; anonymity against a global network observer; protecting a compromised endpoint.
2. Cryptographic primitives
| Role | Primitive | Notes |
|---|---|---|
| DH | X25519 (RFC 7748) | identity + ephemeral + ratchet keys. Implementations MUST reject an all-zero shared-secret output (contributory-behaviour check). |
| KEM | ML-KEM-768 (FIPS 203) | encapsulation keys 1184 B, ciphertexts 1088 B. Implementations MUST run FIPS-203 key/ciphertext validity checks. |
| Hash | SHA-256 | |
| KDF | HKDF-SHA-256 (RFC 5869) | every derivation domain-separated by an info label from §13 |
| MAC | HMAC-SHA-256 | chain-key ratchet |
| AEAD (payload/body/at-rest) | ChaCha20-Poly1305 (RFC 8439) | 12-byte nonce; 16-byte tag appended (@noble/ciphers layout — matches the existing Nygma contract) |
| AEAD (headers only) | XChaCha20-Poly1305 | 24-byte random nonce; used ONLY for Double-Ratchet header encryption (§7.2), where the receiver cannot know the message counter before decrypting and so a random nonce is unavoidable — the 192-bit nonce makes collisions under a long-lived per-chain header key negligible (closes the birthday concern that a 96-bit random header nonce would raise) |
| CSPRNG | platform (crypto.getRandomValues / quick-crypto) |
Hybrid rule (normative): every session secret MUST mix X25519 output(s) and the ML-KEM
shared secret through HKDF, so the session is secure if either algorithm holds. Classical
security MUST NOT depend on ML-KEM correctness (relevant while @noble/post-quantum awaits an
independent audit).
At-rest storage of channel/message artifacts inside a drive reuses the existing ZK-v2 artifact
scheme (nygma-crypto.contract.ts: subkey = HKDF(baseKey, ∅, "type:fileId"), fresh IV, AAD
v2:fileId:type) with the drive's configured algorithm — see §11.
Encoding: canonical CBOR (RFC 8949 §4.2 deterministic encoding). URL-carried binary is base64url without padding. Multi-byte integers big-endian. Transcript hashes are computed over received/transmitted octet strings, never over a re-serialization (§6.2).
3. Entities and roles
- Inviter (I) — creates the channel and the invitation. Plays the Bob role in the ratchet.
- Joiner (J) — consumes the invitation, drives the handshake, and sends the first ratchet message. Plays the Alice role.
- Mailbox server (S) — untrusted store-and-forward (§9); assumed able to read/modify/drop/replay anything it stores and to be compelled. All guarantees except availability MUST hold against a fully malicious S.
- Channel — the pairwise relationship;
channel_id= 16 B random, client-side only.
3.1 Identity model — pairwise, per-channel
Each device generates a fresh identity keypair per channel: IK (X25519). No global chat
identity, username, or directory entry. Normative:
IK_privMUST be generated at channel creation/join, MUST NOT be reused across channels, and IS persisted (vaulted, §11.1) so the channel can be re-established after reinstall (§11.4).- No key is ever placed in, or served from, a server-side directory. The relay does handle the
ciphertext envelopes it forwards; the joiner's identity key
IK_Jis wrapped so the relay never sees it in cleartext (§6.3). The invitation ephemerals that appear on the wire are single-use and unlinkable to any account or directory. (This is the precise, honest form of the earlier "server never sees a key" claim — see §16/H2.)
4. Trust tiers
| Tier | Ceremony | Human comparison | Badge |
|---|---|---|---|
| Verified | §5.2 mutual-scan QR (machine-checked, bidirectional) — or a Connected/reset channel upgraded via §5.5 SAS | none (QR) / full 8-word (SAS) | green |
| Connected | §5.3 one-time link (+ claim code) through an external channel, or the §5.3b in-person QR | none at establishment; 0.5.3: verification optional (Chat info → Verify security), no nudge | neutral (no shield); 0.5.3: no "Connected" label shown |
| Check again (was "Re-verify") | transient state after an unattended §11.4 session-reset (reinstall, unattended lease take-over) until re-confirmed — 0.5.3: clients no longer enter it on a reset (see below); it remains for a "they don't match" SAS result (§5.5) | full 8-word SAS, mutual |
0.5.3 (product decision 2026-10-16) — key change without a banner. After an unattended §11.4 reset the peer adds ONE
quiet system line to the thread — "<name>'s security code changed (new phone or reinstall)." — and keeps the composer
enabled. A Verified channel returns to Connected and its line adds "This chat is no longer verified." The old
session's SAS material is discarded (it describes keys that no longer exist). An approved take-over
(control:superseded, §11.1a) stays silent, as before.
There is no tier in which a key is accepted from the server.
5. Bootstrap
5.1 Invitation payload
Invitation = CBOR {
v: 1,
form: uint, ; 1 = full (QR/link carries the whole Invitation), 2 = short-link blob (§5.3)
inv_id: bytes(16),
ik: bytes(32), ; IK_I public (X25519) — channel identity, fresh per §3.1
ek: bytes(32), ; EK_I public (X25519) — ephemeral, single-invitation, doubles as I's
; initial ratchet key (retained until first ratchet step; see §6.4/§7.1)
kem: bytes(1184), ; ML-KEM-768 encapsulation key, fresh per invitation
q_join: QueueRef, ; J→I queue: { srv, qid:bytes(16), wtok:bytes(32) }
exp: uint, ; unix seconds; MUST be ≤ 48h from creation
? at: bytes(8), ; OPTIONAL same-account tag (2026-09-20, an earlier issue): HMAC-SHA256(K_meta[chats drive],
; "ncp1/same-account")[0:8]. Opaque to everyone but the inviter's own account;
; a joiner whose own tag matches MUST refuse ("invitation from your own account").
}
QueueRef.srv MUST be one of the client's built-in allowlisted server origins (§9.1) — an
invitation MUST NOT be able to point a client at an arbitrary URL.
EK_Iand the ML-KEM keypair are freshly generated per invitation.IK_Iis the channel identity (fresh per §3.1). The inviter pre-createsq_join(§9.2).- Single-use: the inviter MUST accept at most one join per
inv_id. inv_octets(§6.2) is the exact octet string the joiner received in the form used: the decoded Invitation bytes for form 1, the decrypted blob plaintext for form 2. Both sides hash the same octets.- A "commitment QR" form (QR carries
H(kem), KEM key fetched later) was considered in v2 of the plan and rejected (Round-4 F2): the joiner cannot build JoinMsg withoutkem, and the inviter has no path to the joiner before JoinMsg — a bootstrap deadlock. The full-Invitation QR (form 1) is the offline form:ncp:i#‖ base64url(Invitation) ≈ 1.8 K chars → QR version 31 at ECC L. S0 scan test (2026-09-18, POCO X3 Pro, expo-camera): a 1806-char form-1 QR scanned from a laptop screen on the first attempt with an exact hash match (internal engineering notes §4). Form 2 (short link) stays the default because it also scans from a phone screen at arm's length.
5.2 Verified ceremony — mutual QR scan (in person)
- I displays QR₁. Default: the short-link form
ncp:i#‖ base64url(s) (≈60 chars; the AEAD blob is fetched from S per §5.3 — S cannot read, forge or substitute it, it can only deny; both phones need connectivity during the ceremony). Offline form 1:ncp:i#‖ base64url(Invitation) (self-contained; QR version 31; verified scannable in S0 — see §5.1) — offered as "No connection? Show the offline QR". Either phone may scan first: a "Scan their code" entry exists beside "Show my QR", and a phone that has just been scanned advances to its own camera step. - J scans, runs the handshake (§6), posts JoinMsg + its first ratchet message (completes in seconds while co-located).
- J displays QR₂ =
ncp:c#‖ base64url(CBOR{ inv_id, fp: transcript }) (§6.2transcript). - I scans QR₂ and MUST verify
fp == its own transcript. Match → both mark Verified; I signals J via an authenticatedcontrol:verifiedon the now-established (transcript-bound) channel so J also transitions. Mismatch → I MUST abort and destroy the channel.
Machine-checked in both directions; no human string comparison (research: humans fail at it). The client MUST show distinct terminal screens for success ("both phones agree") and mismatch ("no chat was created").
0.5.3: pairing in person no longer runs this ceremony as part of joining. The inviter shows the §5.3b QR (the short form WITH the claim code); the joiner joins and opens the chat — QR₂ is not shown. Steps 3–4 (QR₂ and its machine check) and §5.5 stay available, optionally, from Chat info → Verify security, and are what makes a channel Verified.
5.3 Connected ceremony — one-time link (remote) with claim code
Hosts: https://nygma.genie9.com/ch#… in production
development. Both hosts serve /ch App Links (Android assetlinks.json, iOS AASA) and a landing page
for phones without the app (§5.3a). The payload is always in the URL fragment (never sent to any web
server).
Full link (form 1): …/ch# ‖ base64url(Invitation). Short link (form 2, default):
- I:
s ← random(32);blob_id = HKDF(s, ∅, "ncp1/invblob/id", 16),blob_key = HKDF(s, ∅, "ncp1/invblob/key", 32),poll_tok = HKDF(s, ∅, "ncp1/invblob/poll", 32),nonce = random(12). blob = nonce ‖ ChaCha20P.Enc(blob_key, nonce, CBOR(Invitation), aad="ncp1/invblob"‖blob_id).- Claim code (default on, product decision 2026-09-18): I draws
code= 6 random decimal digits, shown to the user in Latin digits regardless of locale, never placed in the link (0.5.3: in no link form; the in-person QR of §5.3b is the only carrier that holds it), and computesH = HMAC-SHA-256(pepper, "ncp1/claim" ‖ blob_id ‖ code)wherepepperis a server-side secret held outside the database (the server itself computesH; I sendscodeto S over TLS at upload — the code is not a secret from S, see the security notes). Without a claim code,H = ∅. - I uploads
(blob_id, blob, exp, H?, poll_tok_hash = SHA-256(poll_tok))under an upload grant against I's quota (§9.2). S stores it one-time. Expiry is server-authoritative. - Short link =
…/ch#‖ base64url(s) (≈ 60 chars). - J opens the link → derives
blob_id,blob_key→ if the server reports a claim code is required, the app asks for the 6 digits →fetch(blob_id, code?). S checksH; a mismatch increments a server-side attempt counter; on the third mismatch the blob transitions toBURNED(distinct fromCLAIMED,NOT_FOUND,EXPIRED,REVOKED). A correct code (or no code required) atomically marks the blobCLAIMEDand returns it; later fetches returnCLAIMED. - J atomically persists
s+ the fetched Invitation locally before any further step (crash-safe, §5.4) → proceeds to §6. - I polls invitation state with
poll_tok(possession, never the account session — an account-authenticated poll would bindaccount → blob_id → q_joinand defeat the account-less send path).
UI sequencing (normative for clients): the link is shown and shared first; the code is revealed only after the share sheet returns, naming the app used ("You sent the link with WhatsApp. Now tell Nour the code any other way"). No copy affordance on the code by default. Otherwise both artefacts travel in one thread and the code protects nothing.
Security notes (normative consequences):
- S never sees
s/blob_key→ it cannot read, forge, or substitute the invitation (AEAD under a key it lacks). It can only delete/deny (availability). - The claim code is not a secret from the server; the server is a rate limiter. It protects against
everyone who is not the server: a link interceptor or a blob-at-rest thief now also needs the
channel that carried the code. A link-only attacker succeeds with probability 3/10⁶. Say "the link
alone isn't enough to join", never "cannot join". Mixing the code into
blob_keywould buy nothing (10⁶ offline trials against an AEAD tag are trivial); an OPRF/PAKE-gated fetch that makes the code a cryptographic gate against non-servers is reserved (§15). - Interception is detectable by the joiner, not the inviter. If an attacker fetches first, the real
joiner sees
CLAIMED("someone else may have opened it — tell the inviter"); the inviter sees a successful join and cannot tell. If the real joiner fetched first, the attacker learns nothing. The inviter-side signal that exists isBURNED(three wrong codes), which the inviter's client MUST surface in the chat list and the invite screen ("Someone tried the wrong code three times. Link destroyed."). A joiner that already claimed on this device MUST recognise its own persistedsand resume; the UI MUST distinguish "already opened here" from "CLAIMED by someone else." WRONG_CODEandCLAIMEDare distinct responses, so a link holder learns whether the real joiner has already joined. Accepted and disclosed.- Residual risk (honest): an attacker controlling the carrying channel who replaces the link (and, with the code on, also the code) before the joiner opens it achieves full MITM until SAS (§5.5, §12.2). Stated in the published threat model.
5.3a The invitee's path (informative, normative where marked)
- Landing page, app not installed: names the inviter's self-declared name, says "install, then tap the
link again", and MUST NOT fetch the blob (a page load never flips the invitation to
OPENED). The fragment does not survive a store install on iOS; Android MAY carry it via the Play install referrer. - Landing page, app installed: opens the app with the fragment.
- Account: email + one-time code, returning to the invitation.
- Onboarding (product decision 2026-09-18): set the Nygma PIN → display name → done. A "Chats" drive is
created with a device-generated random password (ordinary KDF v3 / key-wrap v2 flow — a random
256-bit string is a valid password), held under
K_work(§11.2). Documented as weaker until the user sets a real password + recovery key from Security Hub or at first vault use (re-wrap, v2 format, own IV). Losing the device before that loses the chat identity and any vaulted history. - Code entry with attempts remaining; one distinct terminal screen each for
WRONG_CODE,BURNED,EXPIRED,CLAIMED-by-other,REVOKED, offline. - First message: a persistent notice that the name is the inviter's own claim, a local nickname field, and one dismissible verify nudge per Connected channel. 0.5.3: no notice and no nudge in the chat; the nickname and the optional Verify security live in Chat info. The inviter's "Not them? Remove this chat" (§5.4) is the at-join check on the name.
5.3b In-person QR with the claim code (0.5.3, product decision 2026-10-16)
For two people side by side the inviter shows ONE QR; the joiner's app needs no code step.
- Carrier:
ncp:i#‖ base64url(s) ‖.‖code— the §5.3 short-form secret (exactly 43 base64url characters) followed by the 6 Latin digits of the same invitation's claim code (56 characters: QR version 4 at ECC M). Same invitation, same blob, same server flow as §5.3; nothing new is stored. - Parsing (normative): a claim code is accepted ONLY in this
ncp:i#form. Thehttps://<host>/ch#pattern, the bare payload and the app's own scheme MUST NOT carry or accept one, so a link can never carry the code. A form-1 (full) QR never takes a code. Older clients reject the suffix ("not a Nygma invitation"). - Joiner: sends the carried code with its first fetch (§5.3 step 6);
CLAIMED,EXPIRED,REVOKEDand the attempt counter behave exactly as for a link. A code the user types (afterWRONG_CODE) replaces the carried one. The scanned text is not echoed into an editable field. - Inviter (mitigations for S1, normative): the invitation is created with an expiry of at most 10 minutes
(server-authoritative; the server accepts 1 s–48 h), is valid only while the QR's screen is open — closing the
screen, switching to a link, or the Chats gate locking revokes it (
DELETE …/invitation/{blob_id}withpoll_tok; a locked client that cannot read its store still revokes at the server and drops the local row on the next open) — and never waits in the Chats list. The screen says "Show this only to the person in front of you." The code is never displayed on this path. After the join the inviter sees "Someone joined as '<their name>'" with "Not them? Remove this chat" (block-and-delete, silent to the peer). - What changes (S1): the code now travels with this invitation — anyone who photographs the QR while it is valid
can join instead (and the real joiner then sees
CLAIMED). Accepted by the product team for the in-person case only; the link path keeps S1. The full (form 1) QR stays available as a fallback behind "Other options", with a photo warning and the same 10-minute limit. - The QR text is not a web URL: a phone camera shows it as text and never opens the
/chlanding page.
5.4 Invitation lifecycle
CREATED → OPENED → CLAIMED → COMPLETED | BURNED | EXPIRED | REVOKED. OPENED = the blob was requested
(with or without a correct code); CLAIMED = the blob was returned. On COMPLETED the q_join queue
persists as the channel's J→I infrastructure (its write token is rotated per §9 first). The inviter
app MUST surface transitions in the chat list row and the invite screen: "Link not opened yet" /
"Link opened · waiting for the code" / "Someone joined as '<their name>'" (with "verify it's them before
sharing anything sensitive"; 0.5.3: with "Not them? Remove this chat" instead) / "Link destroyed after three wrong
codes" / "Expired" / "Revoked". 0.5.3: a waiting link invitation's Chats-list row shows neither the code nor a copy
action; it reopens the invite screen at its step. The
inviter MUST NOT display "opened by <name>" — it cannot know who opened it. A CLAIMED not followed by a
valid JoinMsg within 10 min SHOULD be surfaced as a possible interception — unless the same device
reports it claimed the link itself, which is a benign crash-resume. Expired, burned and revoked
invitations MUST have their q_join queue and ML-KEM/EK state garbage-collected (quota hygiene, §9.2).
5.5 SAS verification (Connected/Re-verify → Verified)
sas = HKDF(sas_seed, ∅, "ncp1/sas" ‖ transcript, 11)→ 88 bits → 8 BIP39 words (11 bits each; list already shipped) or emoji.sas_seedis a dedicated slice of the handshake output (§6.2), not the root key.- Humans compare over a channel where they recognize each other (a call, or in person). Verification
is mutual: each side confirms in-app; the channel becomes Verified only when both confirmations
have been exchanged (
control:verifiedin each direction); the UI shows "waiting for <peer> to confirm" in between; a "they don't match" on either side puts both channels into the Check-again state. Clients MUST render each word as a tile that must be tapped before the confirm action enables, and MUST NOT offer partial confirmation. - The word list is language-independent: the same list rendered identically on both phones irrespective of device locale (English BIP39 words, with a numeric fallback "compare these 8 numbers" rendered in Latin digits). Localised word lists would make the ceremony fail between phones in different languages.
- Security-critical invariant: the SAS's MITM resistance depends on single-use invitations
(§5.1). Because the victim's
SKis frozen once it joins (single response), a dual-session MITM faces a second-preimage grind — ~2⁸⁸ work to make its separately-negotiated session hit the victim's fixed SAS — not a 2⁴⁴ birthday, which would require two grindable sides. Single-use invitations are what deny the second grindable side, so implementations MUST enforce them. (88 bits is chosen over a bare 66 to keep a comfortable margin above the "operational bound" a reviewer can question; an interactive commit-then-reveal round — the alternative hardening — is deliberately not used because it would break the asynchronous one-shot invitation model that is core to the product.) Partial comparison is a break, not graceful degradation: comparing k of 8 words leaves 11·(8−k) bits (e.g. 3 words → 2³³, offline-grindable). The UI MUST require all eight and MUST NOT present partial confirmation as "good enough."
6. Handshake — hybrid, OOB-authenticated, identity-hiding
6.1 Joiner key material
J generates fresh IK_J, EK_J (X25519) and a throwaway wrapping ephemeral WK_J (X25519),
and computes (kem_ct, ss_pq) = ML-KEM-768.Encaps(kem). All received public keys are validated (§2).
6.2 Shared secret and transcript
DH1 = X25519(IK_J_priv, EK_I_pub) ; authenticates J to I
DH2 = X25519(EK_J_priv, IK_I_pub) ; authenticates I to J
DH3 = X25519(EK_J_priv, EK_I_pub) ; ephemeral freshness
transcript = SHA-256( "ncp1/hs" ‖ inv_octets ‖ ik_j ‖ ek_j ‖ kem_ct )
; each term is the exact transmitted/received octet string (no CBOR re-encode)
; NOTE (Round-2 fix): wk_j is NOT in the transcript. It is a throwaway wrapping
; ephemeral that authenticates nothing about the session (peer auth rests on DH1–DH3 +
; ik_j/ek_j/kem_ct). Including it let a relay flip its RFC-7748-masked top bit — same
; decoded point (→ same WK, ct still decrypts) but different octets (→ different
; transcript) — silently desyncing SK and burning the single-use invitation. Out it goes.
SK = HKDF( ikm = 0xFF×32 ‖ DH1 ‖ DH2 ‖ DH3 ‖ ss_pq,
salt = ∅,
info = "ncp1/hs/sk" ‖ transcript,
len = 96 )
= RK₀(32) ‖ session_id(32) ‖ sas_seed(32)
- DH triple = X3DH mutual-identity binding; the one-time-prekey DH4 is unnecessary because
EK_Iis already single-use per invitation. Binding both the KEM key (insideinv_octets) andkem_ctintotranscriptcloses KEM re-encapsulation/substitution (per the PQXDH analysis). ss_pqin the IKM makes the agreement hybrid (P5).0xFF×32is the X3DH domain prefix.session_idis the channel-binding AAD context;sas_seedfeeds only §5.5.session_idis reconstructed locally by both parties and MUST NOT be transmitted anywhere (it shares an HKDF output stream withRK₀; keeping it off the wire keeps that stream's secrecy irrelevant to transport).
6.3 Join message (identity-hiding wrapper)
The relay must not learn IK_J. The joiner wraps identity/ephemeral fields under a key derived from
material S lacks:
WK = HKDF( ikm = X25519(WK_J_priv, EK_I_pub) ‖ ss_pq,
salt = ∅,
info = "ncp1/jmwrap" ‖ inv_id ‖ SHA-256(kem_ct ‖ kem), ; kem = the invitation KEM pubkey
len = 32 )
JoinMsg = CBOR {
v: 1, inv_id,
wk_j: bytes(32), ; WK_J public — throwaway, unlinkable random point (cleartext)
kem_ct: bytes(1088), ; cleartext (random-looking); inviter needs it to Decaps
ct: bytes ; nonce(12) ‖ ChaCha20P.Enc(WK, nonce,
; CBOR{ ik_j, ek_j, profile:{name?,avatar_ref?}, q_reply:QueueRef, pad },
; aad = "ncp1/jm" ‖ inv_id ‖ wk_j ) ; wk_j in the AAD → tamper = recoverable AEAD drop
}
Posted to q_join (invitation wtok) in a join-type envelope (§8). The relay sees only two
random-looking points, a KEM ciphertext, and AEAD ciphertext — never IK_J. q_reply (the I→J
queue) is pre-created by J.
kem_ct+ KEM pubkey are folded into theWKinfo(Round-2 hardening): ML-KEM is IND-CCA2 but not binding (neither MAL-BIND-K-CT nor -K-PK), so a KEM substitution could otherwise steer the wrap key. This mirrors the §6.2 transcript's KEM binding on the one new path that omitted it.wk_jis bound into thectAAD so any malleation of the cleartextwk_jbecomes a recoverable AEAD failure (retryable), never a consumed/burned invitation.- Forward secrecy of the wrap: the joiner MUST zeroize
WK_J_privand the ML-KEM Encaps randomness/ss_pqimmediately after posting JoinMsg. On the inviter side, becauseEK_I_privis retained until the first ratchet step (§6.4/§7.1),ss_pqis the sole forward-secrecy-bearing half of theWKikm during that window — so it MUST be zeroized at SK-derivation time (§6.4 step 4).
6.4 Inviter processing & first-message ordering (C1 fix)
On a join envelope, the inviter:
- Verifies
inv_idis CREATED/CLAIMED and unexpired (server-authoritative expiry + a skew grace margin); duplicate/second distinct JoinMsg for aninv_id→ ignore (anti-replay). Decaps(kem_ct)→ss_pq;WK = HKDF(X25519(EK_I_priv, wk_j) ‖ ss_pq, ∅, "ncp1/jmwrap"‖inv_id‖SHA-256(kem_ct‖kem), 32); decryptct(AAD"ncp1/jm"‖inv_id‖wk_j) →ik_j, ek_j, q_reply, profile. AEAD failure ⇒ drop silently, keep waiting. Then validateq_reply.srvagainst the built-in origin allowlist (§5.1) BEFORE any write or state commit — on failure, abort (do not mark COMPLETED, do not post). (Round-2: the round-1 srv-allowlist covered only the invitation'sq_join; it now applies to every QueueRef a client writes to.)- Compute DH1–DH3 (with its private keys) →
SK→transcript. - Initialize the ratchet in the Bob role, retaining
EK_I_privas the initialDHRsprivate key (§7.1). Destroy at this point the ML-KEM private key, the transientss_pqvalue (Round-2: needed for wrap forward secrecy — see §6.3), and the invitation replay state — notEK_I_priv. - Rotate the
q_joinwrite token (§9) and markinv_idCOMPLETED.
The joiner sends the first ratchet message (§7): immediately after posting JoinMsg it posts a
control:established (or the user's first text) to q_join. That message carries the joiner's new
DHRs_J_pub; when the inviter processes it, the DH ratchet step consumes EK_I_priv (now safe to
destroy), yields the inviter's receiving chain, and only then gives the inviter a sending chain.
The inviter's chan_ack (carrying the rotated q_join write token and confirming establishment) is
Bob's response, posted to q_reply — never the opener.
Lost-message rules (deadlock fix): the joiner MUST persist and retransmit the byte-identical
JoinMsg and first ratchet message until it receives chan_ack; the inviter MUST re-send
chan_ack on receiving an already-processed identical JoinMsg (dedup suppresses only distinct
JoinMsgs, never the ack). A joiner handshake timeout surfaces explicit failure UX.
6.5 Authentication & deniability
- J→I: only the holder of
IK_I/EK_I/kem(delivered OOB) can deriveSK; a relay or link-observer cannot decryptctor complete the channel. - I→J:
SKdepends onIK_Jvia DH1, so a validchan_ackproves the responder controlsEK_I_priv/kemand processedIK_J. Verified adds the QR₂ reverse transcript check; Connected's honest limit is "whoever holds the link," until SAS. - No signatures anywhere → offline deniability (P6); the hybrid KEM does not break it (KEM possession is symmetric-simulatable given the transcript).
7. Message protocol — Double Ratchet with header encryption
Signal Double Ratchet + header encryption (DR spec §5.2), instantiated below. Joiner = Alice role, Inviter = Bob role (§3, §6.4).
7.1 Initialization (H5 / C1 fix — exact DR-HE assignment)
Two shared header keys are derived crosswise from the root:
hka ‖ nhkb = HKDF(RK₀, ∅, "ncp1/hk0" ‖ session_id, 64) (2×32).
- Joiner (Alice):
DHRs = generate(a fresh X25519 keypair that MUST differ fromEK_J, so the first ratchetdh_outcan never equal the handshake'sDH3);DHRr = ek(I's invitation ephemeralEK_I_pub);(RK, CKs, NHKs) = KDF_RK(RK₀, X25519(DHRs_priv, DHRr));CKr = None;HKs = hka;HKr = None;NHKr = nhkb. The joiner therefore has a sending chain immediately and sends first (§6.4). - Inviter (Bob):
DHRs = (EK_I_priv, EK_I_pub)(retained per §6.4);RK = RK₀;CKs = CKr = HKs = HKr = None;NHKs = nhkb;NHKr = hka. Bob gainsCKr(and thenCKs) only after processing the joiner's first message — which performsX25519(EK_I_priv, DHRs_J_pub)and consumesEK_I_priv.
The crosswise assignment (HKs_Alice = NHKr_Bob = hka; NHKs_Bob = NHKr_Alice = nhkb) lets each
side trial-decrypt the other's first header.
7.2 KDFs (exact)
KDF_RK(rk, dh_out): HKDF(ikm=dh_out, salt=rk, info="ncp1/rk", 96) → rk'(32) ‖ ck(32) ‖ nhk(32)
KDF_RK_pq(rk, dh_out, ss_e, e, pk_e, ct_e): ; §7.5 — root step that consumes a KEM epoch
HKDF(ikm=dh_out ‖ ss_e, salt=rk,
info="ncp1/pqrk" ‖ LE32(e) ‖ SHA-256(pk_e ‖ ct_e), 96) → rk' ‖ ck ‖ nhk
KDF_CK(ck): ck' = HMAC(ck, 0x02); mk = HMAC(ck, 0x01)
Msg key expand: HKDF(mk, ∅, "ncp1/mk"‖session_id, 44) → key(32) ‖ nonce(12)
- Message AEAD:
ChaCha20P.Enc(key, nonce, padded_plaintext, aad = 0x01 ‖ "ncp1/msg" ‖ session_id ‖ enc_header)— the leading0x01is the transportversionbyte (§8), bound in so a reframing/downgrade can't strip it.mkis single-use ⇒ the deterministic message nonce is safe;session_idin the key-expandinfoadds defense-in-depth. - Header AEAD:
XChaCha20P.Enc(HKs, random_nonce(24)[prepended], pad(CBOR{dh_pub,pn,n,pq}), aad="ncp1/hdr"‖session_id)wherepq = {epoch:uint, pk?:bytes(1184), ct?:bytes(1088)}(§7.5;pkandctabsent when the chain carries none) andpad()is ISO/IEC 7816-4 padding to the fixedHDR_CAP = 2400bytes, so the encrypted header is always24 + 2400 + 16 = 2440bytes and a rekey message has no shape (Round-4 an earlier issue). The receiver cannot know the counternbefore decrypting the header, so the nonce must be random; the 192-bit XChaCha nonce makes reuse under a long-lived per-chainHKsnegligible (no message-per-chain cap needed).
7.3 Out-of-order handling
MAX_SKIP = 1000 per chain; skipped-key store capped at 2000 entries/channel, 30-day TTL, evict
oldest on overflow. A skipped message key MUST be deleted on first successful use (replay of that
envelope then fails). Trial-decrypt order per DR-HE: HKr then NHKr.
7.4 State persistence
Ratchet state (RK, CKs/CKr, DHRs, DHRr, HK/NHK pairs, Ns/Nr/PN, skipped-key map, pq_rekey epoch
state §7.5) is CBOR-serialized and stored only encrypted under the channel's working key K_store
(§11.2) inside the fixed-slot padded working store; never plaintext on disk; wiped from memory when the
Chats gate locks (§11.3). In strong mode (§11.2) it is additionally locked behind the drive and wiped
on drive lock. Ratchet state is never vaulted/cloned (would break PCS — §11.4) and never backed up
(§11.2). Writes are atomic (temp file + rename, or a SQLite transaction) and MUST complete before ack
(§9.2).
7.5 Periodic post-quantum re-injection (pq_rekey, v1)
Why (corrected in Round 4, an earlier issue). Every root key RK_i = HKDF(salt = RK_{i-1}, ikm = dh_i) inherits
RK₀, which depends on ss_pq; a future quantum adversary who recorded the traffic recovers every dh_i
but not RK₀, so harvest-now-decrypt-later is already defeated for the whole session absent a
state compromise (P5). What the classical DH ratchet lacks is post-quantum post-compromise healing:
after a snapshot of the ratchet state, a classical DH step does not lock a quantum adversary out again;
a KEM step does. pq_rekey provides that, bounded by one epoch. It is the Apple-PQ3 pattern.
Where the material rides (Round-4 F1 — the v2-plan wording "in the encrypted body, mixed at the next
ratchet step" was circular with the chain it keys and ambiguous under reordering; it is void). PQ
material rides in the DR-HE header (§7.2), which the receiver decrypts under HKr/NHKr before
any root step, and it is carried identically on every message of a sending chain, exactly as
dh_pub is, so a lost or skipped chain-starter is recovered from any later message of the chain
(unchanged MAX_SKIP semantics).
header.pq = { epoch: uint, pk?: bytes(1184), ct?: bytes(1088) }
Initiator (either party):
MAY include a fresh ML-KEM-768 encapsulation key pk_e when it starts a new sending chain;
MUST do so if ≥ 50 messages have been sent or 24 h have elapsed since its last pk (whichever first);
sets epoch = e (per-session counter, starts at 0 after the handshake and after every session_reset);
retains at most TWO decapsulation keys (current e, previous e−1); zeroizes sk_e immediately after the
first successful Decaps of a ct for epoch e.
Responder:
on its NEXT sending-chain start after receiving a pk_e it has not yet answered, MUST include
ct_e = ML-KEM-768.Encaps(pk_e) with the same epoch e (and MAY include its own fresh pk for a new epoch);
keeps ss_e until the root step below consumes it, then zeroizes it.
Root step:
the root step keyed by the dh_pub of the sending chain WHOSE HEADER CARRIES ct_e uses
KDF_RK_pq(rk, dh_out, ss_e, e, pk_e, ct_e) (§7.2) on BOTH sides; every other root step uses KDF_RK.
The label difference is the domain separation; presence of pq material is authenticated by the header
AEAD. This rule is well-defined regardless of message order because the KEM secret is bound to a
specific chain, not to "the next step".
Errors:
a ct for an unknown or already-consumed epoch, or a pk that fails FIPS-203 validation, is treated as a
header-AEAD failure (drop; no state change). A peer that never answers two consecutive pk's → the
client surfaces an informational "post-quantum refresh not confirmed" (a non-responding peer already
holds every key; this is a health signal, not a security event).
Implementation clarifications (P1, 2026-09-18 — normative): (1) a sending chain carries at most one
of pk / ct; answering an unanswered peer pk takes priority over opening a new epoch, which then waits
for the next chain. (2) Epoch ids are disjoint per role so both parties may initiate: the joiner
(Alice) uses odd ids 1, 3, 5…; the inviter (Bob) uses even ids 2, 4, 6…; a party MUST reject a pk
whose epoch has its own parity. (3) In the Double Ratchet a party's new sending chain is created when it
receives the peer's new dh_pub, so the answer (Encaps, KDF_RK_pq on the sending root step) and
the cadence decision (open a new epoch if ≥50 of its own messages or ≥24 h since its last pk) happen at
receive time; every message of that chain then carries the same pq field, and a receiver MUST reject a
header whose pq differs from the first header of the same chain. (4) The cadence counter counts the
party's OWN sent messages. Reference implementation: the apps’ ratchet module
(37 tests incl. a randomized-schedule property test).
Guarantee (honest): after a compromise of one party's ratchet state, a quantum-capable adversary
who stays passive is locked out at the first root step that consumes a KEM epoch whose sk_e was
generated after the snapshot — at most one epoch (50 messages / 24 h) plus one round trip. If the
pending sk_e was inside the snapshot, healing happens at the second boundary (hence immediate sk_e
zeroization). An active adversary can defeat PCS in the Double Ratchet regardless (standard caveat).
Approved public wording: "post-quantum at setup; post-quantum recovery after a device compromise within
one epoch." Never "fully post-quantum".
Cost: ≤ 2272 B of KEM material inside the fixed 2400 B header (§7.2); no change to the envelope; one
Encaps or Decaps per epoch per side, in JS via @noble/post-quantum in v1 (§14).
Verification (Round 5, 2026-09-18 — DONE): modelled in ProVerif (ncp-formal/ncp-pqrekey*.pv):
agreement on the root key under a reordering attacker, post-quantum post-compromise healing with a
pre-epoch state snapshot plus every DH value leaked, classical negative control leaking as required,
hybrid with the KEM secret leaked, and the pending-sk_e caveat reproducing — all as intended. See
the formal verification report (published with this spec) Round 5. The P1 feature flag may be enabled once fixtures match.
8. Envelope format (transport unit)
Every queue write is one fixed 16384-byte envelope:
offset 0 : u8 version (0x01)
offset 1 : u8 type (0x01 join | 0x02 msg)
offset 2 : u16 hdr_len ; join: 0. msg: MUST equal 2440 (= 24 + HDR_CAP 2400 + 16, §7.2); any other
; value MUST be rejected (Round-4 an earlier issue: a variable header marked rekey messages)
offset 4 : enc_header ; join: absent; msg: nonce ‖ AEAD(padded header)
... : payload ; join: JoinMsg CBOR; msg: message AEAD ciphertext
... : (no trailing variable region — see padding rule)
Padding rule (H4 — normative, hides length): the plaintext (JoinMsg CBOR, or message payload)
MUST be padded with ISO/IEC 7816-4 padding (0x80 then 0x00s) to the fixed AEAD input
capacity before encryption, so the AEAD ciphertext — and thus the whole envelope — is exactly
16384 bytes regardless of content. Implementations MUST NOT emit an envelope whose length or
ciphertext boundary varies with content. Payloads that don't fit (≳15.8 KB) MUST use attachments
(§10).
Client content payload (inside the message AEAD) is CBOR { t, body…, ts },
t ∈ {text,media,location,receipt,react,edit,del,control,sas} (react/edit/del and the control kinds
protect/screenshot since 0.6, §8b, §11.3a). Since 0.6 every content payload (text/media/location) MUST
carry mid (16 bytes, CSPRNG, chosen by the sender; the one message identity both ends share) and MAY carry
re: {m, o} — a reply to the message whose mid is m; o = true when that message is the SENDER's own. A reply
MUST NOT carry any content of its target: the receiver renders the quote from its own copy and shows none when that
copy is gone (deleted, expired), so a deletion or a disappearing timer is never undone by a quote. Control/receipt payloads MUST be piggybacked onto
already-scheduled message envelopes when possible. Delivery receipts are ON (product decision 17,
2026-10-14 — "Delivered" ticks, like Signal; supersedes the earlier off-by-default rule): after user content
(text, media incl. voice, location) is decrypted and stored, the receiver sends one coalesced receipt per
channel (1.5 s after the last message, at most 5 s into a stream, ≤ 256 ids of each kind) listing the mids
(m, 0.6) and the queue seqs (delivered, for 0.5 senders, who learn their seq from the POST and store it
on the row as qs) it received. Receipts are sent quiet (§9.2: no push poke). Never from the chat duress space or any duress session, never for receipts/control/sas/system rows, never
for a blocked / ended / handed-off / deleted channel; no read receipts. Receipts go only while the transport is
connected (no ratchet step spent waiting) and are dropped after 5 failed sends. Builds before this change read the
ids as row ids — retire them. Residual SDA timing exposure (§12.4) accepted with the decision.
Forward compatibility (0.5.1). The payload codec is deliberately strict: an unknown t or control
kind MUST be rejected, never partially interpreted (a newer peer cannot smuggle semantics past an older
one). A client that receives a payload it does not understand MUST still commit the advanced ratchet state
(the decrypt succeeded), MUST store a placeholder row ("Update Nygma to see this message") in that
position, and MUST NOT act on any field of the unknown payload. Optional fields added to a known type
(e.g. dur on media) are ignored by older clients by construction of the map codec.
0.6: a payload MAY carry ign: true; a receiver that does not know its t / kind then commits the ratchet and
stores NOTHING (no placeholder, no receipt). Types introduced after 0.6 that are safe to miss set it. Optional
extension fields of known types (mid, re) are read leniently: a malformed one is treated as absent and the
message itself is kept.
8b. Message actions (0.6, product decision 2026-10-20)
{ t:"react", ts, m: bstr16, o: bool, e: tstr } e ≤ 64 UTF-8 bytes; "" removes the sender's reaction
{ t:"edit", ts, m: bstr16, body: tstr } body ≤ 12 000 bytes
{ t:"del", ts, ms: [bstr16] } 1–50 mids
Receiver rules (normative; mobile reference: lib/ncp/actions-core.ts, applied inside the inbound commit
transaction, idempotent, a missing or expired target is ignored):
- react — the target is the message with mid
m(o= the sender's own → received here, else sent here); one reaction per person per message, the latest wins. - edit — only the SENDER's own text message; accepted when
edit.ts − target's sender ts ≤ 15 min + 2 min. Both times are the author's clock, so clock skew cancels and an edit that waited offline is judged by when it was made. The latest text replaces the old one; the message shows "Edited". No edit history is kept. - del (delete for everyone) — only the SENDER's own messages; accepted within
48 h + 10 minon the same clock rule. The message is removed with no trace on either side (no "deleted" line), with its plaintext attachment cache; the sender also deletes the attachment blob early (§10.5). - Every action must also arrive within window + 7 days of the target on the receiver's own clock (a back-dated
tscannot reach into old history). - A message that never left the sender's phone is edited / cancelled in its send queue; nothing goes on the wire.
- Limits, disclosed in the app: delete for everyone cannot recall what the other person already copied, saved to a drive or photographed; notifications never contain content, so nothing lingers in the notification shade.
8a. Location payload (0.5.1)
{ t:"location", ts, lat: int, lng: int, acc?: uint, label?: text }
lat/lng are signed micro-degrees (round(deg × 10⁶); CBOR here carries no floats), bounded
±90 000 000 / ±180 000 000; acc is the reported horizontal accuracy in metres; label is text the
sender typed (≤ 200 bytes), never a reverse-geocoded address. v1 is static only — one fix, taken when
the user chooses "Location"; there is no live-location stream. The payload rides inside the ratchet AEAD
like text; it never becomes a blob. Rendering is the receiver's choice and is a privacy setting (§12.14).
Envelope cost (documented decision): a 16 KiB fixed size costs ~32 KiB round-trip per message (~280 MB/month for a 300-msg/day user). This is the SimpleX-precedented price of length-hiding (P8). v1 ships a single 16 KiB tier for maximum uniformity; a second smaller tier (e.g. 4 KiB) is reserved (§15) as an explicit size-vs-savings trade (a 2-bucket length oracle for ~4× savings) — not a silent default.
9. Queue protocol (mailbox server)
0.6 additions (§9.2):
POST queue/{qid}MAY carryx-ncp-quiet: 1— published to a live WebSocket, no push poke (receipts, edits, deletes: nothing anyone needs a notification for; before 0.6 every receipt made the other phone show "New activity"). Metadata cost, accepted: the server can tell quiet envelopes from others, which makes receipt traffic easier to pair with the messages it acknowledges (§12.4).DELETE blob/{blob_id}withx-ncp-btok(§10.5). Client side (§11.2): every send of a channel — messages and actions — goes through ONE durable FIFO queue, persisted as intents (sealed), so an action can never overtake the message it names and a send survives a restart; an item is encrypted when first flushed and that envelope is reused for its retries.
9.1 Trust, auth, and the honest linkage story
- A queue is unidirectional: one owner (reader), one writer.
- Send path carries NO account authentication —
POST /q/{qid}authorizes solely by the 32-byte bearerwtok. Servers MUST ignore any session/Authorization on this endpoint. - Receive path authenticates with the queue's
otok(32-byte owner token), not the account session. - Quota (v1): per-account create-rate. Queue and upload creation are account-authenticated in v1
but the server stores no
account → qidmapping; it records only a rolling per-account counterncpquota {accountId, day, created}(a live queue count cannot be decremented ondestroy, which carries onlyotok, so counts are never kept). Disclosure line: "Nygma can count how many chats you started, not which." Blind quota tokens (Privacy-Pass-style, unlinkable) are reserved for v1.x (§15) and remove even the create-time account knowledge. - Honest limit (P7, §12.1): even with blind quota and account-less sends, a malicious/compelled server that logs IPs can correlate the two queues of a channel by source-IP + interleaved timing and re-link both parties — no cryptography prevents this at the network layer. The claim is minimization, not structural avoidance; Tor/proxy support (roadmap, §15) is what actually closes it. Servers SHOULD keep IP/connection logs ≤ 72 h and MUST publish retention + what is producible (§12.10). Clients SHOULD add randomized send/poll jitter to blunt timing correlation.
9.2 Operations
| Op | Auth | Semantics |
|---|---|---|
create(label)→{qid,otok,wtok} | account session (v1; blind token v1.x) | qid=random(16); label = 8 random bytes chosen by the client, stored with the queue and echoed in pokes so the owning device can name the channel locally without decrypting anything (§9.2 Push); per-account create-rate cap |
send(qid, envelope) | wtok | exactly 16 KiB or reject; per-queue token-bucket rate limit (e.g. 5/s burst, 60/min sustained — tune) + per-queue storage cap (count/bytes; overflow drops oldest and flags the product team). Store ≤ 30 days |
recv(qid, cursor) / WS subscribe | otok | envelopes in arrival order from cursor, each with its coarse server receive time at, plus the server clock now (see Receive time below); the WS carries pokes only |
ack(qid, upto) | otok | delete delivered — see persistence rule below |
rotate_wtok(qid) | otok | issue a new writer token (delivered to peer E2EE via control); MUST be invoked on channel COMPLETED so the invitation's exposed wtok dies |
rotate_otok(qid) | current otok | issue a new owner token and invalidate the old one; MUST be invoked on lease take-over (§11.1a) and re-vaulted atomically (§11.1); a device whose otok is refused MUST stop fetching and re-read the channel record — this is how a superseded device learns it was superseded from the server's response rather than from a synced blob it may never see |
destroy(qid) | otok | delete queue + contents |
blob_put(grant, blob) / blob_get(id) | upload grant (charged to sender quota) / possession | invitation & attachment blobs; hard size cap + per-IP rate limit; see §10 pinning |
inv_fetch(blob_id, code?) / inv_state(blob_id, poll_tok) / inv_revoke(blob_id, poll_tok) | possession (+ claim code) | §5.3: one-time fetch with the 3-strike claim-code gate; state poll and revoke by possession of poll_tok, never by account session |
push_mute(device_token, until?) | device secret (not otok) | server-side mute of all pokes to a device. Backs BOTH the user-facing "Pause notifications" feature and the duress session (§11.3), so the call is not a duress fingerprint; clients call it with jitter, never synchronously on gate entry |
push_rotate(old_token, new_token) | device secret (not otok) | the device's push token changed (FCM refresh, restore): the device row (mute state included) and every queue bound to the old token move to the new one. Lets a client fix its bindings without opening the sealed channel space (chat reliability N1, 2026-10-27). A token FCM reports dead is marked gone (no pokes) and kept 30 days so a late rotate still recovers its bindings |
Delivery persistence (normative): a client MUST persist the raw ciphertext envelope to encrypted
local storage before calling ack. Acking on fetch (before durable persist) is forbidden —
there is no other copy. WS reconnect MUST resume with recv(cursor) then subscribe; duplicates are
tolerated (DR counters dedup).
Receive time (0.5.2, an earlier issue — product decision 2026-10-10): the recv body is
{envelopes: [{seq, data, at}], next, now}. at is when S stored the envelope (epoch ms, coarsened to whole
seconds); now is S's clock when it answered (epoch ms). Both sit outside the ciphertext and add no new
information to S: it wrote the row and already knows when (it keeps that time for the 30-day TTL anyway). A
client computes offset = now − local_now when the response arrives and SHOULD display a received message at
min(at − offset, local_now) — the server's arrival time on the receiver's own clock — so neither the sender's
nor the receiver's clock has to be right, and the disappearing-message timer (§11.4) counts from the same
time. The payload ts (§8) stays as it is, is still authenticated by the ratchet, and MAY be kept locally
beside the display time; it is no longer what the receiver shows. Own messages show the local send time. A
client MUST fall back to the payload ts when at/now are absent (an older server). Order is unchanged:
rows sort by arrival (seq). The WebSocket hub carries pokes only, so recv is the one place the time is
delivered.
Push: on send, S emits a data-only poke to the product team's registered device token carrying the
queue's opaque label and no content. To reduce the send→deliver timing oracle (§12.4), S SHOULD
batch/jitter pokes per device. Client display tiers (§11.3): content-free (default) — "Nygma ·
new activity", nothing decrypted; name only — the client maps label → channel locally, zero
decryption, both platforms; name and message — the client fetches and decrypts (Android: headless
handler; iOS: Notification Service Extension with a native DR-HE receive path sharing the working store
through an app group, v1 product decision 2026-09-18). Delivery is best effort: WebSocket while foreground,
poke when the OS allows, full recv(cursor) on app open. In a duress session (§11.3) and in strong mode
while the drive is locked, notifications for the affected channels MUST be suppressed locally and content
stays in the mailbox. Servers send pokes through the existing Firebase project (FCM data-only, high
priority; APNs via FCM with mutable-content). No separate relay service: the WebSocket upgrade path
lives in the existing HTTP server (server.js), authenticates with otok in the first frame (never in
the URL), and clients tolerate socket drops on deploys by resuming with recv(cursor).
10. Attachments
fk ← random(32); content encrypted in 1 MiB chunks:subkey = HKDF(fk, ∅, "ncp1/att/content:"‖blob_id, 32), deterministic per-chunknonce = LE32(chunk_index) ‖ 0⁸(guaranteed-unique — avoids the random-IV-under-repeated-key shape of the prior ZK IV-reuse bug),aad = "ncp1/att:"‖blob_id‖":"‖chunk_index. Total size is Padmé-bucketed with a random tail before chunking. Thumbnails: separate blobs under"ncp1/att/thumb:"‖blob_id.- Upload via
blob_putwith a grant charged to the sender's quota (§9.2), through dedicated/api/ncp/blob/init|completeendpoints issuing presigned multipart URLs under anncp-blobs/prefix with a 30-day lifecycle rule. The existing drive upload route is drive-bound (drive prefix, file rows, plan limits) and MUST NOT be reused; the existing streamer is reused, parameterised with{chunkSize = 1 MiB, nonceFor(i), aadFor(i), subkey}. - Pointer message (
t:"media", inside the ratchet AEAD):{blob_id, fk, size, mime, name, thumb?, dur?}.dur(0.5.1, optional, seconds) lets a voice note show its length before the blob is fetched; it is the only content hint besidessize, andsizeis already Padmé-bucketed on the wire.thumbis ONE small encrypted preview (≈240 px JPEG, ≤ 10 KB, under"ncp1/att/thumb:"‖blob_id) carried inline in the 16 KiB envelope so a media message renders without a second fetch. True name/size/mime exist only inside message ciphertext. The sender's drive name is never transmitted. - Blob lifetime: blobs live TTL 30 d. Because references live only in E2EE messages the server can't read, there is no server-side pin; instead a client that vaults a media message (§11.4) MUST re-encrypt+store the attachment as a drive artifact at vault time (the drive copy, not the mailbox blob, is the durable copy). Non-vaulted media beyond 30 d is expected to expire — disclosed in-app.
- Vault-native send: a file already in a drive is decrypt-streamed and re-encrypted under
fkvia the #98 streaming pipeline (20 MiB windows); no plaintext staging. 5a. Early delete (0.6, delete for everyone): atblob/completethe sender MAY registerdelHash= SHA-256 of a 32-byte delete token it keeps on its own row (never in the pointer).DELETE blob/{blob_id}with the token inx-ncp-btokremoves the objects and the row at once (204; also when already gone); a wrong / missing token or a blob registered without one → 403 (it expires at 30 days). The receiver holds the blob_id but never the token.delHashis set when the row is created and never replaced. - Size (0.5.3, product decision 2026-10-16): the largest attachment is the sender's plan's largest file (the same
maxFileSizeas drives: 100 MB … 2 GB), applied by the sending app before anything is read — the server cannot tie a blob to the sender's plan without linking chats to the account (§9.1); its ceiling staysBLOB_MAX_CHUNKS= 2048 × 1 MiB (a 2 GiB file pads to exactly 2048 chunks). Both directions stream one chunk at a time. Presigned chunk URLs (PUT 30 min, GET 15 min) are re-requested during a long transfer — the same calls for the sameblob_idand chunk count. Receiving and viewing are never plan-limited; saving into a drive follows the drive's limit.
11. Drive binding, the working store, duress, and history
Round 4 replaced the July "everything under the drive key" model with a two-key model (design plan v2.1 §2; product team decisions 2026-09-18). The vault side stays under the drive; the live side lives under a per-device working key so chat behaves like a messenger. This section is normative for both.
11.1 The channel record (vault side) and the per-drive container
Every channel belongs to exactly one drive. The channel record —
{channel_id, record_version, lease {device_id, device_label, leased_at}, peer profile, tier, IK_J/IK_I keys, peer IK_pub, queue refs + tokens (wtok AND otok), settings, notification mode, strong_mode} — is
stored inside one fixed-size padded container per drive, artifact ncp-channels (baseKey = drive
metadataKey, artifactType="ncp-channels", fixed slot count × fixed record size, random filler,
rewritten as a unit), present on every drive including decoys from drive creation. A compelled
server therefore cannot count a drive's channels from artifact rows (Round-4 F9; the per-channel
ncp-channel artifact of 0.4 is withdrawn). record_version is monotonic and bound into the artifact
AAD (v2:<driveId>:ncp-channels:<version>); a device MUST reject a container whose version is lower
than the highest it has seen (server rollback defence). channel_id/message_id MUST be drawn from a
namespace that cannot collide with drive-file fileIds.
Re-vault invariant: vaulted queue tokens are always current — every rotate_wtok AND every
rotate_otok re-vaults atomically (write the new container, then use the new token). A device that
cannot re-vault (drive locked, offline) MUST NOT rotate.
The channel record is what lets a reinstalled device — or another device of the same user — recover channel identities and queue addresses with the drive password (§11.4). It never contains ratchet state.
11.1a The lease: one active device per channel (Round-4 F3, F6, F7, an earlier issue)
Only the device named in lease runs the ratchet, fetches from and sends to the channel's queues.
Every other device that opens the drive sees the channel read-only ("Active on <device_label> · Move
here") with whatever vaulted history exists.
Approved take-over (normal path). The new device writes lease_request {device_id, label} into the
container. The current holder — which alone holds the non-vaulted continuity secret in its device
keystore — shows "Move chats to <label>?"; on yes it (1) sends control:superseded{reset_epoch+1} to
each peer on the existing trusted ratchet, (2) calls rotate_otok on both queues and re-vaults, (3)
writes lease = new device with record_version+1, (4) wipes its working state. The new device then
runs session_reset (§11.4) with each peer; peers that received control:superseded MUST NOT raise
the Check-again banner. Silent for peers.
Unattended take-over (old device lost, or user chooses "I don't have it"). The new device writes
lease, calls rotate_otok on both queues, re-vaults, and runs session_reset with each peer. Peers see
the Check-again banner (mandatory SAS for sensitive channels). The previous holder, on its next launch,
finds its otok refused and/or a newer record_version, and MUST display "A device called '<label>'
took over your chats at <time>. Not you? → Revoke". Revoke = destroy both queues via the vaulted (now
rotated — so revoke requires the drive password on the old device, which re-reads the current tokens) and
notify peers with control:revoked and with the keyless revoked notice (2026-09-20, an earlier issue): after a
take-over the peer has already replaced the ratchet, so control:revoked on the old ratchet is unreadable.
The notice is authenticated with the channel identities instead, which both sides hold for the life of the
channel: K_rev = HKDF(X25519(IK_mine, IK_peer), ∅, "ncp1/revoke/key:" ‖ channel_id, 32),
tag = ChaCha20-Poly1305(K_rev, nonce 0×11‖'R', ∅, aad = "revoked" ‖ channel_id ‖ LE64(ts)), sent as a
keyless envelope CBOR{kind:'revoked', channel_id, ts, tag}. A peer that verifies it MUST show "This chat
was ended" and stop sending. The old device MUST NOT display a reassuring "moved" message.
Rules.
- A device whose
otokis refused, or that receivescontrol:superseded, MUST stop fetching and sending immediately and re-read the container. - After any rejected
reset_inita device MUST re-read the container and stand down if the lease is not its own;reset_respcarries the peer'slast_accepted_reset_epochfor counter re-sync. - Two devices that both see no lease (first open) race: the first
rotate_otokwins at the server; the loser's fetch is refused and it stands down. This is why enforcement is server-side, not policy. - The lease record is inside the AEAD'd container; the server learns nothing from it except the write pattern (mitigated by the fixed-size container).
- Web has no chat in v1; it may display vaulted history read-only and never takes a lease.
11.2 The working store (live side): K_wrap → K_work → K_store
K_wrap hardware-backed wrapping key. Android: AES-256-GCM in AndroidKeyStore, StrongBox if available,
TEE otherwise; NO setUserAuthenticationRequired; NO setUnlockedDeviceRequired (a push that
arrives with the screen locked must still decrypt). iOS: keychain item with
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly in a shared keychain access group (the
notification extension reads it). Non-exportable.
K_work 32-byte software key generated at first launch on EVERY installation (whether or not a chat
exists), stored wrapped under K_wrap. Never synced. Never backed up (android:allowBackup=false /
fullBackupContent exclusions; NSURLIsExcludedFromBackupKey). Present in process memory whenever
the app runs; that is stated, not hidden.
K_store per channel: HKDF(K_work, ∅, "ncp1/work/store:" ‖ channel_id, 32). Encrypts the ratchet-state
row (§7.4), the message cache and the channel index. At-rest records follow the ZK-v2 artifact
scheme (per-artifact HKDF subkey, fresh IV, AAD) — one IV, one plaintext, forever.
The working store is a single fixed-slot padded container (64 slots × fixed record size, random filler, rewritten as a unit; the message cache in fixed-size pages) in SQLite, present from first launch, so slot count, sizes and modification times reveal nothing about how many channels exist or which are active. It MUST NOT be kept in a small-value secure store.
Strong mode ("Require the vault password for Chats", set in Security Hub; shipped 2026-09-20): the
account's Chats vault (the hidden purpose='chats' drive) gets a user-chosen password — the client
proves the current KDF-v3 verifier and sends the new verifier plus the content/metadata keys re-wrapped
(wrap v2) via PUT /api/drives/{id}/keys; the server stores ciphertext and one-way verifiers only, and the
device-random convenience password is forgotten. The REAL space then uses its own working key
K_real (random 32 B) stored only as nonce24 ‖ XChaCha20-Poly1305(KEK, K_real, aad = "ncp/strong:" ‖ account_id) with KEK = HKDF(K_meta[chats drive], ∅, "nygma-mobile/ncp/strong/kek", 32); every real-space
row (container, slots, messages, inbox, pending) is re-keyed from the keychain K_work to K_real in one
SQLite transaction. K_real exists in memory only after the gate's second step (PIN → vault password →
unlockDrive → unwrap) and is forgotten, with the vault locked, on every gate lock. The decoy space keeps
the keychain K_work, so the duress PIN still opens a plausible empty app with no password step. No
background decrypt, content-free notifications, channels visible only while the vault is open. This is the
only mode in which hidden channels are cryptographically inaccessible while the phone is locked. Turning
it off needs the password: rows are re-keyed back and the password is stored again for convenience mode.
The earlier per-drive wording ("K_work[drive] = wrap_v2(K_drive, …)") is superseded: there is one Chats vault
per account, and the wrap is under a key derived from its metadata key, not under the drive password directly.
Because K_wrap is not credential-gated, the PIN/biometric gate of §11.3 is app-enforced. This
is stated plainly in P9 and in the published threat model (§12.3, §12.13).
11.3 The Chats gate and duress behaviour (normative)
- Gate. The Chats tab opens with the Nygma PIN (one PIN for the app shell and for Chats; per-drive quick-access PINs are unchanged). Biometric unlock is an opt-in shortcut, off by default for Chats. The gate re-locks after a background period (user-configurable 0 / 30 s / 1 min / 5 min; default 1 min, owner decision 2026-09-24; outside activities the app itself opens — camera, pickers, share sheets, permission screens — never count), MUST NOT fail open, and is disabled (chat unusable) until a PIN is set. A "Lock chats now" action exists. The PIN verifier is keystore-bound so an extraction cannot brute-force it offline.
- Duress PIN. The existing two-slot PIN store is reused: slot 1 = real PIN, slot 2 = the existing duress PIN (always present as random filler when unset, keystore-bound, uniform shape). Entering the duress PIN at the Chats gate — or entering the duress password on a drive — sets ONE session-wide duress state that persists until the user passes the locked gate again with the real PIN. Real-PIN entry from inside a duress session is not offered; the session must lock first.
- In a duress session the client MUST: (1) filter the channel index to channels whose owning drive
is a decoy — real channels are absent, never "locked"; (2) render the Chats tab as the first-run
state, pixel-identical to a never-used installation (default decoy; decoy channels with consenting
peers are an advanced option); (3) clear delivered notifications (best effort) and suppress new local
notifications for non-decoy channels — the headless/push handler reads the persisted duress flag
before posting anything; (4) call
push_mutewith jitter (the same endpoint backs "Pause notifications"); (5) show no counts anywhere (app icon badge never shows a number; tab badge is a dot); (6) leave real-password-on-real-drive behaviour exactly as today. Any account/quota UI MUST NOT display channel or queue counts. - Notifications. Default content-free. "Name only" (via the queue
label, §9.2) and "name and message" are per-drive opt-ins behind a disclosure that Android keeps a 24-hour notification history the app cannot clear and iOS keeps delivered notifications until cleared. Previews never contain a drive name. Chat screens are hidden in the app switcher; screenshots follow the device setting "Block screenshots" (default OFF, product decision 2026-10-16) unless the chat is protected (§11.3a). - Protect this chat (§11.3a, 0.6, product decision 2026-10-20). Each person has their own flag
(
control:protect {on}, kept in the channel record aspmmine /pppeer, vaulted, re-sent after a session reset). The chat is protected while EITHER flag is on — strictest wins — and only its product team can turn a flag off. While protected, both phones block screenshots and screen recording (Android FLAG_SECURE; iOS secure layer + a black view while the screen is captured) whatever the device setting, and offer no Copy, Save to drive or Open with. On iOS a screenshot that still happens is reported:control:screenshot→ "X took a screenshot" on both phones (≤ 1 per 10 s; never from the duress space). Honest limit, disclosed: it cannot stop another camera. - Free plan. The duress PIN and the decoy Chats state are available on every plan (product decision 2026-09-18); with "decoy = first-run" they cost nothing.
- Honest limits (elevated to P9, disclosed in §12.3/§12.13): default mode hides, it does not
lock — a coercer with the device passcode and forensic tooling reads every channel's working state;
strong mode restores cryptographic inaccessibility at the cost of background delivery; OS notification
history when "name only" was opted in; per-drive artifact counts are hidden by the fixed-size
container but the container's write pattern remains visible;
push_mutecalls are visible to the server (covered by the "Pause notifications" feature).
11.4 History (ephemeral by default, opt-in vault) & reinstall recovery
History. Default: messages live in the encrypted local cache under per-channel disappearing
timers (enforced both ends via control). Opt-in per channel (either side, visible to both):
messages are additionally written to the drive as v2 artifacts (artifactType="ncp-msg",
fileId=message_id, batched); vaulted media follows §10.4. Documented trade-off (also in-app):
vaulted copies are outside the ratchet's FS — protected instead by the drive's crypto + duress
invisibility.
Reinstall = session reset, not a dead channel. Because IK_J, the peer's IK_pub, and the queue
refs+tokens are vaulted (§11.1) — but ratchet state is not (never cloned) — a reinstalled device
recovers its channel list and re-establishes each channel without an out-of-band meeting, via the
session_reset re-handshake below. A reinstall onto a device that is not the lease holder is a
take-over and follows §11.1a (approved or unattended); session_reset is the cryptographic step of
both.
Round-2 REDESIGN. Draft 0.2's reset was a one-sided, single-message, static-authenticated derivation (
SK' = HKDF(… X25519(IK_restored, IK_peer) ‖ X25519(EK_new, IK_peer) ‖ EK_peer_pub? …)). Adversarial review found it broken on every axis: replayable (all inputs static/reproduced → a relay re-posting a captured reset re-derives an identicalSK', tears down the live ratchet, and restarts the deterministic-nonce sending chain at counter 0 → ChaCha20-Poly1305 (key,nonce) reuse + Poly1305 forgery + permanent DoS); no forward secrecy (both classical DH terms anchor on the peer's staticIK— a laterIK_peer_privleak recomputes the whole classical secret; there was no ephemeral×ephemeral term — the literalEK_peer_pub?was the missing peer contribution); no KCI resistance (symmetricX25519(IK_A,IK_B)=X25519(IK_B,IK_A), so a leak of the peer's ownIK_privlets an attacker impersonate any third party to that peer forever); PQ-downgraded (a one-message reset cannot carry a mutualss_pq', silently dropping the §2 hybrid MUST → harvest-now-decrypt-later returns on every reinstall); and under-specified (no reset transcript, no§13label, so the re-verify SAS meant to catch all this might not even bind the new keys). It is replaced by the following.
session_reset — a two-message, mutually-ephemeral, hybrid-PQ, epoch-bound, key-confirmed
re-handshake. The restored device is Alice-role (sends first). Both messages ride the existing
q_join/q_reply (framed as §8 keyless type 0x01, kind = reset_init / reset_resp), and a
receiver MUST NOT touch its live ratchet until key-confirmation succeeds (step 4).
per-channel reset_epoch : monotonic counter in the vaulted ncp-channel artifact (§11.1); the peer
persists last_accepted_reset_epoch and MUST reject any reset_init whose epoch ≤ it (anti-replay).
1. reset_init (restored A → peer B): { channel_id, reset_epoch, N_A(16),
EK_A(32, fresh X25519), kem_A(1184, fresh ML-KEM enc key) }
— authenticated implicitly: only the IK_A holder can complete step 3/4. No ratchet action yet.
B, on receiving reset_init, generates its own fresh EK_B + N_B, runs Encaps(kem_A), and derives
SK' (step 3) — so it can already key-confirm in reset_resp. B does NOT reset its ratchet yet.
2. reset_resp (peer B → A): { channel_id, reset_epoch, N_B(16),
EK_B(32, fresh X25519), kem_ct(1088 = Encaps(kem_A)),
confirm_B = AEAD(confirm_key, "B", reset_transcript) }
— confirm_B is B's key-confirmation. **It is MANDATORY and load-bearing** (see the Round-3
machine-check note): without a B→A confirmation, A is not authenticated to B's identity — an
attacker who substitutes reset_resp impersonates B to a reinstalling A. A MUST verify confirm_B
before completing (step 4). B still does NOT reset its ratchet yet.
3. Both derive, with FRESH ephemerals on BOTH sides + a real ephemeral×ephemeral term + a KEM secret:
reset_transcript = SHA-256( "ncp1/reset" ‖ channel_id ‖ LE64(reset_epoch)
‖ IK_A_pub ‖ IK_B_pub ‖ EK_A ‖ EK_B ‖ kem_A ‖ kem_ct ‖ N_A ‖ N_B )
RD1 = X25519(IK_A_priv, EK_B) ; binds A's identity to B's fresh ephemeral (KCI-resistant)
RD2 = X25519(EK_A_priv, IK_B_pub) ; binds B's identity
RD3 = X25519(EK_A_priv, EK_B) ; ephemeral×ephemeral → forward secrecy
ss_pq' = Decaps/Encaps(kem_A) ; genuine two-party KEM secret (both hold it after the round-trip)
SK' = HKDF( ikm=0xFF×32 ‖ RD1 ‖ RD2 ‖ RD3 ‖ ss_pq', salt=∅,
info="ncp1/reset/sk" ‖ reset_transcript, len=96 )
= RK₀'(32) ‖ session_id'(32) ‖ sas_seed'(32)
confirm_key = HKDF(SK', ∅, "ncp1/reset/confirm" ‖ session_id', 32)
; confirm_X = ChaCha20-Poly1305(confirm_key, nonce = 0x00×11 ‖ byte('X'), plaintext = ∅,
; aad = reset_transcript) → the 16-byte tag (P1 clarification: 'A' = 0x41, 'B' = 0x42;
; confirm_key is single-use per reset, the two nonces are distinct)
4. MUTUAL key-confirmation BEFORE any ratchet re-init:
- A first verifies B's confirm_B (from reset_resp); on failure ⇒ drop, keep the OLD ratchet.
- A then sends reset_confirm = AEAD(confirm_key, "A", reset_transcript).
- B verifies A's confirm; only then do BOTH re-initialize the ratchet from SK' (§7.1 roles:
A=Alice, B=Bob) and persist the new reset_epoch.
Any AEAD/confirm failure ⇒ drop silently, keep the OLD ratchet intact (never tear down on an
unproven or replayed reset). The MUTUAL confirmation is what makes the reset authenticate in
BOTH directions — ProVerif confirms `A↔B` injective agreement holds only with confirm_B present.
- The peer surfaces a prominent key-change / "Check again" banner (tier → Check again, §4) with the
composer disabled until the user acts, and a mandatory 8-word SAS gate for sensitive/duress
channels (a nudge only for low-sensitivity ones). 0.5.3 (product decision 2026-10-16): replaced by one quiet
system line in the thread and no blocked composer (§4); a Verified channel returns to Connected.
sas = HKDF(sas_seed', ∅, "ncp1/reset/sas" ‖ reset_transcript, 11). Exception: if the peer received an authenticatedcontrol:superseded{epoch}on the old ratchet before this reset (an approved take-over, §11.1a), it MUST NOT raise the banner; the old device vouched for the new one. reset_respadditionally carries B'slast_accepted_reset_epochso a device that lost a reset race (§11.1a) can re-synchronise its counter instead of falling to OOB re-pair (Round-4 an earlier issue).- Reset must not depend on a stale token: §11.1 mandates vaulted queue tokens are always current
(atomic re-vault on every
rotate_wtok). If the cachedwtokis nonetheless dead, the reset falls back to explicit OOB re-pair rather than black-holing. - PCS is honest now: it comes from the mandatory fresh mutual ephemeral (
RD3) whose private keys the attacker never held — NOT from "state was never cloned" (that only preserves the old session's FS; deleted from the spec as a non-sequitur). - Stolen-vault caveat: a coercer who extracts a real-drive
IK_priv(or holds the drive password) can still initiate a reset — and, since 0.5, an unattended lease take-over (§11.1a). The peer sees the key-change banner + SAS prompt (same class as Signal's stolen-identity-key case) and the previous device shows a take-over alert with Revoke on its next launch; it is never told a reassuring "moved". For a silent reset NCP additionally requires the non-vaulted continuity secret held in the device keystore (never written to the §11.1 record), which only the current lease holder has; a vault-only recovery is therefore always loud. A decoy-drive extract yields nothing. - Undecryptable pre-reset backlog is surfaced as "N earlier messages couldn't be recovered."
- Duress interaction (§11.3): re-establishment MUST be de-bursted (randomized jitter, lazily
per-channel on first use) and the reset envelope MUST be wire-indistinguishable from a
0x02 msg(same 16 KiB framing, same constanthdr_len) so a compelled server cannot bucket a decoy-unlock burst vs a later real-unlock burst by push-token/IP/timing to label hidden channels — see §12.9b.
12. Security considerations
- Malicious server. Cannot: learn keys/plaintext, substitute invitation blobs (AEAD), forge
messages, or silently MITM at bootstrap (P2). Can: drop/delay/reorder (availability); via IP+timing
correlate a channel's two queues and re-link both parties (P7 limit — mitigations: account-less
sends, jitter, Tor roadmap, blind quota v1.x); count how many queues an account created (v1
create-rate quota, §9.1 — disclosed); observe message counts/arrival (length hidden by §8); refuse
or roll back the channel container (bounded by
record_version, §11.1); force a reset by dropping traffic (visible as a Check-again banner). - Link interception (Connected). Passive view of a used link → nothing. Winning the race to fetch
first locks out the real joiner, who sees
CLAIMED— detectable by the joiner, not the inviter (the inviter sees a successful join). With the claim code (§5.3) a link-only attacker also needs the second channel; three wrong codes burn the link visibly to the inviter. Active replacement of link (and code) before first open → full MITM until SAS (§5.5). Bounded by the mutual SAS and the one-time verify nudge. - QR substitution (Verified). Requires controlling the display/camera at ceremony time and surviving the reverse-scan transcript check — i.e., endpoint compromise (out of scope).
- PCS limits. Healing needs a fresh, unobserved DH ratchet round-trip; skipped-key caches (§7.3)
extend exposure by design; an
IK-holder can impersonate within the channel until re-verification — surface key-change/reset events prominently (§11.4). Push pokes create a send→deliver timing oracle (SDA, ~5 messages in the literature) — mitigated by poke batching/jitter (§9.2) and coalesced receipts (§8; on since 2026-10-14 by product decision — the remaining send→deliver timing signal is accepted and disclosed). - SAS grinding. ~2⁸⁸ against a single-use invitation (§5.5); single-use is the load-bearing invariant; partial comparison is a break (UI forbids it).
- Deniability. Offline deniability of content: no signatures anywhere; the KEM keys are
ephemeral and unsigned (so the published PQXDH deniability caveats about signed long-term KEM
prekeys apply less here, not more);
confirm_B,reset_confirm,control:verifiedandcontrol:supersededare MACs under keys both parties can compute → simulatable. Not claimed: online deniability; deniability of participation — account-authenticated queue creation and push registration are server records. Say "content deniability yes, participation deniability no". - Replay. Invitations: single-use
inv_id+ one-time blob lock. JoinMsg: dedup distinct msgs; retransmit identical (§6.4). Messages: DR counters + delete-skipped-key-on-use (§7.3). - Downgrade. Version is bound in
transcript(whole invitation hashed); peers MUST reject other versions, never negotiate down. - Quantum (corrected in Round 4, an earlier issue). Handshake AND session-reset are hybrid, and every Double
Ratchet root key is derived from the previous one, so every session key inherits the ML-KEM
secret: recorded traffic is not decryptable by a future quantum adversary absent a device-state
compromise. The classical DH ratchet means a quantum adversary holding a state snapshot can read
messages until the next
pq_rekeyepoch (§7.5: ≤ 50 messages / 24 h + one round trip; passive adversary; epoch key generated after the snapshot). The 0.4 sentence "could break post-appearance messages" was wrong and is withdrawn. 9b. Reinstall reset-burst vs duress (disclosed). Blind quota (§9.1) blinds only create-time account→qid; it does NOT blind the device/push-token grouping. A reinstall re-establishing many channels produces a burst a compelled server could bucket (decoy-unlock vs later real-unlock) to infer hidden channels. Mitigated (not eliminated) by de-bursting/jitter + wire-indistinguishable reset envelopes (§11.4) + per-drive push-token rotation; disclosed in §4.7/P9. 9d. Account-linked message tokens (disclosed, product decision 2026-10-23). For admin messages (offers, news, service notices —/admin/messages) the app registers its FCM token WITH the signed-in account (pushtokentable). The same token may also be the push token of the device's chat queues (ncpqueue.pushToken,ncppushdevice, still never account-linked), so a compelled server can now join account → token → that device's queues, i.e. learn which queues an account's phone reads (not with whom, not the content). Chosen knowingly over FCM topics (no tokens on the server) for exact targeting and counts. The chat tables are unchanged; a later hardening is a second FCM sender id for messages (a different token per purpose). Public claims must not say the server cannot link an account to its chat queues. 9c. Persistent qids link devices across reinstall (disclosed). Per-channel queue IDs are stable pseudonymous identifiers; a reinstall drives the identical qid pair from a new IP/device, so an IP-logging server can link old↔new device. Disclosed in the §4.7 threat table; optional fresh-qid migration insidesession_resetis reserved (§15). - Metadata honesty. The §4.7 threat table in the design doc is normative marketing collateral; public claims MUST NOT exceed it (FTC v. Zoom; UK ASA — never "most secure").
- Long lock/coercion vs 30-day TTL. A user locked out or coerced > 30 days silently loses queued messages. Disclosed in-app; TTL extension MAY be a paid-plan option.
- DoS surfaces. wtok flooding (bounded by per-queue rate + storage caps + mandatory rotation,
§9);
blob_putexhaustion (bounded by grants + size/IP caps, §10); trial-header CPU (bounded by rate limits +MAX_SKIP, §7.3); claim-code burn (requires the link; the loud burn is the intended outcome); envelope must be stored in object storage, not an InnoDB row (16 KiB > page → off-page churn). - Duress default mode hides, it does not lock (Round-4 F4).
K_wrapis not credential-gated (§11.2); a coercer with the device passcode plus forensic tooling reads every channel's working state, hidden ones included. Nygma's drive duress does survive that (the real drive key is password-derived and absent from the device); chat's default mode does not, and marketing MUST never blend the two. Strong mode (§11.2) restores the drive-class guarantee. - Client-code trust. The "no key server" guarantee is strongest in the native apps (store-signed, reproducible Android build). A web client runs code the server delivers; v1 chat is mobile-only for this reason, and web chat (v2) ships with subresource integrity and a signed bundle manifest.
- Lease take-over by a vault holder (§11.1a). Possible by design for recovery; made loud on
the previous device and at the peer (Check-again banner) unless the previous device approved it.
Server rollback/withholding of the container is bounded by
record_versionand by server-sideotokrotation; the failure mode is a refused fetch, never a silent fork. - OS notification history. With "name only" opted in, Android's 24-hour history and iOS's Notification Center retain names the duress session cannot remove; the default is content-free and the opt-in carries this disclosure (§11.3).
12.14 Map previews for location messages (client-side, opt-in)
A location message renders by default as coordinates + accuracy + the sender's label, with "Open in Maps"
as an explicit user action — nothing about the point leaves the device until then. The receiver MAY turn on
map previews (Security Hub › Chats, off by default). With it on, the client fetches one map tile
(z = 15, ≈ 1.2 km square) per location message directly from the tile provider configured at build time
(EXPO_PUBLIC_MAP_TILE_URL, e.g. a keyed MapTiler/Stadia endpoint serving OpenStreetMap data; the public
OpenStreetMap servers block app traffic under their usage policy, so they are not used). Without a
configured provider the switch is disabled and says so. What the provider learns: the receiving device's IP
and the tile (a ~1 km² area) at fetch time, plus the app's key. What it cannot learn: who sent the point,
which chat, the exact coordinates, or the label. The setting text states this plainly; required attribution
is shown on the tile. Senders are not told whether a receiver has previews on. No Google/Apple map SDK is
embedded.
13. Domain-separation label registry (v1 — no additions without a spec bump)
ncp1/invblob/id, ncp1/invblob/key, ncp1/invblob/poll (HKDF, §5.3), ncp1/invblob(aad),
ncp1/claim (HMAC, §5.3), ncp1/hs, ncp1/hs/sk, ncp1/jmwrap, ncp1/jm(aad), ncp1/sas, ncp1/hk0,
ncp1/rk, ncp1/pqrk (HKDF info, §7.5; spelled without a slash so that ncp1/rk is not its prefix — the P1 label test enforces §13's prefix-freedom mechanically), ncp1/mk, ncp1/msg(aad), ncp1/hdr(aad),
ncp1/att/content:*, ncp1/att/thumb:*, ncp1/att:*(aad), ncp1/work/store:* (HKDF, §11.2;
replaces ncp1/store:*), ncp1/reset (reset transcript prefix), ncp1/reset/sk, ncp1/reset/confirm,
ncp1/reset/sas; header field pq (§7.2); at-rest artifactTypes ncp-channels (per-drive
container, §11.1; replaces ncp-channel), ncp-msg (existing v2 AAD scheme), with the
record_version AAD extension v2:<driveId>:ncp-channels:<version>. Removed in 0.5: ncp1/pqrk,
ncp1/lease (the lease is inside the AEAD'd container; no separate label).
Injectivity (corrected in Round-2): within each primitive's input namespace — AEAD
associated-data, HKDF info, and hash-transcript prefix — the labels are pairwise prefix-free, so
label ‖ variable is unambiguous (and inv_id/channel_id are fixed-length). Textual prefixes that
appear across namespaces (ncp1/jm ⊂ ncp1/jmwrap; ncp1/hs ⊂ ncp1/hs/sk; ncp1/reset ⊂
ncp1/reset/*) are harmless because the two members of each pair are never inputs to the same
primitive and labels are constructed at fixed call sites, never runtime-parsed. (The draft-0.2 blanket
"no prefix is a prefix of another" was literally false; this is the accurate property.)
14. Implementation mapping (informative)
- the apps’ shared protocol library — protocol core on the existing
CryptoPrimitivespattern; new primitives via@noble/curves(X25519) +@noble/post-quantum(ML-KEM-768); byte-parity proven web↔mobile by the ZK-v2 fixture harness. All X25519/ML-KEM validity checks (§2) implemented explicitly, not left to the binding. - RN production notes (corrected in Round 4):
(a) CSPRNG health-check gate — before ANY keygen, run a runtime self-test on the app's actual
randomness source (
expo-cryptogetRandomValuesbound into noble'srandomBytes; the app does not usereact-native-get-random-values): draw two 32-byte buffers, assert neither is all-zero and they differ, and fail closed. (b) ML-KEM runs in JS (@noble/post-quantum) in v1: no app-facing native ML-KEM exists on Android, iOS CryptoKit exposes it only from iOS 26, andreact-native-quick-cryptowraps none. This is acceptable precisely because §2's "classical security MUST NOT depend on ML-KEM" rule is load-bearing; native decapsulation (Nitro module over OpenSSL 3.5) is reserved for v1.x. Benchmark Encaps/Decaps on the mid-range test phone in S0. (c) Working store inexpo-sqlite(WAL, transactions), never a small-value secure store;K_wrap/K_workviareact-native-keychain(iOS access group,AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY) or a small Expo Module for the Android StrongBox flag. (d) Push:@react-native-firebase/messagingheadless handler on Android; a Notification Service Extension target on iOS (third signed target in the Mac build chain; App Groups + Keychain Sharing entitlements). (e) Camera/QR:expo-camera+react-native-qrcode-svg;CAMERApermission andNSCameraUsageDescription. (f) App Links for/chon both hosts. (g) Screen hygiene:expo-screen-captureon chat routes. - Test vectors: port Signal DR + DR-HE vectors; generate NCP handshake/reset vectors from the reference impl; property-based tests for the state machine (out-of-order, replay, lost-ack retransmit, session-reset, restart).
- Server:
/api/ncp/*routes in the existing Next.js app; the WebSocket upgrade path in the existingserver.js(no separate relay service);firebase-adminfor pokes; envelopes in S3 (ncp/{qid}/{seq}) with a 30-day lifecycle plus a TTL cron on the existingCRON_SECRETpattern; Prisma tablesncpqueue {qid, label, otokHash, wtokHash, createdAt},ncpenvelope {qid, seq, s3Key, createdAt},ncpinvitation {blobId, blob, exp, claimHash, attempts, state, pollTokHash},ncppushdevice {token, deviceSecretHash, muted, mutedUntil},ncpquota {accountId, day, created}— lowercase names, hand-written migration applied withprisma migrate deploy; no writer identity, noaccount→qidcolumn, ever (blind tokens in v1.x remove even the create-time knowledge).
15. Reserved for future versions
CPace spoken-code pairing (Verified-remote); SPQR-style continuous PQ ratchet (per-message
rather than per-epoch, superseding §7.5); blind quota tokens (Privacy-Pass-style, replacing the v1
create-rate counter); an OPRF/PAKE-gated invitation fetch that makes the claim code a cryptographic
gate against non-servers; native ML-KEM decapsulation; a 4 KiB second envelope tier; hardware-backed per-channel IK (Secure
Enclave/StrongBox, non-exportable); multi-device signed device lists; MLS group channels (RFC 9420,
hybrid-PQ cipher suites, basic credentials verified via §5 ceremonies); Tor/SOCKS transport;
per-message write-token rotation; fresh-qid migration inside session_reset; oblivious message
retrieval for queue read-unlinkability; opt-in commit-reveal "strong verify" SAS.
16. Adversarial review — findings & resolutions
Round 1 (draft 0.1 → 0.2)
Two hostile reviewers (crypto lens; systems/state lens). Verdict: crypto skeleton sound; not implementable as 0.1 due to C1 + claim/spec gaps. All below resolved in 0.2.
Crypto lens
- C1 (critical) — DR init self-contradictory / channel bricks on msg #1. Bob-role inviter had no
sending chain yet was told to send
chan_ackfirst; and destroyingEK_I_privat COMPLETED made the first receive undecryptable. → Fixed §6.4/§7.1: joiner (Alice) sends first; inviter retainsEK_I_privuntil the first ratchet step consumes it; destroys only the ML-KEM key + replay state at COMPLETED;chan_ackis Bob's response. - H2 — "no key to server ever" broken by cleartext
ik_jin JoinMsg. → Fixed §3.1/§6.3: JoinMsg now wrapsik_j/ek_j/profile underWK(derived from a throwawayWK_JDH toEK_Iplusss_pq); relay sees only random points + KEM ct + AEAD. Claim reworded to the precise form. - H3 — P7 "structural, no edge recordable" false vs IP-logging server. → Reworded P7/§9.1/§12.1 to minimization; added blind quota tokens + jitter; named Tor as the real fix (roadmap).
- H4 — envelope padding ambiguous → length oracle. → Fixed §8: plaintext padded to the fixed capacity before AEAD; ciphertext/envelope exactly 16 KiB regardless of content; variable emission forbidden.
- H5 — DR-HE header keys mis-seeded (4 symmetric instead of 2 crosswise). → Fixed §7.1 with the
exact
hka/nhkbcrosswise assignment. - M1 — no mandated key validation. → §2 now requires X25519 all-zero rejection + ML-KEM checks.
- M2 — SAS invariant unstated; partial-comparison "graceful" is wrong. → §5.5 makes single-use invitations a security-critical invariant and states partial comparison is a break; UI must require all 8 words (Round-2 raised 6→8; see the Round-2 block).
- M3 — duress leaks via per-account queue count. → §11.3/§12.3 elevate the limit and spec blind quota as the duress mitigation.
- M4 — push poke is a per-message timing oracle. → §9.2/§12.4 batch/jitter pokes; disclose.
- M5 — transcript hashed over re-encoded CBOR. → §2/§6.2 hash received octets, not re-serialize.
- L1 — random-IV-under-fixed-subkey (echoes prior IV bug). → §10 deterministic per-chunk nonce.
- L2 — skipped key not deleted on use. → §7.3 delete-on-use normative.
- L3 —
hdr_lenunbounded. → §8 bounds-check mandated. L5 — J's Verified transition underspecified. → §5.2 I sends authenticatedcontrol:verified. L6 — SAS derived from material incl. RK₀. → §6.2 dedicatedsas_seedslice. L7 — header-key nonce reuse volume. → §7.2 documented + ratchet-before-2³² guidance. (L4 CPU-DoS noted in §12.12.)
Systems lens
- Critical/product-killer — reinstall = manual re-pair + silent black-hole sends. → Fixed §11.4
session_reset(vaulted IKs re-authenticate; fresh ephemerals; key-change banner; PCS intact). - Critical/claims — "no A↔B edge" practically false. → same as H3.
- High — lost
chan_ackdeadlock. → §6.4 identical-retransmit + ack-resend rules. - High — invitation
wtoknever rotated (permanent flood token). → §5.4/§9.2 mandatoryrotate_wtokon COMPLETED. - High —
blob_putopen/unmetered; TTL "unless referenced" unimplementable. → §9.2/§10.4 blind upload grants + size/IP caps; no server pin — vault re-stores media instead. - High — generic pokes betray hidden channels under duress. → §11.3 device-level
push_muteauto-invoked on duress unlock. - High — ack/cursor loses messages; WS resume undefined. → §9.2 persist-before-ack + resume from cursor.
- High — iOS background delivery unreliable. → §9.2/§12 honest per-platform delivery model (foreground WS reliable; background best-effort; locked-drive fetch-on-unlock).
- Med — joiner crash burns link / false interception alarm. → §5.3/§5.4 atomic persist +
"already opened here" UX. Med —
expenforcement ambiguous. → §5.3/§6.4 server-authoritative states + inviter grace + joiner timeout; orphan-queue GC. Med — subpoenable channel-count. → disclosed in §12.1 + blind quota. Med — 16 KiB cost. → §8 documented decision + piggybacking. - Low — 30-day TTL vs long lock (§12.11); attacker-chosen
srv(§5.1 allowlist); envelope off InnoDB row (§12.12).
Crypto re-review (the second crypto pass, applied into draft 0.2): independently re-derived C1
(both facets — Bob has no sending chain to open with; EK_I_priv must survive to the first ratchet
step) — already fixed as C1. It confirmed as sound, do not "fix": the UKS/handshake auth (whole
invitation + IK_J‖EK_J‖kem_ct transcript-bound), the concat-then-HKDF PQ combiner (secure if either
primitive holds; KEM non-binding not exploitable since kem_ct is transcript-bound), the EK_I
double-use (= Signal's SPK reuse), deniability, deterministic message nonce, and the §13 label
injectivity. Additional refinements it raised, now applied: header AEAD → XChaCha20-Poly1305
(§2/§7.2; 192-bit random nonce removes the long-lived-HKs birthday concern — a deterministic header
nonce is impossible because the receiver can't know the counter pre-decryption); SAS 6→8 words
(§5.5; 2⁸⁸ margin; commitment round rejected as incompatible with async invites); session_id MUST
NOT be transmitted (§6.2); transport version bound into the message AAD (§7.2); session_id
added to the message-key info (§7.2); joiner's first ratchet key MUST be fresh and ≠ EK_J
(§7.1).
Round 2 (draft 0.2 → 0.3) — focused pass on the two new key-agreement paths
A multi-agent adversarial workflow (7 attack lenses + 4 web-validation passes + per-finding verification, ~45 agents) on the §6.3 identity-hiding wrap and §11.4 session-reset (the two paths added in 0.2 that had never been independently attacked). 27 findings confirmed/partial, 5 refuted. Full verbatim log + resolution map in internal review log Round 2.
Verdict per path:
- §6.3 wrap — SOUND (cleared for P1 after mechanical fixes). Confirmed: relay cannot learn
IK_J; hybrid; deniable. Applied fixes: removedwk_jfrom the §6.2 transcript (a relay could flip its RFC-7748 top bit → same decoded point/sameWK/ctstill decrypts, but different octets → SK desync + burned single-use invitation); boundwk_jinto thectAAD; foldedkem_ct+KEM-pubkey into theWKinfo(ML-KEM non-binding hardening); madess_pqzeroization explicit (§6.4 step 4); generalized theq_reply.srvallowlist check (§6.4 step 2). - §11.4 session-reset — REDESIGNED (was needs-redesign). The 0.2 one-sided static-authenticated
reset failed on every axis — replayable (→ deterministic-nonce reuse = two-time-pad + forgery +
permanent DoS), no forward secrecy (no ephemeral×ephemeral term), no KCI resistance (symmetric
X25519(IK_A,IK_B)), PQ-downgraded (one message can't carry a mutualss_pq'), and under-specified (no reset transcript/label → the re-verify SAS might not bind the new keys). ~12 findings retired by one unified redesign: a two-message, mutually-ephemeral, hybrid-PQ, epoch-bound, key-confirmed re-handshake withreset_transcript+ncp1/reset/*labels (§11.4, §13). Added the vaulted-token re-vault invariant (§11.1) and the non-vaulted continuity-secret requirement for silent reset.
Doc/consistency fixes: SAS reconciled to 8 words / 2⁸⁸ everywhere (§4 tiers, §12.5 — the 0.2
change had left stragglers); §13 injectivity reworded to the accurate intra-namespace prefix-free
statement; §8 reset envelope framing (keyless 0x01, kind-discriminated); disclosures added for
persistent-qid device linkage (§12.9c) and reinstall reset-burst vs duress (§12.9b, §4.7).
Design viability (web-validated, 4 passes): YES. The exact architecture class (no key server,
OOB bootstrap, per-direction queues, DR + hybrid PQ) ships in production (SimpleX); @noble is
Cure53-audited; react-native-quick-crypto gives native ML-KEM-768 (sub-ms on mobile); 88-bit SAS is
conservative. The §6.2 handshake is at/ahead of the 2024-26 literature (it already binds both KEM
pubkey and ciphertext into the transcript — the canonical PQXDH mitigation — and is deniability-correct
by having no signatures). Two RN landmines are now normative (§14): a CSPRNG health-check gate and
native ML-KEM decapsulation.
Confirmed sound, do NOT "fix" into bugs: relay JoinMsg-substitution (relay lacks the invitation ephemerals → collapses to the disclosed OOB-link MITM caught by SAS); the per-channel fresh IK does not block the v2 MLS trust-carry (MLS basic credentials are themselves OOB-verified, directory-free); low-order/all-zero already covered by §2.
New hardening ideas (ranked, with adopt-when) live in internal engineering notes. v1 blockers already folded into this draft; v2 headliners: SPQR-style continuous PQ ratchet (the real remaining PQ gap — §15) and hardware-backed per-channel IK (§15).
Round 3 (draft 0.3 → 0.4) — formal verification in ProVerif
The two new key-agreement paths were machine-checked in ProVerif (symbolic Dolev-Yao model;
DH equational theory, KEM as a correct-decapsulation function, HKDF/AEAD as random-oracle/one-way
functions). Models + a reproducible Docker harness are committed under the published ProVerif models (ncp-proverif-models.zip); the
full write-up with every query result is the formal verification report (published with this spec).
Results (all as intended):
- §6.2 handshake + §6.3 wrap (Verified tier): injective agreement ✅, secrecy of the joiner's
identity key
IK_J✅ and ephemeralEK_J✅ from the relay (the wrap's goal), session-key secrecy ✅, honest run reachable ✅. - §11.4 reset — mutual injective authentication ✅✅, new-root-key secrecy ✅.
- §11.4 KCI resistance ✅ — with the peer B's own long-term key compromised, B still authenticates A (attacker cannot impersonate a third party to B).
- §11.4 forward secrecy ✅ — the new root key stays secret even after both long-term identity keys leak post-session (the ephemeral×ephemeral term carries it).
- PQ-hybrid ✅✅ — the session key stays secret when the attacker is handed the KEM secret (the classical leg carries) AND when handed all X25519 exponents (the PQ leg carries): confirms harvest-now-decrypt-later resistance.
- Negative control ✅ — the Connected tier (attacker-substitutable invitation, no reverse-scan) correctly FAILS (identity leaks, auth breaks), proving the models find real attacks and the Verified/Connected boundary is load-bearing.
Real gap found & fixed (→ draft 0.4): the draft-0.3 reset used only a one-way (A→B) key
confirmation, so ProVerif proved B⟹A injective agreement but REFUTED A⟹B — i.e. A was not
authenticated to B, letting an attacker who substitutes reset_resp impersonate B to a reinstalling
device. Fixed by adding a mandatory B→A key confirmation (confirm_B in reset_resp, §11.4
step 2/4); with it, ProVerif verifies both-direction injective agreement. This is a genuine
protocol correction the manual/LLM-adversarial passes (Rounds 1-2) missed and the formal check caught.
Symbolic-model caveats (documented, not gaps): ProVerif abstracts away computational/encoding issues (e.g. the X25519 top-bit malleability from Round-2 finding #13, and ML-KEM's non-binding property) — those are handled by the transcript/AAD binding fixes and by keeping the security proof robust to a KEM break (the hybrid result). Deniability is argued from the no-signatures design, not re-proven here (would need observational equivalence). A Tamarin cross-check with an explicit MAL-BIND KEM theory remains a nice-to-have but is not a P1 blocker.
Next gate: P1 implementation — the apps’ shared protocol library on the CryptoPrimitives
pattern, porting the ProVerif-checked message flows and the DR/PQXDH test vectors.
Round 4 (draft 0.4 → 0.5) — three independent reviews of the v2 product layer
Three fresh-context reviewers (cryptography/protocol, product/UX, engineering feasibility) examined the
September design plan, this spec and the mockups. Reports: chat-review-2026-09-18/; consolidation and
product team decisions: internal review notes; log: internal review log.
Verdict: §6, §7.1–§7.3 and §11.4 unchanged and still sound. All findings were in the v2 plan's new material; resolved in 0.5 as follows (G0 sign-off blockers marked ★):
- ★ F1
pq_rekeycircular/ambiguous → §7.5: header-carried on every message of a chain; root step bound to the chain'sdh_pub;sk_ezeroization; epoch rules;KDF_RK_pq. an earlier issue → fixedHDR_CAP, constanthdr_len(§7.2, §8). - ★ F2 commitment-QR deadlock → form withdrawn (§5.1); raw-byte QR reserved pending S0.
- ★ F3 lease-assisted quiet takeover, F6 no cryptographic teeth / server rollback, F7 move never
silent, an earlier issue epoch race → §11.1a approved vs unattended take-over,
control:superseded,rotate_otok(§9.2),record_versionin the AAD (§11.1), loud alert + Revoke, epoch re-sync inreset_resp(§11.4). - ★ F4 P9 overstated, an earlier issue K_work not implementable as written, an earlier issue §2.3 rationale → §11.2
K_wrap/K_work/K_storelayering, not credential-gated, app-enforced gate, strong mode; P9 and §12.13 rewritten; fixed-slot padded working store. - ★ F5 OS notification history → content-free default, clear on duress entry, disclosure (§11.3, §12.16). ★ F8 two duress triggers → one session-wide duress state; slot-2 reuse; offline gate (§11.3). an earlier issue PIN storage → keystore-bound verifier, two-slot uniform shape (§11.3).
- F9 per-drive artifact counts → fixed-size
ncp-channelscontainer (§11.1). an earlier issuepush_mutefingerprint → "Pause notifications" twin, jittered (§9.2, §11.3). an earlier issue backup exclusion (§11.2). an earlier issue per-drive settings off the server (§11.1 record). an earlier issue stale §7.4/§9.2/§11 text updated. - an earlier issue §15.3 per-app facts, an earlier issue PQ claim → §12.9 corrected; positioning corrected. an earlier issue claim
code → peppered HMAC, possession
poll_tok, "not a secret from the server", lifecycle withOPENED/BURNED(§5.3, §5.4). an earlier issue deniability → §12.6 content-yes/participation-no. - Engineering blockers: K_work gating (→ §11.2), iOS notifications (→ queue
label, tiers in §9.2), lease enforcement (→rotate_otok), no native ML-KEM / stale RNG library (→ §14). Also: no separate relay (§9.2), quota as create-rate (§9.1), blob endpoints + inline thumbnail (§10), SQLite working store (§7.4, §11.2), camera/App Links/screen hygiene (§14). - UX: Connected badge neutral, Check-again amber with disabled composer (§4); mutual SAS, tappable tiles, language-independent word list (§5.5); two-step link-then-code, honest invite states, invitee path (§5.3, §5.3a, §5.4); scan-first entry and terminal screens for the QR ceremony (§5.2).
Not adopted, with reasons: shorter SAS (no commit-reveal round → 88 bits stays, §5.5); leaving the
notification shade uncleared on duress entry (the visible name is the worse leak); a separate relay
service (server.js already hosts sockets).
an earlier issue formal coverage: Round 5 must model (a) the §7.5 root-step rule across a reordered chain
boundary with KEM-break and DH-break experiments, (b) two devices racing a reset (§11.1a), before the
pq_rekey flag is enabled in P1.
Next gate: G0 — external human cryptographer's design review of this draft with
chat-review-2026-09-18/review-crypto.md alongside, then the Round-5 ProVerif models, then P1.
Round 5 (draft 0.5) — ProVerif: pq_rekey rule and the lease race
Models under ncp-formal/ (ncp-pqrekey*.pv, ncp-lease-race*.pv), report in
the formal verification report (published with this spec) Round 5. All intended results obtained: §7.5 root-step agreement and
secrecy; post-quantum post-compromise healing with the classical negative control leaking; hybrid
(KEM secret leaked); the pending-sk_e caveat confirmed (A5 leaks, as §7.5 states); §11.1a no-fork
under an atomic rotate_otok compare-and-swap (B1), race completion witnessed (B2). Modelling note:
ProVerif cannot decide inequality-based mutable state, so the lease result is conditional on the server
implementing rotate_otok as a single atomic conditional update (Suite 78).
Next gate: G0 (external human design review of this draft), then P1.