feat: add post-quantum hybrid KEM + SQLCipher persistence
Feature 1 — Post-Quantum Hybrid KEM (X25519 + ML-KEM-768): - Create hybrid_kem.rs with keygen, encrypt, decrypt + 11 unit tests - Wire format: version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | nonce(12) | ct - Add uploadHybridKey/fetchHybridKey RPCs to node.capnp schema - Server: hybrid key storage in FileBackedStore + RPC handlers - Client: hybrid keypair in StoredState, auto-wrap/unwrap in send/recv/invite/join - demo-group runs full hybrid PQ envelope round-trip Feature 2 — SQLCipher Persistence: - Extract Store trait from FileBackedStore API - Create SqlStore (rusqlite + bundled-sqlcipher) with encrypted-at-rest SQLite - Schema: key_packages, deliveries, hybrid_keys tables with indexes - Server CLI: --store-backend=sql, --db-path, --db-key flags - 5 unit tests for SqlStore (FIFO, round-trip, upsert, channel isolation) Also includes: client lib.rs refactor, auth config, TOML config file support, mdBook documentation, and various cleanups by user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,203 +0,0 @@
|
||||
//! 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]);
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,21 @@
|
||||
//! 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).
|
||||
/// Errors produced by core cryptographic and MLS operations.
|
||||
#[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),
|
||||
|
||||
/// A hybrid KEM (X25519 + ML-KEM-768) operation failed.
|
||||
#[error("hybrid KEM error: {0}")]
|
||||
HybridKem(#[from] crate::hybrid_kem::HybridKemError),
|
||||
}
|
||||
|
||||
452
crates/quicnprotochat-core/src/hybrid_kem.rs
Normal file
452
crates/quicnprotochat-core/src/hybrid_kem.rs
Normal file
@@ -0,0 +1,452 @@
|
||||
//! Post-quantum hybrid KEM: X25519 + ML-KEM-768.
|
||||
//!
|
||||
//! Wraps MLS payloads in an outer encryption layer using a hybrid key
|
||||
//! encapsulation mechanism. The X25519 component provides classical
|
||||
//! ECDH security; the ML-KEM-768 component (FIPS 203) provides
|
||||
//! post-quantum security.
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! ```text
|
||||
//! version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | aead_nonce(12) | aead_ct(var)
|
||||
//! ```
|
||||
//!
|
||||
//! # Key derivation
|
||||
//!
|
||||
//! ```text
|
||||
//! ikm = X25519_shared(32) || ML-KEM_shared(32)
|
||||
//! key = HKDF-SHA256(salt=[], ikm, info="quicnprotochat-hybrid-v1", L=32)
|
||||
//! ```
|
||||
|
||||
use chacha20poly1305::{
|
||||
aead::{Aead, KeyInit},
|
||||
ChaCha20Poly1305, Key, Nonce,
|
||||
};
|
||||
use hkdf::Hkdf;
|
||||
use ml_kem::{
|
||||
array::Array,
|
||||
kem::{Decapsulate, Encapsulate},
|
||||
EncodedSizeUser, KemCore, MlKem768, MlKem768Params,
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey as X25519Public, StaticSecret};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
// Re-import the concrete key types from the kem sub-module.
|
||||
use ml_kem::kem::{DecapsulationKey, EncapsulationKey};
|
||||
|
||||
/// Current hybrid envelope version byte.
|
||||
const HYBRID_VERSION: u8 = 0x01;
|
||||
|
||||
/// HKDF info string for domain separation.
|
||||
const HKDF_INFO: &[u8] = b"quicnprotochat-hybrid-v1";
|
||||
|
||||
/// ML-KEM-768 ciphertext size in bytes.
|
||||
const MLKEM_CT_LEN: usize = 1088;
|
||||
|
||||
/// ML-KEM-768 encapsulation key size in bytes.
|
||||
pub const MLKEM_EK_LEN: usize = 1184;
|
||||
|
||||
/// ML-KEM-768 decapsulation key size in bytes.
|
||||
pub const MLKEM_DK_LEN: usize = 2400;
|
||||
|
||||
/// Envelope header: version(1) + x25519 eph pk(32) + mlkem ct(1088) + nonce(12).
|
||||
const HEADER_LEN: usize = 1 + 32 + MLKEM_CT_LEN + 12;
|
||||
|
||||
// ── Error type ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HybridKemError {
|
||||
#[error("AEAD encryption failed")]
|
||||
EncryptionFailed,
|
||||
|
||||
#[error("AEAD decryption failed (wrong recipient or tampered)")]
|
||||
DecryptionFailed,
|
||||
|
||||
#[error("unsupported hybrid envelope version: {0}")]
|
||||
UnsupportedVersion(u8),
|
||||
|
||||
#[error("envelope too short ({0} bytes, minimum {HEADER_LEN})")]
|
||||
TooShort(usize),
|
||||
|
||||
#[error("invalid ML-KEM encapsulation key")]
|
||||
InvalidMlKemKey,
|
||||
|
||||
#[error("ML-KEM decapsulation failed")]
|
||||
MlKemDecapsFailed,
|
||||
}
|
||||
|
||||
// ── Keypair types ───────────────────────────────────────────────────────────
|
||||
|
||||
/// A hybrid keypair combining X25519 (classical) + ML-KEM-768 (post-quantum).
|
||||
///
|
||||
/// Each peer holds one of these. The public portion is distributed so
|
||||
/// senders can encrypt payloads with post-quantum protection.
|
||||
pub struct HybridKeypair {
|
||||
x25519_sk: StaticSecret,
|
||||
x25519_pk: X25519Public,
|
||||
mlkem_dk: DecapsulationKey<MlKem768Params>,
|
||||
mlkem_ek: EncapsulationKey<MlKem768Params>,
|
||||
}
|
||||
|
||||
/// Serialisable form of a [`HybridKeypair`] for persistence.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct HybridKeypairBytes {
|
||||
pub x25519_sk: [u8; 32],
|
||||
pub mlkem_dk: Vec<u8>,
|
||||
pub mlkem_ek: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The public portion of a hybrid keypair, sent to peers.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct HybridPublicKey {
|
||||
pub x25519_pk: [u8; 32],
|
||||
pub mlkem_ek: Vec<u8>,
|
||||
}
|
||||
|
||||
impl HybridKeypair {
|
||||
/// Generate a fresh hybrid keypair from OS CSPRNG.
|
||||
pub fn generate() -> Self {
|
||||
let x25519_sk = StaticSecret::random_from_rng(OsRng);
|
||||
let x25519_pk = X25519Public::from(&x25519_sk);
|
||||
let (mlkem_dk, mlkem_ek) = MlKem768::generate(&mut OsRng);
|
||||
|
||||
Self {
|
||||
x25519_sk,
|
||||
x25519_pk,
|
||||
mlkem_dk,
|
||||
mlkem_ek,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstruct from serialised bytes.
|
||||
pub fn from_bytes(bytes: &HybridKeypairBytes) -> Result<Self, HybridKemError> {
|
||||
let x25519_sk = StaticSecret::from(bytes.x25519_sk);
|
||||
let x25519_pk = X25519Public::from(&x25519_sk);
|
||||
|
||||
let mlkem_dk_arr = Array::try_from(bytes.mlkem_dk.as_slice())
|
||||
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
||||
let mlkem_dk = DecapsulationKey::<MlKem768Params>::from_bytes(&mlkem_dk_arr);
|
||||
|
||||
let mlkem_ek_arr = Array::try_from(bytes.mlkem_ek.as_slice())
|
||||
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
||||
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
|
||||
|
||||
Ok(Self {
|
||||
x25519_sk,
|
||||
x25519_pk,
|
||||
mlkem_dk,
|
||||
mlkem_ek,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialise the keypair for persistence.
|
||||
pub fn to_bytes(&self) -> HybridKeypairBytes {
|
||||
HybridKeypairBytes {
|
||||
x25519_sk: self.x25519_sk.to_bytes(),
|
||||
mlkem_dk: self.mlkem_dk.as_bytes().to_vec(),
|
||||
mlkem_ek: self.mlkem_ek.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the public portion for distribution to peers.
|
||||
pub fn public_key(&self) -> HybridPublicKey {
|
||||
HybridPublicKey {
|
||||
x25519_pk: self.x25519_pk.to_bytes(),
|
||||
mlkem_ek: self.mlkem_ek.as_bytes().to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HybridPublicKey {
|
||||
/// Serialise to a single byte blob: x25519_pk(32) || mlkem_ek(1184).
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(32 + self.mlkem_ek.len());
|
||||
out.extend_from_slice(&self.x25519_pk);
|
||||
out.extend_from_slice(&self.mlkem_ek);
|
||||
out
|
||||
}
|
||||
|
||||
/// Deserialise from a single byte blob.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Self, HybridKemError> {
|
||||
if bytes.len() < 32 + MLKEM_EK_LEN {
|
||||
return Err(HybridKemError::TooShort(bytes.len()));
|
||||
}
|
||||
let mut x25519_pk = [0u8; 32];
|
||||
x25519_pk.copy_from_slice(&bytes[..32]);
|
||||
let mlkem_ek = bytes[32..32 + MLKEM_EK_LEN].to_vec();
|
||||
Ok(Self {
|
||||
x25519_pk,
|
||||
mlkem_ek,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Encrypt / Decrypt ───────────────────────────────────────────────────────
|
||||
|
||||
/// Encrypt `plaintext` to `recipient_pk` using X25519 + ML-KEM-768 hybrid KEM.
|
||||
///
|
||||
/// Returns the complete hybrid envelope as a byte vector.
|
||||
pub fn hybrid_encrypt(
|
||||
recipient_pk: &HybridPublicKey,
|
||||
plaintext: &[u8],
|
||||
) -> Result<Vec<u8>, HybridKemError> {
|
||||
// 1. Ephemeral X25519 DH
|
||||
let eph_secret = EphemeralSecret::random_from_rng(OsRng);
|
||||
let eph_public = X25519Public::from(&eph_secret);
|
||||
let x25519_recipient = X25519Public::from(recipient_pk.x25519_pk);
|
||||
let x25519_ss = eph_secret.diffie_hellman(&x25519_recipient);
|
||||
|
||||
// 2. ML-KEM-768 encapsulation
|
||||
let mlkem_ek_arr = Array::try_from(recipient_pk.mlkem_ek.as_slice())
|
||||
.map_err(|_| HybridKemError::InvalidMlKemKey)?;
|
||||
let mlkem_ek = EncapsulationKey::<MlKem768Params>::from_bytes(&mlkem_ek_arr);
|
||||
let (mlkem_ct, mlkem_ss) = mlkem_ek
|
||||
.encapsulate(&mut OsRng)
|
||||
.map_err(|_| HybridKemError::EncryptionFailed)?;
|
||||
|
||||
// 3. Combine shared secrets via HKDF
|
||||
let (aead_key, aead_nonce) =
|
||||
derive_aead_material(x25519_ss.as_bytes(), mlkem_ss.as_slice());
|
||||
|
||||
// 4. AEAD encrypt
|
||||
let cipher = ChaCha20Poly1305::new(&aead_key);
|
||||
let ct = cipher
|
||||
.encrypt(&aead_nonce, plaintext)
|
||||
.map_err(|_| HybridKemError::EncryptionFailed)?;
|
||||
|
||||
// 5. Assemble envelope: version || x25519_eph_pk || mlkem_ct || nonce || aead_ct
|
||||
let mut out = Vec::with_capacity(HEADER_LEN + ct.len());
|
||||
out.push(HYBRID_VERSION);
|
||||
out.extend_from_slice(&eph_public.to_bytes());
|
||||
out.extend_from_slice(mlkem_ct.as_slice());
|
||||
out.extend_from_slice(aead_nonce.as_slice());
|
||||
out.extend_from_slice(&ct);
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decrypt a hybrid envelope using the recipient's private key.
|
||||
pub fn hybrid_decrypt(
|
||||
keypair: &HybridKeypair,
|
||||
envelope: &[u8],
|
||||
) -> Result<Vec<u8>, HybridKemError> {
|
||||
if envelope.len() < HEADER_LEN + 16 {
|
||||
// 16 = minimum AEAD tag
|
||||
return Err(HybridKemError::TooShort(envelope.len()));
|
||||
}
|
||||
|
||||
let version = envelope[0];
|
||||
if version != HYBRID_VERSION {
|
||||
return Err(HybridKemError::UnsupportedVersion(version));
|
||||
}
|
||||
|
||||
let mut cursor = 1;
|
||||
|
||||
// X25519 ephemeral public key
|
||||
let mut eph_pk_bytes = [0u8; 32];
|
||||
eph_pk_bytes.copy_from_slice(&envelope[cursor..cursor + 32]);
|
||||
cursor += 32;
|
||||
|
||||
// ML-KEM ciphertext
|
||||
let mlkem_ct_bytes = &envelope[cursor..cursor + MLKEM_CT_LEN];
|
||||
cursor += MLKEM_CT_LEN;
|
||||
|
||||
// AEAD nonce
|
||||
let nonce = Nonce::from_slice(&envelope[cursor..cursor + 12]);
|
||||
cursor += 12;
|
||||
|
||||
// AEAD ciphertext
|
||||
let aead_ct = &envelope[cursor..];
|
||||
|
||||
// 1. X25519 DH with ephemeral public key
|
||||
let eph_pk = X25519Public::from(eph_pk_bytes);
|
||||
let x25519_ss = keypair.x25519_sk.diffie_hellman(&eph_pk);
|
||||
|
||||
// 2. ML-KEM decapsulation — convert bytes to the ciphertext array type
|
||||
// that `DecapsulationKey::decapsulate` expects.
|
||||
let mlkem_ct_arr = Array::try_from(mlkem_ct_bytes)
|
||||
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
|
||||
let mlkem_ss = keypair
|
||||
.mlkem_dk
|
||||
.decapsulate(&mlkem_ct_arr)
|
||||
.map_err(|_| HybridKemError::MlKemDecapsFailed)?;
|
||||
|
||||
// 3. Derive AEAD key
|
||||
let (aead_key, _) = derive_aead_material(x25519_ss.as_bytes(), mlkem_ss.as_slice());
|
||||
|
||||
// 4. Decrypt
|
||||
let cipher = ChaCha20Poly1305::new(&aead_key);
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, aead_ct)
|
||||
.map_err(|_| HybridKemError::DecryptionFailed)?;
|
||||
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
/// Derive AEAD key + nonce from the combined X25519 + ML-KEM shared secrets.
|
||||
fn derive_aead_material(
|
||||
x25519_ss: &[u8],
|
||||
mlkem_ss: &[u8],
|
||||
) -> (Key, Nonce) {
|
||||
let mut ikm = Zeroizing::new(vec![0u8; x25519_ss.len() + mlkem_ss.len()]);
|
||||
ikm[..x25519_ss.len()].copy_from_slice(x25519_ss);
|
||||
ikm[x25519_ss.len()..].copy_from_slice(mlkem_ss);
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(None, &ikm);
|
||||
|
||||
let mut key_bytes = Zeroizing::new([0u8; 32]);
|
||||
hk.expand(HKDF_INFO, &mut *key_bytes)
|
||||
.expect("32 bytes is valid HKDF-SHA256 output length");
|
||||
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
hk.expand(b"quicnprotochat-hybrid-nonce-v1", &mut nonce_bytes)
|
||||
.expect("12 bytes is valid HKDF-SHA256 output length");
|
||||
|
||||
(*Key::from_slice(&*key_bytes), *Nonce::from_slice(&nonce_bytes))
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn keygen_produces_valid_public_key() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
assert_eq!(pk.x25519_pk.len(), 32);
|
||||
assert_eq!(pk.mlkem_ek.len(), MLKEM_EK_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_decrypt_round_trip() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
let plaintext = b"hello post-quantum world!";
|
||||
|
||||
let envelope = hybrid_encrypt(&pk, plaintext).unwrap();
|
||||
let recovered = hybrid_decrypt(&kp, &envelope).unwrap();
|
||||
|
||||
assert_eq!(recovered, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_decryption_fails() {
|
||||
let kp_sender_target = HybridKeypair::generate();
|
||||
let kp_wrong = HybridKeypair::generate();
|
||||
|
||||
let pk = kp_sender_target.public_key();
|
||||
let envelope = hybrid_encrypt(&pk, b"secret").unwrap();
|
||||
|
||||
let result = hybrid_decrypt(&kp_wrong, &envelope);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_aead_ciphertext_fails() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
|
||||
let mut envelope = hybrid_encrypt(&pk, b"payload").unwrap();
|
||||
let last = envelope.len() - 1;
|
||||
envelope[last] ^= 0x01;
|
||||
|
||||
assert!(matches!(
|
||||
hybrid_decrypt(&kp, &envelope),
|
||||
Err(HybridKemError::DecryptionFailed)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_mlkem_ct_fails() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
|
||||
let mut envelope = hybrid_encrypt(&pk, b"payload").unwrap();
|
||||
// Flip a byte in the ML-KEM ciphertext region (starts at offset 33)
|
||||
envelope[40] ^= 0xFF;
|
||||
|
||||
assert!(hybrid_decrypt(&kp, &envelope).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampered_x25519_eph_pk_fails() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
|
||||
let mut envelope = hybrid_encrypt(&pk, b"payload").unwrap();
|
||||
// Flip a byte in the X25519 ephemeral pk region (offset 1..33)
|
||||
envelope[5] ^= 0xFF;
|
||||
|
||||
assert!(hybrid_decrypt(&kp, &envelope).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_version_rejected() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
|
||||
let mut envelope = hybrid_encrypt(&pk, b"payload").unwrap();
|
||||
envelope[0] = 0xFF;
|
||||
|
||||
assert!(matches!(
|
||||
hybrid_decrypt(&kp, &envelope),
|
||||
Err(HybridKemError::UnsupportedVersion(0xFF))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_too_short_rejected() {
|
||||
let kp = HybridKeypair::generate();
|
||||
assert!(matches!(
|
||||
hybrid_decrypt(&kp, &[0x01; 10]),
|
||||
Err(HybridKemError::TooShort(10))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keypair_serialisation_round_trip() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let bytes = kp.to_bytes();
|
||||
let restored = HybridKeypair::from_bytes(&bytes).unwrap();
|
||||
|
||||
assert_eq!(kp.x25519_pk.to_bytes(), restored.x25519_pk.to_bytes());
|
||||
assert_eq!(
|
||||
kp.public_key().mlkem_ek,
|
||||
restored.public_key().mlkem_ek
|
||||
);
|
||||
|
||||
// Verify restored keypair can decrypt
|
||||
let pk = kp.public_key();
|
||||
let ct = hybrid_encrypt(&pk, b"test").unwrap();
|
||||
let pt = hybrid_decrypt(&restored, &ct).unwrap();
|
||||
assert_eq!(pt, b"test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_key_serialisation_round_trip() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
let bytes = pk.to_bytes();
|
||||
let restored = HybridPublicKey::from_bytes(&bytes).unwrap();
|
||||
|
||||
assert_eq!(pk.x25519_pk, restored.x25519_pk);
|
||||
assert_eq!(pk.mlkem_ek, restored.mlkem_ek);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_payload_round_trip() {
|
||||
let kp = HybridKeypair::generate();
|
||||
let pk = kp.public_key();
|
||||
let plaintext = vec![0xAB; 50_000]; // 50 KB
|
||||
|
||||
let envelope = hybrid_encrypt(&pk, &plaintext).unwrap();
|
||||
let recovered = hybrid_decrypt(&kp, &envelope).unwrap();
|
||||
|
||||
assert_eq!(recovered, plaintext);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
//! 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.
|
||||
//! The [`IdentityKeypair`] is the long-term identity key embedded in MLS
|
||||
//! `BasicCredential`s. It is used for signing MLS messages and as the
|
||||
//! indexing key for the Authentication Service.
|
||||
//!
|
||||
//! # Zeroize
|
||||
//!
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,32 @@
|
||||
//! Core cryptographic primitives, Noise_XX transport, MLS group state machine,
|
||||
//! and frame codec for quicnprotochat.
|
||||
//! Core cryptographic primitives, MLS group state machine, and hybrid
|
||||
//! post-quantum KEM 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`] |
|
||||
//! | `error` | [`CoreError`] type |
|
||||
//! | `identity` | [`IdentityKeypair`] — Ed25519 identity key for MLS credentials |
|
||||
//! | `keypackage` | [`generate_key_package`] — standalone KeyPackage generation |
|
||||
//! | `group` | [`GroupMember`] — MLS group lifecycle (create/join/send/recv) |
|
||||
//! | `hybrid_kem` | Hybrid X25519 + ML-KEM-768 key encapsulation |
|
||||
//! | `keystore` | [`DiskKeyStore`] — OpenMLS key store with optional persistence |
|
||||
|
||||
mod codec;
|
||||
mod error;
|
||||
mod group;
|
||||
pub mod hybrid_kem;
|
||||
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 error::CoreError;
|
||||
pub use group::GroupMember;
|
||||
pub use hybrid_kem::{
|
||||
hybrid_decrypt, hybrid_encrypt, HybridKeypair, HybridKeypairBytes, HybridKemError,
|
||||
HybridPublicKey,
|
||||
};
|
||||
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};
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
//! Noise_XX handshake and encrypted transport.
|
||||
//!
|
||||
//! # Protocol
|
||||
//!
|
||||
//! Pattern: `Noise_XX_25519_ChaChaPoly_BLAKE2s`
|
||||
//!
|
||||
//! ```text
|
||||
//! XX handshake (3 messages):
|
||||
//! -> e (initiator sends ephemeral public key)
|
||||
//! <- e, ee, s, es (responder replies; mutual DH + responder static)
|
||||
//! -> s, se (initiator sends static key; final DH)
|
||||
//! ```
|
||||
//!
|
||||
//! After the handshake both peers have authenticated each other's static X25519
|
||||
//! keys and negotiated a symmetric session with ChaCha20-Poly1305.
|
||||
//!
|
||||
//! # Framing
|
||||
//!
|
||||
//! All messages — handshake and application — are carried in length-prefixed
|
||||
//! frames (see [`LengthPrefixedCodec`](crate::LengthPrefixedCodec)).
|
||||
//!
|
||||
//! In the handshake phase the frame payload is the raw Noise handshake bytes
|
||||
//! produced by `snow`. In the transport phase the frame payload is a
|
||||
//! Noise-encrypted Cap'n Proto message.
|
||||
//!
|
||||
//! # Post-quantum gap (ADR-006)
|
||||
//!
|
||||
//! The Noise transport uses classical X25519. PQ-Noise is not yet standardised
|
||||
//! in `snow`. MLS application data is PQ-protected from M5 onward. The residual
|
||||
//! risk (metadata exposure via handshake harvest) is accepted for M1–M5.
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use tokio::{
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user