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,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" }

View 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
}

View 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());
}
}

View File

@@ -0,0 +1,32 @@
[package]
name = "noiseml-core"
version = "0.1.0"
edition = "2021"
description = "Crypto primitives, Noise_XX transport, MLS state machine, and Cap'n Proto frame codec for noiseml."
license = "MIT"
[dependencies]
# Crypto
# openmls / openmls_rust_crypto / openmls_basic_credential — added in M2
# ml-kem — added in M5 (hybrid PQ ciphersuite)
x25519-dalek = { workspace = true }
ed25519-dalek = { workspace = true }
snow = { workspace = true }
sha2 = { workspace = true }
hkdf = { workspace = true }
zeroize = { workspace = true }
rand = { workspace = true }
# Serialisation
capnp = { workspace = true }
noiseml-proto = { path = "../noiseml-proto" }
# Async codec
tokio-util = { workspace = true }
bytes = { version = "1" }
# Error handling
thiserror = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,204 @@
//! Length-prefixed byte frame codec for Tokio's `Framed` adapter.
//!
//! # Wire format
//!
//! ```text
//! ┌──────────────────────────┬──────────────────────────────────────┐
//! │ length (4 bytes, LE u32)│ payload (length bytes) │
//! └──────────────────────────┴──────────────────────────────────────┘
//! ```
//!
//! Little-endian was chosen over big-endian for consistency with Cap'n Proto's
//! own segment table encoding. Both sides of the connection use the same codec.
//!
//! # Usage
//!
//! This codec is transport-agnostic: during the Noise handshake it frames raw
//! Noise handshake messages; after the handshake it frames Noise-encrypted
//! application data. In both cases the payload is opaque bytes from the
//! codec's perspective.
//!
//! # Frame size limit
//!
//! The Noise protocol specifies a maximum message size of 65 535 bytes.
//! Frames larger than [`NOISE_MAX_MSG`] are rejected as protocol violations.
use bytes::{Buf, BufMut, Bytes, BytesMut};
use tokio_util::codec::{Decoder, Encoder};
use crate::error::CodecError;
/// Maximum Noise protocol message size in bytes (per RFC / Noise spec §3).
pub const NOISE_MAX_MSG: usize = 65_535;
/// A stateless codec that prepends / reads a 4-byte little-endian length field.
///
/// Implements both [`Encoder<Bytes>`] and [`Decoder`] so it can be used with
/// `tokio_util::codec::Framed`.
#[derive(Debug, Clone, Copy, Default)]
pub struct LengthPrefixedCodec;
impl LengthPrefixedCodec {
pub fn new() -> Self {
Self
}
}
impl Encoder<Bytes> for LengthPrefixedCodec {
type Error = CodecError;
/// Prepend a 4-byte LE length field and append the payload to `dst`.
///
/// # Errors
///
/// Returns [`CodecError::FrameTooLarge`] if `item.len() > NOISE_MAX_MSG`.
/// Returns [`CodecError::Io`] if the underlying write fails (propagated
/// by `tokio-util` from the TCP stream).
fn encode(&mut self, item: Bytes, dst: &mut BytesMut) -> Result<(), Self::Error> {
let len = item.len();
if len > NOISE_MAX_MSG {
return Err(CodecError::FrameTooLarge {
len,
max: NOISE_MAX_MSG,
});
}
// Reserve exactly the space needed: 4 bytes header + payload.
dst.reserve(4 + len);
dst.put_u32_le(len as u32);
dst.extend_from_slice(&item);
Ok(())
}
}
impl Decoder for LengthPrefixedCodec {
type Item = BytesMut;
type Error = CodecError;
/// Read a length-prefixed frame from `src`.
///
/// Returns `Ok(None)` when more bytes are needed (standard Decoder contract).
/// Returns `Ok(Some(frame))` when a complete frame is available.
///
/// # Errors
///
/// Returns [`CodecError::FrameTooLarge`] if the length field exceeds
/// [`NOISE_MAX_MSG`]. This is treated as an unrecoverable protocol
/// violation — callers should close the connection.
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
// Need at least the 4-byte length header.
if src.len() < 4 {
src.reserve(4_usize.saturating_sub(src.len()));
return Ok(None);
}
// Peek at the length without advancing — avoid mutating state on None.
let frame_len =
u32::from_le_bytes([src[0], src[1], src[2], src[3]]) as usize;
if frame_len > NOISE_MAX_MSG {
return Err(CodecError::FrameTooLarge {
len: frame_len,
max: NOISE_MAX_MSG,
});
}
let total = 4 + frame_len;
if src.len() < total {
// Tell Tokio how many additional bytes we need to avoid O(n) polling.
src.reserve(total - src.len());
return Ok(None);
}
// Consume the 4-byte length header, then split the payload.
src.advance(4);
Ok(Some(src.split_to(frame_len)))
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn encode_then_decode(payload: &[u8]) -> BytesMut {
let mut codec = LengthPrefixedCodec::new();
let mut buf = BytesMut::new();
codec
.encode(Bytes::copy_from_slice(payload), &mut buf)
.expect("encode failed");
let decoded = codec.decode(&mut buf).expect("decode error");
decoded.expect("expected a complete frame")
}
#[test]
fn round_trip_empty_payload() {
let result = encode_then_decode(&[]);
assert!(result.is_empty());
}
#[test]
fn round_trip_small_payload() {
let payload = b"hello noiseml";
let result = encode_then_decode(payload);
assert_eq!(&result[..], payload);
}
#[test]
fn round_trip_max_size_payload() {
let payload = vec![0xAB_u8; NOISE_MAX_MSG];
let result = encode_then_decode(&payload);
assert_eq!(&result[..], &payload[..]);
}
#[test]
fn oversized_encode_returns_error() {
let mut codec = LengthPrefixedCodec::new();
let mut buf = BytesMut::new();
let oversized = Bytes::from(vec![0u8; NOISE_MAX_MSG + 1]);
let err = codec.encode(oversized, &mut buf).unwrap_err();
assert!(matches!(err, CodecError::FrameTooLarge { .. }));
}
#[test]
fn oversized_length_field_decode_returns_error() {
let mut codec = LengthPrefixedCodec::new();
let mut buf = BytesMut::new();
// Encode a fake length field that exceeds NOISE_MAX_MSG.
buf.put_u32_le((NOISE_MAX_MSG + 1) as u32);
let err = codec.decode(&mut buf).unwrap_err();
assert!(matches!(err, CodecError::FrameTooLarge { .. }));
}
#[test]
fn partial_payload_returns_none() {
let mut codec = LengthPrefixedCodec::new();
let mut buf = BytesMut::new();
// Length header says 10 bytes but we only provide 5.
buf.put_u32_le(10);
buf.extend_from_slice(&[0u8; 5]);
let result = codec.decode(&mut buf).expect("decode error");
assert!(result.is_none());
}
#[test]
fn partial_header_returns_none() {
let mut codec = LengthPrefixedCodec::new();
// Only 2 bytes of the 4-byte header are available.
let mut buf = BytesMut::from(&[0x00_u8, 0x01][..]);
let result = codec.decode(&mut buf).expect("decode error");
assert!(result.is_none());
}
#[test]
fn length_field_is_little_endian() {
let payload = b"le-check";
let mut codec = LengthPrefixedCodec::new();
let mut buf = BytesMut::new();
codec
.encode(Bytes::from_static(payload), &mut buf)
.expect("encode failed");
// First 4 bytes are the LE length: 8 in LE is [0x08, 0x00, 0x00, 0x00].
assert_eq!(&buf[..4], &[8, 0, 0, 0]);
}
}

