Rename project to quicnprotochat

This commit is contained in:
2026-02-21 23:37:40 +01:00
parent c9d295c510
commit 3bf3ab23e2
32 changed files with 3370 additions and 1132 deletions

View File

@@ -0,0 +1,41 @@
[package]
name = "quicnprotochat-core"
version = "0.1.0"
edition = "2021"
description = "Crypto primitives, TLS/QUIC transport, MLS state machine, and Cap'n Proto frame codec for quicnprotochat."
license = "MIT"
[dependencies]
# Crypto — classical
x25519-dalek = { workspace = true }
ed25519-dalek = { workspace = true }
snow = { workspace = true }
sha2 = { workspace = true }
hkdf = { workspace = true }
zeroize = { workspace = true }
rand = { workspace = true }
# Crypto — MLS (M2); ml-kem added in M5
openmls = { workspace = true }
openmls_rust_crypto = { workspace = true }
openmls_traits = { workspace = true }
tls_codec = { workspace = true }
serde = { workspace = true }
bincode = { workspace = true }
serde_json = { workspace = true }
# Serialisation
capnp = { workspace = true }
quicnprotochat-proto = { path = "../quicnprotochat-proto" }
# Async runtime + codec
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
bytes = { version = "1" }
# Error handling
thiserror = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

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

View File

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

View File

