Skip to content
Draft
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
2,940 changes: 2,940 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions basic_credential/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@ p521 = "0.13"
secrecy = { version = "0.8", features = ["serde"] }
rand_core = "0.6"
getrandom = { version = "0.2", features = ["js"] }
ml-dsa = { version = "0.1.1", default-features = false, features = ["alloc", "zeroize"] }
zeroize = "1"

[dev-dependencies]
rand = "0.8"
openmls_rust_crypto = { version = "0.2.0", path = "../openmls_rust_crypto" }

[features]
clonable = [] # Make the keys clonable
Expand Down
124 changes: 124 additions & 0 deletions basic_credential/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,43 @@ use openmls_traits::{
types::{CryptoError, SignatureScheme},
};

/// Generate an ML-DSA key pair for parameter set P.
///
/// The private key is stored as the 32-byte FIPS-204 seed (xi); the public key
/// is the raw FIPS-204 verifying-key encoding. This mirrors
/// openmls_rust_crypto's mldsa_key_gen so that signatures produced from the
/// stored seed verify via the provider's verify_signature.
fn mldsa_key_gen<P: ml_dsa::MlDsaParams>(
csprng: &mut impl rand_core::CryptoRngCore,
) -> Result<(SecretVec<u8>, Vec<u8>), CryptoError> {
let mut seed = zeroize::Zeroizing::new(ml_dsa::B32::default());
csprng
.try_fill_bytes(&mut seed)
.map_err(|_| CryptoError::InsufficientRandomness)?;
let signing_key = ml_dsa::SigningKey::<P>::from_seed(&seed);
let public = signing_key.expanded_key().verifying_key().encode().to_vec();
let private: Vec<u8> = seed.to_vec();
Ok((private.into(), public))
}

/// Confirm a stored ML-DSA private seed (FIPS-204 xi) actually derives the given
/// public verifying key, mirroring `mldsa_key_gen`'s seed-to-public mapping. The
/// reconstructed seed is a secret, so it is scrubbed on drop.
fn mldsa_keypair_matches<P: ml_dsa::MlDsaParams>(
private: &[u8],
public: &[u8],
) -> Result<(), CryptoError> {
let seed = zeroize::Zeroizing::new(
ml_dsa::B32::try_from(private).map_err(|_| CryptoError::InvalidKey)?,
);
let signing_key = ml_dsa::SigningKey::<P>::from_seed(&seed);
let derived = signing_key.expanded_key().verifying_key().encode();
if derived.as_slice() != public {
return Err(CryptoError::MismatchKeypair);
}
Ok(())
}

fn expose_sk<S: serde::Serializer>(data: &SecretVec<u8>, ser: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq as _;
let exposed = data.expose_secret();
Expand Down Expand Up @@ -136,6 +173,8 @@ impl SignatureKeyPair {
let sk_pk: Vec<u8> = sk.to_bytes().into();
(sk_pk.into(), pk.to_bytes().into())
}
SignatureScheme::MLDSA65 => mldsa_key_gen::<ml_dsa::MlDsa65>(csprng)?,
SignatureScheme::MLDSA87 => mldsa_key_gen::<ml_dsa::MlDsa87>(csprng)?,
_ => return Err(CryptoError::UnsupportedSignatureScheme),
};

Expand Down Expand Up @@ -206,6 +245,12 @@ impl SignatureKeyPair {
return Err(CryptoError::MismatchKeypair);
}
}
SignatureScheme::MLDSA65 => {
mldsa_keypair_matches::<ml_dsa::MlDsa65>(&private, &public)?
}
SignatureScheme::MLDSA87 => {
mldsa_keypair_matches::<ml_dsa::MlDsa87>(&private, &public)?
}
_ => {}
};