View File

@@ -0,0 +1,71 @@
//! Error types for `noiseml-core`.
//!
//! Two separate error types are used to preserve type-level separation of concerns:
//!
//! - [`CodecError`] — errors from the length-prefixed frame codec (I/O and framing only).
//! `tokio-util` requires the codec error implement `From<io::Error>`.
//!
//! - [`CoreError`] — errors from the Noise handshake and transport layer.
use thiserror::Error;
/// Maximum plaintext bytes per Noise transport frame.
///
/// Noise limits each message to 65 535 bytes. ChaCha20-Poly1305 consumes
/// 16 bytes for the authentication tag, leaving 65 519 bytes for plaintext.
pub const MAX_PLAINTEXT_LEN: usize = 65_519;
// ── Codec errors ──────────────────────────────────────────────────────────────
/// Errors produced by [`LengthPrefixedCodec`](crate::LengthPrefixedCodec).
#[derive(Debug, Error)]
pub enum CodecError {
/// The underlying TCP stream returned an I/O error.
///
/// This variant satisfies the `tokio-util` requirement that codec error
/// types implement `From<std::io::Error>`.
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// A frame length field exceeded the Noise protocol maximum (65 535 bytes).
///
/// This is treated as a protocol violation and the connection should be
/// closed rather than retried.
#[error("frame length {len} exceeds maximum {max} bytes")]
FrameTooLarge { len: usize, max: usize },
}
// ── Core errors ───────────────────────────────────────────────────────────────
/// Errors produced by the Noise handshake and [`NoiseTransport`](crate::NoiseTransport).
#[derive(Debug, Error)]
pub enum CoreError {
/// The `snow` Noise protocol engine returned an error.
///
/// This covers DH failures, decryption failures, state machine violations,
/// and pattern parse errors.
#[error("Noise protocol error: {0}")]
Noise(#[from] snow::Error),
/// The frame codec reported an I/O or framing error.
#[error("frame codec error: {0}")]
Codec(#[from] CodecError),
/// Cap'n Proto serialisation or deserialisation failed.
#[error("Cap'n Proto error: {0}")]
Capnp(#[from] capnp::Error),
/// The remote peer closed the connection before the handshake completed.
#[error("peer closed connection during Noise handshake")]
HandshakeIncomplete,
/// The remote peer closed the connection during normal operation.
#[error("peer closed connection")]
ConnectionClosed,
/// The caller attempted to send a plaintext larger than the Noise maximum.
///
/// The limit is [`MAX_PLAINTEXT_LEN`] bytes per frame.
#[error("plaintext {size} B exceeds Noise frame limit of {MAX_PLAINTEXT_LEN} B")]
MessageTooLarge { size: usize },
}

View File

@@ -0,0 +1,119 @@
//! Static X25519 keypair for the Noise_XX handshake.
//!
//! # Security properties
//!
//! - The private key is stored as [`x25519_dalek::StaticSecret`], which
//! implements [`ZeroizeOnDrop`](zeroize::ZeroizeOnDrop) — the key material
//! is overwritten with zeros when the `StaticSecret` is dropped.
//!
//! - [`NoiseKeypair::private_bytes`] returns a [`Zeroizing`](zeroize::Zeroizing)
//! wrapper so the caller's copy of the raw bytes is also cleared on drop.
//! Pass it directly to `snow::Builder::local_private_key` and let it fall
//! out of scope immediately after.
//!
//! - The public key is not secret and may be freely cloned or logged.
//!
//! # Persistence
//!
//! `NoiseKeypair` does not implement `Serialize` intentionally. Key persistence
//! to disk is handled at the application layer (M6) with appropriate file
//! permission checks and, optionally, passphrase-based encryption.
use rand::rngs::OsRng;
use x25519_dalek::{PublicKey, StaticSecret};
use zeroize::Zeroizing;
/// A static X25519 keypair used for Noise_XX mutual authentication.
///
/// Generate once per node identity and reuse across connections.
/// The private scalar is zeroized when this value is dropped.
pub struct NoiseKeypair {
/// Private scalar — zeroized on drop via `x25519_dalek`'s `ZeroizeOnDrop` impl.
private: StaticSecret,
/// Corresponding public key — derived from `private` at construction time.
public: PublicKey,
}
impl NoiseKeypair {
/// Generate a fresh keypair from the OS CSPRNG.
///
/// This calls `getrandom` on Linux (via `OsRng`) and is suitable for
/// generating long-lived static identity keys.
pub fn generate() -> Self {
let private = StaticSecret::random_from_rng(OsRng);
let public = PublicKey::from(&private);
Self { private, public }
}
/// Return the raw private key bytes in a [`Zeroizing`] wrapper.
///
/// The returned wrapper clears the 32-byte copy when dropped.
/// Use it immediately to initialise a `snow::Builder` and let it drop:
///
/// ```rust,ignore
/// let private = keypair.private_bytes();
/// let session = snow::Builder::new(params)
/// .local_private_key(&private[..])
/// .build_initiator()?;
/// // `private` is zeroized here.
/// ```
pub fn private_bytes(&self) -> Zeroizing<[u8; 32]> {
Zeroizing::new(self.private.to_bytes())
}
/// Return the public key bytes.
///
/// Safe to log or transmit — this is not secret material.
pub fn public_bytes(&self) -> [u8; 32] {
self.public.to_bytes()
}
}
// Prevent accidental `{:?}` printing of the private key.
impl std::fmt::Debug for NoiseKeypair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Show only the first 4 bytes of the public key as a sanity identifier.
// No external crate needed; the private key is never printed.
let pub_bytes = self.public_bytes();
write!(
f,
"NoiseKeypair {{ public: {:02x}{:02x}{:02x}{:02x}…, private: [redacted] }}",
pub_bytes[0], pub_bytes[1], pub_bytes[2], pub_bytes[3],
)
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_public_key_matches_private() {
let kp = NoiseKeypair::generate();
// Re-derive the public key from the private bytes and confirm they match.
let private_bytes = kp.private_bytes();
let secret = StaticSecret::from(*private_bytes);
let rederived = PublicKey::from(&secret);
assert_eq!(rederived.to_bytes(), kp.public_bytes());
}
#[test]
fn two_keypairs_differ() {
let a = NoiseKeypair::generate();
let b = NoiseKeypair::generate();
assert_ne!(a.public_bytes(), b.public_bytes());
}
#[test]
fn private_bytes_is_zeroizing() {
// Verify that Zeroizing<[u8;32]> does not expose the key via Debug.
let kp = NoiseKeypair::generate();
let private = kp.private_bytes();
// We cannot observe zeroization after drop in a test without unsafe,
// but we can confirm the wrapper type is returned and is non-zero.
assert!(private.iter().any(|&b| b != 0),
"freshly generated private key should not be all zeros");
}
}

View File

@@ -0,0 +1,28 @@
//! Core cryptographic primitives, Noise_XX transport, and frame codec for noiseml.
//!
//! # Module layout
//!
//! | Module | Responsibility |
//! |------------|----------------------------------------------------------|
//! | `error` | [`CoreError`] and [`CodecError`] types |
//! | `keypair` | [`NoiseKeypair`] — static X25519 key, zeroize-on-drop |
//! | `codec` | [`LengthPrefixedCodec`] — Tokio Encoder + Decoder |
//! | `noise` | [`handshake_initiator`], [`handshake_responder`], [`NoiseTransport`] |
//!
//! # What is NOT in this crate (M1)
//!
//! - MLS group state machine — added in M2/M3 (`openmls` integration)
//! - Hybrid PQ KEM — added in M5
//! - Ed25519 identity keypair — added in M2 (needed for MLS credentials)
mod codec;
mod error;
mod keypair;
mod noise;
// ── Public API ────────────────────────────────────────────────────────────────
pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG};
pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN};
pub use keypair::NoiseKeypair;
pub use noise::{handshake_initiator, handshake_responder, NoiseTransport};

