Skip to content

Commit 19aa613

Browse files
committed
feat(ssh-key): implement ML-DSA signing and verification
1 parent 6cb88f0 commit 19aa613

3 files changed

Lines changed: 152 additions & 5 deletions

File tree

ssh-key/src/private/mldsa.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ fn derive_public<P: ml_dsa::MlDsaParams>(seed: &[u8; SEED_SIZE]) -> Vec<u8> {
196196
signing_key.verifying_key().encode().as_slice().to_vec()
197197
}
198198

199+
/// Sign a message with "pure" ML-DSA (empty context) for the concrete parameter set `P`.
200+
fn sign_with_params<P: ml_dsa::MlDsaParams>(seed: &[u8; SEED_SIZE], msg: &[u8]) -> Result<Vec<u8>> {
201+
use signature::Signer;
202+
203+
let seed = ml_dsa::B32::from(*seed);
204+
let signing_key = ml_dsa::SigningKey::<P>::from_seed(&seed);
205+
206+
// The `Signer` impl uses "pure" ML-DSA with an empty context string, as
207+
// required by draft-sfluhrer-ssh-mldsa.
208+
let signature = signing_key.try_sign(msg)?;
209+
Ok(signature.encode().as_slice().to_vec())
210+
}
211+
199212
impl MlDsaKeypair {
200213
/// Generate a random ML-DSA keypair for the given parameter set.
201214
///
@@ -224,6 +237,16 @@ impl MlDsaKeypair {
224237
})
225238
}
226239

