Hormigas · Messenger · an encryption whitepaper

A closed-chat encryption whitepaper

The message only two devices can read

Signal-grade end-to-end encryption inside a web PWA — laid on top of a messenger whose delivery guarantee is history and re-sync. Reconciling a ratchet that spends each key once with a transport that delivers the same message twice.

Hormigas Messenger· X3DH + Double Ratchet· opt-in 1:1· companion to “The message you can afford to drop”· install the app

Abstract

This is a companion paper to the messenger's delivery design. That system's guarantee is deliberately not "every message arrives live": it is durable acceptance plus recovery — a message is written to history, live push is best-effort and abandonable, and a client that misses a push re-reads history on reconnect. That model is the ground this encryption stands on, and it is also the source of the one genuinely hard problem. We add end-to-end encryption the way the web platform practically allows: the Signal protocol — X3DH to start a session offline, the Double Ratchet to give every message its own key with forward secrecy. But the Double Ratchet consumes a message key on first decrypt and deletes it, while the transport underneath delivers the same ciphertext by more than one path — live socket, history pull, a forced reconnect-resync. A message decrypted once can arrive again undecryptable; a message never delivered leaves a permanent hole. We resolve it in three moves: decrypt-once and re-wrap at rest under a device key, so reloaded history reads without re-running the ratchet; ratchet-decrypt from history for messages that were only ever delivered offline; and a client-to-client recovery protocol that repairs a genuine gap by asking the original sender to re-encrypt from their own copy — recovering the plaintext, never a key, so forward secrecy holds. MITM is closed the only way it can be: safety numbers, compared out of band. At the end we name what a PWA still cannot promise.

00 — the brief

End-to-end, in a browser tab

A secret chat here is opt-in and one-to-one. Turning it on must hold several promises at once, and two of them are in tension:

And the platform is a web app, not a native one — no secure enclave, no OS keystore. That ceiling is real and we return to it at the end. Everything between here and there is how far a PWA can actually go.

The delivery layer promises durable acceptance and recovery, not exactly-once. Encryption has to be honest about the same thing.

01 — the keys

Six kinds of key, one that never leaves

Every key is generated on the device. Only the public half of the long-lived ones is ever published — to a standalone key-directory service that stores public keys and does no cryptography. Everything private is wrapped at rest under a single non-extractable device key.

The key inventory — what each is for, how long it lives, where it is stored.
KeyForLifetimeStored
Identity keyThe device's stable cryptographic identity; basis of the safety number; anchors every handshake.permanent
(per device)
IndexedDB — private wrapped; public → directory
Signed prekeyLets a peer start a session offline. Signed by the identity key.rotatedIndexedDB wrapped; public+sig → directory
One-time prekeysConsumed one per new session for extra forward secrecy on message #1.single useIndexedDB wrapped; publics → directory pool
Root / chain keysThe evolving Double Ratchet state per (conversation × peer device). Gives forward secrecy.evolves
per message
IndexedDB — session record, wrapped
Message keysEncrypt exactly one message, then deleted — so a message can't be re-decrypted later.one messagetransient (skipped-keys briefly held)
Device keyThe one that never leaves. A non-extractable AES-GCM key that wraps every private key and the stored plaintext of decrypted messages.permanentIndexedDB — non-extractable CryptoKey

The server sees only the middle-column publics. The device key is the hinge of the whole at-rest story below — and, as §10 shows, the thing whose loss you feel most.

02 — the handshake

X3DH — starting a session with an offline peer

To message someone the first time, you don't need them online. You fetch their prekey bundle from the directory — identity key, signed prekey (+signature), and one one-time prekey it hands out and deletes — and run X3DH: a set of Diffie-Hellman agreements that mixes those keys with your own into a shared secret, seeding the first ratchet state. The first message you send carries the signal the peer needs to build their half. No round-trip, no both-online requirement.

the one precondition

Both parties must have published keys once — i.e. opened the app since encryption shipped. A peer with no keys can't be X3DH'd, and the UI says exactly that ("this contact hasn't enabled secret chat yet") rather than failing vaguely. Fetching a bundle consumes a one-time prekey, so we only fetch for a device we don't already have a session with.

03 — the ratchet

The Double Ratchet, and the key it spends

