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:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
*.md
|
||||
docs/
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
**/*.rs.bk
|
||||
.vscode/
|
||||
1340
Cargo.lock
generated
Normal file
1340
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
60
Cargo.toml
Normal file
60
Cargo.toml
Normal 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
|
||||
38
crates/noiseml-client/Cargo.toml
Normal file
38
crates/noiseml-client/Cargo.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "noiseml-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "CLI client for noiseml."
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "noiseml"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
noiseml-core = { path = "../noiseml-core" }
|
||||
noiseml-proto = { path = "../noiseml-proto" }
|
||||
|
||||
# Serialisation + RPC
|
||||
capnp = { workspace = true }
|
||||
capnp-rpc = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
|
||||
# Error handling
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Integration tests spin up both server and client in the same process.
|
||||
noiseml-server = { path = "../noiseml-server" }
|
||||
145
crates/noiseml-client/src/main.rs
Normal file
145
crates/noiseml-client/src/main.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! noiseml CLI client.
|
||||
//!
|
||||
//! # M1 subcommands
|
||||
//!
|
||||
//! | Subcommand | Description |
|
||||
//! |------------|-----------------------------------------|
|
||||
//! | `ping` | Send a Ping to the server, print RTT |
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! | Env var | CLI flag | Default |
|
||||
//! |-----------------|--------------|---------------------|
|
||||
//! | `NOISEML_SERVER`| `--server` | `127.0.0.1:7000` |
|
||||
//! | `RUST_LOG` | — | `warn` |
|
||||
//!
|
||||
//! # Keypair lifecycle
|
||||
//!
|
||||
//! A fresh ephemeral X25519 keypair is generated per invocation in M1.
|
||||
//! M2 introduces persistent identity keys stored locally and registered
|
||||
//! with the Authentication Service.
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Parser, Subcommand};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use noiseml_core::{NoiseKeypair, handshake_initiator};
|
||||
use noiseml_proto::{MsgType, ParsedEnvelope};
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "noiseml", about = "noiseml CLI client", version)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Send a Ping to the server and print the round-trip time.
|
||||
Ping {
|
||||
/// Server address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "NOISEML_SERVER")]
|
||||
server: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
match args.command {
|
||||
Command::Ping { server } => cmd_ping(&server).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subcommand implementations ────────────────────────────────────────────────
|
||||
|
||||
/// Connect to `server`, complete Noise_XX, send a Ping, and print RTT.
|
||||
///
|
||||
/// Exits with status 0 on a valid Pong, non-zero on any error.
|
||||
async fn cmd_ping(server: &str) -> anyhow::Result<()> {
|
||||
// Generate a fresh ephemeral keypair for this session.
|
||||
// M2 will load a persistent identity keypair instead.
|
||||
let keypair = NoiseKeypair::generate();
|
||||
|
||||
let stream = TcpStream::connect(server)
|
||||
.await
|
||||
.with_context(|| format!("could not connect to {server}"))?;
|
||||
|
||||
tracing::debug!(server = %server, "TCP connection established");
|
||||
|
||||
let mut transport = handshake_initiator(stream, &keypair)
|
||||
.await
|
||||
.context("Noise_XX handshake failed")?;
|
||||
|
||||
{
|
||||
let remote = transport
|
||||
.remote_static_public_key()
|
||||
.map(fmt_key)
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
tracing::debug!(server_key = %remote, "handshake complete");
|
||||
}
|
||||
|
||||
// Record send time immediately before writing to minimise measurement skew.
|
||||
let sent_at = current_timestamp_ms();
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: sent_at,
|
||||
})
|
||||
.await
|
||||
.context("failed to send Ping")?;
|
||||
|
||||
tracing::debug!("Ping sent");
|
||||
|
||||
let response = transport
|
||||
.recv_envelope()
|
||||
.await
|
||||
.context("failed to receive Pong")?;
|
||||
|
||||
match response.msg_type {
|
||||
MsgType::Pong => {
|
||||
let rtt_ms = current_timestamp_ms().saturating_sub(sent_at);
|
||||
println!("Pong from {server} rtt={rtt_ms}ms");
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!(
|
||||
"protocol error: expected Pong from {server}, got unexpected message type"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Format the first 4 bytes of a key as hex with a trailing ellipsis.
|
||||
fn fmt_key(key: &[u8]) -> String {
|
||||
if key.len() < 4 {
|
||||
return format!("{key:02x?}");
|
||||
}
|
||||
format!("{:02x}{:02x}{:02x}{:02x}…", key[0], key[1], key[2], key[3])
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
209
crates/noiseml-client/tests/noise_transport.rs
Normal file
209
crates/noiseml-client/tests/noise_transport.rs
Normal file
@@ -0,0 +1,209 @@
|
||||
//! M1 integration test: Noise_XX handshake + Ping/Pong round-trip.
|
||||
//!
|
||||
//! Both the server-side and client-side logic run in the same Tokio runtime
|
||||
//! using `tokio::spawn`. The test verifies:
|
||||
//!
|
||||
//! 1. The Noise_XX handshake completes from both sides.
|
||||
//! 2. A Ping sent by the client arrives as a Ping on the server side.
|
||||
//! 3. The server's Pong arrives correctly on the client side.
|
||||
//! 4. Mutual authentication: each peer's observed remote static key matches the
|
||||
//! other peer's actual public key (the core security property of XX).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use noiseml_core::{NoiseKeypair, handshake_initiator, handshake_responder};
|
||||
use noiseml_proto::{MsgType, ParsedEnvelope};
|
||||
|
||||
/// Completes a full Noise_XX handshake and Ping/Pong exchange, then verifies
|
||||
/// mutual authentication by comparing observed vs. actual static public keys.
|
||||
#[tokio::test]
|
||||
async fn noise_xx_ping_pong_round_trip() {
|
||||
let server_keypair = Arc::new(NoiseKeypair::generate());
|
||||
let client_keypair = NoiseKeypair::generate();
|
||||
|
||||
// Bind the listener *before* spawning so the port is ready when the client
|
||||
// calls connect — no sleep or retry needed.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("failed to bind test listener");
|
||||
let server_addr = listener.local_addr().expect("failed to get local addr");
|
||||
|
||||
// ── Server task ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Handles exactly one connection: completes the handshake, asserts that it
|
||||
// receives a Ping, sends a Pong, then returns the client's observed key.
|
||||
let server_kp = Arc::clone(&server_keypair);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, _peer) = listener.accept().await.expect("server accept failed");
|
||||
|
||||
let mut transport = handshake_responder(stream, &server_kp)
|
||||
.await
|
||||
.expect("server Noise_XX handshake failed");
|
||||
|
||||
let env = transport
|
||||
.recv_envelope()
|
||||
.await
|
||||
.expect("server recv_envelope failed");
|
||||
|
||||
match env.msg_type {
|
||||
MsgType::Ping => {}
|
||||
_ => panic!("server expected Ping, received a different message type"),
|
||||
}
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Pong,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("server send_envelope failed");
|
||||
|
||||
// Return the client's public key as authenticated by the server.
|
||||
transport
|
||||
.remote_static_public_key()
|
||||
.expect("server: no remote static key after completed XX handshake")
|
||||
.to_vec()
|
||||
});
|
||||
|
||||
// ── Client side ───────────────────────────────────────────────────────────
|
||||
let stream = tokio::net::TcpStream::connect(server_addr)
|
||||
.await
|
||||
.expect("client connect failed");
|
||||
|
||||
let mut transport = handshake_initiator(stream, &client_keypair)
|
||||
.await
|
||||
.expect("client Noise_XX handshake failed");
|
||||
|
||||
// Capture the server's public key as authenticated by the client.
|
||||
let server_key_seen_by_client = transport
|
||||
.remote_static_public_key()
|
||||
.expect("client: no remote static key after completed XX handshake")
|
||||
.to_vec();
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
})
|
||||
.await
|
||||
.expect("client send_envelope failed");
|
||||
|
||||
let pong = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
transport.recv_envelope(),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for Pong — server task likely panicked")
|
||||
.expect("client recv_envelope failed");
|
||||
|
||||
match pong.msg_type {
|
||||
MsgType::Pong => {}
|
||||
_ => panic!("client expected Pong, received a different message type"),
|
||||
}
|
||||
|
||||
// ── Mutual authentication assertions ──────────────────────────────────────
|
||||
let client_key_seen_by_server = server_task
|
||||
.await
|
||||
.expect("server task panicked — see output above");
|
||||
|
||||
// The server authenticated the client's static public key correctly.
|
||||
assert_eq!(
|
||||
client_key_seen_by_server,
|
||||
client_keypair.public_bytes().to_vec(),
|
||||
"server's authenticated view of client key does not match client's actual public key"
|
||||
);
|
||||
|
||||
// The client authenticated the server's static public key correctly.
|
||||
assert_eq!(
|
||||
server_key_seen_by_client,
|
||||
server_keypair.public_bytes().to_vec(),
|
||||
"client's authenticated view of server key does not match server's actual public key"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second independent connection on the same server must also succeed,
|
||||
/// confirming that the server keypair reuse across connections is correct.
|
||||
#[tokio::test]
|
||||
async fn two_sequential_connections_both_authenticate() {
|
||||
let server_keypair = Arc::new(NoiseKeypair::generate());
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind failed");
|
||||
let server_addr = listener.local_addr().expect("local_addr failed");
|
||||
|
||||
let server_kp = Arc::clone(&server_keypair);
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..2_u8 {
|
||||
let (stream, _) = listener.accept().await.expect("accept failed");
|
||||
let kp = Arc::clone(&server_kp);
|
||||
tokio::spawn(async move {
|
||||
let mut t = handshake_responder(stream, &kp)
|
||||
.await
|
||||
.expect("server handshake failed");
|
||||
let env = t.recv_envelope().await.expect("recv failed");
|
||||
match env.msg_type {
|
||||
MsgType::Ping => {}
|
||||
_ => panic!("expected Ping"),
|
||||
}
|
||||
t.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Pong,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("server send failed");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for _ in 0..2_u8 {
|
||||
let kp = NoiseKeypair::generate();
|
||||
let stream = tokio::net::TcpStream::connect(server_addr)
|
||||
.await
|
||||
.expect("connect failed");
|
||||
let mut t = handshake_initiator(stream, &kp)
|
||||
.await
|
||||
.expect("client handshake failed");
|
||||
|
||||
t.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("client send failed");
|
||||
|
||||
let pong = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
t.recv_envelope(),
|
||||
)
|
||||
.await
|
||||
.expect("timeout")
|
||||
.expect("recv failed");
|
||||
|
||||
match pong.msg_type {
|
||||
MsgType::Pong => {}
|
||||
_ => panic!("expected Pong"),
|
||||
}
|
||||
|
||||
// Each client sees the *same* server public key (key reuse across connections).
|
||||
let seen = t
|
||||
.remote_static_public_key()
|
||||
.expect("no remote key")
|
||||
.to_vec();
|
||||
assert_eq!(seen, server_keypair.public_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
32
crates/noiseml-core/Cargo.toml
Normal file
32
crates/noiseml-core/Cargo.toml
Normal 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 }
|
||||
204
crates/noiseml-core/src/codec.rs
Normal file
204
crates/noiseml-core/src/codec.rs
Normal 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]);
|
||||
}
|
||||
}
|
||||
71
crates/noiseml-core/src/error.rs
Normal file
71
crates/noiseml-core/src/error.rs
Normal 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 },
|
||||
}
|
||||
119
crates/noiseml-core/src/keypair.rs
Normal file
119
crates/noiseml-core/src/keypair.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
28
crates/noiseml-core/src/lib.rs
Normal file
28
crates/noiseml-core/src/lib.rs
Normal 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};
|
||||
325
crates/noiseml-core/src/noise.rs
Normal file
325
crates/noiseml-core/src/noise.rs
Normal 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 M1–M5.
|
||||
|
||||
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)
|
||||
}
|
||||
15
crates/noiseml-proto/Cargo.toml
Normal file
15
crates/noiseml-proto/Cargo.toml
Normal 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 }
|
||||
45
crates/noiseml-proto/build.rs
Normal file
45
crates/noiseml-proto/build.rs
Normal 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)",
|
||||
);
|
||||
}
|
||||
197
crates/noiseml-proto/src/lib.rs
Normal file
197
crates/noiseml-proto/src/lib.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
32
crates/noiseml-server/Cargo.toml
Normal file
32
crates/noiseml-server/Cargo.toml
Normal 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 }
|
||||
180
crates/noiseml-server/src/main.rs
Normal file
180
crates/noiseml-server/src/main.rs
Normal 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
19
docker-compose.yml
Normal 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
71
docker/Dockerfile
Normal 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
329
master-prompt.md
Normal 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 (M1–M4), 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 M1–M5. 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
52
schemas/envelope.capnp
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user