View File

@@ -0,0 +1,325 @@
//! Noise_XX handshake and encrypted transport.
//!
//! # Protocol
//!
//! Pattern: `Noise_XX_25519_ChaChaPoly_BLAKE2s`
//!
//! ```text
//! XX handshake (3 messages):
//! -> e (initiator sends ephemeral public key)
//! <- e, ee, s, es (responder replies; mutual DH + responder static)
//! -> s, se (initiator sends static key; final DH)
//! ```
//!
//! After the handshake both peers have authenticated each other's static X25519
//! keys and negotiated a symmetric session with ChaCha20-Poly1305.
//!
//! # Framing
//!
//! All messages — handshake and application — are carried in length-prefixed
//! frames (see [`LengthPrefixedCodec`](crate::LengthPrefixedCodec)).
//!
//! In the handshake phase the frame payload is the raw Noise handshake bytes
//! produced by `snow`. In the transport phase the frame payload is a
//! Noise-encrypted Cap'n Proto message.
//!
//! # Post-quantum gap (ADR-006)
//!
//! The Noise transport uses classical X25519. PQ-Noise is not yet standardised
//! in `snow`. MLS application data is PQ-protected from M5 onward. The residual
//! risk (metadata exposure via handshake harvest) is accepted for M1M5.
use bytes::Bytes;
use futures::{SinkExt, StreamExt};
use tokio::net::TcpStream;
use tokio_util::codec::Framed;
use crate::{
codec::{LengthPrefixedCodec, NOISE_MAX_MSG},
error::{CoreError, MAX_PLAINTEXT_LEN},
keypair::NoiseKeypair,
};
use noiseml_proto::{parse_envelope, build_envelope, ParsedEnvelope};
/// Noise parameters used throughout noiseml.
///
/// `Noise_XX_25519_ChaChaPoly_BLAKE2s` — both parties authenticate each
/// other's static X25519 keys; ChaCha20-Poly1305 for AEAD; BLAKE2s as PRF.
const NOISE_PARAMS: &str = "Noise_XX_25519_ChaChaPoly_BLAKE2s";
/// ChaCha20-Poly1305 authentication tag overhead per Noise message.
const NOISE_TAG_LEN: usize = 16;
// ── Public type ───────────────────────────────────────────────────────────────
/// An authenticated, encrypted Noise transport session.
///
/// Obtained by completing a [`handshake_initiator`] or [`handshake_responder`]
/// call. All subsequent I/O is through [`send_frame`](Self::send_frame) and
/// [`recv_frame`](Self::recv_frame), or the higher-level envelope helpers.
///
/// # Thread safety
///
/// `NoiseTransport` is `Send` but not `Clone` or `Sync`. Use one instance per
/// Tokio task; use message passing to share data across tasks.
pub struct NoiseTransport {
/// The TCP stream wrapped in the length-prefix codec.
framed: Framed<TcpStream, LengthPrefixedCodec>,
/// The Noise session in transport mode — encrypts and decrypts frames.
session: snow::TransportState,
/// Remote peer's static X25519 public key, captured from the HandshakeState
/// before `into_transport_mode()` consumes it.
///
/// Stored here explicitly rather than via `TransportState::get_remote_static()`
/// because snow does not guarantee the method survives the mode transition.
remote_static: Option<Vec<u8>>,
}
impl NoiseTransport {
// ── Transport-layer I/O ───────────────────────────────────────────────────
/// Encrypt `plaintext` and send it as a single length-prefixed frame.
///
/// # Errors
///
/// - [`CoreError::MessageTooLarge`] if `plaintext` exceeds
/// [`MAX_PLAINTEXT_LEN`] bytes.
/// - [`CoreError::Noise`] if the Noise session fails to encrypt.
/// - [`CoreError::Codec`] if the underlying TCP write fails.
pub async fn send_frame(&mut self, plaintext: &[u8]) -> Result<(), CoreError> {
if plaintext.len() > MAX_PLAINTEXT_LEN {
return Err(CoreError::MessageTooLarge {
size: plaintext.len(),
});
}
// Allocate exactly the right amount: plaintext + AEAD tag.
let mut ciphertext = vec![0u8; plaintext.len() + NOISE_TAG_LEN];
let len = self
.session
.write_message(plaintext, &mut ciphertext)
.map_err(CoreError::Noise)?;
self.framed
.send(Bytes::copy_from_slice(&ciphertext[..len]))
.await
.map_err(CoreError::Codec)?;
Ok(())
}
/// Receive the next length-prefixed frame and decrypt it.
///
/// Awaits until a complete frame arrives on the TCP stream.
///
/// # Errors
///
/// - [`CoreError::ConnectionClosed`] if the peer closed the connection.
/// - [`CoreError::Noise`] if decryption or authentication fails.
/// - [`CoreError::Codec`] if the underlying TCP read or framing fails.
pub async fn recv_frame(&mut self) -> Result<Vec<u8>, CoreError> {
let ciphertext = self
.framed
.next()
.await
.ok_or(CoreError::ConnectionClosed)?
.map_err(CoreError::Codec)?;
// Plaintext is always shorter than ciphertext (AEAD tag is stripped).
let mut plaintext = vec![0u8; ciphertext.len()];
let len = self
.session
.read_message(&ciphertext, &mut plaintext)
.map_err(CoreError::Noise)?;
plaintext.truncate(len);
Ok(plaintext)
}
// ── Envelope-level I/O ────────────────────────────────────────────────────
/// Serialise and encrypt a [`ParsedEnvelope`], then send it.
///
/// This is the primary application-level send method. The Cap'n Proto
/// encoding is done by [`noiseml_proto::build_envelope`] before encryption.
pub async fn send_envelope(&mut self, env: &ParsedEnvelope) -> Result<(), CoreError> {
let bytes = build_envelope(env).map_err(CoreError::Capnp)?;
self.send_frame(&bytes).await
}
/// Receive a frame, decrypt it, and deserialise it as a [`ParsedEnvelope`].
///
/// This is the primary application-level receive method.
pub async fn recv_envelope(&mut self) -> Result<ParsedEnvelope, CoreError> {
let bytes = self.recv_frame().await?;
parse_envelope(&bytes).map_err(CoreError::Capnp)
}
// ── Session metadata ──────────────────────────────────────────────────────
/// Return the remote peer's static X25519 public key (32 bytes), as
/// authenticated during the Noise_XX handshake.
///
/// Returns `None` only in the impossible case where the XX handshake
/// completed without exchanging static keys (a snow implementation bug).
/// In practice this is always `Some` after a successful handshake.
pub fn remote_static_public_key(&self) -> Option<&[u8]> {
self.remote_static.as_deref()
}
}
impl std::fmt::Debug for NoiseTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let remote = self.remote_static.as_deref().map(|k| {
format!("{:02x}{:02x}{:02x}{:02x}", k[0], k[1], k[2], k[3])
});
f.debug_struct("NoiseTransport")
.field("remote_static", &remote)
.finish_non_exhaustive()
}
}
// ── Handshake functions ───────────────────────────────────────────────────────
/// Complete a Noise_XX handshake as the **initiator** over `stream`.
///
/// The initiator sends the first handshake message. After the three-message
/// exchange completes, the function returns an authenticated [`NoiseTransport`]
/// ready for application data.
///
/// # Errors
///
/// - [`CoreError::HandshakeIncomplete`] if the peer closes the connection mid-handshake.
/// - [`CoreError::Noise`] if any Noise operation fails (pattern mismatch, bad DH, etc.).
/// - [`CoreError::Codec`] if any TCP I/O fails during the handshake.
pub async fn handshake_initiator(
stream: TcpStream,
keypair: &NoiseKeypair,
) -> Result<NoiseTransport, CoreError> {
let params: snow::params::NoiseParams = NOISE_PARAMS.parse().expect(
"NOISE_PARAMS is a compile-time constant and must parse successfully",
);
// The private key bytes are held in a Zeroizing wrapper and cleared after
// snow clones them internally during build_initiator().
let private = keypair.private_bytes();
let mut session = snow::Builder::new(params)
.local_private_key(&private[..])
.build_initiator()
.map_err(CoreError::Noise)?;
drop(private); // zeroize our copy; snow holds its own internal copy
let mut framed = Framed::new(stream, LengthPrefixedCodec::new());
let mut buf = vec![0u8; NOISE_MAX_MSG];
// ── Message 1: -> e ──────────────────────────────────────────────────────
let len = session
.write_message(&[], &mut buf)
.map_err(CoreError::Noise)?;
framed
.send(Bytes::copy_from_slice(&buf[..len]))
.await
.map_err(CoreError::Codec)?;
// ── Message 2: <- e, ee, s, es ───────────────────────────────────────────
let msg2 = recv_handshake_frame(&mut framed).await?;
session
.read_message(&msg2, &mut buf)
.map_err(CoreError::Noise)?;
// ── Message 3: -> s, se ──────────────────────────────────────────────────
let len = session
.write_message(&[], &mut buf)
.map_err(CoreError::Noise)?;
framed
.send(Bytes::copy_from_slice(&buf[..len]))
.await
.map_err(CoreError::Codec)?;
// Zeroize the scratch buffer — it contained plaintext key material during
// the handshake (ephemeral key bytes in message 2 payload).
zeroize::Zeroize::zeroize(&mut buf);
// Capture the remote static key from HandshakeState before consuming it.
let remote_static = session.get_remote_static().map(|k| k.to_vec());
let transport_session = session.into_transport_mode().map_err(CoreError::Noise)?;
Ok(NoiseTransport {
framed,
session: transport_session,
remote_static,
})
}
/// Complete a Noise_XX handshake as the **responder** over `stream`.
///
/// The responder waits for the initiator's first message. After the
/// three-message exchange completes, the function returns an authenticated
/// [`NoiseTransport`] ready for application data.
///
/// # Errors
///
/// Same as [`handshake_initiator`].
pub async fn handshake_responder(
stream: TcpStream,
keypair: &NoiseKeypair,
) -> Result<NoiseTransport, CoreError> {
let params: snow::params::NoiseParams = NOISE_PARAMS.parse().expect(
"NOISE_PARAMS is a compile-time constant and must parse successfully",
);
let private = keypair.private_bytes();
let mut session = snow::Builder::new(params)
.local_private_key(&private[..])
.build_responder()
.map_err(CoreError::Noise)?;
drop(private);
let mut framed = Framed::new(stream, LengthPrefixedCodec::new());
let mut buf = vec![0u8; NOISE_MAX_MSG];
// ── Message 1: <- e ──────────────────────────────────────────────────────
let msg1 = recv_handshake_frame(&mut framed).await?;
session
.read_message(&msg1, &mut buf)
.map_err(CoreError::Noise)?;
// ── Message 2: -> e, ee, s, es ───────────────────────────────────────────
let len = session
.write_message(&[], &mut buf)
.map_err(CoreError::Noise)?;
framed
.send(Bytes::copy_from_slice(&buf[..len]))
.await
.map_err(CoreError::Codec)?;
// ── Message 3: <- s, se ──────────────────────────────────────────────────
let msg3 = recv_handshake_frame(&mut framed).await?;
session
.read_message(&msg3, &mut buf)
.map_err(CoreError::Noise)?;
zeroize::Zeroize::zeroize(&mut buf);
// Capture the remote static key from HandshakeState before consuming it.
let remote_static = session.get_remote_static().map(|k| k.to_vec());
let transport_session = session.into_transport_mode().map_err(CoreError::Noise)?;
Ok(NoiseTransport {
framed,
session: transport_session,
remote_static,
})
}
// ── Private helpers ───────────────────────────────────────────────────────────
/// Read one handshake frame from `framed`, mapping stream closure to
/// [`CoreError::HandshakeIncomplete`].
async fn recv_handshake_frame(
framed: &mut Framed<TcpStream, LengthPrefixedCodec>,
) -> Result<bytes::BytesMut, CoreError> {
framed
.next()
.await
.ok_or(CoreError::HandshakeIncomplete)?
.map_err(CoreError::Codec)
}