@@ -0,0 +1,441 @@
//! MLS group state machine.
//!
//! # Design
//!
//! [`GroupMember`] wraps an openmls [`MlsGroup`] plus the per-client
//! [`StoreCrypto`] backend. The backend is **persistent** — it holds the
//! in-memory key store that maps init-key references to HPKE private keys.
//! openmls's `new_from_welcome` reads those private keys from the key store to
//! decrypt the Welcome, so the same backend instance must be used from
//! `generate_key_package` through `join_group`.
//!
//! # Wire format
//!
//! All MLS messages are serialised/deserialised using TLS presentation language
//! encoding (`tls_codec`). The resulting byte vectors are what the transport
//! layer (and the Delivery Service) sees.
//!
//! # MLS ciphersuite
//!
//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` — same as M2.
//!
//! # Ratchet tree
//!
//! `use_ratchet_tree_extension = true` so that the ratchet tree is embedded
//! in Welcome messages. `new_from_welcome` is called with `ratchet_tree = None`;
//! openmls extracts the tree from the Welcome's `GroupInfo` extension.
use std::sync::Arc;
use openmls::prelude::{
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, GroupId, KeyPackage,
KeyPackageIn, MlsGroup, MlsGroupConfig, MlsMessageInBody, MlsMessageOut,
ProcessedMessageContent, ProtocolMessage, ProtocolVersion, TlsDeserializeTrait,
TlsSerializeTrait,
};
use openmls_traits::OpenMlsCryptoProvider;
use crate::{
error::CoreError,
identity::IdentityKeypair,
keystore::{DiskKeyStore, StoreCrypto},
};
// ── Constants ─────────────────────────────────────────────────────────────────
const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
// ── GroupMember ───────────────────────────────────────────────────────────────
/// Per-client MLS state: identity keypair, crypto backend, and optional group.
///
/// # Lifecycle
///
/// ```text
/// GroupMember::new(identity)
/// ├─ generate_key_package() → upload to AS
/// ├─ create_group(group_id) → become sole member
/// │ └─ add_member(kp) → invite a peer; returns (commit, welcome)
/// └─ join_group(welcome) → join after receiving a Welcome
/// ├─ send_message(msg) → encrypt application data
/// └─ receive_message(b) → decrypt; returns Some(plaintext) or None
/// ```
pub struct GroupMember {
/// Persistent crypto backend. Holds the in-memory key store with HPKE
/// private keys created during `generate_key_package`.
backend: StoreCrypto,
/// Long-term Ed25519 identity keypair. Also used as the MLS `Signer`.
identity: Arc<IdentityKeypair>,
/// Active MLS group, if any.
group: Option<MlsGroup>,
/// Shared group configuration (wire format, ratchet tree extension, etc.).
config: MlsGroupConfig,
}
impl GroupMember {
/// Create a new `GroupMember` with a fresh crypto backend.
pub fn new(identity: Arc<IdentityKeypair>) -> Self {
Self::new_with_state(identity, DiskKeyStore::ephemeral(), None)
}
/// Create a `GroupMember` from pre-existing state (identity + optional group + store).
pub fn new_with_state(
identity: Arc<IdentityKeypair>,
key_store: DiskKeyStore,
group: Option<MlsGroup>,
) -> Self {
let config = MlsGroupConfig::builder()
.use_ratchet_tree_extension(true)
.build();
Self {
backend: StoreCrypto::new(key_store),
identity,
group,
config,
}
}
// ── KeyPackage ────────────────────────────────────────────────────────────
/// Generate a fresh single-use MLS KeyPackage.
///
/// The HPKE init private key is stored in `self.backend`'s key store.
/// **The same `GroupMember` instance must later call `join_group`** so
/// that `new_from_welcome` can retrieve the private key.
///
/// # Returns
///
/// TLS-encoded KeyPackage bytes, ready for upload to the Authentication
/// Service.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage.
pub fn generate_key_package(&mut self) -> Result<Vec<u8>, CoreError> {
let credential_with_key = self.make_credential_with_key()?;
let key_package = KeyPackage::builder()
.build(
CryptoConfig::with_default_version(CIPHERSUITE),
&self.backend,
self.identity.as_ref(),
credential_with_key,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
key_package
.tls_serialize_detached()
.map_err(|e| CoreError::Mls(format!("{e:?}")))
}
// ── Group creation ────────────────────────────────────────────────────────
/// Create a new MLS group with `group_id` as the group identifier.
///
/// The caller becomes the sole member (epoch 0). Use `add_member` to
/// invite additional members.
///
/// `group_id` can be any non-empty byte string; SHA-256 of a human-readable
/// name is a good choice.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the group already exists or openmls fails.
pub fn create_group(&mut self, group_id: &[u8]) -> Result<(), CoreError> {
let credential_with_key = self.make_credential_with_key()?;
let mls_id = GroupId::from_slice(group_id);
let group = MlsGroup::new_with_group_id(
&self.backend,
self.identity.as_ref(),
&self.config,
mls_id,
credential_with_key,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
self.group = Some(group);
Ok(())
}
// ── Membership ────────────────────────────────────────────────────────────
/// Add a new member by their TLS-encoded KeyPackage bytes.
///
/// Produces a Commit (to update existing members' state) and a Welcome
/// (to bootstrap the new member). The caller is responsible for
/// distributing these:
///
/// - Send `commit_bytes` to all **existing** group members via the DS.
/// (In the 2-party case where the creator is the only member, this can
/// be discarded — the creator applies it locally via this method.)
/// - Send `welcome_bytes` to the **new** member via the DS.
///
/// This method also merges the pending Commit into the local group state
/// (advancing the epoch), so the caller is immediately ready to encrypt.
///
/// # Returns
///
/// `(commit_bytes, welcome_bytes)` — both TLS-encoded MLS messages.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the KeyPackage is malformed, no active
/// group exists, or openmls fails.
pub fn add_member(
&mut self,
key_package_bytes: &[u8],
) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
let group = self
.group
.as_mut()
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
// Deserialise and validate the peer's KeyPackage. KeyPackage only derives
// TlsSerialize; KeyPackageIn derives TlsDeserialize and provides validate()
// which verifies the signature and returns a trusted KeyPackage.
let key_package: KeyPackage =
KeyPackageIn::tls_deserialize(&mut key_package_bytes.as_ref())
.map_err(|e| CoreError::Mls(format!("KeyPackage deserialise: {e:?}")))?
.validate(self.backend.crypto(), ProtocolVersion::Mls10)
.map_err(|e| CoreError::Mls(format!("KeyPackage validate: {e:?}")))?;
// Create the Commit + Welcome. The third return value (GroupInfo) is for
// external commits and is not needed here.
let (commit_out, welcome_out, _group_info) = group
.add_members(&self.backend, self.identity.as_ref(), &[key_package])
.map_err(|e| CoreError::Mls(format!("add_members: {e:?}")))?;
// Merge the pending Commit into our own state, advancing the epoch.
group
.merge_pending_commit(&self.backend)
.map_err(|e| CoreError::Mls(format!("merge_pending_commit: {e:?}")))?;
let commit_bytes = commit_out
.to_bytes()
.map_err(|e| CoreError::Mls(format!("commit serialise: {e:?}")))?;
let welcome_bytes = welcome_out
.to_bytes()
.map_err(|e| CoreError::Mls(format!("welcome serialise: {e:?}")))?;
Ok((commit_bytes, welcome_bytes))
}
/// Join an existing MLS group from a TLS-encoded Welcome message.
///
/// The caller must have previously called [`generate_key_package`] on
/// **this same instance** so that the HPKE init private key is in the
/// backend's key store.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the Welcome does not match any known
/// KeyPackage, or openmls validation fails.
///
/// [`generate_key_package`]: Self::generate_key_package
pub fn join_group(&mut self, welcome_bytes: &[u8]) -> Result<(), CoreError> {
// Deserialise MlsMessageIn, then extract the inner Welcome.
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_ref())
.map_err(|e| CoreError::Mls(format!("Welcome deserialise: {e:?}")))?;
// into_welcome() is feature-gated in openmls 0.5; extract() is public.
let welcome = match msg_in.extract() {
MlsMessageInBody::Welcome(w) => w,
_ => return Err(CoreError::Mls("expected a Welcome message".into())),
};
// ratchet_tree = None because use_ratchet_tree_extension = true embeds
// the tree inside the Welcome's GroupInfo extension.
let group = MlsGroup::new_from_welcome(&self.backend, &self.config, welcome, None)
.map_err(|e| CoreError::Mls(format!("new_from_welcome: {e:?}")))?;
self.group = Some(group);
Ok(())
}
// ── Application messages ──────────────────────────────────────────────────
/// Encrypt `plaintext` as an MLS Application message.
///
/// # Returns
///
/// TLS-encoded `MlsMessageOut` bytes (PrivateMessage variant).
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if there is no active group or encryption fails.
pub fn send_message(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, CoreError> {
let group = self
.group
.as_mut()
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
let mls_msg: MlsMessageOut = group
.create_message(&self.backend, self.identity.as_ref(), plaintext)
.map_err(|e| CoreError::Mls(format!("create_message: {e:?}")))?;
mls_msg
.to_bytes()
.map_err(|e| CoreError::Mls(format!("message serialise: {e:?}")))
}
/// Process an incoming TLS-encoded MLS message.
///
/// # Returns
///
/// - `Ok(Some(plaintext))` for Application messages.
/// - `Ok(None)` for Commit messages (group state is updated internally).
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if the message is malformed, fails
/// authentication, or the group state is inconsistent.
pub fn receive_message(&mut self, bytes: &[u8]) -> Result<Option<Vec<u8>>, CoreError> {
let group = self
.group
.as_mut()
.ok_or_else(|| CoreError::Mls("no active group".into()))?;
let msg_in = openmls::prelude::MlsMessageIn::tls_deserialize(&mut bytes.as_ref())
.map_err(|e| CoreError::Mls(format!("message deserialise: {e:?}")))?;
// into_protocol_message() is feature-gated; extract() + manual construction is not.
let protocol_message = match msg_in.extract() {
MlsMessageInBody::PrivateMessage(m) => ProtocolMessage::PrivateMessage(m),
MlsMessageInBody::PublicMessage(m) => ProtocolMessage::PublicMessage(m),
_ => return Err(CoreError::Mls("not a protocol message".into())),
};
let processed = group
.process_message(&self.backend, protocol_message)
.map_err(|e| CoreError::Mls(format!("process_message: {e:?}")))?;
match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(app) => Ok(Some(app.into_bytes())),
ProcessedMessageContent::StagedCommitMessage(staged) => {
// Merge the Commit into the local state (epoch advances).
group
.merge_staged_commit(&self.backend, *staged)
.map_err(|e| CoreError::Mls(format!("merge_staged_commit: {e:?}")))?;
Ok(None)
}
// Proposals are stored for a later Commit; nothing to return yet.
ProcessedMessageContent::ProposalMessage(proposal) => {
group.store_pending_proposal(*proposal);
Ok(None)
}
ProcessedMessageContent::ExternalJoinProposalMessage(proposal) => {
group.store_pending_proposal(*proposal);
Ok(None)
}
}
}
// ── Accessors ─────────────────────────────────────────────────────────────
/// Return the MLS group ID bytes, or `None` if no group is active.
pub fn group_id(&self) -> Option<Vec<u8>> {
self.group
.as_ref()
.map(|g| g.group_id().as_slice().to_vec())
}
/// Return a reference to the identity keypair.
pub fn identity(&self) -> &IdentityKeypair {
&self.identity
}
/// Return the private seed of the identity (for persistence).
pub fn identity_seed(&self) -> [u8; 32] {
self.identity.seed_bytes()
}
/// Return a reference to the underlying crypto backend.
pub fn backend(&self) -> &StoreCrypto {
&self.backend
}
/// Return a reference to the MLS group, if active.
pub fn group_ref(&self) -> Option<&MlsGroup> {
self.group.as_ref()
}
// ── Private helpers ───────────────────────────────────────────────────────
fn make_credential_with_key(&self) -> Result<CredentialWithKey, CoreError> {
let credential = Credential::new(
self.identity.public_key_bytes().to_vec(),
CredentialType::Basic,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
Ok(CredentialWithKey {
credential,
signature_key: self.identity.public_key_bytes().to_vec().into(),
})
}
}
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Full two-party MLS round-trip: create group → add member → exchange messages.
#[test]
fn two_party_mls_round_trip() {
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));
// Bob generates a KeyPackage (stored in bob's backend key store).
let bob_kp = bob.generate_key_package().expect("Bob KeyPackage");
// Alice creates the group.
alice
.create_group(b"test-group-m3")
.expect("Alice create group");
// Alice adds Bob → (commit, welcome).
// Alice is the sole existing member, so she merges the commit herself.
let (_, welcome) = alice.add_member(&bob_kp).expect("Alice add Bob");
// Bob joins via the Welcome. His backend holds the matching init key.
bob.join_group(&welcome).expect("Bob join group");
// Alice → Bob: application message.
let ct_a = alice.send_message(b"hello bob").expect("Alice send");
let pt_b = bob
.receive_message(&ct_a)
.expect("Bob recv")
.expect("should be application message");
assert_eq!(pt_b, b"hello bob");
// Bob → Alice: reply.
let ct_b = bob.send_message(b"hello alice").expect("Bob send");
let pt_a = alice
.receive_message(&ct_b)
.expect("Alice recv")
.expect("should be application message");
assert_eq!(pt_a, b"hello alice");
}
/// `group_id()` returns None before create_group, Some afterwards.
#[test]
fn group_id_lifecycle() {
let id = Arc::new(IdentityKeypair::generate());
let mut member = GroupMember::new(id);
assert!(member.group_id().is_none(), "no group before create");
member.create_group(b"gid").unwrap();
assert_eq!(
member.group_id().unwrap(),
b"gid".as_slice(),
"group_id must match what was passed"
);
}
}

