164 lines
7.7 KiB
Markdown
164 lines
7.7 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 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.
|