-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpqc.rs
More file actions
280 lines (240 loc) · 10.3 KB
/
Copy pathpqc.rs
File metadata and controls
280 lines (240 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
// SPDX-License-Identifier: GPL-3.0-or-later
//! Post-quantum transport (feature `pqc`, off by default).
//!
//! Adds the hybrid **X25519MLKEM768** key-exchange group to the rustls client
//! config, so a TLS 1.3 handshake negotiates a post-quantum-secure shared
//! secret when the server offers it (otherwise it silently falls back to a
//! classical group). The construction is GPL-clean: the classical half reuses
//! ring's audited X25519 (rustls's own `SupportedKxGroup`), and the
//! post-quantum half is `RustCrypto`'s `ml-kem` (Apache-2.0/MIT). We do **not**
//! use aws-lc-rs (the only rustls provider that ships X25519MLKEM768) because
//! its bundled AWS-LC carries OpenSSL-licensed code, which is GPL-incompatible.
//!
//! Wire layout (per `draft-ietf-tls-ecdhe-mlkem`, X25519MLKEM768): the
//! post-quantum element comes first in every share and in the secret. The
//! client sends `ek_ML-KEM-768 (1184) ‖ x25519_pub (32)`; the server replies
//! `ct (1088) ‖ x25519_pub (32)`; the shared secret is `ss_mlkem (32) ‖
//! ss_x25519 (32)`. This module implements the **client** role only.
use std::sync::Arc;
use kem::Decapsulate;
use ml_kem::{Ciphertext, EncodedSizeUser, KemCore, MlKem768};
use rand_core::OsRng;
use rustls::client::ClientConfig;
use rustls::crypto::ring::{default_provider, kx_group};
use rustls::crypto::{ActiveKeyExchange, CryptoProvider, SharedSecret, SupportedKxGroup};
use rustls::{Error, NamedGroup, PeerMisbehaved, ProtocolVersion, RootCertStore};
/// Byte lengths of each component (ML-KEM-768 + X25519).
const X25519_LEN: usize = 32;
const MLKEM768_ENCAP_LEN: usize = 1184;
const MLKEM768_CIPHERTEXT_LEN: usize = 1088;
const INVALID_KEY_SHARE: Error = Error::PeerMisbehaved(PeerMisbehaved::InvalidKeyShare);
type DecapKey = <MlKem768 as KemCore>::DecapsulationKey;
// ---- ML-KEM-768 as a rustls key-exchange group (client / KEM initiator) ----
/// ML-KEM-768 KEM exposed as a rustls [`SupportedKxGroup`]. Only the client
/// role is implemented (generate a keypair, send the encapsulation key, then
/// decapsulate the server's ciphertext).
#[derive(Debug)]
struct MlKem768Kx;
impl SupportedKxGroup for MlKem768Kx {
fn start(&self) -> Result<Box<dyn ActiveKeyExchange>, Error> {
let (decap_key, encap_key) = MlKem768::generate(&mut OsRng);
Ok(Box::new(MlKemActive {
decap_key,
encap_bytes: encap_key.as_bytes().to_vec(),
}))
}
fn name(&self) -> NamedGroup {
NamedGroup::MLKEM768
}
}
/// In-progress ML-KEM-768 exchange: holds the decapsulation key until the
/// server's ciphertext arrives.
struct MlKemActive {
decap_key: DecapKey,
encap_bytes: Vec<u8>,
}
impl ActiveKeyExchange for MlKemActive {
fn complete(self: Box<Self>, peer_pub_key: &[u8]) -> Result<SharedSecret, Error> {
let ciphertext =
Ciphertext::<MlKem768>::try_from(peer_pub_key).map_err(|_| INVALID_KEY_SHARE)?;
let shared = self
.decap_key
.decapsulate(&ciphertext)
.map_err(|()| INVALID_KEY_SHARE)?;
Ok(SharedSecret::from(&shared[..]))
}
fn pub_key(&self) -> &[u8] {
&self.encap_bytes
}
fn group(&self) -> NamedGroup {
NamedGroup::MLKEM768
}
}
// ---- The X25519MLKEM768 hybrid (composes ring X25519 + ML-KEM-768) ----
/// Hybrid X25519MLKEM768, post-quantum element first (per the draft). Mirrors
/// rustls's own (crate-private) `aws_lc_rs::pq::hybrid` composition, but over
/// GPL-clean parts and for the client role only.
#[derive(Debug)]
struct Hybrid {
classical: &'static dyn SupportedKxGroup,
post_quantum: &'static dyn SupportedKxGroup,
}
impl SupportedKxGroup for Hybrid {
fn start(&self) -> Result<Box<dyn ActiveKeyExchange>, Error> {
let classical = self.classical.start()?;
let post_quantum = self.post_quantum.start()?;
// post_quantum_first: ek_ML-KEM ‖ x25519_pub.
let mut combined = Vec::with_capacity(MLKEM768_ENCAP_LEN + X25519_LEN);
combined.extend_from_slice(post_quantum.pub_key());
combined.extend_from_slice(classical.pub_key());
Ok(Box::new(ActiveHybrid {
classical,
post_quantum,
combined_pub_key: combined,
}))
}
fn name(&self) -> NamedGroup {
NamedGroup::X25519MLKEM768
}
fn usable_for_version(&self, version: ProtocolVersion) -> bool {
version == ProtocolVersion::TLSv1_3
}
}
/// In-progress hybrid exchange.
struct ActiveHybrid {
classical: Box<dyn ActiveKeyExchange>,
post_quantum: Box<dyn ActiveKeyExchange>,
combined_pub_key: Vec<u8>,
}
impl ActiveKeyExchange for ActiveHybrid {
fn complete(self: Box<Self>, peer_pub_key: &[u8]) -> Result<SharedSecret, Error> {
// Server share: ct (1088) ‖ x25519_pub (32), post-quantum first.
if peer_pub_key.len() != MLKEM768_CIPHERTEXT_LEN + X25519_LEN {
return Err(INVALID_KEY_SHARE);
}
let (pq_share, classical_share) = peer_pub_key.split_at(MLKEM768_CIPHERTEXT_LEN);
let classical = self.classical.complete(classical_share)?;
let post_quantum = self.post_quantum.complete(pq_share)?;
// Combined secret: ss_ML-KEM ‖ ss_x25519.
let mut secret = Vec::with_capacity(64);
secret.extend_from_slice(post_quantum.secret_bytes());
secret.extend_from_slice(classical.secret_bytes());
Ok(SharedSecret::from(secret))
}
fn pub_key(&self) -> &[u8] {
&self.combined_pub_key
}
fn group(&self) -> NamedGroup {
NamedGroup::X25519MLKEM768
}
}
/// The hybrid X25519MLKEM768 key-exchange group, ready to drop into a
/// [`CryptoProvider`]'s `kx_groups`.
static X25519MLKEM768: &dyn SupportedKxGroup = &Hybrid {
classical: kx_group::X25519,
post_quantum: &MlKem768Kx,
};
/// Build a rustls client config that prefers X25519MLKEM768, falling back to
/// the classical ring groups. Hand the result to `reqwest`'s
/// `use_preconfigured_tls`.
///
/// # Errors
///
/// Returns a [`rustls::Error`] only if the safe default protocol versions are
/// somehow rejected by the provider (not expected in practice).
pub fn client_config() -> Result<ClientConfig, Error> {
let provider = CryptoProvider {
kx_groups: vec![
X25519MLKEM768,
kx_group::X25519,
kx_group::SECP256R1,
kx_group::SECP384R1,
],
..default_provider()
};
let mut roots = RootCertStore::empty();
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Ok(ClientConfig::builder_with_provider(Arc::new(provider))
.with_safe_default_protocol_versions()?
.with_root_certificates(roots)
.with_no_client_auth())
}
#[cfg(test)]
mod tests {
use super::*;
use kem::Encapsulate;
use ml_kem::Encoded;
type EncapKey = <MlKem768 as KemCore>::EncapsulationKey;
#[test]
fn mlkem_client_kx_round_trips() {
// Client: generate keypair, expose the encapsulation key.
let active = MlKem768Kx.start().expect("start");
let ek_bytes = active.pub_key().to_vec();
assert_eq!(ek_bytes.len(), MLKEM768_ENCAP_LEN);
// "Server": reconstruct the encapsulation key, encapsulate to it.
let encoded = Encoded::<EncapKey>::try_from(ek_bytes.as_slice()).expect("ek size");
let encap_key = EncapKey::from_bytes(&encoded);
let (ciphertext, server_secret) = encap_key.encapsulate(&mut OsRng).expect("encapsulate");
assert_eq!(ciphertext.len(), MLKEM768_CIPHERTEXT_LEN);
// Client: decapsulate the ciphertext; the two secrets must match.
let client_secret = active.complete(&ciphertext).expect("complete");
assert_eq!(client_secret.secret_bytes(), &server_secret[..]);
}
#[test]
fn hybrid_pub_key_layout_is_pq_then_classical() {
let active = X25519MLKEM768.start().expect("start");
// Client share = ek_ML-KEM (1184) ‖ x25519_pub (32).
assert_eq!(active.pub_key().len(), MLKEM768_ENCAP_LEN + X25519_LEN);
assert_eq!(active.group(), NamedGroup::X25519MLKEM768);
}
#[test]
fn hybrid_rejects_wrong_length_server_share() {
let active = X25519MLKEM768.start().expect("start");
// A server share of the wrong length must be rejected, not panic.
assert!(active.complete(&[0u8; 16]).is_err());
}
#[test]
fn client_config_prefers_pqc() {
let cfg = client_config().expect("config builds");
let first = cfg
.crypto_provider()
.kx_groups
.first()
.expect("at least one kx group");
assert_eq!(first.name(), NamedGroup::X25519MLKEM768);
}
/// Live gate (PRD §11.4 / `RELEASING.md`): drive a real TLS 1.3 handshake
/// with the [`client_config`] against a PQC-enabled server and confirm the
/// hybrid group is actually *negotiated*, not merely offered. Cloudflare's
/// research host supports X25519MLKEM768; the unit tests above only exercise
/// our half of the exchange, so this is the one check that the wire layout
/// interoperates with an independent server implementation.
///
/// `#[ignore]`d — needs network. Run with:
/// `cargo test -p vault-api --features pqc -- --ignored live_handshake`.
#[test]
#[ignore = "live network: handshakes with pq.cloudflareresearch.com:443"]
fn live_handshake_negotiates_x25519mlkem768() {
use rustls::pki_types::ServerName;
use std::net::TcpStream;
const HOST: &str = "pq.cloudflareresearch.com";
let config = Arc::new(client_config().expect("config builds"));
let server_name = ServerName::try_from(HOST).expect("valid server name");
let mut conn =
rustls::ClientConnection::new(config, server_name).expect("client connection");
let mut sock = TcpStream::connect((HOST, 443)).expect("tcp connect");
// Drive ClientHello → ServerHello…Finished → client Finished to
// completion; no application data needed to fix the negotiated group.
conn.complete_io(&mut sock)
.expect("tls handshake completes");
let group = conn
.negotiated_key_exchange_group()
.expect("a key-exchange group was negotiated");
assert_eq!(
group.name(),
NamedGroup::X25519MLKEM768,
"server negotiated {:?}, not the hybrid PQC group",
group.name(),
);
}
}