diff --git a/Cargo.lock b/Cargo.lock index 42ad341cb..cb65a9c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,13 +573,14 @@ dependencies = [ "cbc", "chacha20poly1305", "ciborium", - "coset", + "coset 0.4.0", "criterion", "digest 0.11.0-rc.3", "ed25519-dalek 3.0.0-pre.1", "generic-array", "hkdf", "hmac 0.13.0-rc.2", + "ml-dsa", "num-bigint", "num-traits", "pbkdf2 0.13.0-rc.1", @@ -688,7 +689,7 @@ dependencies = [ "bitwarden-encoding", "bitwarden-vault", "chrono", - "coset", + "coset 0.3.8", "itertools 0.14.0", "log", "p256", @@ -1008,7 +1009,7 @@ version = "0.11.0-rc.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9ef36a6fcdb072aa548f3da057640ec10859eb4e91ddf526ee648d50c76a949" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.5", ] [[package]] @@ -1476,6 +1477,16 @@ dependencies = [ "ciborium-io", ] +[[package]] +name = "coset" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aeb90e56027edc2a7d7f71cbc500e742e2520bede7a3f8a3bfb1dac7aed623e" +dependencies = [ + "ciborium", + "ciborium-io", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1667,7 +1678,7 @@ version = "0.2.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8235645834fbc6832939736ce2f2d08192652269e11010a6240f61b908a1c6" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.5", "rand_core 0.9.3", ] @@ -2637,6 +2648,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d15931895091dea5c47afa5b3c9a01ba634b311919fd4d41388fa0e3d76af" +dependencies = [ + "typenum", +] + [[package]] name = "hybrid-array" version = "0.4.5" @@ -2912,7 +2932,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7357b6e7aa75618c7864ebd0634b115a7218b0615f4cb1df33ac3eca23943d4" dependencies = [ - "hybrid-array", + "hybrid-array 0.4.5", ] [[package]] @@ -3074,6 +3094,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3245,6 +3274,21 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "ml-dsa" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4a46643af2001eafebcc37031fc459eb72d45057aac5d7a15b00046a2ad6db" +dependencies = [ + "const-oid 0.9.6", + "hybrid-array 0.3.1", + "num-traits", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sha3", + "signature 2.2.0", +] + [[package]] name = "mockall" version = "0.13.1" @@ -3480,7 +3524,7 @@ version = "0.2.0" source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" dependencies = [ "async-trait", - "coset", + "coset 0.3.8", "log", "p256", "passkey-types", @@ -3493,7 +3537,7 @@ version = "0.2.0" source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd12ee14d8e5c87b494ed#3b764633ebc6576c07bdd12ee14d8e5c87b494ed" dependencies = [ "ciborium", - "coset", + "coset 0.3.8", "idna 0.5.0", "nom", "passkey-authenticator", @@ -3516,7 +3560,7 @@ source = "git+https://github.com/bitwarden/passkey-rs?rev=3b764633ebc6576c07bdd1 dependencies = [ "bitflags 2.9.1", "ciborium", - "coset", + "coset 0.3.8", "data-encoding", "getrandom 0.2.16", "indexmap 2.9.0", @@ -4702,6 +4746,16 @@ dependencies = [ "digest 0.11.0-rc.3", ] +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/crates/bitwarden-crypto/Cargo.toml b/crates/bitwarden-crypto/Cargo.toml index f8b326179..ed6487368 100644 --- a/crates/bitwarden-crypto/Cargo.toml +++ b/crates/bitwarden-crypto/Cargo.toml @@ -15,8 +15,9 @@ license-file.workspace = true keywords.workspace = true [features] -default = [] +default = ["post-quantum-crypto"] no-memory-hardening = [] # Disable memory hardening features +post-quantum-crypto = ["dep:ml-dsa"] uniffi = [ "bitwarden-encoding/uniffi", "dep:bitwarden-uniffi-error", @@ -36,12 +37,13 @@ bitwarden-uniffi-error = { workspace = true, optional = true } cbc = { version = ">=0.1.2, <0.2", features = ["alloc", "zeroize"] } chacha20poly1305 = { version = "0.11.0-rc.1" } ciborium = { version = ">=0.2.2, <0.3" } -coset = { version = ">=0.3.8, <0.4" } +coset = { version = "=0.4.0" } digest = { version = "=0.11.0-rc.3" } ed25519-dalek = { version = "=3.0.0-pre.1", features = ["rand_core"] } generic-array = { version = ">=0.14.7, <1.0", features = ["zeroize"] } hkdf = "=0.13.0-rc.2" hmac = "=0.13.0-rc.2" +ml-dsa = { version = "0.0.4", optional = true } num-bigint = ">=0.4, <0.5" num-traits = ">=0.2.15, <0.3" pbkdf2 = { version = "=0.13.0-rc.1", default-features = false } diff --git a/crates/bitwarden-crypto/src/signing/hazmat/composite_sig.rs b/crates/bitwarden-crypto/src/signing/hazmat/composite_sig.rs new file mode 100644 index 000000000..7680cef2e --- /dev/null +++ b/crates/bitwarden-crypto/src/signing/hazmat/composite_sig.rs @@ -0,0 +1,159 @@ +//! Implements Parabel-Jose-PQ-composite-sigs +//! https://www.ietf.org/archive/id/draft-prabel-jose-pq-composite-sigs-04.html + +use ml_dsa::{B32, EncodedVerifyingKey, KeyGen, MlDsa65}; +use rand::RngCore; +use sha2::Digest; + +const ML_DSA_SEED_SIZE: usize = 32; +const RANDOMIZER_SIZE: usize = 32; + +const COMPOSITE_ALGORITHM_SIGNATURE_PREFIX: &[u8] = &[ + 0x43, 0x6F, 0x6D, 0x70, 0x6F, 0x73, 0x69, 0x74, 0x65, 0x41, 0x6C, 0x67, 0x6F, 0x72, 0x69, 0x74, + 0x68, 0x6D, 0x53, 0x69, 0x67, 0x6E, 0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x32, 0x30, 0x32, 0x35, +]; + +const ML_DSA65_ED25519_DOMAIN_SEPARATOR: &[u8] = &[ + 0x06, 0x0B, 0x60, 0x86, 0x48, 0x01, 0x86, 0xFA, 0x6B, 0x50, 0x09, 0x01, 0x0B, +]; + +struct Mldsa65Ed25519SigningKey { + mldsa65_seed: [u8; ML_DSA_SEED_SIZE], + ed25519_key: [u8; ed25519_dalek::SECRET_KEY_LENGTH], +} + +impl Into> for Mldsa65Ed25519SigningKey { + fn into(self) -> Vec { + let mut v = Vec::with_capacity(ML_DSA_SEED_SIZE + ed25519_dalek::SECRET_KEY_LENGTH); + v.extend_from_slice(&self.mldsa65_seed); + v.extend_from_slice(&self.ed25519_key); + v + } +} + +impl From> for Mldsa65Ed25519SigningKey { + fn from(bytes: Vec) -> Self { + let mut mldsa65_seed = [0u8; ML_DSA_SEED_SIZE]; + mldsa65_seed.copy_from_slice(&bytes[0..ML_DSA_SEED_SIZE]); + let mut ed25519_key = [0u8; ed25519_dalek::SECRET_KEY_LENGTH]; + ed25519_key.copy_from_slice(&bytes[ML_DSA_SEED_SIZE..]); + Mldsa65Ed25519SigningKey { + mldsa65_seed, + ed25519_key, + } + } +} + +struct MlDsa65Ed25519VerifyingKey { + mldsa65_key: ml_dsa::VerifyingKey, + ed25519_key: ed25519_dalek::VerifyingKey, +} + +impl Into> for MlDsa65Ed25519VerifyingKey { + fn into(self) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(self.mldsa65_key.encode().as_slice()); + v.extend_from_slice(self.ed25519_key.to_bytes().as_slice()); + v + } +} + +struct MlDsa65Ed25519Signature { + r: [u8; RANDOMIZER_SIZE], + mldsa65_signature: ml_dsa::Signature, + ed25519_signature: ed25519_dalek::Signature, +} + +impl Mldsa65Ed25519SigningKey { + fn make() -> Self { + let mut mldsa65_seed = [0u8; ML_DSA_SEED_SIZE]; + rand::rng().fill_bytes(&mut mldsa65_seed); + let ed25519_key = ed25519_dalek::SigningKey::generate(&mut rand::rng()).to_bytes(); + Mldsa65Ed25519SigningKey { + mldsa65_seed, + ed25519_key, + } + } + + fn to_verifying_key(&self) -> MlDsa65Ed25519VerifyingKey { + let mldsa65_key = MlDsa65::key_gen_internal(&B32::from(self.mldsa65_seed)) + .verifying_key() + .to_owned(); + let ed25519_key = ed25519_dalek::SigningKey::from_bytes(&self.ed25519_key).verifying_key(); + MlDsa65Ed25519VerifyingKey { + mldsa65_key, + ed25519_key, + } + } + + fn sign(&self, message: &[u8]) -> MlDsa65Ed25519Signature { + let mut r = [0u8; RANDOMIZER_SIZE]; + rand::rng().fill_bytes(&mut r); + let m = mldsa65_ed25519_compute_m(message, r); + + let mldsa65_signature = { + use ml_dsa::signature::Signer; + MlDsa65::key_gen_internal(&B32::from(self.mldsa65_seed)) + .signing_key() + // todo this is wrong, and should have the CTX set to domain + .try_sign(m.as_slice()) + .expect("Signing always succeeds with valid key") + }; + let ed25519_signature = { + use ed25519_dalek::Signer; + ed25519_dalek::SigningKey::from_bytes(&self.ed25519_key).sign(message) + }; + + MlDsa65Ed25519Signature { + r, + mldsa65_signature, + ed25519_signature, + } + } +} + +impl MlDsa65Ed25519VerifyingKey { + fn verify(&self, message: &[u8], signature: &MlDsa65Ed25519Signature) -> bool { + let m = mldsa65_ed25519_compute_m(message, signature.r); + let mldsa65_valid = { + use ml_dsa::signature::Verifier; + println!("Verifying MlDsa65Ed25519 signature..."); + self.mldsa65_key + .verify(m.as_slice(), &signature.mldsa65_signature) + .is_ok() + }; + let ed25519_valid = { + println!("Verifying Ed25519 part of MlDsa65Ed25519 signature..."); + self.ed25519_key + .verify_strict(message, &signature.ed25519_signature) + .is_ok() + }; + mldsa65_valid && ed25519_valid + } +} + +fn mldsa65_ed25519_compute_m(message: &[u8], r: [u8; RANDOMIZER_SIZE]) -> Vec { + // Sha512 is specific to MlDsa65Ed25519 + let pre_hash = sha2::Sha512::digest(message); + let m = [ + COMPOSITE_ALGORITHM_SIGNATURE_PREFIX, + ML_DSA65_ED25519_DOMAIN_SEPARATOR, + &[0x00], + pre_hash.as_slice(), + ] + .concat(); + m +} + +mod tests { + use super::*; + + #[test] + fn test_mldsa65_ed25519_signature() { + let signing_key = Mldsa65Ed25519SigningKey::make(); + let verifying_key = signing_key.to_verifying_key(); + let message = b"Test message for MlDsa65Ed25519 composite signature"; + let signature = signing_key.sign(message); + assert!(verifying_key.verify(message, &signature)); + } +} diff --git a/crates/bitwarden-crypto/src/signing/hazmat/mod.rs b/crates/bitwarden-crypto/src/signing/hazmat/mod.rs new file mode 100644 index 000000000..d00d2a858 --- /dev/null +++ b/crates/bitwarden-crypto/src/signing/hazmat/mod.rs @@ -0,0 +1,2 @@ +mod composite_sig; +pub(crate) use composite_sig::*; diff --git a/crates/bitwarden-crypto/src/signing/mod.rs b/crates/bitwarden-crypto/src/signing/mod.rs index 84e35c41d..70cbedd09 100644 --- a/crates/bitwarden-crypto/src/signing/mod.rs +++ b/crates/bitwarden-crypto/src/signing/mod.rs @@ -29,6 +29,8 @@ mod cose; use cose::*; +mod hazmat; +use hazmat::*; mod namespace; pub use namespace::SigningNamespace; mod signed_object; @@ -54,6 +56,9 @@ use {tsify::Tsify, wasm_bindgen::prelude::*}; pub enum SignatureAlgorithm { /// Ed25519 is the modern, secure recommended option for digital signatures on eliptic curves. Ed25519, + /// MlDsa65 is a post-quantum secure signature scheme + #[cfg(feature = "post-quantum-crypto")] + MLDsa65, } impl SignatureAlgorithm { diff --git a/crates/bitwarden-crypto/src/signing/signing_key.rs b/crates/bitwarden-crypto/src/signing/signing_key.rs index 8a802d327..459091c01 100644 --- a/crates/bitwarden-crypto/src/signing/signing_key.rs +++ b/crates/bitwarden-crypto/src/signing/signing_key.rs @@ -6,6 +6,9 @@ use coset::{ iana::{Algorithm, EllipticCurve, EnumI64, KeyOperation, KeyType, OkpKeyParameter}, }; use ed25519_dalek::Signer; +#[cfg(feature = "post-quantum-crypto")] +use ml_dsa::{B32, KeyGen, MlDsa65}; +use rand_core::RngCore; use super::{ SignatureAlgorithm, ed25519_signing_key, key_id, @@ -24,6 +27,15 @@ use crate::{ #[derive(Clone)] enum RawSigningKey { Ed25519(Pin>), + #[cfg(feature = "post-quantum-crypto")] + // ML-DSA has two representations of the private key - the seed, and the expanded signing key. + // We store the seed here as it is always possible to go from seed to expanded private key + public key. + // other transitions are not possible. Further, the seed is used in cose to represent the private key, + // and cose does not allow storing the expanded signing key. + MLDsa65(Pin>), + // #[cfg(feature = "post-quantum-crypto")] + // Hybrid signature scheme combining ML-DSA-65 and Ed25519 + // MLDsa65Ed25519(Pin>), } /// A signing key is a private key used for signing data. An associated `VerifyingKey` can be @@ -54,12 +66,23 @@ impl SigningKey { &mut rand::rng(), ))), }, + #[cfg(feature = "post-quantum-crypto")] + SignatureAlgorithm::MLDsa65 => { + let mut seed = [0u8; 32]; + rand::rng().fill_bytes(&mut seed); + SigningKey { + id: KeyId::make(), + inner: RawSigningKey::MLDsa65(Box::pin(ml_dsa::B32::from(seed))), + } + } } } pub(super) fn cose_algorithm(&self) -> Algorithm { match &self.inner { RawSigningKey::Ed25519(_) => Algorithm::EdDSA, + #[cfg(feature = "post-quantum-crypto")] + RawSigningKey::MLDsa65(_) => Algorithm::ML_DSA_65, } } @@ -71,6 +94,13 @@ impl SigningKey { id: self.id.clone(), inner: RawVerifyingKey::Ed25519(key.verifying_key()), }, + #[cfg(feature = "post-quantum-crypto")] + RawSigningKey::MLDsa65(seed) => VerifyingKey { + id: self.id.clone(), + inner: RawVerifyingKey::MlDsa65( + MlDsa65::key_gen_internal(seed).verifying_key().to_owned(), + ), + }, } } @@ -80,6 +110,17 @@ impl SigningKey { pub(super) fn sign_raw(&self, data: &[u8]) -> Vec { match &self.inner { RawSigningKey::Ed25519(key) => key.sign(data).to_bytes().to_vec(), + #[cfg(feature = "post-quantum-crypto")] + RawSigningKey::MLDsa65(seed) => MlDsa65::key_gen_internal(seed) + // ctx is empty, the CTX is provided otherwise in the namespace of the signature message, to abstract + // away from the specific signature scheme + // note: TODO: replace with sind randomized when crates don't collide + .signing_key() + .sign_deterministic(data, &[]) + .expect("signing should not fail") + .encode() + .as_slice() + .to_vec(), } } } @@ -107,6 +148,42 @@ impl CoseSerializable for SigningKey { .expect("Signing key is always serializable") .into() } + #[cfg(feature = "post-quantum-crypto")] + RawSigningKey::MLDsa65(seed) => { + use crate::KEY_ID_SIZE; + use coset::{Label, iana::AkpKeyParameter}; + use std::collections::BTreeSet; + + CoseKey { + kty: RegisteredLabel::Assigned(KeyType::AKP), + key_id: Vec::from(Into::<[u8; KEY_ID_SIZE]>::into(self.id.clone())), + alg: Some(RegisteredLabelWithPrivate::Assigned(Algorithm::ML_DSA_65)), + base_iv: vec![], + key_ops: BTreeSet::from([ + RegisteredLabel::Assigned(KeyOperation::Sign), + RegisteredLabel::Assigned(KeyOperation::Verify), + ]), + params: vec![ + ( + Label::Int(AkpKeyParameter::Priv.to_i64()), + Value::Bytes(seed.as_ref().to_vec()), + ), + ( + Label::Int(AkpKeyParameter::Pub.to_i64()), + Value::Bytes( + MlDsa65::key_gen_internal(seed) + .verifying_key() + .encode() + .as_slice() + .to_vec(), + ), + ), + ], + } + .to_vec() + .expect("encoding should work") + .into() + } } } @@ -154,4 +231,19 @@ mod tests { .is_ok() ); } + + #[test] + fn test_sign_rountrip_mldsa65() { + #[cfg(feature = "post-quantum-crypto")] + { + let signing_key = SigningKey::make(SignatureAlgorithm::MLDsa65); + let signature = signing_key.sign_raw("Test message".as_bytes()); + let verifying_key = signing_key.to_verifying_key(); + assert!( + verifying_key + .verify_raw(&signature, "Test message".as_bytes()) + .is_ok() + ); + } + } } diff --git a/crates/bitwarden-crypto/src/signing/verifying_key.rs b/crates/bitwarden-crypto/src/signing/verifying_key.rs index 8f5736a13..6928ec1c6 100644 --- a/crates/bitwarden-crypto/src/signing/verifying_key.rs +++ b/crates/bitwarden-crypto/src/signing/verifying_key.rs @@ -3,11 +3,15 @@ //! This implements the lowest layer of the signature module, verifying signatures on raw byte //! arrays. +use std::pin::Pin; + use ciborium::{Value, value::Integer}; use coset::{ CborSerializable, RegisteredLabel, RegisteredLabelWithPrivate, iana::{Algorithm, EllipticCurve, EnumI64, KeyOperation, KeyType, OkpKeyParameter}, }; +#[cfg(feature = "post-quantum-crypto")] +use ml_dsa::{MlDsa65, VerifyingKey as _, signature::Verifier}; use super::{SignatureAlgorithm, ed25519_verifying_key, key_id}; use crate::{ @@ -22,6 +26,8 @@ use crate::{ /// scheme. pub(super) enum RawVerifyingKey { Ed25519(ed25519_dalek::VerifyingKey), + #[cfg(feature = "post-quantum-crypto")] + MlDsa65(ml_dsa::VerifyingKey), } /// A verifying key is a public key used for verifying signatures. It can be published to other @@ -37,6 +43,8 @@ impl VerifyingKey { pub fn algorithm(&self) -> SignatureAlgorithm { match &self.inner { RawVerifyingKey::Ed25519(_) => SignatureAlgorithm::Ed25519, + #[cfg(feature = "post-quantum-crypto")] + RawVerifyingKey::MlDsa65(_) => SignatureAlgorithm::MLDsa65, } } @@ -54,6 +62,16 @@ impl VerifyingKey { key.verify_strict(data, &sig) .map_err(|_| SignatureError::InvalidSignature.into()) } + #[cfg(feature = "post-quantum-crypto")] + RawVerifyingKey::MlDsa65(key) => { + let sig: ml_dsa::Signature = ml_dsa::Signature::from( + signature + .try_into() + .map_err(|_| SignatureError::InvalidSignature)?, + ); + key.verify(data, &sig) + .map_err(|_| SignatureError::InvalidSignature.into()) + } } } } @@ -81,6 +99,27 @@ impl CoseSerializable for VerifyingKey { .to_vec() .expect("Verifying key is always serializable") .into(), + #[cfg(feature = "post-quantum-crypto")] + RawVerifyingKey::MlDsa65(key) => { + use crate::KEY_ID_SIZE; + use coset::{Label, iana::AkpKeyParameter}; + use std::collections::BTreeSet; + + coset::CoseKey { + kty: RegisteredLabel::Assigned(KeyType::AKP), + key_id: Vec::from(Into::<[u8; KEY_ID_SIZE]>::into(self.id.clone())), + alg: Some(RegisteredLabelWithPrivate::Assigned(Algorithm::ML_DSA_65)), + base_iv: vec![], + key_ops: BTreeSet::from([RegisteredLabel::Assigned(KeyOperation::Verify)]), + params: vec![( + Label::Int(AkpKeyParameter::Pub.to_i64()), + Value::Bytes(key.encode().as_slice().to_vec()), + )], + } + .to_vec() + .expect("Verifying key is always serializable") + .into() + } } }