opentitanlib/crypto/
mldsa.rs

1// Copyright lowRISC contributors (OpenTitan project).
2// Licensed under the Apache License, Version 2.0, see LICENSE for details.
3// SPDX-License-Identifier: Apache-2.0
4
5use std::path::Path;
6
7use anyhow::{Context, Result, anyhow, bail, ensure};
8use ml_dsa::common::getrandom::SysRng;
9use ml_dsa::pkcs8::der::asn1::OctetStringRef;
10use ml_dsa::pkcs8::der::oid::db::fips204;
11use ml_dsa::pkcs8::der::zeroize::Zeroizing;
12use ml_dsa::pkcs8::der::{AnyRef, Decode, SliceReader, Tag, Tagged};
13use ml_dsa::pkcs8::spki::AssociatedAlgorithmIdentifier;
14use ml_dsa::pkcs8::{
15    DecodePublicKey, EncodePrivateKey, EncodePublicKey, LineEnding, ObjectIdentifier,
16    PrivateKeyInfoRef, SecretDocument, SubjectPublicKeyInfoRef,
17};
18use ml_dsa::{Keypair, MlDsaParams, Seed, Signature, SigningKey, VerifyingKey};
19use strum::{Display, EnumString};
20
21/// ML-DSA parameter sets with PKCS#8 / SubjectPublicKeyInfo support.
22pub trait MldsaPkcs8Params:
23    MlDsaParams + AssociatedAlgorithmIdentifier<Params = AnyRef<'static>>
24{
25}
26
27impl<P> MldsaPkcs8Params for P where
28    P: MlDsaParams + AssociatedAlgorithmIdentifier<Params = AnyRef<'static>>
29{
30}
31
32/// ML-DSA parameter sets.
33#[derive(Copy, Clone, PartialEq, Eq, Debug, EnumString, Display)]
34#[strum(ascii_case_insensitive)]
35pub enum MldsaParameterSet {
36    #[strum(serialize = "ML-DSA-44")]
37    Mldsa44,
38    #[strum(serialize = "ML-DSA-65")]
39    Mldsa65,
40    #[strum(serialize = "ML-DSA-87")]
41    Mldsa87,
42}
43
44impl MldsaParameterSet {
45    /// Returns the parameter set with the given object identifier.
46    pub fn from_oid(oid: ObjectIdentifier) -> Option<Self> {
47        match oid {
48            fips204::ID_ML_DSA_44 => Some(Self::Mldsa44),
49            fips204::ID_ML_DSA_65 => Some(Self::Mldsa65),
50            fips204::ID_ML_DSA_87 => Some(Self::Mldsa87),
51            _ => None,
52        }
53    }
54}
55
56/// Formats that can be used to represent ML-DSA keys: PKCS#8 for private keys,
57/// and `SubjectPublicKeyInfo` for public keys.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, EnumString, Display)]
59#[strum(ascii_case_insensitive)]
60pub enum MldsaKeyFormat {
61    #[default]
62    #[strum(to_string = "pem", serialize = "pkcs8-pem")]
63    Pem,
64    #[strum(to_string = "der", serialize = "pkcs8-der")]
65    Der,
66}
67
68impl MldsaKeyFormat {
69    /// Standard file extension for a private key in this format.
70    pub fn ext(&self) -> &'static str {
71        match self {
72            Self::Pem => "pem",
73            Self::Der => "der",
74        }
75    }
76
77    /// Standard file extension for a public key in this format.
78    pub fn pub_ext(&self) -> String {
79        format!("pub.{}", self.ext())
80    }
81}
82
83/// Decodes a PEM or DER encoded key into its DER encoding.
84fn decode_key(input: &[u8]) -> Result<SecretDocument> {
85    if input.trim_ascii_start().starts_with(b"-----BEGIN") {
86        let pem = std::str::from_utf8(input).context("Failed to read PEM contents as UTF-8")?;
87        Ok(SecretDocument::from_pem(pem)?.1)
88    } else {
89        Ok(SecretDocument::try_from(input)?)
90    }
91}
92
93/// Reads a PEM or DER encoded key file into its DER encoding.
94pub fn read_mldsa_key_file(path: impl AsRef<Path>) -> Result<SecretDocument> {
95    let path = path.as_ref();
96    let data = Zeroizing::new(
97        std::fs::read(path).with_context(|| format!("Failed to read file: {path:?}"))?,
98    );
99    decode_key(&data)
100}
101
102/// Decodes the seed + expanded form of an ML-DSA private key.
103fn signing_key_from_seed_and_expanded<P: MldsaPkcs8Params>(
104    private_key: &[u8],
105) -> Result<SigningKey<P>> {
106    let (seed, expanded) = AnyRef::from_der(private_key)?.sequence(|r: &mut SliceReader<'_>| {
107        Ok::<_, ml_dsa::pkcs8::der::Error>((
108            <&OctetStringRef>::decode(r)?.as_bytes(),
109            <&OctetStringRef>::decode(r)?.as_bytes(),
110        ))
111    })?;
112    let seed = Zeroizing::new(
113        Seed::try_from(seed).map_err(|_| anyhow!("Bad ML-DSA seed length {}", seed.len()))?,
114    );
115    let key = SigningKey::<P>::from_seed(&seed);
116    // The ml-dsa crate doesn't natively support the seed + expanded form, instead encouraging
117    // use of the seed form, and so marks this functionality as deprecated. If a future crate
118    // version drops this function, this check should probably be dropped.
119    #[allow(deprecated)]
120    let derived = Zeroizing::new(key.expanded_key().to_expanded());
121    ensure!(
122        derived.as_slice() == expanded,
123        "ML-DSA expanded private key does not match the seed"
124    );
125    Ok(key)
126}
127
128/// Decodes an ML-DSA private key from a PKCS#8v1 (PrivateKeyInfo) or PKCS#8v2
129/// (OneAsymmetricKey) in the seed or seed + expanded form.
130fn signing_key_from_pkcs8<P: MldsaPkcs8Params>(
131    info: PrivateKeyInfoRef<'_>,
132) -> Result<SigningKey<P>> {
133    info.algorithm
134        .assert_algorithm_oid(P::ALGORITHM_IDENTIFIER.oid)?;
135    let private_key = info.private_key.as_bytes();
136    let key = match AnyRef::from_der(private_key)?.tag() {
137        Tag::Sequence => signing_key_from_seed_and_expanded::<P>(private_key)?,
138        Tag::OctetString => bail!("Expanded-only ML-DSA private keys are not supported"),
139        _ => SigningKey::<P>::try_from(info.clone())?,
140    };
141    if let Some(public_key) = &info.public_key {
142        ensure!(
143            public_key.as_bytes() == Some(key.verifying_key().encode().as_slice()),
144            "ML-DSA public key does not match the private key"
145        );
146    }
147    Ok(key)
148}
149
150/// Load an ML-DSA private key from its PEM or DER encoded PKCS#8 bytes.
151pub fn mldsa_private_key_from_bytes<P: MldsaPkcs8Params>(input: &[u8]) -> Result<SigningKey<P>> {
152    let doc = decode_key(input)?;
153    signing_key_from_pkcs8(PrivateKeyInfoRef::from_der(doc.as_bytes())?)
154}
155
156/// Load an ML-DSA private key from a PEM or DER file.
157pub fn load_mldsa_private_key<P: MldsaPkcs8Params>(
158    path: impl AsRef<Path>,
159) -> Result<SigningKey<P>> {
160    let doc = read_mldsa_key_file(path)?;
161    signing_key_from_pkcs8(PrivateKeyInfoRef::from_der(doc.as_bytes())?)
162}
163
164/// Write an ML-DSA private key (seed form) to a file in the given format.
165pub fn save_mldsa_private_key<P: MldsaPkcs8Params>(
166    key: &SigningKey<P>,
167    path: impl AsRef<Path>,
168    format: MldsaKeyFormat,
169) -> Result<()> {
170    let path = path.as_ref();
171    match format {
172        MldsaKeyFormat::Pem => std::fs::write(path, key.to_pkcs8_pem(LineEnding::default())?),
173        MldsaKeyFormat::Der => std::fs::write(path, key.to_pkcs8_der()?.as_bytes()),
174    }
175    .with_context(|| format!("Failed to write file: {path:?}"))
176}
177
178/// Load an ML-DSA public key from its PEM or DER encoded SubjectPublicKeyInfo bytes.
179pub fn mldsa_public_key_from_bytes<P: MldsaPkcs8Params>(input: &[u8]) -> Result<VerifyingKey<P>> {
180    Ok(VerifyingKey::from_public_key_der(
181        decode_key(input)?.as_bytes(),
182    )?)
183}
184
185/// Load an ML-DSA public key from a PEM or DER file.
186pub fn load_mldsa_public_key<P: MldsaPkcs8Params>(
187    path: impl AsRef<Path>,
188) -> Result<VerifyingKey<P>> {
189    Ok(VerifyingKey::from_public_key_der(
190        read_mldsa_key_file(path)?.as_bytes(),
191    )?)
192}
193
194/// Decodes the DER encoding of an ML-DSA public key (SubjectPublicKeyInfo)
195/// or private key (PKCS#8), returning the public key and, for a private key,
196/// the private key.
197pub fn mldsa_key_from_der<P: MldsaPkcs8Params>(
198    der: &[u8],
199) -> Result<(VerifyingKey<P>, Option<SigningKey<P>>)> {
200    // Pick the decoder by structure so that its errors are not masked by
201    // the other one.
202    if let Ok(spki) = SubjectPublicKeyInfoRef::from_der(der) {
203        return Ok((VerifyingKey::try_from(spki)?, None));
204    }
205    let info = PrivateKeyInfoRef::from_der(der)
206        .context("Not an ML-DSA public (SubjectPublicKeyInfo) or private (PKCS#8) key")?;
207    let key = signing_key_from_pkcs8(info)?;
208    Ok((key.verifying_key(), Some(key)))
209}
210
211/// Load an ML-DSA public or private key from a PEM or DER file, returning the
212/// public key and, for a private key file, the private key.
213pub fn load_mldsa_key<P: MldsaPkcs8Params>(
214    path: impl AsRef<Path>,
215) -> Result<(VerifyingKey<P>, Option<SigningKey<P>>)> {
216    mldsa_key_from_der(read_mldsa_key_file(path)?.as_bytes())
217}
218
219/// Write an ML-DSA public key to a file in the given format.
220pub fn save_mldsa_public_key<P: MldsaPkcs8Params>(
221    key: &VerifyingKey<P>,
222    path: impl AsRef<Path>,
223    format: MldsaKeyFormat,
224) -> Result<()> {
225    let path = path.as_ref();
226    match format {
227        MldsaKeyFormat::Pem => std::fs::write(path, key.to_public_key_pem(LineEnding::default())?),
228        MldsaKeyFormat::Der => std::fs::write(path, key.to_public_key_der()?.as_bytes()),
229    }
230    .with_context(|| format!("Failed to write file: {path:?}"))
231}
232
233/// Returns the parameter set of the DER encoding of an ML-DSA public or private key.
234pub fn mldsa_parameter_set_from_der(der: &[u8]) -> Result<MldsaParameterSet> {
235    let oid = match SubjectPublicKeyInfoRef::from_der(der) {
236        Ok(spki) => spki.algorithm.oid,
237        Err(_) => PrivateKeyInfoRef::from_der(der)?.algorithm.oid,
238    };
239    MldsaParameterSet::from_oid(oid).ok_or_else(|| anyhow!("Not an ML-DSA key: {oid}"))
240}
241
242/// Signs `msg` under the `context` string (pure mode).
243pub fn mldsa_sign<P: MlDsaParams>(
244    key: &SigningKey<P>,
245    context: &[u8],
246    msg: &[u8],
247) -> Result<Signature<P>> {
248    key.expanded_key()
249        .sign_randomized(msg, context, &mut SysRng)
250        .map_err(|_| anyhow!("ML-DSA signature generation failed"))
251}
252
253/// Verifies the encoded `signature` of `msg` under the `context` string (pure mode).
254pub fn mldsa_verify<P: MlDsaParams>(
255    key: &VerifyingKey<P>,
256    context: &[u8],
257    signature: &[u8],
258    msg: &[u8],
259) -> Result<()> {
260    let signature = Signature::<P>::try_from(signature)
261        .map_err(|_| anyhow!("Malformed ML-DSA signature of length {}", signature.len()))?;
262    ensure!(
263        key.verify_with_context(msg, context, &signature),
264        "ML-DSA signature did not pass verification"
265    );
266    Ok(())
267}
268
269#[cfg(test)]
270mod test {
271    use super::*;
272    use crate::util::testdata;
273    use ml_dsa::pkcs8::der::Encode;
274    use ml_dsa::pkcs8::der::asn1::BitStringRef;
275    use ml_dsa::{Generate, MlDsa65, MlDsa87};
276
277    #[test]
278    fn key_encoding() -> Result<()> {
279        let sk = SigningKey::<MlDsa87>::try_generate()?;
280        let pk = sk.verifying_key();
281        let pem = sk.to_pkcs8_pem(LineEnding::default())?;
282        assert_eq!(mldsa_private_key_from_bytes::<MlDsa87>(pem.as_bytes())?, sk);
283        let der = sk.to_pkcs8_der()?;
284        assert_eq!(mldsa_private_key_from_bytes::<MlDsa87>(der.as_bytes())?, sk);
285        let pem = pk.to_public_key_pem(LineEnding::default())?;
286        assert_eq!(mldsa_public_key_from_bytes::<MlDsa87>(pem.as_bytes())?, pk);
287        let der = pk.to_public_key_der()?;
288        assert_eq!(mldsa_public_key_from_bytes::<MlDsa87>(der.as_bytes())?, pk);
289
290        // A private key cannot be loaded as a public key and vice versa.
291        assert!(mldsa_private_key_from_bytes::<MlDsa87>(der.as_bytes()).is_err());
292        let der = sk.to_pkcs8_der()?;
293        assert!(mldsa_public_key_from_bytes::<MlDsa87>(der.as_bytes()).is_err());
294        // The parameter set must match.
295        assert!(mldsa_private_key_from_bytes::<MlDsa65>(der.as_bytes()).is_err());
296        Ok(())
297    }
298
299    /// Encodes `sk` as a PKCS#8v2 (OneAsymmetricKey) containing `pk`.
300    fn to_pkcs8_v2(sk: &SigningKey<MlDsa87>, pk: &VerifyingKey<MlDsa87>) -> Result<Vec<u8>> {
301        let v1 = sk.to_pkcs8_der()?;
302        let mut info = PrivateKeyInfoRef::from_der(v1.as_bytes())?;
303        let pk = pk.encode();
304        info.public_key = Some(BitStringRef::new(0, &pk)?);
305        Ok(info.to_der()?)
306    }
307
308    #[test]
309    fn pkcs8_v2() -> Result<()> {
310        let sk = SigningKey::<MlDsa87>::try_generate()?;
311        let v2 = to_pkcs8_v2(&sk, &sk.verifying_key())?;
312        assert_eq!(mldsa_private_key_from_bytes::<MlDsa87>(&v2)?, sk);
313
314        // The public key must match the private key.
315        let other = SigningKey::<MlDsa87>::try_generate()?;
316        let v2 = to_pkcs8_v2(&sk, &other.verifying_key())?;
317        assert!(mldsa_private_key_from_bytes::<MlDsa87>(&v2).is_err());
318        // The consistency error is reported when loading a key of unknown kind.
319        let err = mldsa_key_from_der::<MlDsa87>(&v2).unwrap_err();
320        assert!(err.to_string().contains("does not match the private key"));
321        Ok(())
322    }
323
324    #[test]
325    fn sign_verify() -> Result<()> {
326        let sk = SigningKey::<MlDsa87>::try_generate()?;
327        let pk = sk.verifying_key();
328        let sig = mldsa_sign(&sk, b"context", b"message")?.encode();
329        mldsa_verify(&pk, b"context", &sig, b"message")?;
330        assert!(mldsa_verify(&pk, b"other", &sig, b"message").is_err());
331        assert!(mldsa_verify(&pk, b"context", &sig, b"other").is_err());
332        assert!(mldsa_verify(&pk, b"context", &sig[1..], b"message").is_err());
333
334        // The context string may be at most 255 bytes long.
335        mldsa_sign(&sk, &[0x5a; 255], b"message")?;
336        assert!(mldsa_sign(&sk, &[0x5a; 256], b"message").is_err());
337        Ok(())
338    }
339
340    #[test]
341    fn parameter_set() {
342        let oid = MlDsa87::ALGORITHM_IDENTIFIER.oid;
343        assert_eq!(
344            MldsaParameterSet::from_oid(oid),
345            Some(MldsaParameterSet::Mldsa87)
346        );
347        assert_eq!(
348            "ml-dsa-87".parse::<MldsaParameterSet>(),
349            Ok(MldsaParameterSet::Mldsa87)
350        );
351    }
352
353    /// Known answer test vector: generated with OpenSSL 3.5.
354    const KAT_MESSAGE: &[u8] = b"The quick brown fox jumps over the lazy dog";
355    const KAT_CONTEXT: &[u8] = b"OpenTitan";
356
357    #[test]
358    fn known_answer() -> Result<()> {
359        let public = std::fs::read(testdata("mldsa/mldsa87.pub.pem"))?;
360        let signature = std::fs::read(testdata("mldsa/mldsa87.sig"))?;
361        let pk = mldsa_public_key_from_bytes::<MlDsa87>(&public)?;
362        mldsa_verify(&pk, KAT_CONTEXT, &signature, KAT_MESSAGE)
363    }
364
365    #[test]
366    fn openssl_interop() -> Result<()> {
367        // Private keys generated with OpenSSL 3.5 in its three output formats.
368        let seed_priv = std::fs::read(testdata("mldsa/openssl_mldsa87.seed-priv.pem"))?;
369        let seed_only = std::fs::read_to_string(testdata("mldsa/openssl_mldsa87.seed-only.pem"))?;
370        let priv_only = std::fs::read(testdata("mldsa/openssl_mldsa87.priv-only.pem"))?;
371        let public = std::fs::read(testdata("mldsa/openssl_mldsa87.pub.pem"))?;
372
373        let pk = mldsa_public_key_from_bytes::<MlDsa87>(&public)?;
374        let both = mldsa_private_key_from_bytes::<MlDsa87>(&seed_priv)?;
375        let seed = mldsa_private_key_from_bytes::<MlDsa87>(seed_only.as_bytes())?;
376        assert_eq!(both, seed);
377        assert_eq!(seed.verifying_key(), pk);
378        assert_eq!(
379            *seed.to_pkcs8_pem(LineEnding::LF)?,
380            seed_only.replace("\r\n", "\n")
381        );
382        let err = mldsa_private_key_from_bytes::<MlDsa87>(&priv_only).unwrap_err();
383        assert!(err.to_string().contains("Expanded-only"));
384        Ok(())
385    }
386}