hqchat:~/$ █ secure

hqchat

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.

v0 — what actually works today
  • 1:1 text messages
  • presence, and the friend graph
  • invite by exact username
  • safety numbers + QR key verification
  • push notifications, with no message content in 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.

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.
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/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 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.

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

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

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

what is published
  • the iOS and macOS app, one Xcode project
  • the HQC wrapper, the ratchet, the wire codec
  • auth, app-api, push-bridge, broker-watch
  • the postgres schema and its migrations
  • both test suites, and the vectors they share

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.

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

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.

what this is and what it costs · supporters

what it costs to run
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.

get the app

hqChat on the app store

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.