Initial commit
This commit is contained in:
commit
03a7758fa6
24 files changed
+3408
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user