240+
/// Sign a message, producing the raw ML-DSA signature bytes.
241+
pub(crate) fn sign_msg(&self, msg: &[u8]) -> Result<Vec<u8>> {
242+
let seed = self.private.as_ref();
243+
match self.public.params() {
244+
MlDsaParams::MlDsa44 => sign_with_params::<ml_dsa::MlDsa44>(seed, msg),
245+
MlDsaParams::MlDsa65 => sign_with_params::<ml_dsa::MlDsa65>(seed, msg),
246+
MlDsaParams::MlDsa87 => sign_with_params::<ml_dsa::MlDsa87>(seed, msg),
247+
}
248+
}
249+
227250
/// Verify that the stored public key matches the key derived from the seed.
228251
fn validate(&self) -> Result<()> {
229252
let expected = match self.public.params() {

ssh-key/src/public/mldsa.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//!
55
//! [FIPS204]: https://csrc.nist.gov/pubs/fips/204/final
66
7-
use crate::{Algorithm, MlDsaParams, Result};
7+
use crate::{Algorithm, Error, MlDsaParams, Result};
88
use alloc::boxed::Box;
99
use encoding::{Encode, Reader, Writer};
1010
use ml_dsa::{EncodedVerifyingKey, MlDsa44, MlDsa65, MlDsa87};
@@ -90,6 +90,19 @@ impl MlDsaPublicKey {
9090
let key = reader.read_byten(&mut buf)?;
9191
Self::new(params, key)
9292
}
93+
94+
/// Verify a raw ML-DSA signature over the given message using "pure" ML-DSA
95+
/// with an empty context string.
96+
///
97+
/// # Errors
98+
/// Returns [`Error::Signature`] if the signature is malformed or invalid.
99+
pub(crate) fn verify_msg(&self, msg: &[u8], signature: &[u8]) -> Result<()> {
100+
match self {
101+
Self::MlDsa44(key) => verify_with_params::<MlDsa44>(key, msg, signature),
102+
Self::MlDsa65(key) => verify_with_params::<MlDsa65>(key, msg, signature),
103+
Self::MlDsa87(key) => verify_with_params::<MlDsa87>(key, msg, signature),
104+
}
105+
}
93106
}
94107

95108
impl AsRef<[u8]> for MlDsaPublicKey {
@@ -107,3 +120,21 @@ impl Encode for MlDsaPublicKey {
107120
self.as_bytes().encode(writer)
108121
}
109122
}
123+
124+
/// Verify an ML-DSA signature over `msg` with the concrete ML-DSA parameter set `P`.
125+
fn verify_with_params<P: ml_dsa::MlDsaParams>(
126+
key: &EncodedVerifyingKey<P>,
127+
msg: &[u8],
128+
signature: &[u8],
129+
) -> Result<()> {
130+
use signature::Verifier;
131+
132+
let verifying_key = ml_dsa::VerifyingKey::<P>::decode(key);
133+
let signature = ml_dsa::Signature::<P>::try_from(signature).map_err(|_| Error::Signature)?;
134+
135+
// The `Verifier` impl uses "pure" ML-DSA with an empty context string, as
136+
// required by draft-sfluhrer-ssh-mldsa.
137+
verifying_key
138+
.verify(msg, &signature)
139+
.map_err(|_| Error::Signature)
140+
}

ssh-key/src/signature.rs

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ use signature::{SignatureEncoding, Signer, Verifier};
99
#[cfg(feature = "ed25519")]
1010
use crate::{private::Ed25519Keypair, public::Ed25519PublicKey};
1111

12+
#[cfg(feature = "mldsa")]
13+
use crate::{private::MlDsaKeypair, public::MlDsaPublicKey};
14+
1215
#[cfg(feature = "dsa")]
1316
use {
1417
crate::{private::DsaKeypair, public::DsaPublicKey},
@@ -112,6 +115,7 @@ impl Signature {
112115
Algorithm::SkEd25519 if data.len() == SK_ED25519_SIGNATURE_SIZE => (),
113116
Algorithm::SkEcdsaSha2NistP256 => ecdsa_sig_size(&data, EcdsaCurve::NistP256, true)?,
114117
Algorithm::Rsa { .. } => (),
118+
Algorithm::MlDsa { params } if data.len() == params.signature_size() => (),
115119
Algorithm::Other(_) if !data.is_empty() => (),
116120
_ => return Err(encoding::Error::Length.into()),
117121
}
@@ -293,6 +297,8 @@ impl Signer<Signature> for private::KeypairData {
293297
Self::Ed25519(keypair) => keypair.try_sign(message),
294298
#[cfg(feature = "rsa")]
295299
Self::Rsa(keypair) => keypair.try_sign(message),
300+
#[cfg(feature = "mldsa")]
301+
Self::MlDsa(keypair) => keypair.try_sign(message),
296302
_ => Err(self.algorithm()?.unsupported_error().into()),
297303
}
298304
}
@@ -320,6 +326,8 @@ impl Verifier<Signature> for public::KeyData {
320326
Self::SkEcdsaSha2NistP256(pk) => pk.verify(message, signature),
321327
#[cfg(feature = "rsa")]
322328
Self::Rsa(pk) => pk.verify(message, signature),
329+
#[cfg(feature = "mldsa")]
330+
Self::MlDsa(pk) => pk.verify(message, signature),
323331
#[allow(unreachable_patterns)]
324332
_ => Err(self.algorithm().unsupported_error().into()),
325333
}
@@ -452,6 +460,30 @@ impl Verifier<Signature> for Ed25519PublicKey {
452460
}
453461
}
454462

463+
#[cfg(feature = "mldsa")]
464+
impl Signer<Signature> for MlDsaKeypair {
465+
fn try_sign(&self, message: &[u8]) -> signature::Result<Signature> {
466+
let data = self.sign_msg(message)?;
467+
468+
Ok(Signature {
469+
algorithm: self.algorithm(),
470+
data,
471+
})
472+
}
473+
}
474+
475+
#[cfg(feature = "mldsa")]
476+
impl Verifier<Signature> for MlDsaPublicKey {
477+
fn verify(&self, message: &[u8], signature: &Signature) -> signature::Result<()> {
478+
// The signature's algorithm (including parameter set) must match this key.
479+
if signature.algorithm() != self.algorithm() {
480+
return Err(Error::Signature.into());
481+
}
482+
483+
Ok(self.verify_msg(message, signature.as_bytes())?)
484+
}
485+
}
486+
455487
#[cfg(feature = "ed25519")]
456488
impl Verifier<Signature> for public::SkEd25519 {
457489
fn verify(&self, message: &[u8], signature: &Signature) -> signature::Result<()> {
@@ -767,10 +799,19 @@ mod tests {
767799
use encoding::Encode;
768800
use hex_literal::hex;
769801

770-
#[cfg(any(feature = "ed25519", all(feature = "rsa", feature = "sha1")))]
771-
use signature::Verifier;
772802
#[cfg(feature = "ed25519")]
773-
use {super::Ed25519Keypair, signature::Signer};
803+
use super::Ed25519Keypair;
804+
#[cfg(any(feature = "ed25519", feature = "mldsa"))]
805+
use signature::Signer;
806+
#[cfg(any(
807+
feature = "ed25519",
808+
feature = "mldsa",
809+
all(feature = "rsa", feature = "sha1")
810+
))]
811+
use signature::Verifier;
812+
813+
#[cfg(feature = "mldsa")]
814+
use {super::MlDsaKeypair, crate::MlDsaParams};
774815

