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)
305 lines
9.0 KiB
Rust
305 lines
9.0 KiB
Rust
use std::net::IpAddr;
|
|
use std::sync::Arc;
|
|
|
|
use dashmap::DashMap;
|
|
use quicproquo_proto::node_capnp::auth;
|
|
use sha2::Digest;
|
|
use subtle::ConstantTimeEq;
|
|
use tokio::sync::Notify;
|
|
use zeroize::Zeroizing;
|
|
|
|
use crate::error_codes::*;
|
|
|
|
pub const SESSION_TTL_SECS: u64 = 24 * 60 * 60; // 24 hours
|
|
pub const PENDING_LOGIN_TTL_SECS: u64 = 300; // 5 minutes
|
|
pub const RATE_LIMIT_WINDOW_SECS: u64 = 60;
|
|
pub const RATE_LIMIT_MAX_ENQUEUES: u32 = 100;
|
|
|
|
#[derive(Clone)]
|
|
pub struct AuthConfig {
|
|
/// Server bearer token — zeroized on drop to prevent memory disclosure.
|
|
pub required_token: Option<Zeroizing<Vec<u8>>>,
|
|
/// When true, a valid bearer token (no session) is accepted and the request's identity/key is used (dev/e2e only).
|
|
/// CLI flag: --allow-insecure-auth / QPQ_ALLOW_INSECURE_AUTH.
|
|
pub allow_insecure_identity_from_request: bool,
|
|
}
|
|
|
|
impl std::fmt::Debug for AuthConfig {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("AuthConfig")
|
|
.field("required_token", &self.required_token.as_ref().map(|_| "[REDACTED]"))
|
|
.field("allow_insecure_identity_from_request", &self.allow_insecure_identity_from_request)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl AuthConfig {
|
|
pub fn new(required_token: Option<String>, allow_insecure_identity_from_request: bool) -> Self {
|
|
let required_token = required_token
|
|
.filter(|s| !s.is_empty())
|
|
.map(|s| Zeroizing::new(s.into_bytes()));
|
|
Self {
|
|
required_token,
|
|
allow_insecure_identity_from_request,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SessionInfo {
|
|
#[allow(dead_code)]
|
|
pub username: String,
|
|
pub identity_key: Vec<u8>,
|
|
#[allow(dead_code)]
|
|
pub created_at: u64,
|
|
pub expires_at: u64,
|
|
}
|
|
|
|
pub struct PendingLogin {
|
|
pub state_bytes: Vec<u8>,
|
|
pub created_at: u64,
|
|
}
|
|
|
|
pub struct RateEntry {
|
|
pub count: u32,
|
|
pub window_start: u64,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct AuthContext {
|
|
pub token: Vec<u8>,
|
|
pub identity_key: Option<Vec<u8>>,
|
|
}
|
|
|
|
pub fn current_timestamp() -> u64 {
|
|
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
|
Ok(d) => d.as_secs(),
|
|
Err(_) => {
|
|
tracing::warn!("system time is before UNIX_EPOCH; using 0 for session/rate-limit timestamps");
|
|
0
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn check_rate_limit(
|
|
rate_limits: &DashMap<Vec<u8>, RateEntry>,
|
|
token: &[u8],
|
|
) -> Result<(), capnp::Error> {
|
|
let now = current_timestamp();
|
|
let mut entry = rate_limits.entry(token.to_vec()).or_insert(RateEntry {
|
|
count: 0,
|
|
window_start: now,
|
|
});
|
|
|
|
if now - entry.window_start >= RATE_LIMIT_WINDOW_SECS {
|
|
entry.count = 1;
|
|
entry.window_start = now;
|
|
} else {
|
|
entry.count += 1;
|
|
if entry.count > RATE_LIMIT_MAX_ENQUEUES {
|
|
return Err(crate::error_codes::coded_error(
|
|
E014_RATE_LIMITED,
|
|
format!(
|
|
"rate limit exceeded: {} enqueues in {}s window",
|
|
RATE_LIMIT_MAX_ENQUEUES, RATE_LIMIT_WINDOW_SECS
|
|
),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn validate_auth(
|
|
cfg: &AuthConfig,
|
|
sessions: &DashMap<Vec<u8>, SessionInfo>,
|
|
auth: Result<auth::Reader<'_>, capnp::Error>,
|
|
) -> Result<(), capnp::Error> {
|
|
validate_auth_context(cfg, sessions, auth).map(|_| ())
|
|
}
|
|
|
|
pub fn validate_auth_context(
|
|
cfg: &AuthConfig,
|
|
sessions: &DashMap<Vec<u8>, SessionInfo>,
|
|
auth: Result<auth::Reader<'_>, capnp::Error>,
|
|
) -> Result<AuthContext, capnp::Error> {
|
|
let auth = auth?;
|
|
let version = auth.get_version();
|
|
|
|
if version != 1 {
|
|
return Err(crate::error_codes::coded_error(
|
|
E001_BAD_AUTH_VERSION,
|
|
format!("unsupported auth version {} (expected 1)", version),
|
|
));
|
|
}
|
|
|
|
let token = auth
|
|
.get_access_token()
|
|
.map_err(|e| crate::error_codes::coded_error(E020_BAD_PARAMS, format!("auth.accessToken: {e}")))?
|
|
.to_vec();
|
|
|
|
if token.is_empty() {
|
|
return Err(crate::error_codes::coded_error(
|
|
E002_EMPTY_TOKEN,
|
|
"auth.version=1 requires non-empty accessToken",
|
|
));
|
|
}
|
|
|
|
if let Some(expected) = &cfg.required_token {
|
|
if expected.len() == token.len() && bool::from(expected.as_slice().ct_eq(&token)) {
|
|
return Ok(AuthContext {
|
|
token,
|
|
identity_key: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some(session) = sessions.get(&token) {
|
|
let now = current_timestamp();
|
|
if session.expires_at > now {
|
|
let identity = if session.identity_key.is_empty() {
|
|
None
|
|
} else {
|
|
Some(session.identity_key.clone())
|
|
};
|
|
|
|
return Ok(AuthContext {
|
|
token,
|
|
identity_key: identity,
|
|
});
|
|
}
|
|
drop(session);
|
|
sessions.remove(&token);
|
|
return Err(crate::error_codes::coded_error(
|
|
E017_SESSION_EXPIRED,
|
|
"session token has expired",
|
|
));
|
|
}
|
|
|
|
Err(crate::error_codes::coded_error(E003_INVALID_TOKEN, "invalid accessToken"))
|
|
}
|
|
|
|
/// Validate a raw bearer token (no Cap'n Proto dependency).
|
|
/// Used by the WebSocket JSON-RPC bridge.
|
|
pub fn validate_token_raw(
|
|
cfg: &AuthConfig,
|
|
sessions: &DashMap<Vec<u8>, SessionInfo>,
|
|
token: &[u8],
|
|
) -> Result<AuthContext, String> {
|
|
if token.is_empty() {
|
|
return Err("empty access token".to_string());
|
|
}
|
|
|
|
// Check static bearer token.
|
|
if let Some(expected) = &cfg.required_token {
|
|
if expected.len() == token.len() && bool::from(expected.as_slice().ct_eq(token)) {
|
|
return Ok(AuthContext {
|
|
token: token.to_vec(),
|
|
identity_key: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check session tokens.
|
|
if let Some(session) = sessions.get(token) {
|
|
let now = current_timestamp();
|
|
if session.expires_at > now {
|
|
let identity = if session.identity_key.is_empty() {
|
|
None
|
|
} else {
|
|
Some(session.identity_key.clone())
|
|
};
|
|
return Ok(AuthContext {
|
|
token: token.to_vec(),
|
|
identity_key: identity,
|
|
});
|
|
}
|
|
drop(session);
|
|
sessions.remove(token);
|
|
return Err("session token has expired".to_string());
|
|
}
|
|
|
|
Err("invalid access token".to_string())
|
|
}
|
|
|
|
pub fn require_identity(auth_ctx: &AuthContext) -> Result<&[u8], capnp::Error> {
|
|
match auth_ctx.identity_key.as_deref() {
|
|
Some(ik) => Ok(ik),
|
|
None => Err(crate::error_codes::coded_error(
|
|
E003_INVALID_TOKEN,
|
|
"access token is not identity-bound; login required",
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn require_identity_match(auth_ctx: &AuthContext, expected: &[u8]) -> Result<(), capnp::Error> {
|
|
let ik = require_identity(auth_ctx)?;
|
|
if ik.len() != expected.len() || !bool::from(ik.ct_eq(expected)) {
|
|
return Err(crate::error_codes::coded_error(
|
|
E016_IDENTITY_MISMATCH,
|
|
"access token is bound to a different identity",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// When the token is a valid session, require it to match `request_identity`.
|
|
/// When the token is a bearer token (no identity) and `allow_insecure_identity_from_request` is true, accept the request identity (dev/e2e).
|
|
pub fn require_identity_or_request(
|
|
auth_ctx: &AuthContext,
|
|
request_identity: &[u8],
|
|
allow_insecure: bool,
|
|
) -> Result<(), capnp::Error> {
|
|
match auth_ctx.identity_key.as_deref() {
|
|
Some(_) => require_identity_match(auth_ctx, request_identity),
|
|
None if allow_insecure => Ok(()),
|
|
None => Err(crate::error_codes::coded_error(
|
|
E003_INVALID_TOKEN,
|
|
"access token is not identity-bound; login required",
|
|
)),
|
|
}
|
|
}
|
|
|
|
pub fn fmt_hex(bytes: &[u8]) -> String {
|
|
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
|
format!("{hex}…")
|
|
}
|
|
|
|
pub fn waiter(waiters: &DashMap<Vec<u8>, Arc<Notify>>, recipient_key: &[u8]) -> Arc<Notify> {
|
|
waiters
|
|
.entry(recipient_key.to_vec())
|
|
.or_insert_with(|| Arc::new(Notify::new()))
|
|
.clone()
|
|
}
|
|
|
|
pub const CONN_RATE_LIMIT_WINDOW_SECS: u64 = 60;
|
|
pub const CONN_RATE_LIMIT_MAX: u32 = 50;
|
|
|
|
/// Per-IP connection rate limiter. Returns `true` if the connection is allowed.
|
|
pub fn check_conn_rate_limit(
|
|
conn_rate_limits: &DashMap<IpAddr, RateEntry>,
|
|
ip: IpAddr,
|
|
) -> bool {
|
|
let now = current_timestamp();
|
|
let mut entry = conn_rate_limits.entry(ip).or_insert(RateEntry {
|
|
count: 0,
|
|
window_start: now,
|
|
});
|
|
|
|
if now - entry.window_start >= CONN_RATE_LIMIT_WINDOW_SECS {
|
|
entry.count = 1;
|
|
entry.window_start = now;
|
|
true
|
|
} else {
|
|
entry.count += 1;
|
|
entry.count <= CONN_RATE_LIMIT_MAX
|
|
}
|
|
}
|
|
|
|
pub fn fingerprint(data: &[u8]) -> Vec<u8> {
|
|
sha2::Sha256::digest(data).to_vec()
|
|
}
|
|
|
|
pub fn coded_error(code: &str, msg: impl std::fmt::Display) -> capnp::Error {
|
|
crate::error_codes::coded_error(code, msg)
|
|
}
|