diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index e3f44a3b96cb..a9f8c039c72c 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -28,8 +28,6 @@ jobs: denyWarnings: true # Ignored audit issues. This list should be kept short, and effort should be # put into removing items from the list. - # RUSTSEC-2023-0079 - KyberSlash in `pqc_kyber`. - ignore: RUSTSEC-2023-0079 - uses: actions-rust-lang/audit@v1.1.11 name: Audit testrunner Rust Dependencies @@ -38,5 +36,3 @@ jobs: denyWarnings: true # Ignored audit issues. This list should be kept short, and effort should be # put into removing items from the list. - # RUSTSEC-2023-0079 - KyberSlash in `pqc_kyber`. - ignore: RUSTSEC-2023-0079 diff --git a/.github/workflows/rust-unused-dependencies.yml b/.github/workflows/rust-unused-dependencies.yml index cfdd75e75176..3c243b98d08d 100644 --- a/.github/workflows/rust-unused-dependencies.yml +++ b/.github/workflows/rust-unused-dependencies.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: env: # Pinning nightly just to avoid random breakage. It's fine to bump this at any time - RUST_NIGHTLY_TOOLCHAIN: nightly-2024-06-06 + RUST_NIGHTLY_TOOLCHAIN: nightly-2024-10-02 permissions: {} diff --git a/CHANGELOG.md b/CHANGELOG.md index 795f61da7d2e..4a08634efd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ Line wrap the file at 100 chars. Th - Add experimental support for Windows ARM64. ### Changed +- Replace the draft key encapsulation mechanism Kyber (round 3) with the standardized + ML-KEM (FIPS 203) dito in the handshake for Quantum-resistant tunnels. + #### Windows - Enable quantum-resistant tunnels by default (when set to `auto`). diff --git a/Cargo.lock b/Cargo.lock index 2f378ada8076..3a3bd522890b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1536,6 +1536,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hybrid-array" +version = "0.2.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a41e5b0754cae5aaf7915f1df1147ba8d316fc6e019cfcc00fbaba96d5e030" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.28" @@ -2025,6 +2034,16 @@ 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 = "kqueue" version = "1.0.8" @@ -2293,6 +2312,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ml-kem" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97befee0c869cb56f3118f49d0f9bb68c9e3f380dec23c1100aedc4ec3ba239a" +dependencies = [ + "hybrid-array", + "kem", + "rand_core 0.6.4", + "sha3", + "zeroize", +] + [[package]] name = "mnl" version = "0.2.2" @@ -3188,16 +3220,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" -[[package]] -name = "pqc_kyber" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b79004a05337e54e8ffc0ec7470e40fa26eca6fe182968ec2b803247f2283c" -dependencies = [ - "rand_core 0.6.4", - "zeroize", -] - [[package]] name = "prettyplease" version = "0.2.19" @@ -4286,8 +4308,8 @@ dependencies = [ "hyper-util", "libc", "log", + "ml-kem", "oslog", - "pqc_kyber", "prost 0.13.3", "rand 0.8.5", "talpid-types", diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index a51a1deacfd4..39bc7a51248b 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -34,6 +34,8 @@ Line wrap the file at 100 chars. Th ### Changed - Update colors in the app to be more in line with material design. +- Replace the draft key encapsulation mechanism Kyber (round 3) with the standardized + ML-KEM (FIPS 203) dito in the handshake for Quantum-resistant tunnels. ### Fixed - Fix VPN service being recreated multiple times when toggling certain options. diff --git a/deny.toml b/deny.toml index 00a1076b78ca..c310d84330cd 100644 --- a/deny.toml +++ b/deny.toml @@ -26,8 +26,6 @@ yanked = "deny" ignore = [ # Ignored audit issues. This list should be kept short, and effort should be # put into removing items from the list. - # RUSTSEC-2023-0079 - KyberSlash in `pqc_kyber`. - "RUSTSEC-2023-0079", ] diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index 0e9eef986072..68b62affefc7 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -22,6 +22,9 @@ Line wrap the file at 100 chars. Th * **Security**: in case of vulnerabilities. ## Unreleased +### Changed +- Replace the draft key encapsulation mechanism Kyber (round 3) with the standardized + ML-KEM (FIPS 203) dito in the handshake for Quantum-resistant tunnels. ## [2024.8 - 2024-10-14] ### Added diff --git a/osv-scanner.toml b/osv-scanner.toml index 0349695942a6..1837415f4256 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -40,13 +40,3 @@ # `renderer-helper` currently depend on this version of libbaz, preventing us from upgrading to a fixed version. # """ # ``` - -# KyberSlash timing attack against Kyber PQ KEM -[[IgnoredVulns]] -id = "RUSTSEC-2023-0079" -ignoreUntil = 2024-12-05 # Ignored for six months at a time. This class of timing based attacks are not exploitable in our protocol design -reason = """ -KyberSlash is not exploitable in our usage of it: -https://mullvad.net/en/blog/mullvads-usage-of-kyber-is-not-affected-by-kyberslash -And no patched version is available. -""" diff --git a/talpid-tunnel-config-client/Cargo.toml b/talpid-tunnel-config-client/Cargo.toml index 54966873ce7d..5cf6e4b52215 100644 --- a/talpid-tunnel-config-client/Cargo.toml +++ b/talpid-tunnel-config-client/Cargo.toml @@ -24,7 +24,8 @@ classic-mceliece-rust = { version = "2.0.0", features = [ "mceliece460896f", "zeroize", ] } -pqc_kyber = { version = "0.4.0", features = ["std", "kyber1024", "zeroize"] } + +ml-kem = { version = "0.2.1", features = ["zeroize"] } zeroize = "1.5.7" [target.'cfg(unix)'.dependencies] diff --git a/talpid-tunnel-config-client/examples/tuncfg-server.rs b/talpid-tunnel-config-client/examples/tuncfg-server.rs index 306a425ff4e4..4ba996431caa 100644 --- a/talpid-tunnel-config-client/examples/tuncfg-server.rs +++ b/talpid-tunnel-config-client/examples/tuncfg-server.rs @@ -5,11 +5,11 @@ mod proto { tonic::include_proto!("ephemeralpeer"); } -use classic_mceliece_rust::{PublicKey, CRYPTO_PUBLICKEYBYTES}; use proto::{ ephemeral_peer_server::{EphemeralPeer, EphemeralPeerServer}, EphemeralPeerRequestV1, EphemeralPeerResponseV1, PostQuantumResponseV1, }; +use rand::{CryptoRng, RngCore}; use talpid_types::net::wireguard::PresharedKey; use tonic::{transport::Server, Request, Response, Status}; @@ -44,20 +44,9 @@ impl EphemeralPeer for EphemeralPeerImpl { println!("\tKEM algorithm: {}", kem_pubkey.algorithm_name); let (ciphertext, shared_secret) = match kem_pubkey.algorithm_name.as_str() { "Classic-McEliece-460896f-round3" => { - let key_data: [u8; CRYPTO_PUBLICKEYBYTES] = - kem_pubkey.key_data.as_slice().try_into().unwrap(); - let public_key = PublicKey::from(&key_data); - let (ciphertext, shared_secret) = - classic_mceliece_rust::encapsulate_boxed(&public_key, &mut rng); - (ciphertext.as_array().to_vec(), *shared_secret.as_array()) - } - // Kyber round3 - "Kyber1024" => { - let public_key = kem_pubkey.key_data.as_slice(); - let (ciphertext, shared_secret) = - pqc_kyber::encapsulate(public_key, &mut rng).unwrap(); - (ciphertext.to_vec(), shared_secret) + encapsulate_classic_mceliece(kem_pubkey.key_data.as_slice(), &mut rng) } + "ML-KEM-1024" => encapsulate_ml_kem(kem_pubkey.key_data.as_slice(), &mut rng), name => panic!("Unsupported KEM algorithm: {name}"), }; @@ -82,6 +71,40 @@ impl EphemeralPeer for EphemeralPeerImpl { } } +/// Generate a random shared secret and encapsulate it with the given +/// public key/encapsulation key. Returns the ciphertext to return +/// to the owner of the public key, along with the shared secret. +fn encapsulate_classic_mceliece( + public_key: &[u8], + rng: &mut R, +) -> (Vec, [u8; 32]) { + use classic_mceliece_rust::{PublicKey, CRYPTO_PUBLICKEYBYTES}; + + let public_key_array = <[u8; CRYPTO_PUBLICKEYBYTES]>::try_from(public_key).unwrap(); + let public_key = PublicKey::from(&public_key_array); + let (ciphertext, shared_secret) = classic_mceliece_rust::encapsulate_boxed(&public_key, rng); + (ciphertext.as_array().to_vec(), *shared_secret.as_array()) +} + +/// Generate a random shared secret and encapsulate it with the given +/// public key/encapsulation key. Returns the ciphertext to return +/// to the owner of the public key, along with the shared secret. +fn encapsulate_ml_kem( + public_key: &[u8], + rng: &mut R, +) -> (Vec, [u8; 32]) { + use ml_kem::{kem::Encapsulate, Encoded, EncodedSizeUser, KemCore, MlKem1024}; + + type EncapsulationKey = ::EncapsulationKey; + + let encapsulation_key_array = >::try_from(public_key).unwrap(); + let encapsulation_key = EncapsulationKey::from_bytes(&encapsulation_key_array); + + let (ciphertext, shared_secret) = encapsulation_key.encapsulate(rng).unwrap(); + + (ciphertext.to_vec(), shared_secret.into()) +} + #[tokio::main] async fn main() -> Result<(), Box> { let addr = "127.0.0.1:1337".parse()?; diff --git a/talpid-tunnel-config-client/proto/ephemeralpeer.proto b/talpid-tunnel-config-client/proto/ephemeralpeer.proto index d247e8bcb34b..7c78425b347b 100644 --- a/talpid-tunnel-config-client/proto/ephemeralpeer.proto +++ b/talpid-tunnel-config-client/proto/ephemeralpeer.proto @@ -43,10 +43,12 @@ message EphemeralPeerRequestV1 { DaitaRequestV1 daita = 4; } -// The v1 request supports exactly two algorithms. -// The algorithms can appear soletary or in mixed order: +// The v1 request supports these three algorithms. +// The algorithms can appear soletary or mixed. Kyber1024 and ML-KEM-1024 cannot be used in the +// same request as they are just different versions of the same kem. // - "Classic-McEliece-460896f", but explicitly identified as "Classic-McEliece-460896f-round3" // - "Kyber1024", this is round3 of the Kyber KEM +// - "ML-KEM-1024". This is the standardized version of ML-KEM (FIPS 203) at the highest strength message PostQuantumRequestV1 { repeated KemPubkeyV1 kem_pubkeys = 1; } message KemPubkeyV1 { @@ -70,8 +72,6 @@ message EphemeralPeerResponseV1 { // Since the PSK provided to WireGuard is directly fed into a HKDF, it is not important that // the entropy in the PSK is uniformly distributed. The actual keys used for encrypting the // data channel will have uniformly distributed entropy anyway, thanks to the HKDF. - // But even if that was not true, since both CME and Kyber run SHAKE256 as the last step - // of their internal key derivation, the output they produce are uniformly distributed. // // If we later want to support another type of KEM that produce longer or shorter output, // we can hash that secret into a 32 byte hash before proceeding to the XOR step. diff --git a/talpid-tunnel-config-client/src/kyber.rs b/talpid-tunnel-config-client/src/kyber.rs deleted file mode 100644 index 706f05604139..000000000000 --- a/talpid-tunnel-config-client/src/kyber.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! This module implements the Kyber round3 KEM as specified in: -//! https://pq-crystals.org/kyber/data/kyber-specification-round3.pdf - -use pqc_kyber::KYBER_CIPHERTEXTBYTES; -pub use pqc_kyber::{keypair, KyberError, SecretKey}; - -/// Use the strongest variant of Kyber. It is fast and the keys are small, so there is no practical -/// benefit of going with anything lower. -pub const ALGORITHM_NAME: &str = "Kyber1024"; - -// Always inline in order to try to avoid potential copies of `shared_secret` to multiple places on -// the stack. -#[inline(always)] -pub fn decapsulate( - secret_key: SecretKey, - ciphertext_slice: &[u8], -) -> Result<[u8; 32], super::Error> { - // The `pqc_kyber` library takes a byte slice. But we convert it into an array - // in order to catch the length mismatch error and report it better than `pqc_kyber` would. - let ciphertext_array = - <[u8; KYBER_CIPHERTEXTBYTES]>::try_from(ciphertext_slice).map_err(|_| { - super::Error::InvalidCiphertextLength { - algorithm: ALGORITHM_NAME, - actual: ciphertext_slice.len(), - expected: KYBER_CIPHERTEXTBYTES, - } - })?; - let shared_secret = pqc_kyber::decapsulate(ciphertext_array.as_slice(), secret_key.as_slice()) - .map_err(super::Error::FailedDecapsulateKyber)?; - Ok(shared_secret) -} diff --git a/talpid-tunnel-config-client/src/lib.rs b/talpid-tunnel-config-client/src/lib.rs index 9820a71a7317..ab47c13be725 100644 --- a/talpid-tunnel-config-client/src/lib.rs +++ b/talpid-tunnel-config-client/src/lib.rs @@ -13,7 +13,7 @@ use tower::service_fn; use zeroize::Zeroize; mod classic_mceliece; -mod kyber; +mod ml_kem; #[cfg(not(target_os = "ios"))] mod socket; @@ -35,7 +35,6 @@ pub enum Error { InvalidCiphertextCount { actual: usize, }, - FailedDecapsulateKyber(kyber::KyberError), #[cfg(target_os = "ios")] TcpConnectionExpired, #[cfg(target_os = "ios")] @@ -60,7 +59,6 @@ impl std::fmt::Display for Error { InvalidCiphertextCount { actual } => { write!(f, "Expected 2 ciphertext in the response, got {actual}") } - FailedDecapsulateKyber(_) => "Failed to decapsulate Kyber1024 ciphertext".fmt(f), #[cfg(target_os = "ios")] TcpConnectionExpired => "TCP connection is already shut down".fmt(f), #[cfg(target_os = "ios")] @@ -73,7 +71,6 @@ impl std::error::Error for Error { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::GrpcConnectError(error) => Some(error), - Self::FailedDecapsulateKyber(error) => Some(error), _ => None, } } @@ -110,7 +107,7 @@ pub async fn request_ephemeral_peer_with( .await .map_err(Error::GrpcError)?; - let psk = if let Some((cme_kem_secret, kyber_secret)) = kem_secrets { + let psk = if let Some((cme_kem_secret, ml_kem_secret)) = kem_secrets { let ciphertexts = response .into_inner() .post_quantum @@ -118,7 +115,7 @@ pub async fn request_ephemeral_peer_with( .ciphertexts; // Unpack the ciphertexts into one per KEM without needing to access them by index. - let [cme_ciphertext, kyber_ciphertext] = <&[Vec; 2]>::try_from(ciphertexts.as_slice()) + let [cme_ciphertext, ml_kem_ciphertext] = <&[Vec; 2]>::try_from(ciphertexts.as_slice()) .map_err(|_| Error::InvalidCiphertextCount { actual: ciphertexts.len(), })?; @@ -137,9 +134,9 @@ pub async fn request_ephemeral_peer_with( // accidentally removed. shared_secret.zeroize(); } - // Decapsulate Kyber and mix into PSK + // Decapsulate ML-KEM and mix into PSK { - let mut shared_secret = kyber::decapsulate(kyber_secret, kyber_ciphertext)?; + let mut shared_secret = ml_kem_secret.decapsulate(ml_kem_ciphertext)?; xor_assign(&mut psk_data, &shared_secret); // The shared secret is sadly stored in an array on the stack. So we can't get any @@ -182,11 +179,11 @@ async fn post_quantum_secrets( enable_post_quantum: bool, ) -> ( Option, - Option<(classic_mceliece_rust::SecretKey<'static>, [u8; 3168])>, + Option<(classic_mceliece_rust::SecretKey<'static>, ml_kem::Keypair)>, ) { if enable_post_quantum { let (cme_kem_pubkey, cme_kem_secret) = classic_mceliece::generate_keys().await; - let kyber_keypair = kyber::keypair(&mut rand::thread_rng()); + let ml_kem_keypair = ml_kem::keypair(); ( Some(proto::PostQuantumRequestV1 { @@ -196,12 +193,12 @@ async fn post_quantum_secrets( key_data: cme_kem_pubkey.as_array().to_vec(), }, proto::KemPubkeyV1 { - algorithm_name: kyber::ALGORITHM_NAME.to_owned(), - key_data: kyber_keypair.public.to_vec(), + algorithm_name: ml_kem::ALGORITHM_NAME.to_owned(), + key_data: ml_kem_keypair.encapsulation_key(), }, ], }), - Some((cme_kem_secret, kyber_keypair.secret)), + Some((cme_kem_secret, ml_kem_keypair)), ) } else { (None, None) diff --git a/talpid-tunnel-config-client/src/ml_kem.rs b/talpid-tunnel-config-client/src/ml_kem.rs new file mode 100644 index 000000000000..5cf101107ccc --- /dev/null +++ b/talpid-tunnel-config-client/src/ml_kem.rs @@ -0,0 +1,65 @@ +use ml_kem::array::typenum::marker_traits::Unsigned; +use ml_kem::kem::Decapsulate; +use ml_kem::{Ciphertext, EncodedSizeUser, KemCore, MlKem1024, MlKem1024Params}; + +/// Use the strongest variant of ML-KEM. It is fast and the keys are small, so there is no practical +/// benefit of going with anything lower. The servers also only supports the strongest variant. +pub const ALGORITHM_NAME: &str = "ML-KEM-1024"; + +/// The number of bytes in an ML-KEM 1024 ciphertext. +const CIPHERTEXT_LEN: usize = ::CiphertextSize::USIZE; + +pub struct Keypair { + encapsulation_key: ml_kem::kem::EncapsulationKey, + decapsulation_key: ml_kem::kem::DecapsulationKey, +} + +impl Keypair { + /// Returns the encapsulation key. This is sometimes called the public key. + /// + /// This is the key to send to the peer you want to negotiate a shared secret with. + pub fn encapsulation_key(&self) -> Vec { + self.encapsulation_key.as_bytes().as_slice().to_vec() + } + + /// Decapsulates a shared secret that was encapsulated to our encapsulation key. + /// + // Always inline in order to try to avoid potential copies of `shared_secret` to multiple places + // on the stack. This is almost pointless as with optimization all bets are off regarding where + // the shared secrets will end up in memory. In the future we can try to do better, by + // cleaning the stack. But this is not trivial. Please see: + // https://github.com/RustCrypto/KEMs/issues/70 + #[inline(always)] + pub fn decapsulate(&self, ciphertext_slice: &[u8]) -> Result<[u8; 32], super::Error> { + // Convert the ciphertext byte slice into the appropriate Array type. + // This involves validating the length of the ciphertext. + let ciphertext_array = + >::try_from(ciphertext_slice).map_err(|_| { + super::Error::InvalidCiphertextLength { + algorithm: ALGORITHM_NAME, + actual: ciphertext_slice.len(), + expected: CIPHERTEXT_LEN, + } + })?; + + // Decapsulate the shared secret. This is an infallible operation but + // must due to the signature of the trait it is implemented via return a + // Result that we must unwrap... For now. Please see: + // https://github.com/RustCrypto/KEMs/pull/59 + let shared_secret = self + .decapsulation_key + .decapsulate(&ciphertext_array) + .unwrap(); + Ok(shared_secret.0) + } +} + +/// Generates and returns an ML-KEM keypair. +pub fn keypair() -> Keypair { + let (decapsulation_key, encapsulation_key) = + ml_kem::MlKem1024::generate(&mut rand::thread_rng()); + Keypair { + encapsulation_key, + decapsulation_key, + } +} diff --git a/talpid-wireguard/src/config.rs b/talpid-wireguard/src/config.rs index c599d3448323..5326427d13dc 100644 --- a/talpid-wireguard/src/config.rs +++ b/talpid-wireguard/src/config.rs @@ -124,7 +124,7 @@ impl Config { // the order of insertion matters, public key entry denotes a new peer entry let mut wg_conf = WgConfigBuffer::new(); wg_conf - .add("private_key", self.tunnel.private_key.to_bytes().as_ref()) + .add::<&[u8]>("private_key", self.tunnel.private_key.to_bytes().as_ref()) .add("listen_port", "0"); #[cfg(target_os = "linux")] @@ -136,11 +136,11 @@ impl Config { for peer in self.peers() { wg_conf - .add("public_key", peer.public_key.as_bytes().as_ref()) + .add::<&[u8]>("public_key", peer.public_key.as_bytes().as_ref()) .add("endpoint", peer.endpoint.to_string().as_str()) .add("replace_allowed_ips", "true"); if let Some(ref psk) = peer.psk { - wg_conf.add("preshared_key", psk.as_bytes().as_ref()); + wg_conf.add::<&[u8]>("preshared_key", psk.as_bytes().as_ref()); } for addr in &peer.allowed_ips { wg_conf.add("allowed_ip", addr.to_string().as_str());