The deep page: one message from the 12 words to the anchored hash chain — who can see what, honestly · content/encryption-explained.md
Encryption, Explained — how a qalchat message actually works
Date: 2026-09-12 · Companion pages: Using chat (the how-to) · Data sizing (the bytes) · Qal Chat design (the build doc) · FAQ ("Signal-grade" answer)
Founder's question: "go into detail how that actually works and operates." This page does exactly that — one message, end to end, with the real function names so every claim can be checked in qalchat/qalchat.py. Plain-English glosses are inline; nothing here is marketing.
The 60-second version
Your 12 words create two keypairs on your device. A one-time handshake mixes your key with your friend's key into a shared "conversation root." Every message is sealed with its own key derived from that root, encrypted with AES-256-GCM, chained to the previous message by a hash, and signed with your identity key. The relay that passes the message along stores ciphertext only — it cannot read, forge, or (tampering) alter your mail. Once a conversation (or file) is anchored, its integrity is provable from the chain itself, not from trusting us.
- keys — 12 words → X25519 + ed25519
- handshake — ECDH → HKDF → root key
- ratchet — per-message key (counter)
- encrypt — AES-256-GCM + AAD bind
- sign — ed25519 over envelope
- relay — ciphertext only
- anchor — 32-B Merkle root on-chain
Mini-glossary (used throughout):
| Term | Plain English |
|---|---|
| X25519 | A standard recipe for two people to mix private keys into one shared secret over an open channel (elliptic-curve Diffie–Hellman) |
| ed25519 | A standard signature scheme — proves who sent something, unforgeably |
| HKDF | "Hash-based key derivation": a standard recipe for turning one secret into several well-separated keys |
| AES-256-GCM | The workhorse authenticated encryption: hides the message and detects any alteration |
| AAD | "Additional authenticated data" — extra facts folded into the encryption check so a ciphertext is welded to its context (which conversation, which sequence number) |
| Nonce | A random one-time number; reusing one with GCM would be fatal, so it's freshly random per message |
| Ratchet | Deriving each message's key from a shared root so every message gets a different key |
| Forward secrecy | The stronger property that leaking today's key doesn't unlock yesterday's messages — we do not have this yet (see limits) |
1. Your keys — from the 12 words
Everything starts client-side. qalchat.py init (or wallet recovery) takes your BIP-39 mnemonic through qal-derivation v1 (seedkit/derive.py:derive_material, called from qalchat.py:seed_keypairs at qalchat.py:863) and derives exactly:
| Key | Purpose | Ever leaves the device? |
|---|---|---|
x25519_priv / x25519_pub |
key agreement (the shared-secret math) | priv: never. pub: published to the keyserver |
ed25519_priv / ed25519_pub |
signing (authorship proofs, invites, mailbox access) | priv: never. pub: published + pinned by peers |
loopd_secret_hex |
payment API secret (HMAC) | sent once to your own loopd on bind |
The seed itself never leaves seed_keypairs. The identity file on disk is 0600 (qalchat.py:save_identity). No account, no server-side key escrow: if you have the words, you are the account; if you don't, nobody — including us — can reconstruct it.
2. The handshake: ECDH → HKDF → root key
When you first message a peer, the client fetches their pinned public keys from the keyserver (qalchat.py:peer_pubkeys) and derives a shared conversation root:
shared = X25519(my_priv, peer_pub) # qalchat.py:root_key
root = HKDF-SHA256(ikm=shared, salt=convo_id,
info="qalchat/root/v1") # → 32 bytes
convo_id = "qc1:" + sha256(sorted([me, peer])[:24) # qalchat.py:convo_id_for
Three things worth noticing:
- Both sides compute the same root without ever transmitting a secret — that's what Diffie–Hellman is. An eavesdropper sees only public keys.
convo_idcontains no relay URL. It's derived from the two handles alone, so the same conversation survives moving between relays, meshes, or devices unchanged (qalchat.py:252).- The
info/salt strings are version hooks."qalchat/root/v1"domain-separates this derivation from everything else — and is exactly where a v2 handshake would hang its hat (see agility).
3. Anatomy of ONE message
Here is every step for a single send, with the code that performs it (qalchat.py:build_envelope, line 585):
Step 1 — the plaintext payload. Your text is wrapped in a small JSON envelope inside the encryption:
{"type": "text", "from": "alice", "text": "hello",
"ts": 1789200000000, "prev_chain": "<sha256 hex of the chain so far>"}
prev_chain is the only chain value that ever travels — inside the encryption, so the receiver can detect gaps and reordering (nothing chain-related is trusted from the wire).
Step 2 — the per-message key (counter ratchet). qalchat.py:message_key (line 271):
msg_key = HKDF-SHA256(ikm=root, info="qalchat/msg/v1|alice|<ctr>")
ctr is your per-conversation send counter (sent + 1). Every message therefore gets a different AES key, deterministically — both sides compute the same key for the same counter without coordination. (Gloss: this is a "symmetric ratchet" — key separation, not yet forward secrecy.)
Step 3 — encrypt: AES-256-GCM with AAD binding (qalchat.py:606-608):
nonce = 12 random bytes
ct = AESGCM(msg_key).encrypt(nonce, plaintext, AAD="qc1:…|7")
The AAD welds this ciphertext to (conversation, counter) — a stolen-and-replayed ciphertext from another conversation or another position fails the GCM check and is rejected.
Step 4 — transit hash. msg_hash = sha256(ciphertext) (qalchat.py:616). The relay stores it; the receiver's first check on receipt. Corruption in transit dies here, before any key material is even touched.
Step 5 — the hash chain. qalchat.py:chain_next (line 283):
h_0 = sha256(convo_id)
h_i = sha256(h_{i-1} ‖ ciphertext_i)
Each message is hash-linked to everything before it. Delete one, reorder one, replay an old one, or edit one — and the receiver's recomputed chain diverges from the sender's ⇒ REJECTED (exit 3, recv leaves the envelope un-acked so a genuine redelivery still works).
Step 6 — the envelope + signature. The signed wire object (qalchat.py:610-620):
{"from_handle": "alice", "to_handle": "bob",
"ciphertext": "<b64>", "nonce": "<b64>", "ts": 1789200000000,
"msg_hash": "<64 hex>", "ctr": 7, "pub": "<ed25519 pubkey b64>",
"sig": "<ed25519 signature>"}
sig (sign_envelope, line 352) covers the canonical string qalchat-envelope-v1|from|to|ciphertext|nonce|ts|msg_hash|ctr — so nobody but the holder of alice's ed25519 key can produce a valid envelope claiming to be alice, and the relay can't alter a single field.
Step 7 — relay POST. POST /envelopes to any reachable relay (cmd_send_core, line 641; --relays r1,r2 fans out/falls back). Cap: 64 KiB per envelope, 24 h TTL (qalchat.py:99-100).
Step 8 — the receiver's gauntlet (recv_multi/try_accept, lines 650/722), in order: msg_hash matches → signature verifies against the pinned key → AES-GCM opens (AAD checks) → prev_chain equals the local chain head → chain recomputed and stored. Any failure ⇒ rejected, never applied.
Step 9 — optional anchoring. anchor bob → canonical order (sender, ctr) → Merkle root over the msg_hash leaves → canonical blob → on-chain (qalchat.py:convo_merkle, qalpipe/qalpipe.py:anchor_file). Details next.
4. What the relay stores — ciphertext only, byte-proven
The relay (qalchat.py serve) is a ciphertext-blind mailbox: it routes by handle, stores opaque envelopes in relay_state/, expires them at 24 h, deletes on ack. It holds no keys and does no crypto. This is not a claim, it's a test: qalchat/test_files.sh §[1] reads the relay's actual at-rest bytes for a sent file and asserts zero plaintext and zero filename bytes are present ("relay stores CIPHERTEXT ONLY (no filename, no plaintext at rest)"). For messages the same holds by construction — the relay only ever receives ciphertext fields.
What the relay does see, honestly: sender/recipient handles, timestamps, sizes, counters, and source IPs. Content is sealed; traffic analysis is not attempted (DESIGN.md §2, limitation 3). See the table below.
5. What anchoring adds
The hash chain proves integrity between the two of you. Anchoring makes it provable to anyone, without trusting either of you:
- Both parties can order messages identically without a shared clock: sort by
(sender, ctr)(canonical_msgs, line 341). - Merkle root over the
msg_hashleaves in that order (merkle_root, line 326). - The root is written into a byte-stable canonical blob and anchored via qalpipe: a
DataAnchorMove object on the Qalnet chain (qal_data::data_anchor) carrying the 32-byte content hash, a uri, mime, timestamp, and an optional ed25519 signature (qalnet/qal/qal_data/sources/data_anchor.move:32). verify-convorecomputes everything locally and compares against the chain, not our database — local tampering of any committed field ⇒TAMPERED(tested both ways;qalpipe.py:verify_file).
Note what is not on-chain: no ciphertext, no plaintext, no participant list. A Merkle root is an opaque 32-byte fingerprint — it proves "this exact conversation history existed, unmodified," and reveals nothing about what was said. (Files anchor the same way automatically after verified receipt — test_files.sh §[1] shows the auto-anchor with tx id, and chunks swept off the relay.)
6. Who can see what
| Party | Sees | Can do | Cannot do |
|---|---|---|---|
| You | everything, obviously | read, write, anchor, pay | — (your keys, your rules; lose the 12 words and nobody can recover them) |
| Your peer | the full decrypted conversation + your pinned keys | verify your signatures, detect gaps/tamper, anchor their own view | prove they received everything (a relay can silently drop mail — tampering is detectable, suppression is not; DESIGN.md §2.4) |
| The relay (whoever runs it — could be us, could be anyone) | handles (from/to), timestamps, sizes, counters, source IP, and ciphertext blobs | read metadata; withhold/drop mail (detected only as chain gaps on later delivery); refuse service | read any content; forge or alter a message (ed25519 + msg_hash + chain all fail loudly); substitute keys for invite-pinned contacts (client refuses — KeySubstitution, exit 4) |
| Chain validators / the public | anchored Merkle roots / file hashes, timestamps, anchoring txs | verify integrity of anything anchored, forever | learn anything about content or participants from a root alone |
| qalarc the company | whatever a relay sees (we run relay #1) + loopd payment ledger entries (amounts, memos, agent ids — payments are ledger facts, not chat secrets) | operate infrastructure; must never see: your private keys (never transmitted), your plaintext (never transmitted) | decrypt your mail, recover your seed, or retroactively alter an anchored history |
The one-line summary: content is between you and your peer; metadata is between you and whoever carries the bytes; integrity is between you and the chain.
7. Files: chunked, dual-hashed, swept
send-file (2026-09-09) reuses the conversation root with its own domain separation (qalchat.py:cmd_send_file, line 1706):
- Chunking: the file is split into 256 KiB plaintext chunks (
FILE_CHUNK_BYTES, line 112; hard cap 8 MB ⇒ ≤32 chunks, relay cap 64). - Per-chunk keys: each chunk gets its own AES key:
HKDF(root, info="qalchat/file/v1|<file_id>|<seq>")(file_chunk_key, line 294) — files and chat messages can never share a key, and one leaked chunk key opens exactly one chunk. AAD =(convo_id, file_id, seq)(file_aad, line 303): a chunk cannot be replayed into another file or another position. - Dual hash verification: the ciphertext hash is checked before decryption (transit), the plaintext hash after (content) — per chunk — and the whole-file sha256 at the end. A single flipped bit anywhere ⇒ the file is rejected and never reassembled (writes go to a
.partfirst). Proven intest_files.sh§[3]: one corrupted stored bit ⇒ rejection, exit 3. - The manifest — filename, size, whole-file sha256, per-chunk ids and both hashes — travels inside the normal encrypted conversation as a
file-manifestmessage (so the relay never sees even the filename; it's sanitized to a basename anyway,sanitize_name, line 317). - Lifecycle: ciphertext chunks sit on the relay 48 h under opaque ids (
files/<fid>/<seq>.bin), and are swept after verified receipt (test_files.sh§[1] proves the sweep).
The exact byte cost of all this is measured on Data sizing.
8. Invites, pins, and proof-of-possession — the two anti-hijack layers
Layer 1: out-of-band invites (kills TOFU). The classic weakness of "trust on first use" is that whoever answers first contact gets pinned — a malicious relay could substitute keys and read everything thereafter. qalchat's fix (qalchat/invites.py, shipped 2026-09-09): an invite is a QR code or pasted URI carrying both public keys, signed by the inviter's ed25519 identity key. The recipient pins the true keys before ever touching the keyserver. Thereafter the keyserver is demoted to a tamper detector: if it ever advertises a different key for an invite-pinned handle, the client refuses delivery (KeySubstitution, exit 4 — qalchat.py:458, keyserver_check_pinned). For a spoken/visual spot-check, every identity renders a 10-char fingerprint (AB2C-D3E4-F5, invites.py:fingerprint) — an impostor's invite produces a different code. And any later key change on a pinned peer is a hard error requiring manual action (pin_peer, line 399) — silent key rotation is impossible.
Layer 2: proof-of-possession on the mailbox (2026-09-12). Fetching a mailbox used to need only the handle. Now mailbox operations carry pop_headers (qalchat.py:202): a fresh ed25519 signature over qalchat-pop-v1|<handle>|<resource>|<ts>|<nonce>. Without your private key you cannot read someone's pending mail even if you know their handle — and drive-by mailbox enumeration dies with it. Older relays simply ignore the extra headers (backward compatible).
Together: the first layer pins who your peer is despite a hostile keyserver; the second proves you are you to the mailbox. Neither requires trusting the relay.
9. The agility question
Founder asked: "new encryption protocols, or people's own, could run across this network, right?"
Yes — and it's worth understanding exactly why. The relay is protocol-agnostic by construction: it stores opaque JSON envelopes (≤64 KiB, any bytes you like inside the ciphertext field) and opaque file blobs. It performs zero cryptography and understands none of the format — that lives entirely client-side. The wire contract is "a signed blob with a from, a to, and a TTL," which no crypto upgrade changes. So:
- A new scheme = a new client, on the same network. Double-ratchet clients, MLS group clients, post-quantum hybrids, or an app with its own bespoke format can all push envelopes through the existing relays untouched. The relay can't tell the difference — that's the point.
- Per-app custom protocols already have a hook: the
typefield rides inside the encryption (build_envelope'sptype), so applications (qalpub unlocks, qalbazaar offers, payment notices) define their own payload shapes today without any relay change. - What exists for versioning now: the protocol string
qalchat-envelope-v1(qalchat.py:103) is signed inside every envelope's signature; the HKDFinfodomains (qalchat/root/v1,qalchat/msg/v1,qalchat/file/v1) are clean version hooks per layer. - What a real v2 would need to add (honestly, not built yet): an explicit envelope version byte the relay routes on (for coexistence), a capability negotiation step ("I speak v1 and v2, what's your peer?") — today clients simply assume v1, and a v2 client would have to keep speaking v1 to v1 peers — plus versioned keyserver key-docs so old and new handshake keys can coexist per handle.
Planned upgrades, in the order they're queued (DESIGN.md §7, FAQ): the double ratchet (Signal's construction — per-message DH re-keying; ~2–3 weeks + audit per the FAQ answer), MLS (RFC 9420) for proper group chats, post-quantum hybrids (an ML-KEM leg alongside X25519 in the handshake — the standard 2026 migration shape), and any number of application-specific protocols on the same rails. None of them require touching the relay fleet.
10. Honest limits
Stated plainly, because a crypto page without this section is a red flag (full list: DESIGN.md §2):
- Ratchet-lite, not forward-secure. Per-message key separation is real (one leaked message key ≠ others leaked). But the keys derive deterministically from the conversation root — compromise of a root or identity key exposes that conversation's past and future. There is no DH re-keying and no erased state. The FAQ's Signal-grade answer says exactly this: "encrypted, not yet Signal-grade."
- Metadata is visible to relays: handles, timing, sizes, counters, IPs. Sealed content; unsealed traffic analysis.
- A relay can silently drop mail. Chains make tampering detectable; suppression is provable only with per-conversation receipts (roadmap).
- Post-quantum: not yet. X25519 + AES-256 + ed25519 are all classical. The PQ hybrid is an upgrade-path item, not a shipped one.
- Scale caveats: random 96-bit nonces are fine at human chat volume, not at machine fan-out; one device per handle; ~2,000 lines of single-session Python, tested against live services but not externally audited — a prototype, honestly labeled.
Cross-links: the how-to is Using chat; the measured bytes are on Data sizing; the threat model is Security & Threat Model; the "Signal-grade" FAQ answer is FAQ.