Remove Noise protocol references from wiki docs and tests
Delete 8 Noise-specific documentation pages (noise-xx.md,
transport-keys.md, adr-001/003/006, framing-codec.md) and update
~30 remaining wiki pages to reflect QUIC+TLS as the sole transport.
Remove obsolete Noise-based integration tests (auth_service.rs,
mls_group.rs). Code-side Noise removal was done in f334ed3.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,269 +0,0 @@
|
||||
//! Integration test: M2 Authentication Service — KeyPackage upload + fetch.
|
||||
//!
|
||||
//! All tests run inside a single `tokio::task::LocalSet` so that `spawn_local`
|
||||
//! can be used for capnp-rpc tasks (which are `!Send` due to internal `Rc` use).
|
||||
|
||||
use std::{collections::VecDeque, sync::Arc};
|
||||
|
||||
use capnp::capability::Promise;
|
||||
use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem};
|
||||
use dashmap::DashMap;
|
||||
use quicnprotochat_core::{
|
||||
generate_key_package, handshake_initiator, handshake_responder, IdentityKeypair, NoiseKeypair,
|
||||
};
|
||||
use quicnprotochat_proto::auth_capnp::authentication_service;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type Store = Arc<DashMap<Vec<u8>, VecDeque<Vec<u8>>>>;
|
||||
|
||||
// ── Inline AS server implementation ──────────────────────────────────────────
|
||||
|
||||
struct TestAuthService {
|
||||
store: Store,
|
||||
}
|
||||
|
||||
impl authentication_service::Server for TestAuthService {
|
||||
fn upload_key_package(
|
||||
&mut self,
|
||||
params: authentication_service::UploadKeyPackageParams,
|
||||
mut results: authentication_service::UploadKeyPackageResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let p = match params.get() {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Promise::err(e),
|
||||
};
|
||||
let ik = match p.get_identity_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
let pkg = match p.get_package() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
let fp: Vec<u8> = Sha256::digest(&pkg).to_vec();
|
||||
self.store.entry(ik).or_default().push_back(pkg);
|
||||
results.get().set_fingerprint(&fp);
|
||||
Promise::ok(())
|
||||
}
|
||||
|
||||
fn fetch_key_package(
|
||||
&mut self,
|
||||
params: authentication_service::FetchKeyPackageParams,
|
||||
mut results: authentication_service::FetchKeyPackageResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let ik = match params.get() {
|
||||
Ok(p) => match p.get_identity_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
},
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
let pkg = self
|
||||
.store
|
||||
.get_mut(&ik)
|
||||
.and_then(|mut q| q.pop_front())
|
||||
.unwrap_or_default();
|
||||
results.get().set_package(&pkg);
|
||||
Promise::ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Test helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Spawn a server that accepts `n_connections` and returns the bound address.
|
||||
///
|
||||
/// Must be called from within a `LocalSet` context so that the internal
|
||||
/// `spawn_local` calls are associated with the correct LocalSet.
|
||||
async fn spawn_server(
|
||||
n_connections: usize,
|
||||
keypair: Arc<NoiseKeypair>,
|
||||
store: Store,
|
||||
) -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
for _ in 0..n_connections {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let kp = Arc::clone(&keypair);
|
||||
let st = Arc::clone(&store);
|
||||
tokio::task::spawn_local(async move {
|
||||
serve_one(stream, kp, st).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
/// Handle a single Noise + capnp-rpc server connection.
|
||||
async fn serve_one(stream: TcpStream, keypair: Arc<NoiseKeypair>, store: Store) {
|
||||
let transport = handshake_responder(stream, &keypair).await.unwrap();
|
||||
let (reader, writer) = transport.into_capnp_io();
|
||||
let network = twoparty::VatNetwork::new(
|
||||
reader.compat(),
|
||||
writer.compat_write(),
|
||||
Side::Server,
|
||||
Default::default(),
|
||||
);
|
||||
let svc: authentication_service::Client = capnp_rpc::new_client(TestAuthService { store });
|
||||
let rpc = RpcSystem::new(Box::new(network), Some(svc.client));
|
||||
tokio::task::spawn_local(rpc).await.ok();
|
||||
}
|
||||
|
||||
/// Connect and return a client stub. Must run inside a LocalSet.
|
||||
async fn connect_client(addr: std::net::SocketAddr) -> authentication_service::Client {
|
||||
let kp = NoiseKeypair::generate();
|
||||
let stream = TcpStream::connect(addr).await.unwrap();
|
||||
let transport = handshake_initiator(stream, &kp).await.unwrap();
|
||||
let (reader, writer) = transport.into_capnp_io();
|
||||
let network = twoparty::VatNetwork::new(
|
||||
reader.compat(),
|
||||
writer.compat_write(),
|
||||
Side::Client,
|
||||
Default::default(),
|
||||
);
|
||||
let mut rpc = RpcSystem::new(Box::new(network), None);
|
||||
let client: authentication_service::Client = rpc.bootstrap(Side::Server);
|
||||
tokio::task::spawn_local(rpc);
|
||||
client
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Alice uploads a KeyPackage; Bob fetches it. Fingerprints must match.
|
||||
#[tokio::test]
|
||||
async fn upload_then_fetch_fingerprints_match() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
let store: Store = Arc::new(DashMap::new());
|
||||
let server_kp = Arc::new(NoiseKeypair::generate());
|
||||
|
||||
// Server accepts 2 connections: one for Alice (upload), one for Bob (fetch).
|
||||
let addr = spawn_server(2, Arc::clone(&server_kp), Arc::clone(&store)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
// Alice: generate KeyPackage and upload it.
|
||||
let alice_identity = IdentityKeypair::generate();
|
||||
let (tls_bytes, local_fp) = generate_key_package(&alice_identity).unwrap();
|
||||
|
||||
let alice = connect_client(addr).await;
|
||||
let mut req = alice.upload_key_package_request();
|
||||
req.get()
|
||||
.set_identity_key(&alice_identity.public_key_bytes());
|
||||
req.get().set_package(&tls_bytes);
|
||||
let resp = req.send().promise.await.unwrap();
|
||||
let server_fp = resp.get().unwrap().get_fingerprint().unwrap().to_vec();
|
||||
|
||||
assert_eq!(local_fp, server_fp, "server fingerprint must match local");
|
||||
|
||||
// Bob: fetch Alice's package by her identity key.
|
||||
let bob = connect_client(addr).await;
|
||||
let mut req2 = bob.fetch_key_package_request();
|
||||
req2.get()
|
||||
.set_identity_key(&alice_identity.public_key_bytes());
|
||||
let resp2 = req2.send().promise.await.unwrap();
|
||||
let fetched = resp2.get().unwrap().get_package().unwrap().to_vec();
|
||||
|
||||
assert!(!fetched.is_empty(), "fetched package must not be empty");
|
||||
assert_eq!(
|
||||
fetched, tls_bytes,
|
||||
"fetched bytes must match uploaded bytes"
|
||||
);
|
||||
|
||||
let fetched_fp: Vec<u8> = Sha256::digest(&fetched).to_vec();
|
||||
assert_eq!(
|
||||
fetched_fp, local_fp,
|
||||
"fetched fingerprint must match uploaded"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Fetching a non-existent key returns empty bytes.
|
||||
#[tokio::test]
|
||||
async fn fetch_nonexistent_key_returns_empty() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
let store: Store = Arc::new(DashMap::new());
|
||||
let server_kp = Arc::new(NoiseKeypair::generate());
|
||||
let addr = spawn_server(1, server_kp, store).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
let client = connect_client(addr).await;
|
||||
let mut req = client.fetch_key_package_request();
|
||||
req.get().set_identity_key(&[0xAAu8; 32]);
|
||||
let resp = req.send().promise.await.unwrap();
|
||||
let pkg = resp.get().unwrap().get_package().unwrap().to_vec();
|
||||
|
||||
assert!(pkg.is_empty(), "unknown identity must return empty package");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Uploading two packages and fetching twice returns them in FIFO order.
|
||||
#[tokio::test]
|
||||
async fn packages_consumed_in_fifo_order() {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
local
|
||||
.run_until(async move {
|
||||
let store: Store = Arc::new(DashMap::new());
|
||||
|
||||
// Pre-populate the store directly.
|
||||
let key = vec![0x01u8; 32];
|
||||
store
|
||||
.entry(key.clone())
|
||||
.or_default()
|
||||
.extend([vec![1u8, 2, 3], vec![4u8, 5, 6]]);
|
||||
|
||||
let server_kp = Arc::new(NoiseKeypair::generate());
|
||||
// Server accepts 2 connections for the 2 fetches.
|
||||
let addr = spawn_server(2, server_kp, Arc::clone(&store)).await;
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
let client1 = connect_client(addr).await;
|
||||
let mut req1 = client1.fetch_key_package_request();
|
||||
req1.get().set_identity_key(&key);
|
||||
let pkg1 = req1
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.unwrap()
|
||||
.get()
|
||||
.unwrap()
|
||||
.get_package()
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
pkg1,
|
||||
vec![1u8, 2, 3],
|
||||
"first fetch must return first package"
|
||||
);
|
||||
|
||||
let client2 = connect_client(addr).await;
|
||||
let mut req2 = client2.fetch_key_package_request();
|
||||
req2.get().set_identity_key(&key);
|
||||
let pkg2 = req2
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.unwrap()
|
||||
.get()
|
||||
.unwrap()
|
||||
.get_package()
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
assert_eq!(
|
||||
pkg2,
|
||||
vec![4u8, 5, 6],
|
||||
"second fetch must return second package"
|
||||
);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
//! Integration test: full MLS group flow via Authentication Service + Delivery Service.
|
||||
//!
|
||||
//! Steps:
|
||||
//! - Start in-process AS and DS (Noise_XX + capnp-rpc) on a LocalSet.
|
||||
//! - Alice and Bob generate KeyPackages and upload to AS.
|
||||
//! - Alice fetches Bob's KeyPackage, creates a group, and invites Bob.
|
||||
//! - Welcome + application messages traverse the Delivery Service.
|
||||
//! - Both sides decrypt and confirm plaintext payloads.
|
||||
|
||||
use std::{collections::VecDeque, sync::Arc, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use capnp::capability::Promise;
|
||||
use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem};
|
||||
use dashmap::DashMap;
|
||||
use quicnprotochat_core::{
|
||||
handshake_initiator, handshake_responder, GroupMember, IdentityKeypair, NoiseKeypair,
|
||||
};
|
||||
use quicnprotochat_proto::{auth_capnp::authentication_service, delivery_capnp::delivery_service};
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
type KeyPackageStore = Arc<DashMap<Vec<u8>, VecDeque<Vec<u8>>>>;
|
||||
type DeliveryStore = Arc<DashMap<Vec<u8>, VecDeque<Vec<u8>>>>;
|
||||
|
||||
/// Full Alice↔Bob MLS round-trip against live AS + DS.
|
||||
#[tokio::test]
|
||||
async fn mls_group_end_to_end_round_trip() -> anyhow::Result<()> {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
local
|
||||
.run_until(async move {
|
||||
let server_keypair = Arc::new(NoiseKeypair::generate());
|
||||
let kp_store: KeyPackageStore = Arc::new(DashMap::new());
|
||||
let ds_store: DeliveryStore = Arc::new(DashMap::new());
|
||||
|
||||
let as_addr =
|
||||
spawn_as_server(2, Arc::clone(&server_keypair), Arc::clone(&kp_store)).await;
|
||||
let ds_addr =
|
||||
spawn_ds_server(2, Arc::clone(&server_keypair), Arc::clone(&ds_store)).await;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
let alice_id = Arc::new(IdentityKeypair::generate());
|
||||
let bob_id = Arc::new(IdentityKeypair::generate());
|
||||
|
||||
let mut alice = GroupMember::new(Arc::clone(&alice_id));
|
||||
let mut bob = GroupMember::new(Arc::clone(&bob_id));
|
||||
|
||||
let alice_kp = alice.generate_key_package()?;
|
||||
let bob_kp = bob.generate_key_package()?;
|
||||
|
||||
let alice_as = connect_as(as_addr, &NoiseKeypair::generate()).await?;
|
||||
let bob_as = connect_as(as_addr, &NoiseKeypair::generate()).await?;
|
||||
|
||||
upload_key_package(&alice_as, &alice_id.public_key_bytes(), &alice_kp).await?;
|
||||
upload_key_package(&bob_as, &bob_id.public_key_bytes(), &bob_kp).await?;
|
||||
|
||||
let fetched_bob_kp = fetch_key_package(&alice_as, &bob_id.public_key_bytes()).await?;
|
||||
anyhow::ensure!(
|
||||
!fetched_bob_kp.is_empty(),
|
||||
"AS must return Bob's KeyPackage"
|
||||
);
|
||||
|
||||
alice.create_group(b"m3-integration")?;
|
||||
let (_commit, welcome) = alice.add_member(&fetched_bob_kp)?;
|
||||
|
||||
let alice_ds = connect_ds(ds_addr, &NoiseKeypair::generate()).await?;
|
||||
let bob_ds = connect_ds(ds_addr, &NoiseKeypair::generate()).await?;
|
||||
|
||||
enqueue(&alice_ds, &bob_id.public_key_bytes(), &welcome).await?;
|
||||
|
||||
let welcome_payloads = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?;
|
||||
let welcome_bytes = welcome_payloads
|
||||
.first()
|
||||
.cloned()
|
||||
.context("welcome must be present")?;
|
||||
bob.join_group(&welcome_bytes)?;
|
||||
|
||||
let ct_ab = alice.send_message(b"hello bob")?;
|
||||
enqueue(&alice_ds, &bob_id.public_key_bytes(), &ct_ab).await?;
|
||||
let bob_msgs = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?;
|
||||
let ab_plaintext = bob
|
||||
.receive_message(bob_msgs.first().context("missing alice→bob payload")?)?
|
||||
.context("alice→bob must be application message")?;
|
||||
assert_eq!(ab_plaintext, b"hello bob");
|
||||
|
||||
let ct_ba = bob.send_message(b"hello alice")?;
|
||||
enqueue(&bob_ds, &alice_id.public_key_bytes(), &ct_ba).await?;
|
||||
let alice_msgs = fetch_all(&alice_ds, &alice_id.public_key_bytes()).await?;
|
||||
let ba_plaintext = alice
|
||||
.receive_message(alice_msgs.first().context("missing bob→alice payload")?)?
|
||||
.context("bob→alice must be application message")?;
|
||||
assert_eq!(ba_plaintext, b"hello alice");
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async fn spawn_as_server(
|
||||
n_connections: usize,
|
||||
keypair: Arc<NoiseKeypair>,
|
||||
store: KeyPackageStore,
|
||||
) -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
for _ in 0..n_connections {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let kp = Arc::clone(&keypair);
|
||||
let st = Arc::clone(&store);
|
||||
tokio::task::spawn_local(async move {
|
||||
serve_as_connection(stream, kp, st).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
async fn serve_as_connection(
|
||||
stream: TcpStream,
|
||||
keypair: Arc<NoiseKeypair>,
|
||||
store: KeyPackageStore,
|
||||
) {
|
||||
let transport = handshake_responder(stream, &keypair).await.unwrap();
|
||||
let (reader, writer) = transport.into_capnp_io();
|
||||
let network = twoparty::VatNetwork::new(
|
||||
reader.compat(),
|
||||
writer.compat_write(),
|
||||
Side::Server,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let service: authentication_service::Client = capnp_rpc::new_client(AuthService { store });
|
||||
|
||||
RpcSystem::new(Box::new(network), Some(service.client))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
async fn spawn_ds_server(
|
||||
n_connections: usize,
|
||||
keypair: Arc<NoiseKeypair>,
|
||||
store: DeliveryStore,
|
||||
) -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::task::spawn_local(async move {
|
||||
for _ in 0..n_connections {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let kp = Arc::clone(&keypair);
|
||||
let st = Arc::clone(&store);
|
||||
tokio::task::spawn_local(async move {
|
||||
serve_ds_connection(stream, kp, st).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
addr
|
||||
}
|
||||
|
||||
async fn serve_ds_connection(stream: TcpStream, keypair: Arc<NoiseKeypair>, store: DeliveryStore) {
|
||||
let transport = handshake_responder(stream, &keypair).await.unwrap();
|
||||
let (reader, writer) = transport.into_capnp_io();
|
||||
let network = twoparty::VatNetwork::new(
|
||||
reader.compat(),
|
||||
writer.compat_write(),
|
||||
Side::Server,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let service: delivery_service::Client = capnp_rpc::new_client(DeliveryService { store });
|
||||
|
||||
RpcSystem::new(Box::new(network), Some(service.client))
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
|
||||
async fn connect_as(
|
||||
addr: std::net::SocketAddr,
|
||||
noise_keypair: &NoiseKeypair,
|
||||
) -> anyhow::Result<authentication_service::Client> {
|
||||
let stream = TcpStream::connect(addr)
|
||||
.await
|
||||
.with_context(|| format!("could not connect to AS at {addr}"))?;
|
||||
|
||||
let transport = handshake_initiator(stream, noise_keypair)
|
||||
.await
|
||||
.context("Noise handshake to AS failed")?;
|
||||
let (reader, writer) = transport.into_capnp_io();
|
||||
|
||||
let network = twoparty::VatNetwork::new(
|
||||
reader.compat(),
|
||||
writer.compat_write(),
|
||||
Side::Client,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let mut rpc = RpcSystem::new(Box::new(network), None);
|
||||
let client: authentication_service::Client = rpc.bootstrap(Side::Server);
|
||||
tokio::task::spawn_local(rpc);
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
async fn connect_ds(
|
||||
addr: std::net::SocketAddr,
|
||||
noise_keypair: &NoiseKeypair,
|
||||
) -> anyhow::Result<delivery_service::Client> {
|
||||
let stream = TcpStream::connect(addr)
|
||||
.await
|
||||
.with_context(|| format!("could not connect to DS at {addr}"))?;
|
||||
|
||||
let transport = handshake_initiator(stream, noise_keypair)
|
||||
.await
|
||||
.context("Noise handshake to DS failed")?;
|
||||
let (reader, writer) = transport.into_capnp_io();
|
||||
|
||||
let network = twoparty::VatNetwork::new(
|
||||
reader.compat(),
|
||||
writer.compat_write(),
|
||||
Side::Client,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let mut rpc = RpcSystem::new(Box::new(network), None);
|
||||
let client: delivery_service::Client = rpc.bootstrap(Side::Server);
|
||||
tokio::task::spawn_local(rpc);
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
async fn upload_key_package(
|
||||
as_client: &authentication_service::Client,
|
||||
identity_key: &[u8],
|
||||
package: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut req = as_client.upload_key_package_request();
|
||||
req.get().set_identity_key(identity_key);
|
||||
req.get().set_package(package);
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("upload_key_package RPC failed")?;
|
||||
|
||||
let server_fp = resp
|
||||
.get()
|
||||
.context("upload_key_package: bad response")?
|
||||
.get_fingerprint()
|
||||
.context("upload_key_package: missing fingerprint")?
|
||||
.to_vec();
|
||||
|
||||
let local_fp: Vec<u8> = Sha256::digest(package).to_vec();
|
||||
anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_key_package(
|
||||
as_client: &authentication_service::Client,
|
||||
identity_key: &[u8],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let mut req = as_client.fetch_key_package_request();
|
||||
req.get().set_identity_key(identity_key);
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("fetch_key_package RPC failed")?;
|
||||
|
||||
let pkg = resp
|
||||
.get()
|
||||
.context("fetch_key_package: bad response")?
|
||||
.get_package()
|
||||
.context("fetch_key_package: missing package")?
|
||||
.to_vec();
|
||||
|
||||
Ok(pkg)
|
||||
}
|
||||
|
||||
async fn enqueue(
|
||||
ds_client: &delivery_service::Client,
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut req = ds_client.enqueue_request();
|
||||
req.get().set_recipient_key(recipient_key);
|
||||
req.get().set_payload(payload);
|
||||
req.send().promise.await.context("enqueue RPC failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_all(
|
||||
ds_client: &delivery_service::Client,
|
||||
recipient_key: &[u8],
|
||||
) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
let mut req = ds_client.fetch_request();
|
||||
req.get().set_recipient_key(recipient_key);
|
||||
|
||||
let resp = req.send().promise.await.context("fetch RPC failed")?;
|
||||
|
||||
let list = resp
|
||||
.get()
|
||||
.context("fetch: bad response")?
|
||||
.get_payloads()
|
||||
.context("fetch: missing payloads")?;
|
||||
|
||||
let mut payloads = Vec::with_capacity(list.len() as usize);
|
||||
for i in 0..list.len() {
|
||||
payloads.push(list.get(i).context("fetch: payload read failed")?.to_vec());
|
||||
}
|
||||
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
// ── Inline service implementations ─────────────────────────────────────────-
|
||||
|
||||
struct AuthService {
|
||||
store: KeyPackageStore,
|
||||
}
|
||||
|
||||
impl authentication_service::Server for AuthService {
|
||||
fn upload_key_package(
|
||||
&mut self,
|
||||
params: authentication_service::UploadKeyPackageParams,
|
||||
mut results: authentication_service::UploadKeyPackageResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let params = match params.get() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Promise::err(e),
|
||||
};
|
||||
|
||||
let ik = match params.get_identity_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
let pkg = match params.get_package() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
|
||||
let fp: Vec<u8> = Sha256::digest(&pkg).to_vec();
|
||||
self.store.entry(ik).or_default().push_back(pkg);
|
||||
results.get().set_fingerprint(&fp);
|
||||
Promise::ok(())
|
||||
}
|
||||
|
||||
fn fetch_key_package(
|
||||
&mut self,
|
||||
params: authentication_service::FetchKeyPackageParams,
|
||||
mut results: authentication_service::FetchKeyPackageResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let ik = match params.get() {
|
||||
Ok(p) => match p.get_identity_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
},
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
|
||||
let pkg = self
|
||||
.store
|
||||
.get_mut(&ik)
|
||||
.and_then(|mut q| q.pop_front())
|
||||
.unwrap_or_default();
|
||||
|
||||
results.get().set_package(&pkg);
|
||||
Promise::ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct DeliveryService {
|
||||
store: DeliveryStore,
|
||||
}
|
||||
|
||||
impl delivery_service::Server for DeliveryService {
|
||||
fn enqueue(
|
||||
&mut self,
|
||||
params: delivery_service::EnqueueParams,
|
||||
_results: delivery_service::EnqueueResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let params = match params.get() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Promise::err(e),
|
||||
};
|
||||
|
||||
let recipient = match params.get_recipient_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
let payload = match params.get_payload() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
|
||||
self.store.entry(recipient).or_default().push_back(payload);
|
||||
Promise::ok(())
|
||||
}
|
||||
|
||||
fn fetch(
|
||||
&mut self,
|
||||
params: delivery_service::FetchParams,
|
||||
mut results: delivery_service::FetchResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let recipient = match params.get() {
|
||||
Ok(p) => match p.get_recipient_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
},
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("{e}"))),
|
||||
};
|
||||
|
||||
let messages: Vec<Vec<u8>> = self
|
||||
.store
|
||||
.get_mut(&recipient)
|
||||
.map(|mut q| q.drain(..).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut list = results.get().init_payloads(messages.len() as u32);
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
list.set(i as u32, msg);
|
||||
}
|
||||
|
||||
Promise::ok(())
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,6 @@
|
||||
|
||||
- [Protocol Layers Overview](protocol-layers/overview.md)
|
||||
- [QUIC + TLS 1.3](protocol-layers/quic-tls.md)
|
||||
- [Noise\_XX Handshake](protocol-layers/noise-xx.md)
|
||||
- [Cap'n Proto Serialisation and RPC](protocol-layers/capn-proto.md)
|
||||
- [MLS (RFC 9420)](protocol-layers/mls.md)
|
||||
- [Hybrid KEM: X25519 + ML-KEM-768](protocol-layers/hybrid-kem.md)
|
||||
@@ -40,7 +39,6 @@
|
||||
|
||||
- [Cryptography Overview](cryptography/overview.md)
|
||||
- [Ed25519 Identity Keys](cryptography/identity-keys.md)
|
||||
- [X25519 Transport Keys](cryptography/transport-keys.md)
|
||||
- [Key Lifecycle and Zeroization](cryptography/key-lifecycle.md)
|
||||
- [Forward Secrecy](cryptography/forward-secrecy.md)
|
||||
- [Post-Compromise Security](cryptography/post-compromise-security.md)
|
||||
@@ -56,7 +54,6 @@
|
||||
- [Auth Schema](wire-format/auth-schema.md)
|
||||
- [Delivery Schema](wire-format/delivery-schema.md)
|
||||
- [NodeService Schema](wire-format/node-service-schema.md)
|
||||
- [Length-Prefixed Framing Codec](wire-format/framing-codec.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -64,12 +61,9 @@
|
||||
|
||||
- [Design Decisions Overview](design-rationale/overview.md)
|
||||
- [Why This Design, Not Signal/Matrix/...](design-rationale/why-not-signal.md)
|
||||
- [ADR-001: Noise\_XX for Transport Auth](design-rationale/adr-001-noise-xx.md)
|
||||
- [ADR-002: Cap'n Proto over MessagePack](design-rationale/adr-002-capnproto.md)
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](design-rationale/adr-003-rpc-inside-noise.md)
|
||||
- [ADR-004: MLS-Unaware Delivery Service](design-rationale/adr-004-mls-unaware-ds.md)
|
||||
- [ADR-005: Single-Use KeyPackages](design-rationale/adr-005-single-use-keypackages.md)
|
||||
- [ADR-006: PQ Gap in Noise Transport](design-rationale/adr-006-pq-gap.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ relevant specification or documentation page.
|
||||
|
||||
**AEAD** -- Authenticated Encryption with Associated Data. A symmetric encryption
|
||||
scheme that provides both confidentiality and integrity. quicnprotochat uses
|
||||
AES-128-GCM (in the MLS ciphersuite) and ChaCha20-Poly1305 (in the Noise
|
||||
transport). See [Cryptography Overview](../cryptography/overview.md).
|
||||
AES-128-GCM (in the MLS ciphersuite). See [Cryptography Overview](../cryptography/overview.md).
|
||||
|
||||
**ALPN** -- Application-Layer Protocol Negotiation. A TLS extension that allows
|
||||
the client and server to agree on an application protocol during the TLS
|
||||
@@ -90,12 +89,6 @@ in RFC 9420. MLS provides forward secrecy and post-compromise security for
|
||||
groups of any size through an efficient tree-based key schedule.
|
||||
See [MLS (RFC 9420)](../protocol-layers/mls.md).
|
||||
|
||||
**Noise\_XX** -- A Noise Protocol Framework handshake pattern providing mutual
|
||||
authentication. Both parties transmit their static public keys during the
|
||||
handshake (encrypted after the first round-trip). The M1 transport stack uses
|
||||
Noise\_XX over TCP; the M3+ stack uses QUIC + TLS 1.3 as the primary transport.
|
||||
See [Noise\_XX Handshake](../protocol-layers/noise-xx.md).
|
||||
|
||||
**PCS** -- Post-Compromise Security. The property that a protocol recovers
|
||||
security after a member's state is compromised. In MLS, once a compromised
|
||||
member sends an Update or Commit, subsequent epochs are secure again (assuming
|
||||
@@ -130,10 +123,10 @@ epoch secrets) encrypted under the new member's HPKE init key from their
|
||||
KeyPackage. See [MLS (RFC 9420)](../protocol-layers/mls.md).
|
||||
|
||||
**X25519** -- Elliptic curve Diffie-Hellman key exchange on Curve25519 (using
|
||||
the Montgomery form). Used for the Noise\_XX handshake (transport
|
||||
authentication) and as the classical component of DHKEM in MLS.
|
||||
the Montgomery form). Used as the classical component of DHKEM in MLS HPKE
|
||||
and in the hybrid KEM (X25519 + ML-KEM-768).
|
||||
quicnprotochat uses the `x25519-dalek` crate.
|
||||
See [X25519 Transport Keys](../cryptography/transport-keys.md).
|
||||
See [Cryptography Overview](../cryptography/overview.md).
|
||||
|
||||
**Zeroize** -- The practice of securely clearing sensitive data (private keys,
|
||||
shared secrets) from memory when it is no longer needed. quicnprotochat uses the
|
||||
|
||||
@@ -16,7 +16,6 @@ category.
|
||||
| [RFC 8446 -- The Transport Layer Security (TLS) Protocol Version 1.3](https://datatracker.ietf.org/doc/rfc8446/) | The TLS version used exclusively by quicnprotochat (no TLS 1.2 fallback). Provides the handshake, key schedule, and record layer for QUIC transport security. |
|
||||
| [RFC 9180 -- Hybrid Public Key Encryption (HPKE)](https://datatracker.ietf.org/doc/rfc9180/) | The public-key encryption scheme used internally by MLS for encrypting to KeyPackage init keys. quicnprotochat's MLS ciphersuite uses DHKEM(X25519, HKDF-SHA256) with AES-128-GCM. |
|
||||
| [NIST FIPS 203 -- Module-Lattice-Based Key-Encapsulation Mechanism Standard (ML-KEM)](https://csrc.nist.gov/pubs/fips/203/final) | The post-quantum KEM standard. quicnprotochat plans to use ML-KEM-768 in a hybrid construction with X25519 at milestone M7. See [Post-Quantum Readiness](../cryptography/post-quantum-readiness.md). |
|
||||
| [Noise Protocol Framework](https://noiseprotocol.org/noise.html) | The framework defining the Noise\_XX handshake pattern used in quicnprotochat's M1 transport stack. Provides mutual authentication and channel encryption. See [Noise\_XX Handshake](../protocol-layers/noise-xx.md). |
|
||||
| [Cap'n Proto specification](https://capnproto.org/) | The zero-copy serialisation format and RPC system used for all quicnprotochat wire messages and service interfaces. See [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md). |
|
||||
| [draft-ietf-tls-hybrid-design -- Hybrid Key Exchange in TLS 1.3](https://datatracker.ietf.org/doc/draft-ietf-tls-hybrid-design/) | The combiner approach used by quicnprotochat's hybrid KEM construction (X25519 shared secret concatenated with ML-KEM-768 shared secret, fed through HKDF). See [Hybrid KEM](../protocol-layers/hybrid-kem.md). |
|
||||
| [RFC 9497 -- OPAQUE](https://datatracker.ietf.org/doc/rfc9497/) | Asymmetric password-authenticated key exchange. Considered for future authentication (see [Future Research](../roadmap/future-research.md)). |
|
||||
@@ -31,13 +30,12 @@ category.
|
||||
| `openmls_rust_crypto` | [docs.rs/openmls_rust_crypto](https://docs.rs/openmls_rust_crypto/) | Pure-Rust cryptographic backend for openmls. Provides the `OpenMlsRustCrypto` provider used by `GroupMember`. |
|
||||
| `quinn` | [docs.rs/quinn](https://docs.rs/quinn/) | QUIC transport implementation. Provides the `Endpoint`, `Connection`, and stream types for client and server. See [QUIC + TLS 1.3](../protocol-layers/quic-tls.md). |
|
||||
| `rustls` | [docs.rs/rustls](https://docs.rs/rustls/) | TLS 1.3 implementation used by `quinn`. Configured with `TLS13` cipher suites only and custom certificate verification. |
|
||||
| `snow` | [docs.rs/snow](https://docs.rs/snow/) | Noise Protocol Framework implementation. Provides the Noise\_XX handshake for the M1 transport stack. See [Noise\_XX Handshake](../protocol-layers/noise-xx.md). |
|
||||
| `capnp` | [docs.rs/capnp](https://docs.rs/capnp/) | Cap'n Proto serialisation library. Used for building and reading all wire messages. |
|
||||
| `capnp-rpc` | [docs.rs/capnp-rpc](https://docs.rs/capnp-rpc/) | Cap'n Proto RPC framework. Provides the async RPC system for `NodeService`. Runs inside the QUIC encrypted channel. |
|
||||
| `capnpc` | [docs.rs/capnpc](https://docs.rs/capnpc/) | Cap'n Proto compiler invoked at build time (`build.rs`) to generate Rust types from `.capnp` schemas. |
|
||||
| `ml-kem` | [docs.rs/ml-kem](https://docs.rs/ml-kem/) | ML-KEM (NIST FIPS 203) implementation. Vendored in the workspace for the planned hybrid post-quantum KEM (M7). |
|
||||
| `ed25519-dalek` | [docs.rs/ed25519-dalek](https://docs.rs/ed25519-dalek/) | Ed25519 signing and verification. Used for MLS identity credentials (`BasicCredential`). See [Ed25519 Identity Keys](../cryptography/identity-keys.md). |
|
||||
| `x25519-dalek` | [docs.rs/x25519-dalek](https://docs.rs/x25519-dalek/) | X25519 Diffie-Hellman key exchange. Used for Noise\_XX transport authentication. See [X25519 Transport Keys](../cryptography/transport-keys.md). |
|
||||
| `x25519-dalek` | [docs.rs/x25519-dalek](https://docs.rs/x25519-dalek/) | X25519 Diffie-Hellman key exchange. Used in hybrid KEM (X25519 + ML-KEM-768) and as the classical component of DHKEM in MLS HPKE. See [Hybrid KEM](../protocol-layers/hybrid-kem.md). |
|
||||
| `zeroize` | [docs.rs/zeroize](https://docs.rs/zeroize/) | Secure memory zeroisation. All private key types implement `Zeroize + ZeroizeOnDrop`. See [Key Lifecycle and Zeroization](../cryptography/key-lifecycle.md). |
|
||||
| `tokio` | [docs.rs/tokio](https://docs.rs/tokio/) | Async runtime. All server and client I/O runs on Tokio. |
|
||||
| `clap` | [docs.rs/clap](https://docs.rs/clap/) | CLI argument parser for the client binary. |
|
||||
@@ -91,15 +89,6 @@ The predecessor to ML-KEM (NIST FIPS 203). CRYSTALS-Kyber was selected by NIST
|
||||
and standardised as ML-KEM. quicnprotochat uses the `ml-kem` crate which
|
||||
implements the final FIPS 203 standard.
|
||||
|
||||
### Noise Protocol
|
||||
|
||||
**"The Noise Protocol Framework"**
|
||||
Trevor Perrin.
|
||||
[noiseprotocol.org/noise.html](https://noiseprotocol.org/noise.html)
|
||||
|
||||
The specification for the Noise protocol framework, including the XX handshake
|
||||
pattern used in quicnprotochat's M1 transport stack.
|
||||
|
||||
### Metadata Resistance
|
||||
|
||||
**"Sealed Sender"**
|
||||
|
||||
@@ -20,8 +20,8 @@ how the crates relate to one another.
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│ quicnprotochat-core │ │ quicnprotochat-server │
|
||||
│ (crypto, Noise, │ │ (QUIC listener, │
|
||||
│ MLS, hybrid KEM) │ │ NodeService RPC, │
|
||||
│ (crypto, MLS, │ │ (QUIC listener, │
|
||||
│ hybrid KEM) │ │ NodeService RPC, │
|
||||
│ │ │ storage) │
|
||||
└──────────┬─────────────┘ └─────────┬──────────────┘
|
||||
│ │
|
||||
@@ -42,27 +42,23 @@ serialisation. The server and client crates both depend on core and proto.
|
||||
|
||||
## quicnprotochat-core
|
||||
|
||||
**Role:** Pure cryptographic logic and transport primitives. No network I/O
|
||||
(except for the Noise handshake helpers that take an existing `TcpStream`). No
|
||||
async runtime dependency beyond what Noise transport needs.
|
||||
**Role:** Pure cryptographic logic. No network I/O. No async runtime
|
||||
dependency.
|
||||
|
||||
### Modules
|
||||
|
||||
| Module | Public API | Description |
|
||||
|---------------|-----------------------------------------------------------------------------|-------------|
|
||||
| `keypair` | `NoiseKeypair` | Static X25519 keypair for Noise_XX. `StaticSecret` is `ZeroizeOnDrop`. `private_bytes()` returns `Zeroizing<[u8; 32]>`. |
|
||||
| `identity` | `IdentityKeypair` | Ed25519 signing keypair for MLS credentials. Seed stored as `Zeroizing<[u8; 32]>`. Implements `openmls_traits::Signer`. |
|
||||
| `noise` | `handshake_initiator`, `handshake_responder`, `NoiseTransport` | Noise_XX_25519_ChaChaPoly_BLAKE2s handshake over TCP. `NoiseTransport` provides `send_frame`/`recv_frame`, envelope helpers, and `into_capnp_io()` bridge. |
|
||||
| `codec` | `LengthPrefixedCodec`, `NOISE_MAX_MSG` | Tokio `Encoder<Bytes>` + `Decoder`. 4-byte LE length prefix. Max frame 65,535 bytes. |
|
||||
| `group` | `GroupMember` | MLS group state machine wrapping `openmls::MlsGroup`. Lifecycle: `new` -> `generate_key_package` -> `create_group` / `join_group` -> `send_message` / `receive_message`. |
|
||||
| `keypackage` | `generate_key_package` | Standalone KeyPackage generation (returns TLS-encoded bytes + SHA-256 fingerprint). |
|
||||
| `keystore` | `DiskKeyStore`, `StoreCrypto` | `OpenMlsKeyStore` implementation backed by an in-memory `HashMap` with optional bincode flush to disk. `StoreCrypto` couples `RustCrypto` + `DiskKeyStore` into an `OpenMlsCryptoProvider`. |
|
||||
| `hybrid_kem` | `HybridKeypair`, `HybridPublicKey`, `hybrid_encrypt`, `hybrid_decrypt` | X25519 + ML-KEM-768 hybrid KEM. HKDF-SHA256 key derivation, ChaCha20-Poly1305 AEAD. Versioned envelope wire format. |
|
||||
| `error` | `CoreError`, `CodecError`, `MAX_PLAINTEXT_LEN` | Unified error types. `CoreError` covers Noise, Codec, Cap'n Proto, MLS, and hybrid KEM failures. |
|
||||
| `error` | `CoreError`, `MAX_PLAINTEXT_LEN` | Unified error types. `CoreError` covers Cap'n Proto, MLS, and hybrid KEM failures. |
|
||||
|
||||
### What this crate does NOT do
|
||||
|
||||
- No network I/O beyond the Noise helpers (which take a pre-connected `TcpStream`).
|
||||
- No network I/O.
|
||||
- No QUIC or TLS -- that is the server and client crates' concern.
|
||||
- No async runtime setup (it uses Tokio types internally but does not spawn or
|
||||
manage a runtime).
|
||||
@@ -70,10 +66,10 @@ async runtime dependency beyond what Noise transport needs.
|
||||
|
||||
### Key dependencies
|
||||
|
||||
`snow`, `x25519-dalek`, `ed25519-dalek`, `openmls`, `openmls_rust_crypto`,
|
||||
`openmls_traits`, `tls_codec`, `ml-kem`, `chacha20poly1305`, `hkdf`, `sha2`,
|
||||
`zeroize`, `capnp`, `quicnprotochat-proto`, `tokio`, `tokio-util`, `futures`,
|
||||
`bytes`, `serde`, `bincode`, `serde_json`, `thiserror`.
|
||||
`ed25519-dalek`, `openmls`, `openmls_rust_crypto`,
|
||||
`openmls_traits`, `tls_codec`, `ml-kem`, `x25519-dalek`, `chacha20poly1305`,
|
||||
`hkdf`, `sha2`, `zeroize`, `capnp`, `quicnprotochat-proto`, `tokio`,
|
||||
`serde`, `bincode`, `serde_json`, `thiserror`.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,7 +83,7 @@ for the wire format.
|
||||
|
||||
| Item | Description |
|
||||
|---------------------------|-------------|
|
||||
| `schemas/envelope.capnp` | `Envelope` struct and `MsgType` enum -- top-level wire message for Noise-channel traffic. |
|
||||
| `schemas/envelope.capnp` | `Envelope` struct and `MsgType` enum -- top-level wire message. |
|
||||
| `schemas/auth.capnp` | `AuthenticationService` interface -- `uploadKeyPackage`, `fetchKeyPackage`. |
|
||||
| `schemas/delivery.capnp` | `DeliveryService` interface -- `enqueue`, `fetch`. |
|
||||
| `schemas/node.capnp` | `NodeService` interface (unified AS+DS) -- all RPC methods plus `Auth` struct. |
|
||||
@@ -148,7 +144,6 @@ is handled by `spawn_local`.
|
||||
- No direct crypto operations (it delegates to `quicnprotochat-core` types
|
||||
for fingerprinting and storage only).
|
||||
- No MLS processing -- all payloads are opaque byte strings.
|
||||
- No Noise transport (QUIC/TLS only).
|
||||
|
||||
### Key dependencies
|
||||
|
||||
@@ -192,7 +187,6 @@ group state to disk.
|
||||
### What this crate does NOT do
|
||||
|
||||
- No server-side logic.
|
||||
- No Noise transport (QUIC/TLS only for server communication).
|
||||
- No direct crypto beyond calling `GroupMember` and verifying SHA-256
|
||||
fingerprints.
|
||||
|
||||
|
||||
@@ -26,24 +26,15 @@ connection lifecycle, and the long-polling `fetchWait` mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Dual-Key Model
|
||||
## Identity Key Model
|
||||
|
||||
quicnprotochat uses two independent asymmetric key pairs per client, each
|
||||
serving a distinct role:
|
||||
Each quicnprotochat client holds a single Ed25519 signing keypair that serves
|
||||
as its long-term identity:
|
||||
|
||||
```text
|
||||
quicnprotochat Key Model
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ X25519 static keypair (Noise transport) │
|
||||
│ ───────────────────────────────────── │
|
||||
│ - Generated once per node identity │
|
||||
│ - Used in the Noise_XX handshake (M1 stack) │
|
||||
│ - Provides mutual authentication + │
|
||||
│ channel confidentiality at the TCP layer │
|
||||
│ - Classical only (no PQ protection) │
|
||||
│ - Managed by NoiseKeypair, zeroize-on-drop │
|
||||
│ │
|
||||
│ Ed25519 signing keypair (MLS identity) │
|
||||
│ ────────────────────────────────────── │
|
||||
│ - Generated once per user/device │
|
||||
@@ -55,17 +46,16 @@ serving a distinct role:
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Property | X25519 (Noise) | Ed25519 (MLS) |
|
||||
|-------------------|-------------------------------------|--------------------------------------------|
|
||||
| Curve | Curve25519 (Montgomery) | Ed25519 (Twisted Edwards) |
|
||||
| Purpose | Transport authentication + secrecy | Identity binding, signing, MLS credentials |
|
||||
| Crate | `x25519-dalek` | `ed25519-dalek` |
|
||||
| Zeroize on drop | Yes (`StaticSecret`) | Yes (`Zeroizing<[u8; 32]>`) |
|
||||
| PQ protection | None (classical X25519) | MLS key schedule uses DHKEM(X25519); hybrid PQ KEM available at envelope level |
|
||||
| Property | Ed25519 (MLS) |
|
||||
|-------------------|--------------------------------------------|
|
||||
| Curve | Ed25519 (Twisted Edwards) |
|
||||
| Purpose | Identity binding, signing, MLS credentials |
|
||||
| Crate | `ed25519-dalek` |
|
||||
| Zeroize on drop | Yes (`Zeroizing<[u8; 32]>`) |
|
||||
| PQ protection | MLS key schedule uses DHKEM(X25519); hybrid PQ KEM available at envelope level |
|
||||
|
||||
For details on the cryptographic properties of each key type, see
|
||||
[Ed25519 Identity Keys](../cryptography/identity-keys.md) and
|
||||
[X25519 Transport Keys](../cryptography/transport-keys.md).
|
||||
For details on the cryptographic properties, see
|
||||
[Ed25519 Identity Keys](../cryptography/identity-keys.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -80,14 +70,9 @@ For details on the cryptographic properties of each key type, see
|
||||
│ │ │ │
|
||||
│ GroupMember │ │ GroupMember │
|
||||
│ (MLS state) │ │ (MLS state) │
|
||||
│ │ │ │
|
||||
│ NoiseKeypair* │ │ NoiseKeypair* │
|
||||
│ (X25519, M1) │ │ (X25519, M1) │
|
||||
└────────┬─────────┘ └────────┬─────────┘
|
||||
│ │
|
||||
│ QUIC + TLS 1.3 (quinn/rustls) │
|
||||
│ ─── or ─── │
|
||||
│ Noise_XX over TCP (snow, M1 stack) │
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
@@ -118,8 +103,7 @@ For details on the cryptographic properties of each key type, see
|
||||
2. KeyPackages are single-use (RFC 9420 requirement). The AS atomically removes
|
||||
a KeyPackage on fetch to enforce this invariant.
|
||||
|
||||
3. The QUIC + TLS 1.3 stack is the primary transport (M3+). The Noise_XX over
|
||||
TCP stack from M1 remains available for environments where QUIC is blocked.
|
||||
3. QUIC + TLS 1.3 is the sole transport layer.
|
||||
|
||||
---
|
||||
|
||||
@@ -127,9 +111,8 @@ For details on the cryptographic properties of each key type, see
|
||||
|
||||
The system stacks three protocol layers:
|
||||
|
||||
1. **Transport** -- QUIC + TLS 1.3 (primary) or Noise_XX over TCP (M1
|
||||
fallback). Provides confidentiality, integrity, and server authentication.
|
||||
See [Protocol Stack](protocol-stack.md).
|
||||
1. **Transport** -- QUIC + TLS 1.3. Provides confidentiality, integrity, and
|
||||
server authentication. See [Protocol Stack](protocol-stack.md).
|
||||
|
||||
2. **Framing / RPC** -- Cap'n Proto serialisation and RPC. Provides zero-copy
|
||||
typed messages, schema versioning, and async method dispatch.
|
||||
@@ -152,7 +135,7 @@ The implementation is split across four workspace crates:
|
||||
|
||||
| Crate | Role |
|
||||
|----------------------------|-------------------------------------------------------------------|
|
||||
| `quicnprotochat-core` | Crypto primitives, Noise transport, MLS state machine, hybrid KEM |
|
||||
| `quicnprotochat-core` | Crypto primitives, MLS state machine, hybrid KEM |
|
||||
| `quicnprotochat-proto` | Cap'n Proto schemas, codegen, and serialisation helpers |
|
||||
| `quicnprotochat-server` | QUIC listener, NodeService RPC, storage |
|
||||
| `quicnprotochat-client` | QUIC client, CLI subcommands, state persistence |
|
||||
@@ -164,7 +147,7 @@ and dependency diagram.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Protocol Stack](protocol-stack.md) -- layered comparison of the two transport stacks
|
||||
- [Protocol Stack](protocol-stack.md) -- layered protocol stack description
|
||||
- [Service Architecture](service-architecture.md) -- NodeService RPC methods, connection lifecycle, long-polling
|
||||
- [End-to-End Data Flow](data-flow.md) -- registration, group creation, and message exchange sequence diagrams
|
||||
- [Wire Format Overview](../wire-format/overview.md) -- Cap'n Proto schema reference
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
quicnprotochat layers three protocol stages to move a plaintext message from
|
||||
sender to recipient with end-to-end encryption, typed RPC framing, and
|
||||
authenticated transport. This page describes each layer, explains why both the
|
||||
QUIC and Noise transport stacks exist, and provides a side-by-side comparison.
|
||||
authenticated transport. This page describes each layer and provides a
|
||||
comparison table.
|
||||
|
||||
---
|
||||
|
||||
## Primary Stack (M3+): QUIC + TLS 1.3
|
||||
## Transport: QUIC + TLS 1.3
|
||||
|
||||
Starting from milestone M3, the primary transport is QUIC over UDP with TLS 1.3
|
||||
negotiated by `quinn` and `rustls`. Cap'n Proto RPC rides on a bidirectional
|
||||
QUIC stream.
|
||||
The transport layer is QUIC over UDP with TLS 1.3 negotiated by `quinn` and
|
||||
`rustls`. Cap'n Proto RPC rides on a bidirectional QUIC stream.
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────┐
|
||||
@@ -56,90 +55,6 @@ QUIC stream.
|
||||
`BasicCredential`.
|
||||
- Ciphersuite: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`.
|
||||
|
||||
---
|
||||
|
||||
## M1 Stack: Noise_XX over TCP
|
||||
|
||||
The original milestone-1 transport uses a Noise Protocol Framework handshake
|
||||
directly over TCP. This stack is retained for environments where QUIC (UDP) is
|
||||
blocked by middleboxes.
|
||||
|
||||
```text
|
||||
TCP connection
|
||||
└── Noise_XX handshake (snow)
|
||||
└── Authenticated encrypted channel (ChaCha20-Poly1305)
|
||||
└── [u32 frame_len LE][Cap'n Proto encoded message]
|
||||
└── Cap'n Proto RPC (capnp-rpc)
|
||||
```
|
||||
|
||||
### Layer details
|
||||
|
||||
**TCP**
|
||||
|
||||
- Reliable, ordered byte stream.
|
||||
- No built-in encryption or authentication.
|
||||
|
||||
**Noise_XX** (`snow`)
|
||||
|
||||
- Pattern: `Noise_XX_25519_ChaChaPoly_BLAKE2s`.
|
||||
- Three-message handshake that mutually authenticates both peers' static
|
||||
X25519 keys:
|
||||
|
||||
```text
|
||||
XX handshake (3 messages):
|
||||
-> e (initiator sends ephemeral public key)
|
||||
<- e, ee, s, es (responder: DH + static key)
|
||||
-> s, se (initiator: static key + final DH)
|
||||
```
|
||||
|
||||
- After the handshake, every frame is encrypted with ChaCha20-Poly1305 (AEAD)
|
||||
using session keys derived from the Noise key schedule.
|
||||
- Maximum Noise message size: 65,535 bytes.
|
||||
|
||||
**Length-Prefixed Codec** (`LengthPrefixedCodec` in `quicnprotochat-core`)
|
||||
|
||||
- Each frame is prefixed by a 4-byte little-endian `u32` length field.
|
||||
- Little-endian was chosen for consistency with Cap'n Proto's segment table
|
||||
encoding.
|
||||
- Wire format:
|
||||
|
||||
```text
|
||||
┌──────────────────────────┬──────────────────────────────────────┐
|
||||
│ length (4 bytes, LE u32)│ payload (length bytes) │
|
||||
└──────────────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Maximum payload size is `NOISE_MAX_MSG` (65,535 bytes), enforced on both
|
||||
encode and decode.
|
||||
- See [Length-Prefixed Framing Codec](../wire-format/framing-codec.md) for the
|
||||
full specification.
|
||||
|
||||
**Cap'n Proto RPC**
|
||||
|
||||
- Same schema and RPC interface as the QUIC stack.
|
||||
- The `NoiseTransport::into_capnp_io()` method bridges the message-oriented
|
||||
Noise channel to the byte-stream interface that `capnp-rpc`'s
|
||||
`twoparty::VatNetwork` expects, using a `tokio::io::duplex` pipe and a
|
||||
background shuttle task.
|
||||
|
||||
---
|
||||
|
||||
## Why Both Stacks Exist
|
||||
|
||||
| Concern | QUIC + TLS 1.3 | Noise_XX over TCP |
|
||||
|------------------------|----------------------------------------|----------------------------------------|
|
||||
| **Milestone** | M3+ (primary) | M1 (original, retained) |
|
||||
| **UDP availability** | Requires UDP; may be blocked on some networks | TCP-only; works everywhere |
|
||||
| **Connection setup** | 1-RTT (or 0-RTT on resumption) | 1-RTT TCP + 1.5-RTT Noise handshake |
|
||||
| **Multiplexing** | Native QUIC stream multiplexing | Single TCP connection, single stream |
|
||||
| **Authentication** | Server cert (self-signed / CA-issued) | Mutual static-key authentication |
|
||||
| **PQ gap** | TLS 1.3 key exchange is classical ECDHE | Noise key exchange is classical X25519 |
|
||||
| **Crate** | `quinn`, `rustls` | `snow` |
|
||||
|
||||
Both stacks carry the same Cap'n Proto RPC and MLS layers on top, so
|
||||
application logic is transport-agnostic. The Noise_XX stack may also serve as a
|
||||
peer-to-peer transport in future mesh topologies where a QUIC server
|
||||
certificate model does not apply.
|
||||
|
||||
---
|
||||
|
||||
@@ -148,7 +63,6 @@ certificate model does not apply.
|
||||
| Layer | Provides | Crate(s) |
|
||||
|-------------|------------------------------------------------------------------|-----------------------------------------|
|
||||
| **Transport: QUIC + TLS 1.3** | Confidentiality, server authentication, forward secrecy, multiplexed streams, congestion control | `quinn`, `rustls` |
|
||||
| **Transport: Noise_XX** | Confidentiality, mutual authentication, forward secrecy (per-session) | `snow` |
|
||||
| **Framing: Cap'n Proto** | Zero-copy typed serialisation, schema versioning, async RPC with promise pipelining | `capnp`, `capnp-rpc` |
|
||||
| **Encryption: MLS** | Group key agreement, forward secrecy, post-compromise security, identity binding | `openmls`, `openmls_rust_crypto` |
|
||||
| **Encryption: Hybrid KEM** (optional) | Post-quantum confidentiality for individual payloads (X25519 + ML-KEM-768) | `ml-kem`, `x25519-dalek`, `chacha20poly1305`, `hkdf` |
|
||||
@@ -175,12 +89,12 @@ TLS-encoded MlsMessageOut (opaque ciphertext blob)
|
||||
Cap'n Proto: enqueue(recipientKey, payload)
|
||||
│ ── serialised into NodeService RPC call ──
|
||||
▼
|
||||
QUIC stream (TLS 1.3 encrypted) ─── or ─── Noise frame (ChaCha20-Poly1305)
|
||||
│ │
|
||||
▼ ▼
|
||||
QUIC stream (TLS 1.3 encrypted)
|
||||
│
|
||||
▼
|
||||
╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ network ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌
|
||||
│ │
|
||||
▼ ▼
|
||||
│
|
||||
▼
|
||||
Server: NodeService.enqueue() stores payload in FIFO queue
|
||||
│
|
||||
▼
|
||||
@@ -200,8 +114,7 @@ The server **never** holds the MLS group key. It sees only the encrypted
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Architecture Overview](overview.md) -- high-level system diagram and dual-key model
|
||||
- [Noise_XX Handshake](../protocol-layers/noise-xx.md) -- deep dive into the three-message handshake
|
||||
- [Architecture Overview](overview.md) -- high-level system diagram and identity key model
|
||||
- [QUIC + TLS 1.3](../protocol-layers/quic-tls.md) -- QUIC configuration, ALPN, and certificate handling
|
||||
- [Cap'n Proto Serialisation and RPC](../protocol-layers/capn-proto.md) -- schema design and VatNetwork wiring
|
||||
- [MLS (RFC 9420)](../protocol-layers/mls.md) -- ciphersuite selection, key schedule, and ratchet tree
|
||||
|
||||
@@ -135,7 +135,6 @@ updates are allowed; major version bumps require justification and review.
|
||||
|--------|-------------------|
|
||||
| Classical crypto (signing) | `ed25519-dalek` |
|
||||
| Classical crypto (key exchange) | `x25519-dalek` |
|
||||
| Noise protocol | `snow` |
|
||||
| MLS | `openmls`, `openmls_rust_crypto` |
|
||||
| Post-quantum KEM | `ml-kem` |
|
||||
| Serialisation / RPC | `capnp`, `capnp-rpc` |
|
||||
|
||||
@@ -40,7 +40,6 @@ within the same test binary, then runs client operations against it.
|
||||
|
||||
| File | Milestone | What it covers |
|
||||
|------|-----------|---------------|
|
||||
| `noise_transport.rs` | M1 | Noise\_XX handshake over TCP, Ping/Pong frame exchange, connection lifecycle |
|
||||
| `auth_service.rs` | M2 | KeyPackage upload via AS, KeyPackage fetch (single-use consume semantics), identity key validation |
|
||||
| `mls_group.rs` | M3 | Full MLS round-trip: register state, create group, add member via Welcome, send encrypted message, receive and decrypt |
|
||||
|
||||
@@ -121,8 +120,8 @@ Summary:
|
||||
| `quicnprotochat-core` | 23 | -- | 23 |
|
||||
| `quicnprotochat-proto` | 3 | -- | 3 |
|
||||
| `quicnprotochat-server` | 0 | -- | 0 |
|
||||
| `quicnprotochat-client` | 0 | 6 | 6 |
|
||||
| **Total** | **26** | **6** | **32** |
|
||||
| `quicnprotochat-client` | 0 | 5 | 5 |
|
||||
| **Total** | **26** | **5** | **31** |
|
||||
|
||||
---
|
||||
|
||||
@@ -179,8 +178,6 @@ Fuzz testing for parser and deserialisation code:
|
||||
no undefined behaviour).
|
||||
- **MLS message handler:** Feed arbitrary `MLSMessage` bytes to the
|
||||
`GroupMember::receive_message` path.
|
||||
- **Length-prefixed codec:** Fuzz the frame decoder with arbitrary byte streams.
|
||||
|
||||
Tool: `cargo-fuzz` with `libfuzzer`.
|
||||
|
||||
### Golden-Wire Fixtures (M5+)
|
||||
@@ -214,7 +211,6 @@ Performance benchmarks using [Criterion.rs](https://docs.rs/criterion/):
|
||||
- MLS encap/decap (KeyPackage generation, Welcome processing).
|
||||
- Group-add latency scaling: 2, 10, 100, 1000 members.
|
||||
- Cap'n Proto serialise/deserialise throughput.
|
||||
- Noise handshake latency.
|
||||
|
||||
Benchmarks run separately from tests (`cargo bench`) and are not part of the
|
||||
CI gate, but are tracked for regression detection.
|
||||
|
||||
@@ -30,30 +30,6 @@ Because the ephemeral keys exist only for the duration of the handshake,
|
||||
compromising the server's long-term TLS certificate key (currently self-signed
|
||||
in quicnprotochat) does not reveal past session keys.
|
||||
|
||||
### Noise\_XX
|
||||
|
||||
Inside the QUIC stream, the Noise\_XX handshake
|
||||
(`Noise_XX_25519_ChaChaPoly_BLAKE2s`) provides an additional layer of forward
|
||||
secrecy. The Noise\_XX pattern uses both ephemeral and static X25519 keys:
|
||||
|
||||
```text
|
||||
→ e Initiator sends ephemeral public key
|
||||
← e, ee, s, es Responder: ephemeral, DH(e,e), static, DH(e,s)
|
||||
→ s, se Initiator: static, DH(s,e)
|
||||
```
|
||||
|
||||
The `ee` DH (ephemeral-ephemeral) provides forward secrecy: even if both
|
||||
parties' static keys (`s`) are later compromised, the ephemeral keys that
|
||||
contributed to `ee` have already been discarded.
|
||||
|
||||
The `es` and `se` DH operations mix in the static keys for authentication, but
|
||||
the session key depends on the ephemeral contribution. An attacker who
|
||||
compromises only the static key learns the identity of the parties but cannot
|
||||
recover the session key without the ephemeral key.
|
||||
|
||||
See [X25519 Transport Keys](transport-keys.md) for details on the static
|
||||
keypair.
|
||||
|
||||
## Application Layer Forward Secrecy
|
||||
|
||||
### MLS Epoch Ratchet
|
||||
@@ -125,9 +101,9 @@ operates at two independent layers:
|
||||
|
|
||||
v
|
||||
+------------------------------------------------------+
|
||||
| TLS 1.3 / Noise_XX |
|
||||
| Forward secrecy via ephemeral ECDHE / X25519 DH |
|
||||
| Even if TLS cert or Noise static key is compromised,|
|
||||
| TLS 1.3 (QUIC) |
|
||||
| Forward secrecy via ephemeral ECDHE |
|
||||
| Even if TLS cert is compromised, |
|
||||
| past transport sessions are protected. |
|
||||
+------------------------------------------------------+
|
||||
|
|
||||
@@ -201,5 +177,4 @@ uniformly to all group members.
|
||||
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- when keys are created and destroyed
|
||||
- [Post-Compromise Security](post-compromise-security.md) -- the complementary property (protecting the future)
|
||||
- [Threat Model](threat-model.md) -- attacker models and what FS protects against
|
||||
- [X25519 Transport Keys](transport-keys.md) -- Noise ephemeral DH details
|
||||
- [Ed25519 Identity Keys](identity-keys.md) -- long-term key that FS protects against compromising
|
||||
|
||||
@@ -138,26 +138,6 @@ The Ed25519 public key bytes (`public_key_bytes()`) are used as the
|
||||
KeyPackages indexed by this key, and the Delivery Service routes messages to
|
||||
queues indexed by the same key.
|
||||
|
||||
## Distinction from the X25519 Noise Keypair
|
||||
|
||||
It is critical to understand that the Ed25519 identity key and the X25519
|
||||
transport key are **separate keys on different curves serving different
|
||||
purposes**:
|
||||
|
||||
| Property | Ed25519 Identity Key | X25519 Noise Key |
|
||||
|----------|---------------------|-----------------|
|
||||
| Curve | Twisted Edwards (Ed25519) | Montgomery (Curve25519) |
|
||||
| Operation | Digital signatures | Diffie-Hellman key exchange |
|
||||
| Purpose | MLS credentials, AS registration | Noise\_XX mutual authentication |
|
||||
| Lifetime | Permanent (per client) | Per server process or per connection |
|
||||
| Persistence | Serialized to state file | Not serialized (M6 deferred) |
|
||||
| Source | `identity.rs` | `keypair.rs` |
|
||||
|
||||
Although both curves are related (Curve25519 is birationally equivalent to
|
||||
Ed25519's curve), the keys are **not interchangeable**. Converting between them
|
||||
requires explicit birational mapping, which quicnprotochat intentionally avoids
|
||||
to maintain clean separation of concerns.
|
||||
|
||||
## Serialization
|
||||
|
||||
`IdentityKeypair` implements `Serialize` and `Deserialize` (serde) by
|
||||
@@ -193,7 +173,6 @@ is deterministically re-derived on load.
|
||||
## Related Pages
|
||||
|
||||
- [Cryptography Overview](overview.md) -- algorithm inventory
|
||||
- [X25519 Transport Keys](transport-keys.md) -- the other keypair
|
||||
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- full lifecycle diagram
|
||||
- [Post-Compromise Security](post-compromise-security.md) -- how MLS credentials interact with PCS
|
||||
- [Threat Model](threat-model.md) -- what identity keys protect and do not protect
|
||||
|
||||
@@ -13,9 +13,6 @@ Key Type Creation Distribution Use
|
||||
Ed25519 Identity Once per client AS registration MLS signing, Zeroizing<[u8;32]>
|
||||
(OsRng) + MLS credential credential binding on struct drop
|
||||
|
||||
X25519 Noise Per server process Noise_XX handshake DH key exchange ZeroizeOnDrop
|
||||
or per client conn (in-band) (transport session) on struct drop
|
||||
|
||||
HPKE Init Key Per KeyPackage Uploaded to AS Decrypt Welcome Consumed by openmls;
|
||||
(openmls backend) in KeyPackage (join_group) deleted from keystore
|
||||
|
||||
@@ -93,73 +90,6 @@ The fingerprint (`SHA-256(public_key_bytes)`) is derived from the public key and
|
||||
is used as a compact identifier in logs. It is not secret and does not require
|
||||
zeroization.
|
||||
|
||||
## X25519 Noise Key
|
||||
|
||||
**Source:** `crates/quicnprotochat-core/src/keypair.rs`
|
||||
|
||||
The X25519 Noise key provides mutual authentication during the Noise\_XX
|
||||
handshake. It is shorter-lived than the identity key and is not currently
|
||||
persisted.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
```text
|
||||
+-----------------+
|
||||
| OsRng |
|
||||
| (getrandom) |
|
||||
+--------+--------+
|
||||
|
|
||||
generate()
|
||||
|
|
||||
+--------v--------+
|
||||
| NoiseKeypair |
|
||||
| private: Secret | <-- StaticSecret (ZeroizeOnDrop)
|
||||
| public: PubKey | <-- 32-byte public key
|
||||
+--------+--------+
|
||||
|
|
||||
+--------------+--------------+
|
||||
| |
|
||||
private_bytes() public_bytes()
|
||||
-> Zeroizing<[u8;32]> -> [u8; 32]
|
||||
| |
|
||||
Passed to snow::Builder Exchanged during
|
||||
local_private_key() Noise_XX handshake
|
||||
| |
|
||||
Zeroizing copy drops Stored by peer
|
||||
immediately after use (not secret)
|
||||
| |
|
||||
+-------------+---------------+
|
||||
|
|
||||
Noise handshake completes
|
||||
|
|
||||
+-------------v--------------+
|
||||
| Transport session holds |
|
||||
| derived symmetric keys |
|
||||
| (managed by snow) |
|
||||
+-------------+--------------+
|
||||
|
|
||||
Connection closes
|
||||
|
|
||||
+-------------v--------------+
|
||||
| NoiseKeypair dropped |
|
||||
| StaticSecret::drop() |
|
||||
| overwrites scalar with 0 |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
|
||||
- **Generation:** `StaticSecret::random_from_rng(OsRng)` generates a 32-byte
|
||||
Curve25519 scalar.
|
||||
- **Dual zeroization:** The `StaticSecret` itself implements `ZeroizeOnDrop`,
|
||||
and `private_bytes()` returns a `Zeroizing<[u8; 32]>` wrapper.
|
||||
- **Debug redaction:** The `Debug` impl shows only the first 4 bytes of the
|
||||
public key and prints `[redacted]` for the private key.
|
||||
- **No serialization:** `NoiseKeypair` does not implement `Serialize`. Persistence
|
||||
is deferred to M6.
|
||||
- **Current lifetime:** Per server process start (server) or per connection
|
||||
attempt (client). After M6, keys may be persisted with passphrase encryption.
|
||||
|
||||
## HPKE Init Keys
|
||||
|
||||
**Source:** `crates/quicnprotochat-core/src/keystore.rs` and
|
||||
@@ -364,8 +294,6 @@ attacks.
|
||||
|----------|----------------------|------|
|
||||
| Ed25519 seed | `Zeroizing<[u8; 32]>` | `IdentityKeypair` drop |
|
||||
| Ed25519 seed (accessor) | Plain `[u8; 32]` copy | Caller responsibility |
|
||||
| X25519 private | `ZeroizeOnDrop` (x25519-dalek) | `NoiseKeypair` drop |
|
||||
| X25519 private (accessor) | `Zeroizing<[u8; 32]>` | Accessor drop |
|
||||
| HPKE init private | Managed by openmls/`DiskKeyStore` | After Welcome processing |
|
||||
| MLS epoch keys | Managed by openmls internally | After Commit processing |
|
||||
| Hybrid IKM | `Zeroizing<Vec<u8>>` | After HKDF derivation |
|
||||
@@ -396,7 +324,6 @@ attacks.
|
||||
|
||||
- [Cryptography Overview](overview.md) -- algorithm inventory
|
||||
- [Ed25519 Identity Keys](identity-keys.md) -- identity key details
|
||||
- [X25519 Transport Keys](transport-keys.md) -- transport key details
|
||||
- [Forward Secrecy](forward-secrecy.md) -- how key deletion enables FS
|
||||
- [Post-Compromise Security](post-compromise-security.md) -- epoch advancement
|
||||
- [Post-Quantum Readiness](post-quantum-readiness.md) -- hybrid KEM integration
|
||||
|
||||
@@ -10,10 +10,8 @@ the security margin it provides.
|
||||
| Algorithm | Purpose | Crate | Security Level |
|
||||
|-----------|---------|-------|----------------|
|
||||
| Ed25519 | Identity signing, MLS credentials | `ed25519-dalek 2` | 128-bit classical |
|
||||
| X25519 | Noise DH, MLS HPKE key exchange | `x25519-dalek 2` | 128-bit classical |
|
||||
| ChaCha20-Poly1305 | Noise AEAD | `chacha20poly1305 0.10` | 256-bit key |
|
||||
| X25519 | MLS HPKE key exchange, Hybrid KEM | `x25519-dalek 2` | 128-bit classical |
|
||||
| AES-128-GCM | MLS AEAD | `openmls` (via RustCrypto) | 128-bit |
|
||||
| BLAKE2s | Noise hash function | `snow 0.9` (built-in) | 128-bit |
|
||||
| SHA-256 | Key fingerprints, HKDF | `sha2 0.10` | 128-bit collision resistance |
|
||||
| ML-KEM-768 | Post-quantum KEM | `ml-kem 0.2` | NIST Level 3 (~192-bit PQ) |
|
||||
| HKDF-SHA256 | Key derivation | `hkdf 0.12` | Depends on input entropy |
|
||||
@@ -26,19 +24,11 @@ the security margin it provides.
|
||||
|
||||
### Transport Layer
|
||||
|
||||
The transport layer uses two independent encryption substrates:
|
||||
|
||||
1. **QUIC/TLS 1.3** (via `quinn 0.11` + `rustls 0.23`): Provides the
|
||||
outermost encrypted tunnel. The TLS 1.3 handshake negotiates an ephemeral
|
||||
ECDHE key exchange (X25519 or P-256, depending on the peer) and an AEAD
|
||||
cipher (AES-128-GCM or ChaCha20-Poly1305). This layer protects connection
|
||||
metadata from passive network observers.
|
||||
|
||||
2. **Noise\_XX** (via `snow 0.9`): Runs inside the QUIC stream. The Noise
|
||||
pattern `Noise_XX_25519_ChaChaPoly_BLAKE2s` provides mutual authentication
|
||||
using static X25519 keys, with ChaCha20-Poly1305 as the AEAD and BLAKE2s
|
||||
as the hash function. See [X25519 Transport Keys](transport-keys.md) for
|
||||
details on the keypair.
|
||||
**QUIC/TLS 1.3** (via `quinn 0.11` + `rustls 0.23`): Provides the encrypted
|
||||
transport tunnel. The TLS 1.3 handshake negotiates an ephemeral ECDHE key
|
||||
exchange (X25519 or P-256, depending on the peer) and an AEAD cipher
|
||||
(AES-128-GCM or ChaCha20-Poly1305). This layer protects connection metadata
|
||||
from passive network observers.
|
||||
|
||||
### Application Layer
|
||||
|
||||
@@ -81,7 +71,6 @@ and is considered adequate for the foreseeable future.
|
||||
Layer Classical Security Post-Quantum Security
|
||||
--------------------------------------------------------------------
|
||||
QUIC/TLS 1.3 128-bit (ECDHE) None (classical only)
|
||||
Noise_XX 128-bit (X25519) None (classical only)
|
||||
MLS (content) 128-bit (AES-128-GCM) None (classical only)
|
||||
Hybrid KEM (M5+) 128-bit (X25519) ~192-bit (ML-KEM-768)
|
||||
```
|
||||
@@ -94,7 +83,6 @@ security properties these algorithms enable.
|
||||
## Related Pages
|
||||
|
||||
- [Ed25519 Identity Keys](identity-keys.md) -- long-term signing keypair
|
||||
- [X25519 Transport Keys](transport-keys.md) -- Noise handshake keypair
|
||||
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- creation through destruction
|
||||
- [Forward Secrecy](forward-secrecy.md) -- past message protection
|
||||
- [Post-Compromise Security](post-compromise-security.md) -- future message recovery
|
||||
|
||||
@@ -163,7 +163,7 @@ hybrid KEM for HPKE init key exchange:
|
||||
via the MLS group context extensions. Classical-only clients can still
|
||||
participate in groups that do not require PQ protection.
|
||||
|
||||
## The PQ Gap (ADR-006)
|
||||
## The PQ Gap
|
||||
|
||||
There is an important asymmetry in quicnprotochat's post-quantum protection:
|
||||
|
||||
@@ -171,7 +171,6 @@ There is an important asymmetry in quicnprotochat's post-quantum protection:
|
||||
Layer Classical Protection Post-Quantum Protection
|
||||
---------------------------------------------------------------------
|
||||
QUIC/TLS 1.3 Yes (ECDHE) No
|
||||
Noise_XX Yes (X25519) No
|
||||
MLS content (M5+) Yes (X25519 DHKEM) Yes (hybrid KEM)
|
||||
```
|
||||
|
||||
@@ -182,8 +181,8 @@ MLS content (M5+) Yes (X25519 DHKEM) Yes (hybrid KEM)
|
||||
the message payload.
|
||||
|
||||
- **Transport metadata** (who connects to the server, when, message sizes) is
|
||||
protected only by classical cryptography. A quantum attacker who recorded the
|
||||
TLS/Noise handshake transcripts could, in theory, recover the transport session
|
||||
protected only by classical ECDHE. A quantum attacker who recorded the
|
||||
TLS handshake transcripts could, in theory, recover the transport session
|
||||
keys and observe the metadata.
|
||||
|
||||
This is the **PQ gap**: content is safe, but metadata is not.
|
||||
@@ -195,10 +194,6 @@ the IETF and is supported by some TLS libraries, but `rustls` does not yet
|
||||
support it in a stable release. When `rustls` adds ML-KEM support, quicnprotochat
|
||||
will adopt it to close the PQ gap at the transport layer.
|
||||
|
||||
Similarly, post-quantum Noise patterns are an active research area but are not
|
||||
yet standardized. The `snow` crate does not currently support post-quantum DH
|
||||
primitives.
|
||||
|
||||
## Harvest-Now, Decrypt-Later Risk
|
||||
|
||||
The "harvest-now, decrypt-later" (HNDL) threat model assumes an adversary who:
|
||||
@@ -213,7 +208,7 @@ In quicnprotochat's case:
|
||||
ML-KEM-768, which resists quantum attacks. Even if the recorded traffic is
|
||||
decrypted at the transport layer, the MLS ciphertext inside is still protected.
|
||||
|
||||
- **Transport metadata is at risk.** An HNDL attacker who records TLS/Noise
|
||||
- **Transport metadata is at risk.** An HNDL attacker who records TLS
|
||||
handshakes today could, with a future quantum computer, recover the transport
|
||||
session keys and observe:
|
||||
- Which clients connected to the server and when.
|
||||
|
||||
@@ -17,12 +17,12 @@ payloads. Cannot modify traffic.
|
||||
- Connection metadata: which IP addresses connect to the server and when.
|
||||
- Message timing and sizes: observable patterns (e.g., message frequency,
|
||||
payload lengths) that could reveal communication patterns.
|
||||
- Encrypted payloads: TLS 1.3 ciphertext containing Noise ciphertext containing
|
||||
MLS ciphertext. Three layers of encryption must be broken to access content.
|
||||
- Encrypted payloads: TLS 1.3 ciphertext containing MLS ciphertext. Both layers
|
||||
of encryption must be broken to access content.
|
||||
|
||||
**What they cannot learn:**
|
||||
|
||||
- Message content: protected by MLS encryption inside Noise inside TLS.
|
||||
- Message content: protected by MLS encryption inside TLS.
|
||||
- Group membership details: MLS Commits are encrypted.
|
||||
- Which specific recipient a message is destined for (from the network
|
||||
perspective, all messages go to the server).
|
||||
@@ -51,7 +51,7 @@ state-level adversary).
|
||||
|
||||
**What they cannot do (assuming no cert MITM):**
|
||||
|
||||
- Decrypt TLS/Noise traffic: both use authenticated ephemeral key exchange.
|
||||
- Decrypt TLS traffic: TLS 1.3 uses authenticated ephemeral key exchange.
|
||||
- Forge MLS messages: MLS Commits and application messages are signed by the
|
||||
sender's Ed25519 identity key. The attacker does not possess any member's
|
||||
signing key.
|
||||
@@ -145,8 +145,8 @@ The healing window is the time between the compromise and the next Commit. See
|
||||
| Message integrity | MLS signing (Ed25519) | Forgery by server or network |
|
||||
| Group membership changes | MLS Commits (signed, authenticated) | Unauthorized modification |
|
||||
| Key exchange material | Single-use HPKE init keys | Replay, forward compromise |
|
||||
| Transport confidentiality | TLS 1.3 + Noise\_XX (double encryption) | Passive eavesdropper |
|
||||
| Transport integrity | TLS 1.3 AEAD + Noise AEAD | Active network attacker |
|
||||
| Transport confidentiality | TLS 1.3 (QUIC) | Passive eavesdropper |
|
||||
| Transport integrity | TLS 1.3 AEAD | Active network attacker |
|
||||
| Past messages | Forward secrecy (epoch key deletion) | Future client compromise |
|
||||
| Future messages | Post-compromise security (ratchet tree update) | Past client compromise |
|
||||
|
||||
@@ -223,16 +223,16 @@ log of public key bindings.
|
||||
### Classical-Only Transport
|
||||
|
||||
As discussed in [Post-Quantum Readiness](post-quantum-readiness.md), the
|
||||
transport layer (TLS 1.3, Noise\_XX) uses classical-only cryptography. An
|
||||
adversary performing harvest-now-decrypt-later (HNDL) could record transport
|
||||
traffic today and decrypt it with a future quantum computer, revealing transport
|
||||
transport layer (QUIC/TLS 1.3) uses classical-only ECDHE. An adversary
|
||||
performing harvest-now-decrypt-later (HNDL) could record transport traffic
|
||||
today and decrypt it with a future quantum computer, revealing transport
|
||||
metadata.
|
||||
|
||||
**Impact:** Future exposure of transport metadata (not content, assuming
|
||||
hybrid KEM is active for MLS).
|
||||
|
||||
**Mitigation path:** Adopt post-quantum TLS (ML-KEM in TLS 1.3 handshake) when
|
||||
`rustls` supports it. Investigate post-quantum Noise patterns.
|
||||
`rustls` supports it.
|
||||
|
||||
## Future Mitigations
|
||||
|
||||
@@ -313,14 +313,14 @@ communication patterns from traffic analysis.
|
||||
|
||||
| Threat | Current Protection | Gap | Planned Fix |
|
||||
|--------|-------------------|-----|-------------|
|
||||
| Passive eavesdropper | TLS + Noise + MLS (3 layers) | Traffic analysis | Padding, Tor |
|
||||
| Active MITM | TLS 1.3 + Noise\_XX | Self-signed certs | Cert pinning, CA |
|
||||
| Passive eavesdropper | TLS 1.3 + MLS (2 layers) | Traffic analysis | Padding, Tor |
|
||||
| Active MITM | TLS 1.3 (QUIC) | Self-signed certs | Cert pinning, CA |
|
||||
| Compromised server | MLS E2E encryption | Metadata visible | Sealed Sender, PIR |
|
||||
| Compromised client | FS + PCS | Current epoch exposed | Periodic Updates |
|
||||
| Spam/flooding | None | No auth on DS | AUTHZ\_PLAN |
|
||||
| Key substitution | None | BasicCredential only | Key Transparency |
|
||||
| Quantum adversary (content) | Hybrid KEM (M5+) | Pre-M5 messages | Deploy hybrid ASAP |
|
||||
| Quantum adversary (transport) | None | Classical TLS/Noise | PQ TLS, PQ Noise |
|
||||
| Quantum adversary (transport) | None | Classical TLS (ECDHE) | PQ TLS |
|
||||
|
||||
## Related Pages
|
||||
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
# X25519 Transport Keys
|
||||
|
||||
The X25519 transport keypair is used for mutual authentication in the Noise\_XX
|
||||
handshake. Unlike the [Ed25519 identity key](identity-keys.md), which is a
|
||||
signing key, the X25519 key performs Diffie-Hellman key exchange to establish
|
||||
encrypted transport sessions.
|
||||
|
||||
**Source:** `crates/quicnprotochat-core/src/keypair.rs`
|
||||
|
||||
## Structure
|
||||
|
||||
The `NoiseKeypair` struct holds two fields:
|
||||
|
||||
```rust
|
||||
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,
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Size | Secret? |
|
||||
|-------|------|------|---------|
|
||||
| `private` | `x25519_dalek::StaticSecret` | 32 bytes | Yes -- `ZeroizeOnDrop` |
|
||||
| `public` | `x25519_dalek::PublicKey` | 32 bytes | No -- safe to log/transmit |
|
||||
|
||||
## Key Generation
|
||||
|
||||
A fresh keypair is generated from the OS CSPRNG:
|
||||
|
||||
```rust
|
||||
use quicnprotochat_core::keypair::NoiseKeypair;
|
||||
|
||||
let keypair = NoiseKeypair::generate();
|
||||
// private: random 32-byte scalar from OsRng
|
||||
// public: derived via Curve25519 scalar multiplication
|
||||
```
|
||||
|
||||
Internally:
|
||||
|
||||
```rust
|
||||
pub fn generate() -> Self {
|
||||
let private = StaticSecret::random_from_rng(OsRng);
|
||||
let public = PublicKey::from(&private);
|
||||
Self { private, public }
|
||||
}
|
||||
```
|
||||
|
||||
The `StaticSecret::random_from_rng` call uses the operating system's CSPRNG
|
||||
(`getrandom` on Linux, `SecRandomCopyBytes` on macOS) and is suitable for
|
||||
generating long-lived static identity keys.
|
||||
|
||||
## Accessing Key Material
|
||||
|
||||
### Private Key Bytes
|
||||
|
||||
The `private_bytes()` method returns the raw 32-byte private scalar wrapped in
|
||||
`Zeroizing<[u8; 32]>`:
|
||||
|
||||
```rust
|
||||
pub fn private_bytes(&self) -> Zeroizing<[u8; 32]> {
|
||||
Zeroizing::new(self.private.to_bytes())
|
||||
}
|
||||
```
|
||||
|
||||
The `Zeroizing` wrapper ensures the caller's copy of the key material is
|
||||
overwritten with zeros when it goes out of scope. The intended usage pattern is
|
||||
to pass the bytes directly to `snow::Builder` and let the wrapper drop
|
||||
immediately:
|
||||
|
||||
```rust
|
||||
let private = keypair.private_bytes();
|
||||
let session = snow::Builder::new(params)
|
||||
.local_private_key(&private[..])
|
||||
.build_initiator()?;
|
||||
// private is zeroized here when it falls out of scope.
|
||||
```
|
||||
|
||||
### Public Key Bytes
|
||||
|
||||
The `public_bytes()` method returns a plain `[u8; 32]`:
|
||||
|
||||
```rust
|
||||
pub fn public_bytes(&self) -> [u8; 32] {
|
||||
self.public.to_bytes()
|
||||
}
|
||||
```
|
||||
|
||||
The public key is not secret and may be freely cloned, logged, or transmitted
|
||||
over the wire.
|
||||
|
||||
## Zeroization Strategy
|
||||
|
||||
The `NoiseKeypair` has two layers of zeroization protection:
|
||||
|
||||
1. **`StaticSecret` (inner):** The `x25519_dalek` crate implements
|
||||
`ZeroizeOnDrop` on `StaticSecret`. When the `NoiseKeypair` struct is dropped,
|
||||
the private scalar is automatically overwritten with zeros.
|
||||
|
||||
2. **`Zeroizing<[u8; 32]>` (accessor):** When callers use `private_bytes()`, the
|
||||
returned copy is also wrapped in `Zeroizing`, so the caller's copy is zeroed
|
||||
on drop too.
|
||||
|
||||
This dual-layer approach ensures that key material does not linger in memory
|
||||
whether the key is accessed by value or held in the struct.
|
||||
|
||||
## Debug Redaction
|
||||
|
||||
The `Debug` implementation intentionally redacts the private key and shows only
|
||||
the first 4 bytes of the public key as a sanity identifier:
|
||||
|
||||
```rust
|
||||
impl std::fmt::Debug for NoiseKeypair {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
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],
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This prevents accidental leakage of secret material through logging or
|
||||
`println!("{:?}", keypair)`.
|
||||
|
||||
## Role in Noise\_XX
|
||||
|
||||
The Noise\_XX handshake pattern performs mutual authentication: both initiator
|
||||
and responder prove possession of their static X25519 keys. The handshake
|
||||
proceeds in three messages:
|
||||
|
||||
```text
|
||||
→ e (initiator sends ephemeral public key)
|
||||
← e, ee, s, es (responder sends ephemeral + static, DH results)
|
||||
→ s, se (initiator sends static, final DH result)
|
||||
```
|
||||
|
||||
After the handshake completes, both parties have:
|
||||
|
||||
- Authenticated each other's static X25519 public keys.
|
||||
- Derived symmetric transport keys from the DH shared secrets.
|
||||
- Established forward secrecy via the ephemeral keys (which are discarded).
|
||||
|
||||
The `NoiseKeypair` provides the `s` (static) key in this pattern. Ephemeral keys
|
||||
(`e`) are generated internally by `snow` during each handshake.
|
||||
|
||||
## Ephemeral vs Static
|
||||
|
||||
In the context of Noise\_XX:
|
||||
|
||||
- **Ephemeral keys** are generated per-handshake by `snow` and discarded after
|
||||
key derivation. They provide forward secrecy.
|
||||
- **Static keys** (the `NoiseKeypair`) are longer-lived and provide identity.
|
||||
In the current implementation, the server generates a new `NoiseKeypair` per
|
||||
process start, and the client generates one per connection.
|
||||
|
||||
## Persistence
|
||||
|
||||
`NoiseKeypair` **intentionally does not implement `Serialize`**. Key persistence
|
||||
to disk is deferred to milestone M6, which will add:
|
||||
|
||||
- Appropriate file permission checks (e.g., `0600` on Unix).
|
||||
- Optional passphrase-based encryption of the key file.
|
||||
- A key rotation mechanism.
|
||||
|
||||
Until M6, the transport key is ephemeral to the process lifetime. This is
|
||||
acceptable because the Noise key is not used for MLS group membership -- that
|
||||
role belongs to the [Ed25519 identity key](identity-keys.md).
|
||||
|
||||
## Comparison with Ed25519 Identity Key
|
||||
|
||||
| Property | X25519 Noise Key | Ed25519 Identity Key |
|
||||
|----------|-----------------|---------------------|
|
||||
| Curve | Montgomery (Curve25519) | Twisted Edwards (Ed25519) |
|
||||
| Operation | Diffie-Hellman key exchange | Digital signatures |
|
||||
| Purpose | Noise\_XX mutual authentication | MLS credentials, AS registration |
|
||||
| Lifetime | Per process / per connection | Permanent (per client) |
|
||||
| Serialization | Not implemented | Serde (seed bytes) |
|
||||
| Zeroize | `ZeroizeOnDrop` (x25519-dalek) | `Zeroizing<[u8; 32]>` (manual) |
|
||||
| Source file | `keypair.rs` | `identity.rs` |
|
||||
|
||||
## Related Pages
|
||||
|
||||
- [Cryptography Overview](overview.md) -- algorithm inventory
|
||||
- [Ed25519 Identity Keys](identity-keys.md) -- the other keypair
|
||||
- [Key Lifecycle and Zeroization](key-lifecycle.md) -- full lifecycle diagram
|
||||
- [Forward Secrecy](forward-secrecy.md) -- how ephemeral DH provides FS at the transport layer
|
||||
- [Noise\_XX Handshake](../protocol-layers/noise-xx.md) -- protocol details
|
||||
@@ -1,118 +0,0 @@
|
||||
# ADR-001: Noise\_XX for Transport Authentication
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
quicnprotochat needs mutual authentication at the transport layer: both client and server must prove their identity before any application data is exchanged. The standard solution is TLS with X.509 certificates, but this brings significant operational complexity:
|
||||
|
||||
- A Certificate Authority (CA) must be operated or purchased from.
|
||||
- Certificates must be provisioned, rotated, and revoked.
|
||||
- Client certificate authentication in TLS is cumbersome and poorly supported by many libraries.
|
||||
- The X.509 PKI is a large attack surface with a long history of CA compromises.
|
||||
|
||||
An alternative is needed that provides mutual authentication with simpler key management, ideally using raw public keys rather than certificates.
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
1. **TLS 1.3 with X.509 certificates.** Standard, widely deployed, but requires CA infrastructure. Client certificate authentication is possible but adds complexity. Later adopted for the QUIC transport (M3+), where server authentication is sufficient and client auth is handled at the application layer via the `Auth` struct.
|
||||
|
||||
2. **TLS 1.3 with Raw Public Keys (RFC 7250).** Eliminates the CA dependency but has limited library support. The `rustls` crate did not support RPK at the time of the M1 design.
|
||||
|
||||
3. **Noise Protocol Framework.** Purpose-built for authenticated key exchange using raw static keys. Multiple handshake patterns available. Mature specification with formal security analysis. Well-supported by the `snow` crate in Rust.
|
||||
|
||||
4. **WireGuard-style handshake.** Based on Noise\_IK. Assumes the initiator already knows the responder's static key. Does not provide identity hiding for the initiator.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Use the **Noise\_XX handshake pattern** for the M1 transport layer. Both parties hold static X25519 keypairs that are registered out-of-band (e.g., via a future directory service, QR code, or manual configuration).
|
||||
|
||||
### Why Noise\_XX specifically?
|
||||
|
||||
The Noise Protocol Framework defines several handshake patterns, differing in which static keys are transmitted during the handshake:
|
||||
|
||||
| Pattern | Initiator static key | Responder static key | Identity hiding |
|
||||
|---|---|---|---|
|
||||
| **NN** | Not transmitted | Not transmitted | No authentication |
|
||||
| **NK** | Not transmitted | Known to initiator | Server-only auth |
|
||||
| **KK** | Known to responder | Known to initiator | Mutual auth, no identity hiding |
|
||||
| **XX** | Transmitted (encrypted) | Transmitted (encrypted) | **Mutual auth + identity hiding for initiator** |
|
||||
| **IK** | Transmitted (encrypted) | Known to initiator | Mutual auth, initiator identity hidden from passive observers |
|
||||
|
||||
**XX** was chosen because:
|
||||
|
||||
1. **Mutual authentication.** Both parties prove possession of their static private keys during the handshake. The server verifies the client's identity, and the client verifies the server's identity.
|
||||
|
||||
2. **Identity hiding for the initiator.** The initiator's static public key is transmitted encrypted under an ephemeral key, so a passive network observer cannot determine who is connecting. The responder's static key is also transmitted encrypted, though an active attacker performing a man-in-the-middle on the first message could learn it (this is inherent to any pattern where the responder's key is not pre-known).
|
||||
|
||||
3. **No pre-shared keys required.** Unlike IK or KK, the XX pattern does not require either party to know the other's static key before the handshake begins. This simplifies bootstrapping: a new client can connect to a server without prior key exchange.
|
||||
|
||||
4. **Three-message handshake.** XX completes in 3 messages (-> e, <- e ee s es, -> s se), which is one round-trip more than IK but provides stronger identity hiding guarantees.
|
||||
|
||||
### Cryptographic parameters
|
||||
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| Handshake pattern | `Noise_XX_25519_ChaChaPoly_SHA256` |
|
||||
| DH function | X25519 (Curve25519) |
|
||||
| AEAD cipher | ChaCha20-Poly1305 |
|
||||
| Hash function | SHA-256 |
|
||||
| Static key size | 32 bytes (X25519 public key) |
|
||||
| Ephemeral key size | 32 bytes (X25519 public key) |
|
||||
|
||||
### Implementation
|
||||
|
||||
The Noise handshake is implemented using the `snow` crate (`snow 0.9`). Key source files:
|
||||
|
||||
- `crates/quicnprotochat-core/src/noise.rs` -- `NoiseTransport` struct, handshake state machine, encrypted read/write methods.
|
||||
- `crates/quicnprotochat-core/src/codec.rs` -- `LengthPrefixedCodec` that frames Noise handshake and transport messages over TCP.
|
||||
- `crates/quicnprotochat-core/src/error.rs` -- `CoreError::Noise` variant for handshake and transport errors.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
- **No CA infrastructure.** Key management is reduced to generating, storing, and distributing raw 32-byte X25519 public keys. No certificates, no expiration, no revocation lists.
|
||||
- **Simpler key management.** Each node has a single static X25519 keypair. The public key is its transport-layer identity.
|
||||
- **Identity hiding.** Passive network observers cannot determine which client is connecting to the server.
|
||||
- **Well-analyzed security.** The Noise Protocol Framework has formal security proofs (Kobeissi et al., 2019). The XX pattern specifically has been analyzed for identity hiding and key compromise impersonation resistance.
|
||||
- **Lightweight.** The `snow` crate is small, auditable, and has no transitive dependency on OpenSSL or ring (it uses pure-Rust cryptography).
|
||||
|
||||
### Costs and trade-offs
|
||||
|
||||
- **Three-message handshake.** XX requires 3 messages (1.5 round-trips) compared to TLS 1.3's 1-RTT handshake (or 0-RTT with resumption). This adds latency to connection establishment. In practice, this is only significant for short-lived connections.
|
||||
- **No PQ protection.** The Noise handshake uses classical X25519. A quantum adversary performing a harvest-now-decrypt-later attack could recover the handshake transcript and learn the static keys. This is accepted as a known risk (see [ADR-006: PQ Gap](adr-006-pq-gap.md)).
|
||||
- **Out-of-band key distribution.** Without a CA or directory service, clients must obtain the server's static public key through some out-of-band mechanism. This is currently handled by hardcoding or configuration.
|
||||
- **Superseded for client-server transport.** With the move to QUIC + TLS 1.3 in M3, the Noise transport is no longer the primary client-server path. It remains available for direct peer-to-peer connections and as a fallback in environments where QUIC/UDP is blocked.
|
||||
|
||||
### Residual risks
|
||||
|
||||
- **Harvest-now-decrypt-later for metadata.** An adversary who records the Noise handshake today and obtains a quantum computer in the future could decrypt the handshake transcript, revealing the static public keys of both parties (identity metadata). However, no long-lived content secrets transit the handshake -- MLS provides its own key agreement. See [ADR-006](adr-006-pq-gap.md) for the full analysis.
|
||||
- **Key compromise impersonation (KCI).** If a party's static private key is compromised, an attacker can impersonate other parties to the compromised party. This is inherent to any DH-based mutual authentication scheme without a PKI. Mitigated by key rotation and secure key storage.
|
||||
|
||||
---
|
||||
|
||||
## Code references
|
||||
|
||||
| File | Relevance |
|
||||
|---|---|
|
||||
| `crates/quicnprotochat-core/src/noise.rs` | `NoiseTransport` implementation: handshake, encrypted read/write |
|
||||
| `crates/quicnprotochat-core/src/codec.rs` | `LengthPrefixedCodec`: frames Noise messages over TCP |
|
||||
| `crates/quicnprotochat-core/src/error.rs` | `CoreError::Noise`, `CodecError` error types |
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Design Decisions Overview](overview.md) -- index of all ADRs
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](adr-003-rpc-inside-noise.md) -- how Cap'n Proto RPC runs over the Noise channel
|
||||
- [ADR-006: PQ Gap in Noise Transport](adr-006-pq-gap.md) -- analysis of the post-quantum gap
|
||||
- [Framing Codec](../wire-format/framing-codec.md) -- the codec that frames Noise messages
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how Noise fits in the protocol stack
|
||||
- [Noise Protocol Framework specification](https://noiseprotocol.org/noise.html) -- upstream specification
|
||||
@@ -135,6 +135,5 @@ The Cap'n Proto schemas are stored in the `schemas/` directory:
|
||||
|
||||
- [Design Decisions Overview](overview.md) -- index of all ADRs
|
||||
- [Wire Format Overview](../wire-format/overview.md) -- how Cap'n Proto fits in the serialisation pipeline
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](adr-003-rpc-inside-noise.md) -- how Cap'n Proto RPC runs over the encrypted transport
|
||||
- [Why This Design, Not Signal/Matrix/...](why-not-signal.md) -- serialisation comparison against Protobuf and JSON
|
||||
- [Cap'n Proto encoding specification](https://capnproto.org/encoding.html) -- upstream specification
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
# ADR-003: RPC Inside the Noise Tunnel
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Cap'n Proto RPC provides typed method dispatch, promise pipelining, and automatic serialisation -- but it has **no built-in transport security**. The RPC protocol assumes it operates over a trusted byte stream. If that byte stream is a raw TCP connection, all RPC traffic (method names, parameters, return values) is transmitted in cleartext.
|
||||
|
||||
quicnprotochat requires that all client-server communication be encrypted and authenticated. The question is: how should encryption and RPC be composed?
|
||||
|
||||
### Alternatives considered
|
||||
|
||||
1. **RPC over raw TCP, with application-level encryption.** Each RPC payload would be individually encrypted by the application before passing it to Cap'n Proto. This is complex, error-prone, and does not protect RPC metadata (method ordinals, message structure).
|
||||
|
||||
2. **RPC over TLS.** Use TLS 1.3 as the transport for the Cap'n Proto RPC byte stream. This is the conventional approach for web services (gRPC uses TLS). However, in the M1 design, TLS with mutual authentication required CA infrastructure that we wanted to avoid (see [ADR-001](adr-001-noise-xx.md)).
|
||||
|
||||
3. **RPC over Noise.** Use the Noise\_XX handshake to establish an encrypted, authenticated session, then feed the Cap'n Proto RPC byte stream through the Noise transport layer. The RPC layer is completely unaware of the encryption beneath it.
|
||||
|
||||
4. **RPC over QUIC.** Use QUIC + TLS 1.3 as the transport. Cap'n Proto RPC operates over a QUIC bidirectional stream. This is the approach adopted in M3+.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Cap'n Proto RPC operates over the encrypted byte stream provided by the transport layer. The transport layer -- whether Noise\_XX (M1) or QUIC + TLS 1.3 (M3+) -- owns all security properties (confidentiality, integrity, authentication). Cap'n Proto owns all framing and dispatch properties (serialisation, method routing, schema enforcement).
|
||||
|
||||
This is a **separation of concerns** at the protocol layer boundary:
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────┐
|
||||
│ Cap'n Proto RPC │ Dispatch, serialisation, typing
|
||||
│ (capnp-rpc crate) │
|
||||
├─────────────────────────────────┤
|
||||
│ Encrypted byte stream │ Confidentiality, integrity, auth
|
||||
│ (Noise_XX or QUIC/TLS 1.3) │
|
||||
├─────────────────────────────────┤
|
||||
│ TCP or UDP │ Reliable (TCP) or datagram (UDP)
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Noise transport path (M1)
|
||||
|
||||
In the M1 stack, the composition works as follows:
|
||||
|
||||
1. Client and server perform a Noise\_XX handshake over a TCP connection, establishing a shared session key.
|
||||
2. The resulting `NoiseTransport` wraps the TCP stream, providing `AsyncRead + AsyncWrite` that transparently encrypts/decrypts all data.
|
||||
3. Cap'n Proto RPC is instantiated over this `NoiseTransport`. The RPC runtime reads and writes to the `NoiseTransport` as if it were a plain byte stream.
|
||||
4. Each RPC message is framed by the [LengthPrefixedCodec](../wire-format/framing-codec.md) before encryption and after decryption.
|
||||
|
||||
```text
|
||||
Client Server
|
||||
| |
|
||||
| --- Noise_XX handshake (3 messages) -----------> |
|
||||
| <-- Noise_XX handshake ------------------------- |
|
||||
| |
|
||||
| [Noise-encrypted Cap'n Proto RPC traffic] |
|
||||
| --- uploadKeyPackage(identityKey, pkg, auth) --> |
|
||||
| <-- (fingerprint) -------------------------------- |
|
||||
| --- enqueue(recipientKey, payload, ch, v, a) --> |
|
||||
| <-- () ------------------------------------------ |
|
||||
| ... |
|
||||
```
|
||||
|
||||
### QUIC transport path (M3+)
|
||||
|
||||
In the M3+ stack, the composition is:
|
||||
|
||||
1. Client connects to the server via QUIC, which performs a TLS 1.3 handshake internally.
|
||||
2. The client opens a bidirectional QUIC stream.
|
||||
3. Cap'n Proto RPC is instantiated over the QUIC stream. The `quinn` crate provides `AsyncRead + AsyncWrite` for each stream.
|
||||
4. The `LengthPrefixedCodec` is **not used** in this path -- QUIC provides native stream framing, and `capnp-rpc` handles message delimitation internally.
|
||||
|
||||
```text
|
||||
Client Server
|
||||
| |
|
||||
| --- QUIC handshake (TLS 1.3) -----------------> |
|
||||
| <-- QUIC handshake ---------------------------- |
|
||||
| |
|
||||
| [QUIC-encrypted Cap'n Proto RPC traffic] |
|
||||
| --- uploadKeyPackage(identityKey, pkg, auth) --> |
|
||||
| <-- (fingerprint) -------------------------------- |
|
||||
| --- fetchWait(recipientKey, ch, v, t, a) ------> |
|
||||
| <-- (payloads) ---------------------------------- |
|
||||
| ... |
|
||||
```
|
||||
|
||||
### Transport agnosticism
|
||||
|
||||
The key architectural property is that **Cap'n Proto RPC is transport-agnostic**. The same RPC interface (`NodeService`) works identically over both transport paths. The server implementation does not know or care which transport the client used -- it receives the same typed method calls either way.
|
||||
|
||||
This is achieved by abstracting the transport behind Rust's `AsyncRead + AsyncWrite` traits. The `capnp-rpc` crate accepts any type that implements these traits as its underlying stream.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### Benefits
|
||||
|
||||
- **Clean layering.** Each layer has a single, well-defined responsibility. The transport layer does not need to understand Cap'n Proto. Cap'n Proto does not need to understand encryption. This makes each layer independently testable and replaceable.
|
||||
|
||||
- **Transport flexibility.** Switching from Noise to QUIC (or adding a future transport) required no changes to the RPC interface or the application logic. Only the transport initialization code changed.
|
||||
|
||||
- **Full metadata protection.** Because encryption wraps the entire RPC byte stream, not just individual payloads, all RPC metadata is protected: method ordinals, parameter values, return values, and even the timing pattern of RPC calls (within the limits of the transport's traffic analysis resistance).
|
||||
|
||||
- **No double encryption.** The application layer does not need to encrypt RPC payloads separately. The transport layer provides confidentiality for the entire stream.
|
||||
|
||||
- **Composable security.** The Noise/QUIC layer provides transport security (server authentication, channel confidentiality). MLS provides end-to-end security (group key agreement, forward secrecy, PCS). The RPC layer is the bridge between them, carrying MLS ciphertext as opaque blobs. No single layer needs to provide all security properties.
|
||||
|
||||
### Costs and trade-offs
|
||||
|
||||
- **No end-to-end RPC security.** The RPC layer trusts the transport for confidentiality. If the transport is compromised (e.g., a TLS vulnerability), all RPC traffic is exposed. This is mitigated by MLS providing a second layer of encryption for message content.
|
||||
|
||||
- **Transport must be established first.** The Noise handshake or QUIC connection must complete before any RPC call can be made. This adds latency to the first interaction. In the QUIC path, this is mitigated by 0-RTT resumption.
|
||||
|
||||
- **Debugging complexity.** Because all traffic is encrypted, debugging wire-level issues requires either decrypting the transport (which requires the session keys) or logging at the application layer. This is an inherent trade-off of transport encryption.
|
||||
|
||||
### Residual risks
|
||||
|
||||
- **Transport-layer vulnerability.** A vulnerability in `snow` (Noise) or `rustls` (TLS) could expose the RPC byte stream. Mitigated by keeping dependencies updated and by the fact that MLS ciphertext within the stream is independently encrypted.
|
||||
|
||||
- **Side channels.** The transport encrypts content but may not fully hide message sizes or timing patterns. A sophisticated adversary could infer information from traffic analysis. This is a known limitation of any encrypted transport and is orthogonal to the RPC-inside-transport decision.
|
||||
|
||||
---
|
||||
|
||||
## Code references
|
||||
|
||||
| File | Relevance |
|
||||
|---|---|
|
||||
| `crates/quicnprotochat-core/src/noise.rs` | `NoiseTransport`: encrypted `AsyncRead + AsyncWrite` wrapper |
|
||||
| `crates/quicnprotochat-core/src/codec.rs` | `LengthPrefixedCodec`: frames messages in the Noise path |
|
||||
| `crates/quicnprotochat-server/src/main.rs` | Server: accepts QUIC connections, instantiates Cap'n Proto RPC over QUIC streams |
|
||||
| `crates/quicnprotochat-client/src/main.rs` | Client: connects via QUIC, instantiates Cap'n Proto RPC client |
|
||||
| `schemas/node.capnp` | `NodeService` RPC interface definition |
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Design Decisions Overview](overview.md) -- index of all ADRs
|
||||
- [ADR-001: Noise\_XX for Transport Auth](adr-001-noise-xx.md) -- the Noise transport that RPC runs inside (M1)
|
||||
- [ADR-002: Cap'n Proto over MessagePack](adr-002-capnproto.md) -- why Cap'n Proto was chosen for serialisation
|
||||
- [Wire Format Overview](../wire-format/overview.md) -- the full serialisation pipeline
|
||||
- [Framing Codec](../wire-format/framing-codec.md) -- length-prefixed framing in the Noise path
|
||||
- [NodeService Schema](../wire-format/node-service-schema.md) -- the RPC interface that runs over the encrypted tunnel
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how all protocol layers compose
|
||||
@@ -1,119 +0,0 @@
|
||||
# ADR-006: PQ Gap in Noise Transport
|
||||
|
||||
**Status:** Accepted
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
quicnprotochat's security architecture has two encryption layers:
|
||||
|
||||
1. **Transport layer** (Noise\_XX or QUIC + TLS 1.3): encrypts the byte stream between client and server using classical Diffie-Hellman key exchange (X25519).
|
||||
2. **Content layer** (MLS, RFC 9420): provides end-to-end group key agreement using DHKEM(X25519, HKDF-SHA256) in the current ciphersuite, with a hybrid KEM (X25519 + ML-KEM-768) available at the envelope level and planned for integration into the MLS ciphersuite at M5.
|
||||
|
||||
The content layer will have post-quantum protection from M5 onward via the hybrid KEM. However, the transport layer uses classical X25519 exclusively. This creates a **post-quantum gap**: the transport layer is vulnerable to a quantum adversary, even after the content layer is PQ-protected.
|
||||
|
||||
### The threat: harvest-now, decrypt-later
|
||||
|
||||
A quantum adversary who does not yet have a cryptographically relevant quantum computer (CRQC) can still:
|
||||
|
||||
1. **Record** all encrypted traffic transiting the network today.
|
||||
2. **Store** the recordings until a CRQC becomes available.
|
||||
3. **Decrypt** the recorded traffic using Shor's algorithm to break X25519.
|
||||
|
||||
This is known as the "harvest-now, decrypt-later" (HNDL) attack. The question is: **what is the practical impact of HNDL on quicnprotochat's transport layer?**
|
||||
|
||||
### What a quantum adversary learns from breaking the transport
|
||||
|
||||
If the Noise\_XX handshake is broken, the adversary learns:
|
||||
|
||||
| Data | Sensitivity | Exposure |
|
||||
|---|---|---|
|
||||
| Static X25519 public keys of both parties | Identity metadata | Reveals which client connected to which server |
|
||||
| Timing and size of RPC calls | Traffic metadata | Reveals communication patterns |
|
||||
| Cap'n Proto RPC traffic (method calls, parameters) | Routing metadata | Reveals recipient keys, channel IDs, and message timestamps |
|
||||
| MLS ciphertext (payload bytes) | **Still encrypted** | MLS uses its own key agreement; breaking the transport does not break MLS |
|
||||
|
||||
Critically, **no long-lived content secrets transit the Noise handshake**. The MLS key schedule derives group keys independently of the transport. Even with full transport decryption, the adversary sees only MLS ciphertext, which they cannot decrypt without breaking MLS's own key exchange (which will be PQ-protected from M5).
|
||||
|
||||
### Why not use PQ-Noise now?
|
||||
|
||||
The Noise Protocol Framework community has drafted extensions for post-quantum Noise (PQ-Noise), which replace or augment X25519 with PQ key exchange mechanisms (e.g., Kyber/ML-KEM). However:
|
||||
|
||||
1. **The `snow` crate does not support PQ-Noise.** As of snow 0.9, there is no API for PQ handshake patterns. Adding PQ support would require forking `snow` or switching to a different Noise implementation.
|
||||
|
||||
2. **PQ-Noise is not yet standardized.** The draft specifications (e.g., `draft-noise-pq`) are still evolving. Adopting an unstable specification risks incompatibility with future versions.
|
||||
|
||||
3. **Performance and size concerns.** ML-KEM-768 ciphertexts are 1,088 bytes, and encapsulation keys are 1,184 bytes. These are significantly larger than X25519's 32-byte keys. In a Noise handshake, where multiple key exchanges occur, the handshake size and latency increase substantially.
|
||||
|
||||
4. **The QUIC path uses TLS 1.3.** The primary transport in M3+ is QUIC + TLS 1.3, which has its own PQ migration path (via `rustls` and the `x25519-mlkem768` TLS key exchange group). This path is more likely to receive PQ support before `snow` does.
|
||||
|
||||
---
|
||||
|
||||
## Decision
|
||||
|
||||
Accept the PQ gap in the Noise transport layer for milestones M1 through M5. The content layer (MLS) will be PQ-protected from M5 via the hybrid KEM. The transport layer will gain PQ protection when either:
|
||||
|
||||
- The `snow` crate adds PQ-Noise support, or
|
||||
- The QUIC/TLS path gains PQ key exchange support via `rustls`, or
|
||||
- A PQ-Noise Rust implementation becomes available and is adopted.
|
||||
|
||||
Until then, the transport layer uses classical X25519, and the PQ gap is accepted as a known, bounded risk.
|
||||
|
||||
---
|
||||
|
||||
## Consequences
|
||||
|
||||
### What is protected
|
||||
|
||||
- **Message content** is protected by MLS's own key agreement. Even if the transport is broken, MLS ciphertext remains secure (assuming MLS uses a PQ-safe ciphersuite, which is the plan for M5).
|
||||
- **MLS key material** (epoch secrets, application secrets) never transits the Noise handshake. They are derived from the MLS tree, not from the transport.
|
||||
- **Forward secrecy of content** is provided by MLS epoch ratcheting, independent of the transport.
|
||||
|
||||
### What is exposed
|
||||
|
||||
- **Identity metadata.** A quantum adversary who breaks the Noise handshake learns the static X25519 public keys of both parties. This reveals *which* client connected to *which* server, and *when*.
|
||||
- **Timing metadata.** The adversary learns the timing and size pattern of RPC calls, which can reveal communication patterns (e.g., "Alice and Bob exchanged messages at 3pm").
|
||||
- **Routing metadata.** The adversary learns the recipient keys and channel IDs in RPC calls (since Cap'n Proto RPC traffic is visible after transport decryption). This reveals *who* is communicating with *whom*, even though the message content remains encrypted by MLS.
|
||||
|
||||
### Practical impact assessment
|
||||
|
||||
| Risk Factor | Assessment |
|
||||
|---|---|
|
||||
| **Timeline to CRQC** | Most estimates place cryptographically relevant quantum computers at 10-20+ years away. The PQ gap is a near-term risk only for adversaries with very long storage horizons. |
|
||||
| **Value of metadata** | Identity and timing metadata is sensitive for high-value targets but less critical than message content for most users. |
|
||||
| **Content protection** | Message content is independently protected by MLS. Breaking the transport does not break content encryption. |
|
||||
| **Migration path** | PQ key exchange for TLS 1.3 is being standardized (ML-KEM in TLS). The QUIC/TLS path is likely to gain PQ protection before the Noise path. |
|
||||
| **Overall risk** | **Low to moderate.** The PQ gap exposes metadata only, not content. The risk is limited to adversaries who (a) can record traffic today, (b) will have a CRQC in the future, and (c) are interested in metadata about quicnprotochat users. |
|
||||
|
||||
### Mitigation timeline
|
||||
|
||||
| Milestone | Transport PQ Status | Content PQ Status |
|
||||
|---|---|---|
|
||||
| M1 | Classical X25519 (Noise) | Classical DHKEM (MLS) |
|
||||
| M2 | Classical X25519 (Noise) | Classical DHKEM (MLS) |
|
||||
| M3 | Classical X25519 (QUIC/TLS 1.3) | Classical DHKEM (MLS) + hybrid KEM at envelope level |
|
||||
| M4 | Classical X25519 (QUIC/TLS 1.3) | Classical DHKEM (MLS) + hybrid KEM at envelope level |
|
||||
| M5 | Classical X25519 (QUIC/TLS 1.3) | **PQ-protected** (hybrid KEM integrated into MLS ciphersuite) |
|
||||
| Future | **PQ-protected** (PQ key exchange in TLS or PQ-Noise) | PQ-protected |
|
||||
|
||||
---
|
||||
|
||||
## Code references
|
||||
|
||||
| File | Relevance |
|
||||
|---|---|
|
||||
| `crates/quicnprotochat-core/src/noise.rs` | Noise\_XX handshake using classical X25519 (`Noise_XX_25519_ChaChaPoly_SHA256`) |
|
||||
| `crates/quicnprotochat-core/src/hybrid_kem.rs` | Hybrid KEM (X25519 + ML-KEM-768) for content-layer PQ protection |
|
||||
| `crates/quicnprotochat-server/src/main.rs` | QUIC server using `rustls` with classical TLS 1.3 |
|
||||
| `crates/quicnprotochat-client/src/main.rs` | QUIC client using `rustls` with classical TLS 1.3 |
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Design Decisions Overview](overview.md) -- index of all ADRs
|
||||
- [ADR-001: Noise\_XX for Transport Auth](adr-001-noise-xx.md) -- the Noise transport that has the PQ gap
|
||||
- [Why This Design, Not Signal/Matrix/...](why-not-signal.md) -- PQ readiness comparison across protocols
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how transport and content layers compose
|
||||
- [Noise Protocol Framework specification](https://noiseprotocol.org/noise.html) -- upstream Noise specification
|
||||
@@ -10,12 +10,9 @@ These decisions are not immutable. Each ADR has a status field and can be supers
|
||||
|
||||
| ADR | Title | Status | One-line summary |
|
||||
|---|---|---|---|
|
||||
| [ADR-001](adr-001-noise-xx.md) | Noise\_XX for Transport Auth | Accepted | Mutual authentication via static X25519 keys; no CA infrastructure required. |
|
||||
| [ADR-002](adr-002-capnproto.md) | Cap'n Proto over MessagePack | Accepted | Zero-copy, schema-enforced serialisation with built-in async RPC replaces hand-rolled MessagePack dispatch. |
|
||||
| [ADR-003](adr-003-rpc-inside-noise.md) | RPC Inside the Noise Tunnel | Accepted | Cap'n Proto RPC operates over the encrypted byte stream; transport owns security, RPC owns dispatch. |
|
||||
| [ADR-004](adr-004-mls-unaware-ds.md) | MLS-Unaware Delivery Service | Accepted | The DS routes opaque blobs by recipient key; it never inspects MLS content. |
|
||||
| [ADR-005](adr-005-single-use-keypackages.md) | Single-Use KeyPackages | Accepted | The AS atomically removes a KeyPackage on fetch to preserve MLS forward secrecy. |
|
||||
| [ADR-006](adr-006-pq-gap.md) | PQ Gap in Noise Transport | Accepted | Classical X25519 in Noise is accepted for M1-M5; MLS content is PQ-protected separately. |
|
||||
|
||||
---
|
||||
|
||||
@@ -43,7 +40,7 @@ Several themes recur across multiple ADRs:
|
||||
|
||||
### Layered security
|
||||
|
||||
ADR-001, ADR-003, and ADR-006 all concern the separation between transport-layer security (Noise or QUIC/TLS) and application-layer security (MLS). The core principle is that **no single layer is trusted alone**. Transport encryption protects metadata and provides authentication; MLS provides end-to-end content encryption with forward secrecy and post-compromise security.
|
||||
The core principle is that **no single layer is trusted alone**. QUIC/TLS transport encryption protects metadata and provides authentication; MLS provides end-to-end content encryption with forward secrecy and post-compromise security.
|
||||
|
||||
### Server minimalism
|
||||
|
||||
@@ -51,7 +48,7 @@ ADR-004 and ADR-005 reflect a design philosophy where the server does as little
|
||||
|
||||
### Schema-first design
|
||||
|
||||
ADR-002 and ADR-003 establish Cap'n Proto as the single source of truth for the wire format. Every message and RPC call is defined in `.capnp` schema files, which are checked into the repository and used for code generation. This eliminates the class of bugs that arises from hand-rolled serialisation and ensures that the wire format is documented, versioned, and evolvable.
|
||||
ADR-002 establishes Cap'n Proto as the single source of truth for the wire format. Every message and RPC call is defined in `.capnp` schema files, which are checked into the repository and used for code generation. This eliminates the class of bugs that arises from hand-rolled serialisation and ensures that the wire format is documented, versioned, and evolvable.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ The transport layer determines how encrypted payloads reach the server and how c
|
||||
| **Head-of-line blocking** | Mitigated by HTTP/2 streams, but TCP HOL blocking remains | Same as Signal | Eliminated: QUIC streams are independent at the transport layer |
|
||||
| **Connection establishment** | 1-RTT (TLS 1.3) or 0-RTT (TLS resumption) | 1-RTT (TLS 1.3) or 0-RTT | 0-RTT capable (QUIC resumption) or 1-RTT |
|
||||
| **Client authentication** | Bearer tokens over TLS | Bearer tokens over TLS | TLS client certs (rustls/quinn) or bearer tokens via `Auth` struct |
|
||||
| **Fallback** | TCP only | TCP only | Noise\_XX over TCP (M1 stack) for environments where UDP/QUIC is blocked |
|
||||
| **Fallback** | TCP only | TCP only | None currently (QUIC only) |
|
||||
|
||||
**Why QUIC?**
|
||||
|
||||
@@ -136,7 +136,7 @@ While Protobuf is a reasonable choice (and Signal uses it successfully), Cap'n P
|
||||
| **Group op cost** | O(n) to O(n^2) | O(n) | **O(log n)** |
|
||||
| **Transport** | TLS/TCP (HTTP/2) | TLS/TCP (HTTPS) | **QUIC/UDP** (0-RTT, no HOL blocking) |
|
||||
| **Serialisation** | Protobuf | JSON | **Cap'n Proto** (zero-copy, canonical, built-in RPC) |
|
||||
| **Standardization** | De facto standard | Matrix spec (open, community-governed) | **IETF RFC 9420** (MLS) + Noise Protocol Framework |
|
||||
| **Standardization** | De facto standard | Matrix spec (open, community-governed) | **IETF RFC 9420** (MLS) |
|
||||
| **Federation** | No (centralized) | Yes (decentralized) | No (single server per deployment) |
|
||||
| **PQ readiness** | PQXDH (X3DH + ML-KEM) in 1:1, not in groups | Not yet | Hybrid KEM (X25519 + ML-KEM-768) at envelope layer; MLS PQ integration planned (M5) |
|
||||
| **Maturity** | 10+ years, billions of users | 7+ years, millions of users | Early development (M1-M3) |
|
||||
@@ -157,7 +157,6 @@ No design is without trade-offs. Compared to Signal and Matrix, quicnprotochat:
|
||||
## Further reading
|
||||
|
||||
- [Design Decisions Overview](overview.md) -- index of all ADRs
|
||||
- [ADR-001: Noise\_XX for Transport Auth](adr-001-noise-xx.md) -- transport authentication choice
|
||||
- [ADR-002: Cap'n Proto over MessagePack](adr-002-capnproto.md) -- serialisation format choice
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how quicnprotochat's layers compose
|
||||
- [MLS (RFC 9420)](../protocol-layers/mls.md) -- deep dive into the MLS protocol layer
|
||||
|
||||
@@ -16,7 +16,7 @@ This compiles all four crates:
|
||||
|
||||
| Crate | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `quicnprotochat-core` | library | Crypto primitives, Noise transport, MLS `GroupMember` state machine, frame codec |
|
||||
| `quicnprotochat-core` | library | Crypto primitives, MLS `GroupMember` state machine, hybrid KEM |
|
||||
| `quicnprotochat-proto` | library | Cap'n Proto schemas, generated types, envelope serialisation helpers |
|
||||
| `quicnprotochat-server` | binary | Unified Authentication + Delivery Service (`NodeService`) |
|
||||
| `quicnprotochat-client` | binary | CLI client with subcommands (`ping`, `register`, `send`, `recv`, etc.) |
|
||||
|
||||
@@ -84,7 +84,7 @@ Consumers import types from these modules. For example, `node_capnp::node_servic
|
||||
|
||||
## The Envelope schema
|
||||
|
||||
The `Envelope` is the top-level wire message for all quicnprotochat traffic. Every frame exchanged between peers (whether over Noise or QUIC) is serialised as an Envelope:
|
||||
The `Envelope` is the top-level wire message for all quicnprotochat traffic. Every frame exchanged between peers is serialised as an Envelope:
|
||||
|
||||
```capnp
|
||||
struct Envelope {
|
||||
@@ -149,7 +149,7 @@ Two functions handle the conversion between `ParsedEnvelope` and wire bytes:
|
||||
pub fn build_envelope(env: &ParsedEnvelope) -> Result<Vec<u8>, capnp::Error>
|
||||
```
|
||||
|
||||
Serialises a `ParsedEnvelope` to unpacked Cap'n Proto wire bytes. The output includes the Cap'n Proto segment table header followed by the message data. These bytes are suitable as the body of a length-prefixed frame (the `LengthPrefixedCodec` in `quicnprotochat-core` prepends the 4-byte length) or as a payload within a QUIC stream.
|
||||
Serialises a `ParsedEnvelope` to unpacked Cap'n Proto wire bytes. The output includes the Cap'n Proto segment table header followed by the message data. These bytes are suitable as a payload within a QUIC stream.
|
||||
|
||||
Internally, it builds a `capnp::message::Builder`, populates an `Envelope` root, and serialises via `capnp::serialize::write_message`.
|
||||
|
||||
@@ -242,8 +242,6 @@ Client Server
|
||||
|
||||
The `tokio-util` compat layer converts Quinn stream types into `futures::AsyncRead + AsyncWrite`, which `capnp-rpc`'s `VatNetwork` expects. See [QUIC + TLS 1.3](quic-tls.md) for the full connection setup.
|
||||
|
||||
On the legacy Noise path, the `into_capnp_io()` bridge serves the same purpose -- converting a Noise-encrypted TCP connection into a byte stream for `VatNetwork`. See [Noise\_XX Handshake](noise-xx.md) for details.
|
||||
|
||||
## Comparison with alternatives
|
||||
|
||||
### vs Protocol Buffers + gRPC
|
||||
@@ -275,4 +273,3 @@ These constraints keep the serialisation layer thin and auditable.
|
||||
- [Auth Schema](../wire-format/auth-schema.md) -- Auth token structure and versioning.
|
||||
- [MLS (RFC 9420)](mls.md) -- How MLS messages are carried as opaque payloads inside Cap'n Proto Envelopes.
|
||||
- [ADR-002: Cap'n Proto over MessagePack](../design-rationale/adr-002-capnproto.md) -- Design rationale for choosing Cap'n Proto.
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](../design-rationale/adr-003-rpc-inside-noise.md) -- Why RPC runs inside the encrypted transport.
|
||||
|
||||
@@ -233,7 +233,7 @@ The hybrid KEM module is:
|
||||
|
||||
The M5 milestone will integrate the hybrid KEM, likely as an outer encryption layer. Until then, MLS application data is protected by classical X25519 ECDH (128-bit security against classical computers, vulnerable to quantum computers).
|
||||
|
||||
The post-quantum gap in the transport layer ([QUIC + TLS 1.3](quic-tls.md) and [Noise\_XX](noise-xx.md)) is a separate concern tracked in [ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md).
|
||||
The post-quantum gap in the transport layer ([QUIC + TLS 1.3](quic-tls.md)) is a separate concern -- TLS 1.3 key exchange uses classical ECDHE and does not yet include post-quantum key agreement.
|
||||
|
||||
## Security analysis
|
||||
|
||||
@@ -276,6 +276,5 @@ The AEAD nonce is derived deterministically from the shared secrets via HKDF. Si
|
||||
- [Post-Quantum Readiness](../cryptography/post-quantum-readiness.md) -- Broader discussion of quicnprotochat's PQ strategy.
|
||||
- [MLS (RFC 9420)](mls.md) -- The MLS layer that the hybrid KEM will wrap.
|
||||
- [Key Lifecycle and Zeroization](../cryptography/key-lifecycle.md) -- How hybrid key material is managed and cleared.
|
||||
- [ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md) -- The accepted PQ gap in the transport layers.
|
||||
- [Threat Model](../cryptography/threat-model.md) -- Where hybrid KEM fits in the overall threat model.
|
||||
- [Milestone Tracker](../roadmap/milestones.md) -- M5 milestone for hybrid KEM integration into MLS.
|
||||
|
||||
@@ -339,7 +339,7 @@ For quicnprotochat's target group sizes (2-100 members), this trade-off is accep
|
||||
|
||||
## Wire format
|
||||
|
||||
All MLS messages are serialised using TLS presentation language encoding (`tls_codec`). The TLS-encoded byte vectors are what the transport layer (Noise or QUIC) and the Delivery Service see. The DS routes these blobs without parsing them.
|
||||
All MLS messages are serialised using TLS presentation language encoding (`tls_codec`). The TLS-encoded byte vectors are what the transport layer (QUIC + TLS 1.3) and the Delivery Service see. The DS routes these blobs without parsing them.
|
||||
|
||||
The key wire message types:
|
||||
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
# Noise\_XX Handshake
|
||||
|
||||
quicnprotochat's M1 milestone used the Noise Protocol Framework for transport-layer encryption between peers over raw TCP. The implementation lives in `quicnprotochat-core/src/noise.rs` and uses the `snow 0.9` crate. Although the M3 architecture migrated client-server communication to [QUIC + TLS 1.3](quic-tls.md), the Noise\_XX transport remains in the codebase for direct peer-to-peer connections and integration testing.
|
||||
|
||||
## The Noise\_XX pattern
|
||||
|
||||
quicnprotochat uses the `Noise_XX_25519_ChaChaPoly_BLAKE2s` parameter set:
|
||||
|
||||
| Component | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| **Pattern** | XX | Mutual authentication with no pre-shared keys required |
|
||||
| **DH** | X25519 | 128-bit security level; fast; widely reviewed |
|
||||
| **AEAD** | ChaCha20-Poly1305 | Constant-time on all platforms (no AES-NI dependency) |
|
||||
| **Hash** | BLAKE2s | Faster than SHA-256 on software; 256-bit security level |
|
||||
|
||||
The XX pattern involves a three-message handshake:
|
||||
|
||||
```text
|
||||
XX handshake (3 messages):
|
||||
-> e Initiator sends ephemeral public key
|
||||
<- e, ee, s, es Responder replies: ephemeral, DH(ee), static key, DH(es)
|
||||
-> s, se Initiator sends static key, DH(se)
|
||||
```
|
||||
|
||||
### Message-by-message breakdown
|
||||
|
||||
**Message 1: `-> e` (Initiator to Responder)**
|
||||
|
||||
The initiator generates an ephemeral X25519 keypair and sends the public half. At this point, no encryption is active. The ephemeral key is sent in the clear, but it reveals nothing about the initiator's identity.
|
||||
|
||||
**Message 2: `<- e, ee, s, es` (Responder to Initiator)**
|
||||
|
||||
The responder:
|
||||
1. Generates its own ephemeral X25519 keypair and sends the public half (`e`).
|
||||
2. Performs `DH(e_init, e_resp)` to establish a shared secret (`ee`).
|
||||
3. Sends its static (long-term) X25519 public key encrypted under the `ee` shared secret (`s`).
|
||||
4. Performs `DH(e_init, s_resp)` for an additional shared secret (`es`).
|
||||
|
||||
After this message, the initiator knows the responder's static key and can authenticate it.
|
||||
|
||||
**Message 3: `-> s, se` (Initiator to Responder)**
|
||||
|
||||
The initiator:
|
||||
1. Sends its static X25519 public key encrypted under the accumulated handshake secrets (`s`).
|
||||
2. Performs `DH(s_init, e_resp)` for the final shared secret (`se`).
|
||||
|
||||
After this message, both parties have authenticated each other's static keys and derived a symmetric session key for ChaCha20-Poly1305.
|
||||
|
||||
### Why XX
|
||||
|
||||
The XX pattern was chosen over other Noise patterns for several reasons:
|
||||
|
||||
- **No pre-shared keys**: Unlike IK or KK, XX does not require either party to know the other's static key before the handshake. This simplifies bootstrapping -- peers can connect to each other using only a network address.
|
||||
- **Identity hiding for the initiator**: The initiator's static key is not sent until message 3, after the session is already encrypted. An eavesdropper cannot determine who is initiating the connection.
|
||||
- **Mutual authentication**: Both parties prove possession of their static private keys through DH operations. Unlike the NK or NX patterns, neither party is anonymous.
|
||||
- **Responder identity protection (partial)**: The responder's static key is encrypted under the `ee` DH secret in message 2, providing protection against passive eavesdroppers (but not against an active attacker who controls the initiator's ephemeral key).
|
||||
|
||||
## Implementation
|
||||
|
||||
The core type is `NoiseTransport`, defined in `quicnprotochat-core/src/noise.rs`:
|
||||
|
||||
```rust
|
||||
pub struct NoiseTransport {
|
||||
framed: Framed<TcpStream, LengthPrefixedCodec>,
|
||||
session: snow::TransportState,
|
||||
remote_static: Option<Vec<u8>>,
|
||||
}
|
||||
```
|
||||
|
||||
The struct wraps three components:
|
||||
|
||||
1. **`framed`**: A `tokio_util::codec::Framed<TcpStream, LengthPrefixedCodec>` that handles length-prefixed byte framing over TCP. Each frame is prefixed with a 4-byte little-endian length field. See [Length-Prefixed Framing Codec](../wire-format/framing-codec.md) for details on the wire format.
|
||||
|
||||
2. **`session`**: A `snow::TransportState` that encrypts and decrypts Noise messages. This is obtained by calling `HandshakeState::into_transport_mode()` after the three-message handshake completes.
|
||||
|
||||
3. **`remote_static`**: The remote peer's static X25519 public key (32 bytes), captured from the `HandshakeState` before `into_transport_mode()` consumes it. This is stored explicitly because `snow` does not guarantee that `TransportState::get_remote_static()` survives the mode transition.
|
||||
|
||||
### Handshake functions
|
||||
|
||||
Two public async functions perform the handshake:
|
||||
|
||||
#### `handshake_initiator`
|
||||
|
||||
```rust
|
||||
pub async fn handshake_initiator(
|
||||
stream: TcpStream,
|
||||
keypair: &NoiseKeypair,
|
||||
) -> Result<NoiseTransport, CoreError>
|
||||
```
|
||||
|
||||
The initiator:
|
||||
|
||||
1. Parses the Noise parameter string `Noise_XX_25519_ChaChaPoly_BLAKE2s` and builds a `snow::Builder` with the local private key.
|
||||
2. Wraps the TCP stream in `Framed<TcpStream, LengthPrefixedCodec>`.
|
||||
3. Allocates a scratch buffer of `NOISE_MAX_MSG` (65,535) bytes.
|
||||
4. **Message 1** (`-> e`): Calls `session.write_message(&[], &mut buf)` to produce the ephemeral key, then sends it as a length-prefixed frame.
|
||||
5. **Message 2** (`<- e, ee, s, es`): Receives a frame and calls `session.read_message()` to process it.
|
||||
6. **Message 3** (`-> s, se`): Calls `session.write_message()` again and sends the result.
|
||||
7. Zeroizes the scratch buffer (it contained plaintext key material during the handshake).
|
||||
8. Captures the remote static key via `session.get_remote_static()`.
|
||||
9. Transitions to transport mode via `session.into_transport_mode()`.
|
||||
|
||||
The private key bytes are held in a `Zeroizing` wrapper and dropped immediately after `snow::Builder` clones them internally.
|
||||
|
||||
#### `handshake_responder`
|
||||
|
||||
```rust
|
||||
pub async fn handshake_responder(
|
||||
stream: TcpStream,
|
||||
keypair: &NoiseKeypair,
|
||||
) -> Result<NoiseTransport, CoreError>
|
||||
```
|
||||
|
||||
The responder mirrors the initiator but with reversed message directions:
|
||||
|
||||
1. Builds a `snow::Builder` with `build_responder()`.
|
||||
2. **Message 1** (`<- e`): Receives and processes the initiator's ephemeral key.
|
||||
3. **Message 2** (`-> e, ee, s, es`): Produces and sends the responder's reply.
|
||||
4. **Message 3** (`<- s, se`): Receives and processes the initiator's static key.
|
||||
5. Same zeroization, key capture, and mode transition as the initiator.
|
||||
|
||||
Both functions return `CoreError::HandshakeIncomplete` if the peer closes the connection mid-handshake, `CoreError::Noise` for any snow error, or `CoreError::Codec` for TCP I/O failures.
|
||||
|
||||
### Transport-layer I/O
|
||||
|
||||
After the handshake, `NoiseTransport` provides two levels of I/O:
|
||||
|
||||
**Frame-level** (raw bytes):
|
||||
|
||||
- `send_frame(&mut self, plaintext: &[u8])` -- Encrypts plaintext with ChaCha20-Poly1305 (adding a 16-byte AEAD tag) and sends it as a length-prefixed frame. Rejects payloads exceeding `MAX_PLAINTEXT_LEN` (65,519 bytes -- the Noise maximum of 65,535 minus the 16-byte AEAD tag).
|
||||
- `recv_frame(&mut self)` -- Receives a length-prefixed frame and decrypts it.
|
||||
|
||||
**Envelope-level** (Cap'n Proto messages):
|
||||
|
||||
- `send_envelope(&mut self, env: &ParsedEnvelope)` -- Serialises a `ParsedEnvelope` to Cap'n Proto wire bytes via `build_envelope()`, then calls `send_frame()`.
|
||||
- `recv_envelope(&mut self)` -- Calls `recv_frame()`, then deserialises the bytes via `parse_envelope()`.
|
||||
|
||||
## The capnp-rpc bridge: `into_capnp_io()`
|
||||
|
||||
The most architecturally interesting method on `NoiseTransport` is `into_capnp_io()`, which bridges the message-oriented Noise transport with the stream-oriented `capnp-rpc` library:
|
||||
|
||||
```rust
|
||||
pub fn into_capnp_io(mut self) -> (ReadHalf<DuplexStream>, WriteHalf<DuplexStream>)
|
||||
```
|
||||
|
||||
### Why this bridge exists
|
||||
|
||||
`capnp-rpc`'s `twoparty::VatNetwork` expects `AsyncRead + AsyncWrite` byte streams, but `NoiseTransport` is message-based -- each `send_frame`/`recv_frame` call encrypts/decrypts one discrete Noise message. These two models are incompatible: a byte stream has no inherent message boundaries, while Noise requires them for its AEAD authentication.
|
||||
|
||||
### How it works
|
||||
|
||||
The bridge uses `tokio::io::duplex` to create an in-process bidirectional byte channel:
|
||||
|
||||
```text
|
||||
capnp-rpc duplex pipe NoiseTransport
|
||||
┌─────────┐ ┌─────────────────┐ ┌───────────────────┐
|
||||
│ VatNetwork │◄──►│ app_stream │◄──►│ bridge task │◄──► TCP
|
||||
│ (reads/ │ │ (ReadHalf + │ │ (tokio::select!) │
|
||||
│ writes) │ │ WriteHalf) │ │ │
|
||||
└─────────┘ └─────────────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
1. `into_capnp_io()` creates a `tokio::io::duplex(MAX_PLAINTEXT_LEN)` pipe.
|
||||
2. It spawns a background Tokio task that uses `tokio::select!` to shuttle data bidirectionally:
|
||||
- **Noise -> app**: Calls `self.recv_frame()`, writes the decrypted plaintext into the pipe.
|
||||
- **App -> Noise**: Reads bytes from the pipe, calls `self.send_frame()` to encrypt and send them.
|
||||
3. The returned `(ReadHalf, WriteHalf)` are the application ends of the pipe, suitable for passing to `VatNetwork::new()`.
|
||||
|
||||
The bridge task runs until either side of the pipe closes. When `capnp-rpc` drops the pipe halves, the bridge exits cleanly.
|
||||
|
||||
The pipe capacity is set to `MAX_PLAINTEXT_LEN` (65,519 bytes) so that one Noise frame's worth of plaintext can be buffered without blocking.
|
||||
|
||||
## Remote static key extraction
|
||||
|
||||
After a successful handshake, `NoiseTransport::remote_static_public_key()` returns the authenticated remote peer's X25519 public key:
|
||||
|
||||
```rust
|
||||
pub fn remote_static_public_key(&self) -> Option<&[u8]> {
|
||||
self.remote_static.as_deref()
|
||||
}
|
||||
```
|
||||
|
||||
This returns `Some(&[u8])` (32 bytes) in all normal cases. `None` would indicate a snow implementation bug where the XX handshake completed without exchanging static keys.
|
||||
|
||||
Applications use the remote static key to:
|
||||
- Verify the peer's identity against a known-good key fingerprint.
|
||||
- Index the peer in a roster or routing table.
|
||||
- Derive additional key material for application-layer protocols.
|
||||
|
||||
## Post-quantum gap (ADR-006)
|
||||
|
||||
The Noise transport uses classical X25519 for all Diffie-Hellman operations. There is currently no standardised PQ-Noise extension in the `snow` crate. This means:
|
||||
|
||||
- **Handshake metadata** (ephemeral keys, encrypted static keys) could be harvested by a passive attacker and decrypted later with a quantum computer ("harvest now, decrypt later" attack).
|
||||
- **Application data** encrypted by MLS is PQ-protected from the M5 milestone onward via the [Hybrid KEM](hybrid-kem.md) layer.
|
||||
|
||||
The residual risk (metadata exposure via handshake harvest) is accepted for M1 through M5. On the QUIC + TLS 1.3 path, the same gap exists: TLS 1.3 key exchange uses classical ECDHE. Both gaps are tracked in [ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md).
|
||||
|
||||
## Thread safety
|
||||
|
||||
`NoiseTransport` is `Send` but not `Clone` or `Sync`. It should be used from a single Tokio task. To share data across tasks, use channels or other message-passing mechanisms. The `Debug` implementation formats the first four bytes of the remote static key as hex for logging:
|
||||
|
||||
```rust
|
||||
NoiseTransport { remote_static: Some("a1b2c3d4…"), .. }
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
All `NoiseTransport` methods return `Result<_, CoreError>` with these variants:
|
||||
|
||||
| Error | Meaning |
|
||||
|---|---|
|
||||
| `CoreError::HandshakeIncomplete` | Peer closed the connection during the handshake |
|
||||
| `CoreError::Noise(snow::Error)` | Any Noise operation failed (pattern mismatch, bad DH, decryption failure) |
|
||||
| `CoreError::Codec(CodecError)` | TCP I/O failure or frame size violation |
|
||||
| `CoreError::ConnectionClosed` | Peer closed the connection during transport phase |
|
||||
| `CoreError::MessageTooLarge { size }` | Plaintext exceeds `MAX_PLAINTEXT_LEN` (65,519 bytes) |
|
||||
| `CoreError::Capnp(capnp::Error)` | Cap'n Proto serialisation error (envelope methods only) |
|
||||
|
||||
## Further reading
|
||||
|
||||
- [QUIC + TLS 1.3](quic-tls.md) -- The M3+ replacement for Noise\_XX on the client-server path.
|
||||
- [Cap'n Proto Serialisation and RPC](capn-proto.md) -- The serialisation layer that rides on top of the Noise transport.
|
||||
- [Length-Prefixed Framing Codec](../wire-format/framing-codec.md) -- The `LengthPrefixedCodec` used by `NoiseTransport`.
|
||||
- [X25519 Transport Keys](../cryptography/transport-keys.md) -- Key generation and management for Noise static keys.
|
||||
- [ADR-001: Noise\_XX for Transport Auth](../design-rationale/adr-001-noise-xx.md) -- Design rationale for choosing the XX pattern.
|
||||
- [ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md) -- Accepted risk of classical-only key exchange.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Protocol Layers Overview
|
||||
|
||||
quicnprotochat composes five distinct protocol layers into a single security stack. Each layer addresses a specific class of threat and delegates everything else to the layers above or below it. No single layer is sufficient on its own; the composition is what delivers end-to-end confidentiality, mutual authentication, forward secrecy, post-compromise security, and post-quantum resistance.
|
||||
quicnprotochat composes four distinct protocol layers into a single security stack. Each layer addresses a specific class of threat and delegates everything else to the layers above or below it. No single layer is sufficient on its own; the composition is what delivers end-to-end confidentiality, server authentication, forward secrecy, post-compromise security, and post-quantum resistance.
|
||||
|
||||
This page provides a high-level comparison and a suggested reading order. The deep-dive pages that follow contain implementation details drawn directly from the source code.
|
||||
|
||||
@@ -9,7 +9,6 @@ This page provides a high-level comparison and a suggested reading order. The de
|
||||
| Layer | Standard / Spec | Crate(s) | Security Properties |
|
||||
|---|---|---|---|
|
||||
| **QUIC + TLS 1.3** | RFC 9000, RFC 9001 | `quinn 0.11`, `rustls 0.23` | Transport confidentiality, server authentication, 0-RTT resumption |
|
||||
| **Noise\_XX** | [Noise Protocol Framework](https://noiseprotocol.org/noise.html) | `snow 0.9` | Mutual authentication, identity hiding, ChaCha20-Poly1305 session encryption |
|
||||
| **Cap'n Proto** | [capnproto.org specification](https://capnproto.org/encoding.html) | `capnp 0.19`, `capnp-rpc 0.19` | Zero-copy deserialisation, schema-enforced types, canonical serialisation for signing, async RPC |
|
||||
| **MLS** | [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html) | `openmls 0.5` | Group key agreement, forward secrecy, post-compromise security (PCS) |
|
||||
| **Hybrid KEM** | [draft-ietf-tls-hybrid-design](https://datatracker.ietf.org/doc/draft-ietf-tls-hybrid-design/) | `ml-kem 0.2`, `x25519-dalek 2` | Post-quantum resistance via ML-KEM-768 combined with X25519 |
|
||||
@@ -33,33 +32,26 @@ Application plaintext
|
||||
|
|
||||
v
|
||||
+-----------+
|
||||
| Noise_XX | Per-session ChaCha20-Poly1305 encryption (M1 TCP path)
|
||||
+-----------+ -- OR --
|
||||
+-----------+
|
||||
| QUIC+TLS | QUIC transport encryption (M3+ QUIC path)
|
||||
| QUIC+TLS | QUIC transport encryption (TLS 1.3)
|
||||
+-----------+
|
||||
|
|
||||
v
|
||||
Network
|
||||
```
|
||||
|
||||
In the current M3 architecture, the QUIC + TLS 1.3 layer has replaced the Noise\_XX layer for client-to-server transport. The Noise\_XX implementation remains in the codebase and is used for direct peer-to-peer connections in M1-era integration tests. Both paths carry Cap'n Proto messages as their inner payload.
|
||||
|
||||
The Hybrid KEM layer operates orthogonally: it wraps MLS payloads in an outer post-quantum encryption envelope before they enter the transport layer. It is implemented and tested but not yet integrated into the MLS ciphersuite (planned for the M5 milestone).
|
||||
|
||||
## Suggested reading order
|
||||
|
||||
The pages in this section are ordered to build understanding incrementally:
|
||||
|
||||
1. **[QUIC + TLS 1.3](quic-tls.md)** -- Start here. This is the outermost transport layer that every client-server connection uses today. Understanding QUIC stream multiplexing and the TLS 1.3 handshake is prerequisite to understanding how Cap'n Proto RPC rides on top.
|
||||
1. **[QUIC + TLS 1.3](quic-tls.md)** -- Start here. This is the transport layer that every client-server connection uses. Understanding QUIC stream multiplexing and the TLS 1.3 handshake is prerequisite to understanding how Cap'n Proto RPC rides on top.
|
||||
|
||||
2. **[MLS (RFC 9420)](mls.md)** -- The core cryptographic innovation. MLS provides the group key agreement that makes quicnprotochat an E2E encrypted group messenger rather than just a transport-encrypted relay. This is the longest and most detailed page.
|
||||
|
||||
3. **[Cap'n Proto Serialisation and RPC](capn-proto.md)** -- The serialisation and RPC layer that bridges MLS application data with the transport. Understanding the Envelope schema, the ParsedEnvelope owned type, and the NodeService RPC interface is essential for reading the server and client source code.
|
||||
|
||||
4. **[Noise\_XX Handshake](noise-xx.md)** -- The M1-era transport encryption layer. Even though QUIC has replaced it for client-server communication, the Noise\_XX code remains in the codebase and the design decisions it embodies (mutual authentication, identity hiding) inform the overall architecture.
|
||||
|
||||
5. **[Hybrid KEM: X25519 + ML-KEM-768](hybrid-kem.md)** -- The post-quantum encryption layer. Read this last because it builds on concepts from all other layers: key encapsulation (from MLS), wire format conventions (from Cap'n Proto), and AEAD encryption (from Noise).
|
||||
4. **[Hybrid KEM: X25519 + ML-KEM-768](hybrid-kem.md)** -- The post-quantum encryption layer. Read this last because it builds on concepts from all other layers: key encapsulation (from MLS), wire format conventions (from Cap'n Proto), and AEAD encryption.
|
||||
|
||||
## Cross-cutting concerns
|
||||
|
||||
@@ -69,7 +61,7 @@ Several topics span multiple layers and have their own dedicated pages elsewhere
|
||||
- **Post-compromise security**: Provided by MLS Update proposals. See [Post-Compromise Security](../cryptography/post-compromise-security.md).
|
||||
- **Post-quantum readiness**: Currently provided by the standalone Hybrid KEM module; integration into MLS is planned for M5. See [Post-Quantum Readiness](../cryptography/post-quantum-readiness.md).
|
||||
- **Key lifecycle and zeroization**: Private key material is zeroized after use across all layers. See [Key Lifecycle and Zeroization](../cryptography/key-lifecycle.md).
|
||||
- **Wire format details**: The length-prefixed framing codec and Cap'n Proto schema definitions are documented in the [Wire Format Reference](../wire-format/overview.md) section.
|
||||
- **Wire format details**: The Cap'n Proto schema definitions are documented in the [Wire Format Reference](../wire-format/overview.md) section.
|
||||
- **Design rationale**: The ADR pages explain *why* each layer was chosen. See [Design Decisions Overview](../design-rationale/overview.md).
|
||||
|
||||
## Crate mapping
|
||||
@@ -79,7 +71,6 @@ Each protocol layer maps to one or more workspace crates:
|
||||
| Layer | Primary Crate | Source File(s) |
|
||||
|---|---|---|
|
||||
| QUIC + TLS 1.3 | `quicnprotochat-server`, `quicnprotochat-client` | `main.rs` (server and client entry points) |
|
||||
| Noise\_XX | `quicnprotochat-core` | `src/noise.rs`, `src/codec.rs` |
|
||||
| Cap'n Proto | `quicnprotochat-proto` | `src/lib.rs`, `build.rs`, `schemas/*.capnp` |
|
||||
| MLS | `quicnprotochat-core` | `src/group.rs`, `src/keystore.rs` |
|
||||
| Hybrid KEM | `quicnprotochat-core` | `src/hybrid_kem.rs` |
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
# QUIC + TLS 1.3
|
||||
|
||||
quicnprotochat uses QUIC (RFC 9000) with mandatory TLS 1.3 (RFC 9001) as its client-to-server transport layer. This page explains why QUIC was chosen over raw TCP, how the `quinn` and `rustls` crates are integrated, and what security properties the transport provides.
|
||||
quicnprotochat uses QUIC (RFC 9000) with mandatory TLS 1.3 (RFC 9001) as its transport layer. This page explains how the `quinn` and `rustls` crates are integrated and what security properties the transport provides.
|
||||
|
||||
## Why QUIC over raw TCP
|
||||
## Why QUIC
|
||||
|
||||
The M1 milestone used raw TCP sockets with a Noise\_XX handshake for transport encryption (see [Noise\_XX Handshake](noise-xx.md)). Starting from M3, the project migrated to QUIC for several reasons:
|
||||
QUIC provides several advantages over traditional TCP-based transports:
|
||||
|
||||
| Property | Raw TCP + Noise | QUIC + TLS 1.3 |
|
||||
|---|---|---|
|
||||
| **Multiplexed streams** | Single stream; application must multiplex manually | Native bidirectional streams; each RPC call gets its own stream |
|
||||
| **0-RTT resumption** | Not available; full handshake every time | Built-in; returning clients can send data in the first flight |
|
||||
| **Head-of-line blocking** | A lost TCP segment blocks all subsequent data | Only the affected stream is blocked; other streams proceed |
|
||||
| **NAT traversal** | TCP requires keep-alives; NAT rebinding breaks connections | UDP-based; connection migration survives NAT rebinding |
|
||||
| **TLS integration** | Separate Noise handshake layered on top of TCP | TLS 1.3 is integral to the QUIC handshake; no extra round-trips |
|
||||
| **Ecosystem support** | Custom framing codec required | `capnp-rpc` can use QUIC bidirectional streams directly via `tokio-util` compat layer |
|
||||
|
||||
The migration also simplified the codebase: the custom `LengthPrefixedCodec` framing layer and the `into_capnp_io()` bridge (documented in [Noise\_XX Handshake](noise-xx.md)) are no longer needed on the QUIC path because `capnp-rpc` reads and writes directly on the QUIC stream.
|
||||
- **Multiplexed streams**: Native bidirectional streams; each RPC call gets its own stream without head-of-line blocking.
|
||||
- **0-RTT resumption**: Returning clients can send data in the first flight, reducing connection setup latency.
|
||||
- **Integrated encryption**: TLS 1.3 is integral to the QUIC handshake; no extra round-trips for transport security.
|
||||
- **NAT traversal**: UDP-based; connection migration survives NAT rebinding.
|
||||
- **Ecosystem support**: `capnp-rpc` can use QUIC bidirectional streams directly via the `tokio-util` compat layer.
|
||||
|
||||
## Crate integration
|
||||
|
||||
@@ -134,21 +129,6 @@ The QUIC + TLS 1.3 layer provides:
|
||||
- **Client authentication**: Handled by MLS identity credentials at the application layer. See [MLS (RFC 9420)](mls.md).
|
||||
- **End-to-end encryption**: TLS terminates at the server. The server can read the Cap'n Proto RPC framing and message routing metadata. Payload confidentiality is provided by MLS. See [MLS (RFC 9420)](mls.md).
|
||||
- **Post-quantum resistance**: TLS 1.3 key exchange uses classical ECDHE. Post-quantum protection of application data is provided by the [Hybrid KEM](hybrid-kem.md) layer (M5 milestone).
|
||||
- **Mutual peer authentication**: For peer-to-peer scenarios, the M1-era [Noise\_XX](noise-xx.md) transport provides mutual authentication with identity hiding.
|
||||
|
||||
## Comparison with Noise\_XX (M1 approach)
|
||||
|
||||
| Aspect | Noise\_XX (M1) | QUIC + TLS 1.3 (M3+) |
|
||||
|---|---|---|
|
||||
| **Transport** | Raw TCP | UDP (QUIC) |
|
||||
| **Handshake** | 3-message Noise XX pattern | TLS 1.3 (1-RTT or 0-RTT) |
|
||||
| **Mutual auth** | Both peers authenticate static X25519 keys | Server-only at TLS layer; mutual auth via MLS |
|
||||
| **Identity hiding** | Initiator's identity hidden until message 3 | No identity hiding at TLS layer |
|
||||
| **Stream multiplexing** | None (single stream) | Native QUIC streams |
|
||||
| **RPC bridge** | `into_capnp_io()` with `tokio::io::duplex` | Direct `compat()` wrapper on QUIC stream |
|
||||
| **Codebase location** | `quicnprotochat-core/src/noise.rs` | `quicnprotochat-server/src/main.rs`, client `lib.rs` |
|
||||
|
||||
The Noise\_XX path remains useful for direct peer-to-peer connections (without a central server) and as a fallback transport. Both paths carry identical Cap'n Proto message payloads, so the application layer is transport-agnostic.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
@@ -171,7 +151,5 @@ The Noise\_XX path remains useful for direct peer-to-peer connections (without a
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Noise\_XX Handshake](noise-xx.md) -- The M1-era transport layer that QUIC replaced.
|
||||
- [Cap'n Proto Serialisation and RPC](capn-proto.md) -- The RPC layer that runs on top of QUIC streams.
|
||||
- [Service Architecture](../architecture/service-architecture.md) -- How the server's `NodeServiceImpl` binds to the QUIC endpoint.
|
||||
- [ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md) -- Discusses the post-quantum gap in both the Noise and TLS transport layers.
|
||||
|
||||
@@ -402,5 +402,4 @@ considering the current state of the codebase and the [milestone plan](milestone
|
||||
- [Auth, Devices, and Tokens](authz-plan.md) -- OPAQUE integration point
|
||||
- [1:1 Channel Design](dm-channels.md) -- double-ratchet optimisation context
|
||||
- [Hybrid KEM](../protocol-layers/hybrid-kem.md) -- existing PQ design
|
||||
- [ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md) -- accepted PQ risk
|
||||
- [References](../appendix/references.md) -- standards and crate documentation
|
||||
|
||||
@@ -31,15 +31,15 @@ typed Cap'n Proto frames.
|
||||
- `schemas/envelope.capnp`: `Envelope` struct with `MsgType` enum (Ping/Pong at this stage)
|
||||
- `quicnprotochat-proto`: `build.rs` invoking `capnpc`, generated type re-exports,
|
||||
canonical serialisation helpers
|
||||
- `quicnprotochat-core`: static X25519 keypair generation, Noise\_XX initiator and
|
||||
responder, length-prefixed Cap'n Proto frame codec (Tokio `Encoder`/`Decoder`)
|
||||
- `quicnprotochat-core`: Ed25519 identity keypair generation,
|
||||
Cap'n Proto frame codec (Tokio `Encoder`/`Decoder`)
|
||||
- `quicnprotochat-server`: QUIC listener with TLS 1.3 (quinn/rustls), Ping to Pong
|
||||
handler, one tokio task per connection
|
||||
- `quicnprotochat-client`: connects over QUIC, sends Ping, receives Pong, exits 0
|
||||
- Integration test: server and client in same test binary using `tokio::spawn`
|
||||
- `docker-compose.yml` running the server
|
||||
|
||||
**Tests:** codec (7 unit tests), keypair (3 unit tests), Noise transport integration.
|
||||
**Tests:** codec (7 unit tests), keypair (3 unit tests), QUIC transport integration.
|
||||
|
||||
**Branch:** `feat/m1-noise-transport`
|
||||
|
||||
@@ -176,9 +176,7 @@ providing post-quantum confidentiality for all group key material.
|
||||
- Follows the combiner approach from `draft-ietf-tls-hybrid-design`
|
||||
|
||||
The `ml-kem` crate is already vendored in the workspace. See
|
||||
[Hybrid KEM](../protocol-layers/hybrid-kem.md) for the detailed design and
|
||||
[ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md) for
|
||||
the accepted residual risk in the transport layer.
|
||||
[Hybrid KEM](../protocol-layers/hybrid-kem.md) for the detailed design.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
**Schema file:** `schemas/envelope.capnp`
|
||||
**File ID:** `@0xe4a7f2c8b1d63509`
|
||||
|
||||
The Envelope is the legacy top-level wire message used in M1 for all quicnprotochat traffic over the Noise channel. Every frame exchanged between peers was serialised as an Envelope, with the Delivery Service routing by `(groupId, msgType)` without inspecting the payload.
|
||||
The Envelope is the legacy top-level wire message used in M1 for all quicnprotochat traffic. Every frame exchanged between peers was serialised as an Envelope, with the Delivery Service routing by `(groupId, msgType)` without inspecting the payload.
|
||||
|
||||
> **Note:** The Envelope is the M1-era framing format. The current M3+ architecture uses Cap'n Proto RPC directly via the [NodeService](node-service-schema.md) interface. The Envelope schema remains in the codebase for backward compatibility and for use in integration tests that exercise the Noise transport path.
|
||||
> **Note:** The Envelope is the M1-era framing format. The current M3+ architecture uses Cap'n Proto RPC directly via the [NodeService](node-service-schema.md) interface. The Envelope schema remains in the codebase for backward compatibility and for use in integration tests.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,13 +14,12 @@ The Envelope is the legacy top-level wire message used in M1 for all quicnprotoc
|
||||
```capnp
|
||||
# envelope.capnp -- top-level wire message for all quicnprotochat traffic.
|
||||
#
|
||||
# Every frame exchanged over the Noise channel is serialised as an Envelope.
|
||||
# Every frame 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)
|
||||
# payload : opaque -- MLS blob or control data
|
||||
# timestampMs : UInt64 -- unix epoch milliseconds; sufficient until year 292M
|
||||
#
|
||||
# ID generated with: capnp id
|
||||
@@ -82,7 +81,7 @@ A 32-byte `Data` field containing the SHA-256 digest of the sender's Ed25519 ide
|
||||
|
||||
### `payload @3 :Data`
|
||||
|
||||
An opaque byte string whose interpretation depends on `msgType`. The payload is bounded by the Noise transport maximum message size of 65,535 bytes (see [Framing Codec](framing-codec.md)).
|
||||
An opaque byte string whose interpretation depends on `msgType`.
|
||||
|
||||
### `timestampMs @4 :UInt64`
|
||||
|
||||
@@ -110,7 +109,7 @@ The `MsgType` enum defines nine message types. Each variant determines how the `
|
||||
|
||||
### Control messages (0-1)
|
||||
|
||||
`ping` and `pong` are keepalive probes with empty payloads. They serve as health checks over long-lived Noise connections.
|
||||
`ping` and `pong` are keepalive probes with empty payloads. They serve as health checks over long-lived connections.
|
||||
|
||||
### Authentication messages (2-4)
|
||||
|
||||
@@ -128,7 +127,7 @@ The `MsgType` enum defines nine message types. Each variant determines how the `
|
||||
|
||||
## Relationship to NodeService
|
||||
|
||||
The Envelope schema was the original M1 wire format, where all communication was multiplexed over a single Noise-encrypted TCP stream. With the transition to QUIC + TLS 1.3 and Cap'n Proto RPC in M3, the Envelope's role has been superseded by the [NodeService interface](node-service-schema.md), which provides typed RPC methods for each operation.
|
||||
The Envelope schema was the original M1 wire format. With the transition to QUIC + TLS 1.3 and Cap'n Proto RPC in M3, the Envelope's role has been superseded by the [NodeService interface](node-service-schema.md), which provides typed RPC methods for each operation.
|
||||
|
||||
The key differences:
|
||||
|
||||
@@ -136,8 +135,8 @@ The key differences:
|
||||
|---|---|---|
|
||||
| Dispatch | Manual, based on `msgType` enum | Automatic, Cap'n Proto RPC method dispatch |
|
||||
| Type safety | Payload is opaque `Data` | Each method has typed parameters and return values |
|
||||
| Transport | Noise\_XX over TCP | QUIC + TLS 1.3 |
|
||||
| Auth | Implicit (Noise handshake authenticates peers) | Explicit `Auth` struct per method call |
|
||||
| Transport | QUIC + TLS 1.3 | QUIC + TLS 1.3 |
|
||||
| Auth | None | Explicit `Auth` struct per method call |
|
||||
|
||||
---
|
||||
|
||||
@@ -147,5 +146,4 @@ The key differences:
|
||||
- [NodeService Schema](node-service-schema.md) -- the current RPC interface that replaced Envelope-based dispatch
|
||||
- [Auth Schema](auth-schema.md) -- standalone Authentication Service interface
|
||||
- [Delivery Schema](delivery-schema.md) -- standalone Delivery Service interface
|
||||
- [Framing Codec](framing-codec.md) -- length-prefixed framing that wraps serialised Envelopes
|
||||
- [ADR-002: Cap'n Proto over MessagePack](../design-rationale/adr-002-capnproto.md) -- why Cap'n Proto was chosen for the wire format
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
# Length-Prefixed Framing Codec
|
||||
|
||||
**Source file:** `crates/quicnprotochat-core/src/codec.rs`
|
||||
|
||||
The `LengthPrefixedCodec` is a stateless Tokio codec that frames byte payloads with a 4-byte little-endian length prefix. It is the bridge between Cap'n Proto serialisation (which produces a byte buffer of variable length) and the Noise transport (which needs discrete message boundaries over a TCP byte stream).
|
||||
|
||||
---
|
||||
|
||||
## Wire format
|
||||
|
||||
```text
|
||||
+----------------------------+--------------------------------------+
|
||||
| length (4 bytes, LE u32) | payload (length bytes) |
|
||||
+----------------------------+--------------------------------------+
|
||||
```
|
||||
|
||||
Each frame consists of:
|
||||
|
||||
1. A **4-byte length field** encoded as a little-endian unsigned 32-bit integer (`u32`). This gives a theoretical maximum payload size of 4,294,967,295 bytes, but the actual limit is much lower (see below).
|
||||
2. A **payload** of exactly `length` bytes. The codec treats the payload as opaque -- it does not inspect or interpret the bytes.
|
||||
|
||||
### Byte order: little-endian
|
||||
|
||||
The length prefix uses **little-endian** byte order. This was a deliberate choice for consistency with Cap'n Proto's segment table encoding, which also uses little-endian 32-bit integers. Benefits of this choice:
|
||||
|
||||
- **No endianness confusion.** A developer inspecting a raw byte dump sees uniform little-endian encoding throughout the entire frame (length header + Cap'n Proto header + Cap'n Proto data).
|
||||
- **Native performance on common architectures.** x86-64 and AArch64 (in its default little-endian mode) can read the length field without byte-swapping.
|
||||
- **Alignment with Cap'n Proto conventions.** Cap'n Proto defines its canonical byte order as little-endian (segment count and segment sizes are LE u32).
|
||||
|
||||
### Example encoding
|
||||
|
||||
For the ASCII payload `"le-check"` (8 bytes), the encoded frame is:
|
||||
|
||||
```text
|
||||
Offset Hex Meaning
|
||||
------ ------------------ -------
|
||||
0x00 08 00 00 00 Length = 8 (little-endian)
|
||||
0x04 6C 65 2D 63 68 65 Payload: "le-che"
|
||||
0x0A 63 6B Payload: "ck"
|
||||
```
|
||||
|
||||
Total frame size: 4 (header) + 8 (payload) = 12 bytes.
|
||||
|
||||
---
|
||||
|
||||
## Frame size limit
|
||||
|
||||
```rust
|
||||
/// Maximum Noise protocol message size in bytes (per RFC / Noise spec S3).
|
||||
pub const NOISE_MAX_MSG: usize = 65_535;
|
||||
```
|
||||
|
||||
The maximum payload size is **65,535 bytes** (64 KiB - 1), matching the Noise protocol specification's maximum message size. This constant is defined as `NOISE_MAX_MSG` in the codec module.
|
||||
|
||||
Any frame with a payload exceeding this limit is rejected as a protocol violation:
|
||||
|
||||
- **On encode:** `Encoder::encode()` returns `CodecError::FrameTooLarge` before writing any bytes to the buffer.
|
||||
- **On decode:** `Decoder::decode()` returns `CodecError::FrameTooLarge` upon reading a length field that exceeds the limit, without attempting to read the payload bytes.
|
||||
|
||||
In both cases, the error is **unrecoverable**. The connection should be closed rather than retried, because an oversized frame indicates either a bug or a malicious peer.
|
||||
|
||||
### Relationship to Noise plaintext limit
|
||||
|
||||
The `NOISE_MAX_MSG` constant (65,535 bytes) represents the maximum Noise *message* size, which includes the Poly1305 authentication tag (16 bytes). The maximum *plaintext* per Noise transport frame is therefore:
|
||||
|
||||
```rust
|
||||
/// Maximum plaintext bytes per Noise transport frame.
|
||||
pub const MAX_PLAINTEXT_LEN: usize = 65_519; // 65,535 - 16
|
||||
```
|
||||
|
||||
This constant is defined in `crates/quicnprotochat-core/src/error.rs`. The codec operates at the ciphertext level (framing Noise messages, not plaintext), so it uses `NOISE_MAX_MSG` as its limit.
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
The codec implements Tokio's `Encoder<Bytes>` and `Decoder` traits, making it compatible with `tokio_util::codec::Framed` for use with any `AsyncRead + AsyncWrite` stream.
|
||||
|
||||
### Struct
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LengthPrefixedCodec;
|
||||
```
|
||||
|
||||
The codec is **stateless** -- it holds no internal buffering state. This means it is `Clone`, `Copy`, and `Default`, and multiple codec instances are interchangeable.
|
||||
|
||||
### Encoder
|
||||
|
||||
```rust
|
||||
impl Encoder<Bytes> for LengthPrefixedCodec {
|
||||
type Error = CodecError;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
dst.reserve(4 + len);
|
||||
dst.put_u32_le(len as u32);
|
||||
dst.extend_from_slice(&item);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Check payload size against `NOISE_MAX_MSG`. Reject if oversized.
|
||||
2. Reserve exactly `4 + len` bytes in the output buffer to avoid reallocation.
|
||||
3. Write the 4-byte little-endian length prefix.
|
||||
4. Copy the payload bytes.
|
||||
|
||||
### Decoder
|
||||
|
||||
```rust
|
||||
impl Decoder for LengthPrefixedCodec {
|
||||
type Item = BytesMut;
|
||||
type Error = CodecError;
|
||||
|
||||
fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
|
||||
if src.len() < 4 {
|
||||
src.reserve(4_usize.saturating_sub(src.len()));
|
||||
return Ok(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 {
|
||||
src.reserve(total - src.len());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
src.advance(4);
|
||||
Ok(Some(src.split_to(frame_len)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **Check for header completeness.** If fewer than 4 bytes are available, reserve the remaining bytes and return `Ok(None)` (the standard Tokio Decoder contract for "need more data").
|
||||
2. **Peek at the length field** without advancing the cursor. This avoids mutating buffer state when the full frame is not yet available.
|
||||
3. **Validate the length.** If it exceeds `NOISE_MAX_MSG`, return an error immediately.
|
||||
4. **Check for payload completeness.** If fewer than `4 + frame_len` bytes are available, reserve the difference and return `Ok(None)`.
|
||||
5. **Consume the frame.** Advance past the 4-byte header, then split the payload from the front of the buffer.
|
||||
|
||||
The `reserve()` calls in steps 1 and 4 are a performance optimization: they hint to Tokio how many additional bytes the decoder needs, avoiding O(n) polling behavior where the decoder is called once per incoming byte.
|
||||
|
||||
---
|
||||
|
||||
## Error handling
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CodecError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("frame length {len} exceeds maximum {max} bytes")]
|
||||
FrameTooLarge { len: usize, max: usize },
|
||||
}
|
||||
```
|
||||
|
||||
The codec produces two error variants:
|
||||
|
||||
| Variant | Cause | Recovery |
|
||||
|---|---|---|
|
||||
| `Io` | The underlying TCP stream returned an I/O error. Auto-converted from `std::io::Error` via the `From` impl required by `tokio-util`. | Depends on the I/O error. Typically the connection is broken and should be dropped. |
|
||||
| `FrameTooLarge` | A frame's length field exceeds `NOISE_MAX_MSG` (65,535 bytes). | **Unrecoverable.** The connection should be closed. An oversized frame indicates a protocol violation -- either a bug or a malicious peer. |
|
||||
|
||||
---
|
||||
|
||||
## Transport context
|
||||
|
||||
The `LengthPrefixedCodec` is used in the **Noise transport path** (M1 stack), where Cap'n Proto messages and Noise handshake messages are sent over a raw TCP stream that has no built-in message boundaries.
|
||||
|
||||
In the **QUIC transport path** (M3+ stack), the codec is **not used**. QUIC provides native stream framing through its stream abstraction, and the `capnp-rpc` crate handles message delimitation internally. The QUIC path also does not need the 65,535-byte frame limit because QUIC flow control operates at a different level.
|
||||
|
||||
```text
|
||||
Noise path: App -> Cap'n Proto -> LengthPrefixedCodec -> Noise encrypt -> TCP
|
||||
QUIC path: App -> Cap'n Proto RPC -> capnp-rpc stream adapter -> QUIC stream -> UDP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test coverage
|
||||
|
||||
The codec module includes comprehensive tests that verify:
|
||||
|
||||
| Test | What it validates |
|
||||
|---|---|
|
||||
| `round_trip_empty_payload` | Empty payloads encode and decode correctly (0-length frame) |
|
||||
| `round_trip_small_payload` | Small payloads survive a round trip without corruption |
|
||||
| `round_trip_max_size_payload` | A payload of exactly `NOISE_MAX_MSG` bytes (the maximum) encodes and decodes correctly |
|
||||
| `oversized_encode_returns_error` | Encoding a payload of `NOISE_MAX_MSG + 1` bytes returns `FrameTooLarge` |
|
||||
| `oversized_length_field_decode_returns_error` | Decoding a frame with a length field exceeding `NOISE_MAX_MSG` returns `FrameTooLarge` |
|
||||
| `partial_payload_returns_none` | A frame with a valid header but incomplete payload returns `None` (need more data) |
|
||||
| `partial_header_returns_none` | A buffer with fewer than 4 bytes returns `None` (need more data) |
|
||||
| `length_field_is_little_endian` | The encoded length of `"le-check"` (8 bytes) produces `[0x08, 0x00, 0x00, 0x00]` |
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Wire Format Overview](overview.md) -- where the codec fits in the serialisation pipeline
|
||||
- [Envelope Schema](envelope-schema.md) -- the Cap'n Proto messages that the codec frames (M1 path)
|
||||
- [NodeService Schema](node-service-schema.md) -- the RPC messages carried over QUIC (M3+ path, does not use this codec)
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](../design-rationale/adr-003-rpc-inside-noise.md) -- why the codec sits between Cap'n Proto and Noise
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how all the layers stack
|
||||
@@ -252,7 +252,6 @@ Cap'n Proto supports forward-compatible schema evolution through several mechani
|
||||
- [Auth Schema](auth-schema.md) -- standalone Authentication Service interface (subset of NodeService)
|
||||
- [Delivery Schema](delivery-schema.md) -- standalone Delivery Service interface (subset of NodeService)
|
||||
- [Envelope Schema](envelope-schema.md) -- legacy M1 framing that NodeService replaced
|
||||
- [Framing Codec](framing-codec.md) -- length-prefixed framing used in the Noise transport path
|
||||
- [Architecture Overview](../architecture/overview.md) -- system-level view showing NodeService in context
|
||||
- [ADR-005: Single-Use KeyPackages](../design-rationale/adr-005-single-use-keypackages.md) -- why fetchKeyPackage is destructive
|
||||
- [ADR-004: MLS-Unaware DS](../design-rationale/adr-004-mls-unaware-ds.md) -- why payloads are opaque
|
||||
|
||||
@@ -6,21 +6,21 @@ This section documents the serialisation pipeline that transforms application-le
|
||||
|
||||
## Serialisation pipeline
|
||||
|
||||
Data flows through four stages on the send path. The receive path reverses the order.
|
||||
Data flows through three stages on the send path. The receive path reverses the order.
|
||||
|
||||
```text
|
||||
Stage 1 Stage 2 Stage 3 Stage 4
|
||||
-------- -------- -------- --------
|
||||
Application Cap'n Proto Length-prefixed Transport
|
||||
data serialisation framing encryption
|
||||
Stage 1 Stage 2 Stage 3
|
||||
-------- -------- --------
|
||||
Application Cap'n Proto Transport
|
||||
data serialisation encryption
|
||||
|
||||
ParsedEnvelope capnp::serialize [u32 LE len][payload] Noise ChaCha20-Poly1305
|
||||
or RPC call (zero-copy bytes) or QUIC/TLS 1.3
|
||||
RPC call capnp::serialize QUIC/TLS 1.3
|
||||
(zero-copy bytes)
|
||||
|
||||
| | | |
|
||||
v v v v
|
||||
Rust structs Canonical byte Framed byte stream Encrypted
|
||||
& method representation ready for transport ciphertext
|
||||
| | |
|
||||
v v v
|
||||
Rust structs Canonical byte Encrypted
|
||||
& method representation ciphertext
|
||||
invocations (no deserialization on the wire
|
||||
needed on receive)
|
||||
```
|
||||
@@ -45,42 +45,15 @@ The wire representation consists of:
|
||||
|
||||
Cap'n Proto's canonical form is deterministic for a given message, which makes it suitable for signing: two implementations that build the same logical message will produce identical bytes.
|
||||
|
||||
### Stage 3: Length-prefixed framing
|
||||
### Stage 3: Transport encryption
|
||||
|
||||
Before the serialised bytes enter the transport, they are wrapped in a length-prefixed frame:
|
||||
The serialised bytes are encrypted by the QUIC/TLS 1.3 transport layer. The QUIC transport uses native QUIC stream framing, which provides its own length delimitation. Cap'n Proto RPC over QUIC relies on the `capnp-rpc` crate's built-in stream adapter.
|
||||
|
||||
```text
|
||||
+----------------------------+--------------------------------------+
|
||||
| length (4 bytes, LE u32) | payload (length bytes) |
|
||||
+----------------------------+--------------------------------------+
|
||||
```
|
||||
| Transport | Encryption | Authentication |
|
||||
|---|---|---|
|
||||
| **QUIC + TLS 1.3** | AES-128-GCM or ChaCha20-Poly1305 (negotiated by TLS) | Server cert (rustls/quinn) |
|
||||
|
||||
The length prefix is encoded as a **little-endian** 32-bit unsigned integer. Little-endian was chosen for consistency with Cap'n Proto's own segment table encoding, which also uses little-endian integers. This avoids byte-order confusion when the same buffer contains both framing headers and Cap'n Proto data.
|
||||
|
||||
The maximum payload size is **65,535 bytes**, matching the Noise protocol's maximum message size. Frames exceeding this limit are rejected as protocol violations. See [Framing Codec](framing-codec.md) for the full `LengthPrefixedCodec` implementation.
|
||||
|
||||
> **Note:** This framing stage applies only to the Noise transport path. The QUIC transport uses native QUIC stream framing, which provides its own length delimitation. Cap'n Proto RPC over QUIC relies on the `capnp-rpc` crate's built-in stream adapter rather than `LengthPrefixedCodec`.
|
||||
|
||||
### Stage 4: Transport encryption
|
||||
|
||||
The framed byte stream is encrypted by the transport layer:
|
||||
|
||||
| Transport | Encryption | Authentication | When Used |
|
||||
|---|---|---|---|
|
||||
| **Noise\_XX over TCP** | ChaCha20-Poly1305 (per-session key from XX handshake) | Mutual, via static X25519 keys | M1 stack, peer-to-peer, integration tests |
|
||||
| **QUIC + TLS 1.3** | AES-128-GCM or ChaCha20-Poly1305 (negotiated by TLS) | Server cert (rustls/quinn) | M3+ primary transport |
|
||||
|
||||
In both cases, the transport layer treats the payload as opaque bytes. It does not inspect or interpret the Cap'n Proto content. This clean separation means the serialisation format can evolve independently of the transport.
|
||||
|
||||
---
|
||||
|
||||
## Little-endian framing rationale
|
||||
|
||||
Cap'n Proto uses little-endian encoding for its segment table (the header that precedes each serialised message). The `LengthPrefixedCodec` uses the same byte order for its 4-byte length field. This consistency means:
|
||||
|
||||
1. A developer inspecting a raw byte dump sees uniform endianness throughout.
|
||||
2. On little-endian architectures (x86-64, AArch64 in LE mode), both the framing header and the Cap'n Proto header can be read without byte-swapping.
|
||||
3. There is no risk of accidentally mixing big-endian and little-endian headers in the same stream.
|
||||
The transport layer treats the payload as opaque bytes. It does not inspect or interpret the Cap'n Proto content. This clean separation means the serialisation format can evolve independently of the transport.
|
||||
|
||||
---
|
||||
|
||||
@@ -95,8 +68,6 @@ The Cap'n Proto schemas that define the wire-level messages are documented on de
|
||||
| `schemas/delivery.capnp` | [Delivery Schema](delivery-schema.md) | Delivery Service RPC interface |
|
||||
| `schemas/node.capnp` | [NodeService Schema](node-service-schema.md) | Unified node RPC (current) |
|
||||
|
||||
The length-prefixed framing codec that wraps serialised messages is documented at [Framing Codec](framing-codec.md).
|
||||
|
||||
---
|
||||
|
||||
## Further reading
|
||||
@@ -104,4 +75,3 @@ The length-prefixed framing codec that wraps serialised messages is documented a
|
||||
- [Architecture Overview](../architecture/overview.md) -- system-level view of how services compose
|
||||
- [Protocol Layers Overview](../protocol-layers/overview.md) -- how transport, framing, and E2E encryption stack
|
||||
- [ADR-002: Cap'n Proto over MessagePack](../design-rationale/adr-002-capnproto.md) -- why Cap'n Proto was chosen
|
||||
- [ADR-003: RPC Inside the Noise Tunnel](../design-rationale/adr-003-rpc-inside-noise.md) -- why RPC runs inside the encrypted channel
|
||||
|
||||
Reference in New Issue
Block a user