diff --git a/docs/design.md b/docs/design.md index d7187a9..6f68004 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. diff --git a/quantumpipe/exceptions.py b/quantumpipe/exceptions.py index c74e383..eecb765 100644 --- a/quantumpipe/exceptions.py +++ b/quantumpipe/exceptions.py @@ -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 diff --git a/quantumpipe/handshake.py b/quantumpipe/handshake.py index a07620e..9af4c39 100644 --- a/quantumpipe/handshake.py +++ b/quantumpipe/handshake.py @@ -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)) - ) - # 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 + 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( + _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)) 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 + 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)) 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 + 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)) self._commitment = (e_alice_m, e_alice_x, commitment) body = REG_MSG2.pack( e_bob, @@ -229,27 +228,22 @@ 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 - es = self._mlkem.decapsulate(es_ct) - 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)) - ) - # 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", + 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 + es = self._mlkem.decapsulate(es_ct) + 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( + _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)) + if not hmac.compare_digest( + commitment, kdf("commit", e_alice_m, e_alice_x, reveal) + ): + 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,20 +305,15 @@ 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) - sa += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_bob)) - # 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", - ) + 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) + sa += self._x25519.exchange(X25519PublicKey.from_public_bytes(e_bob)) + # 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)) + 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,22 +326,19 @@ 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) - 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 = 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: - raise _Burn(f"bad msg1 contents ({e})") from None + 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) + 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 = 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)) 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 ------------------------------------------------------------- diff --git a/quantumpipe/sansio.py b/quantumpipe/sansio.py index af26680..45fbb81 100644 --- a/quantumpipe/sansio.py +++ b/quantumpipe/sansio.py @@ -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, diff --git a/tests/test_sansio.py b/tests/test_sansio.py index 2b4fb28..7cd7110 100644 --- a/tests/test_sansio.py +++ b/tests/test_sansio.py @@ -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