dwara_core/security/
pq.rs

1//! Post-quantum TLS (DW-105): X25519+ML-KEM hybrid key exchange.
2//!
3//! This module wires the X25519+ML-KEM hybrid key-exchange group into
4//! rustls. The hybrid group combines a classical ECDH secret (X25519)
5//! with a post-quantum KEM secret (ML-KEM, formerly Kyber) so that the
6//! negotiated session key remains confidential even if a future quantum
7//! adversary can break ECDH. The classical X25519 share is kept as a
8//! fallback, so a client that does not support the hybrid group still
9//! completes a classical handshake (rustls's kx group list is a
10//! preference order: the first group the client supports wins, so
11//! prepending the hybrid group PREFERS it without removing the
12//! classical fallback).
13//!
14//! # Experimental
15//!
16//! The rustls API for post-quantum key exchange is EXPERIMENTAL and not
17//! yet stable: the specific kx group type, its registration path, and
18//! the provider integration may change between rustls releases. This
19//! module is therefore structured so the config schema EXISTS and
20//! COMPILES regardless of whether the experimental PQ API is available
21//! in the pinned rustls version. When the experimental API is not
22//! reachable, [`install_pq_kx_group`] is a documented no-op (it logs
23//! and returns [`PqMode::Disabled`]); when the API stabilizes, the real
24//! wiring lands here without touching config, validation, or metrics.
25//!
26//! # FIPS incompatibility
27//!
28//! ML-KEM is NOT on the FIPS-validated list for aws-lc-rs. Combining PQ
29//! hybrid key exchange with FIPS mode (Enterprise `ent` cargo feature)
30//! is
31//! REJECTED at config validation: a listener or upstream with `pq:
32//! true` while FIPS mode is active fails validation naming the field.
33//! The two features must not combine unless both algorithms are on a
34//! validated list (a future NIST FIPS 203 module path would lift this).
35//!
36//! # Opt-in
37//!
38//! PQ hybrid key exchange is opt-in per listener and per upstream via
39//! the additive `pq: true` config field. When the `pq` cargo feature is
40//! OFF, `pq: true` is accepted by the parser (additive-only, strict
41//! serde preserved) but is INERT: no kx group is prepended, and
42//! validation emits a warning issue so the operator knows the build
43//! does not include the PQ feature.
44
45/// The post-quantum TLS mode of the gateway.
46///
47/// [`PqMode::Enabled`] when the PQ module is compiled in (always in the
48/// OSS build); [`PqMode::Disabled`] otherwise. This is a compile-time
49/// constant (the same shape as [`crate::security::fips::FipsMode`]).
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum PqMode {
52    /// Post-quantum hybrid key exchange is available: the X25519+ML-KEM
53    /// kx group is prepended to the rustls provider's kx group list for
54    /// listeners/upstreams that opt in via `pq: true`.
55    Enabled,
56    /// Post-quantum hybrid key exchange is unavailable: the experimental
57    /// rustls PQ API is not reachable, so `pq: true` in config is inert
58    /// (no kx group is prepended).
59    Disabled,
60}
61
62impl PqMode {
63    /// The current PQ mode (compile-time determined).
64    pub fn current() -> Self {
65        {
66            PqMode::Enabled
67        }
68    }
69
70    /// True when PQ hybrid key exchange is available.
71    pub fn is_enabled(self) -> bool {
72        self == PqMode::Enabled
73    }
74}
75
76impl std::fmt::Display for PqMode {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            PqMode::Enabled => write!(f, "enabled"),
80            PqMode::Disabled => write!(f, "disabled"),
81        }
82    }
83}
84
85/// The canonical name of the X25519+ML-KEM hybrid key-exchange group as
86/// rustls would register it. Used as the `kx_group` label on
87/// [`PqHandshakeResult`] and in logs. The exact rustls type name may
88/// differ when the experimental API stabilizes; this constant is the
89/// operator-facing label.
90pub const PQ_KX_GROUP_NAME: &str = "X25519MLKEM768";
91
92/// The outcome of a PQ hybrid handshake attempt, captured for the
93/// `dwara_tls_pq_handshakes_total{result}` metric. The `kx_group` field
94/// carries the negotiated group name when the hybrid group was used, or
95/// the classical fallback group name when the client did not support
96/// the hybrid group (a `fallback` result).
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct PqHandshakeResult {
99    /// Whether the hybrid PQ kx group was negotiated (`true` = success,
100    /// the handshake used X25519+ML-KEM). `false` = the client did not
101    /// support the hybrid group and the handshake fell back to a
102    /// classical group, or PQ was disabled.
103    pub succeeded: bool,
104    /// The negotiated key-exchange group name (e.g.
105    /// [`PQ_KX_GROUP_NAME`] for the hybrid group, or `X25519` for the
106    /// classical fallback). Empty when PQ is disabled.
107    pub kx_group: String,
108}
109
110impl PqHandshakeResult {
111    /// A successful hybrid handshake result.
112    pub fn success() -> Self {
113        PqHandshakeResult {
114            succeeded: true,
115            kx_group: PQ_KX_GROUP_NAME.to_string(),
116        }
117    }
118
119    /// A fallback result: the client did not support the hybrid group,
120    /// so a classical group was negotiated instead.
121    pub fn fallback() -> Self {
122        PqHandshakeResult {
123            succeeded: false,
124            kx_group: "X25519".to_string(),
125        }
126    }
127
128    /// The inert result when PQ is disabled.
129    pub fn disabled() -> Self {
130        PqHandshakeResult {
131            succeeded: false,
132            kx_group: String::new(),
133        }
134    }
135}
136
137/// The metric label for a [`PqHandshakeResult`]: `success` when the
138/// hybrid group was negotiated, `fallback` when the client fell back to
139/// a classical group, `disabled` when PQ is off. This is the closed
140/// three-value label set for `dwara_tls_pq_handshakes_total{result}`.
141pub fn pq_handshake_metric(result: &PqHandshakeResult) -> &'static str {
142    if result.kx_group.is_empty() {
143        "disabled"
144    } else if result.succeeded {
145        "success"
146    } else {
147        "fallback"
148    }
149}
150
151/// Prepend the X25519+ML-KEM hybrid key-exchange group to the rustls
152/// provider's kx group list, so the hybrid group is PREFERRED while the
153/// classical X25519 group remains as a fallback for non-PQ clients.
154///
155/// This function builds a custom `CryptoProvider` from the aws-lc-rs
156/// default provider with the `X25519MLKEM768` hybrid kx group prepended
157/// to the kx group list, and installs it as the default provider. The
158/// classical X25519 group remains in the list as a fallback, so a client
159/// that does not support the hybrid group still completes a classical
160/// handshake (rustls's kx group list is a preference order: the first
161/// group the client supports wins).
162///
163/// # Returns
164///
165/// [`PqMode::Enabled`] when the hybrid kx group was prepended;
166/// [`PqMode::Disabled`] when the feature is off or the installation
167/// failed (a provider was already installed with a different config).
168pub fn install_pq_kx_group() -> PqMode {
169    let pq_provider = pq_provider();
170    match pq_provider.install_default() {
171        Ok(()) => {
172            tracing::info!(
173                code = "pq_kx_group_installed",
174                "X25519+ML-KEM hybrid key exchange group installed and preferred \
175                 (classical X25519 remains as fallback)"
176            );
177            PqMode::Enabled
178        }
179        Err(_) => {
180            // A provider is already installed. This is the idempotent
181            // case: the binary installs the default provider at startup,
182            // and subsequent calls to install_pq_kx_group find it already
183            // installed. We cannot re-install with a different kx group
184            // list (rustls does not support replacing the default
185            // provider). Use [`pq_provider`] directly for per-config PQ
186            // support (the server/client config builders use it).
187            tracing::debug!(
188                code = "pq_kx_group_already_installed",
189                "a crypto provider is already installed; per-config PQ support \
190                 uses pq_provider() directly"
191            );
192            // Return Enabled because the PQ kx group IS available via
193            // pq_provider() even if the default provider doesn't have it.
194            PqMode::Enabled
195        }
196    }
197}
198
199/// Build a `CryptoProvider` from the aws-lc-rs default provider with
200/// the `X25519MLKEM768` hybrid kx group prepended to the kx group list.
201/// This is the per-config PQ support path (matching the FIPS pattern:
202/// `fips_provider()` returns a per-config provider). The server and
203/// client config builders use this when `pq: true` is set, passing the
204/// returned provider to `ServerConfig::builder_with_provider` or
205/// `ClientConfig::builder_with_provider`.
206///
207/// The classical X25519 group remains in the list as a fallback, so a
208/// client that does not support the hybrid group still completes a
209/// classical handshake.
210pub fn pq_provider() -> rustls::crypto::CryptoProvider {
211    use rustls::crypto::aws_lc_rs;
212
213    let provider = aws_lc_rs::default_provider();
214    let mut kx_groups = Vec::with_capacity(provider.kx_groups.len() + 1);
215    kx_groups.push(aws_lc_rs::kx_group::X25519MLKEM768);
216    kx_groups.extend(provider.kx_groups.iter().copied());
217
218    rustls::crypto::CryptoProvider {
219        kx_groups,
220        ..provider
221    }
222}
223
224/// True when the `pq` cargo feature is compiled in AND the
225/// experimental rustls PQ API is reachable. Used by validation to
226/// distinguish "feature on but API inert" (warn) from "feature off"
227/// (warn) — both warn, but the message differs. The rustls 0.23.43
228/// crate exposes the `X25519MLKEM768` hybrid kx group via the
229/// aws-lc-rs provider, so this returns `true`.
230pub fn pq_api_available() -> bool {
231    true
232}