View File

@@ -0,0 +1,15 @@
[package]
name = "noiseml-proto"
version = "0.1.0"
edition = "2021"
description = "Cap'n Proto schemas, generated types, and serialisation helpers for noiseml. No crypto, no I/O."
license = "MIT"
# build.rs invokes capnpc to generate Rust source from .capnp schemas.
build = "build.rs"
[dependencies]
capnp = { workspace = true }
[build-dependencies]
capnpc = { workspace = true }

View File

@@ -0,0 +1,45 @@
//! Build script for noiseml-proto.
//!
//! Invokes the `capnp` compiler to generate Rust types from `.capnp` schemas
//! located in the workspace-root `schemas/` directory.
//!
//! # Prerequisites
//!
//! The `capnp` CLI must be installed and on `PATH`.
//!
//! Debian/Ubuntu: apt-get install capnproto
//! macOS: brew install capnp
//! Docker: see docker/Dockerfile
use std::{env, path::PathBuf};
fn main() {
let manifest_dir = PathBuf::from(
env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set by Cargo"),
);
// Workspace root is two levels above this crate (noiseml/crates/noiseml-proto).
let workspace_root = manifest_dir
.join("../..")
.canonicalize()
.expect("could not canonicalize workspace root path");
let schemas_dir = workspace_root.join("schemas");
// Re-run this build script whenever any schema file changes.
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("envelope.capnp").display()
);
capnpc::CompilerCommand::new()
// Treat `schemas/` as the include root so that inter-schema imports
// (e.g. `using import "/auth.capnp"`) resolve correctly in later milestones.
.src_prefix(&schemas_dir)
.file(schemas_dir.join("envelope.capnp"))
.run()
.expect(
"Cap'n Proto schema compilation failed. \
Is `capnp` installed? (apt-get install capnproto / brew install capnp)",
);
}

