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

@@ -37,13 +37,13 @@ use super::token_cache::{clear_cached_session, load_cached_session, save_cached_
// ── Input parsing ────────────────────────────────────────────────────────────
enum Input {
pub(crate) enum Input {
Slash(SlashCommand),
ChatMessage(String),
Empty,
}
enum SlashCommand {
pub(crate) enum SlashCommand {
Help,
Quit,
Whoami,
@@ -104,7 +104,7 @@ enum SlashCommand {
RevokeDevice { id_prefix: String },
}
fn parse_input(line: &str) -> Input {
pub(crate) fn parse_input(line: &str) -> Input {
let trimmed = line.trim();
if trimmed.is_empty() {
return Input::Empty;
@@ -246,7 +246,7 @@ fn parse_input(line: &str) -> Input {
"/react" => match arg {
Some(rest) => {
let mut parts = rest.splitn(2, ' ');
let emoji = parts.next().unwrap().to_string();
let emoji = parts.next().unwrap_or_default().to_string();
let index = parts.next().and_then(|s| s.trim().parse::<usize>().ok());
Input::Slash(SlashCommand::React { emoji, index })
}
@@ -258,7 +258,7 @@ fn parse_input(line: &str) -> Input {
"/edit" => match arg {
Some(rest) => {
let mut parts = rest.splitn(2, ' ');
let idx_str = parts.next().unwrap();
let idx_str = parts.next().unwrap_or_default();
match (idx_str.parse::<usize>(), parts.next()) {
(Ok(index), Some(new_text)) if !new_text.trim().is_empty() => {
Input::Slash(SlashCommand::Edit { index, new_text: new_text.trim().to_string() })
@@ -847,7 +847,7 @@ async fn handle_slash(
}
}
fn print_help() {
pub(crate) fn print_help() {
display::print_status("Commands:");
display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
display::print_status(" /create-group <name> - Create a new group");
@@ -925,7 +925,7 @@ fn format_ttl(secs: u32) -> String {
}
}
fn cmd_disappear(
pub(crate) fn cmd_disappear(
session: &mut SessionState,
arg: Option<&str>,
) -> anyhow::Result<()> {
@@ -966,7 +966,7 @@ fn cmd_disappear(
Ok(())
}
fn cmd_privacy(
pub(crate) fn cmd_privacy(
session: &mut SessionState,
arg: Option<&str>,
) -> anyhow::Result<()> {
@@ -1047,7 +1047,7 @@ fn cmd_privacy(
Ok(())
}
fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
@@ -1091,7 +1091,7 @@ fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_rotate_all_keys(
pub(crate) async fn cmd_rotate_all_keys(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1109,7 +1109,7 @@ async fn cmd_rotate_all_keys(
}
/// Discover nearby qpq servers via mDNS (requires `--features mesh` build).
fn cmd_mesh_peers() -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_peers() -> anyhow::Result<()> {
use super::mesh_discovery::MeshDiscovery;
match MeshDiscovery::start() {
@@ -1138,7 +1138,7 @@ fn cmd_mesh_peers() -> anyhow::Result<()> {
}
/// Send a direct P2P mesh message (stub — P2pNode not yet wired into session).
fn cmd_mesh_send(peer_id: &str, message: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_send(peer_id: &str, message: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("mesh send: would send to {peer_id}: {message}"));
@@ -1153,7 +1153,7 @@ fn cmd_mesh_send(peer_id: &str, message: &str) -> anyhow::Result<()> {
}
/// Broadcast an encrypted message on a topic (stub — P2pNode not yet wired into session).
fn cmd_mesh_broadcast(topic: &str, message: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_broadcast(topic: &str, message: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("mesh broadcast to {topic}: {message}"));
@@ -1168,7 +1168,7 @@ fn cmd_mesh_broadcast(topic: &str, message: &str) -> anyhow::Result<()> {
}
/// Subscribe to a broadcast topic (stub — P2pNode not yet wired into session).
fn cmd_mesh_subscribe(topic: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_subscribe(topic: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("subscribed to topic: {topic}"));
@@ -1183,7 +1183,7 @@ fn cmd_mesh_subscribe(topic: &str) -> anyhow::Result<()> {
}
/// Display known mesh peers and routes from the mesh identity file.
fn cmd_mesh_route(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_route(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
let mesh_state_path = session.state_path.with_extension("mesh.json");
@@ -1217,7 +1217,7 @@ fn cmd_mesh_route(session: &SessionState) -> anyhow::Result<()> {
}
/// Display mesh node identity information.
fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
let mesh_state_path = session.state_path.with_extension("mesh.json");
@@ -1239,7 +1239,7 @@ fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
}
/// Display mesh store-and-forward statistics.
fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
// Without a live P2pNode in the session, we can only report that the store
@@ -1256,7 +1256,7 @@ fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
display::print_status(&format!(
"identity: {}",
hex::encode(session.identity.public_key_bytes())
@@ -1272,7 +1272,7 @@ fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
fn cmd_list(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_list(session: &SessionState) -> anyhow::Result<()> {
let convs = session.conv_store.list_conversations()?;
if convs.is_empty() {
display::print_status("no conversations yet. Try /dm <username> or /create-group <name>");
@@ -1303,7 +1303,7 @@ fn cmd_list(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
fn cmd_switch(session: &mut SessionState, target: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_switch(session: &mut SessionState, target: &str) -> anyhow::Result<()> {
let target = target.trim();
let conv = if let Some(username) = target.strip_prefix('@') {
@@ -1330,7 +1330,7 @@ fn cmd_switch(session: &mut SessionState, target: &str) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_dm(
pub(crate) async fn cmd_dm(
session: &mut SessionState,
client: &node_service::Client,
username: &str,
@@ -1469,7 +1469,7 @@ async fn cmd_dm(
Ok(())
}
fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()> {
let conv_id = ConversationId::from_group_name(name);
if session.conv_store.find_group_by_name(name)?.is_some() {
@@ -1513,7 +1513,7 @@ fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()
Ok(())
}
async fn cmd_invite(
pub(crate) async fn cmd_invite(
session: &mut SessionState,
client: &node_service::Client,
target: &str,
@@ -1584,7 +1584,7 @@ async fn cmd_invite(
Ok(())
}
async fn cmd_remove(
pub(crate) async fn cmd_remove(
session: &mut SessionState,
client: &node_service::Client,
target: &str,
@@ -1628,7 +1628,7 @@ async fn cmd_remove(
Ok(())
}
async fn cmd_leave(
pub(crate) async fn cmd_leave(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1665,7 +1665,7 @@ async fn cmd_leave(
Ok(())
}
async fn cmd_update_key(
pub(crate) async fn cmd_update_key(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1710,7 +1710,7 @@ async fn cmd_update_key(
Ok(())
}
async fn cmd_join(
pub(crate) async fn cmd_join(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1818,7 +1818,7 @@ async fn resolve_or_hex(
}
}
async fn cmd_members(
pub(crate) async fn cmd_members(
session: &SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1855,7 +1855,7 @@ async fn cmd_members(
Ok(())
}
async fn cmd_group_info(
pub(crate) async fn cmd_group_info(
session: &SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1908,7 +1908,7 @@ async fn cmd_group_info(
Ok(())
}
fn cmd_rename(session: &mut SessionState, new_name: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_rename(session: &mut SessionState, new_name: &str) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
@@ -1926,7 +1926,7 @@ fn cmd_rename(session: &mut SessionState, new_name: &str) -> anyhow::Result<()>
Ok(())
}
fn cmd_history(session: &SessionState, count: usize) -> anyhow::Result<()> {
pub(crate) fn cmd_history(session: &SessionState, count: usize) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
@@ -1943,7 +1943,7 @@ fn cmd_history(session: &SessionState, count: usize) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_verify(
pub(crate) async fn cmd_verify(
session: &SessionState,
client: &node_service::Client,
username: &str,
@@ -1982,7 +1982,7 @@ async fn cmd_verify(
// ── Typing indicator ─────────────────────────────────────────────────────────
async fn cmd_typing(
pub(crate) async fn cmd_typing(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -2033,7 +2033,7 @@ async fn cmd_typing(
Ok(())
}
async fn cmd_react(
pub(crate) async fn cmd_react(
session: &mut SessionState,
client: &node_service::Client,
emoji: &str,
@@ -2127,7 +2127,7 @@ async fn cmd_react(
// ── Edit / Delete ────────────────────────────────────────────────────────────
async fn cmd_edit(
pub(crate) async fn cmd_edit(
session: &mut SessionState,
client: &node_service::Client,
index: usize,
@@ -2200,7 +2200,7 @@ async fn cmd_edit(
Ok(())
}
async fn cmd_delete(
pub(crate) async fn cmd_delete(
session: &mut SessionState,
client: &node_service::Client,
index: usize,
@@ -2313,7 +2313,7 @@ fn format_size(bytes: u64) -> String {
}
}
async fn cmd_send_file(
pub(crate) async fn cmd_send_file(
session: &mut SessionState,
client: &node_service::Client,
path_str: &str,
@@ -2447,7 +2447,7 @@ async fn cmd_send_file(
Ok(())
}
async fn cmd_download(
pub(crate) async fn cmd_download(
session: &mut SessionState,
client: &node_service::Client,
index: usize,
@@ -2582,7 +2582,7 @@ fn extract_filename_from_body(body: &str) -> Option<String> {
}
}
async fn cmd_delete_account(
pub(crate) async fn cmd_delete_account(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -2631,7 +2631,7 @@ async fn handle_send(
}
}
async fn do_send(
pub(crate) async fn do_send(
session: &mut SessionState,
client: &node_service::Client,
text: &str,
@@ -3240,7 +3240,7 @@ async fn replenish_pending_key(
// ── Device management commands ──────────────────────────────────────────────
async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
pub(crate) async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
let devices = list_devices(client).await?;
if devices.is_empty() {
display::print_status("No devices registered.");
@@ -3260,7 +3260,7 @@ async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_register_device(
pub(crate) async fn cmd_register_device(
client: &node_service::Client,
name: &str,
) -> anyhow::Result<()> {
@@ -3279,7 +3279,7 @@ async fn cmd_register_device(
Ok(())
}
async fn cmd_revoke_device(
pub(crate) async fn cmd_revoke_device(
client: &node_service::Client,
id_prefix: &str,
) -> anyhow::Result<()> {