From 50f247bc01353f8c3c6d0eab68b92a8df2c4aaea Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Tue, 16 Jun 2026 12:29:04 +0300 Subject: [PATCH] feat(vault-api): post-quantum transport feature flag (X25519MLKEM768) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an off-by-default `pqc` cargo feature that prefers the hybrid X25519MLKEM768 key exchange on the HTTPS/TLS layer — the PRD §12 M7 PQC transport item. Built from GPL-compatible parts only: the classical half reuses ring's audited X25519, the post-quantum half is RustCrypto `ml-kem` (Apache-2.0/MIT). We deliberately avoid aws-lc-rs (the only rustls provider shipping X25519MLKEM768) because its bundled AWS-LC carries OpenSSL-licensed code, which is GPL-incompatible. - crates/vault-api/src/pqc.rs: ML-KEM-768 as a rustls SupportedKxGroup (client/KEM-initiator role), composed with ring's X25519 into the X25519MLKEM768 hybrid (post-quantum-first wire layout per draft-ietf-tls-ecdhe-mlkem); `client_config()` builds a rustls ClientConfig that lists the hybrid first with classical fallback, injected into reqwest via `use_preconfigured_tls`. No unsafe (stays under forbid(unsafe_code)). - BitwardenClient::new wires it behind `#[cfg(feature = "pqc")]`; a gated Error::PqcTls variant carries config errors. vault-agent gains a `pqc` passthrough feature. - Off by default: the default dep tree, tests, deny, and headless builds are unchanged. Tests (run with `--features pqc`) cover the ML-KEM client kx round-trip + hybrid layout + that the config prefers PQC. - docs/pqc.md (construction + licensing rationale), README pointer, CHANGELOG. TLS 1.3 only; silent classical fallback when the server lacks PQC. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 ++ Cargo.lock | 55 ++++++++ README.md | 4 + crates/vault-agent/Cargo.toml | 3 + crates/vault-api/Cargo.toml | 16 +++ crates/vault-api/src/client.rs | 12 +- crates/vault-api/src/error.rs | 6 + crates/vault-api/src/lib.rs | 2 + crates/vault-api/src/pqc.rs | 241 +++++++++++++++++++++++++++++++++ docs/pqc.md | 61 +++++++++ 10 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 crates/vault-api/src/pqc.rs create mode 100644 docs/pqc.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf87d9..8b4922d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ range may break in any release. ### Added +- **Post-quantum transport (`pqc` feature, off by default).** A GPL-clean hybrid + **X25519MLKEM768** key-exchange group is added to the rustls client config, so + a TLS 1.3 handshake negotiates a post-quantum-secure secret when the server + offers it (silent classical fallback otherwise). The classical half reuses + ring's X25519; the PQ half is RustCrypto `ml-kem` (Apache-2.0/MIT) — *not* + aws-lc-rs, whose OpenSSL-licensed AWS-LC is GPL-incompatible. Build with + `cargo build -p vault-agent --features pqc`; see `docs/pqc.md`. Satisfies the + PRD §12 M7 "PQC transport feature flag" item (`vault-api/src/pqc.rs`, client + role only). Tests cover the ML-KEM client kx round-trip + the hybrid layout. + - **`EncString` parser fuzz harness.** A cargo-fuzz / libFuzzer target (`fuzz/fuzz_targets/enc_string_parse.rs`) for the security-critical Bitwarden "type 2" parser — feeds arbitrary input to `EncString::parse` and asserts the diff --git a/Cargo.lock b/Cargo.lock index eb2c5c2..a92ce19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -873,6 +873,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1132,6 +1141,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "kem" +version = "0.3.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f" +dependencies = [ + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1241,6 +1269,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f" +dependencies = [ + "hybrid-array", + "kem", + "rand_core 0.6.4", + "sha3", +] + [[package]] name = "nom" version = "8.0.0" @@ -1936,6 +1976,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2479,7 +2529,11 @@ name = "vault-api" version = "0.0.1" dependencies = [ "base64", + "kem", + "ml-kem", + "rand_core 0.6.4", "reqwest", + "rustls", "serde", "serde_json", "tempfile", @@ -2490,6 +2544,7 @@ dependencies = [ "uuid", "vault-core", "vault-store", + "webpki-roots", "wiremock", "zeroize", ] diff --git a/README.md b/README.md index e5b0910..57fc622 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,10 @@ agent logs to `agent.log` beside the socket. The security-critical `EncString` parser has a cargo-fuzz harness under `fuzz/` (a standalone workspace, run on nightly) — see [`docs/fuzzing.md`](docs/fuzzing.md). +An optional **post-quantum transport** (`--features pqc`) prefers the hybrid +X25519MLKEM768 key exchange on the HTTPS client, off by default — see +[`docs/pqc.md`](docs/pqc.md). + ## Getting started ```sh diff --git a/crates/vault-agent/Cargo.toml b/crates/vault-agent/Cargo.toml index 7acd634..8551d48 100644 --- a/crates/vault-agent/Cargo.toml +++ b/crates/vault-agent/Cargo.toml @@ -27,6 +27,9 @@ workspace = true # returns a clean "not compiled in" error. default = ["clipboard"] clipboard = ["dep:arboard"] +# Post-quantum transport (off by default): prefer X25519MLKEM768 on the HTTPS +# client. Passes through to vault-api; see `docs/pqc.md`. +pqc = ["vault-api/pqc"] [dependencies] anyhow = { workspace = true } diff --git a/crates/vault-api/Cargo.toml b/crates/vault-api/Cargo.toml index 5f28cf9..9b15616 100644 --- a/crates/vault-api/Cargo.toml +++ b/crates/vault-api/Cargo.toml @@ -16,6 +16,12 @@ readme.workspace = true [lints] workspace = true +[features] +# Off-by-default post-quantum transport. Adds a GPL-clean X25519MLKEM768 hybrid +# key-exchange group (ring X25519 + RustCrypto ML-KEM-768) to the rustls client +# config. See `docs/pqc.md`. +pqc = ["dep:rustls", "dep:ml-kem", "dep:kem", "dep:rand_core", "dep:webpki-roots"] + [dependencies] thiserror = { workspace = true } serde = { workspace = true } @@ -28,6 +34,16 @@ time = { workspace = true } base64 = { workspace = true } vault-core = { path = "../vault-core" } +# PQC transport (feature = "pqc"). Pinned to the versions reqwest already pulls +# so rustls unifies to one crate instance (required for `use_preconfigured_tls`). +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"], optional = true } +ml-kem = { version = "0.2", optional = true } +# `kem` provides the Encapsulate/Decapsulate traits ml-kem's keys implement; +# pinned to the exact pre-release ml-kem 0.2 depends on. +kem = { version = "=0.3.0-pre.0", optional = true } +rand_core = { version = "0.6", features = ["getrandom"], optional = true } +webpki-roots = { version = "1", optional = true } + [dev-dependencies] tokio = { workspace = true } wiremock = { workspace = true } diff --git a/crates/vault-api/src/client.rs b/crates/vault-api/src/client.rs index 8a387c5..33d2f1e 100644 --- a/crates/vault-api/src/client.rs +++ b/crates/vault-api/src/client.rs @@ -61,10 +61,16 @@ impl BitwardenClient { /// Returns [`Error::Transport`] if the underlying `reqwest` client fails to build. pub fn new(urls: BaseUrls, device_id: Uuid, device_name: impl Into) -> Result { let user_agent = format!("vault/{} (Spacecraft-Software)", env!("CARGO_PKG_VERSION")); - let http = Client::builder() + let builder = Client::builder() .user_agent(&user_agent) - .https_only(urls.api.scheme() == "https") - .build()?; + .https_only(urls.api.scheme() == "https"); + // With the `pqc` feature, prefer the X25519MLKEM768 hybrid key exchange + // on the TLS layer (falls back to classical when the server lacks it). + #[cfg(feature = "pqc")] + let builder = builder.use_preconfigured_tls( + crate::pqc::client_config().map_err(|e| Error::PqcTls(e.to_string()))?, + ); + let http = builder.build()?; Ok(Self { http, urls, diff --git a/crates/vault-api/src/error.rs b/crates/vault-api/src/error.rs index 9865bf2..6910614 100644 --- a/crates/vault-api/src/error.rs +++ b/crates/vault-api/src/error.rs @@ -39,6 +39,12 @@ pub enum Error { /// vault-core surfaced an error (KDF, hashing, etc.) during the API flow. #[error("crypto: {0}")] Crypto(#[from] vault_core::Error), + + /// The post-quantum transport (feature `pqc`) could not build its rustls + /// client config. Not expected in practice (safe defaults are always valid). + #[cfg(feature = "pqc")] + #[error("pqc tls config: {0}")] + PqcTls(String), } /// Convenience `Result` alias used throughout `vault-api`. diff --git a/crates/vault-api/src/lib.rs b/crates/vault-api/src/lib.rs index 337410d..30f900a 100644 --- a/crates/vault-api/src/lib.rs +++ b/crates/vault-api/src/lib.rs @@ -9,6 +9,8 @@ pub mod client; pub mod error; pub mod identity; +#[cfg(feature = "pqc")] +pub mod pqc; pub mod sync; pub mod urls; diff --git a/crates/vault-api/src/pqc.rs b/crates/vault-api/src/pqc.rs new file mode 100644 index 0000000..bafb189 --- /dev/null +++ b/crates/vault-api/src/pqc.rs @@ -0,0 +1,241 @@ +// 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 = ::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, 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, +} + +impl ActiveKeyExchange for MlKemActive { + fn complete(self: Box, peer_pub_key: &[u8]) -> Result { + let ciphertext = + Ciphertext::::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, 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, + post_quantum: Box, + combined_pub_key: Vec, +} + +impl ActiveKeyExchange for ActiveHybrid { + fn complete(self: Box, peer_pub_key: &[u8]) -> Result { + // 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 { + 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 = ::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::::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); + } +} diff --git a/docs/pqc.md b/docs/pqc.md new file mode 100644 index 0000000..a74e384 --- /dev/null +++ b/docs/pqc.md @@ -0,0 +1,61 @@ + + +# Post-quantum transport (`pqc` feature) + +Vault can negotiate a **post-quantum-secure TLS 1.3 handshake** with the +Bitwarden / Vaultwarden server using the hybrid **X25519MLKEM768** key-exchange +group. It is **off by default** and enabled with the `pqc` cargo feature. + +```sh +# build the agent with PQC preferred on its HTTPS client +cargo build -p vault-agent --features pqc +# (vault-api exposes the underlying feature: `--features vault-api/pqc`) +``` + +When enabled, the client offers X25519MLKEM768 first and the classical groups +(X25519, P-256, P-384) as fallback. If the server doesn't support PQC — most +don't yet — the handshake silently falls back to a classical group, so enabling +the feature is safe. PQC is TLS 1.3 only. + +## Why we hand-roll it (and don't use aws-lc-rs) + +rustls only ships X25519MLKEM768 through its **aws-lc-rs** provider. aws-lc-rs +bundles AWS-LC, whose license tree includes **OpenSSL-licensed** code — which is +**GPL-incompatible**. Vault is GPL-3.0-or-later (Standard §4), so aws-lc-rs is +not an option, and our `deny.toml` allow-list would reject it. + +Instead, `crates/vault-api/src/pqc.rs` builds the hybrid group from +**GPL-compatible** parts: + +- **classical half** — ring's audited X25519 (`rustls::crypto::ring::kx_group::X25519`), + reused as-is; +- **post-quantum half** — RustCrypto's [`ml-kem`] (`MlKem768`), Apache-2.0/MIT. + +The two are composed into a `rustls::crypto::SupportedKxGroup` and injected via +reqwest's `use_preconfigured_tls`. Only the **client** role is implemented. + +## Wire construction + +Per `draft-ietf-tls-ecdhe-mlkem` (X25519MLKEM768), the post-quantum element +comes first in every share and in the derived secret: + +| | bytes | layout | +|---|---|---| +| client share | 1216 | `ek_ML-KEM-768 (1184) ‖ x25519_pub (32)` | +| server share | 1120 | `ct (1088) ‖ x25519_pub (32)` | +| shared secret | 64 | `ss_ML-KEM (32) ‖ ss_X25519 (32)` | + +The client generates an ML-KEM keypair (keeping the decapsulation key) plus an +X25519 ephemeral, sends `ek ‖ x25519_pub`, then on the server's reply +X25519-DHs the classical part, decapsulates the ML-KEM ciphertext, and +concatenates the two secrets (PQ first). This mirrors rustls's own +(crate-private) `aws_lc_rs::pq::hybrid` layout. + +## Status (PRD §12 M7) + +This satisfies the M7 "PQC transport feature flag" item. Still pending for the +`v0.1` tag: making PQC a tested live handshake against a PQC-enabled server, the +≥ 24 h EncString fuzz soak (`docs/fuzzing.md`), the broader hardening pass, and +the §11.2 two-week daily-driver attestation. + +[`ml-kem`]: https://crates.io/crates/ml-kem