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

163 lines
7.6 KiB
Markdown

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