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:
1 parent
326af72a25
commit
2f8cafaa6d
5 files changed
+55
-72
No files matched your search
+11
-6
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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 -------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user