feat: M1 — Noise transport, Cap'n Proto framing, Ping/Pong
Establishes the foundational transport layer for noiseml: - Noise_XX_25519_ChaChaPoly_BLAKE2s handshake (initiator + responder) via `snow`; mutual authentication of static X25519 keys guaranteed before any application data flows. - Length-prefixed frame codec (4-byte LE u32, max 65 535 B per Noise spec) implemented as a Tokio Encoder/Decoder pair. - Cap'n Proto Envelope schema with MsgType enum (Ping, Pong, and future MLS message types defined but not yet dispatched). - Server: TCP listener, one Tokio task per connection, Ping→Pong handler, fresh X25519 keypair logged at startup. - Client: `ping` subcommand — handshake, send Ping, receive Pong, print RTT, exit 0. - Integration tests: bidirectional Ping/Pong with mutual-auth verification; server keypair reuse across sequential connections. - Docker multi-stage build (rust:bookworm → debian:bookworm-slim, non-root) and docker-compose with TCP healthcheck. No MLS group state, no AS/DS, no persistence — out of scope for M1.
This commit is contained in:
38
crates/noiseml-client/Cargo.toml
Normal file
38
crates/noiseml-client/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "noiseml-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "CLI client for noiseml."
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "noiseml"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
noiseml-core = { path = "../noiseml-core" }
|
||||
noiseml-proto = { path = "../noiseml-proto" }
|
||||
|
||||
# Serialisation + RPC
|
||||
capnp = { workspace = true }
|
||||
capnp-rpc = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Error handling
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Integration tests spin up both server and client in the same process.
|
||||
noiseml-server = { path = "../noiseml-server" }
|
||||
145
crates/noiseml-client/src/main.rs
Normal file
145
crates/noiseml-client/src/main.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! noiseml CLI client.
|
||||
//!
|
||||
//! # M1 subcommands
|
||||
//!
|
||||
//! | Subcommand | Description |
|
||||
//! |------------|-----------------------------------------|
|
||||
//! | `ping` | Send a Ping to the server, print RTT |
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! | Env var | CLI flag | Default |
|
||||
//! |-----------------|--------------|---------------------|
|
||||
//! | `NOISEML_SERVER`| `--server` | `127.0.0.1:7000` |
|
||||
//! | `RUST_LOG` | — | `warn` |
|
||||
//!
|
||||
//! # Keypair lifecycle
|
||||
//!
|
||||
//! A fresh ephemeral X25519 keypair is generated per invocation in M1.
|
||||
//! M2 introduces persistent identity keys stored locally and registered
|
||||
//! with the Authentication Service.
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Parser, Subcommand};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use noiseml_core::{NoiseKeypair, handshake_initiator};
|
||||
use noiseml_proto::{MsgType, ParsedEnvelope};
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "noiseml", about = "noiseml CLI client", version)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Send a Ping to the server and print the round-trip time.
|
||||
Ping {
|
||||
/// Server address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")]
|
||||
server: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
match args.command {
|
||||
Command::Ping { server } => cmd_ping(&server).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subcommand implementations ────────────────────────────────────────────────
|
||||
|
||||
/// Connect to `server`, complete Noise_XX, send a Ping, and print RTT.
|
||||
///
|
||||
/// Exits with status 0 on a valid Pong, non-zero on any error.
|
||||
async fn cmd_ping(server: &str) -> anyhow::Result<()> {
|
||||
// Generate a fresh ephemeral keypair for this session.
|
||||
// M2 will load a persistent identity keypair instead.
|
||||
let keypair = NoiseKeypair::generate();
|
||||
|
||||
let stream = TcpStream::connect(server)
|
||||
.await
|
||||
.with_context(|| format!("could not connect to {server}"))?;
|
||||
|
||||
tracing::debug!(server = %server, "TCP connection established");
|
||||
|
||||
let mut transport = handshake_initiator(stream, &keypair)
|
||||
.await
|
||||
.context("Noise_XX handshake failed")?;
|
||||
|
||||
{
|
||||
let remote = transport
|
||||
.remote_static_public_key()
|
||||
.map(fmt_key)
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
tracing::debug!(server_key = %remote, "handshake complete");
|
||||
}
|
||||
|
||||
// Record send time immediately before writing to minimise measurement skew.
|
||||
let sent_at = current_timestamp_ms();
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: sent_at,
|
||||
})
|
||||
.await
|
||||
.context("failed to send Ping")?;
|
||||
|
||||
tracing::debug!("Ping sent");
|
||||
|
||||
let response = transport
|
||||
.recv_envelope()
|
||||
.await
|
||||
.context("failed to receive Pong")?;
|
||||
|
||||
match response.msg_type {
|
||||
MsgType::Pong => {
|
||||
let rtt_ms = current_timestamp_ms().saturating_sub(sent_at);
|
||||
println!("Pong from {server} rtt={rtt_ms}ms");
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"protocol error: expected Pong from {server}, got unexpected message type"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Format the first 4 bytes of a key as hex with a trailing ellipsis.
|
||||
fn fmt_key(key: &[u8]) -> String {
|
||||
if key.len() < 4 {
|
||||
return format!("{key:02x?}");
|
||||
}
|
||||
format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3])
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
209
crates/noiseml-client/tests/noise_transport.rs
Normal file
209
crates/noiseml-client/tests/noise_transport.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
//! M1 integration test: Noise_XX handshake + Ping/Pong round-trip.
|
||||
//!
|
||||
//! Both the server-side and client-side logic run in the same Tokio runtime
|
||||
//! using `tokio::spawn`. The test verifies:
|
||||
//!
|
||||
//! 1. The Noise_XX handshake completes from both sides.
|
||||
//! 2. A Ping sent by the client arrives as a Ping on the server side.
|
||||
//! 3. The server's Pong arrives correctly on the client side.
|
||||
//! 4. Mutual authentication: each peer's observed remote static key matches the
|
||||
//! other peer's actual public key (the core security property of XX).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use noiseml_core::{NoiseKeypair, handshake_initiator, handshake_responder};
|
||||
use noiseml_proto::{MsgType, ParsedEnvelope};
|
||||
|
||||
/// Completes a full Noise_XX handshake and Ping/Pong exchange, then verifies
|
||||
/// mutual authentication by comparing observed vs. actual static public keys.
|
||||
#[tokio::test]
|
||||
async fn noise_xx_ping_pong_round_trip() {
|
||||
let server_keypair = Arc::new(NoiseKeypair::generate());
|
||||
let client_keypair = NoiseKeypair::generate();
|
||||
|
||||
// Bind the listener *before* spawning so the port is ready when the client
|
||||
// calls connect — no sleep or retry needed.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("failed to bind test listener");
|
||||
let server_addr = listener.local_addr().expect("failed to get local addr");
|
||||
|
||||
// ── Server task ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Handles exactly one connection: completes the handshake, asserts that it
|
||||
// receives a Ping, sends a Pong, then returns the client's observed key.
|
||||
let server_kp = Arc::clone(&server_keypair);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, _peer) = listener.accept().await.expect("server accept failed");
|
||||
|
||||
let mut transport = handshake_responder(stream, &server_kp)
|
||||
.await
|
||||
.expect("server Noise_XX handshake failed");
|
||||
|
||||
let env = transport
|
||||
.recv_envelope()
|
||||
.await
|
||||
.expect("server recv_envelope failed");
|
||||
|
||||
match env.msg_type {
|
||||
MsgType::Ping => {}
|
||||
_ => panic!("server expected Ping, received a different message type"),
|
||||
}
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Pong,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("server send_envelope failed");
|
||||
|
||||
// Return the client's public key as authenticated by the server.
|
||||
transport
|
||||
.remote_static_public_key()
|
||||
.expect("server: no remote static key after completed XX handshake")
|
||||
.to_vec()
|
||||
});
|
||||
|
||||
// ── Client side ───────────────────────────────────────────────────────────
|
||||
let stream = tokio::net::TcpStream::connect(server_addr)
|
||||
.await
|
||||
.expect("client connect failed");
|
||||
|
||||
let mut transport = handshake_initiator(stream, &client_keypair)
|
||||
.await
|
||||
.expect("client Noise_XX handshake failed");
|
||||
|
||||
// Capture the server's public key as authenticated by the client.
|
||||
let server_key_seen_by_client = transport
|
||||
.remote_static_public_key()
|
||||
.expect("client: no remote static key after completed XX handshake")
|
||||
.to_vec();
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
})
|
||||
.await
|
||||
.expect("client send_envelope failed");
|
||||
|
||||
let pong = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
transport.recv_envelope(),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for Pong — server task likely panicked")
|
||||
.expect("client recv_envelope failed");
|
||||
|
||||
match pong.msg_type {
|
||||
MsgType::Pong => {}
|
||||
_ => panic!("client expected Pong, received a different message type"),
|
||||
}
|
||||
|
||||
// ── Mutual authentication assertions ──────────────────────────────────────
|
||||
let client_key_seen_by_server = server_task
|
||||
.await
|
||||
.expect("server task panicked — see output above");
|
||||
|
||||
// The server authenticated the client's static public key correctly.
|
||||
assert_eq!(
|
||||
client_key_seen_by_server,
|
||||
client_keypair.public_bytes().to_vec(),
|
||||
"server's authenticated view of client key does not match client's actual public key"
|
||||
);
|
||||
|
||||
// The client authenticated the server's static public key correctly.
|
||||
assert_eq!(
|
||||
server_key_seen_by_client,
|
||||
server_keypair.public_bytes().to_vec(),
|
||||
"client's authenticated view of server key does not match server's actual public key"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second independent connection on the same server must also succeed,
|
||||
/// confirming that the server keypair reuse across connections is correct.
|
||||
#[tokio::test]
|
||||
async fn two_sequential_connections_both_authenticate() {
|
||||
let server_keypair = Arc::new(NoiseKeypair::generate());
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind failed");
|
||||
let server_addr = listener.local_addr().expect("local_addr failed");
|
||||
|
||||
let server_kp = Arc::clone(&server_keypair);
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..2_u8 {
|
||||
let (stream, _) = listener.accept().await.expect("accept failed");
|
||||
let kp = Arc::clone(&server_kp);
|
||||
tokio::spawn(async move {
|
||||
let mut t = handshake_responder(stream, &kp)
|
||||
.await
|
||||
.expect("server handshake failed");
|
||||
let env = t.recv_envelope().await.expect("recv failed");
|
||||
match env.msg_type {
|
||||
MsgType::Ping => {}
|
||||
_ => panic!("expected Ping"),
|
||||
}
|
||||
t.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Pong,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("server send failed");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for _ in 0..2_u8 {
|
||||
let kp = NoiseKeypair::generate();
|
||||
let stream = tokio::net::TcpStream::connect(server_addr)
|
||||
.await
|
||||
.expect("connect failed");
|
||||
let mut t = handshake_initiator(stream, &kp)
|
||||
.await
|
||||
.expect("client handshake failed");
|
||||
|
||||
t.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("client send failed");
|
||||
|
||||
let pong = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
t.recv_envelope(),
|
||||
)
|
||||
.await
|
||||
.expect("timeout")
|
||||
.expect("recv failed");
|
||||
|
||||
match pong.msg_type {
|
||||
MsgType::Pong => {}
|
||||
_ => panic!("expected Pong"),
|
||||
}
|
||||
|
||||
// Each client sees the *same* server public key (key reuse across connections).
|
||||
let seen = t
|
||||
.remote_static_public_key()
|
||||
.expect("no remote key")
|
||||
.to_vec();
|
||||
assert_eq!(seen, server_keypair.public_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user