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
Both sides encapsulate to the other's public key, and each publishes the resulting ciphertext. Each device then holds two 32-byte secrets: the one it generated and the one it recovered. Sorting them before the KDF is what removes the notion of an initiator — both sides derive the same channel key without either going first.
alice topic c/sha256(sorted(pk_a, pk_b)) bob
│ │ │
│ ct_a = HQC.encap(pk_b) ────────▶│────────▶ HQC.decap(sk_b, ct_a) ──▶ peerSs │
│ └──▶ mySs │ │
│ │ mySs ◀──┐ │
│ peerSs ◀── HQC.decap(sk_a, ct_b) ◀│◀──────── ct_b = HQC.encap(pk_a) │
│ │ │
▼ │ ▼
channelKey = HKDF-SHA256( # sort() makes the two secrets order-
ikm = sort(mySs, peerSs), # independent, so neither side is the
salt = "salt", # initiator and both land on one key
info = "info", len = 32 )
the server carries those two frames and nothing else. it cannot open either:
they are encapsulations to private keys that never left the two devices.this is a mutual encapsulation, not a diffie–hellman. there is no shared group element and no signature; possession of the private key is the whole proof.
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.
tier 1 — epoch re-key every 100 messages sent, or on demand
┌───────────────────────────────────────────────────────────────────────────┐
│ a fresh mutual HQC encapsulation, exactly as above, then │
│ root = HKDF(sort(mySeed, peerSeed), "epoch") │
└────────────────────────────┬──────────────────────────────────────────────┘
│ # this is the step that buys back secrecy
┌────────────────────┴────────────────────┐ # AFTER a device is seized
ck = HKDF(root,"chain-a2b") ck = HKDF(root,"chain-b2a")
│
tier 2 — per message
│
├─ msg 0 key = HKDF(ck,"msg") ──▶ AES-256-GCM ──▶ ck = HKDF(ck,"ck")
├─ msg 1 key = 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.
caveat, stated plainly: a brand-new conversation starts at epoch 0 on a single
static key, with no forward secrecy, until the first re-key. open finding KM-5.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 public keys. 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
│
├──▶ 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.