View File

@@ -0,0 +1,138 @@
//! Ed25519 identity keypair for MLS credentials and AS registration.
//!
//! # Relationship to the Noise keypair
//!
//! The X25519 [`NoiseKeypair`](crate::NoiseKeypair) is the transport-layer
//! static key used in the Noise_XX handshake. The Ed25519 [`IdentityKeypair`]
//! is the long-term identity key embedded in MLS `BasicCredential`s. The two
//! keys serve different roles and must not be confused.
//!
//! # Zeroize
//!
//! The 32-byte private seed is stored as `Zeroizing<[u8; 32]>`, which zeroes
//! the bytes on drop. `[u8; 32]` is `Copy + Default` and satisfies zeroize's
//! `DefaultIsZeroes` constraint, avoiding a conflict with ed25519-dalek's
//! `SigningKey` zeroize impl.
//!
//! # Fingerprint
//!
//! A 32-byte SHA-256 digest of the raw public key bytes is used as a compact,
//! collision-resistant identifier for logging.
use ed25519_dalek::{Signer as DalekSigner, SigningKey, VerifyingKey};
use openmls_traits::signatures::Signer;
use openmls_traits::types::{Error as MlsError, SignatureScheme};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
/// An Ed25519 identity keypair.
///
/// Created with [`IdentityKeypair::generate`]. The private signing key seed
/// is zeroed when this struct is dropped.
pub struct IdentityKeypair {
/// Raw 32-byte private seed — zeroized on drop.
///
/// Stored as bytes rather than `SigningKey` to satisfy zeroize's
/// `DefaultIsZeroes` bound on `Zeroizing<T>`.
seed: Zeroizing<[u8; 32]>,
/// Corresponding 32-byte public verifying key.
verifying: VerifyingKey,
}
impl IdentityKeypair {
/// Recreate an identity keypair from a 32-byte seed.
pub fn from_seed(seed: [u8; 32]) -> Self {
let signing = SigningKey::from_bytes(&seed);
let verifying = signing.verifying_key();
Self {
seed: Zeroizing::new(seed),
verifying,
}
}
/// Return the raw 32-byte private seed (for persistence).
pub fn seed_bytes(&self) -> [u8; 32] {
*self.seed
}
}
impl IdentityKeypair {
/// Generate a fresh random Ed25519 identity keypair.
pub fn generate() -> Self {
use rand::rngs::OsRng;
let signing = SigningKey::generate(&mut OsRng);
let verifying = signing.verifying_key();
let seed = Zeroizing::new(signing.to_bytes());
Self { seed, verifying }
}
/// Return the raw 32-byte Ed25519 public key.
///
/// This is the byte array used as `identityKey` in `auth.capnp` calls.
pub fn public_key_bytes(&self) -> [u8; 32] {
self.verifying.to_bytes()
}
/// Return the SHA-256 fingerprint of the public key (32 bytes).
pub fn fingerprint(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.verifying.to_bytes());
hasher.finalize().into()
}
/// Reconstruct the `SigningKey` from the stored seed bytes.
fn signing_key(&self) -> SigningKey {
SigningKey::from_bytes(&self.seed)
}
}
/// Implement the openmls `Signer` trait so `IdentityKeypair` can be passed
/// directly to `KeyPackage::builder().build(...)` without needing the external
/// `openmls_basic_credential` crate.
impl Signer for IdentityKeypair {
fn sign(&self, payload: &[u8]) -> Result<Vec<u8>, MlsError> {
let sk = self.signing_key();
let sig: ed25519_dalek::Signature = sk.sign(payload);
Ok(sig.to_bytes().to_vec())
}
fn signature_scheme(&self) -> SignatureScheme {
SignatureScheme::ED25519
}
}
impl Serialize for IdentityKeypair {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_bytes(&self.seed[..])
}
}
impl<'de> Deserialize<'de> for IdentityKeypair {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let bytes: Vec<u8> = serde::Deserialize::deserialize(deserializer)?;
let seed: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| serde::de::Error::custom("identity seed must be 32 bytes"))?;
Ok(IdentityKeypair::from_seed(seed))
}
}
impl std::fmt::Debug for IdentityKeypair {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let fp = self.fingerprint();
f.debug_struct("IdentityKeypair")
.field(
"fingerprint",
&format!("{:02x}{:02x}{:02x}{:02x}", fp[0], fp[1], fp[2], fp[3]),
)
.finish_non_exhaustive()
}
}

