Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions impl/rust/pqf-reader/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
48 changes: 28 additions & 20 deletions impl/rust/pqf-reader/src/reader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -350,17 +352,17 @@ pub fn decrypt(parsed: &ParsedFile, identity: &Identity) -> Result<Vec<u8>> {
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::<MlKem768>::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,
};
let ct_arr = MlKem768Ct::try_from(r.pqc_ct.as_slice()).map_err(|_| {
PqfError::new(
RefusalReason::BinaryFieldLengthMismatch,
"ML-KEM-768 ciphertext length mismatch",
)
})?;
// 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
Expand Down Expand Up @@ -536,15 +538,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<<MlKem768 as KemCore>::DecapsulationKey> {
type Dk = <MlKem768 as KemCore>::DecapsulationKey;
let encoded: &Encoded<Dk> = bytes.try_into().map_err(|_| {
fn decode_mlkem_dk(bytes: &[u8]) -> Result<MlKem768Dk> {
let encoded: &Array<u8, <MlKem768Dk as ExpandedKeyEncoding>::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(<Dk as EncodedSizeUser>::from_bytes(encoded))
})
}
13 changes: 8 additions & 5 deletions impl/rust/pqf-writer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
26 changes: 16 additions & 10 deletions impl/rust/pqf-writer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{TryKeyInit, B32};
use rand::rngs::OsRng;
use rand::RngCore;
use sha2::{Digest, Sha256};
Expand Down Expand Up @@ -365,17 +365,23 @@ fn build_recipient_block(

// ML-KEM-768 encapsulation -> PQ shared secret + ciphertext.
let mlkem_pk_bytes = recipient.mlkem_pub();
let encoded: &Encoded<<MlKem768 as KemCore>::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 = <<MlKem768 as KemCore>::EncapsulationKey as EncodedSizeUser>::from_bytes(encoded);
let (ct_arr, ss_pqc) = ek
.encapsulate(&mut OsRng)
.map_err(|_| WriterError::NotYetImplemented("ML-KEM-768 encapsulate failed"))?;
}
})?;
// 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);
let pqc_ct: Vec<u8> = ct_arr.as_slice().to_vec();

// X-Wing combiner per draft-connolly-cfrg-xwing-kem:
Expand Down
23 changes: 9 additions & 14 deletions impl/rust/pqf-writer/tests/xwing_draft_kat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -135,18 +134,14 @@ 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 = <MlKem768 as KemCore>::EncapsulationKey;
let pk_m_encoded: &Encoded<Ek> = 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<ml_kem::MlKem768Params> =
<Ek as EncodedSizeUser>::from_bytes(pk_m_encoded);
let m_b32: ml_kem::B32 = m_arr.into();
let (ct_m, ss_m) = ek
.encapsulate_deterministic(&m_b32)
.expect("ML-KEM-768 EncapDeterministic");
let m_b32: B32 = m_arr.into();
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");
Expand Down
Loading