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
+55 -72

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
surfaces as `ProtocolError`; the application action is always the
same -- log and disconnect. The conversion happens at one point, the
Session dispatch; the burn policy is already decided at the internal
raise sites (handshake.py raises `_Burn` for invalid data inside a valid
box; chab3's ValueError means an unauthentic box) before anything
escapes. Genuine bugs (TypeError, AttributeError) propagate untouched;
chab3 raises ValueError for bad wire data and TypeError only for program
bugs (too-small output buffers). A corrupt LOCAL store entry raises
Session dispatch, which also owns the burn policy: chab3's WireError
(unauthentic or truncated box) proves no key knowledge and burns
nothing; any other ValueError/struct.error means invalid data inside a
validly opened box -- the peer knew the keys and misbehaved, so the
credential burns. handshake.py therefore raises no special types; its
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
problem.
-5
View File
@@ -4,8 +4,3 @@ applications can import it without the sans-io machinery."""
class ProtocolError(Exception):
"""Peer, wire, or protocol-misuse failure: log and disconnect."""
class _Burn(Exception):
# internal handshake -> session signal: the credential must burn
pass
+32 -50
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
state, disposed by the caller at completion; sansio.py owns the state
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
@@ -19,8 +25,7 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PublicKey,
)
from .chab3 import decrypt, encrypt, random
from .exceptions import _Burn
from .chab3 import WireError, decrypt, encrypt, random
from .kdf import kdf
# -- 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)
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:
# 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)
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
@@ -148,20 +158,17 @@ class Registration:
# Open Bob's reply -- an outer AEAD failure burns nothing, 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 = REG_MSG2.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))
# Solving ee lets us read Bob's static from the inner box
s_bob_m, s_bob_x = REG_MSG2_INNER.unpack(
decrypt(inner, key=kdf("reg/msg2/inner", ee))
_open_inner(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_m, s_bob_x)
self._e_bob = e_bob
self._ee = ee
@@ -179,18 +186,13 @@ class Registration:
# Bob's challenge to our static plus his root confirmation. The
# caller gates the answer (msg5) on the out-of-band comparison.
plain = self._chain.decrypt(msg4, key=kdf("reg/msg4", self._shared, self._ee))
try:
se_ct, tb = REG_MSG4.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:
raise _Burn(f"bad msg4 contents ({e})") from None
self._se = se
_check(
hmac.compare_digest(tb, kdf("reg/confirm-b", self._root())),
"responder tag mismatch",
)
if not hmac.compare_digest(tb, kdf("reg/confirm-b", self._root())):
raise ValueError("responder tag mismatch")
sas = kdf("sas", self._shared, self._ee, self._reveal, length=4)
msg5 = self._chain.encrypt(
kdf("reg/confirm-a", self._root()),
@@ -204,15 +206,12 @@ class Registration:
# msg2 from an opened msg1 (open_hello did the caller scan); our
# connection key ships here once, Alice answers every leg with it
self._chain.advance(msg1)
try:
e_alice_m, e_alice_x, commitment = REG_MSG1.unpack(opened)
self._ephemeral = X25519PrivateKey.generate()
e_bob = self._ephemeral.public_key().public_bytes_raw()
# 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_m, e_alice_x, commitment)
body = REG_MSG2.pack(
e_bob,
@@ -229,7 +228,6 @@ class Registration:
# Alice's challenge to our static, her static and her reveal. The
# caller gates the answer (msg4) on the out-of-band comparison.
plain = self._chain.decrypt(msg3, key=kdf("reg/msg3", self._shared, self._ee))
try:
es_ct, inner = REG_MSG3.unpack(plain)
e_alice_m, e_alice_x, commitment = self._commitment
# Solving es proves to Alice that the static key they encapsulated to was ours
@@ -237,19 +235,15 @@ class Registration:
es += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_alice_x))
# This enables us to get Alice's static keys and reveal
s_alice_m, s_alice_x, reveal = REG_MSG3_INNER.unpack(
decrypt(inner, key=kdf("reg/msg3/inner", es))
_open_inner(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(
if not hmac.compare_digest(
commitment, kdf("commit", e_alice_m, e_alice_x, reveal)
),
"commitment reveal mismatch",
)
):
raise ValueError("commitment reveal mismatch")
self._reveal = reveal
self._peer_pub = (s_alice_m, s_alice_x)
self._es = es
@@ -267,12 +261,10 @@ class Registration:
msg5, key=kdf("reg/msg5", self._shared, self._ee, self._es)
)
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
_check(
hmac.compare_digest(ta, kdf("reg/confirm-a", self._root())),
"initiator tag mismatch",
)
if not hmac.compare_digest(ta, kdf("reg/confirm-a", self._root())):
raise ValueError("initiator tag mismatch")
# -- completion -------------------------------------------------------------
@@ -313,7 +305,6 @@ class Resumption:
def respondA(self, msg2: bytes) -> bytes:
# 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 = RES_MSG2.unpack(plain)
# Solving sa proves to Bob that we hold Alice's static
sa = self._mlkem.decapsulate(sa_ct)
@@ -321,12 +312,8 @@ class Resumption:
# 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
_check(
hmac.compare_digest(tb, kdf("res/tag-b", self._sb, sa, ee)),
"responder tag mismatch",
)
if not hmac.compare_digest(tb, kdf("res/tag-b", self._sb, sa, ee)):
raise ValueError("responder tag mismatch")
self._sa = sa
self._ee = ee
return self._chain.encrypt(
@@ -339,7 +326,6 @@ class Resumption:
def respondB(self, msg1: bytes, opened: bytes) -> bytes:
# Prove our static, challenge Alice's, forward secrecy
self._chain.advance(msg1)
try:
e_alice_m, e_alice_x, sb_ct = RES_MSG1.unpack(opened)
# Solving sb proves to Alice that we hold Bob's static
sb = self._mlkem.decapsulate(sb_ct)
@@ -353,8 +339,6 @@ class Resumption:
# 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:
raise _Burn(f"bad msg1 contents ({e})") from None
self._sb = sb
self._sa = sa
self._ee = ee
@@ -366,10 +350,8 @@ class Resumption:
ta = self._chain.decrypt(
msg3, key=kdf("res/msg3", self._shared, self._sb, self._sa, self._ee)
)
_check(
hmac.compare_digest(ta, kdf("res/tag-a", self._sb, self._sa, self._ee)),
"initiator tag mismatch",
)
if not hmac.compare_digest(ta, kdf("res/tag-a", self._sb, self._sa, self._ee)):
raise ValueError("initiator tag mismatch")
# -- completion -------------------------------------------------------------
+9 -8
View File
@@ -10,8 +10,8 @@ import struct
from collections.abc import MutableMapping
from dataclasses import dataclass
from .chab3 import Receiver, Sender, random
from .exceptions import ProtocolError, _Burn
from .chab3 import Receiver, Sender, WireError, random
from .exceptions import ProtocolError
from .handshake import Registration, Resumption, open_hello
from .kdf import kdf
@@ -156,15 +156,16 @@ class Session:
out = self._st_data(msg)
case _:
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
# the keys and misbehaved -- retire the credential
self._delete_credential()
_log.debug("fail state=%s: credential burned: %s", self.state, b)
raise ProtocolError(f"credential burned: {b}") 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("fail state=%s: credential burned: %r", self.state, e)
raise ProtocolError("credential burned") from None
_log.debug(
"emit state=%s results=[%s]",
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
y._exchange._reveal = secrets.token_bytes(32) # Y lies about its reveal
(m3y,) = y(m2y)
with pytest.raises(ProtocolError, match="commitment"):
with pytest.raises(ProtocolError, match="burned"):
sy(m3y)
assert len(srv["alice"]) == _CREDENTIAL.size # winner intact, no burn
@@ -650,7 +650,7 @@ def test_commitment_reveal_checked(psk):
(m2,) = b(m1)
a._exchange._reveal = secrets.token_bytes(32) # alice lies about her reveal
(m3,) = a(m2)
with pytest.raises(ProtocolError, match="commitment"):
with pytest.raises(ProtocolError, match="burned"):
b(m3)
assert b._store == {} # burned
@@ -741,7 +741,7 @@ def test_foreign_mlkem_static_rejected_at_msg4(psk):
(m3,) = a(m2)
(vb,) = b(m3)
(m4,) = b(vb)
with pytest.raises(ProtocolError, match="responder tag"):
with pytest.raises(ProtocolError, match="burned"):
a(m4)
assert a._store == {} # burned