hqchat:~/crypto$ secure

cat crypto/README # the long version

how hqchat encrypts a message

The short version is on the front page. This is the same thing without the shortening — including the parts that are unresolved.


cat crypto/primitives

what it is built from

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.

where HQC comes from

The implementation is the official reference implementation, pinned to a specific upstream commit and compiled into the app. It is deliberately held one commit behind upstream: the newer commit changes the bytes it produces, which would break interoperability between clients mid-conversation. A CI job watches upstream so that drift is a decision rather than a surprise.

Key generation is deterministic from a 32-byte identity seed, which keeps the public key byte-stable across protocol migrations — that is what lets safety numbers and the friend graph survive a change to everything around them.

the compiled HQC binaries are committed to the repository and are not reproducibly built, so the link from source to artifact is trust rather than verification. it is written down in the threat model, and it is worth knowing.

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/auth.txt # proving who you are, without a password

There are two handshakes in the system and they are easy to confuse. This is the first: a client proving to the server that it holds the private key for a public key. It is a KEM challenge–response.

  app                                                         auth service
   │  POST /auth/free/init  { pk }                                 │
   │ ─────────────────────────────────────────────────────────────▶│
   │                                    (ct, ss) = HQC.encap(pk)   │
   │                                    stores HKDF(ss,"auth")     │
   │  { ct }                            60s ttl, single use       │
   │ ◀─────────────────────────────────────────────────────────────│
   │                                                               │
   │  ss = HQC.decap(sk, ct)                                       │
   │  POST /auth/free/verify  { HKDF(ss,"auth") }                  │
   │ ─────────────────────────────────────────────────────────────▶│
   │                                    constant-time compare,     │
   │                                    consume the challenge,     │
   │  { session token, mqtt token }     store both as sha256       │
   │ ◀─────────────────────────────────────────────────────────────│

  a password would be a shared secret the server has to hold. this is a proof of
  possession instead: the client never returns ss or any decrypted bytes, only a
  derived value, so there is nothing here that works as a decryption oracle.

There are two separate doors, /auth/free/* and /auth/paid/*, rather than one endpoint with a flag. The paid door refuses an unclaimed key before it spends an encapsulation on it — which is only safe because the free door exists beside it, so the refusal is a gate rather than an oracle telling a stranger whether you subscribe.

cat crypto/handshake.txt # two devices agreeing on a key

The second handshake, and the one that matters for confidentiality. The server is not a participant in it.

  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.

keeping it live

Re-establishing a channel is where this kind of protocol usually goes wrong in production, and it did here first. The rules that came out of that: both sides answer an offer and nobody answers an answer, or two devices ping-pong forever; each side caches its own ciphertext and re-sends that same one rather than generating a fresh one; and re-offers back off, because answering one costs the person at the other end a Face ID prompt.

cat crypto/ratchet.txt

  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.

messages that arrive late, or out of order

The receiver ratchets forward to the message it was handed and caches every key it skipped past, so a message that arrives after the ones behind it still opens. That cache is bounded — a few thousand keys — because an unbounded one is a memory-exhaustion attack wearing a feature's clothes. One previous epoch is kept receive-only as a grace window while a re-key settles.

epoch 0, stated plainly

A new conversation starts on the static channel key from the handshake. That epoch has no forward secrecy and no post-compromise security, and nothing forces a re-key at establishment — the first one happens after 100 messages, or when someone taps rotate keys. So a short conversation may live its whole life on one key. It is logged as an open finding rather than quietly rounded off.

cat crypto/at-rest.txt # the copy on your device

Encrypting the wire is the easy half. The message history sitting on the device is encrypted too: each message body gets a fresh random AES key, and that key is wrapped to a P-256 key held in the Secure Enclave, created so that it can be used but never extracted.

The asymmetry is deliberate. Sealing needs no user present, so a message that arrives while your phone is in your pocket can still be written down. Reading history needs biometrics. There is no symmetric fallback: a body that cannot be sealed is not stored at all.

Your identity key sits in the Keychain bound to the current biometric enrolment, and is read only for sign-in and handshakes — never to read a message. Reads are batched behind a short unlock window that does not slide, so signing in costs one Face ID rather than one per contact, and a remote peer cannot hold the key resident by sending frames.

Deleting a profile deletes its keypair, which makes that profile's stored messages permanently unopenable. That is the intended behaviour, not a side effect.

cat server/what-it-sees

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

Conversation topics are derived as sha256 of the two sorted public keys, so anyone holding both keys can compute the topic name. Topic secrecy is therefore not the access control and could never have been: the broker checks a per-topic ACL in Postgres on every publish and subscribe, defaulting to deny. That ACL is the barrier, which is exactly why it is the component the threat model watches hardest.

cat threat-model/limits

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

stripe states the price at checkout, before you enter anything.

or read the whole thing first: https://github.com/MartinRougeron2/HQChat-Server