View File

@@ -0,0 +1,80 @@
//! MLS KeyPackage generation and TLS serialisation.
//!
//! # Ciphersuite
//!
//! `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (ciphersuite ID `0x0001`).
//! This is the RECOMMENDED ciphersuite from RFC 9420 §17.1.
//!
//! # Single-use semantics
//!
//! Per RFC 9420 §10.1, each KeyPackage MUST be used at most once. The
//! Authentication Service enforces this by atomically removing a package on
//! fetch.
//!
//! # Wire format
//!
//! KeyPackages are TLS-encoded using `tls_codec` (same version as openmls).
//! The resulting bytes are opaque to the quicnprotochat transport layer.
use openmls::prelude::{
Ciphersuite, Credential, CredentialType, CredentialWithKey, CryptoConfig, KeyPackage,
TlsSerializeTrait,
};
use openmls_rust_crypto::OpenMlsRustCrypto;
use sha2::{Digest, Sha256};
use crate::{error::CoreError, identity::IdentityKeypair};
/// The MLS ciphersuite used throughout quicnprotochat.
const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
/// Generate a fresh MLS KeyPackage for `identity` and serialise it.
///
/// # Returns
///
/// `(tls_bytes, sha256_fingerprint)` where:
/// - `tls_bytes` is the TLS-encoded KeyPackage blob, suitable for uploading.
/// - `sha256_fingerprint` is the SHA-256 digest of `tls_bytes` for tamper detection.
///
/// # Errors
///
/// Returns [`CoreError::Mls`] if openmls fails to create the KeyPackage or if
/// TLS serialisation fails.
pub fn generate_key_package(identity: &IdentityKeypair) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
let backend = OpenMlsRustCrypto::default();
// Build a BasicCredential using the raw Ed25519 public key bytes as the
// MLS identity. Per RFC 9420, any byte string may serve as the identity.
let credential = Credential::new(identity.public_key_bytes().to_vec(), CredentialType::Basic)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
// The `signature_key` in CredentialWithKey is the Ed25519 public key that
// will be used to verify the KeyPackage's leaf node signature.
// `SignaturePublicKey` implements `From<Vec<u8>>`.
let credential_with_key = CredentialWithKey {
credential,
signature_key: identity.public_key_bytes().to_vec().into(),
};
// `IdentityKeypair` implements `openmls_traits::signatures::Signer`
// so it can be passed directly to the builder.
let key_package = KeyPackage::builder()
.build(
CryptoConfig::with_default_version(CIPHERSUITE),
&backend,
identity,
credential_with_key,
)
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
// TLS-encode the KeyPackage using the trait from the openmls prelude.
// This uses tls_codec 0.3 (the same version openmls uses internally),
// avoiding a duplicate-trait conflict with tls_codec 0.4.
let tls_bytes = key_package
.tls_serialize_detached()
.map_err(|e| CoreError::Mls(format!("{e:?}")))?;
let fingerprint: Vec<u8> = Sha256::digest(&tls_bytes).to_vec();
Ok((tls_bytes, fingerprint))
}

