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:
2026-02-19 21:58:51 +01:00
commit 9fa3873bd7
22 changed files with 3521 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
//! noiseml-server — Delivery Service + Authentication Service binary.
//!
//! # M1 scope
//!
//! Accepts Noise_XX connections over TCP and replies to `Ping` frames with
//! `Pong`. The AS and DS RPC interfaces (Cap'n Proto RPC) are added in M2+.
//!
//! # Configuration
//!
//! | Env var | CLI flag | Default |
//! |------------------|-------------|-----------------|
//! | `NOISEML_LISTEN` | `--listen` | `0.0.0.0:7000` |
//! | `RUST_LOG` | — | `info` |
//!
//! # Keypair lifecycle
//!
//! A fresh static X25519 keypair is generated at startup. The public key is
//! logged so clients can optionally pin it. M6 replaces this with persistent
//! key loading from SQLite.
use std::sync::Arc;
use anyhow::Context;
use clap::Parser;
use tokio::net::{TcpListener, TcpStream};
use tracing::Instrument;
use noiseml_core::{CodecError, CoreError, NoiseKeypair, handshake_responder};
use noiseml_proto::{MsgType, ParsedEnvelope};
// ── CLI ───────────────────────────────────────────────────────────────────────
#[derive(Debug, Parser)]
#[command(
name = "noiseml-server",
about = "noiseml Delivery Service + Authentication Service",
version
)]
struct Args {
/// TCP address to listen on.
#[arg(long, default_value = "0.0.0.0:7000", env = "NOISEML_LISTEN")]
listen: 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("info")),
)
.init();
let args = Args::parse();
// Generate a fresh static keypair for this server instance.
// M6 will replace this with persistent key loading from SQLite.
let keypair = Arc::new(NoiseKeypair::generate());
{
let pub_bytes = keypair.public_bytes();
tracing::info!(
listen = %args.listen,
public_key = %fmt_key(&pub_bytes),
"noiseml-server starting — key is ephemeral in M1 (not persisted)"
);
}
let listener = TcpListener::bind(&args.listen)
.await
.with_context(|| format!("failed to bind to {}", args.listen))?;
tracing::info!(listen = %args.listen, "accepting connections");
loop {
let (stream, peer_addr) = listener.accept().await.context("accept failed")?;
let keypair = Arc::clone(&keypair);
tokio::spawn(
async move {
match handle_connection(stream, keypair).await {
Ok(()) => tracing::debug!("connection closed cleanly"),
Err(e) => tracing::warn!(error = %e, "connection error"),
}
}
.instrument(tracing::info_span!("conn", peer = %peer_addr)),
);
}
}
// ── Per-connection handler ────────────────────────────────────────────────────
/// Drive a single client connection through handshake and M1 message loop.
///
/// Returns `Ok(())` on any clean or expected disconnection.
/// Returns `Err` only for unexpected Noise or decryption failures.
async fn handle_connection(
stream: TcpStream,
keypair: Arc<NoiseKeypair>,
) -> Result<(), CoreError> {
let mut transport = handshake_responder(stream, &keypair).await?;
{
let remote = transport
.remote_static_public_key()
.map(fmt_key)
.unwrap_or_else(|| "unknown".into());
tracing::info!(remote_key = %remote, "Noise_XX handshake complete");
}
loop {
let env = match transport.recv_envelope().await {
Ok(env) => env,
// Clean EOF: the peer closed the connection gracefully.
Err(CoreError::ConnectionClosed) => {
tracing::debug!("peer disconnected");
return Ok(());
}
// Unclean TCP close (RST / unexpected EOF): treat as normal disconnect.
Err(CoreError::Codec(CodecError::Io(ref e)))
if matches!(
e.kind(),
std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::UnexpectedEof
| std::io::ErrorKind::BrokenPipe
) =>
{
tracing::debug!(io_kind = %e.kind(), "peer disconnected (unclean)");
return Ok(());
}
Err(e) => return Err(e),
};
match env.msg_type {
MsgType::Ping => {
tracing::debug!("ping → pong");
transport
.send_envelope(&ParsedEnvelope {
msg_type: MsgType::Pong,
group_id: vec![],
sender_id: vec![],
payload: vec![],
timestamp_ms: current_timestamp_ms(),
})
.await?;
}
// All other message types are silently ignored in M1.
// M2 adds AS/DS RPC dispatch here.
_ => {
tracing::warn!("unexpected message type in M1 — ignoring");
}
}
}
}
// ── 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.
///
/// Falls back to 0 if the system clock predates the Unix epoch (pathological).
fn current_timestamp_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}