331 lines
12 KiB
Rust
331 lines
12 KiB
Rust
//! Transport layer construction for ChronoGrid P2P.
|
|
//!
|
|
//! Builds a libp2p transport stack with:
|
|
//!
|
|
//! - **QUIC over UDP** (primary) — low-latency, multiplexed, with built-in TLS
|
|
//! 1.3 equivalent security via the QUIC protocol.
|
|
//! - **TCP + Noise XK** (fallback) — for environments where UDP/QUIC is blocked.
|
|
//! - **Yamux** stream multiplexer over TCP.
|
|
//! - **SwarmKey authentication** — a 256-bit pre-shared key that gates cluster
|
|
//! membership. The key fingerprint is embedded in the `UserAgent` string
|
|
//! exchanged during the `identify` handshake; peers with a mismatched
|
|
//! fingerprint are disconnected immediately after identification.
|
|
//!
|
|
//! # Architecture note
|
|
//!
|
|
//! libp2p 0.54 uses the `SwarmBuilder` API which handles transport construction
|
|
//! internally. This module exposes a `TransportConfig` struct that captures all
|
|
//! parameters needed by `SwarmBuilder`, and a `build_transport` function that
|
|
//! constructs a `Boxed<(PeerId, StreamMuxerBox)>` for use in lower-level swarm
|
|
//! construction or testing.
|
|
|
|
use libp2p::{
|
|
PeerId,
|
|
Transport,
|
|
core::{muxing::StreamMuxerBox, transport::Boxed, upgrade},
|
|
identity::Keypair,
|
|
noise,
|
|
tcp,
|
|
yamux,
|
|
};
|
|
|
|
use crate::{
|
|
error::{NetError, NetResult},
|
|
identity::NodeIdentity,
|
|
swarm_key::SwarmKey,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// TransportConfig
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Configuration parameters for the ChronoGrid transport stack.
|
|
///
|
|
/// Passed to [`build_transport`] to construct the libp2p transport.
|
|
/// Also consumed by `SwarmManager` (Task 2.5) when building the full swarm.
|
|
#[derive(Debug, Clone)]
|
|
pub struct TransportConfig {
|
|
/// The swarm key fingerprint embedded in the identify user-agent string.
|
|
/// Peers that present a different fingerprint are rejected post-handshake.
|
|
pub swarm_key_fingerprint: String,
|
|
|
|
/// Maximum number of simultaneous inbound connections.
|
|
pub max_connections_in: u32,
|
|
|
|
/// Maximum number of simultaneous outbound connections.
|
|
pub max_connections_out: u32,
|
|
|
|
/// Connection keep-alive interval in seconds.
|
|
pub keep_alive_secs: u64,
|
|
}
|
|
|
|
impl TransportConfig {
|
|
/// Create a `TransportConfig` from a `SwarmKey`, using sensible defaults.
|
|
pub fn from_swarm_key(swarm_key: &SwarmKey) -> Self {
|
|
TransportConfig {
|
|
swarm_key_fingerprint: swarm_key.fingerprint(),
|
|
max_connections_in: 50,
|
|
max_connections_out: 50,
|
|
keep_alive_secs: 20,
|
|
}
|
|
}
|
|
|
|
/// Return the user-agent string that encodes the swarm key fingerprint.
|
|
///
|
|
/// Format: `chronogrid/<version>/swarm:<fingerprint>`
|
|
///
|
|
/// This string is exchanged during the `identify` protocol handshake.
|
|
/// Peers validate that the fingerprint matches their own swarm key before
|
|
/// accepting the connection into the cluster.
|
|
pub fn user_agent(&self) -> String {
|
|
format!(
|
|
"chronogrid/{}/swarm:{}",
|
|
env!("CARGO_PKG_VERSION"),
|
|
self.swarm_key_fingerprint
|
|
)
|
|
}
|
|
|
|
/// Extract the swarm key fingerprint from a user-agent string produced by
|
|
/// [`user_agent`](Self::user_agent).
|
|
///
|
|
/// Returns `None` if the string does not match the expected format.
|
|
pub fn extract_fingerprint(user_agent: &str) -> Option<&str> {
|
|
// Format: "chronogrid/<version>/swarm:<fingerprint>"
|
|
let swarm_part = user_agent.split('/').find(|s| s.starts_with("swarm:"))?;
|
|
Some(swarm_part.trim_start_matches("swarm:"))
|
|
}
|
|
|
|
/// Validate that a remote peer's user-agent string carries the correct
|
|
/// swarm key fingerprint.
|
|
///
|
|
/// Returns `true` if the fingerprint matches, `false` otherwise.
|
|
pub fn validate_peer_user_agent(&self, remote_user_agent: &str) -> bool {
|
|
match Self::extract_fingerprint(remote_user_agent) {
|
|
Some(fp) => fp == self.swarm_key_fingerprint,
|
|
None => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// build_transport
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build a libp2p TCP + Noise XK + Yamux transport stack.
|
|
///
|
|
/// This function constructs the **TCP fallback transport** used when QUIC is
|
|
/// unavailable. The primary QUIC transport is configured directly via
|
|
/// `SwarmBuilder::with_quic()` in `SwarmManager` (Task 2.5).
|
|
///
|
|
/// # Transport stack
|
|
///
|
|
/// ```text
|
|
/// TCP (tokio)
|
|
/// └─ Noise XK (Ed25519 keypair from NodeIdentity)
|
|
/// └─ Yamux stream multiplexer
|
|
/// ```
|
|
///
|
|
/// # Parameters
|
|
///
|
|
/// - `identity` — The node's Ed25519 keypair, used for Noise authentication.
|
|
/// - `_swarm_key` — The cluster pre-shared key. Its fingerprint is validated
|
|
/// at the application layer via the `identify` protocol (see
|
|
/// [`TransportConfig::validate_peer_user_agent`]). The raw key bytes are
|
|
/// reserved for a future libp2p private-network transport layer.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`NetError::Transport`] if the Noise keypair cannot be constructed
|
|
/// from the provided identity.
|
|
pub fn build_transport(
|
|
identity: &NodeIdentity,
|
|
_swarm_key: &SwarmKey,
|
|
) -> NetResult<Boxed<(PeerId, StreamMuxerBox)>> {
|
|
// Convert the ed25519-dalek SigningKey to a libp2p Keypair.
|
|
let libp2p_keypair = dalek_to_libp2p_keypair(identity)?;
|
|
|
|
// Noise authentication configuration (XX pattern for TCP fallback;
|
|
// QUIC uses its own built-in security layer).
|
|
let noise_config = noise::Config::new(&libp2p_keypair)
|
|
.map_err(|e| NetError::Transport(format!("noise config error: {e}")))?;
|
|
|
|
// Yamux multiplexer with default configuration.
|
|
let yamux_config = yamux::Config::default();
|
|
|
|
// Build the TCP transport with Noise + Yamux.
|
|
let transport = tcp::tokio::Transport::new(tcp::Config::default().nodelay(true))
|
|
.upgrade(upgrade::Version::V1Lazy)
|
|
.authenticate(noise_config)
|
|
.multiplex(yamux_config)
|
|
.boxed();
|
|
|
|
Ok(transport)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Keypair conversion helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Convert an `ed25519-dalek` `SigningKey` to a `libp2p` `Keypair`.
|
|
///
|
|
/// libp2p uses its own `identity::Keypair` type internally. We extract the
|
|
/// raw 32-byte seed from the dalek key and re-import it into libp2p's Ed25519
|
|
/// key type to ensure compatibility.
|
|
pub(crate) fn dalek_to_libp2p_keypair(identity: &NodeIdentity) -> NetResult<Keypair> {
|
|
// Extract the 32-byte secret seed from the dalek SigningKey.
|
|
let secret_bytes = identity.signing_key().to_bytes();
|
|
|
|
// libp2p's Ed25519 SecretKey is constructed from the 32-byte seed.
|
|
let secret = libp2p::identity::ed25519::SecretKey::try_from_bytes(secret_bytes)
|
|
.map_err(|e| NetError::Transport(format!("ed25519 secret key conversion failed: {e}")))?;
|
|
|
|
let kp = libp2p::identity::ed25519::Keypair::from(secret);
|
|
Ok(Keypair::from(kp))
|
|
}
|
|
|
|
/// Derive the libp2p `PeerId` from a `NodeIdentity`.
|
|
///
|
|
/// The `PeerId` is the libp2p-canonical identifier derived from the Ed25519
|
|
/// public key. It differs from `NodeId` (which is SHA-256 of the public key)
|
|
/// but both are stable identifiers for the same node.
|
|
pub fn peer_id_from_identity(identity: &NodeIdentity) -> NetResult<PeerId> {
|
|
let keypair = dalek_to_libp2p_keypair(identity)?;
|
|
Ok(PeerId::from(keypair.public()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::identity::generate_node_identity;
|
|
|
|
// ── TransportConfig tests ────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_transport_config_from_swarm_key() {
|
|
let key = SwarmKey::generate();
|
|
let config = TransportConfig::from_swarm_key(&key);
|
|
assert_eq!(config.swarm_key_fingerprint, key.fingerprint());
|
|
assert_eq!(config.max_connections_in, 50);
|
|
assert_eq!(config.max_connections_out, 50);
|
|
assert_eq!(config.keep_alive_secs, 20);
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_agent_contains_fingerprint() {
|
|
let key = SwarmKey::generate();
|
|
let config = TransportConfig::from_swarm_key(&key);
|
|
let ua = config.user_agent();
|
|
assert!(ua.contains(&key.fingerprint()), "user_agent must contain fingerprint");
|
|
assert!(ua.starts_with("chronogrid/"), "user_agent must start with 'chronogrid/'");
|
|
assert!(ua.contains("swarm:"), "user_agent must contain 'swarm:' prefix");
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_fingerprint_valid() {
|
|
let key = SwarmKey::generate();
|
|
let config = TransportConfig::from_swarm_key(&key);
|
|
let ua = config.user_agent();
|
|
let extracted = TransportConfig::extract_fingerprint(&ua);
|
|
assert_eq!(extracted, Some(key.fingerprint().as_str()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_extract_fingerprint_invalid_format() {
|
|
let result = TransportConfig::extract_fingerprint("some-other-agent/1.0");
|
|
assert_eq!(result, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_peer_user_agent_matching_key() {
|
|
let key = SwarmKey::generate();
|
|
let config = TransportConfig::from_swarm_key(&key);
|
|
let ua = config.user_agent();
|
|
assert!(config.validate_peer_user_agent(&ua));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_peer_user_agent_wrong_key() {
|
|
let key1 = SwarmKey::generate();
|
|
let key2 = SwarmKey::generate();
|
|
let config1 = TransportConfig::from_swarm_key(&key1);
|
|
let config2 = TransportConfig::from_swarm_key(&key2);
|
|
// config1 should reject config2's user-agent (different fingerprint).
|
|
let ua2 = config2.user_agent();
|
|
assert!(!config1.validate_peer_user_agent(&ua2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_peer_user_agent_empty_string() {
|
|
let key = SwarmKey::generate();
|
|
let config = TransportConfig::from_swarm_key(&key);
|
|
assert!(!config.validate_peer_user_agent(""));
|
|
}
|
|
|
|
// ── Keypair conversion tests ─────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_dalek_to_libp2p_keypair_succeeds() {
|
|
let identity = generate_node_identity();
|
|
let result = dalek_to_libp2p_keypair(&identity);
|
|
assert!(result.is_ok(), "keypair conversion must succeed: {:?}", result);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dalek_to_libp2p_keypair_is_deterministic() {
|
|
let identity = generate_node_identity();
|
|
let kp1 = dalek_to_libp2p_keypair(&identity).unwrap();
|
|
let kp2 = dalek_to_libp2p_keypair(&identity).unwrap();
|
|
// Both keypairs must produce the same PeerId.
|
|
assert_eq!(
|
|
PeerId::from(kp1.public()),
|
|
PeerId::from(kp2.public()),
|
|
"same identity must produce same PeerId"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_peer_id_from_identity_is_stable() {
|
|
let identity = generate_node_identity();
|
|
let pid1 = peer_id_from_identity(&identity).unwrap();
|
|
let pid2 = peer_id_from_identity(&identity).unwrap();
|
|
assert_eq!(pid1, pid2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_different_identities_produce_different_peer_ids() {
|
|
let id1 = generate_node_identity();
|
|
let id2 = generate_node_identity();
|
|
let pid1 = peer_id_from_identity(&id1).unwrap();
|
|
let pid2 = peer_id_from_identity(&id2).unwrap();
|
|
assert_ne!(pid1, pid2);
|
|
}
|
|
|
|
// ── Transport construction test ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_build_transport_succeeds() {
|
|
let identity = generate_node_identity();
|
|
let swarm_key = SwarmKey::generate();
|
|
let result = build_transport(&identity, &swarm_key);
|
|
assert!(
|
|
result.is_ok(),
|
|
"build_transport must succeed: {:?}",
|
|
result.err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_transport_different_identities() {
|
|
// Two different identities must each produce a valid transport.
|
|
let id1 = generate_node_identity();
|
|
let id2 = generate_node_identity();
|
|
let key = SwarmKey::generate();
|
|
assert!(build_transport(&id1, &key).is_ok());
|
|
assert!(build_transport(&id2, &key).is_ok());
|
|
}
|
|
}
|