View File

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

View File

@@ -0,0 +1,144 @@
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::RwLock,
};
use openmls_rust_crypto::RustCrypto;
use openmls_traits::{
key_store::{MlsEntity, OpenMlsKeyStore},
OpenMlsCryptoProvider,
};
/// A disk-backed key store implementing `OpenMlsKeyStore`.
///
/// In-memory when `path` is `None`; otherwise flushes the entire map to disk on
/// every store/delete so HPKE init keys survive process restarts.
#[derive(Debug)]
pub struct DiskKeyStore {
path: Option<PathBuf>,
values: RwLock<HashMap<Vec<u8>, Vec<u8>>>,
}
#[derive(thiserror::Error, Debug, PartialEq, Eq)]
pub enum DiskKeyStoreError {
#[error("serialization error")]
Serialization,
#[error("io error: {0}")]
Io(String),
}
impl DiskKeyStore {
/// In-memory keystore (no persistence).
pub fn ephemeral() -> Self {
Self {
path: None,
values: RwLock::new(HashMap::new()),
}
}
/// Persistent keystore backed by `path`. Creates an empty store if missing.
pub fn persistent(path: impl AsRef<Path>) -> Result<Self, DiskKeyStoreError> {
let path = path.as_ref().to_path_buf();
let values = if path.exists() {
let bytes = fs::read(&path).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?;
if bytes.is_empty() {
HashMap::new()
} else {
bincode::deserialize(&bytes).map_err(|_| DiskKeyStoreError::Serialization)?
}
} else {
HashMap::new()
};
Ok(Self {
path: Some(path),
values: RwLock::new(values),
})
}
fn flush(&self) -> Result<(), DiskKeyStoreError> {
let Some(path) = &self.path else {
return Ok(());
};
let values = self.values.read().unwrap();
let bytes = bincode::serialize(&*values).map_err(|_| DiskKeyStoreError::Serialization)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| DiskKeyStoreError::Io(e.to_string()))?;
}
fs::write(path, bytes).map_err(|e| DiskKeyStoreError::Io(e.to_string()))
}
}
impl Default for DiskKeyStore {
fn default() -> Self {
Self::ephemeral()
}
}
impl OpenMlsKeyStore for DiskKeyStore {
type Error = DiskKeyStoreError;
fn store<V: MlsEntity>(&self, k: &[u8], v: &V) -> Result<(), Self::Error> {
let value = serde_json::to_vec(v).map_err(|_| DiskKeyStoreError::Serialization)?;
let mut values = self.values.write().unwrap();
values.insert(k.to_vec(), value);
drop(values);
self.flush()
}
fn read<V: MlsEntity>(&self, k: &[u8]) -> Option<V> {
let values = self.values.read().unwrap();
values
.get(k)
.and_then(|bytes| serde_json::from_slice(bytes).ok())
}
fn delete<V: MlsEntity>(&self, k: &[u8]) -> Result<(), Self::Error> {
let mut values = self.values.write().unwrap();
values.remove(k);
drop(values);
self.flush()
}
}
/// Crypto provider that couples RustCrypto with a disk-backed key store.
#[derive(Debug)]
pub struct StoreCrypto {
crypto: RustCrypto,
key_store: DiskKeyStore,
}
impl StoreCrypto {
pub fn new(key_store: DiskKeyStore) -> Self {
Self {
crypto: RustCrypto::default(),
key_store,
}
}
}
impl Default for StoreCrypto {
fn default() -> Self {
Self::new(DiskKeyStore::ephemeral())
}
}
impl OpenMlsCryptoProvider for StoreCrypto {
type CryptoProvider = RustCrypto;
type RandProvider = RustCrypto;
type KeyStoreProvider = DiskKeyStore;
fn crypto(&self) -> &Self::CryptoProvider {
&self.crypto
}
fn rand(&self) -> &Self::RandProvider {
&self.crypto
}
fn key_store(&self) -> &Self::KeyStoreProvider {
&self.key_store
}
}

