QALS Wiki · the qalarc network⌂ qalarc.com/projects/qals
generated 2026-09-16 · qalcode autonomous research

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.

One message's journey — keys to anchor
  • 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
Every step is a real function in qalchat.py — §1–§7 below walks each one with the code.

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:


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:

  1. Both parties can order messages identically without a shared clock: sort by (sender, ctr) (canonical_msgs, line 341).
  2. Merkle root over the msg_hash leaves in that order (merkle_root, line 326).
  3. The root is written into a byte-stable canonical blob and anchored via qalpipe: a DataAnchor Move 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).
  4. verify-convo recomputes 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):

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:

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):

  1. 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."
  2. Metadata is visible to relays: handles, timing, sizes, counters, IPs. Sealed content; unsealed traffic analysis.
  3. A relay can silently drop mail. Chains make tampering detectable; suppression is provable only with per-conversation receipts (roadmap).
  4. Post-quantum: not yet. X25519 + AES-256 + ed25519 are all classical. The PQ hybrid is an upgrade-path item, not a shipped one.
  5. 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.