hqchat:~/crypto$ █ secure

cat crypto/README # the long version

how hqchat encrypts a message

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

what it is built from

roleprimitivedetail
key agreementHQC-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 encryptionAES-256-GCM 12-byte random nonce, 16-byte tag, authenticated.
key derivationHKDF-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.
hashingSHA-256 topic derivation, safety numbers, and the hashes that stand in for tokens and email addresses in the database.
signaturesnone authentication is a KEM challenge–response, not a signature scheme.

where HQC comes from

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.

the part most products would not print

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 challenge           bob
    │                                                                              │
    │  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 ────────▶   │
    │       to u/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} on h/{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.

proving who started it

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.

keeping it live

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 it backwards; 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 numbers are the 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.

messages that arrive late, or out of order

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.

the first chain, stated plainly

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.

the tag decides, and nothing before it

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:

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.

how a wire version actually changed

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 holdsand 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

first contact is trust-on-first-use

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.

endpoint compromise beats all of this

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.

no third-party audit

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.

no recovery, and that cuts both ways

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.

published source is not a verified binary

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.

TLS pinning is not on

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.