Skip to content

Commit da9aa44

Browse files
committed
feat(ssh-key): implement ML-DSA signing and verification
1 parent 4e695ad commit da9aa44

3 files changed

Lines changed: 167 additions & 4 deletions

File tree

ssh-key/src/private/mldsa.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,20 @@ fn derive_public<P: ml_dsa::MlDsaParams>(seed: &[u8; SEED_SIZE]) -> Vec<u8> {
201201
signing_key.verifying_key().encode().as_slice().to_vec()
202202
}
203203

204+
/// Sign a message with "pure" ML-DSA (empty context) for the concrete parameter set `P`.
205+
#[cfg(feature = "mldsa")]
206+
fn sign_with_params<P: ml_dsa::MlDsaParams>(seed: &[u8; SEED_SIZE], msg: &[u8]) -> Result<Vec<u8>> {
207+
use signature::Signer;
208+
209+
let seed = ml_dsa::B32::from(*seed);
210+
let signing_key = ml_dsa::SigningKey::<P>::from_seed(&seed);
211+
212+
// The `Signer` impl uses "pure" ML-DSA with an empty context string, as
213+
// required by draft-sfluhrer-ssh-mldsa.
214+
let signature = signing_key.try_sign(msg)?;
215+
Ok(signature.encode().as_slice().to_vec())
216+
}
217+
204218
#[cfg(feature = "mldsa")]
205219
impl MlDsaKeypair {
206220
/// Generate a random ML-DSA keypair for the given parameter set.
@@ -230,6 +244,16 @@ impl MlDsaKeypair {
230244
})
231245
}
232246

247+
/// Sign a message, producing the raw ML-DSA signature bytes.
248+
pub(crate) fn sign_msg(&self, msg: &[u8]) -> Result<Vec<u8>> {
249+
let seed = self.private.as_ref();
250+
match self.public.params() {
251+
MlDsaParams::MlDsa44 => sign_with_params::<ml_dsa::MlDsa44>(seed, msg),
252+
MlDsaParams::MlDsa65 => sign_with_params::<ml_dsa::MlDsa65>(seed, msg),
253+
MlDsaParams::MlDsa87 => sign_with_params::<ml_dsa::MlDsa87>(seed, msg),
254+
}
255+
}
256+
233257
/// Verify that the stored public key matches the key derived from the seed.
234258
fn validate(&self) -> Result<()> {
235259
let expected = match self.public.params() {

ssh-key/src/public/mldsa.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ use crate::{Algorithm, MlDsaParams, Result};
88
use alloc::vec::Vec;
99
use encoding::{Decode, Encode, Reader, Writer};
1010

11+
#[cfg(feature = "mldsa")]
12+
use crate::Error;
13+
1114
/// ML-DSA public key.
1215
///
1316
/// SSH encodings for ML-DSA public keys are described in
@@ -91,3 +94,46 @@ impl Encode for MlDsaPublicKey {
9194
self.key.encode(writer)
9295
}
9396
}
97+
98+
/// Verify an ML-DSA signature over `msg` with the concrete ML-DSA parameter set `P`.
99+
#[cfg(feature = "mldsa")]
100+
fn verify_with_params<P: ml_dsa::MlDsaParams>(
101+
key: &[u8],
102+
msg: &[u8],
103+
signature: &[u8],
104+
) -> Result<()> {
105+
use signature::Verifier;
106+
107+
let encoded = ml_dsa::EncodedVerifyingKey::<P>::try_from(key).map_err(|_| Error::PublicKey)?;
108+
let verifying_key = ml_dsa::VerifyingKey::<P>::decode(&encoded);
109+
let signature = ml_dsa::Signature::<P>::try_from(signature).map_err(|_| Error::Signature)?;
110+
111+
// The `Verifier` impl uses "pure" ML-DSA with an empty context string, as
112+
// required by draft-sfluhrer-ssh-mldsa.
113+
verifying_key
114+
.verify(msg, &signature)
115+
.map_err(|_| Error::Signature)
116+
}
117+
118+
#[cfg(feature = "mldsa")]
119+
impl MlDsaPublicKey {
120+
/// Verify a raw ML-DSA signature over the given message using "pure" ML-DSA
121+
/// with an empty context string.
122+
///
123+
/// # Errors
124+
/// Returns [`Error::Signature`] if the signature is invalid, or
125+
/// [`Error::PublicKey`] if the public key is malformed.
126+
pub(crate) fn verify_msg(&self, msg: &[u8], signature: &[u8]) -> Result<()> {
127+
match self.params {
128+
MlDsaParams::MlDsa44 => {
129+
verify_with_params::<ml_dsa::MlDsa44>(&self.key, msg, signature)
130+
}
131+
MlDsaParams::MlDsa65 => {
132+
verify_with_params::<ml_dsa::MlDsa65>(&self.key, msg, signature)
133+
}
134+
MlDsaParams::MlDsa87 => {
135+
verify_with_params::<ml_dsa::MlDsa87>(&self.key, msg, signature)
136+
}
137+
}
138+
}
139+
}

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)