Skip to main content

cdx_core/security/
ml_dsa.rs

1//! ML-DSA-65 post-quantum signature implementation (FIPS-204).
2//!
3//! ML-DSA (Module-Lattice Digital Signature Algorithm) is a post-quantum
4//! cryptographic signature scheme standardized in FIPS-204. This module
5//! implements the ML-DSA-65 parameter set, providing 128-bit post-quantum
6//! security.
7//!
8//! # Warning
9//!
10//! Post-quantum cryptography is still maturing. While ML-DSA-65 is
11//! standardized by NIST, implementations should be considered experimental.
12
13use crate::error::invalid_manifest;
14use crate::{DocumentId, Result};
15
16use super::signature::{Signature, SignatureAlgorithm, SignatureVerification, SignerInfo};
17use super::signer::{Signer, Verifier};
18
19/// ML-DSA-65 signer.
20///
21/// Uses the FIPS-204 ML-DSA-65 parameter set for post-quantum digital signatures.
22///
23/// # Key Format
24///
25/// Keys are represented as 32-byte seeds. A seed deterministically derives
26/// both the signing and verifying keys via ML-DSA.KeyGen_internal (FIPS 204).
27#[cfg(feature = "ml-dsa")]
28pub struct MlDsaSigner {
29    signing_key: ml_dsa::SigningKey<ml_dsa::MlDsa65>,
30    seed: [u8; 32],
31    signer_info: SignerInfo,
32}
33
34#[cfg(feature = "ml-dsa")]
35impl MlDsaSigner {
36    /// Create a signer from a 32-byte seed.
37    ///
38    /// The seed deterministically derives the full signing and verifying keys.
39    ///
40    /// # Arguments
41    ///
42    /// * `seed_bytes` - The 32-byte seed
43    /// * `signer_info` - Information about the signer
44    ///
45    /// # Errors
46    ///
47    /// Returns an error if the seed is not exactly 32 bytes.
48    pub fn from_bytes(seed_bytes: &[u8], signer_info: SignerInfo) -> Result<Self> {
49        use ml_dsa::KeyGen;
50
51        let seed: [u8; 32] = seed_bytes.try_into().map_err(|_| {
52            invalid_manifest(format!(
53                "Invalid ML-DSA-65 seed length: expected 32, got {}",
54                seed_bytes.len()
55            ))
56        })?;
57
58        let kp = ml_dsa::MlDsa65::from_seed(&seed.into());
59
60        Ok(Self {
61            signing_key: kp.signing_key().clone(),
62            seed,
63            signer_info,
64        })
65    }
66
67    /// Generate a new random ML-DSA-65 key pair.
68    ///
69    /// Returns the signer and the encoded public (verifying) key bytes.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if key generation fails.
74    #[allow(clippy::missing_panics_doc)] // getrandom::SysRng only fails on misconfigured systems
75    pub fn generate(signer_info: SignerInfo) -> Result<(Self, Vec<u8>)> {
76        use ml_dsa::KeyGen;
77
78        let kp = ml_dsa::MlDsa65::key_gen(&mut rand_core::UnwrapErr(getrandom::SysRng));
79        let seed: [u8; 32] = kp.to_seed().into();
80        let public_key_bytes = kp.verifying_key().encode().to_vec();
81
82        Ok((
83            Self {
84                signing_key: kp.signing_key().clone(),
85                seed,
86                signer_info,
87            },
88            public_key_bytes,
89        ))
90    }
91
92    /// Get the public (verifying) key bytes.
93    #[must_use]
94    pub fn public_key_bytes(&self) -> Vec<u8> {
95        self.signing_key.verifying_key().encode().to_vec()
96    }
97
98    /// Get the secret key seed (32 bytes).
99    ///
100    /// # Security Warning
101    ///
102    /// Handle seed bytes with care. Do not log or expose them.
103    #[must_use]
104    pub fn secret_key_bytes(&self) -> Vec<u8> {
105        self.seed.to_vec()
106    }
107}
108
109#[cfg(feature = "ml-dsa")]
110impl Signer for MlDsaSigner {
111    fn algorithm(&self) -> SignatureAlgorithm {
112        SignatureAlgorithm::MlDsa65
113    }
114
115    fn signer_info(&self) -> SignerInfo {
116        self.signer_info.clone()
117    }
118
119    fn sign(&self, document_id: &DocumentId) -> Result<Signature> {
120        use base64::Engine;
121        use ml_dsa::signature::Signer as MlDsaSignerTrait;
122
123        if document_id.is_pending() {
124            return Err(crate::Error::InvalidManifest {
125                reason: "Cannot sign a pending document ID".to_string(),
126            });
127        }
128
129        // Sign the document ID bytes
130        let signature = self.signing_key.sign(document_id.digest());
131
132        // Encode as base64
133        let value = base64::engine::general_purpose::STANDARD.encode(signature.encode());
134
135        // Generate signature ID
136        let sig_id = format!(
137            "sig-{}",
138            &crate::Hasher::hash(crate::HashAlgorithm::Sha256, value.as_bytes()).hex_digest()[..8]
139        );
140
141        Ok(Signature::new(
142            sig_id,
143            SignatureAlgorithm::MlDsa65,
144            self.signer_info.clone(),
145            value,
146        ))
147    }
148}
149
150/// ML-DSA-65 verifier.
151#[cfg(feature = "ml-dsa")]
152pub struct MlDsaVerifier {
153    verifying_key: ml_dsa::VerifyingKey<ml_dsa::MlDsa65>,
154}
155
156#[cfg(feature = "ml-dsa")]
157impl MlDsaVerifier {
158    /// Create a verifier from encoded public key bytes.
159    ///
160    /// # Arguments
161    ///
162    /// * `public_key_bytes` - The encoded verifying key bytes
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the key bytes are invalid.
167    pub fn from_bytes(public_key_bytes: &[u8]) -> Result<Self> {
168        let verifying_key =
169            ml_dsa::VerifyingKey::decode(public_key_bytes.try_into().map_err(|_| {
170                invalid_manifest(format!(
171                    "Invalid ML-DSA-65 public key length: got {}",
172                    public_key_bytes.len()
173                ))
174            })?);
175
176        Ok(Self { verifying_key })
177    }
178}
179
180#[cfg(feature = "ml-dsa")]
181impl Verifier for MlDsaVerifier {
182    fn verify(
183        &self,
184        document_id: &DocumentId,
185        signature: &Signature,
186    ) -> Result<SignatureVerification> {
187        use base64::Engine;
188        use ml_dsa::signature::Verifier as MlDsaVerifierTrait;
189
190        if signature.algorithm != SignatureAlgorithm::MlDsa65 {
191            return Ok(SignatureVerification::invalid(
192                &signature.id,
193                format!(
194                    "Algorithm mismatch: expected ML-DSA-65, got {}",
195                    signature.algorithm
196                ),
197            ));
198        }
199
200        // Decode signature from base64
201        let sig_bytes = base64::engine::general_purpose::STANDARD
202            .decode(&signature.value)
203            .map_err(|e| invalid_manifest(format!("Failed to decode signature: {e}")))?;
204
205        // Parse ML-DSA signature
206        let ml_sig =
207            ml_dsa::Signature::<ml_dsa::MlDsa65>::try_from(sig_bytes.as_slice()).map_err(|_| {
208                invalid_manifest(format!(
209                    "Invalid ML-DSA-65 signature length: got {}",
210                    sig_bytes.len()
211                ))
212            })?;
213
214        // Verify
215        match self.verifying_key.verify(document_id.digest(), &ml_sig) {
216            Ok(()) => Ok(SignatureVerification::valid(&signature.id)),
217            Err(e) => Ok(SignatureVerification::invalid(
218                &signature.id,
219                format!("ML-DSA-65 signature verification failed: {e}"),
220            )),
221        }
222    }
223}
224
225#[cfg(all(test, feature = "ml-dsa"))]
226mod tests {
227    use super::*;
228    use crate::security::test_helpers;
229
230    fn generate_keypair() -> (MlDsaSigner, MlDsaVerifier) {
231        let signer_info = SignerInfo::new("Test ML-DSA Signer");
232        let (signer, public_key_bytes) = MlDsaSigner::generate(signer_info).unwrap();
233        let verifier = MlDsaVerifier::from_bytes(&public_key_bytes).unwrap();
234        (signer, verifier)
235    }
236
237    #[test]
238    fn test_generate_and_sign() {
239        let signer_info = SignerInfo::new("Test ML-DSA Signer");
240        let (signer, public_key_bytes) = MlDsaSigner::generate(signer_info).unwrap();
241
242        assert!(!public_key_bytes.is_empty());
243
244        test_helpers::assert_sign_produces_valid_signature(&signer, SignatureAlgorithm::MlDsa65);
245    }
246
247    #[test]
248    fn test_sign_and_verify() {
249        let (signer, verifier) = generate_keypair();
250        test_helpers::assert_sign_verify_roundtrip(&signer, &verifier);
251    }
252
253    #[test]
254    fn test_verify_wrong_document() {
255        let (signer, verifier) = generate_keypair();
256        test_helpers::assert_verify_wrong_document_fails(&signer, &verifier);
257    }
258
259    #[test]
260    fn test_cannot_sign_pending_id() {
261        let (signer, _) = generate_keypair();
262        test_helpers::assert_cannot_sign_pending_id(&signer);
263    }
264
265    #[test]
266    fn test_algorithm_mismatch() {
267        let (signer, verifier) = generate_keypair();
268        test_helpers::assert_algorithm_mismatch_rejected(
269            &signer,
270            &verifier,
271            SignatureAlgorithm::ES256,
272        );
273    }
274
275    #[test]
276    fn test_key_round_trip() {
277        let signer_info = SignerInfo::new("Test Signer");
278        let (original_signer, _) = MlDsaSigner::generate(signer_info.clone()).unwrap();
279
280        let secret_bytes = original_signer.secret_key_bytes();
281        let public_bytes = original_signer.public_key_bytes();
282
283        let restored_signer = MlDsaSigner::from_bytes(&secret_bytes, signer_info).unwrap();
284
285        let doc_id = crate::Hasher::hash(crate::HashAlgorithm::Sha256, b"test document");
286        let sig1 = original_signer.sign(&doc_id).unwrap();
287        let sig2 = restored_signer.sign(&doc_id).unwrap();
288
289        let verifier = MlDsaVerifier::from_bytes(&public_bytes).unwrap();
290        assert!(verifier.verify(&doc_id, &sig1).unwrap().is_valid());
291        assert!(verifier.verify(&doc_id, &sig2).unwrap().is_valid());
292    }
293}