Once a session exists, every message is encrypted with a fresh key derived one step down a chain. Two ratchets turn: a symmetric one that advances per message, and a Diffie-Hellman one that turns whenever a reply arrives, re-seeding the chain from new key material. The property that matters here is blunt:

A message key is derived, used once, and deleted. There is no way to derive it again.

That is forward secrecy: steal the device tomorrow and yesterday's keys are already gone, so yesterday's ciphertext stays shut. Out-of-order delivery is handled by skipped-message-keys — receive #5 before #3 and the library derives and stores the keys for #3 and #4 until they arrive, bounded by a MAX_SKIP cap so a huge jump can't force unbounded work. All of this is standard Signal, and it is exactly what collides with the transport.

04 — the collision

A ratchet that spends once, a transport that delivers twice

The delivery paper is explicit that the same message can legitimately reach a client more than once — a live push and then a reconnect-sync, or a re-claimed lease — and that a message can also be abandoned from live delivery and recovered only later by a history pull. For a plaintext chat that is harmless: you dedupe by id and move on. For a ratchet it is a landmine:

None of this is a bug in the ratchet or in the transport. It is what happens when you put forward secrecy on top of a delivery model built to drop and recover. Three moves address it — and the third has a sharp constraint the naive version gets wrong.

05 — move one

Decrypt once, then keep the plaintext — sealed

Because a ratchet key can't be re-derived, "decrypt on every render" is impossible. So we decrypt exactly once — the moment a message is received (or the moment we send it) — and immediately re-encrypt that plaintext under the device key and store it, keyed by message id. Reloaded history renders from this store with a cheap symmetric decrypt that has no ordering and no forward-secrecy constraint. The ratchet only ever sees a live, first-time message; it never re-sees history.

ciphertext in
opaque envelope on the wire
→
ratchet · once
message key derived, used, deleted
→
re-wrap
encrypt plaintext under the device key
→
store
IndexedDB, keyed by message id + chat
→
render
symmetric decrypt — any time, any order
Fig 1 — the ratchet runs once; everything after reads the device-key-sealed copy.

There is a second move hidden in the same place. When history loads a secret message we've never decrypted — the offline case, delivered only by the pull — we run the ratchet then. History is fetched in the transport's durable order, but which key opens a message is governed only by the ratchet header and session state, never by that ordering — the two order-spaces are separate, and conflating them is a bug. Our own sent messages can't be ratchet-read (their envelope is encrypted to the peer, not to us), which is exactly why we stash our own plaintext at send time.

the atomicity invariant

"Decrypt once" is not a guarantee the algorithm gives — a crash between advancing the ratchet and persisting the plaintext could spend a key with nothing to show for it. So it is an invariant the implementation must enforce, not a property assumed: a message counts as consumed only when its sealed plaintext, its dedup record, and the resulting ratchet state are durably committed together — one IndexedDB transaction, or none. The honest phrasing is not "decrypted exactly once" but an effectively-once durable result: each ciphertext is ratchet-decrypted at most once per durable message identity, and dedup is itself persisted so a replay after reload can never reach the ratchet.

06 — move three

Recovery must be orthogonal to the broken ratchet

The hard residue is a message genuinely lost to the ratchet: a contiguous gap wide enough to stall the receive chain. Forward secrecy forbids recovering the key, so we recover the plaintext from the one party who still has it — the original sender, who stashed what they sent (§5). But here is the constraint the naive design gets wrong, and a reviewer was right to flag it:

Re-encrypting the recovered plaintext as another message in the same ratchet does not repair a stall — it lands past the same gap the receiver cannot cross.

If the receive chain is stalled at a gap beyond MAX_SKIP, a recovery message sent as the next message in that chain is only reachable after the same un-crossable gap. So recovery must not ride the broken chain. It must be orthogonal: the recovered payload is delivered over a fresh session, independent of the stalled one — the sender fetches the receiver's current prekey bundle and runs a new handshake dedicated to the repair, so its ciphertext derives from new key material, not the poisoned receive chain.

# B finds history secret messages it cannot decrypt (stalled chain) →
B → A   recover-req { conversationId, messageIds:[…], recoverySessionId }  # authenticated; ids are ULIDs
A       look up OWN stored plaintext by messageId (≤ retention); authorize each
A       FRESH X3DH/PQXDH → new session to B  (NOT the stalled chain)
A → B   recover-resp { items:[{ messageId, env }] }   # env AEAD-bound to the fields below
B       verify AD, decrypt, store under the ORIGINAL messageId, splice in place

