88
99from hybrid_pki .pqc .ml_kem import MLKEM
1010
11+ PROTOCOL_LABEL = b"Hybrid-PKI-Lab-Hybrid-Handshake-v2"
12+
1113
1214@dataclass (frozen = True )
1315class ServerHybridHandshakeKeys :
14- """
15- Server-side hybrid handshake key material.
16- """
16+ """Server-side hybrid handshake key material."""
1717
1818 classical_private_key : x25519 .X25519PrivateKey
1919 classical_public_key_bytes : bytes
@@ -24,54 +24,74 @@ class ServerHybridHandshakeKeys:
2424
2525@dataclass (frozen = True )
2626class ClientHybridHandshakeResult :
27- """
28- Client-side hybrid handshake result.
29- """
27+ """Client-side result for an unauthenticated educational key exchange."""
3028
3129 client_classical_public_key_bytes : bytes
3230 pqc_ciphertext : bytes
3331 hybrid_secret : bytes
3432 pqc_algorithm : str
33+ transcript_hash : bytes
34+
35+
36+ def build_transcript_hash (
37+ server_classical_public_key_bytes : bytes ,
38+ client_classical_public_key_bytes : bytes ,
39+ server_pqc_public_key : bytes ,
40+ pqc_ciphertext : bytes ,
41+ pqc_algorithm : str ,
42+ ) -> bytes :
43+ """Bind the KDF to the public handshake transcript."""
44+ digest = hashes .Hash (hashes .SHA256 ())
45+ for value in (
46+ PROTOCOL_LABEL ,
47+ pqc_algorithm .encode ("ascii" ),
48+ server_classical_public_key_bytes ,
49+ client_classical_public_key_bytes ,
50+ server_pqc_public_key ,
51+ pqc_ciphertext ,
52+ ):
53+ digest .update (len (value ).to_bytes (4 , "big" ))
54+ digest .update (value )
55+ return digest .finalize ()
3556
3657
3758def derive_hybrid_secret (
3859 classical_secret : bytes ,
3960 pqc_secret : bytes ,
40- context : bytes = b"Hybrid-PKI-Lab-Hybrid-Handshake-v1" ,
61+ context : bytes = PROTOCOL_LABEL ,
62+ transcript_hash : bytes | None = None ,
4163 length : int = 32 ,
4264) -> bytes :
43- """
44- Derive a hybrid shared secret using HKDF.
65+ """Derive a domain-separated hybrid secret using HKDF-SHA256.
4566
46- secret_hybrid = HKDF(secret_classical || secret_pqc)
67+ This combines independent classical and PQC contributions. Authentication
68+ must be supplied by a higher-level signed transcript or authenticated
69+ transport; this laboratory primitive does not provide peer authentication.
4770 """
71+ if not classical_secret or not pqc_secret :
72+ raise ValueError ("Both classical and PQC secrets are required" )
73+ if not 16 <= length <= 64 :
74+ raise ValueError ("Derived secret length must be between 16 and 64 bytes" )
75+
76+ salt = transcript_hash or bytes (hashes .SHA256 ().digest_size )
4877 hkdf = HKDF (
4978 algorithm = hashes .SHA256 (),
5079 length = length ,
51- salt = None ,
80+ salt = salt ,
5281 info = context ,
5382 )
54-
5583 return hkdf .derive (classical_secret + pqc_secret )
5684
5785
5886def generate_server_hybrid_handshake_keys (
5987 pqc_algorithm : str = "ML-KEM-768" ,
6088) -> ServerHybridHandshakeKeys :
61- """
62- Generate server-side keys for a hybrid handshake.
63- """
6489 classical_private_key = x25519 .X25519PrivateKey .generate ()
65- classical_public_key = classical_private_key .public_key ()
66-
67- classical_public_key_bytes = classical_public_key .public_bytes (
90+ classical_public_key_bytes = classical_private_key .public_key ().public_bytes (
6891 encoding = serialization .Encoding .Raw ,
6992 format = serialization .PublicFormat .Raw ,
7093 )
71-
72- kem = MLKEM (pqc_algorithm )
73- pqc_keypair = kem .generate_keypair ()
74-
94+ pqc_keypair = MLKEM (pqc_algorithm ).generate_keypair ()
7595 return ServerHybridHandshakeKeys (
7696 classical_private_key = classical_private_key ,
7797 classical_public_key_bytes = classical_public_key_bytes ,
@@ -86,36 +106,34 @@ def client_hybrid_encapsulate(
86106 server_pqc_public_key : bytes ,
87107 pqc_algorithm : str = "ML-KEM-768" ,
88108) -> ClientHybridHandshakeResult :
89- """
90- Client side of the hybrid handshake.
91- """
92- server_classical_public_key = x25519 .X25519PublicKey .from_public_bytes (
109+ server_public_key = x25519 .X25519PublicKey .from_public_bytes (
93110 server_classical_public_key_bytes
94111 )
95-
96112 client_private_key = x25519 .X25519PrivateKey .generate ()
97- client_public_key = client_private_key .public_key ()
98-
99- client_public_key_bytes = client_public_key .public_bytes (
113+ client_public_key_bytes = client_private_key .public_key ().public_bytes (
100114 encoding = serialization .Encoding .Raw ,
101115 format = serialization .PublicFormat .Raw ,
102116 )
103-
104- classical_secret = client_private_key .exchange (server_classical_public_key )
105-
106- kem = MLKEM (pqc_algorithm )
107- encapsulation = kem .encapsulate (server_pqc_public_key )
108-
117+ classical_secret = client_private_key .exchange (server_public_key )
118+ encapsulation = MLKEM (pqc_algorithm ).encapsulate (server_pqc_public_key )
119+ transcript_hash = build_transcript_hash (
120+ server_classical_public_key_bytes ,
121+ client_public_key_bytes ,
122+ server_pqc_public_key ,
123+ encapsulation .ciphertext ,
124+ pqc_algorithm ,
125+ )
109126 hybrid_secret = derive_hybrid_secret (
110- classical_secret = classical_secret ,
111- pqc_secret = encapsulation .shared_secret ,
127+ classical_secret ,
128+ encapsulation .shared_secret ,
129+ transcript_hash = transcript_hash ,
112130 )
113-
114131 return ClientHybridHandshakeResult (
115132 client_classical_public_key_bytes = client_public_key_bytes ,
116133 pqc_ciphertext = encapsulation .ciphertext ,
117134 hybrid_secret = hybrid_secret ,
118135 pqc_algorithm = pqc_algorithm ,
136+ transcript_hash = transcript_hash ,
119137 )
120138
121139
@@ -125,25 +143,37 @@ def server_hybrid_decapsulate(
125143 server_pqc_secret_key : bytes ,
126144 pqc_ciphertext : bytes ,
127145 pqc_algorithm : str = "ML-KEM-768" ,
146+ server_pqc_public_key : bytes | None = None ,
128147) -> bytes :
148+ """Complete the server side of the educational hybrid exchange.
149+
150+ The server PQC public key is required in protocol v2 so both peers bind the
151+ same transcript. It is optional only to produce a clear migration error.
129152 """
130- Server side of the hybrid handshake.
131- """
132- client_classical_public_key = x25519 .X25519PublicKey .from_public_bytes (
133- client_classical_public_key_bytes
134- )
153+ if server_pqc_public_key is None :
154+ raise ValueError ("server_pqc_public_key is required for transcript binding" )
135155
136- classical_secret = server_classical_private_key .exchange (
137- client_classical_public_key
156+ server_public_key_bytes = server_classical_private_key .public_key ().public_bytes (
157+ encoding = serialization .Encoding .Raw ,
158+ format = serialization .PublicFormat .Raw ,
138159 )
139-
140- kem = MLKEM (pqc_algorithm )
141- pqc_secret = kem .decapsulate (
160+ client_public_key = x25519 .X25519PublicKey .from_public_bytes (
161+ client_classical_public_key_bytes
162+ )
163+ classical_secret = server_classical_private_key .exchange (client_public_key )
164+ pqc_secret = MLKEM (pqc_algorithm ).decapsulate (
142165 secret_key = server_pqc_secret_key ,
143166 ciphertext = pqc_ciphertext ,
144167 )
145-
168+ transcript_hash = build_transcript_hash (
169+ server_public_key_bytes ,
170+ client_classical_public_key_bytes ,
171+ server_pqc_public_key ,
172+ pqc_ciphertext ,
173+ pqc_algorithm ,
174+ )
146175 return derive_hybrid_secret (
147- classical_secret = classical_secret ,
148- pqc_secret = pqc_secret ,
176+ classical_secret ,
177+ pqc_secret ,
178+ transcript_hash = transcript_hash ,
149179 )
0 commit comments