fix: address 16 architecture design flaws across all crates

Phase 1 — Foundation:
- Constant-time token comparison via subtle::ConstantTimeEq (Fix 11)
- Structured error codes E001–E020 in new error_codes.rs (Fix 15)
- Remove dead envelope.capnp code and related types (Fix 16)

Phase 2 — Auth Hardening:
- Registration collision check via has_user_record() (Fix 5)
- Auth required on uploadHybridKey/fetchHybridKey RPCs (Fix 1)
- Identity-token binding at registration and login (Fix 2)
- Session token expiry with 24h TTL and background reaper (Fix 3)
- Bounded pending logins with 5-minute timeout (Fix 4)

Phase 3 — Resource Limits:
- Rate limiting: 100 enqueues/60s per token (Fix 6)
- Queue depth cap at 1000 + 7-day message TTL/GC (Fix 7)
- Partial queue drain via limit param on fetch/fetchWait (Fix 8)

Phase 4 — Crypto Fixes:
- OPAQUE KSF switched from Identity to Argon2id (Fix 10)
- Random AEAD nonce in hybrid KEM instead of HKDF-derived (Fix 12)
- Zeroize secret fields in HybridKeypairBytes (Fix 13)
- Encrypted client state files via QPCE format (Fix 9)

Phase 5 — Protocol:
- Commit fan-out to all existing members on invite (Fix 14)
- Add member_identities() to GroupMember

Breaking: existing OPAQUE registrations invalidated (Argon2 KSF).
Schema: added auth to hybrid key ops, identityKey to OPAQUE finish
RPCs, limit to fetch/fetchWait.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 10:51:09 +01:00
parent 8d5c1b3b9b
commit 0bdc222724
19 changed files with 4516 additions and 495 deletions

View File

