private, post-quantum messaging
1:1 messages between two devices, with the key agreement done by a post-quantum KEM instead of an elliptic curve. The server routes ciphertext it cannot read — and the whole server is published, so you can read it, or run your own.
stripe states the price at checkout, before you enter anything.
whoami
hqchat is a messenger for iOS and macOS. Your identity is a keypair, not an account: no phone number, no email address, no password — and correspondingly no way for anyone, us included, to reset it or hand it to somebody who asks nicely.
You can hold several identities on one device, fully separate: different keys, different contacts, different history, no thread between them.
no groups. no calls, photos or voice notes — those were cut when the transport moved to MQTT. no read receipts or typing indicators, deliberately. there is no web client and there is not going to be one.
cat crypto/primitives
Four primitives, named exactly, so you can go and check the claims rather than take them.
| 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: epoch,
chain-a2b, chain-b2a, msg, ck. |
| 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. |
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/handshake.txt # how two devices agree on a key
The initiator encapsulates to three of the responder's keys at once — the pinned identity key, a medium-term prekey, and a single-use one-time prekey when one is left — and mixes all three secrets into the first root. The identity encapsulation is what authenticates; the prekeys are what buy forward secrecy. Following PQXDH, that combination is why the prekeys need no signature, which matters because HQC is a KEM and cannot produce one.
alice# bob can be offline for all of thisbob │ │ │ 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)# authenticates: only bob has sk│ │ (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₀ │ ▼ ▼ 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.
this is encapsulation, not a diffie–hellman: there is no shared group element, and possession of the private key is the whole proof. the responder can be offline the entire time — the first message carries the handshake and waits in their inbox topic, so first contact needs no round trip.
cat crypto/ratchet.txt # and how that key stops being the same key
A single long-lived key would mean one compromise decrypts everything, past and future. So the channel key is only a starting point: a per-message chain gives forward secrecy, and a periodic fresh HQC exchange gives post-compromise security — the property that lets a conversation recover after a device has been breached.
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) — about 29 kB base64. stepping on every flip is strongest and is what Signal does; on a phone, in a fast exchange, that is 29 kB per turn. so a flip steps when the chain has run 32 messages or lived 60 seconds, whichever comes first — and always on the responder's first reply. those two numbersarethe post-compromise-security window: a compromise heals within one step. they are not tuning knobs picked for feel.every message is ratcheted from the first. the v1 static-key path — where aconversation under 100 messages had no forward secrecy at all — is gone withthe rest of v1. that was finding KM-5, closed August 2026.
we call this a ratchet with periodic post-quantum re-keying, not "the double ratchet" — there is no per-message asymmetric step, and borrowing signal's name for a different construction would be the kind of thing this page exists to avoid.
cat server/what-it-sees
The server is a relay and an access-control point. It is not a store. The honest summary is the one the repository uses on itself: it fans out ciphertext it cannot read.
Which is a real property, and a narrower one than "private". Here is the whole list, including the parts that are not flattering — because a server that knows your username, your contacts and your timing has no business being described with words that imply it knows nothing about you.
| what the server holds | and what that means |
|---|---|
| ✗ your messages | Never. 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 at all — undelivered ciphertext sits in the broker's own session until you reconnect. |
| ✗ the key exchange | The HQC encapsulations travel inside the conversation topic, which the server routes but cannot open. |
| ✗ your email address | Subscriptions are keyed on sha256(lowercased email). Stripe holds the
plaintext; this database holds the hash. |
| ✗ 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
│
├──▶ c/{hash} the conversation, both members
├──▶ u/{id}/inbox first contact, queued while offline
├──▶ u/{id}/graph "your friend list changed", nothing more
│
├──▶ per-topic ACL in postgres, no_match = deny
└──▶ push-bridge ──▶ APNs ──▶ your device
sees a topic hash, sends "New message"
two protocols, no third. the broker holds undelivered ciphertext in its own
QoS-1 session until you reconnect; there is no message table anywhere.git clone hqchat-server
Every claim above is a claim about software you cannot see running. So the server and the entire infrastructure that runs it are published — services, broker config, database schema, deployment, the lot — under AGPL-3.0.
You do not have to trust this deployment. Run your own: admission can be
open or a private allowlist, and pointing a build at a different server is
one file's worth of change. That is the actual answer to "why should I believe you" —
not a promise, an exit.
cat threat-model/limits # the section that makes the rest worth reading
Anything claiming to protect you should tell you where it stops. These are taken from the project's own threat model and its open findings, which are published alongside the code rather than summarised for you here.
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, and the findings are published in the repository. But nothing has been tested dynamically — no penetration test, no fuzzing, no load test, no external review. Every finding so far comes from reading the code. Treat "audited" as a word we have not earned.
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 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.
./subscribe
The app is free and there is nothing to buy inside it. A free account can talk to the helper bot; a subscription is what lets you add real contacts. It is sold here and only here.
stripe states the price at checkout, before you enter anything.
the button posts straight to /subscribe on this host, which creates a stripe checkout session and sends you there. no account, no form, nothing to fill in before you see the price.
up to 3 devices on one subscription. to cancel, use the billing-portal link in stripe's own subscription emails — this site has no account area, and deliberately no page that will act on a stranger who knows your public key.
hqchat is not on the App Store or TestFlight yet, so there is currently nothing to install. If you subscribe now you are buying ahead of the release — your subscription waits on your email address, and the claim code works the moment the app is out. If you would rather not pay for something you cannot run today, don't. Come back.