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