Skip to main content

dcrypt_sign/dilithium/
mod.rs

1//! FIPS 204 Module-Lattice-Based Digital Signature Algorithm (ML-DSA).
2//!
3//! Key generation, signing, and verification use the portable
4//! [`libcrux_ml_dsa`] backend. Its field arithmetic, NTT polynomial arithmetic,
5//! and serialization have machine-checked proofs. Dcrypt supplies its existing
6//! high-level wrapper types and the [`dcrypt_api::Signature`] adapter. Public
7//! keys, expanded private keys, and signatures use Algorithms 22, 24, and 26 of
8//! final FIPS 204 exactly. Expanded-key interoperability is checked against an
9//! independent implementation in tests; no secondary implementation processes
10//! untrusted keys at runtime.
11//!
12//! The historical `Dilithium2`, `Dilithium3`, and `Dilithium5` type names remain
13//! as source-compatible aliases for `MlDsa44`, `MlDsa65`, and `MlDsa87`. They do
14//! not select the removed pre-FIPS dcrypt implementation.
15
16use crate::error::Error as SignError;
17#[cfg(not(feature = "std"))]
18use alloc::{format, string::ToString, vec::Vec};
19use core::{fmt, marker::PhantomData};
20use dcrypt_algorithms::hash::{HashFunction, Shake256};
21use dcrypt_api::{Result as ApiResult, Signature as SignatureTrait};
22use dcrypt_params::pqc::dilithium::{
23    Dilithium2Params, Dilithium3Params, Dilithium5Params, DilithiumSchemeParams,
24};
25use rand::{CryptoRng, RngCore};
26use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
27
28/// ML-DSA public key encoded with FIPS 204 Algorithm 22 (`pkEncode`).
29#[derive(Clone, Debug, Zeroize)]
30pub struct DilithiumPublicKey(pub(crate) Vec<u8>);
31
32/// ML-DSA expanded private key encoded with FIPS 204 Algorithm 24 (`skEncode`).
33///
34/// In particular, bytes `64..128` contain the complete 64-byte `tr = H(pk, 64)`
35/// value. Bare bytes do not carry a format version, so use paired import or
36/// external provenance/framing when distinguishing affected legacy objects.
37#[derive(Clone, Zeroize, ZeroizeOnDrop)]
38pub struct DilithiumSecretKey {
39    bytes: Vec<u8>,
40    public_key: Option<Vec<u8>>,
41}
42
43/// ML-DSA signature encoded with FIPS 204 Algorithm 26 (`sigEncode`).
44#[derive(Clone, Debug)]
45pub struct DilithiumSignatureData(pub(crate) Vec<u8>);
46
47/// Standards-oriented spelling of [`DilithiumPublicKey`].
48pub type MlDsaPublicKey = DilithiumPublicKey;
49/// Standards-oriented spelling of [`DilithiumSecretKey`].
50pub type MlDsaSecretKey = DilithiumSecretKey;
51/// Standards-oriented spelling of [`DilithiumSignatureData`].
52pub type MlDsaSignature = DilithiumSignatureData;
53
54impl fmt::Debug for DilithiumSecretKey {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.debug_struct("DilithiumSecretKey")
57            .field("bytes", &"[REDACTED]")
58            .finish()
59    }
60}
61
62impl AsRef<[u8]> for DilithiumPublicKey {
63    fn as_ref(&self) -> &[u8] {
64        &self.0
65    }
66}
67
68impl AsMut<[u8]> for DilithiumPublicKey {
69    fn as_mut(&mut self) -> &mut [u8] {
70        &mut self.0
71    }
72}
73
74impl AsRef<[u8]> for DilithiumSecretKey {
75    fn as_ref(&self) -> &[u8] {
76        &self.bytes
77    }
78}
79
80impl AsRef<[u8]> for DilithiumSignatureData {
81    fn as_ref(&self) -> &[u8] {
82        &self.0
83    }
84}
85
86impl AsMut<[u8]> for DilithiumSignatureData {
87    fn as_mut(&mut self) -> &mut [u8] {
88        &mut self.0
89    }
90}
91
92impl DilithiumSecretKey {
93    /// Decode a syntactically valid final-FIPS-204 expanded private key.
94    ///
95    /// An expanded private-key encoding does not carry enough information for
96    /// this backend to derive and validate its public key without reintroducing
97    /// a second secret-arithmetic implementation. Use
98    /// [`Self::from_bytes_with_public_key`] when the corresponding public key is
99    /// available. A key decoded with this method can sign, but
100    /// [`Self::public_key`] returns an error instead of guessing or panicking.
101    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SignError> {
102        match bytes.len() {
103            2560 => Dilithium2Params::validate_secret_key(bytes)?,
104            4032 => Dilithium3Params::validate_secret_key(bytes)?,
105            4896 => Dilithium5Params::validate_secret_key(bytes)?,
106            _ => {
107                return Err(SignError::Deserialization(format!(
108                    "invalid ML-DSA expanded private key size: {} bytes",
109                    bytes.len()
110                )))
111            }
112        }
113
114        Ok(Self {
115            bytes: bytes.to_vec(),
116            public_key: None,
117        })
118    }
119
120    /// Decode an expanded private key and validate it against its public key.
121    ///
122    /// Besides the exact FIPS 204 encodings, this checks the stored 64-byte
123    /// `tr`, then performs a deterministic sign/verify coherence check through
124    /// the libcrux backend. This is the preferred import API.
125    pub fn from_bytes_with_public_key(
126        bytes: &[u8],
127        public_key: &DilithiumPublicKey,
128    ) -> Result<Self, SignError> {
129        match bytes.len() {
130            2560 => Dilithium2Params::validate_key_pair(bytes, public_key.as_ref())?,
131            4032 => Dilithium3Params::validate_key_pair(bytes, public_key.as_ref())?,
132            4896 => Dilithium5Params::validate_key_pair(bytes, public_key.as_ref())?,
133            _ => {
134                return Err(SignError::Deserialization(format!(
135                    "invalid ML-DSA expanded private key size: {} bytes",
136                    bytes.len()
137                )))
138            }
139        }
140
141        Ok(Self {
142            bytes: bytes.to_vec(),
143            public_key: Some(public_key.as_ref().to_vec()),
144        })
145    }
146
147    /// Return the exact FIPS 204 expanded private-key encoding.
148    pub fn to_bytes(&self) -> &[u8] {
149        &self.bytes
150    }
151
152    /// Return the public key retained at generation or paired import time.
153    pub fn public_key(&self) -> Result<DilithiumPublicKey, SignError> {
154        self.public_key
155            .as_ref()
156            .cloned()
157            .map(DilithiumPublicKey)
158            .ok_or_else(|| {
159                SignError::InvalidKey(
160                    "public-key derivation is unavailable for an unpaired imported ML-DSA expanded key; import with from_bytes_with_public_key"
161                        .to_string(),
162                )
163            })
164    }
165}
166
167impl DilithiumPublicKey {
168    /// Decode and validate a final-FIPS-204 public key.
169    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SignError> {
170        match bytes.len() {
171            1312 => Dilithium2Params::validate_public_key(bytes)?,
172            1952 => Dilithium3Params::validate_public_key(bytes)?,
173            2592 => Dilithium5Params::validate_public_key(bytes)?,
174            _ => {
175                return Err(SignError::Deserialization(format!(
176                    "invalid ML-DSA public key size: {} bytes",
177                    bytes.len()
178                )))
179            }
180        }
181
182        Ok(Self(bytes.to_vec()))
183    }
184
185    /// Return the exact FIPS 204 public-key encoding.
186    pub fn to_bytes(&self) -> &[u8] {
187        &self.0
188    }
189}
190
191impl DilithiumSignatureData {
192    /// Decode a final-FIPS-204 signature and enforce canonical hint encoding.
193    ///
194    /// Duplicate or unsorted hint indices, non-monotonic hint boundaries, and
195    /// nonzero unused hint bytes are rejected here and again during verification.
196    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SignError> {
197        match bytes.len() {
198            2420 => validate_hint_encoding(bytes, 32 + 4 * 576, 80, 4)?,
199            3309 => validate_hint_encoding(bytes, 48 + 5 * 640, 55, 6)?,
200            4627 => validate_hint_encoding(bytes, 64 + 7 * 640, 75, 8)?,
201            _ => {
202                return Err(SignError::InvalidSignatureSize {
203                    expected: 0,
204                    actual: bytes.len(),
205                })
206            }
207        }
208
209        Ok(Self(bytes.to_vec()))
210    }
211
212    /// Return the exact FIPS 204 signature encoding.
213    pub fn to_bytes(&self) -> &[u8] {
214        &self.0
215    }
216}
217
218fn validate_hint_encoding(
219    signature: &[u8],
220    hint_offset: usize,
221    omega: usize,
222    k: usize,
223) -> Result<(), SignError> {
224    let hint = signature
225        .get(hint_offset..)
226        .ok_or_else(|| SignError::Deserialization("truncated ML-DSA hint".to_string()))?;
227    if hint.len() != omega + k {
228        return Err(SignError::Deserialization(
229            "invalid ML-DSA hint length".to_string(),
230        ));
231    }
232
233    let (indices, boundaries) = hint.split_at(omega);
234    let mut start = 0usize;
235    for &boundary in boundaries {
236        let end = usize::from(boundary);
237        if end < start || end > omega {
238            return Err(SignError::Deserialization(
239                "non-monotonic ML-DSA hint boundaries".to_string(),
240            ));
241        }
242        if !indices[start..end].windows(2).all(|pair| pair[0] < pair[1]) {
243            return Err(SignError::Deserialization(
244                "duplicate or unsorted ML-DSA hint indices".to_string(),
245            ));
246        }
247        start = end;
248    }
249
250    if indices[start..].iter().any(|&byte| byte != 0) {
251        return Err(SignError::Deserialization(
252            "nonzero unused ML-DSA hint bytes".to_string(),
253        ));
254    }
255
256    Ok(())
257}
258
259/// Internal adapter implemented only for the three parameter sets standardized
260/// by FIPS 204. It is public solely because it appears in a public trait impl.
261#[doc(hidden)]
262pub trait MlDsaBackend: DilithiumSchemeParams {
263    fn validate_public_key(bytes: &[u8]) -> Result<(), SignError>;
264    fn validate_secret_key(bytes: &[u8]) -> Result<(), SignError>;
265    fn validate_key_pair(secret_key: &[u8], public_key: &[u8]) -> Result<(), SignError>;
266    fn keypair<R: CryptoRng + RngCore>(rng: &mut R) -> Result<(Vec<u8>, Vec<u8>), SignError>;
267    fn sign_with_rng<R: CryptoRng + RngCore>(
268        message: &[u8],
269        secret_key: &[u8],
270        rng: &mut R,
271    ) -> Result<Vec<u8>, SignError>;
272    fn verify(message: &[u8], signature: &[u8], public_key: &[u8]) -> Result<(), SignError>;
273}
274
275fn fixed_array<const N: usize>(bytes: &[u8], what: &str) -> Result<[u8; N], SignError> {
276    bytes.try_into().map_err(|_| {
277        SignError::Deserialization(format!(
278            "invalid {what} size: expected {N}, got {}",
279            bytes.len()
280        ))
281    })
282}
283
284fn validate_expanded_secret_encoding(
285    bytes: &[u8],
286    eta: u16,
287    k: usize,
288    l: usize,
289) -> Result<(), SignError> {
290    let bits_per_coefficient = if eta == 2 { 3 } else { 4 };
291    let packed_secret_len = (k + l) * 256 * bits_per_coefficient / 8;
292    let packed_secret = bytes
293        .get(128..128 + packed_secret_len)
294        .ok_or_else(|| SignError::InvalidKey("truncated ML-DSA private key".to_string()))?;
295    let maximum = eta * 2;
296
297    for coefficient in 0..((k + l) * 256) {
298        let bit_offset = coefficient * bits_per_coefficient;
299        let byte_offset = bit_offset / 8;
300        let shift = bit_offset % 8;
301        let mut window = u32::from(packed_secret[byte_offset]);
302        if let Some(&next) = packed_secret.get(byte_offset + 1) {
303            window |= u32::from(next) << 8;
304        }
305        let value = (window >> shift) & ((1u32 << bits_per_coefficient) - 1);
306        if value > u32::from(maximum) {
307            return Err(SignError::InvalidKey(
308                "ML-DSA private key contains an out-of-range s1/s2 coefficient".to_string(),
309            ));
310        }
311    }
312
313    Ok(())
314}
315
316macro_rules! impl_mldsa_backend {
317    (
318        $params:ty,
319        $module:ident,
320        $verification_key:ident,
321        $signing_key:ident,
322        $signature:ident,
323        $pk_len:expr,
324        $sk_len:expr,
325        $sig_len:expr,
326        $eta:expr,
327        $k:expr,
328        $l:expr
329    ) => {
330        impl MlDsaBackend for $params {
331            fn validate_public_key(bytes: &[u8]) -> Result<(), SignError> {
332                // pkEncode uses rho followed by fixed-width t1 coefficients, so
333                // every exactly sized bit string is a canonical syntactic
334                // encoding. Semantic validity is enforced during verification.
335                let _ = fixed_array::<$pk_len>(bytes, "ML-DSA public key")?;
336                Ok(())
337            }
338
339            fn validate_secret_key(bytes: &[u8]) -> Result<(), SignError> {
340                let encoded = Zeroizing::new(fixed_array::<$sk_len>(
341                    bytes,
342                    "ML-DSA expanded private key",
343                )?);
344                validate_expanded_secret_encoding(encoded.as_ref(), $eta, $k, $l)?;
345                Ok(())
346            }
347
348            fn validate_key_pair(
349                secret_key: &[u8],
350                public_key: &[u8],
351            ) -> Result<(), SignError> {
352                Self::validate_secret_key(secret_key)?;
353                Self::validate_public_key(public_key)?;
354
355                let encoded_secret = Zeroizing::new(fixed_array::<$sk_len>(
356                    secret_key,
357                    "ML-DSA expanded private key",
358                )?);
359                let encoded_public = fixed_array::<$pk_len>(public_key, "ML-DSA public key")?;
360                let expected_tr = Shake256::digest(&encoded_public)
361                    .map_err(|error| SignError::Hashing(error.to_string()))?;
362                if encoded_secret[64..128] != expected_tr.as_ref()[..] {
363                    return Err(SignError::InvalidKey(
364                        "ML-DSA private key tr does not match SHAKE256(pk, 64)".to_string(),
365                    ));
366                }
367
368                let mut secret =
369                    libcrux_ml_dsa::$module::$signing_key::new(*encoded_secret);
370                let validation_signature = libcrux_ml_dsa::$module::portable::sign(
371                    &secret,
372                    b"dcrypt ML-DSA expanded-key import validation",
373                    &[],
374                    [0xA5; libcrux_ml_dsa::SIGNING_RANDOMNESS_SIZE],
375                );
376                secret.as_mut_slice().zeroize();
377                let validation_signature = validation_signature.map_err(|details| {
378                    SignError::InvalidKey(format!(
379                        "ML-DSA expanded key cannot produce a validation signature: {details:?}"
380                    ))
381                })?;
382
383                let public =
384                    libcrux_ml_dsa::$module::$verification_key::new(encoded_public);
385                libcrux_ml_dsa::$module::portable::verify(
386                    &public,
387                    b"dcrypt ML-DSA expanded-key import validation",
388                    &[],
389                    &validation_signature,
390                )
391                .map_err(|details| {
392                    SignError::InvalidKey(format!(
393                        "ML-DSA expanded private/public key mismatch: {details:?}"
394                    ))
395                })
396            }
397
398            fn keypair<R: CryptoRng + RngCore>(
399                rng: &mut R,
400            ) -> Result<(Vec<u8>, Vec<u8>), SignError> {
401                let mut seed = Zeroizing::new([
402                    0u8;
403                    libcrux_ml_dsa::KEY_GENERATION_RANDOMNESS_SIZE
404                ]);
405                rng.try_fill_bytes(seed.as_mut()).map_err(|details| {
406                    SignError::KeyGeneration {
407                        algorithm: <$params>::NAME,
408                        details: details.to_string(),
409                    }
410                })?;
411
412                let mut keypair =
413                    libcrux_ml_dsa::$module::portable::generate_key_pair(*seed);
414                let public = keypair.verification_key.as_slice().to_vec();
415                let secret = keypair.signing_key.as_slice().to_vec();
416                keypair.signing_key.as_mut_slice().zeroize();
417                Ok((public, secret))
418            }
419
420            fn sign_with_rng<R: CryptoRng + RngCore>(
421                message: &[u8],
422                secret_key: &[u8],
423                rng: &mut R,
424            ) -> Result<Vec<u8>, SignError> {
425                let encoded = Zeroizing::new(fixed_array::<$sk_len>(
426                    secret_key,
427                    "ML-DSA expanded private key",
428                )?);
429                let mut randomness =
430                    Zeroizing::new([0u8; libcrux_ml_dsa::SIGNING_RANDOMNESS_SIZE]);
431                rng.try_fill_bytes(randomness.as_mut()).map_err(|details| {
432                    SignError::SignatureGeneration {
433                        algorithm: <$params>::NAME,
434                        details: details.to_string(),
435                    }
436                })?;
437
438                let mut secret =
439                    libcrux_ml_dsa::$module::$signing_key::new(*encoded);
440                let signature = libcrux_ml_dsa::$module::portable::sign(
441                    &secret,
442                    message,
443                    &[],
444                    *randomness,
445                );
446                secret.as_mut_slice().zeroize();
447                let signature = signature.map_err(|details| {
448                    SignError::SignatureGeneration {
449                        algorithm: <$params>::NAME,
450                        details: format!("{details:?}"),
451                    }
452                })?;
453                Ok(signature.as_slice().to_vec())
454            }
455
456            fn verify(
457                message: &[u8],
458                signature: &[u8],
459                public_key: &[u8],
460            ) -> Result<(), SignError> {
461                let encoded_key = fixed_array::<$pk_len>(public_key, "ML-DSA public key")?;
462                let encoded_signature = fixed_array::<$sig_len>(signature, "ML-DSA signature")?;
463                let public =
464                    libcrux_ml_dsa::$module::$verification_key::new(encoded_key);
465                let signature =
466                    libcrux_ml_dsa::$module::$signature::new(encoded_signature);
467
468                libcrux_ml_dsa::$module::portable::verify(
469                    &public,
470                    message,
471                    &[],
472                    &signature,
473                )
474                .map_err(|details| SignError::Verification {
475                        algorithm: <$params>::NAME,
476                        details: format!("ML-DSA signature verification failed: {details:?}"),
477                    })
478            }
479        }
480    };
481}
482
483impl_mldsa_backend!(
484    Dilithium2Params,
485    ml_dsa_44,
486    MLDSA44VerificationKey,
487    MLDSA44SigningKey,
488    MLDSA44Signature,
489    1312,
490    2560,
491    2420,
492    2,
493    4,
494    4
495);
496impl_mldsa_backend!(
497    Dilithium3Params,
498    ml_dsa_65,
499    MLDSA65VerificationKey,
500    MLDSA65SigningKey,
501    MLDSA65Signature,
502    1952,
503    4032,
504    3309,
505    4,
506    6,
507    5
508);
509impl_mldsa_backend!(
510    Dilithium5Params,
511    ml_dsa_87,
512    MLDSA87VerificationKey,
513    MLDSA87SigningKey,
514    MLDSA87Signature,
515    2592,
516    4896,
517    4627,
518    2,
519    8,
520    7
521);
522
523/// ML-DSA signature scheme parameterized by a final FIPS 204 parameter set.
524pub struct Dilithium<P: DilithiumSchemeParams + 'static> {
525    _params: PhantomData<P>,
526}
527
528impl<P> Dilithium<P>
529where
530    P: MlDsaBackend + Send + Sync + 'static,
531{
532    /// Generate a randomized FIPS 204 signature with an explicit caller RNG.
533    ///
534    /// The [`dcrypt_api::Signature::sign`] implementation delegates to this
535    /// method with the operating system RNG.
536    pub fn sign_with_rng<R: CryptoRng + RngCore>(
537        message: &[u8],
538        secret_key: &DilithiumSecretKey,
539        rng: &mut R,
540    ) -> ApiResult<DilithiumSignatureData> {
541        let signature =
542            P::sign_with_rng(message, secret_key.as_ref(), rng).map_err(dcrypt_api::Error::from)?;
543        Ok(DilithiumSignatureData(signature))
544    }
545}
546
547impl<P> SignatureTrait for Dilithium<P>
548where
549    P: MlDsaBackend + Send + Sync + 'static,
550{
551    type PublicKey = DilithiumPublicKey;
552    type SecretKey = DilithiumSecretKey;
553    type SignatureData = DilithiumSignatureData;
554    type KeyPair = (Self::PublicKey, Self::SecretKey);
555
556    fn name() -> &'static str {
557        P::NAME
558    }
559
560    fn keypair<R: CryptoRng + RngCore>(rng: &mut R) -> ApiResult<Self::KeyPair> {
561        let (public, secret) = P::keypair(rng).map_err(dcrypt_api::Error::from)?;
562        Ok((
563            DilithiumPublicKey(public.clone()),
564            DilithiumSecretKey {
565                bytes: secret,
566                public_key: Some(public),
567            },
568        ))
569    }
570
571    fn public_key(keypair: &Self::KeyPair) -> Self::PublicKey {
572        keypair.0.clone()
573    }
574
575    fn secret_key(keypair: &Self::KeyPair) -> Self::SecretKey {
576        keypair.1.clone()
577    }
578
579    fn sign(message: &[u8], secret_key: &Self::SecretKey) -> ApiResult<Self::SignatureData> {
580        Self::sign_with_rng(message, secret_key, &mut rand::rngs::OsRng)
581    }
582
583    fn verify(
584        message: &[u8],
585        signature: &Self::SignatureData,
586        public_key: &Self::PublicKey,
587    ) -> ApiResult<()> {
588        validate_hint_encoding_for_len(signature.as_ref()).map_err(dcrypt_api::Error::from)?;
589        P::verify(message, signature.as_ref(), public_key.as_ref()).map_err(dcrypt_api::Error::from)
590    }
591}
592
593fn validate_hint_encoding_for_len(signature: &[u8]) -> Result<(), SignError> {
594    match signature.len() {
595        2420 => validate_hint_encoding(signature, 32 + 4 * 576, 80, 4),
596        3309 => validate_hint_encoding(signature, 48 + 5 * 640, 55, 6),
597        4627 => validate_hint_encoding(signature, 64 + 7 * 640, 75, 8),
598        actual => Err(SignError::InvalidSignatureSize {
599            expected: 0,
600            actual,
601        }),
602    }
603}
604
605/// ML-DSA-44 (FIPS 204 security category 2).
606pub type MlDsa44 = Dilithium<Dilithium2Params>;
607/// ML-DSA-65 (FIPS 204 security category 3).
608pub type MlDsa65 = Dilithium<Dilithium3Params>;
609/// ML-DSA-87 (FIPS 204 security category 5).
610pub type MlDsa87 = Dilithium<Dilithium5Params>;
611
612/// Compatibility alias for [`MlDsa44`].
613pub type Dilithium2 = MlDsa44;
614/// Compatibility alias for [`MlDsa65`].
615pub type Dilithium3 = MlDsa65;
616/// Compatibility alias for [`MlDsa87`].
617pub type Dilithium5 = MlDsa87;
618
619#[cfg(test)]
620mod tests;