View File

@@ -0,0 +1,197 @@
//! Cap'n Proto schemas, generated types, and serialisation helpers for noiseml.
//!
//! # Design constraints
//!
//! This crate is intentionally restricted:
//! - **No crypto** — key material never enters this crate.
//! - **No I/O** — callers own transport; this crate only converts bytes ↔ types.
//! - **No async** — pure synchronous data-layer code.
//!
//! # Generated code
//!
//! `build.rs` invokes `capnpc` at compile time and writes generated Rust source
//! into `$OUT_DIR`. The `include!` macros below splice that code in as a module.
//!
//! # Canonical serialisation (M2+)
//!
//! `build_envelope` uses standard Cap'n Proto wire format. Canonical serialisation
//! (deterministic byte representation for cryptographic signing of KeyPackages and
//! Commits) is added in M2 once the Authentication Service is introduced.
// ── Generated types ───────────────────────────────────────────────────────────
/// Cap'n Proto generated types for `schemas/envelope.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod envelope_capnp {
include!(concat!(env!("OUT_DIR"), "/envelope_capnp.rs"));
}
// ── Re-exports ────────────────────────────────────────────────────────────────
/// The message-type discriminant from the `Envelope` schema.
///
/// Re-exported here so callers can `use noiseml_proto::MsgType` without
/// spelling out the full generated module path.
pub use envelope_capnp::envelope::MsgType;
// ── Owned envelope type ───────────────────────────────────────────────────────
/// An owned, decoded `Envelope` with no Cap'n Proto reader lifetimes.
///
/// All byte fields are eagerly copied out of the Cap'n Proto reader so that
/// this type is `Send + 'static` and can cross async task boundaries freely.
///
/// # Invariants
///
/// - `group_id` and `sender_id` are either empty (for control messages such as
/// `Ping`/`Pong`) or exactly 32 bytes (SHA-256 digest).
/// - `payload` is empty for `Ping` and `Pong`; non-empty for all MLS variants.
#[derive(Debug, Clone)]
pub struct ParsedEnvelope {
pub msg_type: MsgType,
/// SHA-256 of the group name, or empty for point-to-point control messages.
pub group_id: Vec<u8>,
/// SHA-256 of the sender's Ed25519 identity public key, or empty.
pub sender_id: Vec<u8>,
/// Opaque payload — interpretation is determined by `msg_type`.
pub payload: Vec<u8>,
/// Unix timestamp in milliseconds.
pub timestamp_ms: u64,
}
// ── Serialisation helpers ─────────────────────────────────────────────────────
/// Serialise a [`ParsedEnvelope`] to unpacked Cap'n Proto wire bytes.
///
/// The returned bytes include the Cap'n Proto segment table header followed by
/// the message data. They are suitable for use as the body of a length-prefixed
/// noiseml frame (the frame codec in `noiseml-core` prepends the 4-byte length).
///
/// # Errors
///
/// Returns [`capnp::Error`] if the underlying allocator fails (out of memory).
/// This is not expected under normal operation.
pub fn build_envelope(env: &ParsedEnvelope) -> Result<Vec<u8>, capnp::Error> {
use capnp::message;
let mut message = message::Builder::new_default();
{
let mut root = message.init_root::<envelope_capnp::envelope::Builder>();
root.set_msg_type(env.msg_type);
root.set_group_id(&env.group_id);
root.set_sender_id(&env.sender_id);
root.set_payload(&env.payload);
root.set_timestamp_ms(env.timestamp_ms);
}
to_bytes(&message)
}
/// Deserialise unpacked Cap'n Proto wire bytes into a [`ParsedEnvelope`].
///
/// All data is copied out of the Cap'n Proto reader before returning, so the
/// input slice is not retained.
///
/// # Errors
///
/// - [`capnp::Error`] if the bytes are not valid Cap'n Proto wire format.
/// - [`capnp::Error`] if `msgType` contains a discriminant not present in the
/// current schema (forward-compatibility guard).
pub fn parse_envelope(bytes: &[u8]) -> Result<ParsedEnvelope, capnp::Error> {
let reader = from_bytes(bytes)?;
let root = reader.get_root::<envelope_capnp::envelope::Reader>()?;
let msg_type = root.get_msg_type().map_err(|nis| {
capnp::Error::failed(format!(
"Envelope.msgType contains unknown discriminant: {nis}"
))
})?;
Ok(ParsedEnvelope {
msg_type,
group_id: root.get_group_id()?.to_vec(),
sender_id: root.get_sender_id()?.to_vec(),
payload: root.get_payload()?.to_vec(),
timestamp_ms: root.get_timestamp_ms(),
})
}
// ── Low-level byte ↔ message conversions ──────────────────────────────────────
/// Serialise a Cap'n Proto message builder to unpacked wire bytes.
///
/// The output includes the segment table header. For transport, the
/// `noiseml-core` frame codec prepends a 4-byte little-endian length field.
pub fn to_bytes<A: capnp::message::Allocator>(
msg: &capnp::message::Builder<A>,
) -> Result<Vec<u8>, capnp::Error> {
let mut buf = Vec::new();
capnp::serialize::write_message(&mut buf, msg)?;
Ok(buf)
}
/// Deserialise unpacked wire bytes into a message with owned segments.
///
/// Uses `ReaderOptions::new()` (default limits: 64 MiB, 512 nesting levels).
/// Callers that receive data from untrusted peers should consider tightening
/// the traversal limit via `ReaderOptions::traversal_limit_in_words`.
pub fn from_bytes(
bytes: &[u8],
) -> Result<capnp::message::Reader<capnp::serialize::OwnedSegments>, capnp::Error> {
let mut cursor = std::io::Cursor::new(bytes);
capnp::serialize::read_message(&mut cursor, capnp::message::ReaderOptions::new())
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Round-trip a Ping envelope through build → parse and verify all fields.
#[test]
fn ping_round_trip() {
let original = ParsedEnvelope {
msg_type: MsgType::Ping,
group_id: vec![],
sender_id: vec![0xAB; 32],
payload: vec![],
timestamp_ms: 1_700_000_000_000,
};
let bytes = build_envelope(&original).expect("build_envelope failed");
let parsed = parse_envelope(&bytes).expect("parse_envelope failed");
assert!(matches!(parsed.msg_type, MsgType::Ping));
assert_eq!(parsed.group_id, original.group_id);
assert_eq!(parsed.sender_id, original.sender_id);
assert_eq!(parsed.payload, original.payload);
assert_eq!(parsed.timestamp_ms, original.timestamp_ms);
}
/// Round-trip a Pong envelope.
#[test]
fn pong_round_trip() {
let original = ParsedEnvelope {
msg_type: MsgType::Pong,
group_id: vec![],
sender_id: vec![0xCD; 32],
payload: vec![],
timestamp_ms: 1_700_000_001_000,
};
let bytes = build_envelope(&original).expect("build_envelope failed");
let parsed = parse_envelope(&bytes).expect("parse_envelope failed");
assert!(matches!(parsed.msg_type, MsgType::Pong));
assert_eq!(parsed.sender_id, original.sender_id);
assert_eq!(parsed.timestamp_ms, original.timestamp_ms);
}
/// Corrupted bytes must produce an error, not a panic.
#[test]
fn corrupted_bytes_error() {
let result = parse_envelope(&[0xFF, 0xFF, 0xFF, 0xFF]);
assert!(result.is_err(), "expected error for corrupted input");
}
}

View File

@@ -0,0 +1,32 @@
[package]
name = "noiseml-server"
version = "0.1.0"
edition = "2021"
description = "Delivery Service and Authentication Service for noiseml."
license = "MIT"
[[bin]]
name = "noiseml-server"
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 }
# Server utilities
dashmap = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# Error handling
anyhow = { workspace = true }
thiserror = { workspace = true }

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
}