diff --git a/Cargo.toml b/Cargo.toml index 71a0aef..c2d4f8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "multi-key" -version = "1.0.6" +version = "1.0.7" edition = "2021" authors = ["Dave Grantham "] description = "Multikey self-describing cryptographic key data" @@ -20,25 +20,25 @@ wasm = ["getrandom/wasm_js"] legacy_chacha20_fallback = [] [dependencies] -aes-gcm = "0.10" -bcrypt-pbkdf = "0.10" +aes-gcm = "0.11" +bcrypt-pbkdf = "0.11" blake3 = { version = "1.5.1", features = ["traits-preview", "zeroize"] } ciborium = "0.2" curve25519-dalek = { version = "5.0.0", features = ["group"] } # blsful configured per-target below (blst for native, rust for wasm) frodo-kem-rs = { version = "0.7", default-features = false, features = ["frodo640aes", "frodo976aes", "frodo1344aes", "frodo640shake", "frodo976shake", "frodo1344shake"] } -chacha20 = "0.9" -chacha20poly1305 = "0.10" +chacha20 = "0.10" +chacha20poly1305 = "0.11" ed25519-dalek = { version = "3.0", features = ["pkcs8", "rand_core", "alloc"] } elliptic-curve = "0.14" fn-dsa = "0.3" getrandom = { version = "0.4", features = ["sys_rng"] } hex = "0.4" -hkdf = "0.12" +hkdf = "0.13" k256 = { version = "0.14", features = ["ecdh"] } mceliece348864 = { version = "1.0" } ml-dsa = "0.1.1" -ml-kem = { version = "0.2", features = ["deterministic"] } +ml-kem = "0.3" multi-base = "1.0" multi-codec = "1.0" multi-hash = "1.0" @@ -48,7 +48,7 @@ multi-util = "1.0" p256 = { version = "0.14", features = ["ecdsa", "ecdh"] } p384 = { version = "0.14", features = ["ecdsa", "ecdh"] } p521 = { version = "0.14", features = ["ecdsa", "ecdh"] } -poly1305 = "0.8" +poly1305 = "0.9" pq-mayo = "0.5.0" # RustCrypto 0.14 stack (vsss-rs 6, elliptic-curve 0.14, ed25519-dalek 3) is on # rand_core 0.10; pin the rand ecosystem to the matching majors so RNGs thread @@ -66,11 +66,12 @@ rsa = { version = "0.10.0-rc.18", features = ["sha2"] } sec1 = "0.8" serde = { version = "1.0", default-features = false, features = ["alloc", "derive"], optional = true } serde_cbor = "0.11" -sha2 = "0.10" +sha2 = "0.11" signature = "3" slh-dsa = "0.2.0-rc.5" sntrup = "0.3.0" ssh-encoding = "0.3" +subtle = "2" thiserror = { version = "2.0" } typenum = "1.17" unsigned-varint = { version = "0.8", features = ["std"] } diff --git a/src/error.rs b/src/error.rs index 0ff97df..cb821f2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 /// Errors created by this library +#[must_use] #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum Error { @@ -59,6 +60,14 @@ pub enum Error { /// Duplicate attribute error #[error("Duplicate Multikey attribute: {0}")] DuplicateAttribute(u8), + /// Attribute count exceeds the configured maximum + /// + /// Returned by [`crate::mk::Multikey::try_decode_from`] when the number of + /// attributes declared in the wire data exceeds + /// [`crate::mk::MAX_ATTRIBUTES`]. Bounds the work a crafted input can + /// force the decoder to perform and mitigates CWE-400. + #[error("attribute count {0} exceeds maximum {1}")] + TooManyAttributes(usize, usize), /// Incorrect Multikey sigil #[error("Missing Multikey sigil")] MissingSigil, @@ -288,6 +297,13 @@ pub enum SealError { /// Key derivation failed #[error("Key derivation failed: {0}")] KeyDerivationFailed(String), + /// RNG failure during sealing + /// + /// Returned when the OS RNG (`getrandom`) fails to produce randomness + /// (e.g. on constrained targets without entropy). Propagated from the + /// sealing path instead of panicking. + #[error("RNG failure: {0}")] + RngFailure(String), } /// Sign errors created by this library @@ -303,6 +319,13 @@ pub enum SignError { /// Missing scheme #[error("Missing signature scheme")] MissingScheme, + /// RNG failure during signing + /// + /// Returned when the OS RNG (`getrandom`) fails to produce randomness + /// (e.g. on constrained targets without entropy). Propagated from the + /// signing path instead of panicking. + #[error("RNG failure: {0}")] + RngFailure(String), } /// Threshold errors created by this library @@ -363,6 +386,7 @@ pub enum VerifyError { impl Error { /// Get the error kind as a string + #[must_use] pub fn kind(&self) -> &str { match self { Self::Attributes(_) => "Attributes", @@ -383,6 +407,7 @@ impl Error { Self::Multihash(_) => "Multihash", Self::Utf8(_) => "Utf8", Self::DuplicateAttribute(_) => "DuplicateAttribute", + Self::TooManyAttributes(_, _) => "TooManyAttributes", Self::MissingSigil => "MissingSigil", Self::UnsupportedAlgorithm(_) => "UnsupportedAlgorithm", } diff --git a/src/lib.rs b/src/lib.rs index ec0dc06..2cf894c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,6 +72,7 @@ #![warn(missing_docs)] #![deny( + unsafe_code, trivial_casts, trivial_numeric_casts, unused_import_braces, @@ -116,7 +117,7 @@ pub mod keysplit; pub mod mk; pub use mk::{ Builder, EncodedMultikey, Multikey, FN_DSA_KEY_CODECS, KEY_CODECS, KEY_SHARE_CODECS, - ML_DSA_KEY_CODECS, ML_KEM_KEY_CODECS, SLH_DSA_KEY_CODECS, X25519_KEY_CODECS, + MAX_ATTRIBUTES, ML_DSA_KEY_CODECS, ML_KEM_KEY_CODECS, SLH_DSA_KEY_CODECS, X25519_KEY_CODECS, }; /// Nonce type diff --git a/src/mk.rs b/src/mk.rs index 3d9cfb4..3781e0e 100644 --- a/src/mk.rs +++ b/src/mk.rs @@ -30,6 +30,7 @@ use ssh_key::{ EcdsaCurve, PrivateKey, PublicKey, }; use std::{collections::BTreeMap, fmt}; +use subtle::ConstantTimeEq; use zeroize::Zeroizing; /// the list of key codecs supported for key generation @@ -128,6 +129,14 @@ pub const KEY_SHARE_CODECS: [Codec; 4] = [ /// the multicodec sigil for multikey pub const SIGIL: Codec = Codec::Multikey; +/// Maximum number of attributes a single decoded [`Multikey`] will accept. +/// +/// Every legitimate multikey carries at most a handful of attributes (key +/// data, threshold metadata, cipher info, …). The 256 ceiling comfortably +/// covers every codec this crate emits while bounding the work a crafted +/// input can force the decoder to perform (mitigates CWE-400). +pub const MAX_ATTRIBUTES: usize = 256; + /// A base encoded Multikey structure pub type EncodedMultikey = BaseEncoded; @@ -140,6 +149,17 @@ pub struct Multikey { /// key codec pub(crate) codec: Codec, /// the comment for the key + /// + /// # Zeroization + /// + /// The comment is **not** zeroized on drop. It is stored as a plain + /// [`String`] so it can be serialized, compared, and surfaced in SSH/key + /// output without the deref-coercion friction a [`Zeroizing`] + /// wrapper would introduce across the ~120 call sites that read it. If a + /// caller places sensitive material in the comment, they must zeroize that + /// material themselves before it leaves scope; the key *material* (in + /// `attributes`) is wrapped in [`Zeroizing`] and is zeroized on drop. See + /// the security audit finding R6 for the rationale. pub comment: String, /// codec-specific attributes, sorted by key pub attributes: Attributes, @@ -169,6 +189,26 @@ impl EncodingInfo for Multikey { } } +/// Constant-time equality for [`Multikey`] (R7). +/// +/// The derived [`PartialEq`] short-circuits on the first differing byte, +/// which leaks timing information when key material (or metadata derived +/// from it) is compared in a security-sensitive context. This +/// [`ConstantTimeEq`] impl compares the canonical wire encoding +/// (`sigil || codec || comment || attributes`) in constant time so callers +/// that need a side-channel-resistant comparison (e.g. comparing two +/// fingerprints or two secret keys) can use `mk1.ct_eq(&mk2)` instead of +/// `mk1 == mk2`. The wire encoding is what gets hashed into a fingerprint, +/// so comparing it is equivalent to comparing the canonical form. +impl ConstantTimeEq for Multikey { + fn ct_eq(&self, other: &Self) -> subtle::Choice { + // Compare the canonical wire encoding byte-for-byte in constant time. + let a: Vec = self.clone().into(); + let b: Vec = other.clone().into(); + a.ct_eq(&b) + } +} + impl From for Vec { fn from(mk: Multikey) -> Self { let mut v = Vec::default(); @@ -214,6 +254,11 @@ impl<'a> TryDecodeFrom<'a> for Multikey { let comment = String::from_utf8(comment.to_inner())?; // decode the number of codec-specific attributes let (num_attr, ptr) = Varuint::::try_decode_from(ptr)?; + // reject attribute counts that exceed the configured maximum to bound + // the work a crafted input can force the decoder to perform (CWE-400) + if *num_attr > MAX_ATTRIBUTES { + return Err(Error::TooManyAttributes(*num_attr, MAX_ATTRIBUTES)); + } // decode the codec-specific values let (attributes, ptr) = match *num_attr { 0 => (Attributes::default(), ptr), @@ -1442,7 +1487,11 @@ impl Builder { out } Codec::Rsa2048Priv => { - let key = ::rsa::RsaPrivateKey::new(&mut rsa::OsRng, 2048) + // Probe the OS RNG up front so an entropy failure surfaces as an + // error instead of a panic inside UnwrapErr during keygen. + rsa::OsRng::check_entropy() + .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))?; + let key = ::rsa::RsaPrivateKey::new(&mut rand_core::UnwrapErr(rsa::OsRng), 2048) .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))?; ::rsa::pkcs1::EncodeRsaPrivateKey::to_pkcs1_der(&key) .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))? @@ -1450,7 +1499,9 @@ impl Builder { .to_vec() } Codec::Rsa3072Priv => { - let key = ::rsa::RsaPrivateKey::new(&mut rsa::OsRng, 3072) + rsa::OsRng::check_entropy() + .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))?; + let key = ::rsa::RsaPrivateKey::new(&mut rand_core::UnwrapErr(rsa::OsRng), 3072) .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))?; ::rsa::pkcs1::EncodeRsaPrivateKey::to_pkcs1_der(&key) .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))? @@ -1458,7 +1509,9 @@ impl Builder { .to_vec() } Codec::Rsa4096Priv => { - let key = ::rsa::RsaPrivateKey::new(&mut rsa::OsRng, 4096) + rsa::OsRng::check_entropy() + .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))?; + let key = ::rsa::RsaPrivateKey::new(&mut rand_core::UnwrapErr(rsa::OsRng), 4096) .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))?; ::rsa::pkcs1::EncodeRsaPrivateKey::to_pkcs1_der(&key) .map_err(|e| ConversionsError::SecretKeyFailure(e.to_string()))? diff --git a/src/views/bls12381.rs b/src/views/bls12381.rs index 0d5b9cf..b0c4db7 100644 --- a/src/views/bls12381.rs +++ b/src/views/bls12381.rs @@ -165,7 +165,9 @@ impl<'a> TryDecodeFrom<'a> for ThresholdData { let mut p = ptr; for _ in 0..*num_shares { let (share, ptr) = KeyShare::try_decode_from(p)?; - shares.insert(share.0, share); + if shares.insert(share.0, share).is_some() { + return Err(ThresholdError::DuplicateShare.into()); + } p = ptr; } (shares, p) @@ -923,15 +925,20 @@ impl<'a> ThresholdView for View<'a> { let threshold_data: Vec = { let av = self.mk.threshold_attr_view()?; let mut tdata = match av.threshold_data() { - Ok(b) => ThresholdData::try_from(b).unwrap_or_default(), + Ok(b) => ThresholdData::try_from(b) + .map_err(|e| ThresholdError::ShareCombineFailed(e.to_string()))?, Err(_) => ThresholdData::default(), }; + // detect a duplicate share identifier and refuse to silently overwrite it + if tdata.0.contains_key(&identifier) { + return Err(ThresholdError::DuplicateShare.into()); + } // insert the share data tdata.0.insert(identifier, key_share); tdata.into() }; - // if this multikey doesn't already have the threshold/limi set, then + // if this multikey doesn't already have the threshold/limit set, then // set it to match the values from the first share let av = share.threshold_attr_view()?; let threshold = av.threshold().unwrap_or(threshold); @@ -939,7 +946,7 @@ impl<'a> ThresholdView for View<'a> { let comment = if self.mk.comment.is_empty() { share.comment.clone() } else { - String::default() + self.mk.comment.clone() }; Builder::new(self.mk.codec) @@ -957,7 +964,8 @@ impl<'a> ThresholdView for View<'a> { let av = self.mk.threshold_attr_view()?; ( match av.threshold_data() { - Ok(b) => ThresholdData::try_from(b).unwrap_or_default(), + Ok(b) => ThresholdData::try_from(b) + .map_err(|e| ThresholdError::ShareCombineFailed(e.to_string()))?, Err(_) => ThresholdData::default(), }, av.threshold()?, @@ -1147,6 +1155,17 @@ impl<'a> ThresholdView for View<'a> { impl<'a> VerifyView for View<'a> { /// try to verify a Multisig using the Multikey + /// + /// # Curve selection (M1) + /// + /// The BLS curve (G1 vs G2) is selected by the multikey codec tag + /// (`Bls12381G1Pub` / `Bls12381G2Pub` / `*Share`), not by the signature + /// byte length. The 48-byte (G1 point) and 96-byte (G2 point) lengths are + /// used only to slice the canonical signature into the correct group + /// encoding for the curve already chosen by the codec. A 48-byte G2 or + /// 96-byte G1 signature is therefore rejected with a deserialization + /// error rather than being misverified on the wrong curve. This avoids the + /// downgrade heuristic described in the security audit (M1). fn verify(&self, multisig: &Multisig, msg: Option<&[u8]>) -> Result<(), Error> { let attr = self.mk.attr_view()?; let pubmk = if attr.is_secret_key() { diff --git a/src/views/dkg_threshold.rs b/src/views/dkg_threshold.rs index 691c423..5d20164 100644 --- a/src/views/dkg_threshold.rs +++ b/src/views/dkg_threshold.rs @@ -13,6 +13,8 @@ use crate::error::AttributesError; use crate::Error; use crate::{AttrId, AttrView, Multikey, ThresholdAttrView, ThresholdKeyView}; +use multi_trait::TryDecodeFrom; +use multi_util::Varuint; /// Read-only DKG threshold view over a [`Multikey`]. pub(crate) struct View<'a> { @@ -29,9 +31,16 @@ impl<'a> TryFrom<&'a Multikey> for View<'a> { impl<'a> AttrView for View<'a> { fn is_encrypted(&self) -> bool { - // Encryption status is recorded on the multikey itself, not on the view; - // a DKG share is conceptually a secret-bearing share, so report false - // here and let the multikey's own AttrView impl be authoritative. + // A DKG share may itself be encrypted (the `KeyIsEncrypted` attribute is + // stamped on the multikey). Earlier revisions hardcoded `false`, which + // meant callers using `threshold_attr_view()` alone saw an unencrypted + // share even when it was sealed. Read the attribute directly so the + // status is authoritative regardless of which view the caller holds. + if let Some(v) = self.mk.attributes.get(&AttrId::KeyIsEncrypted) { + if let Ok((b, _)) = Varuint::::try_decode_from(v.as_slice()) { + return b.to_inner(); + } + } false } @@ -40,7 +49,11 @@ impl<'a> AttrView for View<'a> { } fn is_secret_key(&self) -> bool { - false + // A DKG private share is a secret-bearing key share; treat it as a + // secret key for status purposes so callers gating on `is_secret_key()` + // (e.g. before signing or exporting) do not silently treat a + // secret share as public. + true } fn is_secret_key_share(&self) -> bool { diff --git a/src/views/mayo.rs b/src/views/mayo.rs index a63fe6a..68367b6 100644 --- a/src/views/mayo.rs +++ b/src/views/mayo.rs @@ -235,14 +235,18 @@ impl<'a> ConvView for View<'a> { impl<'a> FingerprintView for View<'a> { fn fingerprint(&self, codec: Codec) -> Result { - if self.is_secret_key() { - return Err(ConversionsError::SecretKeyFailure( - "MAYO public key derivation not yet implemented".into(), - ) - .into()); - } - let bytes = self.key_bytes()?; - Ok(mh::Builder::new_from_bytes(codec, bytes.as_slice())?.try_build()?) + let pub_bytes = if self.is_secret_key() { + // Derive the public key and fingerprint that, mirroring the + // Ed25519 / BLS / SLH-DSA views. Earlier revisions returned an + // "not yet implemented" error here even though `to_public_key()` + // was implemented, which broke fingerprinting of secret keys. + let pk = self.mk.conv_view()?.to_public_key()?; + let dv = pk.data_view()?; + dv.key_bytes()? + } else { + self.key_bytes()? + }; + Ok(mh::Builder::new_from_bytes(codec, pub_bytes.as_slice())?.try_build()?) } } diff --git a/src/views/ml_dsa.rs b/src/views/ml_dsa.rs index dcea5e4..a16fafa 100644 --- a/src/views/ml_dsa.rs +++ b/src/views/ml_dsa.rs @@ -220,14 +220,18 @@ impl<'a> ConvView for View<'a> { impl<'a> FingerprintView for View<'a> { fn fingerprint(&self, codec: Codec) -> Result { - if self.is_secret_key() { - return Err(ConversionsError::SecretKeyFailure( - "ML-DSA public key derivation not yet implemented".into(), - ) - .into()); - } - let bytes = self.key_bytes()?; - Ok(mh::Builder::new_from_bytes(codec, bytes.as_slice())?.try_build()?) + let pub_bytes = if self.is_secret_key() { + // Derive the public key and fingerprint that, mirroring the + // Ed25519 / BLS / SLH-DSA views. Earlier revisions returned an + // "not yet implemented" error here even though `to_public_key()` + // was implemented, which broke fingerprinting of secret keys. + let pk = self.mk.conv_view()?.to_public_key()?; + let dv = pk.data_view()?; + dv.key_bytes()? + } else { + self.key_bytes()? + }; + Ok(mh::Builder::new_from_bytes(codec, pub_bytes.as_slice())?.try_build()?) } } diff --git a/src/views/ml_kem.rs b/src/views/ml_kem.rs index ac7e2e0..3c64398 100644 --- a/src/views/ml_kem.rs +++ b/src/views/ml_kem.rs @@ -8,8 +8,8 @@ use crate::{ SealView, }; use ml_kem::{ - kem::{Decapsulate, Encapsulate}, - EncodedSizeUser, KemCore, MlKem1024, MlKem768, + kem::{Decapsulate, Encapsulate, FromSeed, Kem, KeyExport}, + MlKem1024, MlKem768, }; use multi_codec::Codec; use multi_hash::{mh, Multihash}; @@ -93,21 +93,19 @@ impl<'a> ConvView for View<'a> { return Err(ConversionsError::SecretKeyFailure("invalid seed length".into()).into()); } - let d: [u8; 32] = secret_bytes[..32] + let seed: [u8; 64] = secret_bytes + .as_slice() .try_into() - .map_err(|_| ConversionsError::SecretKeyFailure("invalid seed".into()))?; - let z: [u8; 32] = secret_bytes[32..64] - .try_into() - .map_err(|_| ConversionsError::SecretKeyFailure("invalid seed".into()))?; + .map_err(|_| ConversionsError::SecretKeyFailure("invalid seed length".into()))?; let (public_key, codec) = match self.mk.codec { Codec::Mlkem768Priv => { - let (_dk, ek) = MlKem768::generate_deterministic(&d.into(), &z.into()); - (ek.as_bytes().to_vec(), Codec::Mlkem768Pub) + let (_dk, ek) = MlKem768::from_seed(&seed.into()); + (ek.to_bytes().to_vec(), Codec::Mlkem768Pub) } Codec::Mlkem1024Priv => { - let (_dk, ek) = MlKem1024::generate_deterministic(&d.into(), &z.into()); - (ek.as_bytes().to_vec(), Codec::Mlkem1024Pub) + let (_dk, ek) = MlKem1024::from_seed(&seed.into()); + (ek.to_bytes().to_vec(), Codec::Mlkem1024Pub) } _ => { return Err( @@ -192,34 +190,33 @@ impl<'a> SealView for View<'a> { } let pub_bytes = self.key_bytes()?; - let mut rng = rand_core_06::OsRng; - - let (kem_ct, shared_secret) = - match self.mk.codec { - Codec::Mlkem768Pub => { - let ek = ::EncapsulationKey::from_bytes( - pub_bytes.as_slice().try_into().map_err(|_| { - SealError::EncapsulationFailed("invalid key size".into()) - })?, - ); - let (ct, ss) = ek.encapsulate(&mut rng).map_err(|_| { - SealError::EncapsulationFailed("encapsulation failed".into()) - })?; - (ct.to_vec(), ss.to_vec()) - } - Codec::Mlkem1024Pub => { - let ek = ::EncapsulationKey::from_bytes( - pub_bytes.as_slice().try_into().map_err(|_| { - SealError::EncapsulationFailed("invalid key size".into()) - })?, - ); - let (ct, ss) = ek.encapsulate(&mut rng).map_err(|_| { - SealError::EncapsulationFailed("encapsulation failed".into()) - })?; - (ct.to_vec(), ss.to_vec()) - } - _ => return Err(SealError::NotEncapsulationKey.into()), - }; + let mut rng = rand::rng(); + + let (kem_ct, shared_secret) = match self.mk.codec { + Codec::Mlkem768Pub => { + let ek = ::EncapsulationKey::new( + pub_bytes + .as_slice() + .try_into() + .map_err(|_| SealError::EncapsulationFailed("invalid key size".into()))?, + ) + .map_err(|_| SealError::EncapsulationFailed("invalid encapsulation key".into()))?; + let (ct, ss) = ek.encapsulate_with_rng(&mut rng); + (ct.to_vec(), ss.to_vec()) + } + Codec::Mlkem1024Pub => { + let ek = ::EncapsulationKey::new( + pub_bytes + .as_slice() + .try_into() + .map_err(|_| SealError::EncapsulationFailed("invalid key size".into()))?, + ) + .map_err(|_| SealError::EncapsulationFailed("invalid encapsulation key".into()))?; + let (ct, ss) = ek.encapsulate_with_rng(&mut rng); + (ct.to_vec(), ss.to_vec()) + } + _ => return Err(SealError::NotEncapsulationKey.into()), + }; // Derive AEAD key from shared secret via HKDF let key_len = aead::key_size(aead_codec)?; @@ -258,32 +255,26 @@ impl<'a> OpenView for View<'a> { return Err(ConversionsError::SecretKeyFailure("invalid seed length".into()).into()); } - let d: [u8; 32] = secret_bytes[..32] - .try_into() - .map_err(|_| ConversionsError::SecretKeyFailure("invalid seed".into()))?; - let z: [u8; 32] = secret_bytes[32..64] + let seed: [u8; 64] = secret_bytes + .as_slice() .try_into() - .map_err(|_| ConversionsError::SecretKeyFailure("invalid seed".into()))?; + .map_err(|_| ConversionsError::SecretKeyFailure("invalid seed length".into()))?; let shared_secret = match self.mk.codec { Codec::Mlkem768Priv => { - let (dk, _ek) = MlKem768::generate_deterministic(&d.into(), &z.into()); + let (dk, _ek) = MlKem768::from_seed(&seed.into()); let ct = kem_ct.as_slice().try_into().map_err(|_| { SealError::DecapsulationFailed("invalid ciphertext size".into()) })?; - let ss = dk - .decapsulate(&ct) - .map_err(|_| SealError::DecapsulationFailed("decapsulation failed".into()))?; + let ss = dk.decapsulate(&ct); ss.to_vec() } Codec::Mlkem1024Priv => { - let (dk, _ek) = MlKem1024::generate_deterministic(&d.into(), &z.into()); + let (dk, _ek) = MlKem1024::from_seed(&seed.into()); let ct = kem_ct.as_slice().try_into().map_err(|_| { SealError::DecapsulationFailed("invalid ciphertext size".into()) })?; - let ss = dk - .decapsulate(&ct) - .map_err(|_| SealError::DecapsulationFailed("decapsulation failed".into()))?; + let ss = dk.decapsulate(&ct); ss.to_vec() } _ => return Err(SealError::NotDecapsulationKey.into()), diff --git a/src/views/rsa.rs b/src/views/rsa.rs index 0f861e7..2a803d5 100644 --- a/src/views/rsa.rs +++ b/src/views/rsa.rs @@ -21,34 +21,69 @@ use multi_sig::{ms, Multisig, Views as SigViews}; use multi_trait::TryDecodeFrom; use multi_util::{Varbytes, Varuint}; use ssh_encoding::{Decode, Encode}; +use std::fmt; use zeroize::Zeroizing; -/// OsRng compatible with rsa 0.10 (rand_core 0.10) using getrandom 0.4 +/// Error returned by [`OsRng`] when [`getrandom`] fails to produce entropy. +/// +/// `OsRng` implements [`rsa::rand_core::TryRng`] with this as its +/// [`TryRng::Error`](rsa::rand_core::TryRng::Error) so that RNG failures are +/// surfaced through the `Result` returned by the rsa signing/encryption APIs +/// instead of panicking. +#[derive(Debug)] +pub(crate) struct RngError(pub String); + +impl fmt::Display for RngError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "getrandom: {}", self.0) + } +} + +impl std::error::Error for RngError {} + +/// OsRng compatible with rsa 0.10 (rand_core 0.10) using getrandom 0.4. +/// +/// Unlike an `Infallible` RNG, this propagates [`getrandom`] failures via +/// [`RngError`] so callers (e.g. [`SignView::sign`], [`SealView::seal`]) +/// can convert them into [`SignError::RngFailure`] / [`SealError::RngFailure`] +/// instead of panicking. pub(crate) struct OsRng; impl ::rsa::rand_core::TryRng for OsRng { - type Error = core::convert::Infallible; + type Error = RngError; fn try_next_u32(&mut self) -> Result { let mut buf = [0u8; 4]; - getrandom::fill(&mut buf).expect("getrandom failed"); + getrandom::fill(&mut buf).map_err(|e| RngError(e.to_string()))?; Ok(u32::from_le_bytes(buf)) } fn try_next_u64(&mut self) -> Result { let mut buf = [0u8; 8]; - getrandom::fill(&mut buf).expect("getrandom failed"); + getrandom::fill(&mut buf).map_err(|e| RngError(e.to_string()))?; Ok(u64::from_le_bytes(buf)) } fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Self::Error> { - getrandom::fill(dest).expect("getrandom failed"); + getrandom::fill(dest).map_err(|e| RngError(e.to_string()))?; Ok(()) } } impl ::rsa::rand_core::TryCryptoRng for OsRng {} +impl OsRng { + /// Probe the OS RNG with a single byte so callers that need an infallible + /// [`rsa::rand_core::CryptoRng`] (e.g. [`rsa::RsaPrivateKey::new`]) can + /// detect an entropy failure up front and surface it as an error instead + /// of panicking inside [`rand_core::UnwrapErr`]. + pub(crate) fn check_entropy() -> Result<(), RngError> { + let mut probe = [0u8; 1]; + getrandom::fill(&mut probe).map_err(|e| RngError(e.to_string()))?; + Ok(()) + } +} + fn pub_codec(codec: Codec) -> Codec { match codec { Codec::Rsa2048Pub | Codec::Rsa2048Priv => Codec::Rsa2048Pub, @@ -317,7 +352,21 @@ impl<'a> SignView for View<'a> { let signing_key = pss::SigningKey::::new(private_key); let signature = signing_key .try_sign_with_rng(&mut OsRng, msg) - .map_err(|e| SignError::SigningFailed(e.to_string()))?; + .map_err(|e| { + // Distinguish an RNG failure (e.g. no entropy on a constrained + // target) from a signing failure by inspecting the error source + // chain. RNG failures become SignError::RngFailure; everything + // else stays SigningFailed. + let mut source: Option<&(dyn std::error::Error + 'static)> = + std::error::Error::source(&e); + while let Some(s) = source { + if s.is::() { + return SignError::RngFailure(s.to_string()); + } + source = s.source(); + } + SignError::SigningFailed(e.to_string()) + })?; use ::rsa::signature::SignatureEncoding; let sig_bytes = signature.to_bytes(); @@ -434,13 +483,22 @@ impl<'a> SealView for View<'a> { // Generate random AEAD key let key_len = aead::key_size(aead_codec)?; let mut aead_key = vec![0u8; key_len]; - use ::rsa::rand_core::Rng; - OsRng.fill_bytes(&mut aead_key); - - // Encrypt AEAD key with RSA-OAEP + use ::rsa::rand_core::TryRng; + OsRng + .try_fill_bytes(&mut aead_key) + .map_err(|e| SealError::RngFailure(e.to_string()))?; + + // Encrypt AEAD key with RSA-OAEP. `RsaPublicKey::encrypt` requires an + // infallible `CryptoRng`; probe the OS RNG first so an entropy failure + // surfaces as SealError::RngFailure instead of a panic in UnwrapErr. + OsRng::check_entropy().map_err(|e| SealError::RngFailure(e.to_string()))?; let padding = Oaep::::new(); let rsa_ct = public_key - .encrypt(&mut OsRng, padding, &aead_key) + .encrypt( + &mut ::rsa::rand_core::UnwrapErr(&mut OsRng), + padding, + &aead_key, + ) .map_err(|e| SealError::EncapsulationFailed(e.to_string()))?; // Encrypt plaintext with AEAD diff --git a/src/views/threshold_meta.rs b/src/views/threshold_meta.rs index 278580d..501dfaa 100644 --- a/src/views/threshold_meta.rs +++ b/src/views/threshold_meta.rs @@ -1,8 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 //! Threshold disclosure modes and encrypted metadata helpers. //! -//! This module provides the types and functions for configurable confidentiality -//! of threshold `t` and share-count `n` values on key shares. +//! This module re-exports the pure crypto types and functions that now live in +//! `multi-sig` (so that `multi-sig` no longer depends on `multi-key`, breaking +//! the former circular dependency), and provides `Multikey`-specific wrappers +//! that extract the raw 32-byte symmetric key from a `Multikey` before +//! delegating to the `multi_sig` implementations. //! //! Three disclosure modes are supported: //! @@ -10,297 +13,27 @@ //! - **[`ThresholdDisclosure::Partial`]** — n is plaintext, t is encrypted. //! - **[`ThresholdDisclosure::FullConfidentialial`]** — both t and n are encrypted. -use crate::{AttrId, Error, Multikey, Views}; -use chacha20poly1305::{ - aead::{Aead, KeyInit, Payload}, - ChaCha20Poly1305, Nonce, +// Re-export the pure crypto types/functions owned by `multi_sig` so that the +// historical `multi_key::ThresholdDisclosure` / `multi_key::encrypt_threshold_meta` +// public API keeps working for downstream callers. +pub use multi_sig::{ + decrypt_threshold_meta, encrypt_threshold_meta, generate_meta_key, ThresholdDisclosure, + ThresholdMetaCipher, ThresholdMetadata, +}; + +use crate::{ + error::{AttributesError, ThresholdError}, + mk::Attributes, + AttrId, Error, Multikey, Views, }; -use multi_codec::Codec; use multi_trait::{EncodeInto, TryDecodeFrom}; use multi_util::Varuint; -use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; -/// Disclosure mode for threshold parameters (t and n). -#[repr(u8)] -#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] -#[non_exhaustive] -pub enum ThresholdDisclosure { - /// t and n are plaintext attributes (default, backward-compatible). - #[default] - Full = 0, - /// n is plaintext, t is encrypted (auditable n, hidden t). - Partial = 1, - /// Both t and n are encrypted. - FullConfidentialial = 2, -} - -impl ThresholdDisclosure { - /// Get the human-readable name for this mode. - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - Self::Full => "full", - Self::Partial => "partial", - Self::FullConfidentialial => "full-confidentialial", - } - } -} - -impl core::fmt::Display for ThresholdDisclosure { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "{}", self.as_str()) - } -} - -impl From for u8 { - fn from(val: ThresholdDisclosure) -> Self { - val as u8 - } -} - -impl TryFrom for ThresholdDisclosure { - type Error = Error; - - fn try_from(code: u8) -> Result { - match code { - 0 => Ok(Self::Full), - 1 => Ok(Self::Partial), - 2 => Ok(Self::FullConfidentialial), - _ => Err(Error::Threshold(ThresholdError::MetaEncryption(format!( - "invalid disclosure mode: {code}" - )))), - } - } -} - -impl EncodeInto for ThresholdDisclosure { - fn encode_into(&self) -> Vec { - let v: u8 = (*self).into(); - v.encode_into() - } -} - -impl<'a> TryDecodeFrom<'a> for ThresholdDisclosure { - type Error = Error; - - fn try_decode_from(bytes: &'a [u8]) -> Result<(Self, &'a [u8]), Self::Error> { - let (code, ptr) = u8::try_decode_from(bytes).map_err(Error::Multitrait)?; - let mode = Self::try_from(code)?; - Ok((mode, ptr)) - } -} - -impl Serialize for ThresholdDisclosure { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_u8((*self).into()) - } -} - -impl<'de> Deserialize<'de> for ThresholdDisclosure { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let code = u8::deserialize(deserializer)?; - Self::try_from(code).map_err(serde::de::Error::custom) - } -} - -/// The threshold parameters that may be encrypted. -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct ThresholdMetadata { - /// The threshold value `t`, or `None` if not stored in the encrypted blob. - pub threshold: Option, - /// The limit value `n`, or `None` if not stored in the encrypted blob. - pub limit: Option, -} - -impl ThresholdMetadata { - /// Create metadata with both t and n. - #[must_use] - pub fn new(threshold: u16, limit: u16) -> Self { - Self { - threshold: Some(threshold), - limit: Some(limit), - } - } - - /// Create metadata with only the threshold (for Partial mode). - #[must_use] - pub fn threshold_only(threshold: u16) -> Self { - Self { - threshold: Some(threshold), - limit: None, - } - } - - /// Encode to CBOR bytes. - pub fn to_cbor_bytes(&self) -> Result, Error> { - let mut buf = Vec::new(); - ciborium::into_writer(self, &mut buf).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!("CBOR encode: {e}"))) - })?; - Ok(buf) - } - - /// Decode from CBOR bytes. - pub fn from_cbor_bytes(bytes: &[u8]) -> Result { - ciborium::from_reader(bytes).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!("CBOR decode: {e}"))) - }) - } -} - -/// Cipher parameters for decrypting [`ThresholdMetadata`]. -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -pub struct ThresholdMetaCipher { - /// The multicodec code of the AEAD cipher (e.g. `0x2000` for ChaCha20-Poly1305). - pub cipher_codec: u64, - /// The nonce bytes. - pub nonce: Vec, -} - -impl ThresholdMetaCipher { - /// Create new cipher info from a codec and nonce. - #[must_use] - pub fn new(codec: Codec, nonce: Vec) -> Self { - Self { - cipher_codec: codec.into(), - nonce, - } - } - - /// Get the codec as a [`Codec`]. - pub fn codec(&self) -> Result { - Codec::try_from(self.cipher_codec).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!( - "invalid codec: {e}" - ))) - }) - } - - /// Encode to CBOR bytes. - pub fn to_cbor_bytes(&self) -> Result, Error> { - let mut buf = Vec::new(); - ciborium::into_writer(self, &mut buf).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!( - "CBOR encode cipher: {e}" - ))) - })?; - Ok(buf) - } - - /// Decode from CBOR bytes. - pub fn from_cbor_bytes(bytes: &[u8]) -> Result { - ciborium::from_reader(bytes).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!( - "CBOR decode cipher: {e}" - ))) - }) - } -} - -/// Encrypt threshold metadata using ChaCha20-Poly1305 AEAD. -/// -/// `key` must be 32 bytes. A random nonce is generated and returned in the -/// [`ThresholdMetaCipher`]. -#[allow(deprecated)] -pub fn encrypt_threshold_meta( - meta: &ThresholdMetadata, - key: &[u8], -) -> Result<(Vec, ThresholdMetaCipher), Error> { - if key.len() != 32 { - return Err(Error::Threshold(ThresholdError::MetaEncryption(format!( - "invalid key length: expected 32, got {}", - key.len() - )))); - } - - let plaintext = meta.to_cbor_bytes()?; - - let mut nonce_bytes = vec![0u8; 12]; - getrandom::fill(&mut nonce_bytes).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!("RNG failure: {e}"))) - })?; - - let cipher = ChaCha20Poly1305::new_from_slice(key).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!( - "AEAD key init: {e}" - ))) - })?; - - let ciphertext = cipher - .encrypt( - Nonce::from_slice(&nonce_bytes), - Payload { - msg: &plaintext, - aad: b"threshold-meta", - }, - ) - .map_err(|e| Error::Threshold(ThresholdError::MetaEncryption(format!("AEAD seal: {e}"))))?; - - let cipher_info = ThresholdMetaCipher::new(Codec::Chacha20Poly1305, nonce_bytes); - Ok((ciphertext, cipher_info)) -} - -/// Decrypt threshold metadata using ChaCha20-Poly1305 AEAD. -#[allow(deprecated)] -pub fn decrypt_threshold_meta( - encrypted: &[u8], - cipher_info: &ThresholdMetaCipher, - key: &[u8], -) -> Result { - if key.len() != 32 { - return Err(Error::Threshold(ThresholdError::MetaEncryption(format!( - "invalid key length: expected 32, got {}", - key.len() - )))); - } - - let codec = cipher_info.codec()?; - match codec { - Codec::Chacha20Poly1305 => { - let cipher = ChaCha20Poly1305::new_from_slice(key).map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!( - "AEAD key init: {e}" - ))) - })?; - - let plaintext = cipher - .decrypt( - Nonce::from_slice(&cipher_info.nonce), - Payload { - msg: encrypted, - aad: b"threshold-meta", - }, - ) - .map_err(|e| { - Error::Threshold(ThresholdError::MetaEncryption(format!("AEAD open: {e}"))) - })?; - - ThresholdMetadata::from_cbor_bytes(&plaintext) - } - _ => Err(Error::Threshold(ThresholdError::MetaEncryption(format!( - "unsupported AEAD codec: {codec}" - )))), - } -} - -/// Generate a random 32-byte ChaCha20-Poly1305 key. -pub fn generate_meta_key() -> Zeroizing> { - let mut key = Zeroizing::new(vec![0u8; 32]); - getrandom::fill(key.as_mut_slice()).expect("getrandom failure during meta key generation"); - key -} - -/// Read the raw 32-byte key from a `Multikey` that contains a symmetric +/// Read the raw 32-byte symmetric key from a `Multikey` that wraps a symmetric /// cipher key (e.g. a ChaCha20-Poly1305 key Multikey). This bridges the /// `Multikey` at-rest encryption infrastructure to the threshold metadata -/// encryption. +/// encryption helpers in `multi_sig`. fn extract_meta_key(meta_key: &Multikey) -> Result>, Error> { let dv = meta_key.data_view()?; let key = dv.key_bytes()?; @@ -396,6 +129,9 @@ pub fn read_threshold_params( ))? as usize; Ok((t, n)) } + _ => Err(Error::Threshold(ThresholdError::MetaEncryption(format!( + "unsupported disclosure mode: {mode}" + )))), } } @@ -404,14 +140,12 @@ pub fn read_threshold_params( /// This is the single place where the attribute-stamping logic lives, shared /// between `Builder::with_disclosure()` and `to_disclosure()`. pub fn stamp_disclosure_attrs( - attributes: &mut crate::mk::Attributes, + attributes: &mut Attributes, mode: ThresholdDisclosure, threshold: usize, limit: usize, meta_key: Option<&Multikey>, ) -> Result<(), Error> { - use crate::error::ThresholdError; - // remove old disclosure-related attributes attributes.remove(&AttrId::Threshold); attributes.remove(&AttrId::Limit); @@ -468,6 +202,11 @@ pub fn stamp_disclosure_attrs( Zeroizing::new(mode.encode_into()), ); } + _ => { + return Err(Error::Threshold(ThresholdError::MetaEncryption(format!( + "unsupported disclosure mode: {mode}" + )))) + } } Ok(()) } @@ -534,8 +273,6 @@ impl<'a> ThresholdDisclosureView for DisclosureView<'a> { } } -use crate::error::{AttributesError, ThresholdError}; - #[cfg(test)] mod tests { use super::*; diff --git a/src/views/x25519_frodokem640.rs b/src/views/x25519_frodokem640.rs index b0efcc6..39bdd0a 100644 --- a/src/views/x25519_frodokem640.rs +++ b/src/views/x25519_frodokem640.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! X25519-FrodoKEM-640 hybrid KEM multikey view (X-Wing-like); combines X25519 ECDH -//! with FrodoKEM-640 (AES or SHAKE), ChaCha20-Poly1305 AEAD, and BLAKE3 KDF. +//! with FrodoKEM-640 (AES or SHAKE), ChaCha20-Poly1305 AEAD, and a BLAKE3 combiner +//! feeding HKDF-SHA512. //! //! Private key layout: `x25519_seed (32) || frodokem_secret_key`. //! Public key layout (classical-first): `x25519_pub (32) || frodokem_public_key`. @@ -295,7 +296,12 @@ impl<'a> SealView for View<'a> { ephemeral_pub.as_bytes(), ); - let (nonce, ct_tag) = aead::aead_seal(aead_codec, &combined_ss, plaintext, aad)?; + // Derive the AEAD key from the combined shared secret via HKDF-SHA512, + // matching the construction used by the other hybrid KEMs. + let key_len = aead::key_size(aead_codec)?; + let aead_key = aead::derive_aead_key(&combined_ss, b"x25519-frodokem640-seal", key_len)?; + + let (nonce, ct_tag) = aead::aead_seal(aead_codec, &aead_key, plaintext, aad)?; Ok(( encode_sealed(ephemeral_pub.as_bytes(), frodo_ct.as_ref(), &nonce, &ct_tag), @@ -359,9 +365,14 @@ impl<'a> OpenView for View<'a> { ephemeral_pub_bytes.as_slice(), ); + // Derive the AEAD key from the combined shared secret via HKDF-SHA512, + // matching the construction used by the other hybrid KEMs. + let key_len = aead::key_size(Codec::Chacha20Poly1305)?; + let aead_key = aead::derive_aead_key(&combined_ss, b"x25519-frodokem640-seal", key_len)?; + Ok(aead::aead_open( Codec::Chacha20Poly1305, - &combined_ss, + &aead_key, &nonce, &ct_tag, aad, diff --git a/src/views/x25519_mceliece348864.rs b/src/views/x25519_mceliece348864.rs index 8102bda..55b3026 100644 --- a/src/views/x25519_mceliece348864.rs +++ b/src/views/x25519_mceliece348864.rs @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 //! X25519-Classic-McEliece-348864 hybrid KEM multikey view (X-Wing-like); combines -//! X25519 ECDH with Classic McEliece 348864, ChaCha20-Poly1305 AEAD, and BLAKE3 KDF. +//! X25519 ECDH with Classic McEliece 348864, ChaCha20-Poly1305 AEAD, and a BLAKE3 +//! combiner feeding HKDF-SHA512. //! //! Private key layout: `x25519_seed (32) || mceliece_seed (32)` = 64 bytes. //! Public key layout (classical-first): `x25519_pub (32) || mceliece_public_key`. @@ -234,7 +235,12 @@ impl<'a> SealView for View<'a> { ephemeral_pub.as_bytes(), ); - let (nonce, ct_tag) = aead::aead_seal(aead_codec, &combined_ss, plaintext, aad)?; + // Derive the AEAD key from the combined shared secret via HKDF-SHA512, + // matching the construction used by the other hybrid KEMs. + let key_len = aead::key_size(aead_codec)?; + let aead_key = aead::derive_aead_key(&combined_ss, b"x25519-mceliece348864-seal", key_len)?; + + let (nonce, ct_tag) = aead::aead_seal(aead_codec, &aead_key, plaintext, aad)?; Ok(( encode_sealed(ephemeral_pub.as_bytes(), mceliece_ct, &nonce, &ct_tag), @@ -305,9 +311,14 @@ impl<'a> OpenView for View<'a> { ephemeral_pub_bytes.as_slice(), ); + // Derive the AEAD key from the combined shared secret via HKDF-SHA512, + // matching the construction used by the other hybrid KEMs. + let key_len = aead::key_size(Codec::Chacha20Poly1305)?; + let aead_key = aead::derive_aead_key(&combined_ss, b"x25519-mceliece348864-seal", key_len)?; + Ok(aead::aead_open( Codec::Chacha20Poly1305, - &combined_ss, + &aead_key, &nonce, &ct_tag, aad, diff --git a/src/views/x25519_mlkem768.rs b/src/views/x25519_mlkem768.rs index b296023..c31eb23 100644 --- a/src/views/x25519_mlkem768.rs +++ b/src/views/x25519_mlkem768.rs @@ -9,8 +9,8 @@ use crate::{ SealView, }; use ml_kem::{ - kem::{Decapsulate, Encapsulate}, - EncodedSizeUser, KemCore, MlKem768, + kem::{Decapsulate, Encapsulate, FromSeed, Kem, KeyExport}, + MlKem768, }; use multi_codec::Codec; use multi_hash::{mh, Multihash}; @@ -101,18 +101,15 @@ impl<'a> ConvView for View<'a> { let x25519_pub = PublicKey::from(&x25519_secret); // ML-KEM-768 public key - let d: [u8; 32] = secret_bytes[X25519_SEED_LEN..X25519_SEED_LEN + MLKEM_D_LEN] + let mlkem_seed: [u8; 64] = secret_bytes[X25519_SEED_LEN..PRIV_SEED_LEN] .try_into() - .map_err(|_| ConversionsError::SecretKeyFailure("invalid ml-kem d".into()))?; - let z: [u8; 32] = secret_bytes[X25519_SEED_LEN + MLKEM_D_LEN..PRIV_SEED_LEN] - .try_into() - .map_err(|_| ConversionsError::SecretKeyFailure("invalid ml-kem z".into()))?; - let (_dk, ek) = MlKem768::generate_deterministic(&d.into(), &z.into()); + .map_err(|_| ConversionsError::SecretKeyFailure("invalid ml-kem seed".into()))?; + let (_dk, ek) = MlKem768::from_seed(&mlkem_seed.into()); // Concatenate: x25519_pub (32) || mlkem_pub (1184) let mut pub_bytes = Vec::with_capacity(PUB_KEY_LEN); pub_bytes.extend_from_slice(x25519_pub.as_bytes()); - pub_bytes.extend_from_slice(&ek.as_bytes()); + pub_bytes.extend_from_slice(ek.to_bytes().as_slice()); Builder::new(Codec::X25519Mlkem768Pub) .with_comment(&self.mk.comment) @@ -222,11 +219,12 @@ impl<'a> SealView for View<'a> { .map_err(|_| SealError::EncapsulationFailed("invalid x25519 public key".into()))?; let recipient_x25519_pub = PublicKey::from(x25519_pub_arr); - let mlkem_ek = ::EncapsulationKey::from_bytes( + let mlkem_ek = ::EncapsulationKey::new( pub_bytes[X25519_PUB_LEN..].try_into().map_err(|_| { SealError::EncapsulationFailed("invalid ML-KEM-768 public key".into()) })?, - ); + ) + .map_err(|_| SealError::EncapsulationFailed("invalid ML-KEM-768 public key".into()))?; // X25519: generate ephemeral keypair and ECDH let ephemeral_secret = StaticSecret::random_from_rng(&mut rand::rng()); @@ -234,10 +232,8 @@ impl<'a> SealView for View<'a> { let ss_x25519 = ephemeral_secret.diffie_hellman(&recipient_x25519_pub); // ML-KEM-768: encapsulate - let mut rng = rand_core_06::OsRng; - let (mlkem_ct, ss_mlkem) = mlkem_ek - .encapsulate(&mut rng) - .map_err(|_| SealError::EncapsulationFailed("ML-KEM encapsulation failed".into()))?; + let mut rng = rand::rng(); + let (mlkem_ct, ss_mlkem) = mlkem_ek.encapsulate_with_rng(&mut rng); // Combine shared secrets via SHA-512 let combined_ss = combine_shared_secrets( @@ -309,21 +305,16 @@ impl<'a> OpenView for View<'a> { let ss_x25519 = x25519_secret.diffie_hellman(&ephemeral_pub); // ML-KEM-768 decapsulate - let d: [u8; 32] = secret_bytes[X25519_SEED_LEN..X25519_SEED_LEN + MLKEM_D_LEN] - .try_into() - .map_err(|_| SealError::DecapsulationFailed("invalid ml-kem d".into()))?; - let z: [u8; 32] = secret_bytes[X25519_SEED_LEN + MLKEM_D_LEN..PRIV_SEED_LEN] + let mlkem_seed: [u8; 64] = secret_bytes[X25519_SEED_LEN..PRIV_SEED_LEN] .try_into() - .map_err(|_| SealError::DecapsulationFailed("invalid ml-kem z".into()))?; - let (dk, _ek) = MlKem768::generate_deterministic(&d.into(), &z.into()); + .map_err(|_| SealError::DecapsulationFailed("invalid ml-kem seed".into()))?; + let (dk, _ek) = MlKem768::from_seed(&mlkem_seed.into()); let mlkem_ct = mlkem_ct_bytes .as_slice() .try_into() .map_err(|_| SealError::DecapsulationFailed("invalid ML-KEM ciphertext size".into()))?; - let ss_mlkem = dk - .decapsulate(&mlkem_ct) - .map_err(|_| SealError::DecapsulationFailed("ML-KEM decapsulation failed".into()))?; + let ss_mlkem = dk.decapsulate(&mlkem_ct); // Combine shared secrets via SHA-512 let combined_ss = combine_shared_secrets( diff --git a/src/views/x25519_sntrup761.rs b/src/views/x25519_sntrup761.rs index 0f051ec..e0f4ec6 100644 --- a/src/views/x25519_sntrup761.rs +++ b/src/views/x25519_sntrup761.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 //! X25519-sntrup761 hybrid KEM multikey view; combines X25519 ECDH with sntrup761 KEM, -//! ChaCha20-Poly1305 AEAD, and BLAKE3 KDF. +//! ChaCha20-Poly1305 AEAD, and a BLAKE3 combiner feeding HKDF-SHA512. use crate::{ error::{AttributesError, ConversionsError, SealError}, @@ -250,8 +250,13 @@ impl<'a> SealView for View<'a> { ephemeral_pub.as_bytes(), ); + // Derive the AEAD key from the combined shared secret via HKDF-SHA512, + // matching the construction used by the other hybrid KEMs. + let key_len = aead::key_size(aead_codec)?; + let aead_key = aead::derive_aead_key(&combined_ss, b"x25519-sntrup761-seal", key_len)?; + // AEAD encrypt - let (nonce, ct_tag) = aead::aead_seal(aead_codec, &combined_ss, plaintext, aad)?; + let (nonce, ct_tag) = aead::aead_seal(aead_codec, &aead_key, plaintext, aad)?; Ok(( encode_sealed( @@ -325,10 +330,15 @@ impl<'a> OpenView for View<'a> { ephemeral_pub_bytes.as_slice(), ); + // Derive the AEAD key from the combined shared secret via HKDF-SHA512, + // matching the construction used by the other hybrid KEMs. + let key_len = aead::key_size(Codec::Chacha20Poly1305)?; + let aead_key = aead::derive_aead_key(&combined_ss, b"x25519-sntrup761-seal", key_len)?; + // AEAD decrypt Ok(aead::aead_open( Codec::Chacha20Poly1305, - &combined_ss, + &aead_key, &nonce, &ct_tag, aad,