Files
quantumtunnel/docs/protocol.md
T
LeoVasanko 168287ca98 One x25519 ephemeral per party per connection
Each exchange party ships a single x25519 ephemeral public (e_alice in
msg1, e_bob in msg2) and every x25519 leg is a plain DH against those
shipped keys; ML-KEM legs ship as bare ciphertexts. Encapsulation is
split into explicit ML-KEM + DH steps at the call sites, key objects
are kept as members (statics derived once from the persisted idroot,
ephemerals generated directly), and all fixed-width wire layouts are
full-message structs. Also: role-suffixed key naming throughout, dead
struct/layout cleanup.
2026-10-07 04:35:31 +00:00

7.6 KiB

The quantumpipe protocol

Hybrid ML-KEM-768 + X25519 mutually authenticated key exchange on top of a symmetric shared key, ChaCha20-Blake3 AEAD boxes (chab3) throughout. All key material crossing the public API is plain bytes; cryptography key objects are built and discarded internally.

The KDF

kdf(info, *items, length=n) is a length-framed SHA-512 KDF (no HKDF/HMAC):

kdf(info, *items, length=n)
  = SHA512( len(items+1) || len(info) || info
            || len(items[0]) || items[0] || ... )[:n]

All integers are 8-byte little-endian; every item is individually length-framed, so the input encoding is unambiguous and each item's contribution is independently domain-separated. str arguments are UTF-8 encoded before their length is counted. By convention the first argument is always the info text. length is capped at 64 (the SHA-512 output size).

Info strings are short functional labels. Wire boxes are namespaced per flow and message -- reg/msg1, reg/msg2, reg/msg3 (plus the inner box reg/msg2/inner) for registration, res/msg1..3 for resumption -- so the two exchanges are cryptographically disjoint. Derivations that do not box a message carry no msg prefix and are named purely by function; where both flows perform the same derivation through the same helper with the same input format, they share one label (the traffic keys a2b/b2a, the idroot subkeys idroot/mlkem and idroot/x25519, cred/id).

The hybrid KEM

ML-KEM-768 (pk 1184 / sk-seed 64 / ct 1088) concatenated with X25519 (32). A private key is a single 32-byte idroot secret: each scheme's key is derived from it with one domain-separated kdf call, so the two subkeys are independent PRF outputs and the idroot is the only thing held.

ML-KEM uses FIPS 203 implicit rejection: decapsulation never fails, and a wrong key yields garbage caught by the kdf tags. X25519 has no such property, so low-order (non-contributory) public keys are rejected explicitly.

Each party ships ONE X25519 ephemeral public per connection: alice's inside e_alice in msg1, bob's as a bare public e_bob in msg2. Every X25519 leg is then a plain DH against those shipped keys, and ML-KEM legs ship as bare ML-KEM ciphertexts. Reusing one ephemeral per party also binds related secrets to the same sender -- ee and se provably come from the same bob, which per-capsule ephemerals cannot show (ML-KEM alone cannot provide it; it rides on the AEAD transcript).

Transcript chaining

No running hash, and box keys derive only from key-exchange secrets. Instead each box's associated data is the tag of the previous message, so the AEAD itself enforces an untampered transcript before any plaintext or key is released. The chain starts from an application ad given to the constructor (session context; both sides must pass the same value), which is bound into msg1.

Registration

First connection. Every leg is a bare ML-KEM ciphertext plus an X25519 DH against the shipped connection ephemerals: ee for forward secrecy, es and se proving the two statics. The statics pass late and hidden: s_bob inside msg2's inner box, s_alice inside msg3's. Each box key lags one secret behind the proof inside it, so invalid data inside an authentic box burns the credential, while an AEAD failure proves no key knowledge and changes nothing:

