Split hybrid public keys into separate wire fields

The packed 1216-byte hybrid public had no consumer that wanted it
whole -- everything unpacked immediately. Message layouts and the
credential record now carry peer_m | peer_x as explicit fields; _PUB
and the unpack-at-use idiom are gone. credential_id values change with
the record layout. Step comments in the exchange methods explain what
each solve/challenge proves.
This commit is contained in:
LeoVasanko committed 2026-10-07 17:59:13 +00:00
1 parent 168287ca98
commit f035859fd2
5 files changed
+92 -73

No files matched your search

+72 -54
View File
@@ -25,16 +25,18 @@ from .kdf import kdf
# -- fixed-size record layouts (struct handles packing + length checks) -----
# hybrid scheme = ML-KEM-768 (pk 1184 / sk-seed 64 / ct 1088) || X25519 (32)
_PUB = struct.Struct("1184s32s") # hybrid public key: 1216
_TAG = struct.Struct("32s") # bare kdf tag
# handshake message bodies; box(N) is a chab3 box of N plaintext bytes (N+28)
_MSG1_REGISTER = struct.Struct("1216s32s") # e_alice | commitment
_MSG2_REGISTER = struct.Struct("32s1088s1244s") # e_bob | ee_ct | box(s_bob)
_MSG3_REGISTER = struct.Struct("1088s1276s") # es_ct | box(s_alice | reveal)
_MSG3_REGISTER_INNER = struct.Struct("1216s32s") # s_alice | reveal
_MSG1_REGISTER = struct.Struct("1184s32s32s") # e_alice_m | e_alice_x | commitment
_MSG2_REGISTER = struct.Struct(
"32s1088s1244s"
) # e_bob | ee_ct | box(s_bob_m | s_bob_x)
_MSG2_REGISTER_INNER = struct.Struct("1184s32s") # s_bob_m | s_bob_x
_MSG3_REGISTER = struct.Struct("1088s1276s") # es_ct | box(s_alice_m | s_alice_x | r)
_MSG3_REGISTER_INNER = struct.Struct("1184s32s32s") # s_alice_m | s_alice_x | reveal
_MSG4_REGISTER = struct.Struct("1088s32s") # se_ct | tb
_MSG1_RESUME = struct.Struct("1216s1088s") # e_alice | sb_ct
_MSG1_RESUME = struct.Struct("1184s32s1088s") # e_alice_m | e_alice_x | sb_ct
_MSG2_RESUME = struct.Struct("32s1088s1088s32s") # e_bob | sa_ct | ee_ct | tb
# msg5 = _TAG (ta); msg3 resumption = _TAG (ta)
@@ -67,23 +69,23 @@ def _x25519_private_key(idroot: bytes) -> X25519PrivateKey:
return X25519PrivateKey.from_private_bytes(kdf("idroot/x25519", idroot))
def _public_from_idroot(idroot: bytes) -> bytes:
return _PUB.pack(
def _public_from_idroot(idroot: bytes) -> tuple[bytes, bytes]:
# (mlkem public, x25519 public) derived from the idroot
return (
_mlkem_private_key(idroot).public_key().public_bytes_raw(),
_x25519_private_key(idroot).public_key().public_bytes_raw(),
)
def _fresh_ephemeral() -> tuple[MLKEM768PrivateKey, X25519PrivateKey, bytes]:
# (mlkem key, x25519 key, packed public) for a fresh connection ephemeral
def _fresh_ephemeral() -> tuple[MLKEM768PrivateKey, X25519PrivateKey, bytes, bytes]:
# (mlkem key, x25519 key, mlkem public, x25519 public), fresh per connection
m_sk = MLKEM768PrivateKey.generate()
x_sk = X25519PrivateKey.generate()
return (
m_sk,
x_sk,
_PUB.pack(
m_sk.public_key().public_bytes_raw(), x_sk.public_key().public_bytes_raw()
),
m_sk.public_key().public_bytes_raw(),
x_sk.public_key().public_bytes_raw(),
)
@@ -138,9 +140,9 @@ class _Chain:
class Registration:
"""Registration exchange (first connection): every leg is a bare
ML-KEM ciphertext plus an X25519 DH against the shipped connection
ephemerals (e_alice in msg1, e_bob in msg2), so related secrets
provably come from the same party. finish() -> (send, recv, rotated
shared, sas, peer pub)."""
keys (e_alice in msg1, e_bob in msg2), so related secrets provably
come from the same party. finish() -> (send, recv, rotated shared,
sas, peer pub)."""
def __init__(self, shared: bytes, idroot: bytes, ad: bytes):
self._shared = shared
@@ -148,7 +150,7 @@ class Registration:
# static key objects, derived once from the persisted idroot
self._mlkem = _mlkem_private_key(idroot)
self._x25519 = _x25519_private_key(idroot)
self._static_pub = _public_from_idroot(idroot)
self._static_m, self._static_x = _public_from_idroot(idroot)
self._chain = _Chain(ad)
def _root(self) -> bytes:
@@ -157,35 +159,40 @@ class Registration:
# -- alice ----------------------------------------------------------------
def startA(self) -> bytes:
# msg1: the ephemeral key, committed under a reveal hidden until msg3
self._eph_m, self._ephemeral, e_alice = _fresh_ephemeral()
# msg1: our connection keys, with the reveal committed so it
# cannot be predicted or steered
self._eph_m, self._ephemeral, e_alice_m, e_alice_x = _fresh_ephemeral()
self._reveal = random(32)
body = _MSG1_REGISTER.pack(e_alice, kdf("commit", e_alice, self._reveal))
commitment = kdf("commit", e_alice_m, e_alice_x, self._reveal)
body = _MSG1_REGISTER.pack(e_alice_m, e_alice_x, commitment)
return self._chain.encrypt(body, key=kdf("reg/msg1", self._shared))
def respondA(self, msg2: bytes) -> bytes:
# open bob's reply -- an outer AEAD failure burns nothing, it
# proves no key knowledge -- then the static inside its inner
# box, and challenge it
# proves no key knowledge -- then challenge his static
plain = self._chain.decrypt(msg2, key=kdf("reg/msg2", self._shared))
try:
e_bob, ee_ct, inner = _MSG2_REGISTER.unpack(plain)
# ee is forward secrecy, made to our connection key only
ee = self._eph_m.decapsulate(ee_ct)
ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_bob))
s_bob = decrypt(inner, key=kdf("reg/msg2/inner", ee))
s_bob_m, s_bob_x = _PUB.unpack(s_bob)
# solving ee lets us read bob's static from the inner box
s_bob_m, s_bob_x = _MSG2_REGISTER_INNER.unpack(
decrypt(inner, key=kdf("reg/msg2/inner", ee))
)
# we challenge his static: only the real bob can solve this
es, es_ct = MLKEM768PublicKey.from_public_bytes(s_bob_m).encapsulate()
es += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(s_bob_x))
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg2 contents ({e})") from None
self._peer_pub = s_bob
self._peer_pub = (s_bob_m, s_bob_x)
self._e_bob = e_bob
self._ee = ee
self._es = es
return self._chain.encrypt(
es_ct
+ encrypt(
_MSG3_REGISTER_INNER.pack(self._static_pub, self._reveal),
_MSG3_REGISTER_INNER.pack(self._static_m, self._static_x, self._reveal),
key=kdf("reg/msg3/inner", es),
),
key=kdf("reg/msg3", self._shared, ee),
@@ -197,6 +204,7 @@ class Registration:
plain = self._chain.decrypt(msg4, key=kdf("reg/msg4", self._shared, self._ee))
try:
se_ct, tb = _MSG4_REGISTER.unpack(plain)
# solving se proves to bob that we hold alice's static
se = self._mlkem.decapsulate(se_ct)
se += self._x25519.exchange(X25519PublicKey.from_public_bytes(self._e_bob))
except (struct.error, ValueError) as e:
@@ -217,22 +225,25 @@ class Registration:
def respondB(self, msg1: bytes, opened: bytes) -> bytes:
# msg2 from an opened msg1 (open_hello did the caller scan); our
# connection x25519 ephemeral ships here, once
# connection key ships here once, alice answers every leg with it
self._chain.advance(msg1)
try:
e_alice, commitment = _MSG1_REGISTER.unpack(opened)
e_alice_m, e_alice_x, commitment = _MSG1_REGISTER.unpack(opened)
self._ephemeral = X25519PrivateKey.generate()
e_bob = self._ephemeral.public_key().public_bytes_raw()
e_alice_m, e_alice_x = _PUB.unpack(e_alice)
# forward secrecy, made to alice's connection key only
ee, ee_ct = MLKEM768PublicKey.from_public_bytes(e_alice_m).encapsulate()
ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg1 contents ({e})") from None
self._commitment = (e_alice, commitment)
self._commitment = (e_alice_m, e_alice_x, commitment)
body = _MSG2_REGISTER.pack(
e_bob,
ee_ct,
encrypt(self._static_pub, key=kdf("reg/msg2/inner", ee)),
encrypt(
_MSG2_REGISTER_INNER.pack(self._static_m, self._static_x),
key=kdf("reg/msg2/inner", ee),
),
)
self._ee = ee
return self._chain.encrypt(body, key=kdf("reg/msg2", self._shared))
@@ -243,24 +254,27 @@ class Registration:
plain = self._chain.decrypt(msg3, key=kdf("reg/msg3", self._shared, self._ee))
try:
es_ct, inner = _MSG3_REGISTER.unpack(plain)
e_alice, commitment = self._commitment
e_alice_x = _PUB.unpack(e_alice)[1]
es = self._mlkem.decapsulate(es_ct) # proves our static
e_alice_m, e_alice_x, commitment = self._commitment
# Solving es proves to Alice that the static key they encapsulated to was ours
es = self._mlkem.decapsulate(es_ct)
es += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
s_alice, reveal = _MSG3_REGISTER_INNER.unpack(
# This enables us to get Alice's static keys and reveal
s_alice_m, s_alice_x, reveal = _MSG3_REGISTER_INNER.unpack(
decrypt(inner, key=kdf("reg/msg3/inner", es))
)
s_alice_m, s_alice_x = _PUB.unpack(s_alice)
# We send Alice the same test, verifying they have s_alice secrets
se, se_ct = MLKEM768PublicKey.from_public_bytes(s_alice_m).encapsulate()
se += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(s_alice_x))
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg3 contents ({e})") from None
_check(
hmac.compare_digest(commitment, kdf("commit", e_alice, reveal)),
hmac.compare_digest(
commitment, kdf("commit", e_alice_m, e_alice_x, reveal)
),
"commitment reveal mismatch",
)
self._reveal = reveal
self._peer_pub = s_alice
self._peer_pub = (s_alice_m, s_alice_x)
self._es = es
self._se = se
sas = kdf("sas", self._shared, self._ee, reveal, length=4)
@@ -271,7 +285,7 @@ class Registration:
return msg4, sas
def finishB(self, msg5: bytes) -> None:
# alice's root confirmation: proves she decapsulated se_ct
# alice's root confirmation: proves she solved our se challenge
plain = self._chain.decrypt(
msg5, key=kdf("reg/msg5", self._shared, self._ee, self._es)
)
@@ -286,7 +300,7 @@ class Registration:
# -- completion -------------------------------------------------------------
def finish(self, initiator: bool) -> tuple[bytes, bytes, bytes, bytes, bytes]:
def finish(self, initiator: bool) -> tuple[bytes, bytes, bytes, bytes, tuple]:
# -> (send, recv, rotated shared, sas, peer pub); the rotation is
# held by the caller until the store swap
root = self._root()
@@ -300,34 +314,36 @@ class Resumption:
the wire; all legs hybrid. Alice: startA/respondA; bob:
respondB/completeB; finish() -> (send, recv)."""
def __init__(self, shared: bytes, idroot: bytes, peer_pub: bytes, ad: bytes):
def __init__(self, shared: bytes, idroot: bytes, peer_pub: tuple, ad: bytes):
self._shared = shared
self._idroot = idroot
self._mlkem = _mlkem_private_key(idroot)
self._x25519 = _x25519_private_key(idroot)
self._peer_pub = peer_pub # proven by decapsulation
self._peer_pub = peer_pub # (mlkem public, x25519 public)
self._chain = _Chain(ad)
# -- alice ----------------------------------------------------------------
def startA(self) -> bytes:
# msg1: the ephemeral key plus an encapsulation to bob's static
self._eph_m, self._ephemeral, e_alice = _fresh_ephemeral()
peer_m, peer_x = _PUB.unpack(self._peer_pub)
# msg1: our connection keys plus a challenge to bob's static
self._eph_m, self._ephemeral, e_alice_m, e_alice_x = _fresh_ephemeral()
peer_m, peer_x = self._peer_pub
sb, sb_ct = MLKEM768PublicKey.from_public_bytes(peer_m).encapsulate()
sb += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(peer_x))
self._sb = sb
body = _MSG1_RESUME.pack(e_alice, sb_ct)
body = _MSG1_RESUME.pack(e_alice_m, e_alice_x, sb_ct)
return self._chain.encrypt(body, key=kdf("res/msg1", self._shared))
def respondA(self, msg2: bytes) -> bytes:
# open bob's reply, decapsulate all three legs
# open bob's reply and solve his three challenges
plain = self._chain.decrypt(msg2, key=kdf("res/msg2", self._shared, self._sb))
try:
e_bob, sa_ct, ee_ct, tb = _MSG2_RESUME.unpack(plain)
sa = self._mlkem.decapsulate(sa_ct) # proves our static
# solving sa proves to bob that we hold alice's static
sa = self._mlkem.decapsulate(sa_ct)
sa += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_bob))
ee = self._eph_m.decapsulate(ee_ct) # forward secrecy
# ee is forward secrecy, made to our connection key
ee = self._eph_m.decapsulate(ee_ct)
ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_bob))
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg2 contents ({e})") from None
@@ -345,18 +361,20 @@ class Resumption:
# -- bob ------------------------------------------------------------------
def respondB(self, msg1: bytes, opened: bytes) -> bytes:
# prove our static, prove alice's, forward secrecy
# prove our static, challenge alice's, forward secrecy
self._chain.advance(msg1)
try:
e_alice, sb_ct = _MSG1_RESUME.unpack(opened)
e_alice_m, e_alice_x = _PUB.unpack(e_alice)
sb = self._mlkem.decapsulate(sb_ct) # proves our static
e_alice_m, e_alice_x, sb_ct = _MSG1_RESUME.unpack(opened)
# solving sb proves to alice that we hold bob's static
sb = self._mlkem.decapsulate(sb_ct)
sb += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
self._ephemeral = X25519PrivateKey.generate()
e_bob = self._ephemeral.public_key().public_bytes_raw()
peer_m, peer_x = _PUB.unpack(self._peer_pub)
peer_m, peer_x = self._peer_pub
# we challenge alice's static the same way
sa, sa_ct = MLKEM768PublicKey.from_public_bytes(peer_m).encapsulate()
sa += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(peer_x))
# and make forward secrecy to her connection key
ee, ee_ct = MLKEM768PublicKey.from_public_bytes(e_alice_m).encapsulate()
ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
except (struct.error, ValueError) as e: