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

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: epoch, chain-a2b, chain-b2a, msg, ck.
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 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 this                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)   # 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 ────────▶   │
    │       to u/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 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) — 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 numbers are the 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 a
  conversation under 100 messages had no forward secrecy at all — is gone with
  the 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 holdsand 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.

https://github.com/MartinRougeron2/HQChat-Server

what is published
  • auth, app-api, push-bridge, broker-watch
  • EMQX config and the per-topic ACL
  • postgres schema and migrations
  • compose, nginx, the NixOS hosts
  • the HQC wrapper, and the audits

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.

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

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.

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.


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

then, to link a device
  • pay — stripe collects your email
  • open hqchat, choose restore with your email
  • enter that address, then the 6-digit code sent to it

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.

not shipped yet

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.