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 both halves are published, the app that does the encrypting and the server that does not, so you can read the code that makes the claim.
hqChat on the app store free, for iPhone and iPad, iOS 18.5 or later. no account to make, nothing to buy in it.
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.
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:
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. |
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 proves the responder, because only the holder of that key can decapsulate; 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.
It also means the first flight proves nothing about who sent it — every value in
it is public. So the responder does not accept an init on its word: it holds the
frame and encapsulates a challenge back to the identity key the frame claims, and the
initiator has 60 seconds to answer with an HKDF proof over that secret. Only then is the
session real.
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.
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 still be offline while the first message is sent — it waits in their inbox topic — but the round trip is now the price of knowing who sent it.
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) — 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.
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 | 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.git clone hqchat
Every claim above is a claim about software you cannot see running. So both halves are published: the app, and the server it talks to.
The app is the half that decides whether any of this is true. Every key this page describes is made, held and used on the device — the server never has one — so "end-to-end encrypted" is a statement about the Swift, not about the backend. That Swift is now there to read: the HQC binding, the ratchet, the keychain handling, the wire codec, and the tests that hold them to the same vectors the server is held to.
The server is the half you do not have to run. Bring up 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.
https://github.com/HQChat/HQCHAT/tree/main/apps/apple
https://github.com/HQChat/HQCHAT/tree/main/services/server · AGPL-3.0
and what is not: the deployment — compose, nginx, terraform, the hosts — and the open audit findings. a list of unfixed weaknesses beside the code that has them helps the wrong reader first. what is in these repositories is the code that runs.
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. Those are tracked privately — a list of unfixed weaknesses published beside the code that has them helps the wrong reader first — so what follows is the summary, written to be the honest version rather than the flattering one.
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
The whole app is free. Every feature, for everyone, with no account tier and nothing to buy anywhere — a donation unlocks nothing, because there is nothing left locked. What it pays for is the bill below.
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.
the buttons post straight to /donate/checkout on this host, which creates a stripe checkout session and sends you there. no account, no form, nothing to fill in here at all.
| DigitalOcean droplet — production, London | $18 |
| DigitalOcean droplet — pre-prod | $18 |
| DigitalOcean Managed Postgres (single node) | $15 |
| Apple Developer Program ($99/yr) | $8 |
| Domain | $1 |
| Cloudflare — DNS, the Worker serving this page, R2 | $0 |
| Sentry — crash reporting | $0 |
about $60 a month, and roughly fixed — more users cost capacity, not money, which is why the product is free rather than metered. to stop a recurring donation, use the billing-portal link in stripe's own emails: this site has no account area, and deliberately no page that will act on a stranger who knows your public key.
free, for iPhone and iPad, iOS 18.5 or later. every feature is in it and there is nothing to buy anywhere inside it. the App Store listing is the iOS app; the macOS build is not on the store.