feat: add protocol comparison docs, P2P crate, production audit, and design fixes

Add comprehensive documentation comparing quicnprotochat against classical
chat protocols (IRC+SSL, XMPP, Telegram) with diagrams and attack scenarios.
Promote comparison pages to top-level sidebar section. Include P2P transport
crate (iroh), production readiness audit, CI workflows, dependency policy,
and continued architecture improvements across all crates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 12:15:44 +01:00
parent 0bdc222724
commit 00b0aa92a1
28 changed files with 1566 additions and 340 deletions

View File

@@ -46,7 +46,7 @@ pub struct ClientAuth {
impl ClientAuth {
/// Build a client auth context from optional token and device id.
/// Requires a non-empty token; we run version=1 only (no legacy mode).
/// Requires a non-empty token (auth version 1).
pub fn from_parts(access_token: String, device_id: Option<String>) -> Self {
let token = access_token.into_bytes();
let device = device_id.unwrap_or_default().into_bytes();
@@ -102,9 +102,8 @@ pub async fn cmd_register_user(
let node_client = connect_node(server, ca_cert, server_name).await?;
// OPAQUE registration step 1: client -> server.
let reg_start =
ClientRegistration::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE register start: {e}"))?;
let reg_start = ClientRegistration::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE register start: {e}"))?;
let mut req = node_client.opaque_register_start_request();
{
@@ -178,9 +177,8 @@ pub async fn cmd_login(
let node_client = connect_node(server, ca_cert, server_name).await?;
// OPAQUE login step 1: client -> server.
let login_start =
ClientLogin::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE login start: {e}"))?;
let login_start = ClientLogin::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE login start: {e}"))?;
let mut req = node_client.opaque_login_start_request();
{
@@ -234,7 +232,10 @@ pub async fn cmd_login(
.context("login_finish: missing session_token")?
.to_vec();
anyhow::ensure!(!session_token.is_empty(), "server returned empty session token");
anyhow::ensure!(
!session_token.is_empty(),
"server returned empty session token"
);
println!("login successful for '{username}'");
println!("session_token: {}", hex::encode(&session_token));
@@ -259,7 +260,7 @@ pub async fn cmd_register(server: &str, ca_cert: &Path, server_name: &str) -> an
p.set_identity_key(&identity.public_key_bytes());
p.set_package(&tls_bytes);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let response = req
@@ -316,7 +317,7 @@ pub async fn cmd_register_state(
p.set_identity_key(&member.identity().public_key_bytes());
p.set_package(&tls_bytes);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let response = req
@@ -381,7 +382,7 @@ pub async fn cmd_fetch_key(
let mut p = req.get();
p.set_identity_key(&identity_key);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let response = req
@@ -487,8 +488,8 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.context("Welcome was not delivered to Bob via DS")?;
// Bob unwraps the hybrid envelope and joins the group.
let welcome_bytes = hybrid_decrypt(&bob_hybrid, &raw_welcome)
.context("Bob: hybrid decrypt welcome failed")?;
let welcome_bytes =
hybrid_decrypt(&bob_hybrid, &raw_welcome).context("Bob: hybrid decrypt welcome failed")?;
bob.join_group(&welcome_bytes)
.context("Bob join_group failed")?;
@@ -496,8 +497,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
let ct_ab = alice
.send_message(b"hello bob")
.context("Alice send_message failed")?;
let wrapped_ab =
hybrid_encrypt(&bob_hybrid_pk, &ct_ab).context("hybrid encrypt Alice->Bob")?;
let wrapped_ab = hybrid_encrypt(&bob_hybrid_pk, &ct_ab).context("hybrid encrypt Alice->Bob")?;
enqueue(&alice_ds, &bob_id.public_key_bytes(), &wrapped_ab).await?;
let bob_msgs = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?;
@@ -528,8 +528,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
let raw_ba = alice_msgs
.first()
.context("Alice: missing Bob ciphertext from DS")?;
let inner_ba =
hybrid_decrypt(&alice_hybrid, raw_ba).context("Alice: hybrid decrypt failed")?;
let inner_ba = hybrid_decrypt(&alice_hybrid, raw_ba).context("Alice: hybrid decrypt failed")?;
let ba_plaintext = alice
.receive_message(&inner_ba)?
.context("Alice expected application message from Bob")?;
@@ -632,7 +631,11 @@ pub async fn cmd_invite(
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!(
"invited peer (welcome queued{}, commit sent to {} existing member(s))",
if peer_hybrid_pk.is_some() { ", hybrid-encrypted" } else { "" },
if peer_hybrid_pk.is_some() {
", hybrid-encrypted"
} else {
""
},
existing_members.len(),
);
Ok(())
@@ -663,8 +666,8 @@ pub async fn cmd_join(
.cloned()
.context("no Welcome found in DS for this identity")?;
// Try hybrid decryption first, fall back to raw MLS welcome.
let welcome_bytes = try_hybrid_unwrap(hybrid_kp.as_ref(), &raw_welcome);
let welcome_bytes = try_hybrid_decrypt(hybrid_kp.as_ref(), &raw_welcome)
.context("decrypt Welcome (hybrid required)")?;
member
.join_group(&welcome_bytes)
@@ -711,7 +714,11 @@ pub async fn cmd_send(
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!(
"message sent{}",
if peer_hybrid_pk.is_some() { " (hybrid-encrypted)" } else { "" }
if peer_hybrid_pk.is_some() {
" (hybrid-encrypted)"
} else {
""
}
);
Ok(())
}
@@ -745,8 +752,13 @@ pub async fn cmd_recv(
}
for (idx, payload) in payloads.iter().enumerate() {
// Try hybrid decryption, fall back to raw MLS payload.
let mls_payload = try_hybrid_unwrap(hybrid_kp.as_ref(), payload);
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
Err(e) => {
println!("[{idx}] decrypt error: {e}");
continue;
}
};
match member.receive_message(&mls_payload) {
Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
@@ -791,7 +803,8 @@ pub async fn connect_node(
let crypto = QuicClientConfig::try_from(tls)
.map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?;
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
let bind_addr: SocketAddr = "0.0.0.0:0".parse().context("parse client bind address")?;
let mut endpoint = Endpoint::client(bind_addr)?;
endpoint.set_default_client_config(ClientConfig::new(Arc::new(crypto)));
let connection = endpoint
@@ -829,7 +842,7 @@ pub async fn upload_key_package(
p.set_identity_key(identity_key);
p.set_package(package);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let resp = req
@@ -860,7 +873,7 @@ pub async fn fetch_key_package(
let mut p = req.get();
p.set_identity_key(identity_key);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let resp = req
@@ -893,7 +906,7 @@ pub async fn enqueue(
p.set_channel_id(&[]);
p.set_version(1);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
req.send().promise.await.context("enqueue RPC failed")?;
Ok(())
@@ -910,9 +923,9 @@ pub async fn fetch_all(
p.set_recipient_key(recipient_key);
p.set_channel_id(&[]);
p.set_version(1);
p.set_limit(0); // fetch all (backward compat)
p.set_limit(0); // fetch all
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let resp = req.send().promise.await.context("fetch RPC failed")?;
@@ -944,9 +957,9 @@ pub async fn fetch_wait(
p.set_timeout_ms(timeout_ms);
p.set_channel_id(&[]);
p.set_version(1);
p.set_limit(0); // fetch all (backward compat)
p.set_limit(0); // fetch all
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let resp = req.send().promise.await.context("fetch_wait RPC failed")?;
@@ -981,7 +994,7 @@ pub async fn upload_hybrid_key(
p.set_identity_key(identity_key);
p.set_hybrid_public_key(&hybrid_pk.to_bytes());
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
req.send()
.promise
@@ -1002,7 +1015,7 @@ pub async fn fetch_hybrid_key(
let mut p = req.get();
p.set_identity_key(identity_key);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
set_auth(&mut auth)?;
}
let resp = req
@@ -1026,15 +1039,13 @@ pub async fn fetch_hybrid_key(
Ok(Some(pk))
}
/// Try to decrypt a hybrid envelope. If the payload is not a hybrid envelope or
/// decryption fails, return the original bytes unchanged (legacy plaintext MLS).
fn try_hybrid_unwrap(hybrid_kp: Option<&HybridKeypair>, payload: &[u8]) -> Vec<u8> {
if let Some(kp) = hybrid_kp {
if let Ok(inner) = hybrid_decrypt(kp, payload) {
return inner;
}
}
payload.to_vec()
/// Decrypt a hybrid envelope. Requires a hybrid key; no fallback to plaintext MLS.
fn try_hybrid_decrypt(
hybrid_kp: Option<&HybridKeypair>,
payload: &[u8],
) -> anyhow::Result<Vec<u8>> {
let kp = hybrid_kp.ok_or_else(|| anyhow::anyhow!("hybrid key required for decryption"))?;
hybrid_decrypt(kp, payload).map_err(|e| anyhow::anyhow!("{e}"))
}
fn sha256(bytes: &[u8]) -> Vec<u8> {
@@ -1042,20 +1053,21 @@ fn sha256(bytes: &[u8]) -> Vec<u8> {
Sha256::digest(bytes).to_vec()
}
fn set_auth(auth: &mut auth::Builder<'_>) {
let ctx = AUTH_CONTEXT
.get()
.expect("init_auth must be called with a non-empty token before RPCs");
fn set_auth(auth: &mut auth::Builder<'_>) -> anyhow::Result<()> {
let ctx = AUTH_CONTEXT.get().ok_or_else(|| {
anyhow::anyhow!("init_auth must be called with a non-empty token before RPCs")
})?;
auth.set_version(ctx.version);
auth.set_access_token(&ctx.access_token);
auth.set_device_id(&ctx.device_id);
Ok(())
}
#[derive(Serialize, Deserialize)]
struct StoredState {
identity_seed: [u8; 32],
group: Option<Vec<u8>>,
/// Post-quantum hybrid keypair (X25519 + ML-KEM-768). `None` for legacy state files.
/// Post-quantum hybrid keypair (X25519 + ML-KEM-768). `None` for state created before hybrid was added; generated on load if missing.
#[serde(default)]
hybrid_key: Option<HybridKeypairBytes>,
/// Cached member public keys for group participants (Fix 14 prep).
@@ -1081,10 +1093,7 @@ impl StoredState {
Ok((member, hybrid_kp))
}
fn from_parts(
member: &GroupMember,
hybrid_kp: Option<&HybridKeypair>,
) -> anyhow::Result<Self> {
fn from_parts(member: &GroupMember, hybrid_kp: Option<&HybridKeypair>) -> anyhow::Result<Self> {
let group = member
.group_ref()
.map(|g| bincode::serialize(g).context("serialize group"))
@@ -1166,7 +1175,7 @@ fn is_encrypted_state(bytes: &[u8]) -> bool {
fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result<StoredState> {
if path.exists() {
let mut state = load_existing_state(path, password)?;
// Upgrade legacy state files: generate hybrid keypair if missing.
// Generate hybrid keypair if missing (upgrade from older state).
if state.hybrid_key.is_none() {
state.hybrid_key = Some(HybridKeypair::generate().to_bytes());
write_state(path, &state, password)?;
@@ -1187,9 +1196,8 @@ fn load_existing_state(path: &Path, password: Option<&str>) -> anyhow::Result<St
let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?;
if is_encrypted_state(&bytes) {
let pw = password.context(
"state file is encrypted (QPCE); a password is required to decrypt it",
)?;
let pw = password
.context("state file is encrypted (QPCE); a password is required to decrypt it")?;
let plaintext = decrypt_state(pw, &bytes)?;
bincode::deserialize(&plaintext).context("decode encrypted state")
} else {

View File

@@ -6,8 +6,7 @@ use clap::{Parser, Subcommand};
use quicnprotochat_client::{
cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_invite, cmd_join, cmd_login, cmd_ping,
cmd_recv, cmd_register, cmd_register_state, cmd_register_user, cmd_send, ClientAuth,
init_auth,
cmd_recv, cmd_register, cmd_register_state, cmd_register_user, cmd_send, init_auth, ClientAuth,
};
// ── CLI ───────────────────────────────────────────────────────────────────────
@@ -35,7 +34,12 @@ struct Args {
/// Bearer token or OPAQUE session token for authenticated requests.
/// Not required for register-user and login commands.
#[arg(long, global = true, env = "QUICNPROTOCHAT_ACCESS_TOKEN", default_value = "")]
#[arg(
long,
global = true,
env = "QUICNPROTOCHAT_ACCESS_TOKEN",
default_value = ""
)]
access_token: String,
/// Optional device identifier (UUID bytes encoded as hex or raw string).
@@ -327,7 +331,13 @@ async fn main() -> anyhow::Result<()> {
Command::Join { state, server } => {
let local = tokio::task::LocalSet::new();
local
.run_until(cmd_join(&state, &server, &args.ca_cert, &args.server_name, state_pw))
.run_until(cmd_join(
&state,
&server,
&args.ca_cert,
&args.server_name,
state_pw,
))
.await
}
Command::Send {

View File

@@ -1,3 +1,6 @@
// cargo_bin! only works for current package's binary; we spawn quicnprotochat-server from another package.
#![allow(deprecated)]
use std::{path::PathBuf, process::Command, time::Duration};
use assert_cmd::cargo::cargo_bin;
@@ -5,9 +8,14 @@ use portpicker::pick_unused_port;
use tempfile::TempDir;
use tokio::time::sleep;
// Required by rustls 0.23 when QUIC/TLS is used from this process (e.g. client in test).
fn ensure_rustls_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
use quicnprotochat_client::{
cmd_create_group, cmd_invite, cmd_join, cmd_ping, cmd_register_state, cmd_send, ClientAuth,
connect_node, fetch_wait, init_auth,
cmd_create_group, cmd_invite, cmd_join, cmd_ping, cmd_register_state, cmd_send, connect_node,
fetch_wait, init_auth, ClientAuth,
};
use quicnprotochat_core::IdentityKeypair;
@@ -39,6 +47,8 @@ async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) ->
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
ensure_rustls_provider();
let temp = TempDir::new()?;
let base = temp.path();
let port = pick_unused_port().expect("free port");
@@ -51,7 +61,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
// Spawn server binary.
let server_bin = cargo_bin("quicnprotochat-server");
let mut child = Command::new(server_bin)
let child = Command::new(server_bin)
.arg("--listen")
.arg(&listen)
.arg("--data-dir")
@@ -108,12 +118,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
.await?;
local
.run_until(cmd_create_group(
&alice_state,
&server,
"test-group",
None,
))
.run_until(cmd_create_group(&alice_state, &server, "test-group", None))
.await?;
// Load Bob identity key from persisted state to use as peer key.
@@ -134,13 +139,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
.await?;
local
.run_until(cmd_join(
&bob_state,
&server,
&ca_cert,
"localhost",
None,
))
.run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None))
.await?;
// Send Alice -> Bob.