diff --git a/Cargo.lock b/Cargo.lock index b067ecd372..b0b0f29182 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5508,6 +5508,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_bytes", + "sha2 0.10.8", "subtle-encoding", "thiserror 2.0.11", "x25519-dalek", diff --git a/common/client-core/src/client/base_client/mod.rs b/common/client-core/src/client/base_client/mod.rs index af95bf2ab1..38d9329f4a 100644 --- a/common/client-core/src/client/base_client/mod.rs +++ b/common/client-core/src/client/base_client/mod.rs @@ -40,6 +40,7 @@ use nym_client_core_config_types::ForgetMe; use nym_client_core_gateways_storage::{GatewayDetails, GatewaysDetailsStore}; use nym_credential_storage::storage::Storage as CredentialStorage; use nym_crypto::asymmetric::{encryption, identity}; +use nym_crypto::hkdf::DerivationMaterial; use nym_gateway_client::client::config::GatewayClientConfig; use nym_gateway_client::{ AcknowledgementReceiver, GatewayClient, GatewayConfig, MixnetMessageReceiver, PacketRouter, @@ -192,6 +193,8 @@ pub struct BaseClientBuilder { #[cfg(unix)] connection_fd_callback: Option>, + + derivation_material: Option, } impl BaseClientBuilder @@ -216,9 +219,19 @@ where setup_method: GatewaySetup::MustLoad { gateway_id: None }, #[cfg(unix)] connection_fd_callback: None, + derivation_material: None, } } + #[must_use] + pub fn with_derivation_material( + mut self, + derivation_material: Option, + ) -> Self { + self.derivation_material = derivation_material; + self + } + #[must_use] pub fn with_forget_me(mut self, forget_me: &ForgetMe) -> Self { self.config.debug.forget_me = *forget_me; @@ -684,6 +697,7 @@ where setup_method: GatewaySetup, key_store: &S::KeyStore, details_store: &S::GatewaysDetailsStore, + derivation_material: Option, ) -> Result where ::StorageError: Sync + Send, @@ -693,7 +707,12 @@ where if key_store.load_keys().await.is_err() { info!("could not find valid client keys - a new set will be generated"); let mut rng = OsRng; - let keys = ClientKeys::generate_new(&mut rng); + let keys = if let Some(derivation_material) = derivation_material { + ClientKeys::from_master_key(&mut rng, &derivation_material) + .map_err(|_| ClientCoreError::HkdfDerivationError {})? + } else { + ClientKeys::generate_new(&mut rng) + }; store_client_keys(keys, key_store).await?; } @@ -715,6 +734,7 @@ where self.setup_method, self.client_store.key_store(), self.client_store.gateway_details_store(), + self.derivation_material, ) .await?; diff --git a/common/client-core/src/client/key_manager/mod.rs b/common/client-core/src/client/key_manager/mod.rs index e9af947117..d67ff80a3e 100644 --- a/common/client-core/src/client/key_manager/mod.rs +++ b/common/client-core/src/client/key_manager/mod.rs @@ -2,7 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 use crate::client::key_manager::persistence::KeyStore; -use nym_crypto::asymmetric::{encryption, identity}; +use nym_crypto::{ + asymmetric::{encryption, identity}, + hkdf::{DerivationMaterial, InvalidLength}, +}; use nym_gateway_requests::shared_key::{LegacySharedKeys, SharedGatewayKey, SharedSymmetricKey}; use nym_sphinx::acknowledgements::AckKey; use rand::{CryptoRng, RngCore}; @@ -10,6 +13,7 @@ use std::sync::Arc; use zeroize::ZeroizeOnDrop; pub mod persistence; +mod test; // Note: to support key rotation in the future, all keys will require adding an extra smart pointer, // most likely an AtomicCell, or if it doesn't work as I think it does, a Mutex. Although I think @@ -43,6 +47,24 @@ impl ClientKeys { } } + pub fn from_master_key( + rng: &mut R, + derivation_material: &DerivationMaterial, + ) -> Result + where + R: RngCore + CryptoRng, + { + let secret = derivation_material.derive_secret()?; + Ok(ClientKeys { + identity_keypair: Arc::new(identity::KeyPair::from_secret( + secret, + derivation_material.index(), + )), + encryption_keypair: Arc::new(encryption::KeyPair::new(rng)), + ack_key: Arc::new(AckKey::new(rng)), + }) + } + pub fn from_keys( id_keypair: identity::KeyPair, enc_keypair: encryption::KeyPair, diff --git a/common/client-core/src/client/key_manager/test.rs b/common/client-core/src/client/key_manager/test.rs new file mode 100644 index 0000000000..35740c207f --- /dev/null +++ b/common/client-core/src/client/key_manager/test.rs @@ -0,0 +1,89 @@ +#[cfg(test)] +mod tests { + use crate::client::key_manager::ClientKeys; + use nym_crypto::hkdf::DerivationMaterial; + use rand::SeedableRng; + use rand_chacha::ChaCha20Rng; + + #[test] + fn test_from_master_key_success() { + // Set up a deterministic RNG. + let seed = [33u8; 32]; + let mut rng = ChaCha20Rng::from_seed(seed); + + // Set up the derivation material. + let master_key = b"this is a secret master key"; + let salt = b"unique-salt"; + let derivation_material = DerivationMaterial::new(master_key, 0, salt); + + // Generate ClientKeys from the master key. + let client_keys = ClientKeys::from_master_key(&mut rng, &derivation_material) + .expect("Failed to create client keys"); + + assert_eq!( + client_keys.identity_keypair().public_key().to_string(), + String::from("FX4Undr5LPPBA7zThWWpAKXKQTXSbW1C28PnxbCqUkU4") + ); + + assert_eq!( + client_keys.identity_keypair().private_key().to_string(), + String::from("6S3uMi2rU5SwyUUYCiMrF5qqdcYnEDMYLggBSvavVzEt") + ); + } + + #[test] + fn test_from_master_key_deterministic_identity() { + // Using identical derivation material should result in the exactly same identity keypair. + let seed = [1u8; 32]; + let mut rng1 = ChaCha20Rng::from_seed(seed); + let mut rng2 = ChaCha20Rng::from_seed(seed); + + let master_key = b"another secret master key"; + let salt = b"deterministic-salt"; + let index = 7u32; + let derivation_material = DerivationMaterial::new(master_key, index, salt); + + let client_keys1 = ClientKeys::from_master_key(&mut rng1, &derivation_material) + .expect("Failed to create client keys (first instance)"); + let client_keys2 = ClientKeys::from_master_key(&mut rng2, &derivation_material) + .expect("Failed to create client keys (second instance)"); + + assert_eq!( + client_keys1.identity_keypair().public_key().to_string(), + client_keys2.identity_keypair().public_key().to_string() + ); + + assert_eq!( + client_keys1.identity_keypair().private_key().to_string(), + client_keys2.identity_keypair().private_key().to_string() + ); + } + + #[test] + fn test_from_master_key_different_indices() { + // Changing the index should yield a different identity key. + let seed = [5u8; 32]; + let mut rng = ChaCha20Rng::from_seed(seed); + + let master_key = b"same secret key"; + let salt = b"same-salt"; + + let derivation_material1 = DerivationMaterial::new(master_key, 1, salt); + let derivation_material2 = DerivationMaterial::new(master_key, 2, salt); + + let client_keys1 = ClientKeys::from_master_key(&mut rng, &derivation_material1) + .expect("Failed to create client keys for index 1"); + let client_keys2 = ClientKeys::from_master_key(&mut rng, &derivation_material2) + .expect("Failed to create client keys for index 2"); + + assert_ne!( + client_keys1.identity_keypair().public_key().to_string(), + client_keys2.identity_keypair().public_key().to_string() + ); + + assert_ne!( + client_keys1.identity_keypair().private_key().to_string(), + client_keys2.identity_keypair().private_key().to_string() + ); + } +} diff --git a/common/client-core/src/error.rs b/common/client-core/src/error.rs index 624cbe6028..76add9d71d 100644 --- a/common/client-core/src/error.rs +++ b/common/client-core/src/error.rs @@ -222,6 +222,9 @@ pub enum ClientCoreError { "fresh registration with gateway {gateway_id} somehow requires an additional key upgrade!" )] UnexpectedKeyUpgrade { gateway_id: String }, + + #[error("failed to derive keys from master key")] + HkdfDerivationError {}, } /// Set of messages that the client can send to listeners via the task manager diff --git a/common/crypto/Cargo.toml b/common/crypto/Cargo.toml index 1dcb0b4957..e840713541 100644 --- a/common/crypto/Cargo.toml +++ b/common/crypto/Cargo.toml @@ -24,6 +24,7 @@ ed25519-dalek = { workspace = true, features = ["rand_core"], optional = true } rand = { workspace = true, optional = true } serde_bytes = { workspace = true, optional = true } serde = { workspace = true, features = ["derive"], optional = true } +sha2 = { workspace = true, optional = true } subtle-encoding = { workspace = true, features = ["bech32-preview"] } thiserror = { workspace = true } zeroize = { workspace = true, optional = true, features = ["zeroize_derive"] } @@ -40,7 +41,7 @@ default = ["sphinx"] aead = ["dep:aead", "aead/std", "aes-gcm-siv", "generic-array"] serde = ["dep:serde", "serde_bytes", "ed25519-dalek/serde", "x25519-dalek/serde"] asymmetric = ["x25519-dalek", "ed25519-dalek", "zeroize"] -hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array"] +hashing = ["blake3", "digest", "hkdf", "hmac", "generic-array", "sha2"] stream_cipher = ["aes", "ctr", "cipher", "generic-array"] sphinx = ["nym-sphinx-types/sphinx"] outfox = ["nym-sphinx-types/outfox"] diff --git a/common/crypto/src/asymmetric/identity/mod.rs b/common/crypto/src/asymmetric/identity/mod.rs index a432b0c806..6bfa513be0 100644 --- a/common/crypto/src/asymmetric/identity/mod.rs +++ b/common/crypto/src/asymmetric/identity/mod.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 pub use ed25519_dalek::SignatureError; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{SecretKey, Signer, SigningKey}; pub use ed25519_dalek::{Verifier, PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH}; use nym_pemstore::traits::{PemStorableKey, PemStorableKeyPair}; use std::fmt::{self, Debug, Display, Formatter}; @@ -18,7 +18,7 @@ pub mod serde_helpers; use nym_sphinx_types::{DestinationAddressBytes, DESTINATION_ADDRESS_LENGTH}; #[cfg(feature = "rand")] -use rand::{CryptoRng, RngCore}; +use rand::{CryptoRng, Rng, RngCore}; #[cfg(feature = "serde")] use serde::de::Error as SerdeError; #[cfg(feature = "serde")] @@ -62,16 +62,33 @@ pub struct KeyPair { // nothing secret about public key #[zeroize(skip)] public_key: PublicKey, + + #[zeroize(skip)] + index: u32, } +/// All keys will always have an index field populated this is to prevent anyone from figuring out if +/// the keys are derived or random, and alter their behaviour based on that. impl KeyPair { #[cfg(feature = "rand")] pub fn new(rng: &mut R) -> Self { + let index = rng.gen(); let ed25519_signing_key = ed25519_dalek::SigningKey::generate(rng); KeyPair { private_key: PrivateKey(ed25519_signing_key.to_bytes()), public_key: PublicKey(ed25519_signing_key.verifying_key()), + index, + } + } + + pub fn from_secret(secret: SecretKey, index: u32) -> Self { + let ed25519_signing_key = SigningKey::from(secret); + + KeyPair { + private_key: PrivateKey(ed25519_signing_key.to_bytes()), + public_key: PublicKey(ed25519_signing_key.verifying_key()), + index, } } @@ -87,15 +104,31 @@ impl KeyPair { Ok(KeyPair { private_key: PrivateKey::from_bytes(priv_bytes)?, public_key: PublicKey::from_bytes(pub_bytes)?, + index: fake_index(pub_bytes), }) } } +/// Reduces a byte slice into a u32 value by XOR-ing all its bytes into a 4-byte accumulator. +/// The process iterates over every byte in the input slice, XOR-ing each one into a slot based on its index modulo 4. +/// If the input slice contains fewer than 4 bytes, the remaining positions in the accumulator remain zero. +/// Finally, the accumulator is interpreted in big-endian order to produce the resulting u32. +/// Index is used to verify deterministic identity key, master key and salt are also requried for verification. +fn fake_index(input: &[u8]) -> u32 { + let mut accumulator = [0u8; 4]; + for (i, &byte) in input.iter().enumerate() { + accumulator[i % 4] ^= byte; + } + u32::from_be_bytes(accumulator) +} + impl From for KeyPair { fn from(private_key: PrivateKey) -> Self { + let public_key = (&private_key).into(); KeyPair { - public_key: (&private_key).into(), + public_key, private_key, + index: fake_index(public_key.to_bytes().as_ref()), } } } @@ -115,6 +148,7 @@ impl PemStorableKeyPair for KeyPair { KeyPair { private_key, public_key, + index: fake_index(public_key.to_bytes().as_ref()), } } } diff --git a/common/crypto/src/hkdf.rs b/common/crypto/src/hkdf.rs index 57c1187b07..4c832e859d 100644 --- a/common/crypto/src/hkdf.rs +++ b/common/crypto/src/hkdf.rs @@ -8,6 +8,10 @@ use hkdf::{ }, Hkdf, }; +use sha2::{Sha256, Sha512}; + +pub use hkdf::InvalidLength; +use zeroize::ZeroizeOnDrop; /// Perform HKDF `extract` then `expand` as a single step. pub fn extract_then_expand( @@ -28,3 +32,78 @@ where Ok(okm) } + +/// `DerivationMaterial` encapsulates parameters for deterministic key derivation using +/// HKDF (SHA-512). +/// +/// It consists of: +/// - A master key (`master_key`): the base secret. +/// - An index (`index`): ensures unique derivations. +/// - A salt (`salt`): adds additional uniqueness, should be application specific. +/// +/// Use the `derive_secret()` method to generate a 32-byte secret. To prepare for a new derivation, +/// call the `next()` method, which increments the index. **It is the caller's responsibility to +/// track and persist the derivation index if keys need to be rederived.** +/// +/// # Example +/// +/// ```rust +/// let master_key = [0u8; 32]; // your secret master key +/// let salt = b"unique-salt-value"; +/// let material = DerivationMaterial::new(master_key, 0, salt); +/// +/// // Derive a secret +/// let secret = material.derive_secret().expect("Failed to derive secret"); +/// +/// // Prepare for the next derivation +/// let next_material = material.next(); +/// ``` +#[derive(ZeroizeOnDrop)] +pub struct DerivationMaterial { + master_key: [u8; 32], + index: u32, + salt: Vec, +} + +impl DerivationMaterial { + pub fn index(&self) -> u32 { + self.index + } + + pub fn salt(&self) -> &[u8] { + &self.salt + } + + /// Derives a 32-byte seed from a master seed and an index using HKDF (with SHA-512). + /// + /// The `salt` and the use of the index (as info) bind this derivation to an application/client. + pub fn derive_secret(&self) -> Result<[u8; 32], hkdf::InvalidLength> { + let salt = &self.salt; + let info = self.index.to_be_bytes(); // Use the index as info + let hk = Hkdf::::new(Some(salt), &self.master_key); + let mut okm = [0u8; 32]; + hk.expand(&info, &mut okm)?; + Ok(okm) + } + + pub fn new>(master_key: T, index: u32, salt: &[u8]) -> Self { + // Coerce master_key to [u8; 32] + let mut hasher = Sha256::new(); + hasher.update(master_key.as_ref()); + let master_key = hasher.finalize().into(); + + Self { + master_key, + index, + salt: salt.to_vec(), + } + } + + pub fn next(&self) -> Self { + Self { + master_key: self.master_key, + index: self.index + 1, + salt: self.salt.clone(), + } + } +} diff --git a/sdk/rust/nym-sdk/src/mixnet/client.rs b/sdk/rust/nym-sdk/src/mixnet/client.rs index dee364044b..6b5076d647 100644 --- a/sdk/rust/nym-sdk/src/mixnet/client.rs +++ b/sdk/rust/nym-sdk/src/mixnet/client.rs @@ -29,6 +29,7 @@ use nym_client_core::init::helpers::gateways_for_init; use nym_client_core::init::setup_gateway; use nym_client_core::init::types::{GatewaySelectionSpecification, GatewaySetup}; use nym_credentials_interface::TicketType; +use nym_crypto::hkdf::DerivationMaterial; use nym_socks5_client_core::config::Socks5; use nym_task::{TaskClient, TaskHandle, TaskStatus}; use nym_topology::provider_trait::TopologyProvider; @@ -64,6 +65,7 @@ pub struct MixnetClientBuilder { storage: S, forget_me: ForgetMe, + derivation_material: Option, } impl MixnetClientBuilder { @@ -101,6 +103,7 @@ impl MixnetClientBuilder { #[cfg(unix)] connection_fd_callback: None, forget_me: Default::default(), + derivation_material: None, }) } } @@ -133,6 +136,7 @@ where gateway_endpoint_config_path: None, storage, forget_me: Default::default(), + derivation_material: None, } } @@ -154,9 +158,16 @@ where gateway_endpoint_config_path: self.gateway_endpoint_config_path, storage, forget_me: self.forget_me, + derivation_material: self.derivation_material, } } + #[must_use] + pub fn with_derivation_material(mut self, derivation_material: DerivationMaterial) -> Self { + self.derivation_material = Some(derivation_material); + self + } + /// Change the underlying storage of this builder to use default implementation of on-disk disk_persistence. #[must_use] pub fn set_default_storage( @@ -312,6 +323,7 @@ where client.connection_fd_callback = self.connection_fd_callback; } client.forget_me = self.forget_me; + client.derivation_material = self.derivation_material; Ok(client) } } @@ -366,6 +378,9 @@ where connection_fd_callback: Option>, forget_me: ForgetMe, + + /// The derivation material to use for the client keys, its up to the caller to save this for rederivation later + derivation_material: Option, } impl DisconnectedMixnetClient @@ -403,6 +418,8 @@ where None }; + let forget_me = config.debug_config.forget_me; + Ok(DisconnectedMixnetClient { config, socks5_config, @@ -417,7 +434,8 @@ where user_agent: None, #[cfg(unix)] connection_fd_callback: None, - forget_me: Default::default(), + forget_me, + derivation_material: None, }) } @@ -641,7 +659,8 @@ where let mut base_builder: BaseClientBuilder<_, _> = BaseClientBuilder::new(base_config, self.storage, self.dkg_query_client) .with_wait_for_gateway(self.wait_for_gateway) - .with_forget_me(&self.forget_me); + .with_forget_me(&self.forget_me) + .with_derivation_material(self.derivation_material); if let Some(user_agent) = self.user_agent { base_builder = base_builder.with_user_agent(user_agent);