The recovered ciphertext is cryptographically bound, in its AEAD associated data, to exactly what it claims to be — so a tampered response can't relabel one message as another:

AD = protocolVersion ‖ conversationId ‖ senderIdentityKey ‖ receiverIdentityKey ‖ originalMessageId ‖ recoverySessionId

Two more properties the request must carry, because the ids are authenticated but not secret. The request is honored only over an authenticated session, only for messages that belong to this conversation and were authored by the requester's peer, and under a rate + batch limit — otherwise a flood of ids is a decrypt-and-encrypt oracle, a cheap DoS. The correlation id is a ULID minted by the sender: globally unique, immutable, and itself authenticated inside the message's AD, so both sides name the same message unambiguously.

Because we recover plaintext and not a key, protocol forward secrecy is untouched — a later device compromise still can't reach a deleted message key. Recovery works only while the sender still holds the plaintext (a retention window, §8); past that, "lost" is honest and permanent. And note the sharper case: after an eviction the receiver's identity itself is gone, so recovery is not just message recovery but session re-establishment — another reason the repair path is its own protocol, not a message on an old chain. 1:1 only; groups and attachments are future work.

One property worth stating because it is what makes the wait bounded: the response is a positive or negative acknowledgement, never silence. For each requested id the sender either returns the re-encrypted message or names it in a missing list — the ids it no longer holds (past the 48h window) or never had. So a reachable sender always produces a definitive outcome — recovered, or lost — and the requester marks a missing id lost at once rather than waiting out its retry budget. Silence means only one thing, an unreachable sender, and there the bounded retry-then-lost schedule takes over. The failure states below are reached promptly, not by timeout.

implementation note — shipped

An earlier draft re-encrypted over the existing session — repairing the common small-gap case but failing the true >MAX_SKIP stall it was meant for, because the repair landed past the same un-crossable gap. A reviewer flagged it; it is now fixed. Recovery runs on a dedicated address, torn down and re-handshook per batch so its first frame is a prekey message and the receiver builds a clean chain, and each recovered item is AEAD-bound to (messageId, chatId) and verified on decrypt. The receiver drops the recovery session after each batch, so that address never accumulates state. Named here rather than hidden.

07 — order & identity

Sorting, dedup, and the number that isn't the order

The encryption layer sits under the messenger's ordinary sort and dedup, and reuses them. Messages sort by their durable position and dedupe by id exactly as plaintext messages do — the delivery paper's rule that identity is a ULID, order is a sequence holds unchanged. Two encryption-specific rules ride on top: dedupe before the ratchet (and durably, §5 — a memory-only seen-set would let a replay through after reload); and a stall beyond MAX_SKIP shows a placeholder rather than silently reordering. The ratchet's own header carries its per-chain counters — we invent no sequence numbers of our own.

The skip budget is also a hardening knob, two-sided. A malicious sender can try to force a receiver to retain a mountain of skipped-message-keys, and retained skipped keys are themselves loot if the device is later compromised — so the budget is bounded on two axes. The library caps a single forward jump at MAX_SKIP_PER_CHAIN = 2000 (a wider jump throws rather than deriving keys), and it self-evicts across chains and sessions — at most ten old ratchet chains and forty archived sessions per record, oldest first. So retained-key memory is bounded without forking the crypto library; a gap past the per-chain cap is un-healable on the live chain by construction and routes to recovery. Bounded work, bounded exposure.

08 — lifetime

Disappearing, and what a deleted chat takes with it

The stored plaintext — the only readable copy — has a clock. It is auto-deleted 48 hours after it was saved; after that the message reads "lost" (unrecoverable by design, the ratchet key long gone). Deleting a chat wipes more than the server row: it purges that chat's local caches — attachment files, cached history, and the sealed secret plaintext — so "delete" removes the readable copy, not just the reference.

sealed plaintext
the readable copy
kept 48h → auto-wiped → "lost"
recovery window
sender can re-encrypt
≤ 48h
Fig 2 — the readable copy and the recovery window share one 48-hour clock.
09 — trust

Safety numbers — closing the one hole encryption can't

