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:
@@ -2,21 +2,99 @@
|
||||
//!
|
||||
//! Delegates all operations to the local [`Store`], acting as a trusted relay
|
||||
//! from authenticated peer servers.
|
||||
//!
|
||||
//! **Security:** Each handler validates the request's `origin` field against
|
||||
//! the `verified_peer_domain` extracted from the mTLS client certificate at
|
||||
//! connection time. Per-peer rate limits prevent abuse.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use capnp::capability::Promise;
|
||||
use dashmap::DashMap;
|
||||
use quicproquo_proto::federation_capnp::federation_service;
|
||||
use tokio::sync::Notify;
|
||||
use dashmap::DashMap;
|
||||
|
||||
use crate::auth::RateEntry;
|
||||
use crate::storage::Store;
|
||||
|
||||
/// Per-peer federation rate limit: max requests within a 60-second window.
|
||||
const FED_RATE_LIMIT_WINDOW_SECS: u64 = 60;
|
||||
const FED_RATE_LIMIT_MAX: u32 = 200;
|
||||
|
||||
/// Inbound federation RPC handler.
|
||||
pub struct FederationServiceImpl {
|
||||
pub store: Arc<dyn Store>,
|
||||
pub waiters: Arc<DashMap<Vec<u8>, Arc<Notify>>>,
|
||||
pub local_domain: String,
|
||||
/// The peer domain extracted from the mTLS client certificate's CN/SAN
|
||||
/// at connection time. All requests must declare an `origin` matching this.
|
||||
pub verified_peer_domain: Option<String>,
|
||||
/// Per-peer rate limiter (keyed by peer domain).
|
||||
pub rate_limits: Arc<DashMap<String, RateEntry>>,
|
||||
}
|
||||
|
||||
/// Validate that the request's `origin` matches the mTLS-verified peer domain.
|
||||
fn validate_origin(
|
||||
verified: &Option<String>,
|
||||
declared: &str,
|
||||
) -> Result<(), capnp::Error> {
|
||||
match verified {
|
||||
Some(ref expected) if expected == declared => Ok(()),
|
||||
Some(ref expected) => Err(capnp::Error::failed(format!(
|
||||
"federation auth: origin '{}' does not match mTLS cert '{}'",
|
||||
declared, expected
|
||||
))),
|
||||
None => Err(capnp::Error::failed(
|
||||
"federation auth: no verified peer domain (mTLS required)".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract and validate the origin string from the request's auth field.
|
||||
fn extract_and_validate_origin(
|
||||
service: &FederationServiceImpl,
|
||||
get_auth: Result<quicproquo_proto::federation_capnp::federation_auth::Reader<'_>, capnp::Error>,
|
||||
) -> Result<String, capnp::Error> {
|
||||
let auth = get_auth
|
||||
.map_err(|_| capnp::Error::failed("federation auth: missing auth field".into()))?;
|
||||
let origin_reader = auth.get_origin()
|
||||
.map_err(|_| capnp::Error::failed("federation auth: missing origin".into()))?;
|
||||
let origin = origin_reader.to_str()
|
||||
.map_err(|_| capnp::Error::failed("federation auth: origin is not valid UTF-8".into()))?;
|
||||
|
||||
if origin.is_empty() {
|
||||
return Err(capnp::Error::failed("federation auth: origin must not be empty".into()));
|
||||
}
|
||||
|
||||
validate_origin(&service.verified_peer_domain, origin)?;
|
||||
check_federation_rate_limit(&service.rate_limits, origin)?;
|
||||
|
||||
Ok(origin.to_string())
|
||||
}
|
||||
|
||||
/// Per-peer federation rate limiter.
|
||||
fn check_federation_rate_limit(
|
||||
rate_limits: &DashMap<String, RateEntry>,
|
||||
peer_domain: &str,
|
||||
) -> Result<(), capnp::Error> {
|
||||
let now = crate::auth::current_timestamp();
|
||||
let mut entry = rate_limits.entry(peer_domain.to_string()).or_insert(RateEntry {
|
||||
count: 0,
|
||||
window_start: now,
|
||||
});
|
||||
|
||||
if now - entry.window_start >= FED_RATE_LIMIT_WINDOW_SECS {
|
||||
entry.count = 1;
|
||||
entry.window_start = now;
|
||||
} else {
|
||||
entry.count += 1;
|
||||
if entry.count > FED_RATE_LIMIT_MAX {
|
||||
return Err(capnp::Error::failed(format!(
|
||||
"federation rate limit exceeded for peer '{peer_domain}'"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl federation_service::Server for FederationServiceImpl {
|
||||
@@ -30,6 +108,12 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
|
||||
};
|
||||
|
||||
// Validate origin against mTLS cert and apply rate limit.
|
||||
let origin = match extract_and_validate_origin(self, p.get_auth()) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return Promise::err(e),
|
||||
};
|
||||
|
||||
let recipient_key = match p.get_recipient_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad recipient_key: {e}"))),
|
||||
@@ -40,13 +124,6 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
};
|
||||
let channel_id = p.get_channel_id().unwrap_or_default().to_vec();
|
||||
|
||||
if let Ok(a) = p.get_auth() {
|
||||
if let Ok(origin) = a.get_origin() {
|
||||
let origin = origin.to_str().unwrap_or("?");
|
||||
tracing::debug!(origin = origin, "federation relay_enqueue");
|
||||
}
|
||||
}
|
||||
|
||||
if recipient_key.len() != 32 {
|
||||
return Promise::err(capnp::Error::failed("recipient_key must be 32 bytes".into()));
|
||||
}
|
||||
@@ -67,6 +144,7 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
origin = %origin,
|
||||
recipient_prefix = %hex::encode(&recipient_key[..4]),
|
||||
seq = seq,
|
||||
"federation: relayed enqueue"
|
||||
@@ -85,6 +163,12 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
|
||||
};
|
||||
|
||||
// Validate origin against mTLS cert and apply rate limit.
|
||||
let _origin = match extract_and_validate_origin(self, p.get_auth()) {
|
||||
Ok(o) => o,
|
||||
Err(e) => return Promise::err(e),
|
||||
};
|
||||
|
||||
let recipient_keys = match p.get_recipient_keys() {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad recipient_keys: {e}"))),
|
||||
@@ -134,11 +218,21 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
params: federation_service::ProxyFetchKeyPackageParams,
|
||||
mut results: federation_service::ProxyFetchKeyPackageResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let identity_key = match params.get().and_then(|p| p.get_identity_key()) {
|
||||
Ok(v) => v.to_vec(),
|
||||
let p = match params.get() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
|
||||
};
|
||||
|
||||
// Validate origin against mTLS cert and apply rate limit.
|
||||
if let Err(e) = extract_and_validate_origin(self, p.get_auth()) {
|
||||
return Promise::err(e);
|
||||
}
|
||||
|
||||
let identity_key = match p.get_identity_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad identity_key: {e}"))),
|
||||
};
|
||||
|
||||
match self.store.fetch_key_package(&identity_key) {
|
||||
Ok(Some(pkg)) => results.get().set_package(&pkg),
|
||||
Ok(None) => results.get().set_package(&[]),
|
||||
@@ -153,11 +247,21 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
params: federation_service::ProxyFetchHybridKeyParams,
|
||||
mut results: federation_service::ProxyFetchHybridKeyResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let identity_key = match params.get().and_then(|p| p.get_identity_key()) {
|
||||
Ok(v) => v.to_vec(),
|
||||
let p = match params.get() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
|
||||
};
|
||||
|
||||
// Validate origin against mTLS cert and apply rate limit.
|
||||
if let Err(e) = extract_and_validate_origin(self, p.get_auth()) {
|
||||
return Promise::err(e);
|
||||
}
|
||||
|
||||
let identity_key = match p.get_identity_key() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad identity_key: {e}"))),
|
||||
};
|
||||
|
||||
match self.store.fetch_hybrid_key(&identity_key) {
|
||||
Ok(Some(pk)) => results.get().set_hybrid_public_key(&pk),
|
||||
Ok(None) => results.get().set_hybrid_public_key(&[]),
|
||||
@@ -172,7 +276,17 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
params: federation_service::ProxyResolveUserParams,
|
||||
mut results: federation_service::ProxyResolveUserResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
let username = match params.get().and_then(|p| p.get_username()) {
|
||||
let p = match params.get() {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad params: {e}"))),
|
||||
};
|
||||
|
||||
// Validate origin against mTLS cert and apply rate limit.
|
||||
if let Err(e) = extract_and_validate_origin(self, p.get_auth()) {
|
||||
return Promise::err(e);
|
||||
}
|
||||
|
||||
let username = match p.get_username() {
|
||||
Ok(u) => match u.to_str() {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => return Promise::err(capnp::Error::failed(format!("bad utf-8: {e}"))),
|
||||
@@ -194,8 +308,42 @@ impl federation_service::Server for FederationServiceImpl {
|
||||
_params: federation_service::FederationHealthParams,
|
||||
mut results: federation_service::FederationHealthResults,
|
||||
) -> Promise<(), capnp::Error> {
|
||||
// Health check does not require origin validation (diagnostic endpoint).
|
||||
results.get().set_status("ok");
|
||||
results.get().set_server_domain(&self.local_domain);
|
||||
Promise::ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the peer domain from the mTLS client certificate's first SAN (DNS name)
|
||||
/// or CN, given the QUIC connection's peer identity (a certificate chain).
|
||||
pub fn extract_peer_domain(conn: &quinn::Connection) -> Option<String> {
|
||||
let identity = conn.peer_identity()?;
|
||||
let certs = identity.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>().ok()?;
|
||||
let first_cert = certs.first()?;
|
||||
|
||||
// Parse the DER certificate to extract SAN DNS names or CN.
|
||||
let (_, parsed) = x509_parser::parse_x509_certificate(first_cert.as_ref()).ok()?;
|
||||
|
||||
// Prefer SAN DNS names.
|
||||
if let Ok(Some(san)) = parsed.subject_alternative_name() {
|
||||
for name in &san.value.general_names {
|
||||
if let x509_parser::extensions::GeneralName::DNSName(dns) = name {
|
||||
return Some(dns.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to CN.
|
||||
for rdn in parsed.subject().iter() {
|
||||
for attr in rdn.iter() {
|
||||
if attr.attr_type() == &x509_parser::oid_registry::OID_X509_COMMON_NAME {
|
||||
if let Ok(cn) = attr.as_str() {
|
||||
return Some(cn.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user