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