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:
@@ -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(®_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(®_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 Alice↔Bob 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 Alice→Bob")?;
|
||||
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 Bob→Alice")?;
|
||||
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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user