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.
790 lines
27 KiB
Python
790 lines
27 KiB
Python
import secrets
|
|
|
|
import pytest
|
|
from cryptography.hazmat.primitives.asymmetric.mlkem import MLKEM768PrivateKey
|
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
|
|
|
|
from quantumpipe.chab3 import encrypt
|
|
from quantumpipe.exceptions import ProtocolError
|
|
from quantumpipe.handshake import (
|
|
_MSG1_REGISTER,
|
|
_fresh_ephemeral,
|
|
_public_from_idroot,
|
|
_x25519_private_key,
|
|
)
|
|
from quantumpipe.kdf import kdf
|
|
from quantumpipe.sansio import (
|
|
_CREDENTIAL,
|
|
Established,
|
|
Message,
|
|
Session,
|
|
credential_id,
|
|
generate_registration_secret,
|
|
)
|
|
|
|
|
|
def make_sessions(cred_a, cred_b=None, ad=b"") -> tuple[Session, Session]:
|
|
"""A Session pair over fresh key stores; cred_b defaults to cred_a."""
|
|
a = Session({"x": cred_a}, "x", ad=ad)
|
|
b = Session({"x": cred_a if cred_b is None else cred_b}, ad=ad)
|
|
return a, b
|
|
|
|
|
|
def run_handshake(
|
|
a: Session, b: Session, answer=True
|
|
) -> tuple[Established, Established]:
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
out = a(m2)
|
|
if isinstance(out[-1], bytes): # registration: two gated tail messages
|
|
(m3,) = out
|
|
(vb,) = b(m3) # the responder gates first
|
|
vb.answer = answer
|
|
(m4,) = b(vb) # his answer releases msg4
|
|
(va,) = a(m4)
|
|
assert va.sas == vb.sas
|
|
va.answer = answer
|
|
m5, est_a = a(va) # the initiator's answer finishes her side
|
|
(est_b,) = b(m5) # and msg5 finishes the responder's
|
|
assert est_a.sas == est_b.sas == va.sas
|
|
assert est_a.verified == est_b.verified == (answer is True)
|
|
else: # verified credential: no comparison, established right away
|
|
m3, est_a = out
|
|
(est_b,) = b(m3)
|
|
assert est_a.sas == est_b.sas # the stored digest
|
|
assert est_a.verified and est_b.verified
|
|
assert isinstance(est_a, Established)
|
|
assert a.state == b.state == "established"
|
|
# both ends derived matching channels: traffic roundtrips both ways
|
|
(w,) = a(Message(b"ping", 1))
|
|
assert b(w) == [Message(b"ping", 1)]
|
|
(w,) = b(Message(b"pong", 1))
|
|
assert a(w) == [Message(b"pong", 1)]
|
|
return est_a, est_b
|
|
|
|
|
|
@pytest.fixture
|
|
def psk():
|
|
# both parties simply store the registration secret itself
|
|
return generate_registration_secret()
|
|
|
|
|
|
@pytest.fixture
|
|
def registered(psk):
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b)
|
|
return a.credential, b.credential
|
|
|
|
|
|
def test_registration_credential(psk):
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b)
|
|
blob_a, blob_b = a.credential, b.credential
|
|
assert len(blob_a) == len(blob_b) == _CREDENTIAL.size
|
|
id_a, shared_a, pub_b_m, pub_b_x, sas_a, vf_a = _CREDENTIAL.unpack(blob_a)
|
|
id_b, shared_b, pub_a_m, pub_a_x, sas_b, vf_b = _CREDENTIAL.unpack(blob_b)
|
|
assert id_a != id_b # fresh random identity roots per party
|
|
assert shared_a == shared_b # same rotated shared key
|
|
assert (pub_a_m, pub_a_x) == _public_from_idroot(id_a) # pubs match idroots
|
|
assert (pub_b_m, pub_b_x) == _public_from_idroot(id_b)
|
|
assert sas_a == sas_b # the stored comparison digest
|
|
assert vf_a and vf_b # confirmed by run_handshake's answers
|
|
|
|
|
|
def test_key_change_is_atomic(psk):
|
|
# the composite replaces the registration secret only at the
|
|
# key-change points -- the initiator's swap at her answer, the
|
|
# responder's when msg5 completes; until then the registration
|
|
# secret keeps serving new connections
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
assert a._store["x"] == psk
|
|
(vb,) = b(m3)
|
|
assert b._store["x"] == psk
|
|
(m4,) = b(vb) # answered (default None), but bob's swap waits for msg5
|
|
assert b._store["x"] == psk
|
|
(va,) = a(m4)
|
|
m5, _est_a = a(va) # alice's swap happens at her answer
|
|
assert len(a._store["x"]) == _CREDENTIAL.size
|
|
assert b._store["x"] == psk # bob's still pending
|
|
b(m5) # msg5 completes bob's side: swap
|
|
assert len(b._store["x"]) == _CREDENTIAL.size
|
|
|
|
|
|
def test_unanswered_registration_retries(psk):
|
|
# a connection dropped mid-registration leaves the registration
|
|
# secret live; the next connection registers afresh
|
|
store_a, store_b = {"x": psk}, {"x": psk}
|
|
a, b = Session(store_a, "x"), Session(store_b)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
a(m2) # parked awaiting msg4, never answered
|
|
assert store_a["x"] == psk and store_b["x"] == psk
|
|
run_handshake(Session(store_a, "x"), Session(store_b)) # simply retried
|
|
|
|
|
|
def test_traffic_roundtrip(psk):
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b)
|
|
(w,) = a(Message(b"one", 1))
|
|
assert b(w) == [Message(b"one", 1)]
|
|
(w,) = a(Message(b"", 1))
|
|
assert b(w) == [Message(b"", 1)] # empty payloads work
|
|
|
|
|
|
def test_user_kinds_pass_through(psk):
|
|
# the kind is opaque to the state machine: any nonzero value roundtrips
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b)
|
|
for kind in (1, 2, 7, 255):
|
|
(w,) = a(Message(b"payload", kind))
|
|
assert b(w) == [Message(b"payload", kind)]
|
|
|
|
|
|
def test_protocol_kind_reserved(psk):
|
|
# kind 0 is reserved for in-band protocol messages: rejected both ways
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b)
|
|
with pytest.raises(ProtocolError, match="reserved"):
|
|
a(Message(b"x", 0))
|
|
w = bytes(a._tx.encrypt(bytes([0]) + b"x")) # inbound kind 0
|
|
with pytest.raises(ProtocolError, match="reserved"):
|
|
b(w)
|
|
with pytest.raises(ProtocolError):
|
|
a(Message(b"x", 300)) # does not fit the kind byte
|
|
|
|
|
|
def test_traffic_tamper_rejected(psk):
|
|
# any tampering with a traffic frame fails the open; failed opens
|
|
# consume no nonce, so the untampered frame still opens afterwards
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b)
|
|
cred = b.credential
|
|
(w,) = a(Message(b"ping", 1))
|
|
w = bytearray(w)
|
|
orig = bytes(w)
|
|
w[-1] ^= 0x01
|
|
with pytest.raises(ProtocolError):
|
|
b(bytes(w))
|
|
w[0] ^= 0x01 # a different spot: still a tag failure
|
|
with pytest.raises(ProtocolError):
|
|
b(bytes(w))
|
|
assert b.credential == cred # an AEAD failure burns nothing
|
|
assert b(orig) == [Message(b"ping", 1)] # no nonce consumed, still in sync
|
|
|
|
|
|
def test_traffic_in_order(psk):
|
|
# the channel is sequential: out-of-order frames fail without
|
|
# consuming a nonce
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b)
|
|
(w1,) = a(Message(b"one", 1))
|
|
(w2,) = a(Message(b"two", 1))
|
|
with pytest.raises(ProtocolError):
|
|
b(w2) # out of order
|
|
assert b(w1) == [Message(b"one", 1)]
|
|
assert b(w2) == [Message(b"two", 1)]
|
|
|
|
|
|
def test_traffic_before_establishment_rejected(psk):
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(_m3,) = a(m2) # initiator parked awaiting msg4
|
|
with pytest.raises(ProtocolError, match="no traffic"):
|
|
a(Message(b"early", 1))
|
|
with pytest.raises(ProtocolError, match="no traffic"):
|
|
b(Message(b"early", 1)) # responder still awaiting msg3
|
|
|
|
|
|
def test_resume_does_not_rotate(registered):
|
|
blob_a, blob_b = registered
|
|
a, b = make_sessions(blob_a, blob_b)
|
|
run_handshake(a, b)
|
|
assert a.credential == blob_a # no rotation in ordinary operation
|
|
assert b.credential == blob_b
|
|
|
|
|
|
def test_resume_fresh_session_keys(registered):
|
|
blob_a, blob_b = registered
|
|
a1, b1 = make_sessions(blob_a, blob_b)
|
|
run_handshake(a1, b1)
|
|
(ct1,) = a1(Message(b"same plaintext", 1))
|
|
a2, b2 = make_sessions(blob_a, blob_b)
|
|
run_handshake(a2, b2)
|
|
(ct2,) = a2(Message(b"same plaintext", 1))
|
|
assert ct2 != ct1 # fresh ephemeral keys
|
|
|
|
|
|
def test_role_reversal(registered):
|
|
a, b = make_sessions(registered[1], registered[0]) # either may initiate
|
|
run_handshake(a, b)
|
|
|
|
|
|
def test_state_strings(psk):
|
|
a, b = make_sessions(psk)
|
|
assert a.state == "reg:hello"
|
|
assert b.state == "hello" # mode unknown until msg1 selects a key
|
|
(m1,) = a()
|
|
assert a.state == "reg:msg2"
|
|
(m2,) = b(m1)
|
|
assert b.state == "reg:msg3"
|
|
(m3,) = a(m2)
|
|
assert a.state == "reg:msg4" # msg3 out, awaiting bob's challenge
|
|
(vb,) = b(m3)
|
|
assert b.state == "reg:verify" # the responder gates first
|
|
(m4,) = b(vb) # default answer: proceed unverified
|
|
assert b.state == "reg:msg5"
|
|
(va,) = a(m4)
|
|
assert a.state == "reg:verify"
|
|
(m5, _est_a) = a(va)
|
|
assert a.state == "established"
|
|
(_est_b,) = b(m5)
|
|
assert b.state == "established"
|
|
|
|
|
|
def test_established_gated_on_answer(psk):
|
|
# the comparison answers gate the tail of the exchange: the
|
|
# responder's answer releases msg4, the initiator's releases msg5
|
|
# and finishes her side; msg5 finishes the responder's
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
(vb,) = b(m3)
|
|
assert a.state == "reg:msg4" and b.state == "reg:verify"
|
|
with pytest.raises(ProtocolError):
|
|
_ = b.verified # handshake not complete
|
|
vb.answer = True
|
|
(m4,) = b(vb)
|
|
assert b.state == "reg:msg5" # answered, but not established
|
|
with pytest.raises(ProtocolError):
|
|
_ = b.verified # still not complete
|
|
(va,) = a(m4)
|
|
va.answer = True
|
|
m5, est_a = a(va)
|
|
assert est_a.verified and est_a.sas == va.sas
|
|
(est_b,) = b(m5)
|
|
assert est_b.verified and est_b.sas == va.sas
|
|
|
|
|
|
def test_verification_reject(psk):
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
(vb,) = b(m3)
|
|
(m4,) = b(vb) # responder proceeds (default answer)
|
|
(va,) = a(m4)
|
|
va.answer = False
|
|
with pytest.raises(ProtocolError, match="rejected"):
|
|
a(va)
|
|
assert a.state == "failed"
|
|
assert a._store == {} # rejected: the credential is deleted; no msg5
|
|
with pytest.raises(ProtocolError):
|
|
a()
|
|
|
|
|
|
def test_verification_reject_responder(psk):
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
(vb,) = b(m3)
|
|
vb.answer = False
|
|
with pytest.raises(ProtocolError, match="rejected"):
|
|
b(vb)
|
|
assert b.state == "failed"
|
|
assert b._store == {} # rejected: the credential is deleted; no msg4
|
|
|
|
|
|
def test_verification_not_required(psk):
|
|
# answer=None (the default) skips the comparison and continues,
|
|
# flagged unverified
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
(vb,) = b(m3)
|
|
(m4,) = b(vb)
|
|
(va,) = a(m4)
|
|
m5, est_a = a(va)
|
|
(est_b,) = b(m5)
|
|
assert isinstance(est_a, Established) and isinstance(est_b, Established)
|
|
assert not est_a.verified and not est_b.verified
|
|
assert not a.verified and not b.verified
|
|
assert est_a.sas == est_b.sas # the SAS is still exposed
|
|
assert a.state == b.state == "established"
|
|
assert _CREDENTIAL.unpack(a._store["x"])[5] is False # stored unverified
|
|
|
|
|
|
def test_wrong_calls_rejected(psk):
|
|
a, b = make_sessions(psk)
|
|
with pytest.raises(ProtocolError):
|
|
_ = a.verification # too early
|
|
with pytest.raises(ProtocolError):
|
|
_ = a.verified # not complete
|
|
(m1,) = a()
|
|
with pytest.raises(ProtocolError):
|
|
a() # local advance not possible while expecting msg2
|
|
(m2,) = b(m1)
|
|
with pytest.raises(ProtocolError):
|
|
b() # msg3 expected, local advance not possible
|
|
(m3,) = a(m2)
|
|
with pytest.raises(ProtocolError):
|
|
a() # msg4 expected, local advance not possible
|
|
with pytest.raises(ProtocolError):
|
|
a(b"junk") # msg4 expected, junk does not open
|
|
(vb,) = b(m3)
|
|
with pytest.raises(ProtocolError):
|
|
b(b"junk") # verify takes only the comparison answer
|
|
(m4,) = b(vb)
|
|
(_v,) = a(m4)
|
|
with pytest.raises(ProtocolError):
|
|
a(b"junk") # verify takes only the comparison answer
|
|
with pytest.raises(ProtocolError, match="key_id"):
|
|
Session({})() # initiation requires a key_id
|
|
|
|
|
|
def test_session_ad(psk, registered):
|
|
ad = b"myapp v2"
|
|
for cred_a, cred_b in ((psk, psk), registered):
|
|
a, b = make_sessions(cred_a, cred_b, ad=ad)
|
|
run_handshake(a, b)
|
|
|
|
|
|
def test_mismatched_ad_rejected(psk, registered):
|
|
for cred_a, cred_b in ((psk, psk), registered):
|
|
a = Session({"x": cred_a}, "x", ad=b"v1")
|
|
b = Session({"x": cred_b}, ad=b"v2")
|
|
(m1,) = a()
|
|
with pytest.raises(ProtocolError):
|
|
b(m1)
|
|
assert b._store["x"] == cred_b # an AEAD failure burns nothing
|
|
|
|
|
|
def test_tampered_msg1_rejected(psk, registered):
|
|
for cred_a, cred_b in ((psk, psk), registered):
|
|
a, b = make_sessions(cred_a, cred_b)
|
|
(m1,) = a()
|
|
m1 = bytearray(m1)
|
|
m1[-1] ^= 0x01
|
|
with pytest.raises(ProtocolError):
|
|
b(bytes(m1))
|
|
assert b._store["x"] == cred_b # an AEAD failure burns nothing
|
|
|
|
|
|
def test_tampered_msg2_rejected(psk, registered):
|
|
for cred_a, cred_b in ((psk, psk), registered):
|
|
a, b = make_sessions(cred_a, cred_b)
|
|
(m1,) = a()
|
|
m2 = bytearray(b(m1)[0])
|
|
m2[-1] ^= 0x01
|
|
with pytest.raises(ProtocolError):
|
|
a(bytes(m2))
|
|
assert a._store["x"] == cred_a # an AEAD failure burns nothing
|
|
|
|
|
|
def test_tampered_msg3_rejected(psk, registered):
|
|
for cred_a, cred_b in ((psk, psk), registered):
|
|
a, b = make_sessions(cred_a, cred_b)
|
|
(m1,) = a()
|
|
m2 = b(m1)[0]
|
|
m3 = bytearray(a(m2)[0]) # msg3 comes first in both flows
|
|
m3[-1] ^= 0x01
|
|
with pytest.raises(ProtocolError):
|
|
b(bytes(m3))
|
|
assert b._store["x"] == cred_b # an AEAD failure burns nothing
|
|
|
|
|
|
def test_tampered_msg4_rejected(psk):
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
(vb,) = b(m3)
|
|
m4 = bytearray(b(vb)[0])
|
|
m4[-1] ^= 0x01
|
|
with pytest.raises(ProtocolError):
|
|
a(bytes(m4))
|
|
assert a._store["x"] == psk # an AEAD failure burns nothing
|
|
|
|
|
|
def test_tampered_msg5_rejected(psk):
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
(vb,) = b(m3)
|
|
(m4,) = b(vb)
|
|
(va,) = a(m4)
|
|
m5 = bytearray(a(va)[0]) # registration's last message
|
|
m5[-1] ^= 0x01
|
|
with pytest.raises(ProtocolError):
|
|
b(bytes(m5))
|
|
assert b._store["x"] == psk # an AEAD failure burns nothing
|
|
|
|
|
|
def test_mismatched_shared_rejected(psk, registered):
|
|
# bob still holds the bare registration secret, alice the rotated
|
|
# composite: the shared keys differ, so her msg1 box does not open
|
|
a = Session({"x": registered[0]}, "x")
|
|
b = Session({"x": psk})
|
|
(m1,) = a()
|
|
with pytest.raises(ProtocolError):
|
|
b(m1)
|
|
assert b._store["x"] == psk # no burn
|
|
|
|
|
|
def test_forged_idroot_rejected(registered):
|
|
# resume composite with a wrong idroot: decapsulation yields garbage,
|
|
# so bob's tag fails to verify -- invalid data inside a valid box,
|
|
# and the forged credential burns
|
|
blob = registered[0]
|
|
bad = _CREDENTIAL.pack(secrets.token_bytes(32), *_CREDENTIAL.unpack(blob)[1:])
|
|
a, b = make_sessions(bad, registered[1])
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
with pytest.raises(ProtocolError, match="burned"):
|
|
a(m2)
|
|
assert a._store == {} # burned
|
|
assert b._store["x"] == registered[1] # bob's side untouched
|
|
|
|
|
|
def test_verified_resumption_is_silent(registered):
|
|
# a verified credential authenticates via the pinned statics: no
|
|
# comparison is asked
|
|
a, b = make_sessions(registered[0], registered[1])
|
|
(m1,) = a()
|
|
(m2,) = b(m1) # no Verification emitted
|
|
assert b.state == "auth:msg3"
|
|
with pytest.raises(ProtocolError):
|
|
_ = b.verification # nothing to verify
|
|
m3, est_a = a(m2)
|
|
assert isinstance(est_a, Established)
|
|
assert est_a.verified and est_a.sas # pinned statics, stored digest
|
|
assert a.state == "established"
|
|
(est_b,) = b(m3)
|
|
assert isinstance(est_b, Established)
|
|
assert est_b.verified and est_b.sas == est_a.sas
|
|
|
|
|
|
def test_unverified_resumption_is_silent(psk):
|
|
# registration skipped: the stored composite is unverified, but the
|
|
# resumption flow never prompts -- the stored digest rides on
|
|
# Established and the parties confirm or reject whenever they wish
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b, answer=None)
|
|
cred_a, cred_b = a.credential, b.credential
|
|
assert _CREDENTIAL.unpack(cred_a)[5] is False
|
|
|
|
a2, b2 = make_sessions(cred_a, cred_b)
|
|
(m1,) = a2()
|
|
(m2,) = b2(m1) # no Verification emitted on the resumption flow
|
|
m3, est_a = a2(m2)
|
|
(est_b,) = b2(m3)
|
|
assert a2.state == b2.state == "established"
|
|
assert not est_a.verified and not est_b.verified
|
|
# the stored digest is still exposed for an optional comparison
|
|
assert est_a.sas == est_b.sas == a2.verification.sas
|
|
a2.confirm(True) # compared out of band: flag it verified
|
|
b2.confirm(True)
|
|
assert a2.verified and b2.verified
|
|
assert _CREDENTIAL.unpack(a2._store["x"])[5] is True # flag flipped
|
|
|
|
a3, b3 = make_sessions(a2._store["x"], b2._store["x"])
|
|
run_handshake(a3, b3) # verified: silent as before
|
|
|
|
|
|
def test_resumption_reject_deletes(psk):
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b, answer=None)
|
|
a2, b2 = make_sessions(a.credential, b.credential)
|
|
(m1,) = a2()
|
|
(m2,) = b2(m1)
|
|
m3, _est_a = a2(m2)
|
|
b2(m3)
|
|
a2.confirm(False) # rejected even on the resumption flow: gone
|
|
assert a2._store == {}
|
|
|
|
|
|
def test_responder_scans_key_store(psk):
|
|
# a server holds credentials for many callers; msg1 picks the one
|
|
# that opens it, and only that entry is touched
|
|
other1, other2 = generate_registration_secret(), generate_registration_secret()
|
|
a = Session({"alice": psk}, "alice")
|
|
b = Session({"alice": psk, "bob": other1, "carol": other2})
|
|
run_handshake(a, b)
|
|
assert len(a._store["alice"]) == _CREDENTIAL.size
|
|
assert len(b._store["alice"]) == _CREDENTIAL.size # registered
|
|
assert b._store["bob"] == other1 # untouched
|
|
assert b._store["carol"] == other2
|
|
# same rotated key on both sides
|
|
assert (
|
|
_CREDENTIAL.unpack(a._store["alice"])[1]
|
|
== _CREDENTIAL.unpack(b._store["alice"])[1]
|
|
)
|
|
|
|
|
|
def test_unknown_caller_no_burn(psk):
|
|
stranger = Session({"x": generate_registration_secret()}, "x")
|
|
b = Session({"alice": psk})
|
|
(m1,) = stranger()
|
|
with pytest.raises(ProtocolError):
|
|
b(m1) # no credential opens the box
|
|
assert b._store == {"alice": psk} # nothing burned
|
|
|
|
|
|
def test_parallel_registration_loser_fails(psk):
|
|
# two registrations race on one registration key: the first exchange
|
|
# to complete msg5 swaps the slot -- the loser's finish finds the
|
|
# registration key gone and fails instead of overwriting
|
|
srv = {"alice": psk}
|
|
x, y = Session({"s": psk}, "s"), Session({"s": psk}, "s")
|
|
sx, sy = Session(srv), Session(srv) # two responder sessions, one store
|
|
(m1x,) = x()
|
|
(m1y,) = y()
|
|
(m2x,) = sx(m1x)
|
|
(m2y,) = sy(m1y) # both in flight on the same key
|
|
(m3x,) = x(m2x)
|
|
(m3y,) = y(m2y)
|
|
(vx,) = sx(m3x)
|
|
(vy,) = sy(m3y) # both responders parked; the key is still live
|
|
assert srv["alice"] == psk
|
|
(m4x,) = sx(vx) # default answers release the msg4s
|
|
(m4y,) = sy(vy)
|
|
(vx2,) = x(m4x)
|
|
(vy2,) = y(m4y)
|
|
m5x, _ex = x(vx2)
|
|
(est_x,) = sx(m5x) # X completes first: wins the swap
|
|
assert isinstance(est_x, Established)
|
|
assert len(srv["alice"]) == _CREDENTIAL.size
|
|
m5y, _ey = y(vy2)
|
|
with pytest.raises(ProtocolError, match="spent"):
|
|
sy(m5y) # Y finds the registration key gone
|
|
assert sy.state == "failed"
|
|
assert len(srv["alice"]) == _CREDENTIAL.size # the winner is intact
|
|
|
|
|
|
def test_parallel_registration_same_client_store(psk):
|
|
# the same arbitration on the initiator's store: the second session
|
|
# to answer loses
|
|
cli = {"s": psk}
|
|
x, y = Session(cli, "s"), Session(cli, "s")
|
|
b1, b2 = Session({"s": psk}), Session({"s": psk})
|
|
(m1x,) = x()
|
|
(m1y,) = y()
|
|
(m2x,) = b1(m1x)
|
|
(m2y,) = b2(m1y)
|
|
(m3x,) = x(m2x)
|
|
(m3y,) = y(m2y)
|
|
(v1,) = b1(m3x)
|
|
(v2,) = b2(m3y)
|
|
(m4x,) = b1(v1) # default answers release the msg4s
|
|
(m4y,) = b2(v2)
|
|
(vx,) = x(m4x)
|
|
(vy,) = y(m4y) # both parked; the registration key is still live
|
|
assert cli["s"] == psk
|
|
vx.answer = None # even a skip swaps: X wins
|
|
_m5x, est_x = x(vx)
|
|
assert isinstance(est_x, Established)
|
|
assert len(cli["s"]) == _CREDENTIAL.size
|
|
vy.answer = None
|
|
with pytest.raises(ProtocolError, match="spent"):
|
|
y(vy)
|
|
assert len(cli["s"]) == _CREDENTIAL.size # not overwritten
|
|
|
|
|
|
def test_loser_burn_spares_winner(psk):
|
|
# the loser's content failure must not burn the winner's freshly
|
|
# swapped credential
|
|
srv = {"alice": psk}
|
|
x, y = Session({"s": psk}, "s"), Session({"s": psk}, "s")
|
|
sx, sy = Session(srv), Session(srv)
|
|
(m1x,) = x()
|
|
(m1y,) = y()
|
|
(m2x,) = sx(m1x)
|
|
(m2y,) = sy(m1y)
|
|
(m3x,) = x(m2x)
|
|
(vx,) = sx(m3x)
|
|
(m4x,) = sx(vx)
|
|
(vx2,) = x(m4x)
|
|
m5x, _ex = x(vx2)
|
|
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"):
|
|
sy(m3y)
|
|
assert len(srv["alice"]) == _CREDENTIAL.size # winner intact, no burn
|
|
|
|
|
|
def test_flag_flip_tolerates_parallel_confirm(psk):
|
|
# two resumptions of the same credential confirm it concurrently;
|
|
# the second flag flip must recognize it as the same credential
|
|
# (only the flag byte changed underneath it)
|
|
cli, srv = {"s": psk}, {"s": psk}
|
|
run_handshake(Session(cli, "s"), Session(srv), answer=None)
|
|
r1, r2 = Session(cli, "s"), Session(cli, "s") # same store, two connections
|
|
s1, s2 = Session(srv), Session(srv)
|
|
(m1_1,) = r1()
|
|
(m1_2,) = r2()
|
|
(m2_1,) = s1(m1_1)
|
|
(m2_2,) = s2(m1_2)
|
|
m3_1, _e1 = r1(m2_1)
|
|
m3_2, _e2 = r2(m2_2)
|
|
s1(m3_1)
|
|
s2(m3_2)
|
|
r1.confirm(True) # flips the slot to verified
|
|
r2.confirm(True) # slot changed under it, but same credential
|
|
assert r1.verified and r2.verified
|
|
assert _CREDENTIAL.unpack(cli["s"])[5] is True
|
|
|
|
|
|
def test_commitment_reveal_checked(psk):
|
|
# a reveal that does not open the msg1 commitment is invalid data
|
|
# inside a valid box: the credential burns
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
a._exchange._reveal = secrets.token_bytes(32) # alice lies about her reveal
|
|
(m3,) = a(m2)
|
|
with pytest.raises(ProtocolError, match="commitment"):
|
|
b(m3)
|
|
assert b._store == {} # burned
|
|
|
|
|
|
def test_mitm_digest_not_steerable(psk):
|
|
# A MITM who knows the registration secret completes independent legs
|
|
# with both parties. Bob's digest derives from the commitment the MITM
|
|
# made in msg1 -- before alice's reveal could be known -- so it cannot
|
|
# be steered toward alice's digest: the two values simply differ.
|
|
a = Session({"x": psk}, "x")
|
|
m_in = Session({"x": psk})
|
|
m_out = Session({"x": psk}, "x")
|
|
b = Session({"x": psk})
|
|
(m1,) = a()
|
|
(m2m,) = m_in(m1) # the MITM answers alice
|
|
(m3a,) = a(m2m)
|
|
(vm,) = m_in(m3a)
|
|
(m4m,) = m_in(vm)
|
|
(va,) = a(m4m) # alice's digest
|
|
(m1m,) = m_out() # and opens his own leg toward bob
|
|
(m2b,) = b(m1m)
|
|
(m3m,) = m_out(m2b)
|
|
(vb,) = b(m3m) # bob's digest
|
|
assert va.sas != vb.sas
|
|
|
|
|
|
def test_mitm_reused_reveal_detected(psk):
|
|
# A MITM who finishes a leg with alice and steals her reveal cannot
|
|
# reuse it toward bob: the sas covers the per-connection ee, which
|
|
# the responder contributes only after the commitment -- so a
|
|
# recommitted reveal still produces a different digest.
|
|
a = Session({"x": psk}, "x")
|
|
m_in = Session({"x": psk})
|
|
m_out = Session({"x": psk}, "x")
|
|
b = Session({"x": psk})
|
|
# leg alpha: the MITM completes with alice and steals her reveal
|
|
(m1,) = a()
|
|
(m2m,) = m_in(m1)
|
|
(m3a,) = a(m2m)
|
|
(vm,) = m_in(m3a)
|
|
r_a = a._exchange._reveal
|
|
# leg beta: the MITM recommits the stolen reveal toward bob
|
|
hs = m_out._exchange
|
|
hs._reveal = r_a
|
|
hs._eph_m, hs._ephemeral, e_m, e_x = _fresh_ephemeral()
|
|
body = _MSG1_REGISTER.pack(e_m, e_x, kdf("commit", e_m, e_x, r_a))
|
|
msg1m = bytes(encrypt(body, key=kdf("reg/msg1", psk), ad=b""))
|
|
hs._chain.advance(msg1m) # startA was bypassed: advance the chain
|
|
(m2b,) = b(msg1m)
|
|
m3m = hs.respondA(m2b)
|
|
(vb,) = b(m3m) # bob's checks pass: the commitment is valid...
|
|
(m4m,) = m_in(vm)
|
|
(va,) = a(m4m)
|
|
assert va.sas != vb.sas # ...but the digests differ
|
|
|
|
|
|
def test_relayed_reveal_rejected(psk):
|
|
# A MITM relaying alice's msg1 verbatim cannot reuse her msg3 either:
|
|
# the reveal box is keyed and ad-chained to her own leg only.
|
|
a = Session({"x": psk}, "x")
|
|
m = Session({"x": psk})
|
|
b = Session({"x": psk})
|
|
(m1,) = a()
|
|
b(m1) # bob answers the relayed hello
|
|
(m2m,) = m(m1) # the MITM answers alice too
|
|
(m3,) = a(m2m) # alice reveals r -- on the MITM's leg only
|
|
with pytest.raises(ProtocolError):
|
|
b(m3)
|
|
assert b._store["x"] == psk # an AEAD failure burns nothing
|
|
|
|
|
|
def test_low_order_x25519_rejected():
|
|
# non-contributory public keys are rejected at the DH exchange
|
|
sk = _x25519_private_key(secrets.token_bytes(32))
|
|
with pytest.raises(ValueError):
|
|
sk.exchange(X25519PublicKey.from_public_bytes(b"\x00" * 32))
|
|
|
|
|
|
def test_foreign_mlkem_static_rejected_at_msg4(psk):
|
|
# alice presents a static whose ML-KEM half she does not hold (foreign
|
|
# mlkem half, her own x25519 half): she cannot decapsulate bob's ct_se
|
|
# challenge, so bob's confirm tag does not match her root and she burns
|
|
a, b = make_sessions(psk)
|
|
# alice presents a static whose ML-KEM half she does not hold
|
|
a._exchange._static_m = _public_from_idroot(secrets.token_bytes(32))[0]
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
(m3,) = a(m2)
|
|
(vb,) = b(m3)
|
|
(m4,) = b(vb)
|
|
with pytest.raises(ProtocolError, match="responder tag"):
|
|
a(m4)
|
|
assert a._store == {} # burned
|
|
|
|
|
|
def test_foreign_mlkem_static_rejected_responder(psk):
|
|
# the symmetric check on the responder: bob cannot answer alice's
|
|
# ct_es challenge for the static he presented -- his decapsulation
|
|
# key does not match it, the inner box keyed with es does not open
|
|
# for him, and he burns
|
|
a, b = make_sessions(psk)
|
|
(m1,) = a()
|
|
(m2,) = b(m1)
|
|
b._exchange._mlkem = MLKEM768PrivateKey.generate() # not the presented key
|
|
(m3,) = a(m2)
|
|
with pytest.raises(ProtocolError, match="burned"):
|
|
b(m3)
|
|
assert b._store == {} # burned
|
|
|
|
|
|
def test_credential_id(psk):
|
|
# 16 bytes, recomputable from the stored record
|
|
assert len(credential_id(psk)) == 16
|
|
assert credential_id(psk) == credential_id(bytes(psk))
|
|
a, b = make_sessions(psk)
|
|
run_handshake(a, b, answer=None)
|
|
blob = a.credential
|
|
# stable across the verified flag (metadata may change)
|
|
flagged = _CREDENTIAL.pack(*_CREDENTIAL.unpack(blob)[:5], True)
|
|
assert credential_id(blob) == credential_id(flagged)
|
|
# tied to the credential: a different record hashes differently
|
|
assert credential_id(blob) != credential_id(psk) # rotation rotates it
|
|
assert credential_id(blob) != credential_id(b.credential) # per-record
|
|
forged = _CREDENTIAL.pack(secrets.token_bytes(32), *_CREDENTIAL.unpack(blob)[1:])
|
|
assert credential_id(forged) != credential_id(blob)
|
|
|
|
|
|
def test_confirm_guards(psk):
|
|
a, b = make_sessions(psk)
|
|
with pytest.raises(ProtocolError, match="nothing to confirm"):
|
|
a.confirm(True) # handshake not complete
|
|
run_handshake(a, b) # verified by the registration answer already
|
|
with pytest.raises(ProtocolError, match="nothing to confirm"):
|
|
a.confirm(False)
|
|
with pytest.raises(ProtocolError, match="nothing to verify"):
|
|
_ = a.verification # verified: no comparison value anymore
|