Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
// SPDX-License-Identifier: GPL-3.0-only

use anyhow::{Context, bail};
use nym_crypto::asymmetric::x25519;
use anyhow::{Context, anyhow, bail};
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_crypto::hkdf;
use nym_pemstore::load_key;
use sha2::Sha256;
use std::path::Path;
use std::sync::Arc;

/// Domain-separation label for the ed25519 client identity derived from the agent's noise key.
/// Changing it rotates every agent's identity, which then has to be re-announced on chain.
const CLIENT_IDENTITY_HKDF_LABEL: &[u8] = b"nym-network-monitor-agent-ed25519-client-identity-v1";

/// Loads an x25519 Noise private key from a PEM file and returns the full key pair
/// wrapped in an [`Arc`] for shared ownership.
pub(crate) fn load_noise_key<P: AsRef<Path>>(path: P) -> anyhow::Result<Arc<x25519::KeyPair>> {
Expand All @@ -17,3 +23,47 @@ pub(crate) fn load_noise_key<P: AsRef<Path>>(path: P) -> anyhow::Result<Arc<x255
let noise_key: x25519::PrivateKey = load_key(path).context("failed to load noise key")?;
Ok(Arc::new(noise_key.into()))
}

/// Derives the agent's ed25519 client identity from its x25519 noise private key, so that a gateway
/// client session needs no key material beyond the noise key the agent already holds.
///
/// The HKDF output is used directly as the ed25519 seed. The label is what keeps this derivation
/// separate from anything else that may ever be derived from the same secret - seeding a CSPRNG with
/// the raw private key would give no such separation.
pub(crate) fn derive_client_identity(
noise_key: &x25519::KeyPair,
) -> anyhow::Result<ed25519::KeyPair> {
let seed = hkdf::extract_then_expand::<Sha256>(
None,
&noise_key.private_key().to_bytes(),
Some(CLIENT_IDENTITY_HKDF_LABEL),
ed25519::SECRET_KEY_LENGTH,
)
.map_err(|err| {
anyhow!("failed to derive the ed25519 client identity from the noise key: {err}")
})?;

let private_key = ed25519::PrivateKey::from_bytes(&seed)
.context("the derived ed25519 client identity seed was not a valid private key")?;
Ok(private_key.into())
}

#[cfg(test)]
mod tests {
use super::*;

// a known-answer vector rather than a determinism check: HKDF over fixed bytes can't be
// non-deterministic, but the label or the KDF changing silently WOULD invalidate every identity
// already announced on chain and stop gateways granting monitor sessions. pinning the output
// makes such a change fail here instead of in the field
#[test]
fn the_derived_client_identity_matches_its_known_answer() {
let noise_key: x25519::KeyPair = x25519::PrivateKey::from_secret([42u8; 32]).into();

let identity = derive_client_identity(&noise_key).unwrap();
assert_eq!(
identity.public_key().to_base58_string(),
"DwMcWZ1JGq3UosmKbhCXvkZmF2tcFuiFis8u2du1hn5x"
);
}
}
20 changes: 15 additions & 5 deletions nym-network-monitor-v3/nym-network-monitor-agent/src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// SPDX-License-Identifier: GPL-3.0-only

use crate::agent::config::NodeTesterConfig;
use crate::agent::helpers::derive_client_identity;
use crate::agent::tested_node::TestedNodeDetails;
use crate::agent::tester::NodeStressTester;
use anyhow::Context;
use nym_crypto::asymmetric::x25519;
use nym_crypto::asymmetric::{ed25519, x25519};
use nym_network_monitor_orchestrator_requests::client::OrchestratorClient;
use nym_network_monitor_orchestrator_requests::models::{
AgentAnnounceRequest, AgentMixAddresses, TestRunAssignmentRequest,
Expand Down Expand Up @@ -33,6 +34,11 @@ pub(crate) struct NetworkMonitorAgent {

/// The tester's own Noise key pair, used to authenticate the egress connection.
noise_key: Arc<x25519::KeyPair>,

/// The ed25519 identity this agent presents when opening a gateway client session, derived from
/// [`Self::noise_key`]. It is announced on chain, so it must stay stable for as long as the
/// noise key does.
client_identity: ed25519::KeyPair,
}

impl NetworkMonitorAgent {
Expand All @@ -42,12 +48,15 @@ impl NetworkMonitorAgent {
tester_config: NodeTesterConfig,
noise_key: Arc<x25519::KeyPair>,
orchestrator_client: OrchestratorClient,
) -> Self {
NetworkMonitorAgent {
) -> anyhow::Result<Self> {
let client_identity = derive_client_identity(&noise_key)?;

Ok(NetworkMonitorAgent {
tester_config,
orchestrator_client,
noise_key,
}
client_identity,
})
}

/// The addresses this agent announces to the orchestrator, and thus the ones the nodes it
Expand All @@ -59,7 +68,7 @@ impl NetworkMonitorAgent {
}
}

/// Announces this agent's details (mixnet address, noise key, protocol version)
/// Announces this agent's details (mixnet address, noise key, protocol version, client identity)
/// to the orchestrator so they can be registered in the smart contract.
pub(crate) async fn announce_agent(&self) -> anyhow::Result<()> {
self.orchestrator_client
Expand All @@ -68,6 +77,7 @@ impl NetworkMonitorAgent {
x25519_noise_key: *self.noise_key.public_key(),
// we're always using the latest noise version available
noise_version: LATEST_NOISE_VERSION.into(),
ed25519_identity: *self.client_identity.public_key(),
})
.await?;
Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,13 @@ pub(crate) async fn execute(args: Args) -> anyhow::Result<()> {
let external_address_v4 = SocketAddr::new(args.host_ip_v4, args.host_port);
let external_address_v6 = SocketAddr::new(args.host_ip_v6, args.host_port);

// 1. build instance of the agent (loads the noise keys)
// 1. build instance of the agent (loads the noise keys and derives the client identity)
let agent = NetworkMonitorAgent::new(
args.common_args
.build_config(external_address_v4, external_address_v6)?,
noise_key,
orchestrator_client,
);
)?;

// 2. announce the agent to the orchestrator
// so that it would be registered in the smart contract
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ pub struct AgentAnnounceRequest {

/// Version of the noise protocol used by the agent.
pub noise_version: u8,

/// Base-58 encoded ed25519 identity the agent presents when opening a gateway client session.
#[serde(with = "bs58_ed25519_pubkey")]
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub ed25519_identity: ed25519::PublicKey,
}

/// Confirmation returned to an agent after a successful announcement.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ async fn announce_agent(
// 2. upsert the agent in the cache and learn whether it has already been announced
let already_announced = state
.agents
.try_announce_agent(body.mix_addresses, body.x25519_noise_key)
.try_announce_agent(
body.mix_addresses,
body.x25519_noise_key,
body.ed25519_identity,
)
.await;

// 3. if the agent was already announced, skip the contract tx
Expand All @@ -89,9 +93,9 @@ async fn announce_agent(
mixnet_address,
bs58_x25519_noise: body.x25519_noise_key.to_base58_string(),
noise_version: body.noise_version,
// the agent does not announce an identity key yet; until it does, entries
// are written without one and the upsert fills it in on a later announce
bs58_ed25519_identity: None,
// both of the agent's entries carry the same identity: the gateway session
// gate is keyed on it alone, so it can't be tied to either address
bs58_ed25519_identity: Some(body.ed25519_identity.to_base58_string()),
},
Vec::new(),
)
Expand Down
Loading