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

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
target/
.git/
.gitignore
*.md
docs/
docker-compose.yml
.dockerignore

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
**/*.rs.bk
.vscode/

1340
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

60
Cargo.toml Normal file
View File

@@ -0,0 +1,60 @@
[workspace]
resolver = "2"
members = [
"crates/noiseml-core",
"crates/noiseml-proto",
"crates/noiseml-server",
"crates/noiseml-client",
]
# Shared dependency versions — bump here to affect the whole workspace.
[workspace.dependencies]
# ── Crypto ────────────────────────────────────────────────────────────────────
openmls = { version = "0.5", default-features = false, features = ["crypto-subtle"] }
openmls_rust_crypto = { version = "0.2" }
openmls_basic_credential = { version = "0.2" }
# ml-kem 0.2 is the current stable release (FIPS 203, ML-KEM-768).
# All three parameter sets (512/768/1024) are compiled in by default — no feature flag needed.
ml-kem = { version = "0.2" }
x25519-dalek = { version = "2", features = ["static_secrets"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
snow = { version = "0.9", features = ["default-resolver"] }
sha2 = { version = "0.10" }
hkdf = { version = "0.12" }
zeroize = { version = "1", features = ["derive"] }
rand = { version = "0.8" }
# ── Serialisation + RPC ───────────────────────────────────────────────────────
capnp = { version = "0.19" }
capnp-rpc = { version = "0.19" }
# ── Async / networking ────────────────────────────────────────────────────────
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }
futures = { version = "0.3" }
# ── Server utilities ──────────────────────────────────────────────────────────
dashmap = { version = "5" }
tracing = { version = "0.1" }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# ── Error handling ────────────────────────────────────────────────────────────
anyhow = { version = "1" }
thiserror = { version = "1" }
# ── CLI ───────────────────────────────────────────────────────────────────────
clap = { version = "4", features = ["derive"] }
# ── Build-time ────────────────────────────────────────────────────────────────
capnpc = { version = "0.19" }
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
[profile.dev]
opt-level = 0
debug = true

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
}

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
server:
build:
context: .
dockerfile: docker/Dockerfile
ports:
- "7000:7000"
environment:
RUST_LOG: "info"
NOISEML_LISTEN: "0.0.0.0:7000"
# Healthcheck: attempt a TCP connection to port 7000.
# Uses bash /dev/tcp — available in debian:bookworm-slim without extra packages.
healthcheck:
test: ["CMD", "bash", "-c", "echo '' > /dev/tcp/localhost/7000"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
restart: unless-stopped

71
docker/Dockerfile Normal file
View File

@@ -0,0 +1,71 @@
# ── Stage 1: Builder ──────────────────────────────────────────────────────────
#
# Uses the official Rust image on Debian Bookworm.
# capnproto is installed here because build.rs invokes `capnp` at compile time.
FROM rust:bookworm AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends capnproto \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Copy manifests first so dependency layers are cached independently of source.
COPY Cargo.toml Cargo.lock ./
COPY crates/noiseml-core/Cargo.toml crates/noiseml-core/Cargo.toml
COPY crates/noiseml-proto/Cargo.toml crates/noiseml-proto/Cargo.toml
COPY crates/noiseml-server/Cargo.toml crates/noiseml-server/Cargo.toml
COPY crates/noiseml-client/Cargo.toml crates/noiseml-client/Cargo.toml
# Create dummy source files so `cargo build` can resolve the dependency graph
# and cache the compiled dependencies before copying real source.
RUN mkdir -p \
crates/noiseml-core/src \
crates/noiseml-proto/src \
crates/noiseml-server/src \
crates/noiseml-client/src \
&& echo 'fn main() {}' > crates/noiseml-server/src/main.rs \
&& echo 'fn main() {}' > crates/noiseml-client/src/main.rs \
&& touch crates/noiseml-core/src/lib.rs \
&& touch crates/noiseml-proto/src/lib.rs
# Schemas must exist before the proto crate's build.rs runs.
COPY schemas/ schemas/
# Build dependencies only (source stubs mean this layer is cache-friendly).
RUN cargo build --release --bin noiseml-server 2>/dev/null || true
# Copy real source and build for real.
COPY crates/ crates/
# Touch main.rs files to force re-compilation of the binary crates.
RUN touch \
crates/noiseml-core/src/lib.rs \
crates/noiseml-proto/src/lib.rs \
crates/noiseml-server/src/main.rs \
crates/noiseml-client/src/main.rs
RUN cargo build --release --bin noiseml-server
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
#
# Minimal Debian Bookworm image — no Rust toolchain, no capnp compiler.
FROM debian:bookworm-slim AS runtime
# ca-certificates is included so future HTTPS calls (e.g. from M6 key sync)
# work without further changes to this stage.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/noiseml-server /usr/local/bin/noiseml-server
EXPOSE 7000
ENV RUST_LOG=info \
NOISEML_LISTEN=0.0.0.0:7000
# Run as a non-root user.
USER nobody
CMD ["noiseml-server"]

329
master-prompt.md Normal file
View File

@@ -0,0 +1,329 @@
# noiseml — Master Project Prompt
## Project Identity
You are building **noiseml**, a production-grade end-to-end encrypted group messenger in Rust. It uses the MLS protocol (RFC 9420) for group key agreement, ML-KEM-768 (NIST FIPS 203) hybrid post-quantum key exchange, the Noise Protocol Framework (Noise_XX pattern) over raw TCP as the transport layer, and Cap'n Proto for wire serialisation and RPC. There is no TLS, no HTTP, no WebSocket, no MessagePack.
This is not a prototype. Every milestone produces production-ready, tested, deployable code.
---
## Non-Negotiable Engineering Standards
- **Production-ready only.** No stubs, mocks, `todo!()`, `unimplemented!()`, or placeholder logic in deliverables. If something is out of scope for the current milestone, it is explicitly omitted with a documented reason, not silently stubbed.
- **YAGNI / KISS / DRY.** Do not add features, abstractions, or generics that are not required by the current milestone. Favour clarity over cleverness.
- **Spec-first.** Document the design (ADR or inline doc comment) before implementing it. Every public API must have a doc comment explaining what it does, its invariants, and any error conditions.
- **Security-by-design.** Secrets use `zeroize`. No secret material in logs. No `unwrap()` on cryptographic operations — all errors are typed and propagated. Constant-time comparisons where required.
- **Containerised.** The server runs in Docker. `docker-compose.yml` is always kept up to date.
- **Dependency hygiene.** Pin major versions. Prefer the `dalek` ecosystem for classical crypto, `snow` for Noise, `openmls` for MLS, `ml-kem` for post-quantum, `capnp`/`capnp-rpc` for serialisation and RPC. Do not introduce new dependencies without justification.
- **Review before presenting.** Before presenting any code, review it for: missing error handling, security gaps, incomplete implementations, and deviation from these standards. Fix all issues found before output.
---
## Git Standards
- GPG-signed commits only.
- Conventional commits: `feat:`, `fix:`, `chore:`, `docs:`, `test:`, `refactor:`.
- Feature branches per milestone: `feat/m1-noise-transport`, `feat/m2-keypackage-as`, etc.
- No `Co-authored-by` trailers.
- Commit messages describe *why*, not just *what*.
---
## Architecture
### Workspace Layout
```
noiseml/
├── Cargo.toml # workspace root
├── crates/
│ ├── noiseml-core/ # crypto primitives, MLS wrapper, Noise framing codec
│ ├── noiseml-proto/ # Cap'n Proto schemas + generated types, no crypto, no I/O
│ ├── noiseml-server/ # Delivery Service (DS) + Authentication Service (AS)
│ └── noiseml-client/ # CLI client
├── schemas/ # .capnp schema files (canonical source of truth)
│ ├── envelope.capnp
│ ├── auth.capnp
│ └── delivery.capnp
├── docker/
│ └── Dockerfile
├── docker-compose.yml
└── docs/
└── architecture.md
```
### Crate Responsibilities
**noiseml-core**
- Noise_XX handshake initiator and responder (via `snow`)
- Length-prefixed Cap'n Proto frame codec (Tokio `Encoder`/`Decoder` traits)
- MLS group state machine wrapper around `openmls`
- Hybrid PQ ciphersuite (X25519 + ML-KEM-768)
- Key generation and zeroize-on-drop key types
**noiseml-proto**
- Cap'n Proto `.capnp` schemas in `schemas/` (workspace root, shared)
- `build.rs` invokes `capnpc` to generate Rust types into `src/generated/`
- Re-exports generated types with ergonomic builder/reader helpers
- Canonical serialisation helpers for signing (uses `capnp::message::Builder::canonicalize()`)
- No crypto, no I/O, no async
**noiseml-server**
- Authentication Service: KeyPackage store (DashMap → SQLite at M6)
- Delivery Service: Cap'n Proto RPC interface, fan-out router, per-group append-only message log
- Tokio TCP listener, Noise handshake per connection, then Cap'n Proto RPC over the encrypted channel
- Structured logging (tracing)
**noiseml-client**
- Tokio TCP connection to server
- Noise handshake, then Cap'n Proto RPC client stub
- CLI interface (clap)
- Drives noiseml-core for all crypto operations
- Displays received messages to stdout
### Transport Stack
```
TCP connection
└── Noise_XX handshake (snow)
└── Authenticated encrypted channel (ChaCha20-Poly1305)
└── [u32 frame_len][Cap'n Proto encoded message]
└── Cap'n Proto RPC (capnp-rpc, M2+)
```
Both sides hold static X25519 keypairs for the Noise handshake and Ed25519 keypairs for MLS identity. After Noise_XX, mutual authentication is complete. All subsequent frames are Noise-encrypted. Cap'n Proto RPC runs inside the encrypted channel — it has no knowledge of the transport security.
### Cap'n Proto Schemas
```capnp
# schemas/envelope.capnp
@0xDEADBEEFCAFEBABE; # unique file ID (generate with: capnp id)
struct Envelope {
msgType @0 :MsgType;
groupId @1 :Data; # 32 bytes, SHA-256 of group name
senderId @2 :Data; # 32 bytes, SHA-256 of sender identity key
payload @3 :Data; # opaque: MLS blob or control payload
timestampMs @4 :UInt64; # unix milliseconds
enum MsgType {
ping @0;
pong @1;
keyPackageUpload @2;
keyPackageFetch @3;
keyPackageResponse @4;
mlsWelcome @5;
mlsCommit @6;
mlsApplication @7;
error @8;
}
}
# schemas/auth.capnp
@0xAAAABBBBCCCCDDDD;
interface AuthenticationService {
# Upload a KeyPackage for later retrieval by peers adding this client to a group.
# identityKey: Ed25519 public key bytes (32 bytes)
# package: openmls-serialised KeyPackage blob
# Returns the SHA-256 fingerprint of the package on success.
uploadKeyPackage @0 (identityKey :Data, package :Data) -> (fingerprint :Data);
# Fetch one KeyPackage for a given identity key.
# Consuming: the server removes the returned KeyPackage (one-time use, MLS spec).
# Returns empty Data if no KeyPackage is available for this identity.
fetchKeyPackage @1 (identityKey :Data) -> (package :Data);
}
# schemas/delivery.capnp
@0x1111222233334444;
interface DeliveryService {
# Fan out an MLS message to all current members of a group.
# groupId: 32-byte group identifier
# message: serialised MLSMessage blob
# Returns count of recipients the message was queued for.
fanOut @0 (groupId :Data, message :Data) -> (recipientCount :UInt32);
# Subscribe to incoming messages for a group.
# memberId: 32-byte identity key fingerprint of the subscribing client.
# Returns a capability stream; server pushes MLS blobs as they arrive.
subscribe @1 (groupId :Data, memberId :Data) -> (stream :MessageStream);
}
interface MessageStream {
# Pull the next available message for this subscriber.
# Blocks (promise does not resolve) until a message is available.
# sequenceNo is monotonically increasing per group, used for gap detection.
next @0 () -> (message :Data, sequenceNo :UInt64);
}
```
### MLS Design
- Ciphersuite: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` as baseline (M1M4), replaced with hybrid PQ ciphersuite at M5.
- DS is MLS-unaware: routes `MLSMessage` blobs by `group_id`. Does not inspect epoch or content.
- AS stores `KeyPackage` blobs indexed by `(identity_key_fingerprint, package_id)`. One KeyPackage consumed per member-add operation (MLS requirement: KeyPackages are single-use).
- Welcome messages routed by the DS to the target client using `sender_id``target_id` mapping in the Envelope.
- Cap'n Proto canonical form used when serialising any structure that is subsequently signed (MLS Commit signatures, AS KeyPackage fingerprints).
### Post-Quantum (M5)
Hybrid KEM construction:
```
SharedSecret = HKDF-SHA256(
ikm = X25519_ss || ML-KEM-768_ss,
info = "noiseml-hybrid-v1",
len = 32
)
```
Follows the combiner approach from draft-ietf-tls-hybrid-design. Implemented as a custom `openmls` `OpenMlsCryptoProvider` trait implementation in `noiseml-core`.
---
## Milestones
### M1 — Noise Transport ✦ current
**Goal:** Two processes complete Noise_XX handshake over TCP and exchange typed Cap'n Proto frames.
Deliverables:
- `schemas/envelope.capnp`: `Envelope` + `MsgType` (Ping/Pong only needed at this stage)
- `noiseml-proto`: `build.rs` with `capnpc`, generated type re-exports, canonical helper
- `noiseml-core`: static X25519 keypair generation, Noise_XX initiator + responder, length-prefixed Cap'n Proto frame codec
- `noiseml-server`: TCP listener, Noise handshake, Ping→Pong handler, one tokio task per connection
- `noiseml-client`: connects, Noise handshake, sends Ping, receives Pong, exits 0
- Integration test: server and client in same test binary using `tokio::spawn`
- `docker-compose.yml` running the server
### M2 — Authentication Service + KeyPackage Exchange
**Goal:** Clients register identity and publish/fetch MLS KeyPackages via Cap'n Proto RPC.
Deliverables:
- `schemas/auth.capnp`: `AuthenticationService` interface
- `noiseml-proto`: generated RPC stubs + client/server bootstrap helpers
- `noiseml-core`: MLS KeyPackage generation (openmls)
- `noiseml-server`: AS RPC server implementation with DashMap store
- `noiseml-client`: `register` and `fetch-key` CLI subcommands
- Test: Alice uploads KeyPackage, Bob fetches it, fingerprints match
### M3 — MLS Group Create + Welcome
**Goal:** Alice creates a group and adds Bob via MLS Welcome. Both hold valid epoch 1 state.
Deliverables:
- `schemas/delivery.capnp`: `DeliveryService` + `MessageStream` interfaces
- `noiseml-core`: group create, add member, process Welcome
- `noiseml-server`: DS RPC server, Welcome routing by identity
- `noiseml-client`: `create-group` and `join` CLI subcommands
- Test: two clients reach identical epoch 1 group state, verified by comparing group context hashes
### M4 — Encrypted Group Messaging
**Goal:** Alice and Bob exchange MLS Application messages through the DS.
Deliverables:
- `noiseml-core`: send/receive application message, epoch rotation on Commit
- `noiseml-server`: DS fan-out via `MessageStream` capability stream, per-group ordered log (in-memory)
- `noiseml-client`: `send` subcommand, live receive loop via `MessageStream.next()`
- Test: round-trip message integrity, forward secrecy verified by confirming distinct key material across epochs
### M5 — Hybrid PQ Ciphersuite
**Goal:** Replace MLS crypto backend with X25519 + ML-KEM-768 hybrid.
Deliverables:
- `noiseml-core`: custom `OpenMlsCryptoProvider` with hybrid KEM
- All M3/M4 tests pass unchanged with new ciphersuite
- Criterion benchmarks: key generation, encap/decap, group-add latency (10/100/1000 members)
### M6 — Persistence + Production Docker
**Goal:** Server survives restart. Full containerised deployment.
Deliverables:
- `noiseml-server`: SQLite via `sqlx` for AS key store and DS message log, `migrations/` directory
- `docker/Dockerfile`: multi-stage build (rust:bookworm builder → debian:bookworm-slim runtime)
- `docker-compose.yml`: server + SQLite volume, healthcheck
- Client reconnect with session resume (re-handshake + rejoin group epoch from DS log)
---
## Dependencies (pinned majors)
```toml
# Crypto
openmls = "0.6"
openmls_rust_crypto = "0.6"
ml-kem = "0.3"
x25519-dalek = "2"
ed25519-dalek = "2"
snow = "0.9"
chacha20poly1305 = "0.10"
sha2 = "0.10"
hkdf = "0.12"
zeroize = { version = "1", features = ["derive"] }
rand = "0.8"
# Serialisation + RPC
capnp = "0.19"
capnp-rpc = "0.19"
# Build-time only
capnpc = "0.19" # build-dependency in noiseml-proto
# Async / networking
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["codec"] }
# Server utilities
dashmap = "5"
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio"] } # M6+
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "1"
# CLI
clap = { version = "4", features = ["derive"] }
```
---
## Key Design Decisions (ADR Summary)
**ADR-001: Noise_XX for transport authentication**
Both parties hold static keys registered with the AS. XX pattern provides mutual authentication and identity hiding for the initiator. No TLS dependency, no certificate infrastructure.
**ADR-002: Cap'n Proto replaces MessagePack**
Cap'n Proto provides: zero-copy reads, schema-enforced types, canonical serialisation for cryptographic signing, and a built-in async RPC system (capnp-rpc) that eliminates hand-rolled message dispatch. The build-time codegen overhead is accepted as worthwhile.
**ADR-003: Cap'n Proto RPC runs inside the Noise tunnel**
Cap'n Proto RPC has no transport security of its own. It operates over the byte stream produced by the Noise session. Separation of concerns: Noise owns authentication and confidentiality, Cap'n Proto owns framing and dispatch.
**ADR-004: DS is MLS-unaware**
The Delivery Service routes opaque `MLSMessage` blobs by `group_id`. It never decrypts or inspects MLS content. This is the correct MLS architecture (RFC 9420 §4) and is a natural Audit-Core integration point: the DS log is an append-only sequence of authenticated blobs.
**ADR-005: Single-use KeyPackages**
MLS requires that each KeyPackage be used at most once (to preserve forward secrecy of the initial key exchange). The AS consumes a KeyPackage on fetch. Clients should pre-upload multiple KeyPackages. The AS warns when a client's supply runs low (M2+).
**ADR-006: PQ gap in Noise transport is accepted**
The MLS content layer is PQ-protected from M5. The Noise transport (X25519) remains classical — PQ-Noise (draft-noise-pq) is not yet supported by `snow`. Harvest-now-decrypt-later against the handshake metadata is an accepted residual risk for M1M5. No long-lived content secrets transit the Noise handshake, so the practical impact is limited to identity/timing metadata.
---
## How to Use This Prompt
Paste this document at the start of any session working on noiseml. Then state which milestone you are working on and what specific task you need. The assistant will:
1. Confirm the current milestone and task.
2. State any design decisions being made (ADR format if significant).
3. Produce complete, production-ready code for the task.
4. Review the code internally for gaps before presenting.
5. State what the next logical task is.
When asking for code, always specify:
- Which crate(s) are affected.
- Whether this is a new file or modification to existing.
- Any constraints or context the assistant may not have (e.g. existing types already defined).
---
*noiseml — MLS + Post-Quantum + Noise/TCP + Cap'n Proto messenger in Rust*
*Architecture version: 1.1 | Last updated: 2026-02-19*

52
schemas/envelope.capnp Normal file
View File

@@ -0,0 +1,52 @@
# envelope.capnp — top-level wire message for all noiseml traffic.
#
# Every frame exchanged over the Noise channel is serialised as an Envelope.
# The Delivery Service routes by (groupId, msgType) without inspecting payload.
#
# Field sizing rationale:
# groupId / senderId : 32 bytes — SHA-256 digest
# payload : opaque — MLS blob or control data; size bounded by
# the Noise transport max message size (65535 B)
# timestampMs : UInt64 — unix epoch milliseconds; sufficient until year 292M
#
# ID generated with: capnp id
@0xe4a7f2c8b1d63509;
struct Envelope {
# Message type discriminant — determines how payload is interpreted.
msgType @0 :MsgType;
# 32-byte SHA-256 digest of the group name.
# The Delivery Service uses this as its routing key.
# Zero-filled for point-to-point control messages (ping, keyPackageUpload, etc.).
groupId @1 :Data;
# 32-byte SHA-256 digest of the sender's Ed25519 identity public key.
senderId @2 :Data;
# Opaque payload. Interpretation is determined by msgType:
# ping / pong — empty
# keyPackageUpload — openmls-serialised KeyPackage blob
# keyPackageFetch — target identity key (32 bytes)
# keyPackageResponse — openmls-serialised KeyPackage blob (or empty if none)
# mlsWelcome — MLSMessage blob (Welcome variant)
# mlsCommit — MLSMessage blob (PublicMessage / Commit variant)
# mlsApplication — MLSMessage blob (PrivateMessage / Application variant)
# error — UTF-8 error description
payload @3 :Data;
# Unix timestamp in milliseconds at the time of send.
timestampMs @4 :UInt64;
enum MsgType {
ping @0;
pong @1;
keyPackageUpload @2;
keyPackageFetch @3;
keyPackageResponse @4;
mlsWelcome @5;
mlsCommit @6;
mlsApplication @7;
error @8;
}
}