@@ -26,10 +26,6 @@ fn main() {
let schemas_dir = workspace_root.join("schemas");
// Re-run this build script whenever any schema file changes.
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("envelope.capnp").display()
);
println!(
"cargo:rerun-if-changed={}",
schemas_dir.join("auth.capnp").display()
@@ -47,7 +43,6 @@ fn main() {
// Treat `schemas/` as the include root so that inter-schema imports
// resolve correctly.
.src_prefix(&schemas_dir)
.file(schemas_dir.join("envelope.capnp"))
.file(schemas_dir.join("auth.capnp"))
.file(schemas_dir.join("delivery.capnp"))
.file(schemas_dir.join("node.capnp"))

View File

@@ -11,22 +11,9 @@
//!
//! `build.rs` invokes `capnpc` at compile time and writes generated Rust source
//! into `$OUT_DIR`. The `include!` macros below splice that code in as a module.
//!
//! # Canonical serialisation (M2+)
//!
//! `build_envelope` uses standard Cap'n Proto wire format. Canonical serialisation
//! (deterministic byte representation for cryptographic signing of KeyPackages and
//! Commits) is added in M2 once the Authentication Service is introduced.
// ── Generated types ───────────────────────────────────────────────────────────
/// Cap'n Proto generated types for `schemas/envelope.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
pub mod envelope_capnp {
include!(concat!(env!("OUT_DIR"), "/envelope_capnp.rs"));
}
/// Cap'n Proto generated types for `schemas/auth.capnp`.
///
/// Do not edit this module by hand — it is entirely machine-generated.
@@ -48,95 +35,6 @@ pub mod node_capnp {
include!(concat!(env!("OUT_DIR"), "/node_capnp.rs"));
}
// ── Re-exports ────────────────────────────────────────────────────────────────
/// The message-type discriminant from the `Envelope` schema.
///
/// Re-exported here so callers can `use quicnprotochat_proto::MsgType` without
/// spelling out the full generated module path.
pub use envelope_capnp::envelope::MsgType;
// ── Owned envelope type ───────────────────────────────────────────────────────
/// An owned, decoded `Envelope` with no Cap'n Proto reader lifetimes.
///
/// All byte fields are eagerly copied out of the Cap'n Proto reader so that
/// this type is `Send + 'static` and can cross async task boundaries freely.
///
/// # Invariants
///
/// - `group_id` and `sender_id` are either empty (for control messages such as
/// `Ping`/`Pong`) or exactly 32 bytes (SHA-256 digest).
/// - `payload` is empty for `Ping` and `Pong`; non-empty for all MLS variants.
#[derive(Debug, Clone)]
pub struct ParsedEnvelope {
pub msg_type: MsgType,
/// SHA-256 of the group name, or empty for point-to-point control messages.
pub group_id: Vec<u8>,
/// SHA-256 of the sender's Ed25519 identity public key, or empty.
pub sender_id: Vec<u8>,
/// Opaque payload — interpretation is determined by `msg_type`.
pub payload: Vec<u8>,
/// Unix timestamp in milliseconds.
pub timestamp_ms: u64,
}
// ── Serialisation helpers ─────────────────────────────────────────────────────
/// Serialise a [`ParsedEnvelope`] to unpacked Cap'n Proto wire bytes.
///
/// The returned bytes include the Cap'n Proto segment table header followed by
/// the message data. They are suitable for use as the body of a length-prefixed
/// quicnprotochat frame (the frame codec in `quicnprotochat-core` prepends the 4-byte length).
///
/// # Errors
///
/// Returns [`capnp::Error`] if the underlying allocator fails (out of memory).
/// This is not expected under normal operation.
pub fn build_envelope(env: &ParsedEnvelope) -> Result<Vec<u8>, capnp::Error> {
use capnp::message;
let mut message = message::Builder::new_default();
{
let mut root = message.init_root::<envelope_capnp::envelope::Builder>();
root.set_msg_type(env.msg_type);
root.set_group_id(&env.group_id);
root.set_sender_id(&env.sender_id);
root.set_payload(&env.payload);
root.set_timestamp_ms(env.timestamp_ms);
}
to_bytes(&message)
}
/// Deserialise unpacked Cap'n Proto wire bytes into a [`ParsedEnvelope`].
///
/// All data is copied out of the Cap'n Proto reader before returning, so the
/// input slice is not retained.
///
/// # Errors
///
/// - [`capnp::Error`] if the bytes are not valid Cap'n Proto wire format.
/// - [`capnp::Error`] if `msgType` contains a discriminant not present in the
/// current schema (forward-compatibility guard).
pub fn parse_envelope(bytes: &[u8]) -> Result<ParsedEnvelope, capnp::Error> {
let reader = from_bytes(bytes)?;
let root = reader.get_root::<envelope_capnp::envelope::Reader>()?;
let msg_type = root.get_msg_type().map_err(|nis| {
capnp::Error::failed(format!(
"Envelope.msgType contains unknown discriminant: {nis}"
))
})?;
Ok(ParsedEnvelope {
msg_type,
group_id: root.get_group_id()?.to_vec(),
sender_id: root.get_sender_id()?.to_vec(),
payload: root.get_payload()?.to_vec(),
timestamp_ms: root.get_timestamp_ms(),
})
}
// ── Low-level byte ↔ message conversions ──────────────────────────────────────
/// Serialise a Cap'n Proto message builder to unpacked wire bytes.
@@ -162,57 +60,3 @@ pub fn from_bytes(
let mut cursor = std::io::Cursor::new(bytes);
capnp::serialize::read_message(&mut cursor, capnp::message::ReaderOptions::new())
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
/// Round-trip a Ping envelope through build → parse and verify all fields.
#[test]
fn ping_round_trip() {
let original = ParsedEnvelope {
msg_type: MsgType::Ping,
group_id: vec![],
sender_id: vec![0xAB; 32],
payload: vec![],
timestamp_ms: 1_700_000_000_000,
};
let bytes = build_envelope(&original).expect("build_envelope failed");
let parsed = parse_envelope(&bytes).expect("parse_envelope failed");
assert!(matches!(parsed.msg_type, MsgType::Ping));
assert_eq!(parsed.group_id, original.group_id);
assert_eq!(parsed.sender_id, original.sender_id);
assert_eq!(parsed.payload, original.payload);
assert_eq!(parsed.timestamp_ms, original.timestamp_ms);
}
/// Round-trip a Pong envelope.
#[test]
fn pong_round_trip() {
let original = ParsedEnvelope {
msg_type: MsgType::Pong,
group_id: vec![],
sender_id: vec![0xCD; 32],
payload: vec![],
timestamp_ms: 1_700_000_001_000,
};
let bytes = build_envelope(&original).expect("build_envelope failed");
let parsed = parse_envelope(&bytes).expect("parse_envelope failed");
assert!(matches!(parsed.msg_type, MsgType::Pong));
assert_eq!(parsed.sender_id, original.sender_id);
assert_eq!(parsed.timestamp_ms, original.timestamp_ms);
}
/// Corrupted bytes must produce an error, not a panic.
#[test]
fn corrupted_bytes_error() {
let result = parse_envelope(&[0xFF, 0xFF, 0xFF, 0xFF]);
assert!(result.is_err(), "expected error for corrupted input");
}
}