The key directory is trusted by construction. Encryption defeats a passive eavesdropper, but not an active one who can substitute a key: a compromised directory could hand you an attacker's identity key instead of your peer's, sit in the middle, and read everything. No amount of ratcheting closes that — the fix is human.

A safety number is a fingerprint of both parties' public identity keys. It does not, on its own, prove no key was swapped — nothing computed locally can. What it does is let two people detect a mismatch when they compare fingerprints over an authentic external channel. A verified number therefore binds the session to the identity keys observed during that out-of-band comparison — and nothing more. Compare it inside the same possibly-compromised chat and an attacker forges the comparison too; the out-of-band step is the whole guarantee. If an identity key later changes, the number changes and verification drops until you re-check.

One honesty about scope: the identity a number binds is a device (browser-instance) identity, not a user identity. "Alice on this laptop's Chrome" is a distinct key from "Alice on her phone." v1 verifies against a peer's first device; true multi-device — a person's several keys, asynchronously managed — is a Sesame-shaped problem left for later.

when the number changes

Only the identity key moves the number — not prekey rotation, not one-time prekeys. And it moves only when a device loses its stored identity: a new device or browser, a reinstall, cleared site data, or an evicted IndexedDB. Ordinary logout/login keeps it. There is no automatic identity rotation. So a changed number means one of those things happened — or someone is in the middle.

10 — the price of a lost key

Eviction: normal history returns, secret history doesn't

The most important thing to understand about this design is what happens when the browser reclaims storage. Eviction takes the whole origin's IndexedDB at once — the device key, the Signal identity and sessions, the sealed plaintext, the media and history caches. What you lose then splits cleanly in two, and the split is the difference between a normal chat and a secret one.

normal messages — recovered
The truth lives on the server (message_history). On reconnect the client re-pulls it and re-downloads files from storage. The local cache was only a cache; it repopulates. Nothing is lost.
server is the source of truth
secret messages — gone
The server holds only ciphertext it cannot read. The readable plaintext lived only on the device; the ratchet keys are consumed. Client-to-client recovery can salvage what a peer sent within 48h — your own sent, and anything older, is gone for good.
no one else can restore it
Fig 3 — the same eviction; two outcomes. Secrecy is exactly the reason the server can't help.

That is not a defect to be fixed — it is the definition of end-to-end encryption. A server that could restore your secret history could read it. What we can do is make eviction less likely: we request persistent storage (navigator.storage.persist()) so the browser won't reclaim under pressure. It is a mitigation, not a guarantee — a user who clears site data or reinstalls still starts fresh, with a new identity and a new safety number.

11 — what holds, and what doesn't

The invariants — and the residuals we name honestly

What the design guarantees, each resting on a concrete mechanism:

✓ by construction
E1

The server never holds plaintext, a private key, or session state.

It relays opaque envelopes and stores public keys only. All crypto is client-side.

✓ protocol
E2

Protocol forward secrecy: compromising the current ratchet state does not recover already-deleted message keys.

Keys are derived once and deleted; recovery restores plaintext, never a key. (Distinct from E3 — see the note.)

✓ web-crypto
E3

At-rest storage holds no plaintext outside the WebCrypto boundary.

Private keys + stored plaintext are AES-GCM-wrapped under a non-extractable device key. NOT a defence against runtime origin compromise — see R1, and the FS/at-rest split below.

✓ three layers
E4

Three separate durabilities, not one promise.

Transport: accepted ciphertext returns from server history. Crypto: the receiver can decrypt it. Plaintext: a local sealed copy or an eligible peer-recovery source exists. A message can hold the first two and still lose the third.

✓ human-checked
E5

MITM is detectable.

Safety numbers over both identity keys, compared out of band; a key change drops verification.

Fig 4 — the guarantees. Below: the ceilings none of them lift.

two secrecies, kept apart

Protocol forward secrecy (E2) and local plaintext confidentiality (E3) are different properties and must not be conflated. The ratchet guarantees that a compromise of current session state can't reopen old messages — the keys are gone. But the sealed plaintext of the last 48 hours is on the device: an attacker with the device key + IndexedDB reads it. That is not a break of Double Ratchet FS — it is the price of storing readable history at all, and it is bounded by the retention clock.

Failure modes — the operational truth