Expand Down Expand Up @@ -253,6 +298,63 @@ impl SignatureKeyPair {
#[cfg(test)]
pub mod tests {
use super::*;
use openmls_traits::{crypto::OpenMlsCrypto, signatures::Signer};

/// A SignatureKeyPair ML-DSA signature MUST verify via the provider's
/// verify_signature. This proves sign<->verify interop and that the
/// private (32-byte seed) / public (raw FIPS-204) representations match
/// what the provider expects.
#[test]
fn mldsa_sign_verifies_via_provider() {
let provider = openmls_rust_crypto::RustCrypto::default();
let schemes = [SignatureScheme::MLDSA65, SignatureScheme::MLDSA87];
let expected_pk_len = [1952usize, 2592usize];

for (scheme, pk_len) in schemes.into_iter().zip(expected_pk_len) {
let kp = SignatureKeyPair::new(scheme, &mut rand::thread_rng()).unwrap();

// Private key is the 32-byte FIPS-204 seed
assert_eq!(
kp.private.expose_secret().len(),
32,
"{scheme:?} private key must be the 32-byte seed"
);
// Public key is the raw FIPS-204 verifying-key encoding
assert_eq!(
kp.public.len(),
pk_len,
"{scheme:?} public key must be the raw FIPS-204 encoding"
);

let msg = b"ml-dsa sign<->provider-verify interop";
let sig = kp.sign(msg).expect("ML-DSA signing must succeed");

// The signature MUST verify via the provider
provider
.verify_signature(scheme, msg, kp.public(), &sig)
.unwrap_or_else(|e| panic!("{scheme:?} provider verify must succeed: {e:?}"));

// Tampering with the message must fail verification
let mut bad_msg = msg.to_vec();
bad_msg[0] ^= 0xFF;
assert!(
provider
.verify_signature(scheme, &bad_msg, kp.public(), &sig)
.is_err(),
"{scheme:?} verify must fail on a tampered message"
);

// Tampering with the signature must fail verification
let mut bad_sig = sig.clone();
bad_sig[0] ^= 0xFF;
assert!(
provider
.verify_signature(scheme, msg, kp.public(), &bad_sig)
.is_err(),
"{scheme:?} verify must fail on a tampered signature"
);
}
}

#[test]
fn signature_keypair_try_from_raw_should_work() {
Expand All @@ -261,6 +363,8 @@ pub mod tests {
SignatureScheme::ECDSA_SECP256R1_SHA256,
SignatureScheme::ECDSA_SECP384R1_SHA384,
SignatureScheme::ECDSA_SECP521R1_SHA512,
SignatureScheme::MLDSA65,
SignatureScheme::MLDSA87,
];
for scheme in schemes {
let kp = SignatureKeyPair::new(scheme, &mut rand::thread_rng()).unwrap();
Expand All @@ -269,4 +373,24 @@ pub mod tests {
SignatureKeyPair::try_from_raw(scheme, sk, pk).unwrap();
}
}

/// `try_from_raw` must reject a private/public pair that does not belong
/// together - including for the ML-DSA schemes, which previously fell into
/// the no-op `_ => {}` arm and were accepted without any check.
#[test]
fn signature_keypair_try_from_raw_rejects_mismatched_mldsa() {
for scheme in [SignatureScheme::MLDSA65, SignatureScheme::MLDSA87] {
let kp1 = SignatureKeyPair::new(scheme, &mut rand::thread_rng()).unwrap();
let kp2 = SignatureKeyPair::new(scheme, &mut rand::thread_rng()).unwrap();
let mismatched = SignatureKeyPair::try_from_raw(
scheme,
kp1.private.expose_secret().clone(),
kp2.public.clone(),
);
assert!(
matches!(mismatched, Err(CryptoError::MismatchKeypair)),
"{scheme:?} try_from_raw must reject a mismatched ML-DSA keypair, got {mismatched:?}"
);
}
}
}
1 change: 1 addition & 0 deletions openmls/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ openmls_basic_credential = { version = "0.2.0", path = "../basic_credential", fe
openmls_x509_credential = { version = "0.2.0", path = "../x509_credential" }
x509-cert = "0.2"
subtle = "2.5"
zeroize = { version = "1.6", default-features = false, features = ["alloc"] }
web-time = "1.1.0"
indexmap = "2.0"
itertools = "0.12"
Expand Down
1 change: 1 addition & 0 deletions openmls/src/ciphersuite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod codec;
pub mod hpke;
mod kdf_label;
mod mac;
mod pq_kdf;
mod reuse_guard;
mod secret;

Expand Down
115 changes: 115 additions & 0 deletions openmls/src/ciphersuite/pq_kdf.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! SHAKE256 one-shot KDF for the official PQ MLS key schedule.
//!
//! draft-ietf-mls-pq-ciphersuites, "One shot KDFs in MLS" (PR #21, branch
//! `oneshot-kdf` - PROVISIONAL, unmerged, under WGLC):
//!
//! Extract(secret, salt) = Derive(concat(salt, secret), Nh)
//! Expand(secret, label, length) = Derive(concat(label, secret), length)
//!
//! `Derive = SHAKE256(M, d = 8*L)` (draft-ietf-hpke-pq-04 section 5); SHAKE256 `Nh = 64`.
//! This module is the single home of that construction across both providers.

use openmls_traits::{crypto::OpenMlsCrypto, types::CryptoError};
use tls_codec::SecretVLBytes;
use zeroize::Zeroizing;

/// SHAKE256 KDF.Nh - the Extract output length (draft-ietf-hpke-pq-04 section 5, Table 1)
const SHAKE256_NH: usize = 64;

/// TODO(PR#21): WGLC review proposed prefixing BOTH concats with this domain
/// separator ("MLS 1.0 expand") so Extract and Expand cannot collide. It is NOT
/// adopted upstream and NOT applied here. If the WG merges it, set this to the
/// label bytes; both ops below prepend it automatically. Keep empty until then.
const SHAKE_KDF_DOMAIN_SEP: &[u8] = b"";

/// Extract(secret=ikm, salt) = Derive(SHAKE_KDF_DOMAIN_SEP || salt || ikm, Nh)
pub(crate) fn shake256_extract(
crypto: &impl OpenMlsCrypto,
salt: &[u8],
ikm: &[u8],
) -> Result<SecretVLBytes, CryptoError> {
let mut input = Zeroizing::new(Vec::with_capacity(
SHAKE_KDF_DOMAIN_SEP.len() + salt.len() + ikm.len(),
));
input.extend_from_slice(SHAKE_KDF_DOMAIN_SEP);
input.extend_from_slice(salt);
input.extend_from_slice(ikm);
crypto.shake256_kdf_derive(&input, SHAKE256_NH)
}

/// Expand(secret=prk, label=info, length) = Derive(SHAKE_KDF_DOMAIN_SEP || info || prk, length)
pub(crate) fn shake256_expand(
crypto: &impl OpenMlsCrypto,
prk: &[u8],
info: &[u8],
length: usize,
) -> Result<SecretVLBytes, CryptoError> {
let mut input = Zeroizing::new(Vec::with_capacity(
SHAKE_KDF_DOMAIN_SEP.len() + info.len() + prk.len(),
));
input.extend_from_slice(SHAKE_KDF_DOMAIN_SEP);
input.extend_from_slice(info);
input.extend_from_slice(prk);
crypto.shake256_kdf_derive(&input, length)
}

#[cfg(test)]
mod tests {
use super::*;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::OpenMlsCryptoProvider;

#[test]
fn extract_is_derive_of_salt_then_ikm_to_nh() {
let backend = OpenMlsRustCrypto::default();
let crypto = backend.crypto();
let salt = b"salt-bytes";
let ikm = b"ikm-bytes";
// Extract(secret=ikm, salt) = Derive(salt || ikm, Nh=64)
let mut expected_input = Vec::new();
expected_input.extend_from_slice(salt);
expected_input.extend_from_slice(ikm);
let expected = crypto
.shake256_kdf_derive(&expected_input, SHAKE256_NH)
.unwrap();
let got = shake256_extract(crypto, salt, ikm).unwrap();
assert_eq!(got.as_slice(), expected.as_slice());
assert_eq!(got.as_slice().len(), 64, "SHAKE256 Nh");
}

#[test]
fn expand_is_derive_of_info_then_prk_to_length() {
let backend = OpenMlsRustCrypto::default();
let crypto = backend.crypto();
let prk = b"prk-bytes";
let info = b"info-bytes";
// Expand(secret=prk, label=info, length) = Derive(info || prk, length)
let mut expected_input = Vec::new();
expected_input.extend_from_slice(info);
expected_input.extend_from_slice(prk);
let expected = crypto.shake256_kdf_derive(&expected_input, 48).unwrap();
let got = shake256_expand(crypto, prk, info, 48).unwrap();
assert_eq!(got.as_slice(), expected.as_slice());
assert_eq!(got.as_slice().len(), 48);
}

#[test]
fn concat_order_matters_extract_ne_swapped() {
// Guards against silently swapping salt/ikm: order is normative
let backend = OpenMlsRustCrypto::default();
let crypto = backend.crypto();
let a = shake256_extract(crypto, b"AAAA", b"BBBB").unwrap();
let b = shake256_extract(crypto, b"BBBB", b"AAAA").unwrap();
assert_ne!(a.as_slice(), b.as_slice());
}

#[test]
fn no_domain_sep_prefix_applied_yet() {
// PR#21 domain-sep label is NOT adopted: input must be exactly salt||ikm
let backend = OpenMlsRustCrypto::default();
let crypto = backend.crypto();
let got = shake256_extract(crypto, b"S", b"I").unwrap();
let bare = crypto.shake256_kdf_derive(b"SI", SHAKE256_NH).unwrap();
assert_eq!(got.as_slice(), bare.as_slice());
}
}
Loading
Loading