From c23fb47ccf42dcfb2d735b11a62d25c296f5e225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Wed, 29 Jul 2026 14:40:33 +0200 Subject: [PATCH] feat(stdlib): Add ML-DSA-44/65/87 signature support Adds the FIPS 204 module-lattice signature algorithms as key and signature algorithms, wires them through the OpenSSL backend and the PKI actions, and adds a post-quantum root CA example. Ceremony authors get `algorithm: ML-DSA-87` on generate_keypair; generate_csr and issue_certificate derive the signature algorithm from the key, so no new DSL surface is introduced. Implementation notes: - Key generation goes through EVP_PKEY_fromdata with an explicit 32-byte seed. FIPS 204 expands the whole keypair deterministically from that seed, and it is the only generation route the openssl crate exposes without raw FFI, which the workspace forbids. - Signing takes the digest-free EVP_DigestSign path: ML-DSA signs the message directly with no pre-hash. - ML-DSA bindings are gated on OpenSSL 3.5 at build time, so rite-openssl gains a build script that re-derives the ossl350 cfg from the version openssl-sys publishes via its links metadata. Builds against older OpenSSL compile without ML-DSA and report it as an unsupported algorithm. openssl-sys is a dependency with no source reference for this reason, and says so, since removing it as unused would silently drop ML-DSA everywhere. - CSR self-signature verification delegates ML-DSA to the OpenSSL provider, since the existing RSA and ECDSA branches use RustCrypto and no in-tree verifier covers ML-DSA. Tests pin the FIPS 204 signature sizes, a seed-to-public-key known answer cross-checked against the OpenSSL CLI, the hedged (non-reproducible) nature of signing, and the certificate OIDs as a wire contract. --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 1 + crates/rite-openssl/Cargo.toml | 5 + crates/rite-openssl/build.rs | 33 ++ crates/rite-openssl/src/backend.rs | 311 +++++++++++++++++- crates/rite-openssl/src/lib.rs | 2 +- crates/rite-sdk/src/types.rs | 21 ++ .../rite-stdlib/src/pki/issue_certificate.rs | 33 +- crates/rite-stdlib/src/pki/oids.rs | 72 ++++ examples/pki/README.md | 28 ++ examples/pki/root_ca_post_quantum.rite.yaml | 173 ++++++++++ 12 files changed, 675 insertions(+), 6 deletions(-) create mode 100644 crates/rite-openssl/build.rs create mode 100644 examples/pki/root_ca_post_quantum.rite.yaml diff --git a/Cargo.lock b/Cargo.lock index 613846b..1522d0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2048,6 +2048,7 @@ version = "0.4.2" dependencies = [ "base16ct 1.0.0", "openssl", + "openssl-sys", "rite-sdk", "tempfile", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 5e1f2dd..172cf17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ base32ct = { version = "0.3.1", features = ["alloc"] } base64ct = { version = "1.8.3", features = ["alloc"] } rand = "0.10.1" openssl = { version = "0.10.78" } +openssl-sys = { version = "0.9.109" } yubikey = "0.8" tempfile = "3.27.0" clap = { version = "4.6.1", features = ["derive"] } diff --git a/README.md b/README.md index 5901a78..34359c0 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,7 @@ Both bundle the `rite-ls` language server. For other LSP-aware editors, run `rit - [ ] Teardown act on abort or failure - [x] **Cryptographic backends** - [x] OpenSSL: RSA, ECDSA-P256, signing, wrapping, PKI + - [x] Post-quantum: ML-DSA-44/65/87 signatures and certificates (needs OpenSSL 3.5+) - [ ] Post-quantum: ML-KEM key encapsulation - [x] Hardware backends - [x] YubiKey PIV: key generation, signing, certificate read, on-device attestation diff --git a/crates/rite-openssl/Cargo.toml b/crates/rite-openssl/Cargo.toml index d7df6ea..791cfbe 100644 --- a/crates/rite-openssl/Cargo.toml +++ b/crates/rite-openssl/Cargo.toml @@ -18,6 +18,11 @@ vendored = ["openssl/vendored"] [dependencies] rite-sdk = { workspace = true } openssl = { workspace = true } +# Referenced by build.rs, not by the source. `openssl-sys` carries the +# `links = "openssl"` key, and Cargo only hands `DEP_OPENSSL_*` metadata to +# build scripts of *direct* dependents. Removing this as an "unused" dependency +# silently drops ML-DSA support from every build. +openssl-sys = { workspace = true } base16ct = { workspace = true } zeroize = { workspace = true } diff --git a/crates/rite-openssl/build.rs b/crates/rite-openssl/build.rs new file mode 100644 index 0000000..ebef7ce --- /dev/null +++ b/crates/rite-openssl/build.rs @@ -0,0 +1,33 @@ +//! Detects the linked OpenSSL version so ML-DSA support is compiled in only +//! where the provider exists. +//! +//! ML-DSA landed in OpenSSL 3.5, and the `openssl` crate gates the +//! corresponding bindings behind its own `ossl350` cfg. That cfg is private to +//! that crate, so this build script re-derives it from the version number +//! `openssl-sys` publishes through its `links` metadata. + +fn main() { + println!("cargo::rustc-check-cfg=cfg(ossl350)"); + + // LibreSSL and BoringSSL report an OpenSSL version number for source + // compatibility but do not ship the ML-DSA provider. Each signals itself + // through a separate variable, so bail out rather than trusting the + // version number alone. + if std::env::var_os("DEP_OPENSSL_LIBRESSL_VERSION_NUMBER").is_some() + || std::env::var_os("DEP_OPENSSL_BORINGSSL").is_some() + { + return; + } + + let Ok(raw) = std::env::var("DEP_OPENSSL_VERSION_NUMBER") else { + return; + }; + let Ok(version) = u64::from_str_radix(&raw, 16) else { + return; + }; + + // OPENSSL_VERSION_NUMBER is 0xMNN00PPSL, so 3.5.0 is 0x30500000. + if version >= 0x3050_0000 { + println!("cargo::rustc-cfg=ossl350"); + } +} diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index 6546014..18156f8 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -122,9 +122,11 @@ impl Backend for OpenSslBackend { } rite_sdk::backend_capabilities!( - /// Supports RSA-2048, RSA-4096, and ECDSA-P256 key generation and storage. + /// Supports RSA-2048, RSA-4096, ECDSA-P256, and (with OpenSSL 3.5+) + /// ML-DSA-44/65/87 key generation and storage. as_keystore_mut: KeyStoreBackend, - /// Supports RSA-PKCS1-v1.5 (SHA-256), RSA-PSS (SHA-256), and ECDSA-P256 (SHA-256) signing. + /// Supports RSA-PKCS1-v1.5 (SHA-256), RSA-PSS (SHA-256), ECDSA-P256 + /// (SHA-256), and (with OpenSSL 3.5+) ML-DSA-44/65/87 signing. as_sign_mut: SignBackend, /// Supports CMS-RSA-GCM and CMS-RSA-CBC key wrapping and unwrapping. as_transport_mut: KeyTransportBackend, @@ -167,8 +169,130 @@ fn detect_key_algorithm(pkey: &PKey) -> Result Result, BackendError> { + let key_type = ML_DSA_KEY_TYPES + .iter() + .find(|(candidate, _)| *candidate == algorithm) + .map(|(_, key_type)| *key_type) + .ok_or_else(|| { + BackendError::UnsupportedAlgorithm(format!( + "{algorithm} is not an ML-DSA parameter set" + )) + })?; + let mut seed = Zeroizing::new(vec![0u8; ML_DSA_SEED_LEN]); + openssl::rand::rand_bytes(&mut seed).map_err(|e| ossl_err("ML-DSA seed generation", &e))?; + PKey::private_key_from_seed(None, key_type, None, &seed) + .map_err(|e| ossl_err("ML-DSA key generation", &e)) +} + +#[cfg(not(ossl350))] +fn generate_ml_dsa(_algorithm: KeyAlgorithm) -> Result, BackendError> { + Err(unsupported_ml_dsa("key generation")) +} + +/// Sign with ML-DSA. +/// +/// FIPS 204 signs the message directly with no pre-hash, so this takes the +/// digest-free `EVP_DigestSign` path. Signing is hedged by default, so two +/// signatures over the same message with the same key differ. +#[cfg(ossl350)] +fn ml_dsa_sign(pkey: &PKeyRef, message: &[u8]) -> Result, BackendError> { + let mut signer = + Signer::new_without_digest(pkey).map_err(|e| ossl_err("Create ML-DSA signer", &e))?; + signer + .sign_oneshot_to_vec(message) + .map_err(|e| ossl_err("ML-DSA sign operation", &e)) +} + +#[cfg(not(ossl350))] +fn ml_dsa_sign(_pkey: &PKeyRef, _message: &[u8]) -> Result, BackendError> { + Err(unsupported_ml_dsa("signing")) +} + +/// Verify an ML-DSA signature against an SPKI DER public key, without a backend. +/// +/// Verification needs only the public key, so this takes no [`OpenSslBackend`] +/// and no [`KeyId`]. Signing-only devices (PIV cards, HSMs) can therefore have +/// their signatures checked through the same path as software keys. +/// +/// # Errors +/// +/// Returns [`BackendError::UnsupportedAlgorithm`] when the linked OpenSSL +/// predates 3.5, and [`BackendError::Other`] when the public key or signature +/// cannot be parsed. +#[cfg(ossl350)] +pub fn verify_ml_dsa_signature( + public_der: &[u8], + message: &[u8], + signature: &[u8], +) -> Result { + let pub_pkey = PKey::public_key_from_der(public_der) + .map_err(|e| ossl_err("Decode ML-DSA public key", &e))?; + let mut verifier = Verifier::new_without_digest(&pub_pkey) + .map_err(|e| ossl_err("Create ML-DSA verifier", &e))?; + verifier + .verify_oneshot(signature, message) + .map_err(|e| ossl_err("ML-DSA verify operation", &e)) +} + +/// Verify an ML-DSA signature against an SPKI DER public key, without a backend. +/// +/// # Errors +/// +/// Always returns [`BackendError::UnsupportedAlgorithm`]: this build links an +/// OpenSSL older than 3.5, which has no ML-DSA provider. +#[cfg(not(ossl350))] +pub fn verify_ml_dsa_signature( + _public_der: &[u8], + _message: &[u8], + _signature: &[u8], +) -> Result { + Err(unsupported_ml_dsa("verification")) +} + +/// Error for an ML-DSA `operation` on a build linked against OpenSSL below 3.5. +/// +/// Reports the runtime version, which is the one that actually lacks the +/// provider and the one an operator can act on. +#[cfg(not(ossl350))] +fn unsupported_ml_dsa(operation: &str) -> BackendError { + BackendError::UnsupportedAlgorithm(format!( + "ML-DSA {operation} requires OpenSSL 3.5 or newer, but this build links OpenSSL {}", + openssl::version::version() )) } @@ -203,6 +327,9 @@ impl KeyStoreBackend for OpenSslBackend { EcKey::generate(&group).map_err(|e| ossl_err("ECDSA-P256 keygen", &e))?; PKey::from_ec_key(ec_key).map_err(|e| ossl_err("PKey from ECDSA-P256", &e))? } + KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { + generate_ml_dsa(spec.algorithm)? + } other => { return Err(BackendError::UnsupportedAlgorithm(format!( "Algorithm {other:?} not yet implemented for OpenSslBackend" @@ -299,6 +426,17 @@ impl SignBackend for OpenSslBackend { .sign_oneshot_to_vec(message) .map_err(|e| ossl_err("ECDSA sign operation", &e)) } + KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { + // ML-DSA fixes one signature scheme per parameter set, so the + // request is valid only if it names the key's own algorithm. + if algorithm.key_algorithm() != key.algorithm { + return Err(BackendError::UnsupportedAlgorithm(format!( + "Sign algorithm {algorithm:?} not supported for {} keys", + key.algorithm + ))); + } + ml_dsa_sign(&key.pkey, message) + } other => Err(BackendError::UnsupportedAlgorithm(format!( "Signing not yet implemented for algorithm {other:?}" ))), @@ -362,6 +500,15 @@ impl SignBackend for OpenSslBackend { .verify_oneshot(signature, message) .map_err(|e| ossl_err("ECDSA verify operation", &e))?) } + KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { + if algorithm.key_algorithm() != key.algorithm { + return Err(BackendError::UnsupportedAlgorithm(format!( + "Verify algorithm {algorithm:?} not supported for {} keys", + key.algorithm + ))); + } + verify_ml_dsa_signature(&key.public_der, message, signature) + } other => Err(BackendError::UnsupportedAlgorithm(format!( "Verification not yet implemented for algorithm {other:?}" ))), @@ -1076,4 +1223,162 @@ mod tests { let bytes2 = backend.generate_random(32).unwrap(); assert_ne!(bytes, bytes2); } + + /// Every ML-DSA parameter set, with the FIPS 204 sizes each one fixes. + #[cfg(ossl350)] + const ML_DSA_PARAMS: [(KeyAlgorithm, SignAlgorithm, usize, usize); 3] = [ + (KeyAlgorithm::MlDsa44, SignAlgorithm::MlDsa44, 1312, 2420), + (KeyAlgorithm::MlDsa65, SignAlgorithm::MlDsa65, 1952, 3309), + (KeyAlgorithm::MlDsa87, SignAlgorithm::MlDsa87, 2592, 4627), + ]; + + /// Known-answer test for seed-derived key generation. + /// + /// FIPS 204 expands the keypair deterministically from the 32-byte seed, so + /// a fixed seed pins an exact public key. The expected digests are an + /// independent cross-check produced with the OpenSSL CLI + /// (`openssl genpkey -algorithm ML-DSA-NN -pkeyopt hexseed:...`), which + /// exercises the provider through a different entry point than the + /// `EVP_PKEY_fromdata` path the backend uses. + #[test] + #[cfg(ossl350)] + fn ml_dsa_seed_derivation_matches_known_answer() { + use openssl::hash::{MessageDigest, hash}; + + let seed: [u8; ML_DSA_SEED_LEN] = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, + 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, + 0x1c, 0x1d, 0x1e, 0x1f, + ]; + let expected = [ + ( + KeyAlgorithm::MlDsa44, + "837832708c5236d951581f1fddf2b79991b3424a0486d16da1ddad0fd69701be", + ), + ( + KeyAlgorithm::MlDsa65, + "b8b62131bfbe84433efb2273d7f5b87f7a22854a2cfd366fc2aead86d837c52d", + ), + ( + KeyAlgorithm::MlDsa87, + "07e57c4f14dbad1267f621ec3777b4e2e6c4fbc4c22fbb87510ff8e0b3c6a642", + ), + ]; + + // Zipped against the production table so a reordering there is caught + // rather than silently pairing a digest with the wrong parameter set. + for ((algorithm, key_type), (expected_algorithm, expected_digest)) in + ML_DSA_KEY_TYPES.into_iter().zip(expected) + { + assert_eq!(algorithm, expected_algorithm); + let pkey = PKey::private_key_from_seed(None, key_type, None, &seed).unwrap(); + let spki = pkey.public_key_to_der().unwrap(); + let digest = hash(MessageDigest::sha256(), &spki).unwrap(); + assert_eq!( + base16ct::lower::encode_string(&digest), + expected_digest, + "{algorithm} public key does not match the known answer for this seed" + ); + } + } + + #[test] + #[cfg(ossl350)] + fn ml_dsa_generate_sign_and_verify_roundtrip() { + for (key_algorithm, sign_algorithm, public_len, signature_len) in ML_DSA_PARAMS { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let metadata = backend.generate_key(spec(key_algorithm, "pq-key")).unwrap(); + + assert_eq!(metadata.algorithm, key_algorithm); + let public_key = metadata.public_key.as_ref().unwrap(); + // SPKI wraps the raw public key in an AlgorithmIdentifier header, + // so the encoding is a little longer than the FIPS 204 figure. + assert!( + public_key.len() > public_len, + "{key_algorithm} SPKI ({}) should exceed the raw public key ({public_len})", + public_key.len() + ); + + let message = b"ceremony transcript digest"; + let signature = backend + .sign(&metadata.key_id, message, sign_algorithm) + .unwrap(); + assert_eq!(signature.len(), signature_len, "{key_algorithm}"); + + assert!( + backend + .verify(&metadata.key_id, message, &signature, sign_algorithm) + .unwrap(), + "{key_algorithm} signature should verify" + ); + assert!( + !backend + .verify(&metadata.key_id, b"tampered", &signature, sign_algorithm) + .unwrap(), + "{key_algorithm} signature should not verify against a different message" + ); + } + } + + /// ML-DSA signing is hedged by default: it mixes fresh randomness into every + /// signature, so the same key over the same message yields different bytes. + /// Ceremony assertions must therefore verify signatures, never compare them. + #[test] + #[cfg(ossl350)] + fn ml_dsa_signing_is_hedged() { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let metadata = backend + .generate_key(spec(KeyAlgorithm::MlDsa65, "pq-key")) + .unwrap(); + + let message = b"same message"; + let first = backend + .sign(&metadata.key_id, message, SignAlgorithm::MlDsa65) + .unwrap(); + let second = backend + .sign(&metadata.key_id, message, SignAlgorithm::MlDsa65) + .unwrap(); + + assert_ne!(first, second); + assert!( + backend + .verify(&metadata.key_id, message, &first, SignAlgorithm::MlDsa65) + .unwrap() + ); + assert!( + backend + .verify(&metadata.key_id, message, &second, SignAlgorithm::MlDsa65) + .unwrap() + ); + } + + /// Each parameter set is its own signature scheme, so a request naming a + /// different one is rejected rather than silently signing. + #[test] + #[cfg(ossl350)] + fn ml_dsa_rejects_mismatched_parameter_set() { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let metadata = backend + .generate_key(spec(KeyAlgorithm::MlDsa65, "pq-key")) + .unwrap(); + + let result = backend.sign(&metadata.key_id, b"data", SignAlgorithm::MlDsa87); + assert!(matches!(result, Err(BackendError::UnsupportedAlgorithm(_)))); + } + + /// Two independently generated keys must differ, confirming the seed is + /// drawn fresh per key rather than fixed. + #[test] + #[cfg(ossl350)] + fn ml_dsa_generation_uses_a_fresh_seed() { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let first = backend + .generate_key(spec(KeyAlgorithm::MlDsa65, "key-a")) + .unwrap(); + let second = backend + .generate_key(spec(KeyAlgorithm::MlDsa65, "key-b")) + .unwrap(); + + assert_ne!(first.public_key, second.public_key); + } } diff --git a/crates/rite-openssl/src/lib.rs b/crates/rite-openssl/src/lib.rs index f8f93f1..4dee96a 100644 --- a/crates/rite-openssl/src/lib.rs +++ b/crates/rite-openssl/src/lib.rs @@ -27,4 +27,4 @@ mod backend; -pub use backend::OpenSslBackend; +pub use backend::{OpenSslBackend, verify_ml_dsa_signature}; diff --git a/crates/rite-sdk/src/types.rs b/crates/rite-sdk/src/types.rs index c94dcf5..9de026b 100644 --- a/crates/rite-sdk/src/types.rs +++ b/crates/rite-sdk/src/types.rs @@ -84,6 +84,12 @@ pub enum KeyAlgorithm { EcdsaP384, /// Ed25519 (`EdDSA`). Ed25519, + /// ML-DSA-44, module-lattice signature at NIST security category 2 (FIPS 204). + MlDsa44, + /// ML-DSA-65, module-lattice signature at NIST security category 3 (FIPS 204). + MlDsa65, + /// ML-DSA-87, module-lattice signature at NIST security category 5 (FIPS 204). + MlDsa87, /// AES 128-bit symmetric key. Aes128, /// AES 256-bit symmetric key. @@ -98,6 +104,9 @@ impl fmt::Display for KeyAlgorithm { KeyAlgorithm::EcdsaP256 => write!(f, "ECDSA-P256"), KeyAlgorithm::EcdsaP384 => write!(f, "ECDSA-P384"), KeyAlgorithm::Ed25519 => write!(f, "Ed25519"), + KeyAlgorithm::MlDsa44 => write!(f, "ML-DSA-44"), + KeyAlgorithm::MlDsa65 => write!(f, "ML-DSA-65"), + KeyAlgorithm::MlDsa87 => write!(f, "ML-DSA-87"), KeyAlgorithm::Aes128 => write!(f, "AES-128"), KeyAlgorithm::Aes256 => write!(f, "AES-256"), } @@ -121,6 +130,9 @@ impl std::str::FromStr for KeyAlgorithm { "ECDSA-P256" => Ok(Self::EcdsaP256), "ECDSA-P384" => Ok(Self::EcdsaP384), "Ed25519" => Ok(Self::Ed25519), + "ML-DSA-44" => Ok(Self::MlDsa44), + "ML-DSA-65" => Ok(Self::MlDsa65), + "ML-DSA-87" => Ok(Self::MlDsa87), "AES-128" => Ok(Self::Aes128), "AES-256" => Ok(Self::Aes256), _ => Err(ParseError(s.to_owned())), @@ -263,6 +275,12 @@ pub enum SignAlgorithm { EcdsaSha384, /// Ed25519 (pure `EdDSA`, no hash function). Ed25519, + /// ML-DSA-44 (pure, no pre-hash). FIPS 204. + MlDsa44, + /// ML-DSA-65 (pure, no pre-hash). FIPS 204. + MlDsa65, + /// ML-DSA-87 (pure, no pre-hash). FIPS 204. + MlDsa87, } impl SignAlgorithm { @@ -278,6 +296,9 @@ impl SignAlgorithm { SignAlgorithm::EcdsaSha256 => KeyAlgorithm::EcdsaP256, SignAlgorithm::EcdsaSha384 => KeyAlgorithm::EcdsaP384, SignAlgorithm::Ed25519 => KeyAlgorithm::Ed25519, + SignAlgorithm::MlDsa44 => KeyAlgorithm::MlDsa44, + SignAlgorithm::MlDsa65 => KeyAlgorithm::MlDsa65, + SignAlgorithm::MlDsa87 => KeyAlgorithm::MlDsa87, } } } diff --git a/crates/rite-stdlib/src/pki/issue_certificate.rs b/crates/rite-stdlib/src/pki/issue_certificate.rs index 58bf01c..b518f9b 100644 --- a/crates/rite-stdlib/src/pki/issue_certificate.rs +++ b/crates/rite-stdlib/src/pki/issue_certificate.rs @@ -46,7 +46,10 @@ use x509_cert::{ use crate::params::IssueCertificateParams; -use super::oids::{ECDSA_WITH_SHA256, SHA256_WITH_RSA_ENCRYPTION, sig_profile_for_algorithm}; +use super::oids::{ + ECDSA_WITH_SHA256, ML_DSA_44, ML_DSA_65, ML_DSA_87, SHA256_WITH_RSA_ENCRYPTION, + sig_profile_for_algorithm, +}; /// id-kp-serverAuth OID (1.3.6.1.5.5.7.3.1) const ID_KP_SERVER_AUTH: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.3.1"); @@ -565,14 +568,40 @@ fn verify_csr_signature(csr: &CertReq) -> Result<(), String> { verifying_key.verify(&info_der, &signature).map_err(|_| { "CSR self-signature verification failed: ECDSA signature does not match".to_string() }) + } else if oid == ML_DSA_44 || oid == ML_DSA_65 || oid == ML_DSA_87 { + verify_ml_dsa_csr(&spki_der, &info_der, csr.signature.raw_bytes()) } else { Err(format!( "CSR signature algorithm {oid} is not supported for verification. \ - Supported algorithms are sha256WithRSAEncryption and ecdsa-with-SHA256." + Supported algorithms are sha256WithRSAEncryption, ecdsa-with-SHA256, \ + and ML-DSA-44/65/87." )) } } +/// Verify an ML-DSA CSR self-signature. +/// +/// The RSA and ECDSA branches above verify with `RustCrypto`, but no equivalent +/// in-tree verifier covers ML-DSA, so this delegates to the same OpenSSL +/// provider that produced the signature. Unifying all three onto one +/// implementation is tracked as the open question in the meta-repo's +/// `sign-verify-actions` investigation. +#[cfg(feature = "openssl")] +fn verify_ml_dsa_csr(spki_der: &[u8], info_der: &[u8], signature: &[u8]) -> Result<(), String> { + match rite_openssl::verify_ml_dsa_signature(spki_der, info_der, signature) { + Ok(true) => Ok(()), + Ok(false) => Err( + "CSR self-signature verification failed: ML-DSA signature does not match".to_string(), + ), + Err(e) => Err(format!("Failed to verify ML-DSA CSR self-signature: {e}")), + } +} + +#[cfg(not(feature = "openssl"))] +fn verify_ml_dsa_csr(_spki_der: &[u8], _info_der: &[u8], _signature: &[u8]) -> Result<(), String> { + Err("ML-DSA CSR verification requires the 'openssl' feature".to_string()) +} + fn parse_csr(bytes: &[u8]) -> Result { if bytes.starts_with(b"-----") { let pem_str = diff --git a/crates/rite-stdlib/src/pki/oids.rs b/crates/rite-stdlib/src/pki/oids.rs index 461c10e..eafa88e 100644 --- a/crates/rite-stdlib/src/pki/oids.rs +++ b/crates/rite-stdlib/src/pki/oids.rs @@ -12,6 +12,18 @@ pub(super) const SHA256_WITH_RSA_ENCRYPTION: ObjectIdentifier = pub(super) const ECDSA_WITH_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); +/// id-ml-dsa-44 (2.16.840.1.101.3.4.3.17) +pub(super) const ML_DSA_44: ObjectIdentifier = + ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.17"); + +/// id-ml-dsa-65 (2.16.840.1.101.3.4.3.18) +pub(super) const ML_DSA_65: ObjectIdentifier = + ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.18"); + +/// id-ml-dsa-87 (2.16.840.1.101.3.4.3.19) +pub(super) const ML_DSA_87: ObjectIdentifier = + ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.19"); + pub(super) fn sig_profile_for_algorithm( key_algorithm: KeyAlgorithm, ) -> Result<(SignAlgorithm, AlgorithmIdentifier, &'static str), String> { @@ -34,8 +46,68 @@ pub(super) fn sig_profile_for_algorithm( }, "ecdsa-with-SHA256", )), + // FIPS 204 fixes one signature scheme per parameter set, with no hash + // or padding left to choose, so the key algorithm fully determines the + // signature algorithm identifier. Parameters are absent. + KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { + let (sign_algorithm, oid, name) = match key_algorithm { + KeyAlgorithm::MlDsa44 => (SignAlgorithm::MlDsa44, ML_DSA_44, "ML-DSA-44"), + KeyAlgorithm::MlDsa65 => (SignAlgorithm::MlDsa65, ML_DSA_65, "ML-DSA-65"), + _ => (SignAlgorithm::MlDsa87, ML_DSA_87, "ML-DSA-87"), + }; + Ok(( + sign_algorithm, + AlgorithmIdentifier { + oid, + parameters: None, + }, + name, + )) + } other => Err(format!( "key algorithm '{other}' is not supported for PKI signing yet" )), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// The ML-DSA identifiers are a wire contract: they appear in every + /// certificate and CSR the runtime emits, so they are pinned by value + /// rather than by whatever the constants happen to hold. + #[test] + fn ml_dsa_signature_identifiers_are_stable() { + let cases = [ + ( + KeyAlgorithm::MlDsa44, + "2.16.840.1.101.3.4.3.17", + "ML-DSA-44", + ), + ( + KeyAlgorithm::MlDsa65, + "2.16.840.1.101.3.4.3.18", + "ML-DSA-65", + ), + ( + KeyAlgorithm::MlDsa87, + "2.16.840.1.101.3.4.3.19", + "ML-DSA-87", + ), + ]; + + for (key_algorithm, expected_oid, expected_name) in cases { + let (sign_algorithm, identifier, name) = + sig_profile_for_algorithm(key_algorithm).unwrap(); + + assert_eq!(identifier.oid.to_string(), expected_oid); + assert_eq!(name, expected_name); + // Absent parameters, not NULL: ML-DSA has nothing to parameterise. + assert!(identifier.parameters.is_none()); + // The parameter set round-trips, so a signing request cannot end up + // on a different one than the key. + assert_eq!(sign_algorithm.key_algorithm(), key_algorithm); + } + } +} diff --git a/examples/pki/README.md b/examples/pki/README.md index 07ca19a..8116139 100644 --- a/examples/pki/README.md +++ b/examples/pki/README.md @@ -19,6 +19,32 @@ The ceremony produces a self-signed root CA certificate and an encrypted key bac The backup can only be recovered by the holder of the transport private key, which is the input to the intermediate CA signing ceremony. +### `root_ca_post_quantum.rite.yaml` — Root CA Key Generation (ML-DSA) + +The same ceremony with an ML-DSA-87 root key (FIPS 204) instead of RSA-4096. + +A root CA issued today with a 20-year validity is still a trust anchor in the window +where a cryptographically relevant quantum computer is plausible, and a root key cannot +be rotated without redistributing the trust anchor everywhere it is pinned. That lifetime +is the reason to pick a lattice signature now. + +ML-DSA-87 is the NIST category 5 parameter set and the CNSA 2.0 requirement. `ML-DSA-65` +(category 3) and `ML-DSA-44` (category 2) are also accepted by `generate_keypair`. The +resulting certificate is around 10 KB against 2 KB for RSA-4096, which is immaterial for +a root that issues a handful of certificates over its life. + +The transport key protecting the backup stays RSA-4096: it guards the wrapped key only +until restore, so it does not carry the root key's multi-decade exposure. + +**Requires OpenSSL 3.5 or newer.** Support is decided when `rite` is built, not when it +runs: a binary linked against an older OpenSSL has no ML-DSA code compiled in and fails +the key generation step with an unsupported-algorithm error. Released binaries and the +container image bundle a current OpenSSL and always support it. To check a local build: + +```sh +openssl version # the library rite was built against, if system-linked +``` + ### `intermediate_ca.rite.yaml` _(planned — requires `root_ca_software` outputs)_ Signs an intermediate CA CSR using the root CA private key recovered from the encrypted @@ -47,6 +73,8 @@ rite script examples/pki/root_ca_software.rite.yaml rite run examples/pki/root_ca_software.rite.yaml ``` +Substitute `root_ca_post_quantum.rite.yaml` for the ML-DSA variant. + After the ceremony, decrypt the wrapped private key to verify the output: ```sh diff --git a/examples/pki/root_ca_post_quantum.rite.yaml b/examples/pki/root_ca_post_quantum.rite.yaml new file mode 100644 index 0000000..a5348a5 --- /dev/null +++ b/examples/pki/root_ca_post_quantum.rite.yaml @@ -0,0 +1,173 @@ +version: "0.2" +name: "Post-Quantum Root CA Key Generation" +description: | + Generate an offline root CA keypair using ML-DSA-87 (FIPS 204) on an air-gapped machine. + + A root CA minted today with a 20-year validity is still a trust anchor in the window where + a cryptographically relevant quantum computer is plausible, and a root key cannot be + rotated without redistributing the trust anchor everywhere it is pinned. That lifetime, + not any present-day threat, is the reason to choose a lattice signature now. + + ML-DSA-87 is the NIST category 5 parameter set and the CNSA 2.0 requirement. Certificates + and signatures are roughly an order of magnitude larger than their ECDSA equivalents, + which is immaterial at root scale where a handful of certificates are ever issued. + + Requires OpenSSL 3.5 or newer. Builds linked against an older OpenSSL will fail this + ceremony at the key generation step with an unsupported-algorithm error. + +backends: + openssl: + provider: openssl + +parameters: + ceremony_date: + type: date + default: "2026-07-28" + description: "Date of the ceremony (YYYY-MM-DD)" + +materials: + transport_pubkey: + type: digital + path: "test_keys/transport_public.pem" + description: | + Crypto officer's transport RSA-4096 public key. + + The transport key stays classical: it protects the backup only until the key is + restored, so it does not carry the root key's multi-decade exposure. + +output: + root_ca_cert: + type: certificate + description: "Self-signed ML-DSA-87 root CA certificate" + wrapped_root_ca_key: + type: wrapped_key + description: "Root CA private key wrapped with transport key" + +roles: + crypto_officer: + name: "Crypto Officer" + person: "Alice Smith" + witness__1: + person: "Bob Jones" + witness__2: + person: "Carol White" + +acts: + - id: opening + name: "Opening" + description: "Verify participants and environment before proceeding." + - id: key_generation + name: "Root CA Key Generation" + description: "Generate and protect the post-quantum root CA keypair." + - id: closing + name: "Closing" + +prerequisites: + - "Air-gapped workstation is powered on with all network interfaces disabled" + - "The rite build in use links OpenSSL 3.5 or newer" + +sections: + environment: + act: opening + name: "Environment Verification" + role: ${role.crypto_officer} + steps: + verify_time: + action: clock_check + with: + message: "Verify system clock before recording timestamps" + record_machine: + action: machine_info + with: + include_machine_id: true + include_cpu: true + include_os: true + verify_air_gap: + action: confirm + with: + message: "Confirm the workstation has no active network connections" + + keygen: + act: key_generation + name: "Key Generation" + role: ${role.crypto_officer} + steps: + generate_root_ca: + action: generate_keypair + backend: openssl + with: + algorithm: ML-DSA-87 + key_usage: + - key_cert_sign + - crl_sign + creates: root_ca_keypair + description: "Generate ML-DSA-87 root CA keypair." + generate_root_csr: + action: generate_csr + backend: openssl + reads: + signing_key: ${artifact.root_ca_keypair} + with: + subject: "CN=Post-Quantum Root CA,O=Example Org" + creates: root_ca_csr + description: "Generate a certificate signing request for the root CA key." + issue_root_cert: + action: issue_certificate + backend: openssl + reads: + signing_key: ${artifact.root_ca_keypair} + csr: ${artifact.root_ca_csr} + with: + profile: root_ca + validity_days: 7300 + creates: root_ca_cert + description: "Issue the self-signed ML-DSA-87 root CA certificate." + + protection: + act: key_generation + name: "Key Protection" + role: ${role.crypto_officer} + steps: + wrap_root_ca_key: + action: wrap_key + backend: openssl + reads: + key_to_wrap: ${artifact.root_ca_keypair} + wrapping_key: ${artifact.transport_pubkey} + with: + algorithm: CMS-RSA-GCM + creates: wrapped_root_ca_key + description: "Encrypt the root CA private key with the transport public key." + + attestation: + act: closing + name: "Witness Attestation" + steps: + witness1_attest: + action: attest + role: ${role.witness__1} + with: + statement: "I witnessed the post-quantum root CA key generation and key wrapping." + witness2_attest: + action: attest + role: ${role.witness__2} + with: + statement: "I witnessed the post-quantum root CA key generation and key wrapping." + officer_attest: + action: attest + role: ${role.crypto_officer} + with: + statement: "ML-DSA-87 root CA keypair generated." + +after: + archive: + type: archive_materials + role: crypto_officer + items: + - "Ceremony transcript" + - "Root CA certificate (root_ca_cert.pem)" + - "Wrapped root CA key backup (wrapped_root_ca_key.bin)" + location: "Secure document archive" + notify: + type: notify_stakeholders + description: "Distribute root_ca_cert.pem to PKI team and update trust anchor documentation"