What each condition is expected to produce, and what the reader sees while it resolves. The value of the design is in these rows, not the prose. The visible state is one of a small closed set — decrypting (transient), ⏳ re-requested (recovery in flight), 🔒 unavailable (no readable copy, not recoverable), 🔒 lost (recovery exhausted or past the 48h window) — never a raw error.
ConditionExpected resultWhat you see
duplicate ciphertextdedup (durable) — never reaches the ratchetthe message (no flicker)
out-of-order within MAX_SKIPskipped-key processing; decryptsthe message
contiguous gap > MAX_SKIPGAP → recovery⏳ re-requested → the message
crash before atomic commitciphertext re-processed (key re-derivable only if not yet spent)decrypting → the message
crash after atomic commitdedupthe message
server history replaydedupthe message
IndexedDB evictionnew device identity + session re-establishmentolder secret history 🔒 lost; new messages resume
plaintext within recovery windowpeer recovery (orthogonal session)⏳ re-requested → the message
plaintext past retentionLOST (honest, permanent)🔒 lost
own message we can't ratchet, no stored copynot recoverable from the ratchet🔒 unavailable
identity key changedverification invalidated → re-verifysafety number clears; re-verify prompt
malformed ciphertext / bad ratchet headerreject🔒 unavailable
recovery request floodrate + batch limit—
recovery response, wrong AD / unknown idrejectstays ⏳ / 🔒 lost

And the residuals — the honest ones, because a security design that hides them is worse than one that names them:

R1 · no secure enclave

Keys live in IndexedDB, reachable by any code that runs in the origin. Wrapping raises the at-rest bar, but a runtime-compromised origin can reach unwrapped bytes in memory. This is the fundamental ceiling of PWA E2EE — a native keystore would raise it, a web app cannot.

R2 · code-delivery integrity

Encryption is only as trustworthy as the JavaScript that runs it. An enforcing CSP and pinned dependencies harden delivery; fully closing it needs an external code-verifier (the "Code Verify" idea), which is out of scope here.

R3 · metadata is not hidden

Contents are sealed; the envelope is not. The server still sees who talks to whom and when. E2EE protects the message, not the social graph.

R4 · loss is real

Eviction, reinstall, or cleared storage loses secret history (§10) and rotates the identity — no cloud backup by design. Persistent-storage requests reduce, not remove, the risk. Multi-device shares an identity per device, and v1 verifies against the first device only.

R5 · handshake is pre-quantum

The initial agreement is X3DH, over X25519 — pre-quantum. Signal has since moved to PQXDH, which runs a post-quantum KEM (ML-KEM) alongside the elliptic-curve exchange and mixes both into the root key, so a session stays shut unless an attacker breaks both lattices and curves. Its whole purpose is the "harvest now, decrypt later" threat: record ciphertext today, store it, decrypt it years from now when a quantum computer exists.

Why it is a named future item here and not a shipped one, stated plainly for our architecture: the harvest-later threat has little to feed on in this design. The one readable copy of a message lives 48 hours (§8) and the per-message ratchet key is destroyed the instant it is used (forward secrecy, §2) — so a wiretapped envelope is ciphertext under a key that no longer exists. Breaking X25519 in fifteen years would recover a long-dead handshake secret for a conversation already far outside any retention window. PQXDH matters most where sessions and archives are long-lived; ours are deliberately short. Two practical gates also stand in the way: the library we depend on implements X3DH, not a KEM — a post-quantum handshake means forking in ML-KEM or changing libraries, and post-quantum crypto is precisely the code you must not hand-roll — and the key directory would need to publish and rotate Kyber prekeys, a new server contract. So: a deliberate, bounded residual — not a silent gap. We adopt PQXDH when the library and directory support it; until then the 48-hour window and forward secrecy carry the load. Treat X3DH here as the v1 baseline, not the ceiling.

Scope, stated plainly: opt-in, 1:1, text. Groups, encrypted attachments, a user-set retention, a PQXDH handshake, and a logout key-wipe policy are named future work, not silent gaps. And the claim is deliberately narrow and checkable — not "the strongest encryption a browser can offer," which is close to unprovable, but this: a practical browser-based E2EE design on the Signal Double Ratchet, built to account explicitly for at-least-once delivery, durable history, browser-storage loss, and the security limits of the web platform — losing nothing a peer still holds, and admitting exactly what it cannot keep.