Seedable clients (#5440)

* Seedable clients

* Finalize seedable PR

* Address PR comments

* More generic DerivationMaterials init

* Fix xoring the wrong index

* Tests
This commit is contained in:
Drazen Urch
2025-02-17 00:00:17 +01:00
committed by GitHub
parent dcc48db301
commit 134883522d
9 changed files with 276 additions and 8 deletions
Generated
+1
View File
@@ -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",
@@ -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<C, S: MixnetClientStorage> {
#[cfg(unix)]
connection_fd_callback: Option<Arc<dyn Fn(RawFd) + Send + Sync>>,
derivation_material: Option<DerivationMaterial>,
}
impl<C, S> BaseClientBuilder<C, S>
@@ -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<DerivationMaterial>,
) -> 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<DerivationMaterial>,
) -> Result<InitialisationResult, ClientCoreError>
where
<S::KeyStore as KeyStore>::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?;
@@ -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<R>(
rng: &mut R,
derivation_material: &DerivationMaterial,
) -> Result<Self, InvalidLength>
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,
@@ -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()
);
}
}
+3
View File
@@ -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
+2 -1
View File
@@ -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"]
+37 -3
View File
@@ -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<R: RngCore + CryptoRng>(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<PrivateKey> 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()),
}
}
}
+79
View File
@@ -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<D>(
@@ -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<u8>,
}
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::<Sha512>::new(Some(salt), &self.master_key);
let mut okm = [0u8; 32];
hk.expand(&info, &mut okm)?;
Ok(okm)
}
pub fn new<T: AsRef<[u8]>>(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(),
}
}
}
+21 -2
View File
@@ -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<S: MixnetClientStorage = Ephemeral> {
storage: S,
forget_me: ForgetMe,
derivation_material: Option<DerivationMaterial>,
}
impl MixnetClientBuilder<Ephemeral> {
@@ -101,6 +103,7 @@ impl MixnetClientBuilder<OnDiskPersistent> {
#[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<Arc<dyn Fn(std::os::fd::RawFd) + Send + Sync>>,
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<DerivationMaterial>,
}
impl<S> DisconnectedMixnetClient<S>
@@ -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);