Flip burn policy: remove _Burn, burn on any non-WireError failure

The burn decision now lives solely in the Session dispatch: chab3's
WireError (unauthentic or truncated box) proves no key knowledge and
burns nothing; any other ValueError/struct.error means a peer who
opened the box misbehaved, so the credential burns. handshake.py drops
its try/except wrappers and the _check helper; inner-box WireErrors
are reclassified to plain ValueError since they occur inside an
already valid outer box.
This commit is contained in:
LeoVasanko committed 2026-10-07 19:59:56 +00:00
1 parent 326af72a25
commit 2f8cafaa6d
5 files changed
+106 -123

No files matched your search

+11 -6
View File
@@ -105,12 +105,17 @@ transports don't reimplement it.
Everything attributable to the peer, the wire, or protocol-level misuse Everything attributable to the peer, the wire, or protocol-level misuse
surfaces as `ProtocolError`; the application action is always the surfaces as `ProtocolError`; the application action is always the
same -- log and disconnect. The conversion happens at one point, the same -- log and disconnect. The conversion happens at one point, the
Session dispatch; the burn policy is already decided at the internal Session dispatch, which also owns the burn policy: chab3's WireError
raise sites (handshake.py raises `_Burn` for invalid data inside a valid (unauthentic or truncated box) proves no key knowledge and burns
box; chab3's ValueError means an unauthentic box) before anything nothing; any other ValueError/struct.error means invalid data inside a
escapes. Genuine bugs (TypeError, AttributeError) propagate untouched; validly opened box -- the peer knew the keys and misbehaved, so the
chab3 raises ValueError for bad wire data and TypeError only for program credential burns. handshake.py therefore raises no special types; its
bugs (too-small output buffers). A corrupt LOCAL store entry raises one exception is the inner boxes, whose WireError it reclassifies to a
plain ValueError because an inner box sits inside an already valid
outer one. Genuine bugs (TypeError, AttributeError) propagate
untouched; chab3 raises WireError (a ValueError) for bad wire data and
TypeError only for program bugs (too-small output buffers). A corrupt
LOCAL store entry raises
KeyError/struct.error from the constructor -- the caller's own data KeyError/struct.error from the constructor -- the caller's own data
problem. problem.
-5
View File
@@ -4,8 +4,3 @@ applications can import it without the sans-io machinery."""
class ProtocolError(Exception): class ProtocolError(Exception):
"""Peer, wire, or protocol-misuse failure: log and disconnect.""" """Peer, wire, or protocol-misuse failure: log and disconnect."""
class _Burn(Exception):
# internal handshake -> session signal: the credential must burn
pass
+83 -101
View File
@@ -3,6 +3,12 @@ symmetric shared key, chab3-boxed throughout. Pure cryptography: a
Registration or Resumption object holds one exchange's per-connection Registration or Resumption object holds one exchange's per-connection
state, disposed by the caller at completion; sansio.py owns the state state, disposed by the caller at completion; sansio.py owns the state
machine, key store and policies. See docs/protocol.md. machine, key store and policies. See docs/protocol.md.
Error contract: chab3's WireError (unauthentic or truncated box)
escapes untouched -- it proves no key knowledge. Everything else
(struct.error, ValueError) means invalid data inside a validly opened
box: the peer knew the keys and misbehaved. sansio.py burns the
credential on anything that is not a WireError.
""" """
from __future__ import annotations from __future__ import annotations
@@ -19,8 +25,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PublicKey, X25519PublicKey,
) )
from .chab3 import decrypt, encrypt, random from .chab3 import WireError, decrypt, encrypt, random
from .exceptions import _Burn
from .kdf import kdf from .kdf import kdf
# -- Fixed-size record layouts (struct handles packing + length checks) ----- # -- Fixed-size record layouts (struct handles packing + length checks) -----
@@ -73,17 +78,22 @@ def _traffic_keys(key: bytes, initiator: bool) -> tuple[bytes, bytes]:
return (a2b, b2a) if initiator else (b2a, a2b) return (a2b, b2a) if initiator else (b2a, a2b)
def _check(ok: bool, why: str) -> None:
if not ok:
raise _Burn(why)
def open_hello(msg1: bytes, *, shared: bytes, ad: bytes, flow: str) -> bytes: def open_hello(msg1: bytes, *, shared: bytes, ad: bytes, flow: str) -> bytes:
# Open a msg1 box under a candidate credential's shared key (the # Open a msg1 box under a candidate credential's shared key (the
# responder's caller scan); ValueError = not this credential # responder's caller scan); WireError = not this credential
return decrypt(msg1, key=kdf(flow + "/msg1", shared), ad=ad) return decrypt(msg1, key=kdf(flow + "/msg1", shared), ad=ad)
def _open_inner(ct: bytes, *, key: bytes) -> bytes:
# An inner box sits inside an already valid outer box: a tag failure
# there came from a peer who proved key knowledge, so it is not a
# wire error -- reclassify to keep the burn decision in sansio
try:
return decrypt(ct, key=key)
except WireError as e:
raise ValueError("inner box authentication failed") from e
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# AD-chained boxes: each box's associated data is the previous box's tag # AD-chained boxes: each box's associated data is the previous box's tag
@@ -148,20 +158,17 @@ class Registration:
# Open Bob's reply -- an outer AEAD failure burns nothing, it # Open Bob's reply -- an outer AEAD failure burns nothing, it
# proves no key knowledge -- then challenge his static # proves no key knowledge -- then challenge his static
plain = self._chain.decrypt(msg2, key=kdf("reg/msg2", self._shared)) plain = self._chain.decrypt(msg2, key=kdf("reg/msg2", self._shared))
try: e_bob, ee_ct, inner = REG_MSG2.unpack(plain)
e_bob, ee_ct, inner = REG_MSG2.unpack(plain) # ee is forward secrecy, made to our connection key only
# ee is forward secrecy, made to our connection key only ee = self._eph_m.decapsulate(ee_ct)
ee = self._eph_m.decapsulate(ee_ct) ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_bob))
ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_bob)) # Solving ee lets us read Bob's static from the inner box
# Solving ee lets us read Bob's static from the inner box s_bob_m, s_bob_x = REG_MSG2_INNER.unpack(
s_bob_m, s_bob_x = REG_MSG2_INNER.unpack( _open_inner(inner, key=kdf("reg/msg2/inner", ee))
decrypt(inner, key=kdf("reg/msg2/inner", ee)) )
) # We challenge his static: only the real Bob can solve this
# We challenge his static: only the real Bob can solve this es, es_ct = MLKEM768PublicKey.from_public_bytes(s_bob_m).encapsulate()
es, es_ct = MLKEM768PublicKey.from_public_bytes(s_bob_m).encapsulate() es += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(s_bob_x))
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_m, s_bob_x) self._peer_pub = (s_bob_m, s_bob_x)
self._e_bob = e_bob self._e_bob = e_bob
self._ee = ee self._ee = ee
@@ -179,18 +186,13 @@ class Registration:
# Bob's challenge to our static plus his root confirmation. The # Bob's challenge to our static plus his root confirmation. The
# caller gates the answer (msg5) on the out-of-band comparison. # caller gates the answer (msg5) on the out-of-band comparison.
plain = self._chain.decrypt(msg4, key=kdf("reg/msg4", self._shared, self._ee)) plain = self._chain.decrypt(msg4, key=kdf("reg/msg4", self._shared, self._ee))
try: se_ct, tb = REG_MSG4.unpack(plain)
se_ct, tb = REG_MSG4.unpack(plain) # Solving se proves to Bob that we hold Alice's static
# Solving se proves to Bob that we hold Alice's static se = self._mlkem.decapsulate(se_ct)
se = self._mlkem.decapsulate(se_ct) se += self._x25519.exchange(X25519PublicKey.from_public_bytes(self._e_bob))
se += self._x25519.exchange(X25519PublicKey.from_public_bytes(self._e_bob))
except (struct.error, ValueError) as e:
raise _Burn(f"bad msg4 contents ({e})") from None
self._se = se self._se = se
_check( if not hmac.compare_digest(tb, kdf("reg/confirm-b", self._root())):
hmac.compare_digest(tb, kdf("reg/confirm-b", self._root())), raise ValueError("responder tag mismatch")
"responder tag mismatch",
)
sas = kdf("sas", self._shared, self._ee, self._reveal, length=4) sas = kdf("sas", self._shared, self._ee, self._reveal, length=4)
msg5 = self._chain.encrypt( msg5 = self._chain.encrypt(
kdf("reg/confirm-a", self._root()), kdf("reg/confirm-a", self._root()),
@@ -204,15 +206,12 @@ class Registration:
# msg2 from an opened msg1 (open_hello did the caller scan); our # msg2 from an opened msg1 (open_hello did the caller scan); our
# connection key ships here once, Alice answers every leg with it # connection key ships here once, Alice answers every leg with it
self._chain.advance(msg1) self._chain.advance(msg1)
try: e_alice_m, e_alice_x, commitment = REG_MSG1.unpack(opened)
e_alice_m, e_alice_x, commitment = REG_MSG1.unpack(opened) self._ephemeral = X25519PrivateKey.generate()
self._ephemeral = X25519PrivateKey.generate() e_bob = self._ephemeral.public_key().public_bytes_raw()
e_bob = self._ephemeral.public_key().public_bytes_raw() # Forward secrecy, made to Alice's connection key only
# Forward secrecy, made to Alice's connection key only ee, ee_ct = MLKEM768PublicKey.from_public_bytes(e_alice_m).encapsulate()
ee, ee_ct = MLKEM768PublicKey.from_public_bytes(e_alice_m).encapsulate() ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
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_m, e_alice_x, commitment) self._commitment = (e_alice_m, e_alice_x, commitment)
body = REG_MSG2.pack( body = REG_MSG2.pack(
e_bob, e_bob,
@@ -229,27 +228,22 @@ class Registration:
# Alice's challenge to our static, her static and her reveal. The # Alice's challenge to our static, her static and her reveal. The
# caller gates the answer (msg4) on the out-of-band comparison. # caller gates the answer (msg4) on the out-of-band comparison.
plain = self._chain.decrypt(msg3, key=kdf("reg/msg3", self._shared, self._ee)) plain = self._chain.decrypt(msg3, key=kdf("reg/msg3", self._shared, self._ee))
try: es_ct, inner = REG_MSG3.unpack(plain)
es_ct, inner = REG_MSG3.unpack(plain) e_alice_m, e_alice_x, commitment = self._commitment
e_alice_m, e_alice_x, commitment = self._commitment # Solving es proves to Alice that the static key they encapsulated to was ours
# Solving es proves to Alice that the static key they encapsulated to was ours es = self._mlkem.decapsulate(es_ct)
es = self._mlkem.decapsulate(es_ct) es += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
es += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_alice_x)) # This enables us to get Alice's static keys and reveal
# This enables us to get Alice's static keys and reveal s_alice_m, s_alice_x, reveal = REG_MSG3_INNER.unpack(
s_alice_m, s_alice_x, reveal = REG_MSG3_INNER.unpack( _open_inner(inner, key=kdf("reg/msg3/inner", es))
decrypt(inner, key=kdf("reg/msg3/inner", es))
)
# 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_m, e_alice_x, reveal)
),
"commitment reveal mismatch",
) )
# 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))
if not hmac.compare_digest(
commitment, kdf("commit", e_alice_m, e_alice_x, reveal)
):
raise ValueError("commitment reveal mismatch")
self._reveal = reveal self._reveal = reveal
self._peer_pub = (s_alice_m, s_alice_x) self._peer_pub = (s_alice_m, s_alice_x)
self._es = es self._es = es
@@ -267,12 +261,10 @@ class Registration:
msg5, key=kdf("reg/msg5", self._shared, self._ee, self._es) msg5, key=kdf("reg/msg5", self._shared, self._ee, self._es)
) )
if len(plain) != 32: if len(plain) != 32:
raise _Burn("bad msg5 contents (expected a 32-byte tag)") raise ValueError("bad msg5 contents (expected a 32-byte tag)")
ta = plain ta = plain
_check( if not hmac.compare_digest(ta, kdf("reg/confirm-a", self._root())):
hmac.compare_digest(ta, kdf("reg/confirm-a", self._root())), raise ValueError("initiator tag mismatch")
"initiator tag mismatch",
)
# -- completion ------------------------------------------------------------- # -- completion -------------------------------------------------------------
@@ -313,20 +305,15 @@ class Resumption:
def respondA(self, msg2: bytes) -> bytes: def respondA(self, msg2: bytes) -> bytes:
# Open Bob's reply and solve his three challenges # Open Bob's reply and solve his three challenges
plain = self._chain.decrypt(msg2, key=kdf("res/msg2", self._shared, self._sb)) plain = self._chain.decrypt(msg2, key=kdf("res/msg2", self._shared, self._sb))
try: e_bob, sa_ct, ee_ct, tb = RES_MSG2.unpack(plain)
e_bob, sa_ct, ee_ct, tb = RES_MSG2.unpack(plain) # Solving sa proves to Bob that we hold Alice's static
# Solving sa proves to Bob that we hold Alice's static sa = self._mlkem.decapsulate(sa_ct)
sa = self._mlkem.decapsulate(sa_ct) sa += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_bob))
sa += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_bob)) # ee is forward secrecy, made to our connection key
# ee is forward secrecy, made to our connection key ee = self._eph_m.decapsulate(ee_ct)
ee = self._eph_m.decapsulate(ee_ct) ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_bob))
ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_bob)) if not hmac.compare_digest(tb, kdf("res/tag-b", self._sb, sa, ee)):
except (struct.error, ValueError) as e: raise ValueError("responder tag mismatch")
raise _Burn(f"bad msg2 contents ({e})") from None
_check(
hmac.compare_digest(tb, kdf("res/tag-b", self._sb, sa, ee)),
"responder tag mismatch",
)
self._sa = sa self._sa = sa
self._ee = ee self._ee = ee
return self._chain.encrypt( return self._chain.encrypt(
@@ -339,22 +326,19 @@ class Resumption:
def respondB(self, msg1: bytes, opened: bytes) -> bytes: def respondB(self, msg1: bytes, opened: bytes) -> bytes:
# Prove our static, challenge Alice's, forward secrecy # Prove our static, challenge Alice's, forward secrecy
self._chain.advance(msg1) self._chain.advance(msg1)
try: e_alice_m, e_alice_x, sb_ct = RES_MSG1.unpack(opened)
e_alice_m, e_alice_x, sb_ct = RES_MSG1.unpack(opened) # Solving sb proves to Alice that we hold Bob's static
# Solving sb proves to Alice that we hold Bob's static sb = self._mlkem.decapsulate(sb_ct)
sb = self._mlkem.decapsulate(sb_ct) sb += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
sb += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_alice_x)) self._ephemeral = X25519PrivateKey.generate()
self._ephemeral = X25519PrivateKey.generate() e_bob = self._ephemeral.public_key().public_bytes_raw()
e_bob = self._ephemeral.public_key().public_bytes_raw() peer_m, peer_x = self._peer_pub
peer_m, peer_x = self._peer_pub # We challenge Alice's static the same way
# We challenge Alice's static the same way sa, sa_ct = MLKEM768PublicKey.from_public_bytes(peer_m).encapsulate()
sa, sa_ct = MLKEM768PublicKey.from_public_bytes(peer_m).encapsulate() sa += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(peer_x))
sa += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(peer_x)) # And make forward secrecy to her connection key
# And make forward secrecy to her connection key ee, ee_ct = MLKEM768PublicKey.from_public_bytes(e_alice_m).encapsulate()
ee, ee_ct = MLKEM768PublicKey.from_public_bytes(e_alice_m).encapsulate() ee += self._ephemeral.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
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._sb = sb self._sb = sb
self._sa = sa self._sa = sa
self._ee = ee self._ee = ee
@@ -366,10 +350,8 @@ class Resumption:
ta = self._chain.decrypt( ta = self._chain.decrypt(
msg3, key=kdf("res/msg3", self._shared, self._sb, self._sa, self._ee) msg3, key=kdf("res/msg3", self._shared, self._sb, self._sa, self._ee)
) )
_check( if not hmac.compare_digest(ta, kdf("res/tag-a", self._sb, self._sa, self._ee)):
hmac.compare_digest(ta, kdf("res/tag-a", self._sb, self._sa, self._ee)), raise ValueError("initiator tag mismatch")
"initiator tag mismatch",
)
# -- completion ------------------------------------------------------------- # -- completion -------------------------------------------------------------
+9 -8
View File
@@ -10,8 +10,8 @@ import struct
from collections.abc import MutableMapping from collections.abc import MutableMapping
from dataclasses import dataclass from dataclasses import dataclass
from .chab3 import Receiver, Sender, random from .chab3 import Receiver, Sender, WireError, random
from .exceptions import ProtocolError, _Burn from .exceptions import ProtocolError
from .handshake import Registration, Resumption, open_hello from .handshake import Registration, Resumption, open_hello
from .kdf import kdf from .kdf import kdf
@@ -156,15 +156,16 @@ class Session:
out = self._st_data(msg) out = self._st_data(msg)
case _: case _:
raise ProtocolError(f"unexpected input in state {self.state}") raise ProtocolError(f"unexpected input in state {self.state}")
except _Burn as b: except WireError as e:
# unauthentic box: proves no key knowledge, nothing burns
_log.debug("fail state=%s: %r", self.state, e)
raise ProtocolError(str(e)) from None
except (ValueError, struct.error) as e:
# invalid data inside a validly opened box: the sender knew # invalid data inside a validly opened box: the sender knew
# the keys and misbehaved -- retire the credential # the keys and misbehaved -- retire the credential
self._delete_credential() self._delete_credential()
_log.debug("fail state=%s: credential burned: %s", self.state, b) _log.debug("fail state=%s: credential burned: %r", self.state, e)
raise ProtocolError(f"credential burned: {b}") from None raise ProtocolError("credential burned") from None
except (ValueError, struct.error) as e:
_log.debug("fail state=%s: %r", self.state, e)
raise ProtocolError(str(e)) from None
_log.debug( _log.debug(
"emit state=%s results=[%s]", "emit state=%s results=[%s]",
self.state, self.state,
+3 -3
View File
@@ -615,7 +615,7 @@ def test_loser_burn_spares_winner(psk):
sx(m5x) # X completes first: wins the swap sx(m5x) # X completes first: wins the swap
y._exchange._reveal = secrets.token_bytes(32) # Y lies about its reveal y._exchange._reveal = secrets.token_bytes(32) # Y lies about its reveal
(m3y,) = y(m2y) (m3y,) = y(m2y)
with pytest.raises(ProtocolError, match="commitment"): with pytest.raises(ProtocolError, match="burned"):
sy(m3y) sy(m3y)
assert len(srv["alice"]) == _CREDENTIAL.size # winner intact, no burn assert len(srv["alice"]) == _CREDENTIAL.size # winner intact, no burn
@@ -650,7 +650,7 @@ def test_commitment_reveal_checked(psk):
(m2,) = b(m1) (m2,) = b(m1)
a._exchange._reveal = secrets.token_bytes(32) # alice lies about her reveal a._exchange._reveal = secrets.token_bytes(32) # alice lies about her reveal
(m3,) = a(m2) (m3,) = a(m2)
with pytest.raises(ProtocolError, match="commitment"): with pytest.raises(ProtocolError, match="burned"):
b(m3) b(m3)
assert b._store == {} # burned assert b._store == {} # burned
@@ -741,7 +741,7 @@ def test_foreign_mlkem_static_rejected_at_msg4(psk):
(m3,) = a(m2) (m3,) = a(m2)
(vb,) = b(m3) (vb,) = b(m3)
(m4,) = b(vb) (m4,) = b(vb)
with pytest.raises(ProtocolError, match="responder tag"): with pytest.raises(ProtocolError, match="burned"):
a(m4) a(m4)
assert a._store == {} # burned assert a._store == {} # burned