fix: security hardening — 40 findings from full codebase review

Full codebase review by 4 independent agents (security, architecture,
code quality, correctness) identified ~80 findings. This commit fixes 40
of them across all workspace crates.

Critical fixes:
- Federation service: validate origin against mTLS cert CN/SAN (C1)
- WS bridge: add DM channel auth, size limits, rate limiting (C2)
- hpke_seal: panic on error instead of silent empty ciphertext (C3)
- hpke_setup_sender_and_export: error on parse fail, no PQ downgrade (C7)

Security fixes:
- Zeroize: seed_bytes() returns Zeroizing<[u8;32]>, private_to_bytes()
  returns Zeroizing<Vec<u8>>, ClientAuth.access_token, SessionState.password,
  conversation hex_key all wrapped in Zeroizing
- Keystore: 0o600 file permissions on Unix
- MeshIdentity: 0o600 file permissions on Unix
- Timing floors: resolveIdentity + WS bridge resolve_user get 5ms floor
- Mobile: TLS verification gated behind insecure-dev feature flag
- Proto: from_bytes default limit tightened from 64 MiB to 8 MiB

Correctness fixes:
- fetch_wait: register waiter before fetch to close TOCTOU window
- MeshEnvelope: exclude hop_count from signature (forwarding no longer
  invalidates sender signature)
- BroadcastChannel: encrypt returns Result instead of panicking
- transcript: rename verify_transcript_chain → validate_transcript_structure
- group.rs: extract shared process_incoming() for receive_message variants
- auth_ops: remove spurious RegistrationRequest deserialization
- MeshStore.seen: bounded to 100K with FIFO eviction

Quality fixes:
- FFI error classification: typed downcast instead of string matching
- Plugin HookVTable: SAFETY documentation for unsafe Send+Sync
- clippy::unwrap_used: warn → deny workspace-wide
- Various .unwrap_or("") → proper error returns

Review report: docs/REVIEW-2026-03-04.md
152 tests passing (72 core + 35 server + 14 E2E + 1 doctest + 30 P2P)
This commit is contained in:
2026-03-04 07:52:12 +01:00
parent 4694a3098b
commit 394199b19b
58 changed files with 3893 additions and 414 deletions

View File

@@ -393,6 +393,34 @@ enum Command {
#[arg(long)]
input: PathBuf,
},
/// Execute a YAML playbook (scripted command sequence) and exit.
/// Requires `--features playbook`.
#[cfg(feature = "playbook")]
Run {
/// Path to the YAML playbook file.
playbook: PathBuf,
/// State file path (identity + MLS state).
#[arg(long, default_value = "qpq-state.bin", env = "QPQ_STATE")]
state: PathBuf,
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// OPAQUE username for automatic login.
#[arg(long, env = "QPQ_USERNAME")]
username: Option<String>,
/// OPAQUE password.
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
/// Override playbook variables: KEY=VALUE (repeatable).
#[arg(long = "var", short = 'V')]
vars: Vec<String>,
},
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -410,6 +438,77 @@ fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf {
state
}
// ── Playbook execution ───────────────────────────────────────────────────────
#[cfg(feature = "playbook")]
async fn run_playbook(
playbook_path: &Path,
state: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
state_pw: Option<&str>,
username: Option<&str>,
password: Option<&str>,
access_token: &str,
device_id: Option<&str>,
extra_vars: &[String],
) -> anyhow::Result<()> {
use quicproquo_client::PlaybookRunner;
let insecure = std::env::var("QPQ_DANGER_ACCEPT_INVALID_CERTS").is_ok();
// Connect to server.
let client =
quicproquo_client::connect_node_opt(server, ca_cert, server_name, insecure)
.await
.context("connect to server")?;
// Build session state.
let mut session = quicproquo_client::client::session::SessionState::load(state, state_pw)
.context("load session state")?;
// If username/password provided, do OPAQUE login.
if let (Some(uname), Some(pw)) = (username, password) {
if let Err(e) =
quicproquo_client::opaque_login(&client, uname, pw, &session.identity.public_key_bytes()).await
{
eprintln!("OPAQUE login failed: {e:#}");
}
} else if !access_token.is_empty() {
let auth = ClientAuth::from_parts(access_token.to_string(), device_id.map(String::from));
init_auth(auth);
}
// Load playbook.
let mut runner = PlaybookRunner::from_file(playbook_path)
.with_context(|| format!("load playbook: {}", playbook_path.display()))?;
// Inject extra variables from --var KEY=VALUE flags.
for kv in extra_vars {
if let Some((k, v)) = kv.split_once('=') {
runner.set_var(k, v);
} else {
eprintln!("warning: ignoring malformed --var '{kv}' (expected KEY=VALUE)");
}
}
// Inject connection info as variables.
runner.set_var("_server", server);
if let Some(u) = username {
runner.set_var("_username", u);
}
let report = runner.run(&mut session, &client).await;
print!("{report}");
if report.all_passed() {
Ok(())
} else {
std::process::exit(1);
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
#[tokio::main]
@@ -736,5 +835,32 @@ async fn main() -> anyhow::Result<()> {
)
}
Command::ExportVerify { input } => cmd_export_verify(&input),
#[cfg(feature = "playbook")]
Command::Run {
playbook,
state,
server,
username,
password,
vars,
} => {
let state = derive_state_path(state, username.as_deref());
let local = tokio::task::LocalSet::new();
local
.run_until(run_playbook(
&playbook,
&state,
&server,
&args.ca_cert,
&args.server_name,
state_pw,
username.as_deref(),
password.as_deref(),
&args.access_token,
args.device_id.as_deref(),
&vars,
))
.await
}
}
}