Initial commit

This commit is contained in:
LeoVasanko committed 2026-10-07 03:07:34 +00:00
commit 03a7758fa6
24 files changed
+3408

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
# 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 and capsules are
rejected explicitly.
An encapsulation's output is a hybrid CAPSULE: an ML-KEM ciphertext ||
a fresh X25519 ephemeral public key (the X25519 "encapsulation" is a
DH, and its public half rides inside the capsule). The only ephemeral
public key shipped separately is alice's e_alice in msg1 -- bob must
receive it before he can encapsulate to it; every other leg is an
encapsulation and brings its own ephemeral along.
## 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 the same hybrid KEM operation: ee_cap
gives forward secrecy, es_cap and se_cap prove 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(ee_cap | box'(s_bob)) k2 = kdf(reg/msg2, shared)
k' = kdf(reg/msg2/inner, ee)
msg3 A->B: box(es_cap | box'(s_alice | r)) k3 = kdf(reg/msg3, shared, ee)
k' = kdf(reg/msg3/inner, es)
msg4 B->A: box(se_cap | 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_cap
ta = kdf(reg/confirm-a, root) proves alice decapsulated se_cap
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; the only ephemeral
public shipped is alice's e_alice (the responder needs none of his
own: every leg is an encapsulation, which already carries a fresh
ephemeral inside the capsule). 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_cap) k1 = kdf(res/msg1, shared)
msg2 B->A: Box_k2(sa_cap | ee_cap | 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_cap = Encap(s_bob) authenticates bob (to alice, via tb)
sa_cap = Encap(s_alice) authenticates alice (to bob, via ta)
ee_cap = Encap(e_alice) forward secrecy
Bob ships no ephemeral of his own here: no leg references it (ee is an
encapsulation to alice's ephemeral, whose fresh X25519 public half
already rides inside ee_cap). 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.