cat crypto/README # the long version
The short version is on the front page. This is the same thing without the shortening — including the parts that are unresolved.
cat crypto/primitives
| role | primitive | detail |
|---|---|---|
| key agreement | HQC-256 | code-based KEM, NIST level 5. public key 7237 B · ciphertext 14421 B · shared secret 32 B. IND-CCA2, constant-time decapsulation with masked implicit rejection — a malformed ciphertext yields a pseudo-random secret rather than an error, so there is no decryption oracle to probe. |
| message encryption | AES-256-GCM | 12-byte random nonce, 16-byte tag, authenticated. |
| key derivation | HKDF-SHA-256 | 32-byte outputs, domain-separated by info:
hqchat/v2/init/root, hqchat/v2/init/chain,
hqchat/v2/root, hqchat/v2/chain, hqchat/v2/msg,
hqchat/v2/ck for the ratchet; auth for the server
challenge and hqchat/handshake/v2 for the peer one, so the two proofs
can never be confused. |
| hashing | SHA-256 | topic derivation, safety numbers, and the hashes that stand in for tokens and email addresses in the database. |
| signatures | none | authentication is a KEM challenge–response, not a signature scheme. |
The implementation is the official reference implementation, pinned to a specific upstream commit and compiled into the app. It is deliberately held one commit behind upstream: the newer commit changes the bytes it produces, which would break interoperability between clients mid-conversation. A CI job watches upstream so that drift is a decision rather than a surprise.
Key generation is deterministic from a 32-byte identity seed, which keeps the public key byte-stable across protocol migrations — that is what lets safety numbers and the friend graph survive a change to everything around them.
the compiled HQC binaries are committed to the repository and are not reproducibly built, so the link from source to artifact is trust rather than verification. it is written down in the threat model, and it is worth knowing. the wrapper around them is readable in the app source.
This is not a hybrid. The message layer is pure HQC — there is no X25519, no ML-KEM and no elliptic-curve exchange folded into the shared secret. Most post-quantum deployments ship a hybrid precisely so that a break in the new, less-studied algorithm still leaves a classical one standing. hqchat does not have that fallback.
TLS sits underneath, but it is a separate layer rather than a composed one, and it is terminated at the edge rather than end-to-end — so it protects the hop, not the message. If HQC falls, the message layer falls. That is the honest shape of the trade, and you should weigh it before trusting this with anything that matters.
cat crypto/auth.txt # proving who you are, without a password
There are two handshakes in the system and they are easy to confuse. This is the first: a client proving to the server that it holds the private key for a public key. It is a KEM challenge–response.
app auth service
│ POST /auth/init { pk } │
│ ─────────────────────────────────────────────────────────────▶│
│ (ct, ss) = HQC.encap(pk) │
│ stores HKDF(ss,"auth") │
│ { ct } 60s ttl, single use │
│ ◀─────────────────────────────────────────────────────────────│
│ │
│ ss = HQC.decap(sk, ct) │
│ POST /auth/verify { HKDF(ss,"auth"), signing key } │
│ ─────────────────────────────────────────────────────────────▶│
│ constant-time compare, │
│ consume the challenge, │
│ { session token, key id } token stored as sha256 │
│ ◀─────────────────────────────────────────────────────────────│
a password would be a shared secret the server has to hold. this is a proof of
possession instead: the client never returns ss or any decrypted bytes, only a
derived value, so there is nothing here that works as a decryption oracle.Every MQTT connection afterwards is authorised by a fresh, single-use proof signed with an Ed25519 key that exists only in the app's memory; the server keeps its public half for twelve hours. A captured connection packet cannot be replayed.
cat crypto/handshake.txt # two devices agreeing on a key
The second handshake, and the one that matters for confidentiality. The server is not a participant in it.
alice# bob can be offline until the challengebob │ │ │ 1. fetch bob's identity key, and CHECK sha256(pk) == the id you have │ │# a substituted key produces no session, not a wrong one│ │ │ │ 2. claim a prekey bundle {medium_pk, onetime_pk?} │ │ │ │ 3. encapsulate to ALL of them │ │ (ct_id, ss_id) = Encap(identity_pk)# only bob can decapsulate│ │ (ct_mt, ss_mt) = Encap(medium_pk)# forward secrecy, 7-day window│ │ (ct_ot, ss_ot) = Encap(onetime_pk)# forward secrecy, single use│ │ │ │ root₀ = HKDF(ss_id ‖ ss_mt ‖ ss_ot?, "hqchat/v2/init/root") │ │ │ │ ── init {ct_id, ct_mt, ct_ot?, rk, senderPk} + a sealed message ────────▶ │ │tou/bob/inbox— queued by the broker until he connects│ │ CHECK sha256(senderPk) │ │ decapsulate all three │ │ derive the same root₀ │ │ and then HOLD it │ │ │ │ ◀────────── challenge {nonce}onh/{hash}— 60s to answer─────────────── │ │# ct_c = Encap(alice's identity_pk): only alice can decapsulate│ │ │ │ proof = HKDF(ss_c, "hqchat/handshake/v2" ‖ nonce ‖ bob_id ‖ alice_id ‖ T) │ │# T = SHA-256 of the init: binds the proof to that one init│ │ │ │ ────────────────────────── proof ───────────────────────────────────────▶ │ │ constant-time compare, │ │ now accept the init │ │ │ ▼ ▼ three secrets, not one. the server can withhold a one-time key and force the medium-term fallback — that shortens a forward-secrecy window. it cannot substitute the identity key, because your id is a hash of it. HQC is a KEM, not a signature scheme, so prekeys cannot be signed. following PQXDH they do not need to be: a substituted prekey yields nothing without ss_id, which is why there is no post-quantum signature scheme in the build.
The three encapsulations above are all to the responder's keys, so what they
demonstrate is that the responder holds its identity secret. Nothing in the frame ever
demonstrated the reverse — an init is derived entirely from published values:
a prekey bundle anyone may claim, and a senderPk anyone can fetch. Reproduced
against this code, that let any accepted friend of A open a session with A while claiming
to be a third party B, using only B's public key — and A's real conversation with B was
dead from then on, because B's genuine init arrived afterwards and was refused
as a replay.
A KEM cannot fix that in one flight: encapsulation demonstrates the recipient's
secret, never the sender's. X3DH gets it from a Diffie–Hellman that needs the initiator's
key, and PQXDH keeps those DHs for exactly this reason. This project is HQC research, so it
pays a round trip rather than adding a second primitive. The responder holds the
init, encapsulates a challenge to the identity key the frame names — safe
because the decoder has already refused any init whose senderPk
does not hash to its sender, so it is that peer's real key — and waits
up to 60 seconds for:
info = "hqchat/handshake/v2" ‖ nonce(32) ‖ challenger(32) ‖ prover(32) ‖ transcript(32) proof = HKDF-SHA256(ikm = ss, salt = "salt", info, 32)
Every component is fixed-width, so there is nothing to delimit and no encoding for a
Swift client and a TypeScript bot to disagree about. The nonce is freshness; binding both
ids in a fixed order means a proof made for one pair cannot be presented to another, or
replayed back at its own author. The transcript is a SHA-256 over the init
itself — both ids and every ciphertext in it — which each side computes on its own and
never sends. It binds the proof to one init: in v1 it was missing, so a forged init
naming the initiator was released by the proof the initiator made for its real one. The prover answers only for a handshake it started, so
this is not a standing decryption oracle for anyone who can reach the topic.
what it does not stop is a relay — someone who can both see the challenge
and reach the real B could forward it. what stops that here is the transport: the exchange
runs on hs/{handshake_id}, a random id only the two members are handed, on a
broker that refuses wildcard subscriptions. a malicious server still can, and is outside
what any of this defends: it mints the ids.
Re-establishing a channel is where this kind of protocol usually goes wrong in production, and it did here first. The rules that came out of that: both sides answer an offer and nobody answers an answer, or two devices ping-pong forever; each side caches its own ciphertext and re-sends that same one rather than generating a fresh one; and re-offers back off, because answering one costs the person at the other end a Face ID prompt.
cat crypto/ratchet.txt
the root chain — steps on a direction flip ┌────────────────────────────────────────────────────────────────────────────┐ │ (root', ck) = HKDF(root ‖ ss, "hqchat/v2/root") │ │ │ │ the new root depends on the old one. someone who learns the state at │ │ step n cannot run itbackwards; someone who missed a step cannot rejoin │ │ from later ciphertexts alone. │ └────────────────────────────┬───────────────────────────────────────────────┘ │# a DH ratchet cannot be transliterated: with a│# KEM the encapsulator picks the secret, so a step│# carries a fresh ratchet key AND a ciphertext┌────────────────────┘ │ the message chain — every message │ ├─ msg 0 mk = HKDF(ck,"msg") ──▶ AES-256-GCM ──▶ ck = HKDF(ck,"ck") ├─ msg 1 mk = HKDF(ck,"msg") ──▶ AES-256-GCM ──▶ ck = HKDF(ck,"ck") ├─ msg 2 ...the old ck is destroyed▼ one-way. a device seized at message 40 cannot work backwards to message 3. when it steps. a step costs a public key (7237 B) plus a ciphertext (14421 B) — 21658 B of key material, which v2 spells as about 29 kB of base64 and v3 sends as itself. stepping on every flip is strongest and is what Signal does; on a phone that is a step per turn. so a flip steps when the chain has run 32 messages or lived 15 minutes, whichever comes first — and always on the responder's first reply, where the ratchet actually starts turning. a chain that has sent nothing is never stepped off: there is nothing on it to rotate away from.# the clock was 60 seconds until september 2026, and that was tuned for a fast# exchange, where a step is nearly free. in the pattern messaging actually has —# replies minutes apart — EVERY message crossed the threshold and carried a full# step: a 40-character message that should be a ~300-byte frame became a ~29 kB# one. at 15 minutes a chain steps on VOLUME inside a conversation and on# ELAPSED TIME between them, which is the shape the cost profile wants.those two numbersarethe post-compromise-security window: at most 32 messages, or 15 minutes of an idle conversation, before a compromise heals. widening either widens that window. they are not tuning knobs picked for feel.
The receiver ratchets forward to the message it was handed and caches every key it skipped past, so a message that arrives after the ones behind it still opens. That cache is bounded — a few thousand keys — because an unbounded one is a memory-exhaustion attack wearing a feature's clothes. One previous epoch is kept receive-only as a grace window while a re-key settles.
A conversation opens on the chain the initiator advertised in its init, and
the root does not turn until the responder's first reply — that reply always steps. So the
initiator's opening messages, however many it sends before an answer, sit on one chain.
They have forward secrecy, because the message chain is one-way from the first message;
what they do not have is post-compromise security, which cannot exist
before the peer has contributed anything. A monologue nobody replies to never heals.
HQC uses implicit rejection: decapsulating a forged or corrupt
ciphertext returns a pseudo-random secret rather than an error. So a fabricated step — a
random ratchet key and 14421 random bytes — reaches the root derivation and succeeds.
While the receive path committed before checking the AEAD tag, that one frame replaced the
root, adopted the sender's ratchet key and reset the receive chain; the tag then failed, and
the session was saved anyway. The two sides could never converge again, and no re-handshake
could rescue it, because a fresh init is refused as a replay by anyone who has
already heard from you. Any accepted friend could send that frame.
The KEM cannot tell you a step was forged. The tag can. So nothing is written to the session — not the root, not the adopted key, not even the removal of a cached skipped key — until the AEAD has opened the payload, and the decrypt is passed into the ratchet as the verifier rather than handed back to four call sites that each have to remember.
cat crypto/wire.txt # the bytes two clients actually exchange
The ratchet decides what a key is. The wire format decides what the two implementations agree they are looking at — and for a protocol written twice, once in Swift and once in TypeScript, that second question is where the bugs live.
There used to be two formats. There is one. v2 was JSON carrying base64, and it is deleted — nothing reads it, and a v2 frame is now refused like any other non-frame. The rest of this section is why, because the reasons are the reasons the current format looks the way it does.
Base64 cost the 33% you would expect, which landed hardest on the frames that already
hurt: roughly 24 kB of every 82 kB init was encoding rather than protocol. But
size was never the strongest argument. The AAD has to agree byte-for-byte between the two
implementations, and in v2 it was a second construction — a netstring of a fixed
field order, hand-written on both sides, beside a JSON encoding produced by two different
libraries.
Running the two parsers against each other found four ways that drifts, none of which any test had caught:
JSON.parse keeps the last value, Swift's JSONDecoder keeps the
first — so one frame naming sender twice is two different frames to two
peers."\ud83d" with no pair is accepted by
JSON.parse and produces a string no UTF-8 encoder can represent; the AAD is
then built over substituted bytes rather than the ones on the wire. JSONDecoder
refuses the document outright.msg carrying
"ctOt": {} passed validation and had the object stringified into the AAD.
Every optional is type-checked now, whatever the frame kind.Both classes that survive on text are unreachable now — there is no text on the wire at
all. And n, pn and otId are unsigned 32-bit, because
the binary header has four bytes for them; v2 admitted any safe integer, so there were v2
frames with no v3 expression, which made the version a lie rather than a spelling.
The second construction is gone entirely. The canonical header
is the frame's own prefix, so the AAD is a byte range of the thing that arrived —
there is nothing to keep in step. Two fields are bound that were not: the version, so a
client cannot be flipped between formats without breaking the tag, and to, the
recipient's client id. A v2 frame said who it was from and never who it was for, which is
why a receiver had to fall back on checking the topic it arrived on; that check is
cryptographic now instead of transport-level. It is kept anyway, as defence in depth — it
is cheap, and it caught a real bug.
v2 — JSON carrying base64, and a SECOND encoding beside it┌────────────────────────────────────────────────────────────────────────────┐ │{"v":2,"t":"msg","sender":"64 hex","cid":"32 hex","n":7,…}│ │ │ │the AAD was not this text. it was a netstring of a fixed field order,│ │written by hand twice — once in Swift, once in TypeScript — beside a│ │JSON encoding produced by two different libraries.│ │ │ │ deleted. nothing reads it. a v2 frame is now refused like any other │ │ non-frame — which is what makes the recipient binding below mean │ │ anything, since an attacker avoiding it could just have sent v2. │ └────────────────────────────────────────────────────────────────────────────┘ the frame — length-prefixed binary. all integers big-endian. ┌────────────────────────────────────────────────────────────────────────────┐ │ 0 magic 4 B "HQCE" │ │ 4 version u8 3# inside the AAD: a downgrade cannot be│ │ 5 kind u8 0 msg / 1 init# spelled│ │ 6 flags u8 bit0 rk · bit1 kemCt · bit2 one-time │ │ 7 sender 32 B the client id, RAW# v2 spent 64 characters│ │ 39 to 32 B the RECIPIENT's client id — new in v3 │ │ 71 cid 16 B the chain selector, raw │ │ 87 n u32 │ │ 91 pn u32 │ │ 95 msgIdLen u8 1..128 │ │ 96 msgId msgIdLen B, UTF-8 │ │ [rk] u32 len + bytes [kemCt] u32 len + bytes │ │ [init] u32 len + senderPk, ctId, ctMt │ │ [oneTime] u32 len + ctOt, u32 otId │ ├────────────────────────────────────────────────────────────────────────────┤ │ ── the header ends here; everything above IS the AAD ── │ ├────────────────────────────────────────────────────────────────────────────┤ │ payload u32 len + ciphertext │ └────────────────────────────────────────────────────────────────────────────┘ a receiver binds frame.subarray(0, headerLength) — the literal bytes that arrived. there is no second encoding, so there is nothing to keep in step.
Worth keeping, because it is the shape of the job rather than a detail of one format. Every build read both versions, always — that was never a setting. Which one a build emitted was: a compile-time default, overridable per build on Apple and per deployment on the server. The asymmetry was the whole rollout plan. A peer on an older build drops a frame it cannot read silently, so acceptance had to ship a full release before emission moved anywhere.
And the recipient binding bought nothing while v2 was still accepted — an attacker who wanted to avoid it simply sent v2, and both receivers took it. The protection arrived when v2 acceptance was dropped, which is a decision that needs evidence rather than a memory: each process counted the well-formed frames it had read of each version, and zero v2 was the condition. Without a number, "confirm there are no stragglers" is a feeling, and a transitional state with no trigger is how a transition becomes permanent.
Both halves are done, so both flags and the tally are deleted. Dropping emission was recoverable; dropping acceptance was not, and that is why it was a separate step with a reading behind it.
the flip is announced rather than enforced: the server can push an advisory note to every client, and it blocks nothing. a hard gate during a maintenance window is the wrong instinct for a security reason — the window is exactly when a client most needs to reach the server, to receive the build that closes whatever the window is for.
cat crypto/at-rest.txt # the copy on your device
Encrypting the wire is the easy half. The message history sitting on the device is encrypted too: each message body gets a fresh random AES key, and that key is wrapped to a P-256 key held in the Secure Enclave, created so that it can be used but never extracted.
The asymmetry is deliberate. Sealing needs no user present, so a message that arrives while your phone is in your pocket can still be written down. Reading history needs biometrics. There is no symmetric fallback: a body that cannot be sealed is not stored at all.
Your identity key sits in the Keychain bound to the current biometric enrolment, and is read only for sign-in and handshakes — never to read a message. Reads are batched behind a short unlock window that does not slide, so signing in costs one Face ID rather than one per contact, and a remote peer cannot hold the key resident by sending frames.
Deleting a profile deletes its keypair, which makes that profile's stored messages permanently unopenable. That is the intended behaviour, not a side effect.
cat server/what-it-sees
| what the server holds | and what that means |
|---|---|
| ✗ your messages | Bodies are AES-256-GCM under keys derived on the two devices; no server component holds those keys or ever has. Nothing is stored server-side — undelivered ciphertext sits in the broker's own session, in memory: at most 1000 messages, kept while you have been offline for under two hours. Past either limit the broker drops them, oldest first. One exception, and it is yours to make: if you report a message, your copy of that one message is uploaded with the report, and deleted after 90 days. The app says so on the screen where you send it. Nothing else moves a message body off your device, and nothing can do it without you. |
| ✗ the key exchange | The HQC encapsulations travel inside the conversation topic, which the server routes but cannot open. |
| ✗ your email address | Not in any form. This row used to say the database held
sha256(lowercased email) to identify subscribers; the paywall it served
is gone, and no address now reaches this server hashed or otherwise. Stripe holds
what it needs to take a donation, and it is the only party that does. |
| ✗ your private keys | Generated on device, stored in the Keychain behind biometrics, never transmitted. |
| ✗ your notification content | The push bridge sees a topic hash and sends a generic "New message". It could not decrypt one if it wanted to. |
| ✓ your public key and username | The server is the directory — that is how anyone finds you. Lookup is exact-match only: there is nothing to browse and nothing to enumerate. |
| ✓ your friend graph | Stored as rows naming both client ids. An id is sha256 of a public key, which
makes it a commitment, not an anonymisation — anyone holding the key can derive it. We are
not going to dress this up: those rows are the relationship, and a server compromise
exposes them. |
| ✓ topic names, sizes and timing | Cloudflare terminates TLS and sees the same. End-to-end encrypted is not the same claim as unobserved, and we are not making the second one. |
iOS / macOS app
│
├── REST over TLS ──────▶ auth + app-api ──▶ postgres
│ who you are, who you talk to: handshake, username, friend graph
│
└── MQTT over WSS ──────▶ EMQX broker
the conversations themselves — ciphertext only
│
├──▶ cv/{random} the conversation, both members
├──▶ u/{id}/inbox first contact, queued while offline
├──▶ hs/{random} the challenge that authenticates it
├──▶ u/{id}/graph "your friend list changed", nothing more
│
├──▶ static ACL, no wildcards: a conversation id is its key
└──▶ push-bridge ──▶ APNs ──▶ your device
sees a topic id, sends "New message"
two protocols, no third. the broker holds undelivered ciphertext in its own
QoS-1 session — up to 1000 messages, for two hours offline — and nothing
else does; there is no message table anywhere.Conversation topics — and the handshake topic beside each one — are random ids the
server mints when a friendship is made: 64 hex characters from two random UUIDs, so 244
random bits. Nothing about them follows from the two client ids or keys, and the server
hands them only to the friendship's two members. They are also the access control. The
broker's ACL is a static file, not a lookup: any signed-in client may publish to and read
from cv/ and hs/ topics, by exact name only, since wildcard
filters are refused. Knowing a conversation's id is therefore the permission to
read its ciphertext and to write into it, which is why an id is never logged, never
derived, and never shown to anyone but its two members — and why the threat model treats
the ids as secrets.
cat threat-model/limits
The first time you add someone, their key is accepted as given. Safety numbers and a QR code let you compare keys out of band, and a key change raises a banner — but verification is optional and most people skip it. Substitution at first contact is visible to someone who checks, not prevented. We do not claim MITM protection.
If someone has your unlocked device, the cryptography is beside the point. The project's own threat model reaches the same conclusion: server compromise costs metadata and ciphertext, and everything that is left sits on the endpoint and on first contact.
There have been standing self-assessments against OWASP ASVS and MASVS. There is now some dynamic testing too: the two parsers that read the network before anything authenticates it are fuzzed against each other, and one command runs a whole conversation — real HQC, real ratchet, both wire versions — through the compiled Swift. That has found real bugs, and the wire section above names four of them. But it is our own testing. No penetration test, no load test, no external review, no third-party audit. Treat "audited" as a word we have not earned.
the Apple fuzzer's default driver is also coverage-blind — Apple's toolchain ships no libFuzzer runtime — so out of the box it is a random walk around a seed corpus rather than a guided search. that is a real limit and it is written down.
Your identity is a keypair held on one device behind biometrics. There is no password reset, because there is no password, and no key escrow to reset it from. Lose the device and the identity and its history are gone. Re-enrolling Face ID currently destroys them too — a known issue, tracked as a release gate, not yet fixed.
The app's source is published; the build on your phone came from the App Store. Nothing links the two. The build is not reproducible, so reading the code tells you what this project wrote, not what was shipped to you — the same gap every store-distributed app has, named here rather than left for you to notice. If it matters, build it from the repository and run that instead.
The app carries pinning code, but ships with no pins configured, so today it uses ordinary system trust. Do not count on pinning until this line changes.
./support
a donation buys nothing — everyone has the whole app either way. stripe takes it from here; there is nothing to fill in on this site.
or read the whole thing first — the app and the server.