msg1  A->B:  box(e_alice | c)                   k1 = kdf(reg/msg1, shared)
msg2  B->A:  box(e_bob | ee_ct | box'(s_bob))   k2 = kdf(reg/msg2, shared)
                                                k' = kdf(reg/msg2/inner, ee)
msg3  A->B:  box(es_ct | box'(s_alice | r))     k3 = kdf(reg/msg3, shared, ee)
                                                k' = kdf(reg/msg3/inner, es)
msg4  B->A:  box(se_ct | tb)                    k4 = kdf(reg/msg4, shared, ee)
msg5  A->B:  box(ta)                            k5 = kdf(reg/msg5, shared, ee, es)

c = kdf(commit, e_alice, r)   r = random reveal, chosen with e_alice
ee = Encap(e_alice)   forward secrecy
es = Encap(s_bob)     challenges bob's static
se = Encap(s_alice)   challenges alice's static

root = kdf(reg/root, shared, ee, es, se)
tb = kdf(reg/confirm-b, root)   proves bob decapsulated es_ct
ta = kdf(reg/confirm-a, root)   proves alice decapsulated se_ct
sas = kdf(sas, shared, ee, r, length=4)  -- compared out of band

shared' = kdf(rotate, root),  send/recv = kdf(a2b | b2a, root)

e_alice, s_alice, s_bob are hybrid public keys; e_bob is a bare X25519 ephemeral public. Every leg's shared secret is ML-KEM ss || DH ss, the DH made against the shipped connection ephemerals. The inner boxes key only on the secret they actually protect: the outer box already binds shared and ee, so there is no use in repeating them in the inner key.

The exchange's tail is gated on the out-of-band comparison: msg4 is released only after the responder's answer, msg5 only after the initiator's, and a rejection ends the exchange right there. The key change happens at the very end for each side -- the initiator at her answer, the responder when msg5 completes (see design.md).

The comparison (sas)

The sas binds three things: the registration secret, the per-connection ee, and the initiator's hidden reveal -- nothing later. That is deliberate: it can be computed while the exchange is still running, so a comparison dialog may be shown early while only the final messages wait on the answers.

The reveal is committed in msg1 (c above) and disclosed only in msg3, checked against c. Without the commitment a MITM who knows the registration secret and runs independent legs with both parties would learn bob's digest first and grind his own material offline until the short digests match. With it, bob's digest is pinned to the commitment he received before the reveal could be known: it can neither be predicted nor steered.

And ee must be in the digest: it is contributed by the responder's encapsulation after the initiator has committed. Without it, a MITM could instead complete a leg with alice, steal her reveal, and re-commit it toward bob in a fresh leg -- the digests would match although there is no shared session. With ee the two legs' digests differ and cannot be aligned: matching them means guessing ee before it exists, and every such attempt costs a fresh online connection.

Resumption

Statics PROVEN, never on the wire; all legs hybrid:

msg1  A->B:  Box_k1(e_alice | sb_ct)              k1 = kdf(res/msg1, shared)
msg2  B->A:  Box_k2(e_bob | sa_ct | ee_ct | tb)   k2 = kdf(res/msg2, shared, sb)
msg3  A->B:  Box_k3(ta)                           k3 = kdf(res/msg3, shared, sb, sa, ee)

tb = kdf(res/tag-b, sb, sa, ee)   ta = kdf(res/tag-a, sb, sa, ee)
key = kdf(res/session-key, shared, sb, sa, ee)
    -> send/recv = kdf(a2b | b2a, key)

sb_ct = Encap(s_bob)    authenticates bob (to alice, via tb)
sa_ct = Encap(s_alice)  authenticates alice (to bob, via ta)
ee_ct = Encap(e_alice)  forward secrecy

With the statics pre-shared, challenges and answers fit into msg1..3, so possession is proven by the per-leg tags tb/ta directly, without registration's extra tail messages.

Traffic

After establishment, traffic boxes come from counter-nonce channels (chab3 Sender/Receiver) keyed at completion; nothing rides outside the encryption, and no traffic flows before establishment -- the state machine rejects it. One kind byte heads every traffic box's plaintext; see design.md for the kind namespace.

Failures

Invalid data inside an otherwise authentic box (bad kdf tags, a bad commitment reveal, low-order points, malformed bodies) proves the sender knew the keys and misbehaved; the credential burns (see design.md). AEAD failures prove no key knowledge and change nothing.