Skip to content

Commit fa6a10b

Browse files
committed
Bind hybrid secrets to the handshake transcript
1 parent d6f296d commit fa6a10b

1 file changed

Lines changed: 82 additions & 52 deletions

File tree

src/hybrid_pki/hybrid/hybrid_handshake.py

Lines changed: 82 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@
88

99
from hybrid_pki.pqc.ml_kem import MLKEM
1010

11+
PROTOCOL_LABEL = b"Hybrid-PKI-Lab-Hybrid-Handshake-v2"
12+
1113

1214
@dataclass(frozen=True)
1315
class 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)
2626
class 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

3758
def 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

5886
def 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

Comments
 (0)