Skip to content

Commit 2feef18

Browse files
committed
feat(nat): apply Mick's TLS-derived identity + coordinator rotation fixes
Incorporates saorsa-core#75 and saorsa-transport#52.
1 parent 68835e6 commit 2feef18

23 files changed

+1988
-545
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/saorsa-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ blake3 = "1.6"
5959
# Performance optimization
6060
parking_lot = "0.12"
6161
once_cell = "1.21"
62+
dashmap = "6"
6263

6364
# Networking
6465
saorsa-transport = { path = "../saorsa-transport" }

crates/saorsa-core/src/dht_network_manager.rs

Lines changed: 861 additions & 65 deletions
Large diffs are not rendered by default.

crates/saorsa-core/src/identity/node_identity.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,17 @@ impl NodeIdentity {
185185
self.secret_key.as_bytes()
186186
}
187187

188+
/// Clone the underlying ML-DSA-65 keypair.
189+
///
190+
/// Used to install the node's identity as the transport's TLS keypair so
191+
/// that the SPKI carried in the QUIC handshake authenticates the same
192+
/// peer ID that signs application messages. Without this, the
193+
/// transport-level and application-level identities are distinct and
194+
/// must be reconciled by a wire-level handshake.
195+
pub fn clone_keypair(&self) -> (MlDsaPublicKey, MlDsaSecretKey) {
196+
(self.public_key.clone(), self.secret_key.clone())
197+
}
198+
188199
/// Sign a message
189200
pub fn sign(&self, message: &[u8]) -> Result<MlDsaSignature> {
190201
crate::quantum_crypto::ml_dsa_sign(&self.secret_key, message).map_err(|e| {

crates/saorsa-core/src/network.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -132,17 +132,17 @@ const DEFAULT_CONNECTION_TIMEOUT_SECS: u64 = 25;
132132
/// Number of cached bootstrap peers to retrieve.
133133
const BOOTSTRAP_PEER_BATCH_SIZE: usize = 20;
134134

135-
/// Timeout in seconds for waiting on a bootstrap peer's identity exchange.
135+
/// Defensive upper bound on the wait for a bootstrap peer's
136+
/// TLS-authenticated identity to be registered after `connect_peer`.
136137
///
137-
/// Identity exchange is two RTTs over a freshly-handshaken QUIC connection
138-
/// plus an ML-DSA-65 signature verification. On a LAN this completes in
139-
/// well under a second; on congested cellular or cross-region links it can
140-
/// blow past 5s with retransmits. The previous 5s default fired
141-
/// spuriously on slow networks during testnet validation, forcing
142-
/// reconnect loops that masqueraded as NAT traversal failures, so we
143-
/// budget enough headroom for two QUIC handshake retries on a high-latency
144-
/// link.
145-
const BOOTSTRAP_IDENTITY_TIMEOUT_SECS: u64 = 15;
138+
/// Since identity is now derived synchronously from the TLS-handshake
139+
/// SPKI inside the connection lifecycle monitor, the typical wait is a
140+
/// scheduler tick. This timeout only fires if the lifecycle monitor is
141+
/// wedged (e.g. broadcast lag) or the peer presented an SPKI that fails
142+
/// the saorsa-pqc parse — both cases that should be loud test failures
143+
/// rather than silent stalls. 2 s is generous and still well below any
144+
/// outer caller timeout, so this constant exists purely as a safety net.
145+
const BOOTSTRAP_IDENTITY_TIMEOUT_SECS: u64 = 2;
146146

147147
/// Serde helper — returns `true`.
148148
const fn default_true() -> bool {

crates/saorsa-core/src/transport/saorsa_transport_adapter.rs

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
//! automatically enables saorsa-transport's prometheus metrics collection.
4040
4141
use crate::error::{GeoRejectionError, GeographicConfig};
42+
use crate::quantum_crypto::saorsa_transport_integration::{MlDsaPublicKey, MlDsaSecretKey};
4243
use crate::transport::observed_address_cache::ObservedAddressCache;
4344
use anyhow::{Context, Result};
4445
use std::collections::HashMap;
@@ -188,16 +189,30 @@ impl P2PNetworkNode<P2pLinkTransport> {
188189
max_connections: usize,
189190
max_msg_size: Option<usize>,
190191
) -> Result<Self> {
191-
Self::new_with_options(bind_addr, max_connections, max_msg_size, false).await
192+
Self::new_with_options(bind_addr, max_connections, max_msg_size, false, None).await
192193
}
193194

194195
/// Create a new P2P network node with full control over connection
195-
/// limits, message size, and loopback address acceptance.
196+
/// limits, message size, loopback acceptance, and TLS keypair injection.
197+
///
198+
/// When `keypair` is `Some`, the supplied ML-DSA-65 keypair is installed
199+
/// as the transport's TLS identity, so the SPKI carried in every QUIC
200+
/// handshake authenticates the same peer ID that signs application
201+
/// messages. saorsa-core threads its `NodeIdentity` keys through here so
202+
/// the lifecycle monitor can derive the app-level peer ID directly from
203+
/// the TLS-handshake bytes — no separate identity-announce protocol is
204+
/// required.
205+
///
206+
/// When `keypair` is `None`, saorsa-transport generates a fresh keypair
207+
/// internally; the resulting peer ID will not match anything stored in
208+
/// saorsa-core, so this branch is only suitable for tests that don't
209+
/// cross the identity boundary.
196210
pub async fn new_with_options(
197211
bind_addr: SocketAddr,
198212
max_connections: usize,
199213
max_msg_size: Option<usize>,
200214
allow_loopback: bool,
215+
keypair: Option<(MlDsaPublicKey, MlDsaSecretKey)>,
201216
) -> Result<Self> {
202217
let mut builder = P2pConfig::builder()
203218
.bind_addr(bind_addr)
@@ -213,6 +228,9 @@ impl P2PNetworkNode<P2pLinkTransport> {
213228
..NatConfig::default()
214229
});
215230
}
231+
if let Some((public_key, secret_key)) = keypair {
232+
builder = builder.keypair(public_key, secret_key);
233+
}
216234
let config = builder
217235
.build()
218236
.map_err(|e| anyhow::anyhow!("Failed to build P2P config: {}", e))?;
@@ -856,18 +874,30 @@ impl DualStackNetworkNode<P2pLinkTransport> {
856874
}
857875
}
858876

859-
/// Set a preferred coordinator for hole-punching to a specific target.
860-
/// The preferred coordinator is a peer that referred us to the target
861-
/// during a DHT lookup, so it has a connection to the target.
862-
pub async fn set_hole_punch_preferred_coordinator(
877+
/// Set an ordered list of preferred coordinators for hole-punching to a
878+
/// specific target.
879+
///
880+
/// The list is iterated front to back at hole-punch time: every
881+
/// coordinator except the last gets a short per-attempt timeout
882+
/// (~1.5s) so a busy or unreachable referrer is abandoned quickly,
883+
/// and the final coordinator gets the strategy's full hole-punch
884+
/// timeout to give it time to actually complete the punch.
885+
///
886+
/// The caller (`DhtNetworkManager::dial_candidate`) is expected to
887+
/// rank the list best-first using DHT signals — round observed,
888+
/// trust score, etc. — via [`DhtNetworkManager::rank_referrers`].
889+
///
890+
/// Empty `coordinators` removes any preferred coordinators for
891+
/// `target`.
892+
pub async fn set_hole_punch_preferred_coordinators(
863893
&self,
864894
target: SocketAddr,
865-
coordinator: SocketAddr,
895+
coordinators: Vec<SocketAddr>,
866896
) {
867897
for node in [&self.v6, &self.v4].into_iter().flatten() {
868898
node.transport
869899
.endpoint()
870-
.set_hole_punch_preferred_coordinator(target, coordinator)
900+
.set_hole_punch_preferred_coordinators(target, coordinators.clone())
871901
.await;
872902
}
873903
}
@@ -1063,17 +1093,22 @@ impl DualStackNetworkNode<P2pLinkTransport> {
10631093
max_connections: usize,
10641094
max_msg_size: Option<usize>,
10651095
) -> Result<Self> {
1066-
Self::new_with_options(v6_addr, v4_addr, max_connections, max_msg_size, false).await
1096+
Self::new_with_options(v6_addr, v4_addr, max_connections, max_msg_size, false, None).await
10671097
}
10681098

10691099
/// Create dual nodes with full control over connection limits, message
1070-
/// size, and loopback address acceptance.
1100+
/// size, loopback acceptance, and TLS keypair injection.
1101+
///
1102+
/// When `keypair` is `Some`, both stacks share the same ML-DSA-65
1103+
/// identity, so the SPKI carried in every QUIC handshake authenticates
1104+
/// the same peer ID regardless of which stack the connection arrived on.
10711105
pub async fn new_with_options(
10721106
v6_addr: Option<SocketAddr>,
10731107
v4_addr: Option<SocketAddr>,
10741108
max_connections: usize,
10751109
max_msg_size: Option<usize>,
10761110
allow_loopback: bool,
1111+
keypair: Option<(MlDsaPublicKey, MlDsaSecretKey)>,
10771112
) -> Result<Self> {
10781113
let v6 = if let Some(addr) = v6_addr {
10791114
Some(
@@ -1082,6 +1117,7 @@ impl DualStackNetworkNode<P2pLinkTransport> {
10821117
max_connections,
10831118
max_msg_size,
10841119
allow_loopback,
1120+
keypair.clone(),
10851121
)
10861122
.await?,
10871123
)
@@ -1094,6 +1130,7 @@ impl DualStackNetworkNode<P2pLinkTransport> {
10941130
max_connections,
10951131
max_msg_size,
10961132
allow_loopback,
1133+
keypair.clone(),
10971134
)
10981135
.await
10991136
{

0 commit comments

Comments
 (0)