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

@@ -36,6 +36,8 @@ thiserror = { workspace = true }
# Crypto — for fingerprint verification in fetch-key subcommand
sha2 = { workspace = true }
argon2 = { workspace = true }
chacha20poly1305 = { workspace = true }
quinn = { workspace = true }
quinn-proto = { workspace = true }
rustls = { workspace = true }

View File

@@ -4,7 +4,13 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use anyhow::Context;
use argon2::Argon2;
use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem};
use chacha20poly1305::{
aead::{Aead, KeyInit},
ChaCha20Poly1305, Key, Nonce,
};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
@@ -13,12 +19,21 @@ use quinn_proto::crypto::rustls::QuicClientConfig;
use rustls::pki_types::CertificateDer;
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
};
use quicnprotochat_core::{
generate_key_package, hybrid_decrypt, hybrid_encrypt, DiskKeyStore, GroupMember,
HybridKeypair, HybridKeypairBytes, HybridPublicKey, IdentityKeypair,
generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite, DiskKeyStore,
GroupMember, HybridKeypair, HybridKeypairBytes, HybridPublicKey, IdentityKeypair,
};
use quicnprotochat_proto::node_capnp::{auth, node_service};
/// Magic bytes for encrypted client state files.
const STATE_MAGIC: &[u8; 4] = b"QPCE";
const STATE_SALT_LEN: usize = 16;
const STATE_NONCE_LEN: usize = 12;
// Global auth context initialized once per process.
static AUTH_CONTEXT: OnceLock<ClientAuth> = OnceLock::new();
@@ -49,7 +64,7 @@ pub fn init_auth(ctx: ClientAuth) {
let _ = AUTH_CONTEXT.set(ctx);
}
// ── Subcommand implementations ───────────────────────────────────────────────
// -- Subcommand implementations -----------------------------------------------
/// Connect to `server`, call health, and print RTT over QUIC/TLS.
pub async fn cmd_ping(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
@@ -72,6 +87,161 @@ pub async fn cmd_ping(server: &str, ca_cert: &Path, server_name: &str) -> anyhow
Ok(())
}
/// Register a new user account via the OPAQUE protocol.
///
/// The server never sees the password in plaintext.
pub async fn cmd_register_user(
server: &str,
ca_cert: &Path,
server_name: &str,
username: &str,
password: &str,
) -> anyhow::Result<()> {
let mut rng = rand::rngs::OsRng;
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 mut req = node_client.opaque_register_start_request();
{
let mut p = req.get();
p.set_username(username);
p.set_request(&reg_start.message.serialize());
}
let resp = req
.send()
.promise
.await
.context("opaque_register_start RPC failed")?;
let response_bytes = resp
.get()
.context("register_start: bad response")?
.get_response()
.context("register_start: missing response")?
.to_vec();
let reg_response = RegistrationResponse::<OpaqueSuite>::deserialize(&response_bytes)
.map_err(|e| anyhow::anyhow!("invalid registration response: {e}"))?;
// OPAQUE registration step 2: client finishes -> server.
let reg_finish = reg_start
.state
.finish(
&mut rng,
password.as_bytes(),
reg_response,
ClientRegistrationFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| anyhow::anyhow!("OPAQUE register finish: {e}"))?;
let mut req = node_client.opaque_register_finish_request();
{
let mut p = req.get();
p.set_username(username);
p.set_upload(&reg_finish.message.serialize());
// Identity-token binding: pass empty bytes (no state file available).
p.set_identity_key(&[]);
}
let resp = req
.send()
.promise
.await
.context("opaque_register_finish RPC failed")?;
let success = resp
.get()
.context("register_finish: bad response")?
.get_success();
anyhow::ensure!(success, "server rejected registration");
println!("user '{username}' registered successfully (OPAQUE)");
Ok(())
}
/// Log in via the OPAQUE protocol and receive a session token.
///
/// Returns the session token as a hex string. Use it as `--access-token` for
/// subsequent commands.
pub async fn cmd_login(
server: &str,
ca_cert: &Path,
server_name: &str,
username: &str,
password: &str,
) -> anyhow::Result<()> {
let mut rng = rand::rngs::OsRng;
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 mut req = node_client.opaque_login_start_request();
{
let mut p = req.get();
p.set_username(username);
p.set_request(&login_start.message.serialize());
}
let resp = req
.send()
.promise
.await
.context("opaque_login_start RPC failed")?;
let response_bytes = resp
.get()
.context("login_start: bad response")?
.get_response()
.context("login_start: missing response")?
.to_vec();
let credential_response = CredentialResponse::<OpaqueSuite>::deserialize(&response_bytes)
.map_err(|e| anyhow::anyhow!("invalid credential response: {e}"))?;
// OPAQUE login step 2: client finishes -> server.
let login_finish = login_start
.state
.finish(
&mut rng,
password.as_bytes(),
credential_response,
ClientLoginFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| anyhow::anyhow!("OPAQUE login finish (bad password?): {e}"))?;
let mut req = node_client.opaque_login_finish_request();
{
let mut p = req.get();
p.set_username(username);
p.set_finalization(&login_finish.message.serialize());
// Identity-token binding: pass empty bytes (no state file available).
p.set_identity_key(&[]);
}
let resp = req
.send()
.promise
.await
.context("opaque_login_finish RPC failed")?;
let session_token = resp
.get()
.context("login_finish: bad response")?
.get_session_token()
.context("login_finish: missing session_token")?
.to_vec();
anyhow::ensure!(!session_token.is_empty(), "server returned empty session token");
println!("login successful for '{username}'");
println!("session_token: {}", hex::encode(&session_token));
println!("(use as --access-token for subsequent commands)");
Ok(())
}
/// Generate a KeyPackage for a fresh identity and upload it to the AS.
///
/// Must run on a `LocalSet` because capnp-rpc is `!Send`.
@@ -128,8 +298,9 @@ pub async fn cmd_register_state(
server: &str,
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path)?;
let state = load_or_init_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let tls_bytes = member
@@ -181,7 +352,7 @@ pub async fn cmd_register_state(
println!("fingerprint : {}", hex::encode(&fingerprint));
println!("KeyPackage uploaded successfully.");
save_state(state_path, &member, hybrid_kp.as_ref())?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
Ok(())
}
@@ -241,7 +412,7 @@ pub async fn cmd_fetch_key(
Ok(())
}
/// Run a complete AliceBob MLS round-trip using the unified server endpoint.
/// Run a complete Alice/Bob MLS round-trip using the unified server endpoint.
///
/// All payloads are wrapped in post-quantum hybrid envelopes (X25519 + ML-KEM-768).
pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
@@ -321,12 +492,12 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
bob.join_group(&welcome_bytes)
.context("Bob join_group failed")?;
// Alice Bob (hybrid-wrapped)
// Alice -> Bob (hybrid-wrapped)
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 AliceBob")?;
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?;
@@ -338,11 +509,11 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.receive_message(&inner_ab)?
.context("Bob expected application message from Alice")?;
println!(
"Alice Bob plaintext: {}",
"Alice -> Bob plaintext: {}",
String::from_utf8_lossy(&ab_plaintext)
);
// Bob Alice (hybrid-wrapped)
// Bob -> Alice (hybrid-wrapped)
let alice_hybrid_pk = fetch_hybrid_key(&bob_node, &alice_id.public_key_bytes())
.await?
.context("Alice hybrid key not found")?;
@@ -350,7 +521,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.send_message(b"hello alice")
.context("Bob send_message failed")?;
let wrapped_ba =
hybrid_encrypt(&alice_hybrid_pk, &ct_ba).context("hybrid encrypt BobAlice")?;
hybrid_encrypt(&alice_hybrid_pk, &ct_ba).context("hybrid encrypt Bob->Alice")?;
enqueue(&bob_ds, &alice_id.public_key_bytes(), &wrapped_ba).await?;
let alice_msgs = fetch_all(&alice_ds, &alice_id.public_key_bytes()).await?;
@@ -363,7 +534,7 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
.receive_message(&inner_ba)?
.context("Alice expected application message from Bob")?;
println!(
"Bob Alice plaintext: {}",
"Bob -> Alice plaintext: {}",
String::from_utf8_lossy(&ba_plaintext)
);
@@ -377,8 +548,9 @@ pub async fn cmd_create_group(
state_path: &Path,
_server: &str,
group_id: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path)?;
let state = load_or_init_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
anyhow::ensure!(
@@ -390,7 +562,7 @@ pub async fn cmd_create_group(
.create_group(group_id.as_bytes())
.context("create_group failed")?;
save_state(state_path, &member, hybrid_kp.as_ref())?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!("group created: {group_id}");
Ok(())
}
@@ -405,8 +577,9 @@ pub async fn cmd_invite(
ca_cert: &Path,
server_name: &str,
peer_key_hex: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path)?;
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let peer_key = decode_identity_key(peer_key_hex)?;
@@ -421,7 +594,30 @@ pub async fn cmd_invite(
.group_ref()
.context("no active group; run create-group first")?;
let (_, welcome) = member.add_member(&peer_kp).context("add_member failed")?;
// Collect existing member identity keys *before* adding the new member,
// so we know who to fan-out the commit to.
let existing_members: Vec<Vec<u8>> = member
.member_identities()
.into_iter()
.filter(|k| k.as_slice() != member.identity().public_key_bytes())
.collect();
let (commit, welcome) = member.add_member(&peer_kp).context("add_member failed")?;
// Fan out the Commit to all existing members (excluding self and the
// new joiner who receives the Welcome instead). Fix 14.
for mk in &existing_members {
if mk.as_slice() == peer_key.as_slice() {
continue;
}
let peer_hpk = fetch_hybrid_key(&node_client, mk).await?;
let commit_payload = if let Some(ref pk) = peer_hpk {
hybrid_encrypt(pk, &commit).context("hybrid encrypt commit")?
} else {
commit.clone()
};
enqueue(&node_client, mk, &commit_payload).await?;
}
// Wrap welcome in hybrid envelope if peer has a hybrid public key.
let peer_hybrid_pk = fetch_hybrid_key(&node_client, &peer_key).await?;
@@ -433,10 +629,11 @@ pub async fn cmd_invite(
enqueue(&node_client, &peer_key, &payload).await?;
save_state(state_path, &member, hybrid_kp.as_ref())?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!(
"invited peer (welcome queued{})",
if peer_hybrid_pk.is_some() { ", hybrid-encrypted" } else { "" }
"invited peer (welcome queued{}, commit sent to {} existing member(s))",
if peer_hybrid_pk.is_some() { ", hybrid-encrypted" } else { "" },
existing_members.len(),
);
Ok(())
}
@@ -449,8 +646,9 @@ pub async fn cmd_join(
server: &str,
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path)?;
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
anyhow::ensure!(
@@ -472,7 +670,7 @@ pub async fn cmd_join(
.join_group(&welcome_bytes)
.context("join_group failed")?;
save_state(state_path, &member, hybrid_kp.as_ref())?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!("joined group successfully");
Ok(())
}
@@ -488,8 +686,9 @@ pub async fn cmd_send(
server_name: &str,
peer_key_hex: &str,
msg: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path)?;
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let peer_key = decode_identity_key(peer_key_hex)?;
@@ -509,7 +708,7 @@ pub async fn cmd_send(
enqueue(&node_client, &peer_key, &payload).await?;
save_state(state_path, &member, hybrid_kp.as_ref())?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!(
"message sent{}",
if peer_hybrid_pk.is_some() { " (hybrid-encrypted)" } else { "" }
@@ -527,8 +726,9 @@ pub async fn cmd_recv(
server_name: &str,
wait_ms: u64,
stream: bool,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path)?;
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let client = connect_node(server, ca_cert, server_name).await?;
@@ -555,7 +755,7 @@ pub async fn cmd_recv(
}
}
save_state(state_path, &member, hybrid_kp.as_ref())?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
if !stream {
return Ok(());
@@ -563,7 +763,7 @@ pub async fn cmd_recv(
}
}
// ── Shared helpers ───────────────────────────────────────────────────────────
// -- Shared helpers -----------------------------------------------------------
/// Establish a QUIC/TLS connection and return a `NodeService` client.
///
@@ -583,9 +783,10 @@ pub async fn connect_node(
.add(CertificateDer::from(cert_bytes))
.context("add root cert")?;
let tls = RustlsClientConfig::builder()
let mut tls = RustlsClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
tls.alpn_protocols = vec![b"capnp".to_vec()];
let crypto = QuicClientConfig::try_from(tls)
.map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?;
@@ -709,6 +910,7 @@ 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)
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
}
@@ -742,6 +944,7 @@ 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)
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
}
@@ -777,6 +980,8 @@ pub async fn upload_hybrid_key(
let mut p = req.get();
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);
}
req.send()
.promise
@@ -793,7 +998,12 @@ pub async fn fetch_hybrid_key(
identity_key: &[u8],
) -> anyhow::Result<Option<HybridPublicKey>> {
let mut req = client.fetch_hybrid_key_request();
req.get().set_identity_key(identity_key);
{
let mut p = req.get();
p.set_identity_key(identity_key);
let mut auth = p.reborrow().init_auth();
set_auth(&mut auth);
}
let resp = req
.send()
@@ -848,6 +1058,9 @@ struct StoredState {
/// Post-quantum hybrid keypair (X25519 + ML-KEM-768). `None` for legacy state files.
#[serde(default)]
hybrid_key: Option<HybridKeypairBytes>,
/// Cached member public keys for group participants (Fix 14 prep).
#[serde(default)]
member_keys: Vec<Vec<u8>>,
}
impl StoredState {
@@ -881,17 +1094,82 @@ impl StoredState {
identity_seed: member.identity_seed(),
group,
hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()),
member_keys: Vec::new(),
})
}
}
fn load_or_init_state(path: &Path) -> anyhow::Result<StoredState> {
// -- Encrypted state file helpers ---------------------------------------------
/// Derive a 32-byte key from a password and salt using Argon2id.
fn derive_state_key(password: &str, salt: &[u8]) -> anyhow::Result<[u8; 32]> {
let mut key = [0u8; 32];
Argon2::default()
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| anyhow::anyhow!("argon2 key derivation failed: {e}"))?;
Ok(key)
}
/// Encrypt `plaintext` with the QPCE format: magic(4) | salt(16) | nonce(12) | ciphertext.
fn encrypt_state(password: &str, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut salt = [0u8; STATE_SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; STATE_NONCE_LEN];
rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
let key = derive_state_key(password, &salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext)
.map_err(|e| anyhow::anyhow!("state encryption failed: {e}"))?;
let mut out = Vec::with_capacity(4 + STATE_SALT_LEN + STATE_NONCE_LEN + ciphertext.len());
out.extend_from_slice(STATE_MAGIC);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(out)
}
/// Decrypt a QPCE-formatted state file. Caller must verify magic prefix beforehand.
fn decrypt_state(password: &str, data: &[u8]) -> anyhow::Result<Vec<u8>> {
let header_len = 4 + STATE_SALT_LEN + STATE_NONCE_LEN;
anyhow::ensure!(
data.len() > header_len,
"encrypted state file too short ({} bytes)",
data.len()
);
let salt = &data[4..4 + STATE_SALT_LEN];
let nonce_bytes = &data[4 + STATE_SALT_LEN..header_len];
let ciphertext = &data[header_len..];
let key = derive_state_key(password, salt)?;
let cipher = ChaCha20Poly1305::new(Key::from_slice(&key));
let nonce = Nonce::from_slice(nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|_| anyhow::anyhow!("state decryption failed (wrong password?)"))?;
Ok(plaintext)
}
/// Returns true if raw bytes begin with the QPCE magic header.
fn is_encrypted_state(bytes: &[u8]) -> bool {
bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC
}
fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result<StoredState> {
if path.exists() {
let mut state = load_existing_state(path)?;
let mut state = load_existing_state(path, password)?;
// Upgrade legacy state files: generate hybrid keypair if missing.
if state.hybrid_key.is_none() {
state.hybrid_key = Some(HybridKeypair::generate().to_bytes());
write_state(path, &state)?;
write_state(path, &state, password)?;
}
return Ok(state);
}
@@ -901,29 +1179,46 @@ fn load_or_init_state(path: &Path) -> anyhow::Result<StoredState> {
let key_store = DiskKeyStore::persistent(keystore_path(path))?;
let member = GroupMember::new_with_state(Arc::new(identity), key_store, None);
let state = StoredState::from_parts(&member, Some(&hybrid_kp))?;
write_state(path, &state)?;
write_state(path, &state, password)?;
Ok(state)
}
fn load_existing_state(path: &Path) -> anyhow::Result<StoredState> {
fn load_existing_state(path: &Path, password: Option<&str>) -> anyhow::Result<StoredState> {
let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?;
bincode::deserialize(&bytes).context("decode state")
if is_encrypted_state(&bytes) {
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 {
bincode::deserialize(&bytes).context("decode state")
}
}
fn save_state(
path: &Path,
member: &GroupMember,
hybrid_kp: Option<&HybridKeypair>,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = StoredState::from_parts(member, hybrid_kp)?;
write_state(path, &state)
write_state(path, &state, password)
}
fn write_state(path: &Path, state: &StoredState) -> anyhow::Result<()> {
fn write_state(path: &Path, state: &StoredState, password: Option<&str>) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?;
}
let bytes = bincode::serialize(state).context("encode state")?;
let plaintext = bincode::serialize(state).context("encode state")?;
let bytes = if let Some(pw) = password {
encrypt_state(pw, &plaintext)?
} else {
plaintext
};
std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?;
Ok(())
}
@@ -950,7 +1245,7 @@ fn current_timestamp_ms() -> u64 {
.as_millis() as u64
}
// ── Hex encoding helper ─────────────────────────────────────────────────────
// -- Hex encoding helper ------------------------------------------------------
//
// We use a tiny inline module rather than adding `hex` as a dependency.

View File

@@ -5,8 +5,9 @@ use std::path::PathBuf;
use clap::{Parser, Subcommand};
use quicnprotochat_client::{
cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_invite, cmd_join, cmd_ping, cmd_recv,
cmd_register, cmd_register_state, cmd_send, ClientAuth, init_auth,
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,
};
// ── CLI ───────────────────────────────────────────────────────────────────────
@@ -32,20 +33,48 @@ struct Args {
)]
server_name: String,
/// Bearer token for authenticated requests (version 1, required).
#[arg(long, global = true, env = "QUICNPROTOCHAT_ACCESS_TOKEN", required = true)]
/// 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 = "")]
access_token: String,
/// Optional device identifier (UUID bytes encoded as hex or raw string).
#[arg(long, global = true, env = "QUICNPROTOCHAT_DEVICE_ID")]
device_id: Option<String>,
/// Password to encrypt/decrypt client state files (QPCE format).
/// If set, state files are encrypted at rest with Argon2id + ChaCha20Poly1305.
#[arg(long, global = true, env = "QUICNPROTOCHAT_STATE_PASSWORD")]
state_password: Option<String>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Register a new user via OPAQUE (password never leaves the client).
RegisterUser {
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
server: String,
/// Username for the new account.
#[arg(long)]
username: String,
/// Password (will be used in OPAQUE PAKE; server never sees it).
#[arg(long)]
password: String,
},
/// Log in via OPAQUE and receive a session token.
Login {
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
server: String,
#[arg(long)]
username: String,
#[arg(long)]
password: String,
},
/// Send a Ping to the server and print the round-trip time.
Ping {
/// Server address (host:port).
@@ -54,9 +83,6 @@ enum Command {
},
/// Generate a fresh MLS KeyPackage and upload it to the Authentication Service.
///
/// Prints the SHA-256 fingerprint of the uploaded package and the raw
/// Ed25519 identity public key bytes (hex), which peers need to fetch it.
Register {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
@@ -64,9 +90,6 @@ enum Command {
},
/// Fetch a peer's KeyPackage from the Authentication Service.
///
/// IDENTITY_KEY is the peer's Ed25519 public key encoded as 64 lowercase
/// hex characters (32 bytes).
FetchKey {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
@@ -76,7 +99,7 @@ enum Command {
identity_key: String,
},
/// Run a full AliceBob MLS round-trip against live AS and DS endpoints.
/// Run a full Alice/Bob MLS round-trip against live AS and DS endpoints.
DemoGroup {
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
@@ -140,7 +163,7 @@ enum Command {
env = "QUICNPROTOCHAT_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
server: String,
},
@@ -152,7 +175,7 @@ enum Command {
env = "QUICNPROTOCHAT_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
server: String,
/// Recipient identity key (hex, 32 bytes -> 64 chars).
#[arg(long)]
@@ -170,7 +193,7 @@ enum Command {
env = "QUICNPROTOCHAT_STATE"
)]
state: PathBuf,
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
server: String,
/// Wait for up to this many milliseconds if no messages are queued.
@@ -196,11 +219,45 @@ async fn main() -> anyhow::Result<()> {
let args = Args::parse();
// Initialize auth context once for all RPCs.
// Initialize auth context once for all RPCs (empty token OK for register-user/login).
let auth_ctx = ClientAuth::from_parts(args.access_token.clone(), args.device_id.clone());
init_auth(auth_ctx);
let state_pw = args.state_password.as_deref();
match args.command {
Command::RegisterUser {
server,
username,
password,
} => {
let local = tokio::task::LocalSet::new();
local
.run_until(cmd_register_user(
&server,
&args.ca_cert,
&args.server_name,
&username,
&password,
))
.await
}
Command::Login {
server,
username,
password,
} => {
let local = tokio::task::LocalSet::new();
local
.run_until(cmd_login(
&server,
&args.ca_cert,
&args.server_name,
&username,
&password,
))
.await
}
Command::Ping { server } => cmd_ping(&server, &args.ca_cert, &args.server_name).await,
Command::Register { server } => {
let local = tokio::task::LocalSet::new();
@@ -236,6 +293,7 @@ async fn main() -> anyhow::Result<()> {
&server,
&args.ca_cert,
&args.server_name,
state_pw,
))
.await
}
@@ -246,7 +304,7 @@ async fn main() -> anyhow::Result<()> {
} => {
let local = tokio::task::LocalSet::new();
local
.run_until(cmd_create_group(&state, &server, &group_id))
.run_until(cmd_create_group(&state, &server, &group_id, state_pw))
.await
}
Command::Invite {
@@ -262,13 +320,14 @@ async fn main() -> anyhow::Result<()> {
&args.ca_cert,
&args.server_name,
&peer_key,
state_pw,
))
.await
}
Command::Join { state, server } => {
let local = tokio::task::LocalSet::new();
local
.run_until(cmd_join(&state, &server, &args.ca_cert, &args.server_name))
.run_until(cmd_join(&state, &server, &args.ca_cert, &args.server_name, state_pw))
.await
}
Command::Send {
@@ -286,6 +345,7 @@ async fn main() -> anyhow::Result<()> {
&args.server_name,
&peer_key,
&msg,
state_pw,
))
.await
}
@@ -304,8 +364,9 @@ async fn main() -> anyhow::Result<()> {
&args.server_name,
wait_ms,
stream,
state_pw,
))
.await
}
}
}
}

View File

@@ -93,6 +93,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
&server,
&ca_cert,
"localhost",
None,
))
.await?;
@@ -102,6 +103,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
&server,
&ca_cert,
"localhost",
None,
))
.await?;
@@ -110,6 +112,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
&alice_state,
&server,
"test-group",
None,
))
.await?;
@@ -126,6 +129,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
&ca_cert,
"localhost",
&bob_pk_hex,
None,
))
.await?;
@@ -135,6 +139,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
&server,
&ca_cert,
"localhost",
None,
))
.await?;
@@ -147,6 +152,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
"localhost",
&bob_pk_hex,
"hello bob",
None,
))
.await?;