775816
#[cfg(feature = "p256")]
776817
use super::{Mpint, zero_pad_field_bytes};
@@ -922,7 +963,6 @@ mod tests {
922963
fn try_sign_and_verify_dsa() {
923964
use super::{DSA_COMPONENT_SIZE, DsaKeypair};
924965
use encoding::Decode as _;
925-
use signature::{Signer as _, Verifier as _};
926966

927967
fn check_signature_component_lengths(
928968
keypair: &DsaKeypair,
@@ -1001,6 +1041,59 @@ mod tests {
10011041
assert!(keypair.public.verify(EXAMPLE_MSG, &signature).is_ok());
10021042
}
10031043

1044+
#[cfg(feature = "mldsa")]
1045+
#[test]
1046+
fn sign_and_verify_mldsa() {
1047+
let msg = b"Hello, world!";
1048+
1049+
for params in [
1050+
MlDsaParams::MlDsa44,
1051+
MlDsaParams::MlDsa65,
1052+
MlDsaParams::MlDsa87,
1053+
] {
1054+
let keypair = MlDsaKeypair::from_seed(params, &[42; 32]).unwrap();
1055+
let signature = keypair.sign(msg);
1056+
1057+
assert_eq!(signature.algorithm(), Algorithm::MlDsa { params });
1058+
assert_eq!(signature.as_bytes().len(), params.signature_size());
1059+
assert!(keypair.public.verify(msg, &signature).is_ok());
1060+
1061+
// Signing is deterministic (empty context, deterministic variant).
1062+
assert_eq!(keypair.sign(msg), signature);
1063+
1064+
// A tampered message must fail verification.
1065+
assert!(keypair.public.verify(b"tampered", &signature).is_err());
1066+
1067+
// Signature encode/decode round-trips.
1068+
let encoded = signature.encode_vec().unwrap();
1069+
let decoded = Signature::try_from(&encoded[..]).unwrap();
1070+
assert_eq!(decoded, signature);
1071+
}
1072+
}
1073+
1074+
#[cfg(feature = "mldsa")]
1075+
#[test]
1076+
fn mldsa_key_roundtrip() {
1077+
use crate::{private::KeypairData, public::KeyData};
1078+
use encoding::Decode;
1079+
1080+
let params = MlDsaParams::MlDsa65;
1081+
let keypair = MlDsaKeypair::from_seed(params, &[7; 32]).unwrap();
1082+
1083+
// Public key wire-format round-trips and reports the correct algorithm.
1084+
let public: KeyData = keypair.public.clone().into();
1085+
let encoded = public.encode_vec().unwrap();
1086+
let decoded = KeyData::decode(&mut &encoded[..]).unwrap();
1087+
assert_eq!(decoded, public);
1088+
assert_eq!(decoded.algorithm(), Algorithm::MlDsa { params });
1089+
1090+
// Keypair wire-format round-trips.
1091+
let keypair_data = KeypairData::from(keypair);
1092+
let encoded = keypair_data.encode_vec().unwrap();
1093+
let decoded = KeypairData::decode(&mut &encoded[..]).unwrap();
1094+
assert_eq!(decoded, keypair_data);
1095+
}
1096+
10041097
#[test]
10051098
fn placeholder() {
10061099
assert!(

0 commit comments

Comments
 (0)