Whitepaper
A short, honest overview of how YGOOW works and, just as importantly, where each protection ends. Nothing here asks you to trust us; it asks you to verify.
1. Design goals
- No identity on the server. No accounts, no phone numbers, no user table. The relay cannot know who talks to whom.
- Access by key, not by permission. Who can read a message is decided entirely by who holds the key — never by a role or a list on our side.
- Metadata minimisation. The network layer (Tor v3) hides your IP and location; the application layer stores only ciphertext and timestamps.
- Honesty about limits. Every layer is documented together with what it does not protect.
2. Identity & key exchange
Your identity is a set of keypairs — Ed25519, X25519 and a post-quantum ML-KEM-768 key — all derived locally from one seed and held only on your device. A password does not derive the identity — losing the device loses the identity by design. One install can hold several independent identities behind a single app-lock, each with its own keys and contacts and nothing linking them.
Optionally, a YubiKey (over NFC) can be required as a second factor for the app-lock: its HMAC-SHA1 challenge-response — computed on the key and never leaving it — contributes one secret to the Argon2id key alongside your password, so unlocking needs both. It is a local, offline access gate, not a per-message signature; the slot is set up once with ykman.
Conversation keys are exchanged offline by default — in person, by shared file, or by a QR scan — which removes an entire class of man-in-the-middle attacks. A remote, network-assisted exchange is available as a deliberate, clearly-labelled choice, with a fingerprint (safety number) to verify later. Once two people are contacts, a conversation needs no room code at all: both sides derive the same session identifier and channel key from their X25519 ECDH secret (bound to both identities), so writing to a contact is the invitation — and the relay sees only an unguessable id it cannot tie to either party.
3. Message cryptography — “Variant C”
Each message is sealed with AES-256-GCM under a key derived from your chosen secret:
- every secret is measured for its real strength; low-entropy inputs — a short password, a small or guessable file, a predictable link — are stretched with Argon2id (memory-hard) before use, while high-entropy material is hashed with SHA3-256, and the weakest factor is never allowed to hide behind the strongest;
- there is no key-wrapping and no key hint in the ciphertext. The client simply trial-decrypts with the keys it holds. A wrong key yields a locked block — no error, no oracle.
Because every message can use a different key, the same room looks different to each participant: some lines are text, others are just a lock.
On the phone, AES-256-GCM runs in a native library (ring) on the processor’s own AES and carry-less-multiply instructions, called through FFI from every place the app encrypts — including background workers. It replaced a pure-Dart implementation after we measured that one leaking timing (§11): the output is byte-identical, and the app’s self-test shows which implementation is actually running.
4. Quorum decryption (Shamir)
A message can be split so it only opens when K of N people combine their keys (Shamir’s Secret Sharing over GF(2⁸)). One infiltrator holding one share learns nothing.
5. Conversation modes, forward secrecy, and post-compromise security
A message passes through up to five independent layers — transport (TLS), Tor, the end-to-end channel, optionally locked content, and optionally a set of conditions (a lifetime, an open-after date, or burn-after-read) — and each layer above the channel is a deliberate choice. There is no insecure default: you select a conversation’s protection before it begins (see choosing your protection). Those conditions travel inside the sealed block, so the relay never sees them; they are checked on the recipient’s device — an honest client-side policy, not something the server can read or enforce against a determined holder.
Forward secrecy is always present. For an ongoing conversation a symmetric ratchet advances the key per message and erases the used key, so compromising the current state does not retro-actively expose earlier messages.
Post-compromise security (PCS) — a conversation healing itself after a state compromise — is provided where the channel itself is what protects you, and is not over-claimed where it is not. The plain-channel contact mode runs a Double Ratchet: every reply mixes a brand-new key-exchange result into the conversation root, so an attacker who steals your state loses the conversation again after one full round-trip. The locked-content modes do not depend on it — there the content is sealed under a separate secret the channel never holds, so a compromised channel does not read the words at all.
The honest edge: PCS needs entropy the attacker has never seen, and we have no prekey server to hand it to us (§6 — the relay is deliberately incapable of holding anything on your behalf). So the responder’s starting ratchet key is derived deterministically from the ECDH secret the two of you already share, which means the very first chain is only as fresh as that root. It heals after one full round-trip, once each side has contributed a key the other has never seen. This is a real trade against designs that keep one-time prekeys on a server — we take it deliberately, and we write it down rather than round it up.
Post-quantum. An adversary who cannot break X25519 today can still record your ciphertext and wait (“harvest now, decrypt later”). The contact channel therefore runs a hybrid: each ratchet step mixes an ML-KEM-768 (FIPS 203) shared secret alongside the X25519 one, combined so the result stays secure as long as either primitive holds — ML-KEM is added to X25519, never substituted for it. The post-quantum material rides in every message of an epoch rather than only the first — see §7 for why paying that bandwidth is the point.
Group rooms use per-sender keys with a forward-secret chain per sender, signed epoch changes for every membership change, and the same size padding and message conditions as one-to-one conversations. They are a deliberate trade: a room gives up the deniability of a one-to-one channel for who said what among its members, and the app says so before you create one. The honest edge today: a membership change reaches the members who open the room while it is fresh (roughly half an hour); members who don’t, stay on the old epoch until the room is re-keyed with both sides present. Delivering those changes in the background is being redesigned together with its metadata cost, which we measured (§7) — so the app’s removal dialog states this limit instead of promising more.
The prekey-server problem applies here too, and gets a different answer than it does for PCS above. Your identity carries its own long-term ML-KEM keypair, derived from the same seed as your Ed25519 and X25519 keys and travelling in the same contact bundle — so the first message encapsulates to a key that has nothing to do with the X25519 secret, and still nothing is deposited on any server. This matters precisely because of the threat being defended against: if the post-quantum starting key were derived from the X25519 shared secret, then breaking X25519 would hand over both halves of the hybrid at once for the first chain, which is the exact window a harvest-now-decrypt-later adversary is recording. An earlier revision of this design did derive it that way; the reasoning that justified it, and the measurement that overturned it, are written up in the changelog entry below.
6. Transport — onion + store-and-forward
The relay is reachable as a Tor v3 onion service, so the server’s location and your IP are hidden, and the connection is authenticated by the onion address itself. The asymmetry matters: the relay is the onion service; you are a Tor client reaching it, never a standing service of your own — which removes the surface that has deanonymised peer-to-peer onion messengers, where each user is a fixed service an attacker can locate by watching its guard nodes (see the Ricochet lesson). Tor is the default; the app can also connect over Tor bridges (for censored networks) or plain clearnet, but leaving Tor is a deliberate, labelled step the app makes you confirm — because off means the server sees your address.
Delivery is store-and-forward: messages wait, encrypted, until the recipient reconnects — across a dropping Tor circuit and without either party being online at the same moment. The server keeps no per-recipient state; the client carries its own cursor. The address of a conversation is not a fixed handle: by default it is re-derived on a short clock (a new, unguessable id roughly every 15 minutes), and each conversation’s rotation is phase-shifted by its own shared key, so the addresses themselves never expire or appear together. What rotation does and does not buy against the relay is a measured question, and the measurement is in §7 — the short version is that it limits what an outside observer gains from one address, and does not stop the relay from grouping a device’s addresses by their timing.
7. Traffic analysis — the metadata that remains, measured
A deaf relay hides content and who-talks-to-whom — but nothing that actually delivers messages can hide that traffic exists at all. We’d rather name what’s left, and what we do about it, than imply it’s nothing. Since v0.3 we have also stopped arguing about this layer and started measuring it: each mechanism below was tested against the strongest adversary we could build from our own public protocol description, with the acceptance criteria written down before the run. Where the numbers overturned a claim this document used to make, the claim is corrected here and listed in the changelog.
The relay can still observe size, timing, and volume — over Tor the source is hidden, the event is not. We narrow each, and we say how far:
- Size. Every frame on the wire is padded to a size bucket with a 384-byte floor, computed from the size on the wire rather than the size of the text, so the same frame comes out at the same size whatever the conversation mode, whether a ratchet is running, and whatever message policy is attached — a class of 2 bits of secret state that earlier code leaked through four distinct sizes for one identical text. Group rooms are padded the same way (they were not, before we measured). Under the message-length model we declared before measuring, the wire size carries 0.043 bit of the ~6.8 bits a message length holds; almost every frame (99.65 %) is the 384-byte class. The post-quantum header rides in every message of an epoch at a constant ~2.3 KB for the same reason: a ratchet step must not be a different size. The honest edges: the length model is a declaration, not a population; text in Polish or with emoji leaks 3–9× more than ASCII, so size still carries something about language; and quantisation into Tor cells passes this leak through unchanged (0.032 bit on both granularities — a coarser unit does not help, only merging classes does).
- Cover traffic. In the optional high-risk mode the app sends on a fixed grid and fills the gaps with decoy frames that pick their size class from the same distribution as real ones. Measured: a content frame and a decoy frame are indistinguishable by size (0.0002 bit; zero frames that are certainly real) and by spacing (0.0018 bit) — and the grid is re-phased from a random source at every address rotation, because we found that a grid carried across rotations matched the new address to the old one 86.5 % of the time (it is 12.0 % now, against a 9.0 % chance level). What cover does not do: it is off by default and costs ~6.3 MiB per day per open conversation; it masks messages inside an open conversation, not the fact that a conversation is open; and in the default configuration the relay retains 98.5 % of the information about when you talk. We used to say the relay “cannot tell when you actually send”. The precise sentence is: with cover on, it cannot tell which frames are yours.
-
Device clocks. The relay’s best handle on “these addresses are one phone” turned out not to be the cover grid but our own background poll: a periodic timer that touched every contact of every identity at once, which grouped a device’s addresses 77× above chance from a few rounds. The poll and the resume-after-background logic were redesigned so that no device-wide clock is both periodic and shared — random intervals, fresh per-target stagger, jittered window gaps, one resume anchor per device — at zero extra bandwidth. Under the phase-histogram adversary that fix brings the grouping down to 1.9–3.1× chance. Then we built a stronger adversary, and it undid the comfort: a relay computing a likelihood ratio over the public schedule groups a device’s addresses 85–90 % after one 15-minute epoch and 99.8 % within 30 minutes, resume timings link the addresses of one device 96–98 % after three resumes, and a larger population does not dilute it. This is the current state, and it is why §6 no longer says the relay cannot batch addresses.
-
Group rooms. Opening a room re-establishes a pairwise channel to every member at once, and that burst is its own clock. From a single opening the relay learns the room’s size (exactly, 84–88 %) and the set of member addresses (83–89 %), and a phone with a room groups its contact addresses 96–98 % — around the schedule fixes above. A membership change with two or more recipients is detectable 100 % (its type 88–92 %). Padding hides that a frame is a control frame; it does not hide that the same instant produced a frame for every member, and cover traffic does not touch control frames at all. The single-channel design under decision closes this only if the relay also cannot see which address a frame is routed to — a requirement now written into that design before it is built.
So the corrected statement of what the relay learns is this. It cannot read a word, does not see an IP, and holds no name, key, or contact list. It can — from timing alone, today — keep a persistent pseudonym for a device across address rotations and see, per epoch, which pseudonymous devices exchange frames and roughly how many conversations each keeps open — and, for a room, how many members it has and when its membership changes. Address rotation still bounds what an outside slip reveals: a malicious contact or a one-off timing correlation on the network ties you to one address for one epoch, not to a fixed handle. Two designs that close the timing channel at the relay are measured and under decision — independent schedules per address, or a single fixed-rate channel per device with addresses multiplexed inside it (which closes all four measured axes with one change). Neither is claimed here until it ships and is measured again.
What remains beyond that is the hard limit of every low-latency anonymity network: a global passive adversary who can watch both ends of a Tor circuit at once can still attempt timing correlation. At the guard relay our own shaping changes nothing measurable — what helps there is Tor’s own netflow padding — and cover traffic raises the bar without removing it.
The honest summary: YGOOW makes who you are and what you said unavailable, and it makes who you talked to a pseudonymous timing inference rather than a record. It does not claim to beat an adversary who already watches the entire network — no usable messenger does — and it no longer claims to beat its own relay’s stopwatch until the mechanism that does so is measured.
8. The device, and being forced to open it
The profile is encrypted at rest under your app-lock; the screen is blocked from screenshots and the OS “recent apps” preview. For a forced unlock, two tools shift the choice to you: a decoy — a second password opens a separate profile while the real one stays hidden in the same store (a VeraCrypt-style hidden volume) — and a panic lock that disarms biometrics so a compelled fingerprint opens nothing. Biometric unlock, when set up from the decoy, can only ever open the decoy.
A hidden volume is easy to make deniable on the day it is created and hard to keep deniable while it is used, so we measured it over a lifetime: scripted months of ordinary use on the production storage code, against someone who copies the phone’s storage once, or twice days apart, and someone who times the unlock. The first measurement (v0.4 era) failed the design: after 30 days a single copy revealed the hidden profile in 69 % of simulated phones whose decoy was never opened, two copies in 98–100 %, and unlocking the real profile took about 1.1 s longer than anything else. The redesign was measured the same way:
- Without the decoy password — one copy or two — a store with a hidden profile is indistinguishable from one without: sizes give nothing away (0 size mismatches in 7 200 snapshots), and the size and change-pattern detectors register 0 %. The strongest detector we built — trained on decoy-free phones to learn what an ordinary write looks like — was down to 1.2–1.7 % of snapshot pairs after one fix, all of a single kind (setting up the decoy at a moment that also moved storage into a larger size class); that kind is now closed by construction, pinned by a test at the size boundaries that fails without the fix, but it has not been re-measured as a population. Across 600 simulated lifetimes the hidden profile lost no data.
- Unlock time no longer tells: on the reference phone the real profile opens within −3 to +28 ms of a phone without a decoy (medians of three runs). Not yet measured on a phone with 2–4 cores.
- With the decoy password handed over, two edges remain and we name them. If someone copies your storage twice, any use of the real profile between the copies is visible (100 % of the periods with real activity). And a decoy that holds a handful of messages in storage sized for far more is itself a clue — the sizes are equal, the contents are not. Use the decoy like a real profile, and assume that a coercer who can come back can see that something changed.
These narrow what coercion reveals. But deniability is a credible denial with no on-disk proof against it — not invulnerability: a determined coercer can keep pressing on suspicion (the feature is public), and cryptography does not survive a wrench. They also do not survive live spyware, and cannot hide that the app is installed — which is exactly the limit we name next.
9. The backup file
A profile can be exported to a file so a lost phone does not mean a lost identity. The file is one sealed block: Argon2id with 256 MiB of memory and 4 passes derives its key from your secret, on top of the memory-hard step already applied to the secret itself — so one guess costs an attacker about 3.6 GiB of memory traffic, which caps a top-end GPU at a few hundred guesses per second. We chose those parameters by measuring, on the reference phone, that memory is cheap for the owner (allocation is ~3 % of the time) and expensive for the attacker (whose cost grows with the square of memory), after an earlier revision of ours had lowered memory and raised passes on a mistaken argument — see the changelog. Restoring a profile takes about six seconds on that phone.
The file is padded to a multiple of 64 KiB, and whether it was sealed with a password or with a file is byte-for-byte indistinguishable. Measured: its size reveals nothing about the number of contacts (up to ~324), conversations, rooms, or the structure of your unlock conditions. It does reveal the order of magnitude of your message history — 1.5 of 2 bits, and no padding scheme within a sane budget hides it — which is why the export offers identity and contacts only, without history, a file that is both smaller and silent. Because a copy can be attacked offline forever, sealing it with a typed password passes a stricter gate than the rest of the app: an estimator that treats a dictionary word as a word rather than as letters. Measured against password classes of known construction, “word + digits + symbol” passwords now get past it without a weak-password warning in 0.3–0.6 % of cases (97 % with the old meter), and 0.21 % of a leaked-password corpus does. Below the threshold the app warns and asks you to confirm; above it, it still reminds you that the number is a ceiling — it never silently accepts a typed password, and never blocks one. Its known blind spots are named in the dialog: keyboard walks and letter-for-digit swaps can still pass, and random 8-character secrets draw a warning in about 71 % of cases — a warning you can override, not a block. The limits we cannot design away: the file is not bound to a device, and neither the YubiKey factor nor the decoy profile applies to it — a copy is opened by the secret alone, with as many attempts as the attacker’s hardware allows.
10. What YGOOW does not protect
- A compromised device. Spyware, or a phone taken while already unlocked, reads your screen regardless of any cryptography. No messenger survives this; the decoy and panic lock above help only before that point.
- The fact that you run the app. YGOOW hides content and metadata, not the existence of the app on your phone.
- Endpoint key hygiene. Weak shared secrets, or keys handed to the wrong person, defeat the math.
We state these plainly — because the projects that don’t are the ones you shouldn’t trust.
11. Verify, don’t trust
The cryptography is implemented against known-answer test vectors and a reference implementation. As the project matures we will publish the protocol specification and seek independent review.
Measured, not assumed
Between August and September 2026 we ran a series of pre-registered measurements against our own implementation on the target hardware — an ARM64 Android phone, release build — rather than on a developer’s desktop. The method is the same each time: criteria and predictions written down before the run, a null control and a positive control in every comparison, three replications, and the ledger of which predictions missed published with the result. The ledger is not flattering (one card records four misses to one hit) and that is the point of keeping it.
- ML-KEM-768. The reference had a secret-dependent division in its number-theoretic transform and compression — the KyberSlash class of leak. On the desktop it was invisible (the compiler strength-reduces a constant divisor); on the phone it produced a 15.6 % timing delta (|t| = 339). Fixed with Barrett reduction, verified on the phone (|t| = 3.9); the implementation now contains no division or modulo on secret data at all, and its transform is 12 % faster.
- Shamir arithmetic (GF(2⁸)). The table-driven multiply leaked (|t| up to 1222 on ARM64). Rewritten branch-free and table-free, proven equivalent exhaustively over all 65 536 input pairs; splitting a secret was shown never to depend on the secret byte, combining did and now does not.
- Ed25519. The pure-Dart signer recomputed the public key on every call and leaked both the nonce and the long-term scalar (|t| = 60 / 71). Signing now runs in a native, constant-time implementation (
ed25519-dalek) through FFI: |t| = 2.6 / 1.6 against null controls of 1.5–3.1, byte-identical signatures, and about 21× faster. This holds on the native path; if the library fails to load, the app falls back to the pure-Dart signer and records that it did — no claim holds there. - X25519. Measured clean on ARM64 (|t| ≈ 2.2 against controls of 1.1–2.4).
- AES-256-GCM — leaked, published, fixed. The pure-Dart library we used showed key-dependent timing in its GHASH path (a “constant-time” mask the Dart compiler turns into a branch on ARM64) and a data-dependent branch in its shift — published in v0.4 before it was fixed. Message encryption now runs in a native implementation (
ring) on the ARMv8 AES and PMULL instructions. Byte-identical output on every vector and on 10 000 random cases (texts up to 256 KiB, with and without associated data); every tampered bit rejected. Re-running the same timing families on the phone, release build, three runs: the old library still showed the leak in the same session (|t| = 54–64 on GHASH block weight) — our positive control — while the native path stayed at the level of its null controls in all 24 readings (max |t| = 3.37, threshold 4.5). It is also ~290× faster (a 256 KiB profile: 266 ms → 0.91 ms). The claim holds on that hardware path with the native backend active — the self-test shows which one runs, on the main thread and in background workers — and not on the pure-Dart fallback. One unexplained timing anomaly in the third-party SHA3/SHAKE library remains open. - The strength meter. We measured the entropy estimate the app shows against password classes of known construction. For a “word + digits + symbol” password it overstates by a median of 45 bits and let 97 % of that class through the backup export gate without comment. The meter is therefore presented as a ceiling (“at most ~N bits”), and the sentence “the strength you see is the strength you have” has been withdrawn from this site. The backup gate — the one place a password faces an offline attacker forever — now uses a stricter estimator, ported bit for bit from the reference that produced these numbers (348 vectors) and re-measured: that class now gets past it without a warning in 0.3–0.6 % (§9).
- The hidden profile, over a lifetime. Snapshot adversaries (one and two copies of storage, with and without the decoy password) and an unlock stopwatch, on the production storage code across months of scripted use — the measurement that overturned our “indistinguishable on disk” and then measured its replacement (§8). The pre-registered prediction for the redesign was refuted as registered: our first version still left rare events (a password change) with their own signature, and closing them took two further changes, both recorded.
- Traffic analysis. The measurements behind §7 — size buckets, cover traffic, device clocks, Tor cells, two likelihood-ratio adversaries and the group-room fan-out — including the ones that overturned our own earlier fixes.
None of this is an audit, and we do not call it one. It is the work an audit would check, done in the open before anyone is asked to check it.
12. Dead Drop — a note for someone without the app
A Dead Drop is a one-time text note shared by a link or QR code: no account, no contact exchange, and the recipient does not need the app — the link also opens in a browser at /d. The design follows from three threats.
- Link previews. Chat apps fetch links to draw a preview, and a note that burns on fetch would burn for the bot. So the note’s id and key live only in the URL fragment (after
#), which browsers never send to a server; neither the app nor the page contacts the relay until the recipient presses Open. - The relay. It stores the sealed note and a salted hash of a verifier, and deletes the note on the first request that proves knowledge of the key (and of the password, if one was set). A wrong proof, an expired note and an already-opened note get the same answer; twenty wrong proofs destroy the note, because a password guessed online over Tor has no per-address limit. It sees when a note is created, its size class and when it is opened — not the words, not whether a password exists.
- The password. Optional, stretched with Argon2id (64 MiB) and bound into the proof, so a link without the password can neither read the note nor burn it.
In a browser two things are weaker than in the app, and the page says so: its code is served by us each time it loads, and over a normal connection our server sees the visitor’s IP (Tor Browser and our onion address remove the second). The browser client uses Web Crypto for AES-GCM and randomness, plain JavaScript for SHA3 and HKDF — so notes without a password still work in Tor Browser at “Safer”, where WebAssembly is off — and a pinned Argon2id build only when a password is set. Both clients are pinned to the same test vectors, and the browser one re-runs them for anyone at the self-test page. “Gone after reading” is the relay’s promise, not a proof — which is why the note is encrypted anyway.
Appendix — cryptographic parameters
The exact primitives, as implemented in the reference and ported verbatim to the client. Every domain-separation salt is constant by design: a shared key must reproduce the same bytes on every device, so a per-device salt is not an option — and the secrecy of these salts is not what provides security (it never is). Known-answer test vectors pin every value below.
Message AEAD (“Variant C”)
blob = nonce(12) ‖ AES-256-GCM(K, nonce, plaintext); 16-byte GCM tag; no key hint anywhere in the block.K = SHA3-256(key_material).- On the phone:
ringthrough FFI (ARMv8 AES + PMULL path), byte-identical to the reference; the pure-Dart implementation remains only as a recorded fallback (§11). - Nonce: 96-bit, random per message. The random-nonce birthday bound is ≈2³² messages per key — comfortable for the short-session profile, and the reason long-lived conversations use the ratchet rather than one static key.
Key material
- Low-entropy input (password, short text, a small or guessable file / link) → Argon2id, salt
ygoow/redact/argon2/v1, time = 3, memory = 64 MiB, parallelism = 1, 32-byte output. Memory-hardness is what blunts precompute against the constant salt. (The app-lock envelope and the backup file use a random 16-byte salt stored in their header — the constant salt applies only to conversation key material, where both sides must reproduce the same bytes.) - High-entropy input (a large random file, a random QR key) → SHA3-256.
- The client estimates entropy to route between the two, and surfaces the result as a strength ceiling — measured to overstate structured passwords, see §11 — while the weakest factor never hides behind the strongest.
- Combined sources → HKDF-SHA3-256 over length-prefixed inputs (4-byte big-endian prefixes remove the
a‖bvsa‖bambiguity), saltygoow/redact/combine/v1.
Identity & key exchange
- 32-byte CSPRNG seed → HKDF-SHA3-256 → Ed25519 (signing / fingerprint) + X25519 (ECDH) + a long-term ML-KEM-768 keypair (post-quantum, see below). The seed never leaves the device.
- Remote exchange: X25519 ECDH → HKDF-SHA3-256 bound to both public keys (sorted, so the result is identical for either side).
- Fingerprint / safety number: SHA3-256 over the sorted pair of whole-bundle digests — each digest covering that identity’s Ed25519 key, X25519 key, post-quantum generation and ML-KEM encapsulation key → 8 groups of 5 digits, symmetric. Comparing the digits therefore detects substitution of any of those keys, not only the signing key.
Forward-secrecy ratchet
- Symmetric KDF chain (HKDF-SHA3-256, salt
ygoow/redact/ratchet/v1), a separate chain per direction. mk_n = HKDF(ck_n, "mk"),ck_{n+1} = HKDF(ck_n, "ck"); used keys are erased (forward secrecy). The 4-byte message number is bound as AEAD associated data; out-of-order delivery is tolerated up to 1000 skipped keys; replays are rejected.- Post-compromise security is scoped, not skipped: the symmetric chain gives forward secrecy but not PCS by itself. The locked-content modes do not need it — their secrecy lives in a key the channel never holds — while the plain-channel contact mode runs the Double Ratchet below.
Double Ratchet (plain-channel contact mode)
header = dh_pub(32) ‖ PN(4) ‖ N(4)— 40 bytes, bound in full as AEAD associated data;blob = header ‖ nonce(12) ‖ AES-256-GCM(mk, nonce, plaintext, aad=header).- Root step:
(RK', CK) = HKDF-SHA3-256(salt=RK, ikm=DH_out, info="dratchet/rk", L=64), withRK₀= the static ECDH secret. Because the root starts at a secret only the two static private keys can compute, a new ratchet key is authenticated transitively through the root — so rotating keys need no signatures, and the channel’s deniability survives intact. - Responder bootstrap:
boot_seed = HKDF-SHA3-256(salt="…/dratchet/v1", ikm=sk, info="bootstrap/responder", L=32)— both devices compute it, nothing is deposited on the relay. Roles are fixed without negotiation (initiator = lexicographically smaller static public key). - Skipped-key cache is hard-bounded:
MAX_SKIP = 1000per chain,MAX_SKIP_KEYS = 2000globally, FIFO eviction — out-of-order delivery over Tor is tolerated without giving anyone a memory-exhaustion lever. - Decryption is transactional: a DH step is taken on a copy of the state and committed only after the AEAD tag verifies, so a forged header cannot desynchronise a live session.
Post-quantum hybrid (X25519 + ML-KEM-768)
- ML-KEM-768, FIPS 203: encapsulation key 1184 B, ciphertext 1088 B, shared secret 32 B. Category 3 — the same parameter the industry picked for hybrids (X-Wing, TLS
X25519MLKEM768). - Combiner:
HKDF-SHA3-256(salt=<context>, ikm = ss_x25519 ‖ ss_mlkem ‖ ct_mlkem, info="…/pqhybrid/v1"). A standard dual-PRF construction: secure if either input holds. The ML-KEM ciphertext is bound into the input as well, which closes re-encapsulation subtleties independently of the primitive’s binding properties. - In the ratchet, the current root key
RKis the HKDF salt — so the post-quantum step inherits the same signature-free, deniability-preserving authentication as the classical one (ML-KEM, like DH, produces no signature). - Hybrid header:
dh_pub(32) ‖ pq_ek(1184) ‖ pq_ct(1088) ‖ PN(4) ‖ N(4)= 2312 B, all of it bound as AEAD associated data, constant for every message in an epoch (§7). - Correctness is pinned against the published C2SP/CCTV accumulated vectors — 10 000 deterministic keygen/encapsulate/decapsulate operations reduced to one digest — recomputed independently by both our implementations, on top of our own cross-implementation vectors.
- Identity ML-KEM keypair:
(d, z) = HKDF-SHA3-256(salt="…/identity/v1", ikm=seed, info="mlkem768-identity/"‖gen), thenML-KEM.KeyGen(d, z). Deterministic from the identity seed, so it needs no separate backup;genallows rotating the post-quantum key without rotating the identity. It travels in the contact bundle (1254 B: Ed25519 ‖ X25519 ‖ gen ‖ encapsulation key), which is why that bundle is exchanged as a split QR, NFC, or a link rather than one dense code. - Where it ends, plainly: our implementations (Python and Dart) exist to be checkable in a test VM on both sides. They no longer perform any secret-dependent division — that was measured, not assumed, and the fix was verified on the target ARM64 hardware, where the earlier code did leak and the same test on a desktop CPU showed nothing. One unexplained timing anomaly in the third-party SHA3/SHAKE library remains open and is not claimed to be resolved. Post-quantum protection here covers the conversation content; the rendezvous address is deliberately out of scope, as it is metadata rather than content.
Quorum decryption (Shamir)
- Secret sharing over GF(2⁸) (AES reduction polynomial
0x11B), byte-wise, K-of-N with 1 ≤ K ≤ N ≤ 255. Any K−1 shares reveal nothing. Shares are generated from the secret and distributed offline. - Field arithmetic is branch-free and table-free (masked shift-and-add multiply, inversion as a fixed chain of squarings,
b^254), after measurement showed the table-driven version leaked on ARM64 (§11).
Signatures (group rooms)
- Ed25519 via a native constant-time backend (
ed25519-dalek, FFI), byte-identical to the reference vectors; the pure-Dart signer remains only as a recorded fallback.
Wire framing
- Size buckets
[384, 1024, 4096, 16384, 64000]bytes, applied to the size on the wire (a per-configuration overhead compensation with a ceiling of 60 bytes), for direct conversations and rooms alike. Cover frames draw their class from the published distribution384: 99.65 %, 1024: 0.34 %, 4096: 0.004 %— that distribution must be recomputed whenever the buckets change, or the decoys become distinguishable again. - A text longer than one frame is split into up to 32 visibly numbered parts (“part 1/3”), each padded to the largest class (64 000 bytes), so every part of a long text has the same size on the wire. The relay can still see that several top-class frames left in a row. Frames over the relay limit close the connection with an explicit code (4413) rather than being dropped silently.
Backup file
key = Argon2id(secret_material, salt(16, random, in header), time = 4, memory = 256 MiB, parallelism = 1); AES-256-GCM over the profile; padded to a multiple of 64 KiB; the password-vs-file variant is byte-indistinguishable (type-hiding); optional export without message history.
Dead Drop
- Link:
https://ygoow.com/d#<id>.<base64url(K)>[.p],K= 32 random bytes;.pmarks a password. salt = SHA3-256("ygoow/deaddrop/v1/salt" ‖ K)[:16];pw = Argon2id(password, salt, t = 3, m = 64 MiB, p = 1)or empty.root = SHA3-256("ygoow/deaddrop/v1/root" ‖ K ‖ pw);enc,auth= HKDF-SHA3-256(root, salt =ygoow/deaddrop/v1, info =enc/auth); the relay receivesSHA3-256(auth)at creation andauthat pickup.payload = nonce(12) ‖ AES-256-GCM(enc, 0x01 ‖ len(4) ‖ text ‖ zero padding), padded to 1 024 / 4 096 / 16 384 / 64 000 bytes; lifetime 1–72 h; 20 failed pickups destroy the note.
Every value above is exercised by known-answer test vectors on both implementations. The app’s on-device self-test re-runs the core of them — key derivation and the message cipher, the handshake and safety number, the forward-secrecy ratchet and the no-oracle property — so you can confirm the math on your own phone, not just take our word for it. (The ML-KEM and Double Ratchet vectors run in the test suites, not yet on the device; we say so rather than round it up.)
Changelog
A design document that quietly rewrites itself is worth less than one that says what it got wrong.
v0.5 — 2026-09-17 — the decoy measured over a lifetime, a leak fixed, and one new disclosure.
- “Indistinguishable on disk” was true on the day you set it up. §8 used to say the decoy left no on-disk evidence of the hidden profile. Measured over months of simulated use, a single copy of storage revealed it in 69 % of phones after 30 days (87–88 % after 90) when the decoy was never opened, and two copies in 98–100 %. The same measurement found that heavy use of the decoy could overwrite the hidden profile — real data loss. Redesigned and measured again: no data loss in 600 simulated lifetimes, equal sizes, and detection from one or two copies without the decoy password down to a single residue that is now closed by construction. What remains with the decoy password is stated in §8 rather than implied away.
- Unlock time gave the hidden profile away. Opening the real profile took ~1.1 s longer than opening the decoy or a phone without one — fully separable in three runs. It is now within tens of milliseconds on the reference phone.
- The AES-GCM timing leak published in v0.4 is fixed with a native implementation, verified on the phone against the old library as a positive control in the same session (§11). The sentence “we make no constant-time claim for message encryption” is replaced by a claim scoped to the hardware path it was measured on.
- The backup gate no longer waves “Password1!” through — a stricter estimator for typed passwords, which flags that class as weak in all but 0.3–0.6 % of cases (§9). The in-app meter stays a ceiling.
- Long messages are split, not refused — numbered parts, each padded to the top size class (appendix).
- New disclosure: group rooms leak their size and membership changes to the relay when opened or changed (§7), and a membership change reaches only members who open the room while it is fresh (§5). Neither was claimed otherwise here; both are now written down.
- Dead Drop (§12): one-time notes by link, in the app and in a browser.
v0.4 — 2026-09-10 — five corrections from measurement.
- The relay can group a device’s addresses. §6 used to say that per-conversation phase-shifted rotation meant the relay “cannot batch addresses into one device”, and §7 that a correlation “confirms at most one 15-minute window”. We measured our own background schedule against a relay computing likelihood ratios over the public protocol: 85–90 % correct grouping after one epoch, 99.8 % within 30 minutes, resume timings 96–98 % after three resumes, and a thousand devices do not dilute it. An earlier fix (randomised, staggered polling) had looked sufficient under a weaker adversary — 1.9–3.1× chance — and that number turned out to be a property of the measure, not of the schedule. The claim is withdrawn; §7 now states what the relay learns and what is under decision to close it. The per-persona claim (“personas don’t reveal that they share a device”) falls with it, since the poll round covered every identity at once.
- Cover traffic hides which frames are yours, not that you are talking. “The relay cannot tell when you actually send” is replaced by the measured statement: 0.0018 bit between real and decoy frames inside an open conversation, grid re-phased at every rotation, and 98.5 % of timing information retained by the relay when cover is off — which it is by default.
- The location condition is gone. A message or app-lock could be sealed to open only near a place. Evaluating it asked Android for a “network” position, which on a phone with Google services sends the surrounding Wi-Fi and cell identifiers to Google through the system network stack — outside Tor, attributed, at the moment of reading. The feature was removed entirely (code, permissions, envelope v4); its opcode is permanently retired. Written up on the blog.
- The backup KDF was lowered on a wrong argument, then restored. In late August we moved the file’s Argon2id from 256 MiB / 4 passes to 128 MiB / 6 passes, reasoning that iterations would recover the cost. They do not: the attacker’s cost scales with memory squared times passes, so the change had cut it by 62 %. The premise that 256 MiB might not fit was also false by a factor of four (1 GiB ran without failure on the reference phone). Restored to 256 MiB / 4 on measurement; the false sentence was removed from our internal documents as well.
- The padding claim and the strength meter were overstated. Our documents said the backup file’s 64 KiB padding “hides the scale of the profile”; it hides contacts, rooms and condition structure, not history (1.5 of 2 bits leak), so an export without history was added. The entropy meter was described here as “honest”; measured, it overstates structured passwords by a median 45 bits. It is now a ceiling and labelled as one (§11).
v0.3 — 2026-08-23 — the post-quantum starting key no longer comes from the X25519 secret.
Until this revision, the responder’s starting ML-KEM keypair was derived deterministically from the X25519 ECDH secret two contacts already share. The argument for it was that this secret never crosses the wire, so a passive recorder cannot reconstruct it — and that argument was wrong, not in its premise but in its conclusion. That secret is a function of both parties’ long-term X25519 public keys, and two supported flows put those keys on channels an adversary can record: adding a contact remotely by passing their code through some other app, and an invite that carries the sender’s identity. An adversary who records that today and breaks X25519 later recovers the shared secret, and with it the post-quantum starting key — so for the first chain the hybrid did not hold “if either primitive holds”; both fell together. That first chain is also not necessarily short: a ratchet step happens on receipt, so a conversation nobody replies to stays in it entirely.
The property did hold for contacts added face to face, where those public keys never reach a recordable channel. It does not now depend on that distinction: each identity carries its own long-term ML-KEM key, derived from the identity seed and independent of X25519.
Two consequences worth stating plainly. The safety number now covers the whole bundle rather than only the Ed25519 key — previously, comparing digits with a contact would not have detected a substituted key-exchange key, which is the thing a remote-add attacker would substitute. And a contact whose bundle predates this revision falls back to the old bootstrap; the app says so on that channel instead of quietly claiming a protection it is not providing.
Living document, v0.5 — the full protocol specification will follow as YGOOW matures.