Skip to content

Commit 389a2ca

Browse files
authored
Add tests for handshake functionality
1 parent a587621 commit 389a2ca

1 file changed

Lines changed: 258 additions & 0 deletions

File tree

tests/test_handshake.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
from hashlib import sha256
2+
3+
import pytest
4+
from cryptography.exceptions import InvalidTag
5+
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PublicKey
6+
7+
from cyphersyntax.handshake import (
8+
HandshakeOffer,
9+
HandshakeResponse,
10+
handshake_transcript,
11+
)
12+
from cyphersyntax.identity import Identity
13+
from cyphersyntax.kdf import derive_session_root
14+
from cyphersyntax.protocol import PROTOCOL_VERSION
15+
from cyphersyntax.session import AeadSuite, SessionFactory
16+
17+
18+
@pytest.mark.parametrize("suite", [AeadSuite.AES_GCM_SIV, AeadSuite.CHACHA20_POLY1305])
19+
def test_public_ephemeral_handshake_roundtrip(suite):
20+
alice = Identity.generate("alice")
21+
bob = Identity.generate("bob")
22+
23+
pending_alice = SessionFactory.initiator(
24+
local_identity=alice,
25+
remote_name=bob.name,
26+
suite=suite,
27+
)
28+
response, bob_session = SessionFactory.responder(
29+
local_identity=bob,
30+
offer=pending_alice.offer,
31+
)
32+
alice_session = pending_alice.complete(response)
33+
34+
assert pending_alice.completed is True
35+
assert alice_session.root_key == bob_session.root_key
36+
assert (
37+
alice_session.local_ephemeral_public_bytes
38+
== pending_alice.offer.initiator_ephemeral_public_key
39+
)
40+
assert (
41+
bob_session.local_ephemeral_public_bytes
42+
== response.responder_ephemeral_public_key
43+
)
44+
assert bob_session.decrypt(alice_session.encrypt(b"hello bob")) == b"hello bob"
45+
assert alice_session.decrypt(bob_session.encrypt(b"hello alice")) == b"hello alice"
46+
47+
48+
def test_responder_uses_fresh_ephemeral_key_not_static_exchange_key():
49+
alice = Identity.generate("alice")
50+
bob = Identity.generate("bob")
51+
pending_alice = SessionFactory.initiator(
52+
local_identity=alice,
53+
remote_name=bob.name,
54+
)
55+
56+
response, bob_session = SessionFactory.responder(
57+
local_identity=bob,
58+
offer=pending_alice.offer,
59+
)
60+
alice_session = pending_alice.complete(response)
61+
62+
assert response.responder_ephemeral_public_key != bob.x25519_public_bytes()
63+
64+
static_shared_secret = bob.exchange_private_key.exchange(
65+
X25519PublicKey.from_public_bytes(
66+
pending_alice.offer.initiator_ephemeral_public_key
67+
)
68+
)
69+
static_candidate_root = derive_session_root(
70+
shared_secret=static_shared_secret,
71+
transcript_hash=sha256(
72+
handshake_transcript(pending_alice.offer, response)
73+
).digest(),
74+
)
75+
assert static_candidate_root != alice_session.root_key
76+
assert static_candidate_root != bob_session.root_key
77+
78+
79+
def test_each_handshake_uses_new_ephemeral_keys_and_root():
80+
alice = Identity.generate("alice")
81+
bob = Identity.generate("bob")
82+
83+
first_pending = SessionFactory.initiator(
84+
local_identity=alice,
85+
remote_name=bob.name,
86+
)
87+
first_response, first_bob = SessionFactory.responder(
88+
local_identity=bob,
89+
offer=first_pending.offer,
90+
)
91+
first_alice = first_pending.complete(first_response)
92+
93+
second_pending = SessionFactory.initiator(
94+
local_identity=alice,
95+
remote_name=bob.name,
96+
)
97+
second_response, second_bob = SessionFactory.responder(
98+
local_identity=bob,
99+
offer=second_pending.offer,
100+
)
101+
second_alice = second_pending.complete(second_response)
102+
103+
assert (
104+
first_pending.offer.initiator_ephemeral_public_key
105+
!= second_pending.offer.initiator_ephemeral_public_key
106+
)
107+
assert (
108+
first_response.responder_ephemeral_public_key
109+
!= second_response.responder_ephemeral_public_key
110+
)
111+
assert first_alice.root_key != second_alice.root_key
112+
assert first_bob.root_key != second_bob.root_key
113+
114+
115+
def test_initiator_handshake_cannot_be_completed_twice():
116+
alice = Identity.generate("alice")
117+
bob = Identity.generate("bob")
118+
pending_alice = SessionFactory.initiator(
119+
local_identity=alice,
120+
remote_name=bob.name,
121+
)
122+
response, _ = SessionFactory.responder(
123+
local_identity=bob,
124+
offer=pending_alice.offer,
125+
)
126+
127+
pending_alice.complete(response)
128+
with pytest.raises(RuntimeError, match="already been completed"):
129+
pending_alice.complete(response)
130+
131+
132+
def test_responder_rejects_offer_for_different_identity():
133+
alice = Identity.generate("alice")
134+
bob = Identity.generate("bob")
135+
mallory = Identity.generate("mallory")
136+
pending_alice = SessionFactory.initiator(
137+
local_identity=alice,
138+
remote_name=bob.name,
139+
)
140+
141+
with pytest.raises(ValueError, match="does not match handshake responder"):
142+
SessionFactory.responder(
143+
local_identity=mallory,
144+
offer=pending_alice.offer,
145+
)
146+
147+
148+
def test_response_must_match_original_offer():
149+
alice = Identity.generate("alice")
150+
bob = Identity.generate("bob")
151+
pending_alice = SessionFactory.initiator(
152+
local_identity=alice,
153+
remote_name=bob.name,
154+
)
155+
response, _ = SessionFactory.responder(
156+
local_identity=bob,
157+
offer=pending_alice.offer,
158+
)
159+
mismatched_response = HandshakeResponse(
160+
version=response.version,
161+
suite=response.suite,
162+
initiator=response.initiator,
163+
responder="mallory",
164+
initiator_ephemeral_public_key=response.initiator_ephemeral_public_key,
165+
responder_ephemeral_public_key=response.responder_ephemeral_public_key,
166+
)
167+
168+
with pytest.raises(ValueError, match="responder mismatch"):
169+
pending_alice.complete(mismatched_response)
170+
assert pending_alice.completed is False
171+
172+
173+
def test_supplemental_secret_must_match():
174+
alice = Identity.generate("alice")
175+
bob = Identity.generate("bob")
176+
pending_alice = SessionFactory.initiator(
177+
local_identity=alice,
178+
remote_name=bob.name,
179+
supplemental_secret=b"initiator secret",
180+
)
181+
response, bob_session = SessionFactory.responder(
182+
local_identity=bob,
183+
offer=pending_alice.offer,
184+
supplemental_secret=b"different responder secret",
185+
)
186+
alice_session = pending_alice.complete(response)
187+
188+
assert alice_session.root_key != bob_session.root_key
189+
with pytest.raises(InvalidTag):
190+
bob_session.decrypt(alice_session.encrypt(b"must not decrypt"))
191+
192+
193+
def test_handshake_offer_validation():
194+
with pytest.raises(ValueError, match="participants must be distinct"):
195+
HandshakeOffer(
196+
version=PROTOCOL_VERSION,
197+
suite=AeadSuite.AES_GCM_SIV.value,
198+
initiator="alice",
199+
responder="alice",
200+
initiator_ephemeral_public_key=b"a" * 32,
201+
)
202+
203+
with pytest.raises(ValueError, match="exactly 32 bytes"):
204+
HandshakeOffer(
205+
version=PROTOCOL_VERSION,
206+
suite=AeadSuite.AES_GCM_SIV.value,
207+
initiator="alice",
208+
responder="bob",
209+
initiator_ephemeral_public_key=b"short",
210+
)
211+
212+
213+
def test_handshake_rejects_non_integer_version():
214+
with pytest.raises(TypeError, match="version must be an integer"):
215+
HandshakeOffer(
216+
version=True,
217+
suite=AeadSuite.AES_GCM_SIV.value,
218+
initiator="alice",
219+
responder="bob",
220+
initiator_ephemeral_public_key=b"a" * 32,
221+
)
222+
223+
224+
def test_handshake_response_rejects_reflected_ephemeral_key():
225+
offer = HandshakeOffer(
226+
version=PROTOCOL_VERSION,
227+
suite=AeadSuite.AES_GCM_SIV.value,
228+
initiator="alice",
229+
responder="bob",
230+
initiator_ephemeral_public_key=b"a" * 32,
231+
)
232+
233+
with pytest.raises(ValueError, match="ephemeral public keys must be distinct"):
234+
HandshakeResponse.from_offer(
235+
offer,
236+
responder_ephemeral_public_key=offer.initiator_ephemeral_public_key,
237+
)
238+
239+
240+
def test_handshake_response_rejects_suite_substitution():
241+
offer = HandshakeOffer(
242+
version=PROTOCOL_VERSION,
243+
suite=AeadSuite.AES_GCM_SIV.value,
244+
initiator="alice",
245+
responder="bob",
246+
initiator_ephemeral_public_key=b"a" * 32,
247+
)
248+
response = HandshakeResponse(
249+
version=PROTOCOL_VERSION,
250+
suite=AeadSuite.CHACHA20_POLY1305.value,
251+
initiator="alice",
252+
responder="bob",
253+
initiator_ephemeral_public_key=b"a" * 32,
254+
responder_ephemeral_public_key=b"b" * 32,
255+
)
256+
257+
with pytest.raises(ValueError, match="suite mismatch"):
258+
response.validate_for_offer(offer)

0 commit comments

Comments
 (0)