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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user