View File

@@ -0,0 +1,34 @@
//! Core cryptographic primitives, Noise_XX transport, MLS group state machine,
//! and frame codec for quicnprotochat.
//!
//! # Module layout
//!
//! | Module | Responsibility |
//! |--------------|------------------------------------------------------------------|
//! | `error` | [`CoreError`] and [`CodecError`] types |
//! | `keypair` | [`NoiseKeypair`] — static X25519 key, zeroize-on-drop |
//! | `codec` | [`LengthPrefixedCodec`] — Tokio Encoder + Decoder |
//! | `noise` | [`handshake_initiator`], [`handshake_responder`], [`NoiseTransport`] |
//! | `identity` | [`IdentityKeypair`] — Ed25519 identity key for MLS credentials |
//! | `keypackage` | [`generate_key_package`] — standalone KeyPackage generation |
//! | `group` | [`GroupMember`] — MLS group lifecycle (create/join/send/recv) |
mod codec;
mod error;
mod group;
mod identity;
mod keypackage;
mod keypair;
mod keystore;
mod noise;
// ── Public API ────────────────────────────────────────────────────────────────
pub use codec::{LengthPrefixedCodec, NOISE_MAX_MSG};
pub use error::{CodecError, CoreError, MAX_PLAINTEXT_LEN};
pub use group::GroupMember;
pub use identity::IdentityKeypair;
pub use keypackage::generate_key_package;
pub use keypair::NoiseKeypair;
pub use keystore::DiskKeyStore;
pub use noise::{handshake_initiator, handshake_responder, NoiseTransport};

View File

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