"""R5 — consent handshake + X25519 hop-credential acceptance checks (Python). Acceptance predicate (roadmap R5): a host recruits a hop only after an explicit accept; a reject leaves no circuit entry; the hop credential decrypts only with the host's X25519 key (a third party cannot open it); signature verification gates acceptance (unsigned/forged request -> rejected). Mirrors the Rust checks in hh/src/sor/consent.rs, and pins cross-language KDF parity with crypto.rs. """ import base64 import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cmd_chat.sor.consent import ( CircuitBuilder, ConsentAccept, ConsentReject, ConsentRequest, _seal_key, node_evaluate, open_sealed, parse_sor_frame, persona_sign, seal_to_pubkey, x25519_keypair, ) def _ed_signer(seed: int): raw = bytes([seed]) * 32 sk = Ed25519PrivateKey.from_private_bytes(raw) pub_b64 = base64.standard_b64encode(sk.public_key().public_bytes_raw()).decode() return raw, pub_b64 def _signed_request(host_ed_raw, host_ed_pub, host_x_pub, circuit="c0", hop=1): req = ConsentRequest(host_ed_pub, host_x_pub, circuit, hop, "deadbeef", "") req.sig = persona_sign(host_ed_raw, req.canonical()) return req def test_accept_recruits_a_hop_only_for_the_host(): host_ed_raw, host_ed_pub = _ed_signer(1) host_x_sk, host_x_pub = x25519_keypair() _, node_ed_pub = _ed_signer(2) req = _signed_request(host_ed_raw, host_ed_pub, host_x_pub) assert req.signature_ok() decision = node_evaluate(req, node_ed_pub, True, b"ephemeral-hop-key") assert isinstance(decision, ConsentAccept) cb = CircuitBuilder("c0", host_x_sk) assert cb.recruit(decision), "accept must recruit a hop" assert len(cb.hops) == 1 assert cb.hops[0].cred == b"ephemeral-hop-key" # A different host (wrong X25519 secret) cannot open the cred -> no hop. other_x_sk, _ = x25519_keypair() evil = CircuitBuilder("c0", other_x_sk) assert not evil.recruit(decision), "third party must not recruit" assert evil.hops == [] def test_forged_or_unsigned_request_is_rejected(): _, host_ed_pub = _ed_signer(1) _, host_x_pub = x25519_keypair() _, node_ed_pub = _ed_signer(2) unsigned = ConsentRequest(host_ed_pub, host_x_pub, "c0", 1, "n", "") assert not unsigned.signature_ok() assert isinstance(node_evaluate(unsigned, node_ed_pub, True, b"k"), ConsentReject) # Forged: signed by the WRONG key but claiming host_ed_pub. wrong_raw, _ = _ed_signer(9) unsigned.sig = persona_sign(wrong_raw, unsigned.canonical()) assert not unsigned.signature_ok(), "forged signature must not verify" assert isinstance(node_evaluate(unsigned, node_ed_pub, True, b"k"), ConsentReject) def test_tampering_a_bound_field_breaks_signature(): host_ed_raw, host_ed_pub = _ed_signer(1) _, host_x_pub = x25519_keypair() req = _signed_request(host_ed_raw, host_ed_pub, host_x_pub) req.hop_index = 2 # was 1 when signed assert not req.signature_ok() def test_reject_leaves_no_entry(): host_ed_raw, host_ed_pub = _ed_signer(1) host_x_sk, host_x_pub = x25519_keypair() _, node_ed_pub = _ed_signer(2) req = _signed_request(host_ed_raw, host_ed_pub, host_x_pub) decision = node_evaluate(req, node_ed_pub, False, b"k") assert isinstance(decision, ConsentReject) cb = CircuitBuilder("c0", host_x_sk) assert not cb.recruit(decision) assert cb.hops == [], "reject must leave no circuit entry" def test_sealed_cred_only_host_opens(): host_x_sk, host_x_pub = x25519_keypair() sealed = seal_to_pubkey(host_x_pub, b"hop-credential") assert open_sealed(host_x_sk, sealed) == b"hop-credential" other_sk, _ = x25519_keypair() with pytest.raises(ValueError): open_sealed(other_sk, sealed) def test_seal_key_matches_rust_vector(): # Same fixed (shared, epk, recipient_pub) triple as # hh/src/crypto.rs::sealed_box::seal_key_matches_python_vector. shared = bytes(range(32)) epk = bytes(range(32, 64)) pub = bytes(range(64, 96)) assert ( _seal_key(shared, epk, pub).hex() == "7708606d3ae3f6c1fb103975302a77b65fc14d5363ee737a7eb8bdd0ac246e81" ) def test_parse_request_roundtrip(): frame = ( '{"_sor":{"op":"request","host_ed":"E","host_x":"X","cid":"c1",' '"hop":3,"nonce":"nn","sig":"ss"}}' ) parsed = parse_sor_frame(frame) assert parsed is not None and parsed["kind"] == "request" r = parsed["req"] assert (r.host_ed_pub, r.host_x_pub, r.circuit_id, r.hop_index) == ("E", "X", "c1", 3) @pytest.mark.parametrize( "text", ["", "not json", "{}", '{"_sor":123}', '{"_sor":{"op":42}}', '{"_sor":{"op":"peer"}}', '{"_sor":{"op":"unknown"}}', "[]", "null"], ) def test_parse_never_raises(text): parse_sor_frame(text) # must not raise