From 4f0a9754cb194bf5cbc0c724cfa277aba525ef30 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 28 Aug 2026 17:22:47 +0900 Subject: [PATCH 1/3] feat: add mldsa44_ed25519 key support --- Cargo.lock | 77 +++++ ssh-key/Cargo.toml | 6 +- ssh-key/README.md | 3 +- ssh-key/src/algorithm.rs | 21 ++ ssh-key/src/lib.rs | 2 + ssh-key/src/mldsa_eddsa.rs | 266 ++++++++++++++++++ ssh-key/src/private.rs | 16 ++ ssh-key/src/private/keypair.rs | 47 ++++ ssh-key/src/private/mldsa_eddsa.rs | 244 ++++++++++++++++ ssh-key/src/public.rs | 12 + ssh-key/src/public/key_data.rs | 41 +++ ssh-key/src/public/mldsa_eddsa.rs | 108 +++++++ ssh-key/src/signature.rs | 115 +++++++- ssh-key/tests/dot_ssh.rs | 4 +- ssh-key/tests/examples/mldsa44_ed25519_1 | 58 ++++ .../tests/examples/mldsa44_ed25519_1-cert.pub | 1 + ssh-key/tests/examples/mldsa44_ed25519_1.pub | 1 + ssh-key/tests/examples/mldsa44_ed25519_1_pw | 59 ++++ ssh-key/tests/examples/mldsa44_ed25519_2 | 58 ++++ .../examples/mldsa44_ed25519_kat_ctx.bin | 1 + .../tests/examples/mldsa44_ed25519_kat_m.bin | 1 + .../tests/examples/mldsa44_ed25519_kat_pk.bin | Bin 0 -> 1344 bytes .../tests/examples/mldsa44_ed25519_kat_s.bin | Bin 0 -> 2484 bytes .../mldsa44_ed25519_kat_s_with_context.bin | Bin 0 -> 2484 bytes .../tests/examples/mldsa44_ed25519_kat_sk.bin | Bin 0 -> 64 bytes ssh-key/tests/mldsa44_ed25519.rs | 189 +++++++++++++ 26 files changed, 1322 insertions(+), 8 deletions(-) create mode 100644 ssh-key/src/mldsa_eddsa.rs create mode 100644 ssh-key/src/private/mldsa_eddsa.rs create mode 100644 ssh-key/src/public/mldsa_eddsa.rs create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_1 create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_1-cert.pub create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_1.pub create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_1_pw create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_2 create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_kat_ctx.bin create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_kat_m.bin create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_kat_pk.bin create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_kat_s.bin create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_kat_s_with_context.bin create mode 100644 ssh-key/tests/examples/mldsa44_ed25519_kat_sk.bin create mode 100644 ssh-key/tests/mldsa44_ed25519.rs diff --git a/Cargo.lock b/Cargo.lock index b266312f..79c2d571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -439,6 +439,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ + "ctutils", "subtle", "typenum", "zeroize", @@ -453,6 +454,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures", +] + [[package]] name = "libc" version = "0.2.186" @@ -465,6 +476,34 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "crypto-common", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8", + "shake", + "signature", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -552,6 +591,16 @@ dependencies = [ "ctutils", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "poly1305" version = "0.9.1" @@ -745,6 +794,17 @@ dependencies = [ "digest", ] +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + [[package]] name = "signature" version = "3.0.0" @@ -755,6 +815,22 @@ dependencies = [ "rand_core", ] +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "ssh-cipher" version = "0.3.0" @@ -810,6 +886,7 @@ dependencies = [ "hex", "hex-literal", "hmac", + "ml-dsa", "p256", "p384", "p521", diff --git a/ssh-key/Cargo.toml b/ssh-key/Cargo.toml index b35cf82b..2c9bc085 100644 --- a/ssh-key/Cargo.toml +++ b/ssh-key/Cargo.toml @@ -32,6 +32,7 @@ dsa = { version = "0.7", optional = true, default-features = false, features = [ ed25519-dalek = { version = "3", optional = true, default-features = false } hex = { version = "0.4", optional = true, default-features = false, features = ["alloc"] } hmac = { version = "0.13", optional = true } +ml-dsa = { version = "0.1", optional = true, default-features = false, features = ["alloc", "zeroize"] } p256 = { version = "0.14", optional = true, default-features = false, features = ["ecdsa"] } p384 = { version = "0.14", optional = true, default-features = false, features = ["ecdsa"] } p521 = { version = "0.14", optional = true, default-features = false, features = ["ecdsa"] } @@ -51,7 +52,7 @@ default = ["ecdsa", "rand_core", "std"] alloc = ["encoding/alloc", "signature/alloc", "zeroize/alloc", ] std = ["alloc"] -crypto = ["ed25519", "p256", "p384", "p521", "rsa"] # NOTE: `dsa` is obsolete/weak +crypto = ["ed25519", "mldsa-eddsa", "p256", "p384", "p521", "rsa"] # NOTE: `dsa` is obsolete/weak dsa = ["dep:dsa", "dep:sha1", "alloc", "encoding/bigint", "signature/rand_core"] ecdsa = ["dep:sec1"] ed25519 = ["dep:ed25519-dalek", "rand_core"] @@ -63,6 +64,9 @@ encryption = [ "rand_core" ] getrandom = ["cipher/getrandom", "rand_core"] +# MLDSA44-Ed25519-SHA512 composite signatures, as implemented by OpenSSH. Based on +# draft-miller-sshm-mldsa44-ed25519-composite-sigs, which is still a draft +mldsa-eddsa = ["dep:ml-dsa", "ed25519", "alloc", "rand_core"] p256 = ["dep:p256", "ecdsa"] p384 = ["dep:p384", "ecdsa"] p521 = ["dep:p521", "ecdsa"] diff --git a/ssh-key/README.md b/ssh-key/README.md index 821091f5..dd2f5a7d 100644 --- a/ssh-key/README.md +++ b/ssh-key/README.md @@ -42,7 +42,7 @@ respective SSH key algorithm. - [x] OpenSSH certificate authority (CA) support i.e. cert builder/signer - [x] Private key encryption/decryption (`bcrypt-pbkdf` + `aes256-ctr` only) - [x] Private key generation support: DSA, Ed25519, ECDSA (P-256/P-384/P-521), - and RSA + RSA, and MLDSA44-Ed25519 - [x] FIDO/U2F key support (`sk-*`) as specified in [PROTOCOL.u2f] - [x] Fingerprint support - [x] "randomart" fingerprint visualizations @@ -73,6 +73,7 @@ respective SSH key algorithm. | `ssh‑rsa` | ✅ | ✅ | ✅ | ✅️ | ✅️ | ✅ | `rsa` | `alloc` | | `sk‑ecdsa‑sha2‑nistp256@openssh.com` | ✅ | ✅ | ✅ | ⛔ | ⛔️ | ✅️ | ⛔ | `alloc` | | `sk‑ssh‑ed25519@openssh.com` | ✅ | ✅ | ✅ | ⛔ | ⛔️ | ✅️️ | `ed25519` | `alloc` | +| `ssh‑mldsa44‑ed25519@openssh.com` | ✅ | ✅ | ✅ | ✅️ | ✅️ | ✅️ | `mldsa-eddsa` | `alloc` | By default *no SSH signature algorithms are enabled* and you will get an `Error::AlgorithmUnsupported` error if you try to use them. diff --git a/ssh-key/src/algorithm.rs b/ssh-key/src/algorithm.rs index ed56c706..7243e0f4 100644 --- a/ssh-key/src/algorithm.rs +++ b/ssh-key/src/algorithm.rs @@ -47,6 +47,9 @@ const CERT_SK_ECDSA_SHA2_P256: &str = "sk-ecdsa-sha2-nistp256-cert-v01@openssh.c /// OpenSSH certificate for Ed25519 U2F/FIDO security key const CERT_SK_SSH_ED25519: &str = "sk-ssh-ed25519-cert-v01@openssh.com"; +/// OpenSSH certificate for MLDSA44-Ed25519 composite public key +const CERT_MLDSA44_ED25519: &str = "ssh-mldsa44-ed25519-cert-v01@openssh.com"; + /// ECDSA with SHA-256 + NIST P-256 const ECDSA_SHA2_P256: &str = "ecdsa-sha2-nistp256"; @@ -86,6 +89,9 @@ const SK_ECDSA_SHA2_P256: &str = "sk-ecdsa-sha2-nistp256@openssh.com"; /// U2F/FIDO security key with Ed25519 const SK_SSH_ED25519: &str = "sk-ssh-ed25519@openssh.com"; +/// MLDSA44-Ed25519-SHA512 composite signature algorithm +const SSH_MLDSA44_ED25519: &str = "ssh-mldsa44-ed25519@openssh.com"; + /// SSH key algorithms, i.e. digital signature algorithms used with SSH private/public keys. #[derive(Clone, Debug, Default, Eq, Hash, PartialEq, PartialOrd, Ord)] #[non_exhaustive] @@ -121,6 +127,9 @@ pub enum Algorithm { /// FIDO/U2F key with Ed25519 SkEd25519, + /// MLDSA44-Ed25519-SHA512 composite signature algorithm. + MlDsa44Ed25519, + /// Other #[cfg(feature = "alloc")] Other(AlgorithmName), @@ -138,6 +147,7 @@ impl Algorithm { /// - `ssh-rsa` /// - `sk-ecdsa-sha2-nistp256@openssh.com` (FIDO/U2F key) /// - `sk-ssh-ed25519@openssh.com` (FIDO/U2F key) + /// - `ssh-mldsa44-ed25519@openssh.com` /// /// Any other algorithms are mapped to the [`Algorithm::Other`] variant. /// @@ -161,6 +171,7 @@ impl Algorithm { /// - `ssh-ed25519-cert-v01@openssh.com` /// - `sk-ecdsa-sha2-nistp256-cert-v01@openssh.com` (FIDO/U2F key) /// - `sk-ssh-ed25519-cert-v01@openssh.com` (FIDO/U2F key) + /// - `ssh-mldsa44-ed25519-cert-v01@openssh.com` /// /// Any other algorithms are mapped to the [`Algorithm::Other`] variant. /// @@ -190,6 +201,7 @@ impl Algorithm { }), CERT_SK_ECDSA_SHA2_P256 => Ok(Algorithm::SkEcdsaSha2NistP256), CERT_SK_SSH_ED25519 => Ok(Algorithm::SkEd25519), + CERT_MLDSA44_ED25519 => Ok(Algorithm::MlDsa44Ed25519), #[cfg(feature = "alloc")] _ => Ok(Algorithm::Other(AlgorithmName::from_certificate_type(id)?)), #[cfg(not(feature = "alloc"))] @@ -215,6 +227,7 @@ impl Algorithm { }, Algorithm::SkEcdsaSha2NistP256 => SK_ECDSA_SHA2_P256, Algorithm::SkEd25519 => SK_SSH_ED25519, + Algorithm::MlDsa44Ed25519 => SSH_MLDSA44_ED25519, #[cfg(feature = "alloc")] Algorithm::Other(algorithm) => algorithm.as_str(), } @@ -247,6 +260,7 @@ impl Algorithm { } => CERT_RSA_SHA2_512, Algorithm::SkEcdsaSha2NistP256 => CERT_SK_ECDSA_SHA2_P256, Algorithm::SkEd25519 => CERT_SK_SSH_ED25519, + Algorithm::MlDsa44Ed25519 => CERT_MLDSA44_ED25519, Algorithm::Other(algorithm) => return algorithm.certificate_type(), } .to_owned() @@ -276,6 +290,12 @@ impl Algorithm { matches!(self, Algorithm::Rsa { .. }) } + /// Is the algorithm the MLDSA44-Ed25519 composite scheme? + #[must_use] + pub fn is_mldsa44_ed25519(self) -> bool { + matches!(self, Algorithm::MlDsa44Ed25519) + } + /// Return an error indicating this algorithm is unsupported. #[allow(dead_code)] pub(crate) fn unsupported_error(self) -> Error { @@ -322,6 +342,7 @@ impl str::FromStr for Algorithm { SSH_RSA => Ok(Algorithm::Rsa { hash: None }), SK_ECDSA_SHA2_P256 => Ok(Algorithm::SkEcdsaSha2NistP256), SK_SSH_ED25519 => Ok(Algorithm::SkEd25519), + SSH_MLDSA44_ED25519 => Ok(Algorithm::MlDsa44Ed25519), #[cfg(feature = "alloc")] _ => Ok(Algorithm::Other(AlgorithmName::from_str(id)?)), #[cfg(not(feature = "alloc"))] diff --git a/ssh-key/src/lib.rs b/ssh-key/src/lib.rs index 820e701b..7d14970b 100644 --- a/ssh-key/src/lib.rs +++ b/ssh-key/src/lib.rs @@ -139,6 +139,8 @@ mod kdf; mod comment; #[cfg(feature = "std")] mod dot_ssh; +#[cfg(feature = "mldsa-eddsa")] +mod mldsa_eddsa; #[cfg(feature = "ppk")] mod ppk; #[cfg(feature = "alloc")] diff --git a/ssh-key/src/mldsa_eddsa.rs b/ssh-key/src/mldsa_eddsa.rs new file mode 100644 index 00000000..c2857744 --- /dev/null +++ b/ssh-key/src/mldsa_eddsa.rs @@ -0,0 +1,266 @@ +//! MLDSA44-Ed25519-SHA512 composite signature primitive. +//! +//! This is the raw composite scheme from [draft-miller-sshm-composite-sigs-01]. It pairs ML-DSA-44 +//! ([FIPS204]) with Ed25519 ([RFC8032]). +//! +//! Keys and signatures are simple concatenations of their two components: +//! +//! ```text +//! public key (1344) = mldsa_pk (1312) || ed25519_pk (32) +//! private key (64) = mldsa_seed (32) || ed25519_seed (32) +//! signature (2484) = mldsa_sig (2420) || ed25519_sig (64) +//! ``` +//! +//! Both component signatures are computed over the same message +//! representative `M'`, which binds a domain-separation prefix, the algorithm +//! label, and the caller's context string to a SHA-512 digest of the message: +//! +//! ```text +//! M' = PREFIX || LABEL || u8(ctx.len()) || ctx || SHA512(msg) +//! ``` +//! +//! The ML-DSA component additionally passes `LABEL` as its own FIPS 204 +//! context string; the Ed25519 component signs `M'` directly. +//! +//! [draft-ietf-lamps-pq-composite-sigs]: https://datatracker.ietf.org/doc/draft-ietf-lamps-pq-composite-sigs/ +//! [draft-miller-sshm-composite-sigs-01]: https://www.ietf.org/archive/id/draft-miller-sshm-composite-sigs-01.html +//! [FIPS204]: https://csrc.nist.gov/pubs/fips/204/final +//! [RFC8032]: https://datatracker.ietf.org/doc/html/rfc8032 + +use crate::{Error, Result}; +use alloc::{vec, vec::Vec}; +use ml_dsa::{EncodedVerifyingKey, MlDsa44, VerifyingKey}; +use sha2::{Digest, Sha512}; +use zeroize::Zeroizing; + +/// Domain-separation prefix shared by all composite signature algorithms. +const PREFIX: &[u8] = b"CompositeAlgorithmSignatures2025"; + +/// Label identifying this specific composite algorithm. Also used as the +/// FIPS 204 context string for the ML-DSA component. +const LABEL: &[u8] = b"COMPSIG-MLDSA44-Ed25519-SHA512"; + +/// Size of a raw ML-DSA-44 public key. +pub(crate) const MLDSA_PUBLIC_KEY_SIZE: usize = 1312; + +/// Size of a raw ML-DSA-44 signature. +const MLDSA_SIGNATURE_SIZE: usize = 2420; + +/// Size of an ML-DSA or Ed25519 seed. Both are 32 bytes. +pub(crate) const SEED_SIZE: usize = 32; + +/// Size of a raw Ed25519 public key. +const ED25519_PUBLIC_KEY_SIZE: usize = 32; + +/// Size of a raw Ed25519 signature. +const ED25519_SIGNATURE_SIZE: usize = 64; + +/// Size of a composite public key: `mldsa_pk || ed25519_pk`. +pub(crate) const PUBLIC_KEY_SIZE: usize = MLDSA_PUBLIC_KEY_SIZE + ED25519_PUBLIC_KEY_SIZE; + +/// Size of a composite private key: `mldsa_seed || ed25519_seed`. +pub(crate) const PRIVATE_KEY_SIZE: usize = SEED_SIZE + SEED_SIZE; + +/// Size of a composite signature: `mldsa_sig || ed25519_sig`. +pub(crate) const SIGNATURE_SIZE: usize = MLDSA_SIGNATURE_SIZE + ED25519_SIGNATURE_SIZE; + +/// Maximum length of a context string, since its length is encoded as a `u8`. +const MAX_CTX_SIZE: usize = 255; + +/// Build the message representative `M'` () signed by both components. +/// See section 4.2.2 of `draft-miller-sshm-composite-sigs-01`. +/// +/// # Errors +/// Returns [`Error::Crypto`] if `ctx` is longer than 255 bytes. +fn construct_m_prime(msg: &[u8], ctx: &[u8]) -> Result> { + if ctx.len() > MAX_CTX_SIZE { + return Err(Error::Crypto); + } + + // Conversion cannot fail: bounds-checked against MAX_CTX_SIZE above. + let ctx_len = u8::try_from(ctx.len()).map_err(|_| Error::Crypto)?; + + let mut m_prime = Vec::with_capacity(PREFIX.len() + LABEL.len() + 1 + ctx.len() + 64); + m_prime.extend_from_slice(PREFIX); + m_prime.extend_from_slice(LABEL); + m_prime.push(ctx_len); + m_prime.extend_from_slice(ctx); + m_prime.extend_from_slice(&Sha512::digest(msg)); + + Ok(m_prime) +} + +/// Expand an ML-DSA-44 signing key from the ML-DSA half of a composite seed. +fn mldsa_signing_key(sk: &[u8; PRIVATE_KEY_SIZE]) -> ml_dsa::SigningKey { + let seed = Zeroizing::new(ml_dsa::B32::from( + <[u8; SEED_SIZE]>::try_from(&sk[..SEED_SIZE]).expect("seed half is 32 bytes"), + )); + + ml_dsa::SigningKey::::from_seed(&seed) +} + +/// Expand an Ed25519 signing key from the Ed25519 half of a composite seed. +fn ed25519_signing_key(sk: &[u8; PRIVATE_KEY_SIZE]) -> ed25519_dalek::SigningKey { + let seed = Zeroizing::new( + <[u8; SEED_SIZE]>::try_from(&sk[SEED_SIZE..]).expect("seed half is 32 bytes"), + ); + + ed25519_dalek::SigningKey::from_bytes(&seed) +} + +/// Derive the composite public key from a composite private key (two seeds). +pub(crate) fn derive_public(sk: &[u8; PRIVATE_KEY_SIZE]) -> [u8; PUBLIC_KEY_SIZE] { + use signature::Keypair; + + let mut pk = [0u8; PUBLIC_KEY_SIZE]; + let mldsa_pk = mldsa_signing_key(sk).verifying_key().encode(); + let ed25519_pk = ed25519_signing_key(sk).verifying_key().to_bytes(); + + pk[..MLDSA_PUBLIC_KEY_SIZE].copy_from_slice(mldsa_pk.as_slice()); + pk[MLDSA_PUBLIC_KEY_SIZE..].copy_from_slice(&ed25519_pk); + pk +} + +/// Produce a composite signature over `msg` with the given context string. +/// +/// # Errors +/// - Returns [`Error::Crypto`] if `ctx` is longer than 255 bytes or if a +/// component signature cannot be produced. +pub(crate) fn raw_sign(sk: &[u8; PRIVATE_KEY_SIZE], msg: &[u8], ctx: &[u8]) -> Result> { + use signature::Signer; + + let m_prime = construct_m_prime(msg, ctx)?; + + let mldsa_sig = mldsa_signing_key(sk) + .expanded_key() + .sign_deterministic(&m_prime, LABEL) + .map_err(|_| Error::Crypto)?; + let ed25519_sig = ed25519_signing_key(sk).try_sign(&m_prime)?; + + let mut sig = vec![0u8; SIGNATURE_SIZE]; + sig[..MLDSA_SIGNATURE_SIZE].copy_from_slice(mldsa_sig.encode().as_slice()); + sig[MLDSA_SIGNATURE_SIZE..].copy_from_slice(&ed25519_sig.to_bytes()); + Ok(sig) +} + +/// Verify a composite signature over `msg` with the given context string. +/// +/// Both component signatures must verify; a failure in either is fatal. +/// +/// # Errors +/// Returns [`Error::Signature`] if the signature is malformed or invalid, or +/// [`Error::Crypto`] if `ctx` is longer than 255 bytes. +pub(crate) fn raw_verify( + pk: &[u8; PUBLIC_KEY_SIZE], + sig: &[u8], + msg: &[u8], + ctx: &[u8], +) -> Result<()> { + use signature::Verifier; + + if sig.len() != SIGNATURE_SIZE { + return Err(Error::Signature); + } + + let m_prime = construct_m_prime(msg, ctx)?; + let (mldsa_sig, ed25519_sig) = sig.split_at(MLDSA_SIGNATURE_SIZE); + let (mldsa_pk, ed25519_pk) = pk.split_at(MLDSA_PUBLIC_KEY_SIZE); + + // ML-DSA component. Note the FIPS 204 context string is LABEL, *not* `ctx`; + // `ctx` is already bound into `m_prime`. + let mldsa_pk = EncodedVerifyingKey::::try_from(mldsa_pk).map_err(|_| Error::Crypto)?; + let mldsa_sig = + ml_dsa::Signature::::try_from(mldsa_sig).map_err(|_| Error::Signature)?; + + if !VerifyingKey::::decode(&mldsa_pk).verify_with_context(&m_prime, LABEL, &mldsa_sig) + { + return Err(Error::Signature); + } + + // Ed25519 component. + let ed25519_pk = + ed25519_dalek::VerifyingKey::from_bytes(ed25519_pk.try_into().map_err(|_| Error::Crypto)?) + .map_err(|_| Error::Crypto)?; + let ed25519_sig = + ed25519_dalek::Signature::from_slice(ed25519_sig).map_err(|_| Error::Signature)?; + + ed25519_pk + .verify(&m_prime, &ed25519_sig) + .map_err(|_| Error::Signature) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Known-answer vectors from `draft-ietf-lamps-pq-composite-sigs`, as + /// shipped in OpenSSH's `regress/unittests/crypto/testdata`. + const KAT_M: &[u8] = include_bytes!("../tests/examples/mldsa44_ed25519_kat_m.bin"); + const KAT_CTX: &[u8] = include_bytes!("../tests/examples/mldsa44_ed25519_kat_ctx.bin"); + const KAT_PK: &[u8] = include_bytes!("../tests/examples/mldsa44_ed25519_kat_pk.bin"); + const KAT_SK: &[u8] = include_bytes!("../tests/examples/mldsa44_ed25519_kat_sk.bin"); + const KAT_S: &[u8] = include_bytes!("../tests/examples/mldsa44_ed25519_kat_s.bin"); + const KAT_S_WITH_CONTEXT: &[u8] = + include_bytes!("../tests/examples/mldsa44_ed25519_kat_s_with_context.bin"); + + fn kat_pk() -> [u8; PUBLIC_KEY_SIZE] { + KAT_PK.try_into().expect("KAT public key is 1344 bytes") + } + + fn kat_sk() -> [u8; PRIVATE_KEY_SIZE] { + KAT_SK.try_into().expect("KAT private key is 64 bytes") + } + + #[test] + fn kat_sizes() { + assert_eq!(KAT_PK.len(), PUBLIC_KEY_SIZE); + assert_eq!(KAT_SK.len(), PRIVATE_KEY_SIZE); + assert_eq!(KAT_S.len(), SIGNATURE_SIZE); + assert_eq!(KAT_S_WITH_CONTEXT.len(), SIGNATURE_SIZE); + } + + /// Expanding the two seeds must reproduce the published public key exactly. + /// This pins the concatenation order and the seed split. + #[test] + fn kat_key_expansion() { + assert_eq!(derive_public(&kat_sk()), kat_pk()); + } + + #[test] + fn kat_verify_without_context() { + raw_verify(&kat_pk(), KAT_S, KAT_M, &[]).expect("KAT signature should verify"); + } + + #[test] + fn kat_verify_with_context() { + raw_verify(&kat_pk(), KAT_S_WITH_CONTEXT, KAT_M, KAT_CTX) + .expect("KAT signature should verify"); + } + + #[test] + fn kat_context_is_bound() { + assert!(raw_verify(&kat_pk(), KAT_S, KAT_M, KAT_CTX).is_err()); + assert!(raw_verify(&kat_pk(), KAT_S_WITH_CONTEXT, KAT_M, &[]).is_err()); + } + + #[test] + fn round_trip() { + for ctx in [&[][..], KAT_CTX] { + let sig = raw_sign(&kat_sk(), KAT_M, ctx).expect("signing should succeed"); + raw_verify(&kat_pk(), &sig, KAT_M, ctx).expect("own signature should verify"); + } + } + + #[test] + fn tampered_message_is_rejected() { + let mut msg = KAT_M.to_vec(); + msg[0] ^= 1; + assert!(raw_verify(&kat_pk(), KAT_S, &msg, &[]).is_err()); + } + + #[test] + fn wrong_signature_length_is_rejected() { + assert!(raw_verify(&kat_pk(), &KAT_S[..SIGNATURE_SIZE - 1], KAT_M, &[]).is_err()); + assert!(raw_verify(&kat_pk(), &[0u8; 0], KAT_M, &[]).is_err()); + } +} diff --git a/ssh-key/src/private.rs b/ssh-key/src/private.rs index a7bf4a2a..b5749dcb 100644 --- a/ssh-key/src/private.rs +++ b/ssh-key/src/private.rs @@ -99,6 +99,8 @@ mod dsa; mod ecdsa; mod ed25519; mod keypair; +#[cfg(feature = "mldsa-eddsa")] +mod mldsa_eddsa; #[cfg(feature = "alloc")] mod opaque; #[cfg(feature = "alloc")] @@ -123,6 +125,9 @@ pub use crate::{ sha2::Digest, }; +#[cfg(feature = "mldsa-eddsa")] +pub use crate::private::mldsa_eddsa::{MlDsa44Ed25519Keypair, MlDsa44Ed25519PrivateKey}; + #[cfg(feature = "ecdsa")] pub use self::ecdsa::{EcdsaKeypair, EcdsaPrivateKey}; @@ -622,6 +627,8 @@ impl PrivateKey { Algorithm::Rsa { .. } => { KeypairData::from(RsaKeypair::random(rng, DEFAULT_RSA_KEY_SIZE)?) } + #[cfg(feature = "mldsa-eddsa")] + Algorithm::MlDsa44Ed25519 => KeypairData::from(MlDsa44Ed25519Keypair::random(rng)?), _ => return Err(Error::AlgorithmUnknown), }; let public_key = public::KeyData::try_from(&key_data)?; @@ -1002,6 +1009,15 @@ impl From for PrivateKey { } } +#[cfg(feature = "mldsa-eddsa")] +impl From for PrivateKey { + fn from(keypair: MlDsa44Ed25519Keypair) -> PrivateKey { + KeypairData::from(keypair) + .try_into() + .expect(CONVERSION_ERROR_MSG) + } +} + #[cfg(all(feature = "alloc", feature = "ecdsa"))] impl From for PrivateKey { fn from(keypair: SkEcdsaSha2NistP256) -> PrivateKey { diff --git a/ssh-key/src/private/keypair.rs b/ssh-key/src/private/keypair.rs index f187bbaf..80de8875 100644 --- a/ssh-key/src/private/keypair.rs +++ b/ssh-key/src/private/keypair.rs @@ -11,6 +11,9 @@ use { alloc::vec::Vec, }; +#[cfg(feature = "mldsa-eddsa")] +use super::MlDsa44Ed25519Keypair; + #[cfg(feature = "ecdsa")] use super::EcdsaKeypair; @@ -44,6 +47,10 @@ pub enum KeypairData { #[cfg(feature = "alloc")] Rsa(RsaKeypair), + /// ML-DSA keypair + #[cfg(feature = "mldsa-eddsa")] + MlDsa44Ed25519(MlDsa44Ed25519Keypair), + /// Security Key (FIDO/U2F) using ECDSA/NIST P-256 as specified in [PROTOCOL.u2f]. /// /// [PROTOCOL.u2f]: https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.u2f?annotate=HEAD @@ -77,6 +84,8 @@ impl KeypairData { Self::Encrypted(_) => return Err(Error::Encrypted), #[cfg(feature = "alloc")] Self::Rsa(_) => Algorithm::Rsa { hash: None }, + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(key) => key.algorithm(), #[cfg(all(feature = "alloc", feature = "ecdsa"))] Self::SkEcdsaSha2NistP256(_) => Algorithm::SkEcdsaSha2NistP256, #[cfg(feature = "alloc")] @@ -136,6 +145,16 @@ impl KeypairData { } } + /// Get MLDSA44-Ed25519 keypair if this key is the correct type. + #[cfg(feature = "mldsa-eddsa")] + #[must_use] + pub fn mldsa44_ed25519(&self) -> Option<&MlDsa44Ed25519Keypair> { + match self { + Self::MlDsa44Ed25519(key) => Some(key), + _ => None, + } + } + /// Get FIDO/U2F ECDSA/NIST P-256 private key if this key is the correct type. #[cfg(all(feature = "alloc", feature = "ecdsa"))] #[must_use] @@ -207,6 +226,13 @@ impl KeypairData { matches!(self, Self::Rsa(_)) } + /// Is this key an ML-DSA key? + #[cfg(feature = "mldsa-eddsa")] + #[must_use] + pub fn is_mldsa44_ed25519(&self) -> bool { + matches!(self, Self::MlDsa44Ed25519(_)) + } + /// Is this key a FIDO/U2F ECDSA/NIST P-256 key? #[cfg(all(feature = "alloc", feature = "ecdsa"))] #[must_use] @@ -243,6 +269,8 @@ impl KeypairData { Self::Encrypted(ciphertext) => ciphertext.as_ref(), #[cfg(feature = "alloc")] Self::Rsa(rsa) => rsa.private().d().as_bytes(), + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(mldsa) => mldsa.private.as_ref(), #[cfg(all(feature = "alloc", feature = "ecdsa"))] Self::SkEcdsaSha2NistP256(sk) => sk.key_handle(), #[cfg(feature = "alloc")] @@ -278,6 +306,10 @@ impl KeypairData { Algorithm::Ed25519 => Ed25519Keypair::decode(reader).map(Self::Ed25519), #[cfg(feature = "alloc")] Algorithm::Rsa { .. } => RsaKeypair::decode(reader).map(Self::Rsa), + #[cfg(feature = "mldsa-eddsa")] + Algorithm::MlDsa44Ed25519 => { + MlDsa44Ed25519Keypair::decode(reader).map(Self::MlDsa44Ed25519) + } #[cfg(all(feature = "alloc", feature = "ecdsa"))] Algorithm::SkEcdsaSha2NistP256 => { SkEcdsaSha2NistP256::decode(reader).map(Self::SkEcdsaSha2NistP256) @@ -307,6 +339,8 @@ impl CtEq for KeypairData { (Self::Encrypted(a), Self::Encrypted(b)) => a.ct_eq(b), #[cfg(feature = "alloc")] (Self::Rsa(a), Self::Rsa(b)) => a.ct_eq(b), + #[cfg(feature = "mldsa-eddsa")] + (Self::MlDsa44Ed25519(a), Self::MlDsa44Ed25519(b)) => a.ct_eq(b), #[cfg(all(feature = "alloc", feature = "ecdsa"))] (Self::SkEcdsaSha2NistP256(a), Self::SkEcdsaSha2NistP256(b)) => { // Security Keys store the actual private key in hardware. @@ -363,6 +397,8 @@ impl Encode for KeypairData { Self::Encrypted(ciphertext) => return Ok(ciphertext.len()), #[cfg(feature = "alloc")] Self::Rsa(key) => key.encoded_len()?, + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(key) => key.encoded_len()?, #[cfg(all(feature = "alloc", feature = "ecdsa"))] Self::SkEcdsaSha2NistP256(sk) => sk.encoded_len()?, #[cfg(feature = "alloc")] @@ -389,6 +425,8 @@ impl Encode for KeypairData { Self::Encrypted(ciphertext) => writer.write(ciphertext)?, #[cfg(feature = "alloc")] Self::Rsa(key) => key.encode(writer)?, + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(key) => key.encode(writer)?, #[cfg(all(feature = "alloc", feature = "ecdsa"))] Self::SkEcdsaSha2NistP256(sk) => sk.encode(writer)?, #[cfg(feature = "alloc")] @@ -415,6 +453,8 @@ impl TryFrom<&KeypairData> for public::KeyData { KeypairData::Encrypted(_) => return Err(Error::Encrypted), #[cfg(feature = "alloc")] KeypairData::Rsa(rsa) => public::KeyData::Rsa(rsa.into()), + #[cfg(feature = "mldsa-eddsa")] + KeypairData::MlDsa44Ed25519(mldsa) => public::KeyData::MlDsa44Ed25519(mldsa.into()), #[cfg(all(feature = "alloc", feature = "ecdsa"))] KeypairData::SkEcdsaSha2NistP256(sk) => { public::KeyData::SkEcdsaSha2NistP256(sk.public().clone()) @@ -454,6 +494,13 @@ impl From for KeypairData { } } +#[cfg(feature = "mldsa-eddsa")] +impl From for KeypairData { + fn from(keypair: MlDsa44Ed25519Keypair) -> KeypairData { + Self::MlDsa44Ed25519(keypair) + } +} + #[cfg(all(feature = "alloc", feature = "ecdsa"))] impl From for KeypairData { fn from(keypair: SkEcdsaSha2NistP256) -> KeypairData { diff --git a/ssh-key/src/private/mldsa_eddsa.rs b/ssh-key/src/private/mldsa_eddsa.rs new file mode 100644 index 00000000..89dd15b6 --- /dev/null +++ b/ssh-key/src/private/mldsa_eddsa.rs @@ -0,0 +1,244 @@ +//! MLDSA44-Ed25519 composite private keys. + +use crate::{ + Algorithm, Error, Result, + mldsa_eddsa::{self, PRIVATE_KEY_SIZE, SEED_SIZE}, + public::MlDsa44Ed25519PublicKey, +}; +use alloc::vec::Vec; +use core::fmt; +use ctutils::{Choice, CtEq}; +use encoding::{CheckedSum, Decode, Encode, Reader, Writer}; +use zeroize::{Zeroize, Zeroizing}; + +#[cfg(feature = "rand_core")] +use rand_core::CryptoRng; + +/// MLDSA44-Ed25519 composite private key. +/// +/// This is the seed representation: `mldsa_seed (32) || ed25519_seed (32)`. +/// Both component keys are re-expanded from these seeds on demand. +#[derive(Clone)] +pub struct MlDsa44Ed25519PrivateKey([u8; PRIVATE_KEY_SIZE]); + +impl MlDsa44Ed25519PrivateKey { + /// Size of a composite private key in bytes. + pub const BYTE_SIZE: usize = PRIVATE_KEY_SIZE; + + /// Generate a random composite private key. + #[cfg(feature = "rand_core")] + pub fn random(rng: &mut R) -> Self { + let mut seeds = [0u8; PRIVATE_KEY_SIZE]; + rng.fill_bytes(&mut seeds); + Self(seeds) + } + + /// Parse a composite private key from bytes. + #[must_use] + pub fn from_bytes(bytes: &[u8; PRIVATE_KEY_SIZE]) -> Self { + Self(*bytes) + } + + /// Convert to the inner seed byte array. + #[must_use] + pub fn to_bytes(&self) -> [u8; PRIVATE_KEY_SIZE] { + self.0 + } + + /// Get the ML-DSA-44 seed component. + #[must_use] + pub fn mldsa_seed(&self) -> &[u8] { + &self.0[..SEED_SIZE] + } + + /// Get the Ed25519 seed component. + #[must_use] + pub fn ed25519_seed(&self) -> &[u8] { + &self.0[SEED_SIZE..] + } + + /// Re-expand both seeds into the matching composite public key. + /// + /// # Errors + /// Returns [`Error::Encoding`] if key derivation produces a malformed key, + /// which should not occur. + pub fn public_key(&self) -> Result { + MlDsa44Ed25519PublicKey::new(mldsa_eddsa::derive_public(&self.0)) + } +} + +impl AsRef<[u8; PRIVATE_KEY_SIZE]> for MlDsa44Ed25519PrivateKey { + fn as_ref(&self) -> &[u8; PRIVATE_KEY_SIZE] { + &self.0 + } +} + +impl CtEq for MlDsa44Ed25519PrivateKey { + fn ct_eq(&self, other: &Self) -> Choice { + self.as_ref().ct_eq(other.as_ref()) + } +} + +impl Eq for MlDsa44Ed25519PrivateKey {} + +impl PartialEq for MlDsa44Ed25519PrivateKey { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +impl TryFrom<&[u8]> for MlDsa44Ed25519PrivateKey { + type Error = Error; + + fn try_from(bytes: &[u8]) -> Result { + Ok(Self::from_bytes(bytes.try_into()?)) + } +} + +impl fmt::Debug for MlDsa44Ed25519PrivateKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MlDsa44Ed25519PrivateKey") + .finish_non_exhaustive() + } +} + +impl Drop for MlDsa44Ed25519PrivateKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +/// MLDSA44-Ed25519 composite private/public keypair. +/// +/// The SSH encoding of the keypair is the [`MlDsa44Ed25519PublicKey`] followed +/// by the 64-byte seed pair encoded as an SSH `string`. +#[derive(Clone)] +pub struct MlDsa44Ed25519Keypair { + /// Public key. + pub public: MlDsa44Ed25519PublicKey, + + /// Private key (seed pair). + pub private: MlDsa44Ed25519PrivateKey, +} + +impl MlDsa44Ed25519Keypair { + /// Generate a random composite keypair. + /// + /// # Errors + /// Returns [`Error::Encoding`] if key derivation produces a malformed key, + /// which should not occur. + #[cfg(feature = "rand_core")] + pub fn random(rng: &mut R) -> Result { + Self::from_seed(&MlDsa44Ed25519PrivateKey::random(rng).to_bytes()) + } + + /// Derive a composite keypair from a 64-byte seed pair. + /// + /// # Errors + /// Returns [`Error::Encoding`] if key derivation produces a malformed key, + /// which should not occur. + pub fn from_seed(seed: &[u8; PRIVATE_KEY_SIZE]) -> Result { + let private = MlDsa44Ed25519PrivateKey::from_bytes(seed); + + Ok(Self { + public: private.public_key()?, + private, + }) + } + + /// Get the [`Algorithm`] for this keypair. + #[must_use] + pub fn algorithm(&self) -> Algorithm { + Algorithm::MlDsa44Ed25519 + } + + /// Sign a message, producing the raw composite signature bytes. + pub(crate) fn sign_msg(&self, msg: &[u8]) -> Result> { + mldsa_eddsa::raw_sign(self.private.as_ref(), msg, &[]) + } + + /// Verify that the stored public key matches the key derived from the seeds. + /// + /// A mismatch means the private key cannot produce signatures verifiable + /// under the advertised public key, so the keypair is rejected outright. + fn validate(&self) -> Result<()> { + if self + .private + .public_key()? + .as_bytes() + .ct_eq(self.public.as_bytes()) + .into() + { + Ok(()) + } else { + Err(Error::PublicKey) + } + } +} + +impl CtEq for MlDsa44Ed25519Keypair { + fn ct_eq(&self, other: &Self) -> Choice { + Choice::from(u8::from(self.public == other.public)) & self.private.ct_eq(&other.private) + } +} + +impl Eq for MlDsa44Ed25519Keypair {} + +impl PartialEq for MlDsa44Ed25519Keypair { + fn eq(&self, other: &Self) -> bool { + self.ct_eq(other).into() + } +} + +impl Decode for MlDsa44Ed25519Keypair { + type Error = Error; + + fn decode(reader: &mut impl Reader) -> Result { + let public = MlDsa44Ed25519PublicKey::decode(reader)?; + + let mut seed = Zeroizing::new([0u8; PRIVATE_KEY_SIZE]); + reader.read_prefixed(|reader| reader.read(&mut *seed))?; + + let keypair = Self { + public, + private: MlDsa44Ed25519PrivateKey::from_bytes(&seed), + }; + + keypair.validate()?; + Ok(keypair) + } +} + +impl Encode for MlDsa44Ed25519Keypair { + fn encoded_len(&self) -> encoding::Result { + [self.public.encoded_len()?, 4, PRIVATE_KEY_SIZE].checked_sum() + } + + fn encode(&self, writer: &mut impl Writer) -> encoding::Result<()> { + self.public.encode(writer)?; + Zeroizing::new(self.private.to_bytes()) + .as_slice() + .encode(writer)?; + Ok(()) + } +} + +impl fmt::Debug for MlDsa44Ed25519Keypair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MlDsa44Ed25519Keypair") + .field("public", &self.public) + .finish_non_exhaustive() + } +} + +impl From for MlDsa44Ed25519PublicKey { + fn from(keypair: MlDsa44Ed25519Keypair) -> MlDsa44Ed25519PublicKey { + keypair.public + } +} + +impl From<&MlDsa44Ed25519Keypair> for MlDsa44Ed25519PublicKey { + fn from(keypair: &MlDsa44Ed25519Keypair) -> MlDsa44Ed25519PublicKey { + keypair.public.clone() + } +} diff --git a/ssh-key/src/public.rs b/ssh-key/src/public.rs index 9dd0b106..5fd6e2e9 100644 --- a/ssh-key/src/public.rs +++ b/ssh-key/src/public.rs @@ -8,6 +8,8 @@ mod dsa; mod ecdsa; mod ed25519; mod key_data; +#[cfg(feature = "mldsa-eddsa")] +mod mldsa_eddsa; #[cfg(feature = "alloc")] mod opaque; #[cfg(feature = "alloc")] @@ -27,6 +29,9 @@ pub use self::{ #[cfg(feature = "ecdsa")] pub use self::{ecdsa::EcdsaPublicKey, sk::SkEcdsaSha2NistP256}; +#[cfg(feature = "mldsa-eddsa")] +pub use self::mldsa_eddsa::MlDsa44Ed25519PublicKey; + pub(crate) use self::ssh_format::SshFormat; use crate::{Algorithm, Error, Fingerprint, HashAlg, Result}; @@ -448,6 +453,13 @@ impl From for PublicKey { } } +#[cfg(feature = "mldsa-eddsa")] +impl From for PublicKey { + fn from(public_key: MlDsa44Ed25519PublicKey) -> PublicKey { + KeyData::from(public_key).into() + } +} + #[cfg(feature = "alloc")] impl From for PublicKey { fn from(public_key: RsaPublicKey) -> PublicKey { diff --git a/ssh-key/src/public/key_data.rs b/ssh-key/src/public/key_data.rs index 5924ca98..4d74e9fe 100644 --- a/ssh-key/src/public/key_data.rs +++ b/ssh-key/src/public/key_data.rs @@ -14,6 +14,9 @@ use { #[cfg(feature = "ecdsa")] use super::{EcdsaPublicKey, SkEcdsaSha2NistP256}; +#[cfg(feature = "mldsa-eddsa")] +use super::MlDsa44Ed25519PublicKey; + /// Public key data. #[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] #[non_exhaustive] @@ -33,6 +36,10 @@ pub enum KeyData { #[cfg(feature = "alloc")] Rsa(RsaPublicKey), + /// ML-DSA public key data. + #[cfg(feature = "mldsa-eddsa")] + MlDsa44Ed25519(MlDsa44Ed25519PublicKey), + /// Security Key (FIDO/U2F) using ECDSA/NIST P-256 as specified in [PROTOCOL.u2f]. /// /// [PROTOCOL.u2f]: https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.u2f?annotate=HEAD @@ -69,6 +76,8 @@ impl KeyData { Self::Ed25519(_) => Algorithm::Ed25519, #[cfg(feature = "alloc")] Self::Rsa(_) => Algorithm::Rsa { hash: None }, + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(key) => key.algorithm(), #[cfg(feature = "ecdsa")] Self::SkEcdsaSha2NistP256(_) => Algorithm::SkEcdsaSha2NistP256, Self::SkEd25519(_) => Algorithm::SkEd25519, @@ -127,6 +136,16 @@ impl KeyData { } } + /// Get MLDSA44-Ed25519 public key if this key is the correct type. + #[cfg(feature = "mldsa-eddsa")] + #[must_use] + pub fn mldsa44_ed25519(&self) -> Option<&MlDsa44Ed25519PublicKey> { + match self { + Self::MlDsa44Ed25519(key) => Some(key), + _ => None, + } + } + /// Get FIDO/U2F ECDSA/NIST P-256 public key if this key is the correct type. #[cfg(feature = "ecdsa")] #[must_use] @@ -203,6 +222,13 @@ impl KeyData { matches!(self, Self::Rsa(_)) } + /// Is this key an ML-DSA key? + #[cfg(feature = "mldsa-eddsa")] + #[must_use] + pub fn is_mldsa44_ed25519(&self) -> bool { + matches!(self, Self::MlDsa44Ed25519(_)) + } + /// Is this key a FIDO/U2F ECDSA/NIST P-256 key? #[cfg(feature = "ecdsa")] #[must_use] @@ -248,6 +274,10 @@ impl KeyData { Algorithm::Ed25519 => Ed25519PublicKey::decode(reader).map(Self::Ed25519), #[cfg(feature = "alloc")] Algorithm::Rsa { .. } => RsaPublicKey::decode(reader).map(Self::Rsa), + #[cfg(feature = "mldsa-eddsa")] + Algorithm::MlDsa44Ed25519 => { + MlDsa44Ed25519PublicKey::decode(reader).map(Self::MlDsa44Ed25519) + } #[cfg(feature = "ecdsa")] Algorithm::SkEcdsaSha2NistP256 => { SkEcdsaSha2NistP256::decode(reader).map(Self::SkEcdsaSha2NistP256) @@ -280,6 +310,8 @@ impl KeyData { Self::Ed25519(key) => key.encoded_len(), #[cfg(feature = "alloc")] Self::Rsa(key) => key.encoded_len(), + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(key) => key.encoded_len(), #[cfg(feature = "ecdsa")] Self::SkEcdsaSha2NistP256(sk) => sk.encoded_len(), Self::SkEd25519(sk) => sk.encoded_len(), @@ -300,6 +332,8 @@ impl KeyData { Self::Ed25519(key) => key.encode(writer), #[cfg(feature = "alloc")] Self::Rsa(key) => key.encode(writer), + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(key) => key.encode(writer), #[cfg(feature = "ecdsa")] Self::SkEcdsaSha2NistP256(sk) => sk.encode(writer), Self::SkEd25519(sk) => sk.encode(writer), @@ -380,6 +414,13 @@ impl From for KeyData { } } +#[cfg(feature = "mldsa-eddsa")] +impl From for KeyData { + fn from(public_key: MlDsa44Ed25519PublicKey) -> KeyData { + Self::MlDsa44Ed25519(public_key) + } +} + #[cfg(feature = "ecdsa")] impl From for KeyData { fn from(public_key: SkEcdsaSha2NistP256) -> KeyData { diff --git a/ssh-key/src/public/mldsa_eddsa.rs b/ssh-key/src/public/mldsa_eddsa.rs new file mode 100644 index 00000000..4209a8c7 --- /dev/null +++ b/ssh-key/src/public/mldsa_eddsa.rs @@ -0,0 +1,108 @@ +//! MLDSA44-Ed25519 composite public keys. + +use crate::{ + Algorithm, Error, Result, + mldsa_eddsa::{self, MLDSA_PUBLIC_KEY_SIZE, PUBLIC_KEY_SIZE}, +}; +use alloc::boxed::Box; +use core::fmt; +use encoding::{Decode, Encode, Reader, Writer}; + +/// MLDSA44-Ed25519 composite public key. +#[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct MlDsa44Ed25519PublicKey(Box<[u8; PUBLIC_KEY_SIZE]>); + +impl MlDsa44Ed25519PublicKey { + /// Size of a composite public key in bytes. + pub const BYTE_SIZE: usize = PUBLIC_KEY_SIZE; + + /// Create a composite public key from raw bytes. + /// + /// # Errors + /// Returns [`Error::Encoding`] with [`encoding::Error::Length`] if `key` is + /// not exactly [`Self::BYTE_SIZE`] bytes. + pub fn new(key: impl AsRef<[u8]>) -> Result { + let key: &[u8; PUBLIC_KEY_SIZE] = key + .as_ref() + .try_into() + .map_err(|_| encoding::Error::Length)?; + + Ok(Self(Box::new(*key))) + } + + /// Get the [`Algorithm`] for this public key. + #[must_use] + pub fn algorithm(&self) -> Algorithm { + Algorithm::MlDsa44Ed25519 + } + + /// Get the composite public key bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8; PUBLIC_KEY_SIZE] { + &self.0 + } + + /// Get the raw ML-DSA-44 component of the public key. + #[must_use] + pub fn mldsa_bytes(&self) -> &[u8] { + &self.0[..MLDSA_PUBLIC_KEY_SIZE] + } + + /// Get the raw Ed25519 component of the public key. + #[must_use] + pub fn ed25519_bytes(&self) -> &[u8] { + &self.0[MLDSA_PUBLIC_KEY_SIZE..] + } + + /// Verify a raw composite signature over `msg`, using an empty context + /// string as SSH requires. + /// + /// # Errors + /// Returns [`Error::Signature`] if the signature is malformed or invalid. + pub(crate) fn verify_msg(&self, msg: &[u8], signature: &[u8]) -> Result<()> { + mldsa_eddsa::raw_verify(&self.0, signature, msg, &[]) + } +} + +impl AsRef<[u8]> for MlDsa44Ed25519PublicKey { + fn as_ref(&self) -> &[u8] { + self.as_bytes() + } +} + +impl TryFrom<&[u8]> for MlDsa44Ed25519PublicKey { + type Error = Error; + + fn try_from(bytes: &[u8]) -> Result { + Self::new(bytes) + } +} + +impl Decode for MlDsa44Ed25519PublicKey { + type Error = Error; + + fn decode(reader: &mut impl Reader) -> Result { + let mut key = Box::new([0u8; PUBLIC_KEY_SIZE]); + reader.read_prefixed(|reader| reader.read(key.as_mut_slice()))?; + Ok(Self(key)) + } +} + +impl Encode for MlDsa44Ed25519PublicKey { + fn encoded_len(&self) -> encoding::Result { + self.as_bytes().as_slice().encoded_len() + } + + fn encode(&self, writer: &mut impl Writer) -> encoding::Result<()> { + self.as_bytes().as_slice().encode(writer) + } +} + +impl fmt::Display for MlDsa44Ed25519PublicKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.as_bytes().iter() { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} diff --git a/ssh-key/src/signature.rs b/ssh-key/src/signature.rs index db132c31..57de6b12 100644 --- a/ssh-key/src/signature.rs +++ b/ssh-key/src/signature.rs @@ -9,6 +9,9 @@ use signature::{SignatureEncoding, Signer, Verifier}; #[cfg(feature = "ed25519")] use crate::{private::Ed25519Keypair, public::Ed25519PublicKey}; +#[cfg(feature = "mldsa-eddsa")] +use crate::{private::MlDsa44Ed25519Keypair, public::MlDsa44Ed25519PublicKey}; + #[cfg(feature = "dsa")] use { crate::{private::DsaKeypair, public::DsaPublicKey}, @@ -42,6 +45,8 @@ const DSA_SIGNATURE_SIZE: usize = DSA_COMPONENT_SIZE * 2; const ED25519_SIGNATURE_SIZE: usize = 64; const SK_SIGNATURE_TRAILER_SIZE: usize = 5; // flags(u8), counter(u32) const SK_ED25519_SIGNATURE_SIZE: usize = ED25519_SIGNATURE_SIZE + SK_SIGNATURE_TRAILER_SIZE; +const MSDSA44_SIG_SIZE: usize = 2420; +const MLDSA44_ED25519_SIG_SIZE: usize = MSDSA44_SIG_SIZE + ED25519_SIGNATURE_SIZE; /// Trait for signing keys which produce a [`Signature`]. /// @@ -112,6 +117,7 @@ impl Signature { Algorithm::SkEd25519 if data.len() == SK_ED25519_SIGNATURE_SIZE => (), Algorithm::SkEcdsaSha2NistP256 => ecdsa_sig_size(&data, EcdsaCurve::NistP256, true)?, Algorithm::Rsa { .. } => (), + Algorithm::MlDsa44Ed25519 if data.len() == MLDSA44_ED25519_SIG_SIZE => (), Algorithm::Other(_) if !data.is_empty() => (), _ => return Err(encoding::Error::Length.into()), } @@ -293,6 +299,8 @@ impl Signer for private::KeypairData { Self::Ed25519(keypair) => keypair.try_sign(message), #[cfg(feature = "rsa")] Self::Rsa(keypair) => keypair.try_sign(message), + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(keypair) => keypair.try_sign(message), _ => Err(self.algorithm()?.unsupported_error().into()), } } @@ -320,6 +328,8 @@ impl Verifier for public::KeyData { Self::SkEcdsaSha2NistP256(pk) => pk.verify(message, signature), #[cfg(feature = "rsa")] Self::Rsa(pk) => pk.verify(message, signature), + #[cfg(feature = "mldsa-eddsa")] + Self::MlDsa44Ed25519(pk) => pk.verify(message, signature), #[allow(unreachable_patterns)] _ => Err(self.algorithm().unsupported_error().into()), } @@ -452,6 +462,30 @@ impl Verifier for Ed25519PublicKey { } } +#[cfg(feature = "mldsa-eddsa")] +impl Signer for MlDsa44Ed25519Keypair { + fn try_sign(&self, message: &[u8]) -> signature::Result { + let data = self.sign_msg(message)?; + + Ok(Signature { + algorithm: self.algorithm(), + data, + }) + } +} + +#[cfg(feature = "mldsa-eddsa")] +impl Verifier for MlDsa44Ed25519PublicKey { + fn verify(&self, message: &[u8], signature: &Signature) -> signature::Result<()> { + // The signature's algorithm must match this key. + if signature.algorithm() != self.algorithm() { + return Err(Error::Signature.into()); + } + + Ok(self.verify_msg(message, signature.as_bytes())?) + } +} + #[cfg(feature = "ed25519")] impl Verifier for public::SkEd25519 { fn verify(&self, message: &[u8], signature: &Signature) -> signature::Result<()> { @@ -767,10 +801,19 @@ mod tests { use encoding::Encode; use hex_literal::hex; - #[cfg(any(feature = "ed25519", all(feature = "rsa", feature = "sha1")))] - use signature::Verifier; #[cfg(feature = "ed25519")] - use {super::Ed25519Keypair, signature::Signer}; + use super::Ed25519Keypair; + #[cfg(any(feature = "ed25519", feature = "mldsa-eddsa"))] + use signature::Signer; + #[cfg(any( + feature = "ed25519", + feature = "mldsa-eddsa", + all(feature = "rsa", feature = "sha1") + ))] + use signature::Verifier; + + #[cfg(feature = "mldsa-eddsa")] + use super::{MLDSA44_ED25519_SIG_SIZE, MlDsa44Ed25519Keypair}; #[cfg(feature = "p256")] use super::{Mpint, zero_pad_field_bytes}; @@ -922,7 +965,6 @@ mod tests { fn try_sign_and_verify_dsa() { use super::{DSA_COMPONENT_SIZE, DsaKeypair}; use encoding::Decode as _; - use signature::{Signer as _, Verifier as _}; fn check_signature_component_lengths( keypair: &DsaKeypair, @@ -1001,6 +1043,71 @@ mod tests { assert!(keypair.public.verify(EXAMPLE_MSG, &signature).is_ok()); } + #[cfg(feature = "mldsa-eddsa")] + #[test] + fn sign_and_verify_mldsa44_ed25519() { + let msg = b"Hello, world!"; + let keypair = MlDsa44Ed25519Keypair::from_seed(&[42; 64]).unwrap(); + let signature = keypair.sign(msg); + + assert_eq!(signature.algorithm(), Algorithm::MlDsa44Ed25519); + assert_eq!(signature.as_bytes().len(), MLDSA44_ED25519_SIG_SIZE); + assert!(keypair.public.verify(msg, &signature).is_ok()); + + // Signing is deterministic: ML-DSA uses the FIPS 204 deterministic + // variant and Ed25519 is deterministic by construction. + assert_eq!(keypair.sign(msg), signature); + + // A tampered message must fail verification. + assert!(keypair.public.verify(b"tampered", &signature).is_err()); + + // Signature encode/decode round-trips. + let encoded = signature.encode_vec().unwrap(); + let decoded = Signature::try_from(&encoded[..]).unwrap(); + assert_eq!(decoded, signature); + } + + #[cfg(feature = "mldsa-eddsa")] + #[test] + fn mldsa44_ed25519_key_roundtrip() { + use crate::{private::KeypairData, public::KeyData}; + use encoding::Decode; + + let keypair = MlDsa44Ed25519Keypair::from_seed(&[7; 64]).unwrap(); + + // Public key wire-format round-trips and reports the correct algorithm. + let public: KeyData = keypair.public.clone().into(); + let encoded = public.encode_vec().unwrap(); + let decoded = KeyData::decode(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, public); + assert_eq!(decoded.algorithm(), Algorithm::MlDsa44Ed25519); + + // Keypair wire-format round-trips. + let keypair_data = KeypairData::from(keypair); + let encoded = keypair_data.encode_vec().unwrap(); + let decoded = KeypairData::decode(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, keypair_data); + } + + /// A keypair whose public half does not match its seeds must be rejected + /// at decode time rather than silently producing unverifiable signatures. + #[cfg(feature = "mldsa-eddsa")] + #[test] + fn mldsa44_ed25519_mismatched_public_key_is_rejected() { + use crate::private::KeypairData; + use encoding::Decode; + + let keypair = MlDsa44Ed25519Keypair::from_seed(&[7; 64]).unwrap(); + let mut encoded = KeypairData::from(keypair).encode_vec().unwrap(); + + // Corrupt the last byte of the encoded public key, which sits just + // before the 4-byte length prefix of the 64-byte seed pair. + let public_key_end = encoded.len() - 4 - 64; + encoded[public_key_end - 1] ^= 1; + + assert!(KeypairData::decode(&mut &encoded[..]).is_err()); + } + #[test] fn placeholder() { assert!( diff --git a/ssh-key/tests/dot_ssh.rs b/ssh-key/tests/dot_ssh.rs index 9e517aab..a7bbdcb1 100644 --- a/ssh-key/tests/dot_ssh.rs +++ b/ssh-key/tests/dot_ssh.rs @@ -20,7 +20,7 @@ fn path_round_trip() { #[test] fn private_keys() { let dot_ssh = dot_ssh(); - assert_eq!(dot_ssh.private_keys().unwrap().count(), 22); + assert_eq!(dot_ssh.private_keys().unwrap().count(), 25); } #[test] @@ -37,7 +37,7 @@ fn private_key_with_fingerprint() { #[test] fn public_keys() { let dot_ssh = dot_ssh(); - assert_eq!(dot_ssh.public_keys().unwrap().count(), 12); + assert_eq!(dot_ssh.public_keys().unwrap().count(), 13); } #[test] diff --git a/ssh-key/tests/examples/mldsa44_ed25519_1 b/ssh-key/tests/examples/mldsa44_ed25519_1 new file mode 100644 index 00000000..e06c5fbf --- /dev/null +++ b/ssh-key/tests/examples/mldsa44_ed25519_1 @@ -0,0 +1,58 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- diff --git a/ssh-key/tests/examples/mldsa44_ed25519_1-cert.pub b/ssh-key/tests/examples/mldsa44_ed25519_1-cert.pub new file mode 100644 index 00000000..d076e511 --- /dev/null +++ b/ssh-key/tests/examples/mldsa44_ed25519_1-cert.pub @@ -0,0 +1 @@ +ssh-mldsa44-ed25519-cert-v01@openssh.com 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 MLDSA44-ED25519 test key #1 diff --git a/ssh-key/tests/examples/mldsa44_ed25519_1.pub b/ssh-key/tests/examples/mldsa44_ed25519_1.pub new file mode 100644 index 00000000..f31f8b82 --- /dev/null +++ b/ssh-key/tests/examples/mldsa44_ed25519_1.pub @@ -0,0 +1 @@ +ssh-mldsa44-ed25519@openssh.com AAAAH3NzaC1tbGRzYTQ0LWVkMjU1MTlAb3BlbnNzaC5jb20AAAVA8QQbc4KQ8kqE9dEtVE6wZo/v4XRmRufNJGj9GlPtJR+5F5Ubvez2tTfW2p4x0BFXxgT1D+BPU2NFqlfEcOgV6YEzPmlU7zHAF0P4vM3U9SBjHyXu7LKTsHcd38m5iK+52URdgvM1gKkzgrcqA/bcXbNtZU3HKYPdh0IAYsy37v6wTVJ3E9yLjxEK7aBoLlUnXHSIOOe3DtYSXqsmrQflSZ8WQUDY1mmELPOaB2bOJ9LMxJHI5ATj0LFke0phWSzIy1bC7218mz9j8zagrAPvybGl/NioRP5Vx61HXND/CUWr3i7H92sWFgKgsaPqs8c6qWggoBzu4HuIb6GtGfMjoVWmfkFWMGjb4jHOaVPmrb9DVppP2ZWL99al2Ax9Rds72hAJUcSBGWk9hpQX2P4aBqBonh6J/xUjaHsGrFah54UWY2M5xOSB5cLobtuzheizUrG1UeWTFWHTPXZrz6FjI3gmhmXZuMJZLrh9xYcVYqbWH8wJWo5tnrkR3k7LKVkJXeeZ+Gu0DNyOhMmnblt1Mcn9P+u5NKAZutNebuzOF91d5y7m+sToM+H9EM+rsHKOrNpvdvcEZ5baN7Y/jqzn94LryOHTdQ3ybl+t17QOy2QiAtKxnsrGLSv+MgMW/q7+/u5vUt82/OjtjQXdEGZmZBugL4BK+Z5bSp7qRq/F9mFAmLsEkNB0pgtcxjEjCRqo1lfTqhZfXJ/cHGeAiRmGKcaFFq+uFw6ayjD5NXAJ4VhaVzKvbCi19mi/QDVotcWN6xX0t883DP7jp1yc+sy1Mtyc3N92+A+m/CBa9y7nXv3iAcLPeoH5aLzepwD/0uk8lVqWNGPMSm136EVSXBd47Sa7bsjqYxEN1evmo/ZgphDji7U2y5jLuBg5JTNfDjbRJdF6Xw3HT4h+mLrZk+iiHzkSKh9XI6UI3AVzoqFZ9S88FpYnqCdhh/qaAruEiYSKjUDr10rJ9Qj57BRo5BFxHNsNr+9Xe3UoYDdh5uhN3OjCYKNC5wVQ1DPaevnCiet9k7TLkAk7eHl4r5kS6rCUJByBXmDp6j1TsRrljCXzSUtOQFXQF3GfVU8OtxWchmoMmj27MD2AhmREQWqepAaehUk5Tq27stTtXJDSOZbTHiTMFBCR/OJKTCkwHWV9IM48oZ6doxW9nWZOaL57p7smNP31PCK7RqOiF0xV+/hrzG3Zdg/7F0S7s7smDUrX9xdUrs/F3gd3EvWg+SKma7NxfUxLjYF3TIqwa3UJw2miUR6VlbEw8Su7NQUPDsD1uRGL9Q8J+L4ie7hSJ3BoyY6AgpnK58T8o+DSywbmbLscY7+UKGBa+SD3xT9OlitNQ0K9GF59wO/svOlzcfDmDxKk6/LWSc55LjWlcD73hYjGgLgQWA0MDDQLrxpeUArCEmbhYvQL2yk90SwgQeX/DieG51R54EHiWXTALWY9HJEZ3+b1gzvsP/YqA+XawDTjoGWL+0rp+P6lMFbnu4wK4m5YLZv/+B8jD0l00al3rQLdVnwiZmNVZFtbgIHUX++lGAa/0S4PS5Wgn7qliXjXH9ObZ8TsFR6useXZDUnlKVuEk3y+wvPGd7M6s/jpdr7+BgjKsYItoszLOhMGUtYqOOFP2hdi+XtKlK6TYQfJT1NcpdT+CnyzNx1vrqdEd4FO9DrqL3TZAOvdEJm1nqSUChlTA4caZ4X1P2pmBJeOC07/0oiR+hHNfmtKcSI+vjBXWNYa92XQ8HhkauyLiVfR4HzDQ/JVAUSuFD4T/7i/kgTTETX6ThIgQbxltm8XE8OoTKcW MLDSA44-ED25519 test key #1 diff --git a/ssh-key/tests/examples/mldsa44_ed25519_1_pw b/ssh-key/tests/examples/mldsa44_ed25519_1_pw new file mode 100644 index 00000000..fdf9cae6 --- /dev/null +++ b/ssh-key/tests/examples/mldsa44_ed25519_1_pw @@ -0,0 +1,59 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +SKsHIisqSi1zPyq0OdyZgY/OqsRW3ck8NJc= +-----END OPENSSH PRIVATE KEY----- diff --git a/ssh-key/tests/examples/mldsa44_ed25519_2 b/ssh-key/tests/examples/mldsa44_ed25519_2 new file mode 100644 index 00000000..f49cf8aa --- /dev/null +++ b/ssh-key/tests/examples/mldsa44_ed25519_2 @@ -0,0 +1,58 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- diff --git a/ssh-key/tests/examples/mldsa44_ed25519_kat_ctx.bin b/ssh-key/tests/examples/mldsa44_ed25519_kat_ctx.bin new file mode 100644 index 00000000..3870f89e --- /dev/null +++ b/ssh-key/tests/examples/mldsa44_ed25519_kat_ctx.bin @@ -0,0 +1 @@ +The lethargic, colorless dog sat beneath the energetic, stationary fox. \ No newline at end of file diff --git a/ssh-key/tests/examples/mldsa44_ed25519_kat_m.bin b/ssh-key/tests/examples/mldsa44_ed25519_kat_m.bin new file mode 100644 index 00000000..8fe2a4b5 --- /dev/null +++ b/ssh-key/tests/examples/mldsa44_ed25519_kat_m.bin @@ -0,0 +1 @@ +The quick brown fox jumps over the lazy dog. \ No newline at end of file diff --git a/ssh-key/tests/examples/mldsa44_ed25519_kat_pk.bin b/ssh-key/tests/examples/mldsa44_ed25519_kat_pk.bin new file mode 100644 index 0000000000000000000000000000000000000000..ef0daf1c29013c620a9e52d47d94f52bf838d1b7 GIT binary patch literal 1344 zcmV-G1;6?hT4iYhAXerC15PJ5)mO;His5kKMj6@}rH<%Ys0!GFubRvA+9nWGh=0v> zUl!XQl7S~QPN*7+eY4=Qvk_)6T^;0SwhI?%jz&AsFD9Y5hX1eierjO;%QQ7b*?g#~2x1*-Hbhf;Dz zU%c(XVxa+!jrsSs-L8d-OshPR$>y@cJSy_si3^568syV_I+w4$kaqOJ;!>C6X=Q^V z2y`4A;4ovl1(*^gQT+Tyn>;cg6pZxv7d!>NiZ{fQDw@fC96ba@YN)y-tYL#lmH;u6 zvcU@ShuOnE4ewoCqq5O8DxYx?Tlob2zjlB@FV$wua!)499J)XVJ1X{|!gANJ9736; zRw`^;HLFl;r{a6z^Y z(c5JoE9`d6+LC4Dw`AU*%2)22W&oSAD>pg#ajN`-t^<7zfH9HYJgy$9UR~yYB1#lM z6*OS-F|X>G8bA{@IiuWI#@f~3;&9$+$R?PB3gKfdvQ*vfOyPL_ooU6N7ejFI>p1}L z*Z4oTO@=X@VtGLyTmYX0-5KNfdYLz9UE@dcNsSzIGZ(KcMhZ7@>qt&~(4~`@VGJ|X z5ODncu%G@`17v{`hDCUA(@kpf@G}w`;xEaCotQyrBX1ct+>rFg;<&e|VcUp0bGJC^ zDh~f{W?z*K&F?MVU(6OsogmSOeJC<&!onQxW~dKs3&jLNuV+wXpC&Zu?62c!oFLBx z4lc5vV%MdV)v$0yzcr`gx-n|n$Lt3BZJq{y=lW2Wb8LDJ27 zhm#B@4Tm<-k*M#oMB%VyY668rU6${L(tEM+KW_uZ+tw*=dLquHGU}ODNIV$>7T}Ec zg?~~g7rOr3H0bxBJT~e57sUe{>h|{=*=qg2~yVSie8LNiD;CEt@lS&{_NI2JY;=Nfb2i4hQ%_>ib)F!Dq=R+8aEYfqEx@qpz)3< zJE<`OZ=zWud0L9lIMl|+!!7^Srb^7|@%I#nu;@Jdy)5?$^$0^q1APd;!%r4iBm2{( zM|eCJ*1H@6`SuCUzpHfHKi14*fiFu=Dde-pczbVO^VP}w{Uq9eZnRaJ+miv^YeE>0 zG;+BleqVbBo0k(#vRjQGn2Fla`_UiwEym00IEPEn-=iGIK6UqMwW(BHS!afsiuaEI z<@^WId(FIEh8I-Tzev^TSH?vH0?(#+qHbUBEXQ<)4phr#; z557j4JS1RX7bz>t*>fsS$bvh}5Js}6c!z<4WD24>C6AF1l>!*1(6b&ow2@V4y53sd CZ^)!oSHLY78FF#DAx{#n^9&BbLz5W$pm~ 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 WJ8>GA5o>ez?5r^Q@!gwGp?zq|NFQJT literal 0 HcmV?d00001 diff --git a/ssh-key/tests/mldsa44_ed25519.rs b/ssh-key/tests/mldsa44_ed25519.rs new file mode 100644 index 00000000..294b4115 --- /dev/null +++ b/ssh-key/tests/mldsa44_ed25519.rs @@ -0,0 +1,189 @@ +//! MLDSA44-Ed25519 composite key tests. +//! +//! Key, certificate and fingerprint fixtures are taken from OpenSSH's +//! `regress/unittests/sshkey/testdata`. + +#![cfg(feature = "mldsa-eddsa")] + +use ssh_key::{Algorithm, HashAlg, PrivateKey, PublicKey}; + +#[cfg(feature = "alloc")] +use ssh_key::Certificate; + +/// Unencrypted OpenSSH-format private key. +const OPENSSH_PRIVATE_1: &str = include_str!("examples/mldsa44_ed25519_1"); + +/// The same key, encrypted under [`PASSWORD`]. +const OPENSSH_PRIVATE_1_PW: &str = include_str!("examples/mldsa44_ed25519_1_pw"); + +/// Public key matching [`OPENSSH_PRIVATE_1`]. +const OPENSSH_PUBLIC_1: &str = include_str!("examples/mldsa44_ed25519_1.pub"); + +/// Certificate over [`OPENSSH_PUBLIC_1`]. +#[cfg(feature = "alloc")] +const OPENSSH_CERT_1: &str = include_str!("examples/mldsa44_ed25519_1-cert.pub"); + +/// A second, unrelated key. +const OPENSSH_PRIVATE_2: &str = include_str!("examples/mldsa44_ed25519_2"); + +/// Passphrase protecting [`OPENSSH_PRIVATE_1_PW`], from OpenSSH's `testdata/pw`. +const PASSWORD: &[u8] = b"mekmitasdigoat"; + +/// Expected SHA-256 fingerprints, as reported by `ssh-keygen -l`. +const FINGERPRINT_1: &str = "SHA256:MHrtMS/Vf35zxTemIBpFyKqv9rT1bd1XMLqM9CnT8WY"; +const FINGERPRINT_2: &str = "SHA256:fkgm6buCjmefUacy+TObQT3vsbO08/z6D8VHKqlMh/4"; + +#[test] +fn decode_openssh_private_key() { + let key = PrivateKey::from_openssh(OPENSSH_PRIVATE_1).unwrap(); + assert_eq!(key.algorithm(), Algorithm::MlDsa44Ed25519); + + let keypair = key.key_data().mldsa44_ed25519().unwrap(); + assert_eq!(keypair.public.as_bytes().len(), 1344); + assert_eq!(keypair.private.to_bytes().len(), 64); +} + +#[test] +fn decode_openssh_public_key() { + let key = PublicKey::from_openssh(OPENSSH_PUBLIC_1).unwrap(); + assert_eq!(key.algorithm(), Algorithm::MlDsa44Ed25519); + assert!(key.key_data().is_mldsa44_ed25519()); +} + +#[test] +fn public_key_matches_private_key() { + let private = PrivateKey::from_openssh(OPENSSH_PRIVATE_1).unwrap(); + let public = PublicKey::from_openssh(OPENSSH_PUBLIC_1).unwrap(); + assert_eq!(private.public_key().key_data(), public.key_data()); +} + +#[test] +fn encode_openssh_private_key() { + let key = PrivateKey::from_openssh(OPENSSH_PRIVATE_1).unwrap(); + let encoded = key.to_openssh(ssh_key::LineEnding::LF).unwrap(); + assert_eq!(&*encoded, OPENSSH_PRIVATE_1); +} + +#[test] +fn encode_openssh_public_key() { + let key = PublicKey::from_openssh(OPENSSH_PUBLIC_1).unwrap(); + let encoded = key.to_openssh().unwrap(); + // The fixture carries a trailing newline and a comment; compare trimmed. + assert_eq!(encoded.trim_end(), OPENSSH_PUBLIC_1.trim_end()); +} + +#[cfg(feature = "encryption")] +#[test] +fn decrypt_openssh_private_key() { + let encrypted = PrivateKey::from_openssh(OPENSSH_PRIVATE_1_PW).unwrap(); + assert!(encrypted.is_encrypted()); + + let decrypted = encrypted.decrypt(PASSWORD).unwrap(); + let expected = PrivateKey::from_openssh(OPENSSH_PRIVATE_1).unwrap(); + assert_eq!(decrypted.key_data(), expected.key_data()); +} + +#[cfg(feature = "encryption")] +#[test] +fn decrypt_with_wrong_password_fails() { + let encrypted = PrivateKey::from_openssh(OPENSSH_PRIVATE_1_PW).unwrap(); + assert!(encrypted.decrypt(b"not the password").is_err()); +} + +#[test] +fn fingerprints_match_openssh() { + let key_1 = PrivateKey::from_openssh(OPENSSH_PRIVATE_1).unwrap(); + assert_eq!( + key_1.fingerprint(HashAlg::Sha256).to_string(), + FINGERPRINT_1 + ); + + let key_2 = PrivateKey::from_openssh(OPENSSH_PRIVATE_2).unwrap(); + assert_eq!( + key_2.fingerprint(HashAlg::Sha256).to_string(), + FINGERPRINT_2 + ); +} + +#[test] +fn sign_and_verify() { + use signature::{Signer, Verifier}; + + let msg = b"MLDSA44-Ed25519 signing test"; + let key = PrivateKey::from_openssh(OPENSSH_PRIVATE_1).unwrap(); + let signature = key.key_data().sign(msg); + + assert_eq!(signature.algorithm(), Algorithm::MlDsa44Ed25519); + assert_eq!(signature.as_bytes().len(), 2484); + + let public = PublicKey::from_openssh(OPENSSH_PUBLIC_1).unwrap(); + assert!(public.key_data().verify(msg, &signature).is_ok()); + assert!( + public + .key_data() + .verify(b"other message", &signature) + .is_err() + ); + + // A signature from key 2 must not verify under key 1. + let other = PrivateKey::from_openssh(OPENSSH_PRIVATE_2).unwrap(); + let other_signature = other.key_data().sign(msg); + assert!(public.key_data().verify(msg, &other_signature).is_err()); +} + +#[cfg(feature = "alloc")] +#[test] +fn decode_certificate() { + let cert = Certificate::from_openssh(OPENSSH_CERT_1).unwrap(); + assert_eq!(cert.algorithm(), Algorithm::MlDsa44Ed25519); + assert!(cert.public_key().is_mldsa44_ed25519()); + + // OpenSSH fingerprints a certificate by its embedded public key, so this + // matches the plain key's fingerprint. + assert_eq!( + cert.public_key().fingerprint(HashAlg::Sha256).to_string(), + FINGERPRINT_1 + ); + + let public = PublicKey::from_openssh(OPENSSH_PUBLIC_1).unwrap(); + assert_eq!(cert.public_key(), public.key_data()); +} + +#[cfg(feature = "alloc")] +#[test] +fn encode_certificate() { + let cert = Certificate::from_openssh(OPENSSH_CERT_1).unwrap(); + assert_eq!( + cert.to_openssh().unwrap().trim_end(), + OPENSSH_CERT_1.trim_end() + ); +} + +/// A composite key can act as a certificate authority: it signs the cert and +/// the cert validates against it. +#[cfg(feature = "alloc")] +#[test] +fn certificate_sign_and_validate() { + use ssh_key::certificate::Builder; + use std::time::{SystemTime, UNIX_EPOCH}; + + let ca_key = PrivateKey::from_openssh(OPENSSH_PRIVATE_1).unwrap(); + let subject = PrivateKey::from_openssh(OPENSSH_PRIVATE_2).unwrap(); + + let valid_after = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let valid_before = valid_after + 3600; + + let mut builder = + Builder::new([0u8; 32], subject.public_key(), valid_after, valid_before).unwrap(); + builder.valid_principal("example").unwrap(); + + let cert = builder.sign(&ca_key).unwrap(); + + assert_eq!(cert.algorithm(), Algorithm::MlDsa44Ed25519); + assert_eq!(cert.signature_key(), ca_key.public_key().key_data()); + cert.validate_at(valid_after + 1, &[ca_key.fingerprint(HashAlg::Sha256)]) + .unwrap(); +} From 41db2b03c20e44557a2110e986087a82d6e3c0a5 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 28 Aug 2026 17:28:05 +0900 Subject: [PATCH 2/3] fix: fix test error --- ssh-key/tests/mldsa44_ed25519.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ssh-key/tests/mldsa44_ed25519.rs b/ssh-key/tests/mldsa44_ed25519.rs index 294b4115..f3231379 100644 --- a/ssh-key/tests/mldsa44_ed25519.rs +++ b/ssh-key/tests/mldsa44_ed25519.rs @@ -14,6 +14,7 @@ use ssh_key::Certificate; const OPENSSH_PRIVATE_1: &str = include_str!("examples/mldsa44_ed25519_1"); /// The same key, encrypted under [`PASSWORD`]. +#[cfg(feature = "encryption")] const OPENSSH_PRIVATE_1_PW: &str = include_str!("examples/mldsa44_ed25519_1_pw"); /// Public key matching [`OPENSSH_PRIVATE_1`]. @@ -27,6 +28,7 @@ const OPENSSH_CERT_1: &str = include_str!("examples/mldsa44_ed25519_1-cert.pub") const OPENSSH_PRIVATE_2: &str = include_str!("examples/mldsa44_ed25519_2"); /// Passphrase protecting [`OPENSSH_PRIVATE_1_PW`], from OpenSSH's `testdata/pw`. +#[cfg(feature = "encryption")] const PASSWORD: &[u8] = b"mekmitasdigoat"; /// Expected SHA-256 fingerprints, as reported by `ssh-keygen -l`. From 9212f3e96f7e72df7b4070f3fbfc825e9a8c420e Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Fri, 28 Aug 2026 17:35:10 +0900 Subject: [PATCH 3/3] fix: fix test error --- ssh-key/tests/dot_ssh.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/ssh-key/tests/dot_ssh.rs b/ssh-key/tests/dot_ssh.rs index a7bbdcb1..ce99b2aa 100644 --- a/ssh-key/tests/dot_ssh.rs +++ b/ssh-key/tests/dot_ssh.rs @@ -5,6 +5,18 @@ use hex_literal::hex; use ssh_key::{Algorithm, DotSsh, Fingerprint}; +/// Number of `mldsa44_ed25519` example keys, which only parse when the +/// algorithm is enabled. +#[cfg(feature = "mldsa-eddsa")] +const MLDSA_EDDSA_PRIVATE_KEYS: usize = 3; +#[cfg(not(feature = "mldsa-eddsa"))] +const MLDSA_EDDSA_PRIVATE_KEYS: usize = 0; + +#[cfg(feature = "mldsa-eddsa")] +const MLDSA_EDDSA_PUBLIC_KEYS: usize = 1; +#[cfg(not(feature = "mldsa-eddsa"))] +const MLDSA_EDDSA_PUBLIC_KEYS: usize = 0; + /// Open `.ssh` using the `test/examples`. fn dot_ssh() -> DotSsh { DotSsh::open("tests/examples") @@ -20,7 +32,10 @@ fn path_round_trip() { #[test] fn private_keys() { let dot_ssh = dot_ssh(); - assert_eq!(dot_ssh.private_keys().unwrap().count(), 25); + assert_eq!( + dot_ssh.private_keys().unwrap().count(), + 22 + MLDSA_EDDSA_PRIVATE_KEYS + ); } #[test] @@ -37,7 +52,10 @@ fn private_key_with_fingerprint() { #[test] fn public_keys() { let dot_ssh = dot_ssh(); - assert_eq!(dot_ssh.public_keys().unwrap().count(), 13); + assert_eq!( + dot_ssh.public_keys().unwrap().count(), + 12 + MLDSA_EDDSA_PUBLIC_KEYS + ); } #[test]