From 1ecf09363809f9cecdf1831ee0141eeb4fb1772e Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Thu, 4 Jun 2026 05:07:20 -0400 Subject: [PATCH 1/5] =?UTF-8?q?deps(rust):=20bump=20ml-kem=200.2=20?= =?UTF-8?q?=E2=86=92=200.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API migration to ml-kem 0.3: - `KemCore` removed → use `kem::Kem` (we no longer reference it directly; the per-parameter type aliases below capture it). - `EncodedSizeUser::from_bytes` removed → `ExpandedKeyEncoding:: from_expanded_bytes` is the replacement, now fallible (returns Result with FIPS 203 hash validation on the expanded key, per RustCrypto/KEMs #207). - `Encoded` replaced by `Array` from the re-exported `hybrid_array` (`ml_kem::array`). - Per-parameter aliases moved into `ml_kem::ml_kem_768` — use `MlKem768Ct` / `MlKem768Dk` (renamed locally for brevity since the param is fixed at the import site). Interop note: the 2400-byte FIPS 203 expanded decapsulation key layout produced by BouncyCastle's `MLKemPrivateKeyParameters. GetEncoded()` is preserved bit-for-bit in 0.3, so all existing test vectors continue to decrypt. The 1088-byte ciphertext wire format is unchanged. Notable: `ExpandedKeyEncoding` is marked deprecated in 0.3.0; the crate authors recommend `DecapsulationKey::from_seed` (64-byte d||z) instead. We can't switch because BC does not expose the seed via `GetEncoded()`, so we accept the deprecation warning. --- impl/rust/pqf-reader/Cargo.toml | 15 +++++++----- impl/rust/pqf-reader/src/reader.rs | 39 ++++++++++++++++++------------ 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/impl/rust/pqf-reader/Cargo.toml b/impl/rust/pqf-reader/Cargo.toml index 2017d77..fd0eada 100644 --- a/impl/rust/pqf-reader/Cargo.toml +++ b/impl/rust/pqf-reader/Cargo.toml @@ -17,12 +17,15 @@ path = "src/bin/conformance.rs" [dependencies] # Hybrid KEM x25519-dalek = { version = "2.0", features = ["static_secrets"] } -# Pinned to 0.2 (not 0.3) because ml-kem 0.3 reorganized its public API: -# `KemCore`, `Encoded`, and `EncodedSizeUser` were renamed / moved, and -# `Ciphertext::try_from` changed shape. Bumping requires a code-side -# rewrite of reader.rs and writer/lib.rs that's tracked as a follow-up; -# the dep stack itself is otherwise compatible. -ml-kem = "0.2" +# ml-kem 0.3 moved KemCore to `kem::Kem`, replaced `EncodedSizeUser` with +# the (deprecated but interop-required) `ExpandedKeyEncoding` trait, and +# moved per-parameter type aliases (Ciphertext, DecapsulationKey) into +# `ml_kem::ml_kem_768`. The 2400-byte FIPS 203 expanded layout that +# BouncyCastle's `MLKemPrivateKeyParameters.GetEncoded()` produces is +# preserved bit-for-bit, so interop still holds; reader.rs uses +# `from_expanded_bytes` rather than the new seed-based init because BC +# does not expose the d||z seed. +ml-kem = "0.3.0" # Hybrid signatures ed25519-dalek = { version = "2.1", features = ["std"] } diff --git a/impl/rust/pqf-reader/src/reader.rs b/impl/rust/pqf-reader/src/reader.rs index 822c0e5..5fdc7f1 100644 --- a/impl/rust/pqf-reader/src/reader.rs +++ b/impl/rust/pqf-reader/src/reader.rs @@ -6,8 +6,10 @@ use aes_gcm::{Aes256Gcm, Nonce}; use ed25519_dalek::{Signature as EdSig, Verifier, VerifyingKey as EdVerifyingKey}; use hkdf::Hkdf; +use ml_kem::array::Array; use ml_kem::kem::Decapsulate; -use ml_kem::{Encoded, EncodedSizeUser, KemCore, MlKem768}; +use ml_kem::ml_kem_768::{Ciphertext as MlKem768Ct, DecapsulationKey as MlKem768Dk}; +use ml_kem::ExpandedKeyEncoding; use sha2::{Digest, Sha256}; use sha3::Sha3_256; use x25519_dalek::{PublicKey as XPub, StaticSecret as XSec}; @@ -350,13 +352,12 @@ pub fn decrypt(parsed: &ParsedFile, identity: &Identity) -> Result> { let ss_classical = x_sec.diffie_hellman(&epk); // ML-KEM-768 decapsulation. ss_M (X-Wing) = ML-KEM-Decap(dk_M, ct_M). - let ct_arr = - ml_kem::Ciphertext::::try_from(r.pqc_ct.as_slice()).map_err(|_| { - PqfError::new( - RefusalReason::BinaryFieldLengthMismatch, - "ML-KEM-768 ciphertext length mismatch", - ) - })?; + let ct_arr = MlKem768Ct::try_from(r.pqc_ct.as_slice()).map_err(|_| { + PqfError::new( + RefusalReason::BinaryFieldLengthMismatch, + "ML-KEM-768 ciphertext length mismatch", + ) + })?; let ss_pqc = match mlkem_dk.decapsulate(&ct_arr) { Ok(ss) => ss, Err(_) => continue, @@ -536,15 +537,21 @@ fn verify_mldsa87(pub_key: &[u8], message: &[u8], sig: &[u8]) -> bool { vk.verify_with_context(message, &[], &parsed) } -fn decode_mlkem_dk( - bytes: &[u8], -) -> Result<::DecapsulationKey> { - type Dk = ::DecapsulationKey; - let encoded: &Encoded = bytes.try_into().map_err(|_| { +fn decode_mlkem_dk(bytes: &[u8]) -> Result { + let encoded: &Array::EncodedSize> = + bytes.try_into().map_err(|_| { + PqfError::new( + RefusalReason::BinaryFieldLengthMismatch, + format!( + "ML-KEM-768 decapsulation key length {} != expected", + bytes.len() + ), + ) + })?; + MlKem768Dk::from_expanded_bytes(encoded).map_err(|_| { PqfError::new( RefusalReason::BinaryFieldLengthMismatch, - format!("ML-KEM-768 decapsulation key length {} != expected", bytes.len()), + "ML-KEM-768 expanded key validation failed".to_string(), ) - })?; - Ok(::from_bytes(encoded)) + }) } From 0c337f772e2356128c03c908cbfd90805f68c905 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Thu, 4 Jun 2026 05:18:43 -0400 Subject: [PATCH 2/5] deps(rust): bump pqf-writer to ml-kem 0.3 (workspace alignment) The pqf-reader bump in this branch made cargo unify the transitive `kem` crate across both ml-kem versions, which broke ml-kem 0.2's compile (its source was written against the older `kem` trait shape). Both crates must move together. Writer-side changes: - `Encapsulate` trait moved to crate root and renamed its required method to `encapsulate_with_rng(rng)` (the old `encapsulate(rng)` was a single method; now the trait splits into a required RNG method and a no-arg convenience). Return is now an infallible tuple instead of Result. - `EncodedSizeUser::from_bytes` is gone; construct `EncapsulationKey` via `TryKeyInit::new_from_slice(bytes) -> Result<_, InvalidKey>`. - Per-parameter aliases live under `ml_kem::ml_kem_768`. - The "deterministic" feature flag was renamed to "hazmat" and the `EncapsulateDeterministic` trait promoted to an inherent method on `EncapsulationKey` (same name, same shape). - `MlKem768Params` is gone; `EncapsulationKey` (the crate root struct parameterized by Kem) is the new form. The X-Wing draft KAT in tests/xwing_draft_kat.rs replays the published IETF vectors via deterministic encap; that's the load-bearing test for FIPS 203 bit-compatibility across this version bump. --- impl/rust/pqf-writer/Cargo.toml | 13 ++++++++----- impl/rust/pqf-writer/src/lib.rs | 17 +++++++---------- impl/rust/pqf-writer/tests/xwing_draft_kat.rs | 19 ++++++++----------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/impl/rust/pqf-writer/Cargo.toml b/impl/rust/pqf-writer/Cargo.toml index 96de2a5..960f395 100644 --- a/impl/rust/pqf-writer/Cargo.toml +++ b/impl/rust/pqf-writer/Cargo.toml @@ -17,11 +17,14 @@ pqf-reader = { path = "../pqf-reader" } # Hybrid KEM x25519-dalek = { version = "2.0", features = ["static_secrets"] } -# Pinned to 0.2 (see pqf-reader/Cargo.toml for the API-rename rationale). -# The "deterministic" feature exposes EncapsulationKey::encapsulate_deterministic -# (encap from explicit 32-byte randomness). Required by the X-Wing KAT in -# tests/xwing_draft_kat.rs to replay the published IETF draft vectors. -ml-kem = { version = "0.2", features = ["deterministic"] } +# 0.3 must match pqf-reader's version: cargo unifies the transitive `kem` +# crate across both, and 0.2's source no longer compiles against the newer +# `kem` API. The "hazmat" feature is the renamed-and-rescoped successor to +# 0.2's "deterministic" feature; it exposes EncapsulationKey:: +# encapsulate_deterministic (encap from explicit 32-byte randomness), +# required by the X-Wing KAT in tests/xwing_draft_kat.rs to replay the +# published IETF draft vectors. +ml-kem = { version = "0.3.0", features = ["hazmat"] } # Hybrid signatures ed25519-dalek = { version = "2.1", features = ["std", "rand_core"] } diff --git a/impl/rust/pqf-writer/src/lib.rs b/impl/rust/pqf-writer/src/lib.rs index 8ada429..1f24280 100644 --- a/impl/rust/pqf-writer/src/lib.rs +++ b/impl/rust/pqf-writer/src/lib.rs @@ -38,8 +38,8 @@ use aes_gcm::{Aes256Gcm, Nonce}; use ed25519_dalek::{Signer, SigningKey as EdSigningKey}; use hkdf::Hkdf; use ml_dsa::{MlDsa87, SigningKey as MlDsaSigningKey}; -use ml_kem::kem::Encapsulate; -use ml_kem::{Encoded, EncodedSizeUser, KemCore, MlKem768}; +use ml_kem::ml_kem_768::EncapsulationKey as MlKem768Ek; +use ml_kem::{Encapsulate, TryKeyInit}; use rand::rngs::OsRng; use rand::RngCore; use sha2::{Digest, Sha256}; @@ -365,17 +365,14 @@ fn build_recipient_block( // ML-KEM-768 encapsulation -> PQ shared secret + ciphertext. let mlkem_pk_bytes = recipient.mlkem_pub(); - let encoded: &Encoded<::EncapsulationKey> = mlkem_pk_bytes - .try_into() - .map_err(|_| WriterError::RecipientFieldLength { + let ek = MlKem768Ek::new_from_slice(mlkem_pk_bytes).map_err(|_| { + WriterError::RecipientFieldLength { field: "ml_kem_768_public_key", got: mlkem_pk_bytes.len(), want: MLKEM_PK_LEN, - })?; - let ek = <::EncapsulationKey as EncodedSizeUser>::from_bytes(encoded); - let (ct_arr, ss_pqc) = ek - .encapsulate(&mut OsRng) - .map_err(|_| WriterError::NotYetImplemented("ML-KEM-768 encapsulate failed"))?; + } + })?; + let (ct_arr, ss_pqc) = ek.encapsulate_with_rng(&mut OsRng); let pqc_ct: Vec = ct_arr.as_slice().to_vec(); // X-Wing combiner per draft-connolly-cfrg-xwing-kem: diff --git a/impl/rust/pqf-writer/tests/xwing_draft_kat.rs b/impl/rust/pqf-writer/tests/xwing_draft_kat.rs index fc17212..6718fc6 100644 --- a/impl/rust/pqf-writer/tests/xwing_draft_kat.rs +++ b/impl/rust/pqf-writer/tests/xwing_draft_kat.rs @@ -30,9 +30,8 @@ //! every public X-Wing implementation we have spot-checked. use hex::FromHex; -use ml_kem::{kem::EncapsulationKey as MlKemEncapsulationKey, KemCore, MlKem768}; -use ml_kem::{Encoded, EncodedSizeUser}; -use ml_kem::EncapsulateDeterministic; +use ml_kem::ml_kem_768::EncapsulationKey as MlKem768Ek; +use ml_kem::{TryKeyInit, B32}; use sha3::{Digest, Sha3_256}; use x25519_dalek::{PublicKey, StaticSecret}; @@ -135,15 +134,13 @@ fn xwing_combiner_matches_published_draft_vector() { // ---- (4) ML-KEM-768 deterministic encapsulation ---------------------- // Load pk_M as an ML-KEM-768 EncapsulationKey and feed it the - // deterministic `m`. The crate's "deterministic" feature exposes - // exactly this entry point; we use no internal/hazmat hooks. - type Ek = ::EncapsulationKey; - let pk_m_encoded: &Encoded = pk_m_bytes - .try_into() + // deterministic `m`. ml-kem 0.3 promoted `encapsulate_deterministic` + // to an inherent method gated behind the `hazmat` feature (renamed + // from 0.2's `deterministic` feature); the trait `EncapsulateDeterministic` + // is gone. + let ek = MlKem768Ek::new_from_slice(pk_m_bytes) .expect("pk_M length matches ML-KEM-768 ek encoding"); - let ek: MlKemEncapsulationKey = - ::from_bytes(pk_m_encoded); - let m_b32: ml_kem::B32 = m_arr.into(); + let m_b32: B32 = m_arr.into(); let (ct_m, ss_m) = ek .encapsulate_deterministic(&m_b32) .expect("ML-KEM-768 EncapDeterministic"); From d867d2f97073dd707b21e0b1c001e9bdd9aef7ed Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Thu, 4 Jun 2026 05:25:12 -0400 Subject: [PATCH 3/5] fix(rust): ml-kem 0.3 decapsulate is infallible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In ml-kem 0.2 `DecapsulationKey::decapsulate` returned `Result` even though the `()` error never fired in practice — FIPS 203 implicit rejection always returns a pseudorandom secret, not an error. 0.3 made the inherent method honest and returns `Array` directly. Drop the Result match. The constant-time recipient-trial loop still iterates every recipient regardless of match, and the AEAD tag below remains the sole signal of a real match. --- impl/rust/pqf-reader/src/reader.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/impl/rust/pqf-reader/src/reader.rs b/impl/rust/pqf-reader/src/reader.rs index 5fdc7f1..ecc194a 100644 --- a/impl/rust/pqf-reader/src/reader.rs +++ b/impl/rust/pqf-reader/src/reader.rs @@ -358,10 +358,11 @@ pub fn decrypt(parsed: &ParsedFile, identity: &Identity) -> Result> { "ML-KEM-768 ciphertext length mismatch", ) })?; - let ss_pqc = match mlkem_dk.decapsulate(&ct_arr) { - Ok(ss) => ss, - Err(_) => continue, - }; + // ml-kem 0.3's Decapsulate is infallible — FIPS 203 implicit + // rejection returns a pseudorandom secret on a malformed/wrong + // ciphertext rather than an error, so the AEAD tag below is the + // sole signal of a real match (see the spec §6.5 comment above). + let ss_pqc = mlkem_dk.decapsulate(&ct_arr); // X-Wing combiner: KEK = SHA3-256(ss_M || ss_X || ct_X || pk_X || label) // (draft-connolly-cfrg-xwing-kem). pk_X is the recipient's own From 9ae269f070ac71da98aeeea1d790f90e338b33d2 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Thu, 4 Jun 2026 05:30:42 -0400 Subject: [PATCH 4/5] fix(rust): use encapsulate_deterministic to avoid RNG version mismatch ml-kem 0.3's `encapsulate_with_rng` requires a `CryptoRng` from the newer rand_core it re-exports; the writer uses rand 0.8 everywhere else (x25519-dalek's `random_from_rng`, AEAD nonce filling). Mixing versions inside one fn would force a deep import chain. Switch the encap call to `encapsulate_deterministic` (gated by the "hazmat" feature, already enabled): generate 32 CSPRNG bytes via rand 0.8 OsRng and pass them in. Cryptographically equivalent to the RNG-based variant since the bytes come from the same OS CSPRNG. --- impl/rust/pqf-writer/src/lib.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/impl/rust/pqf-writer/src/lib.rs b/impl/rust/pqf-writer/src/lib.rs index 1f24280..76b4805 100644 --- a/impl/rust/pqf-writer/src/lib.rs +++ b/impl/rust/pqf-writer/src/lib.rs @@ -39,7 +39,7 @@ use ed25519_dalek::{Signer, SigningKey as EdSigningKey}; use hkdf::Hkdf; use ml_dsa::{MlDsa87, SigningKey as MlDsaSigningKey}; use ml_kem::ml_kem_768::EncapsulationKey as MlKem768Ek; -use ml_kem::{Encapsulate, TryKeyInit}; +use ml_kem::{TryKeyInit, B32}; use rand::rngs::OsRng; use rand::RngCore; use sha2::{Digest, Sha256}; @@ -372,7 +372,18 @@ fn build_recipient_block( want: MLKEM_PK_LEN, } })?; - let (ct_arr, ss_pqc) = ek.encapsulate_with_rng(&mut OsRng); + // ml-kem 0.3's `encapsulate_with_rng` requires a `CryptoRng` from the + // newer rand_core version it re-exports — incompatible with the + // rand 0.8 `OsRng` we use everywhere else. The "hazmat" feature + // gates `encapsulate_deterministic`, which takes 32 explicit bytes + // and is cryptographically equivalent to `encapsulate` when those + // bytes come from a CSPRNG. Fill from rand 0.8's OsRng. + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let seed_b32: B32 = seed.into(); + let (ct_arr, ss_pqc) = ek + .encapsulate_deterministic(&seed_b32) + .map_err(|_| WriterError::NotYetImplemented("ML-KEM-768 encapsulate failed"))?; let pqc_ct: Vec = ct_arr.as_slice().to_vec(); // X-Wing combiner per draft-connolly-cfrg-xwing-kem: From ec7914b6dde9249923da6a519dcd710aaa2b605b Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Thu, 4 Jun 2026 05:34:28 -0400 Subject: [PATCH 5/5] fix(rust): ml-kem 0.3 encapsulate_deterministic is infallible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as decapsulate — the 0.2 Result return was a vestigial () error type that never fired. 0.3 honestly returns the `(Ciphertext, SharedKey)` tuple directly. --- impl/rust/pqf-writer/src/lib.rs | 4 +--- impl/rust/pqf-writer/tests/xwing_draft_kat.rs | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/impl/rust/pqf-writer/src/lib.rs b/impl/rust/pqf-writer/src/lib.rs index 76b4805..b377802 100644 --- a/impl/rust/pqf-writer/src/lib.rs +++ b/impl/rust/pqf-writer/src/lib.rs @@ -381,9 +381,7 @@ fn build_recipient_block( let mut seed = [0u8; 32]; OsRng.fill_bytes(&mut seed); let seed_b32: B32 = seed.into(); - let (ct_arr, ss_pqc) = ek - .encapsulate_deterministic(&seed_b32) - .map_err(|_| WriterError::NotYetImplemented("ML-KEM-768 encapsulate failed"))?; + let (ct_arr, ss_pqc) = ek.encapsulate_deterministic(&seed_b32); let pqc_ct: Vec = ct_arr.as_slice().to_vec(); // X-Wing combiner per draft-connolly-cfrg-xwing-kem: diff --git a/impl/rust/pqf-writer/tests/xwing_draft_kat.rs b/impl/rust/pqf-writer/tests/xwing_draft_kat.rs index 6718fc6..6b61b2a 100644 --- a/impl/rust/pqf-writer/tests/xwing_draft_kat.rs +++ b/impl/rust/pqf-writer/tests/xwing_draft_kat.rs @@ -141,9 +141,7 @@ fn xwing_combiner_matches_published_draft_vector() { let ek = MlKem768Ek::new_from_slice(pk_m_bytes) .expect("pk_M length matches ML-KEM-768 ek encoding"); let m_b32: B32 = m_arr.into(); - let (ct_m, ss_m) = ek - .encapsulate_deterministic(&m_b32) - .expect("ML-KEM-768 EncapDeterministic"); + let (ct_m, ss_m) = ek.encapsulate_deterministic(&m_b32); // Sanity: the ML-KEM-768 ciphertext is 1088 bytes. assert_eq!(ct_m.as_slice().len(), 1088, "ct_M length"); assert_eq!(ss_m.as_slice().len(), 32, "ss_M length");