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)
413 lines
15 KiB
Rust
413 lines
15 KiB
Rust
use capnp::capability::Promise;
|
|
use opaque_ke::{
|
|
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload,
|
|
ServerLogin, ServerRegistration,
|
|
};
|
|
use quicproquo_core::opaque_auth::OpaqueSuite;
|
|
use quicproquo_proto::node_capnp::node_service;
|
|
|
|
use crate::auth::{coded_error, current_timestamp, PendingLogin, SESSION_TTL_SECS};
|
|
use crate::error_codes::*;
|
|
use crate::metrics;
|
|
use crate::storage::StorageError;
|
|
|
|
use crate::hooks::AuthEvent;
|
|
|
|
use super::NodeServiceImpl;
|
|
|
|
// Audit events in this module must never include secrets (no session tokens, passwords, or raw keys).
|
|
|
|
fn storage_err(err: StorageError) -> capnp::Error {
|
|
coded_error(E009_STORAGE_ERROR, err)
|
|
}
|
|
|
|
/// Parse username from Cap'n Proto reader; requires valid UTF-8.
|
|
fn parse_username_param(
|
|
result: Result<capnp::text::Reader<'_>, capnp::Error>,
|
|
) -> Result<String, capnp::Error> {
|
|
let reader = result.map_err(|e| coded_error(E020_BAD_PARAMS, e))?;
|
|
reader
|
|
.to_string()
|
|
.map_err(|_| coded_error(E020_BAD_PARAMS, "username must be valid UTF-8"))
|
|
}
|
|
|
|
impl NodeServiceImpl {
|
|
pub fn handle_opaque_login_start(
|
|
&mut self,
|
|
params: node_service::OpaqueLoginStartParams,
|
|
mut results: node_service::OpaqueLoginStartResults,
|
|
) -> Promise<(), capnp::Error> {
|
|
let p = match params.get() {
|
|
Ok(p) => p,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let username = match parse_username_param(p.get_username()) {
|
|
Ok(s) => s,
|
|
Err(e) => return Promise::err(e),
|
|
};
|
|
let request_bytes = match p.get_request() {
|
|
Ok(v) => v.to_vec(),
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
|
|
if username.is_empty() {
|
|
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
|
}
|
|
|
|
// Check for existing recent pending login before expensive OPAQUE/storage work (DoS mitigation).
|
|
if let Some(existing) = self.pending_logins.get(&username) {
|
|
let age = current_timestamp().saturating_sub(existing.created_at);
|
|
if age < 60 {
|
|
return Promise::err(coded_error(E010_OPAQUE_ERROR, "login already in progress"));
|
|
}
|
|
}
|
|
|
|
let credential_request = match CredentialRequest::<OpaqueSuite>::deserialize(&request_bytes) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("invalid credential request: {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
let password_file = match self.store.get_user_record(&username) {
|
|
Ok(Some(bytes)) => match ServerRegistration::<OpaqueSuite>::deserialize(&bytes) {
|
|
Ok(pf) => Some(pf),
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("corrupt user record: {e}"),
|
|
))
|
|
}
|
|
},
|
|
Ok(None) => None,
|
|
Err(e) => return Promise::err(storage_err(e)),
|
|
};
|
|
|
|
let mut rng = rand::rngs::OsRng;
|
|
let result = match ServerLogin::<OpaqueSuite>::start(
|
|
&mut rng,
|
|
&self.opaque_setup,
|
|
password_file,
|
|
credential_request,
|
|
username.as_bytes(),
|
|
Default::default(),
|
|
) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("OPAQUE login start failed: {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
let state_bytes = result.state.serialize().to_vec();
|
|
self.pending_logins.insert(
|
|
username.clone(),
|
|
PendingLogin {
|
|
state_bytes,
|
|
created_at: current_timestamp(),
|
|
},
|
|
);
|
|
|
|
let response_bytes = result.message.serialize();
|
|
results.get().set_response(&response_bytes);
|
|
|
|
tracing::info!(user = %username, "OPAQUE login started");
|
|
Promise::ok(())
|
|
}
|
|
|
|
pub fn handle_opaque_register_start(
|
|
&mut self,
|
|
params: node_service::OpaqueRegisterStartParams,
|
|
mut results: node_service::OpaqueRegisterStartResults,
|
|
) -> Promise<(), capnp::Error> {
|
|
let p = match params.get() {
|
|
Ok(p) => p,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let username = match parse_username_param(p.get_username()) {
|
|
Ok(s) => s,
|
|
Err(e) => return Promise::err(e),
|
|
};
|
|
let request_bytes = match p.get_request() {
|
|
Ok(v) => v.to_vec(),
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
|
|
if username.is_empty() {
|
|
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
|
}
|
|
|
|
if let Ok(true) = self.store.has_user_record(&username) {
|
|
return Promise::err(coded_error(
|
|
E018_USER_EXISTS,
|
|
format!("user '{}' already registered", username),
|
|
));
|
|
}
|
|
|
|
let registration_request = match RegistrationRequest::<OpaqueSuite>::deserialize(&request_bytes) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("invalid registration request: {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
let result = match ServerRegistration::<OpaqueSuite>::start(
|
|
&self.opaque_setup,
|
|
registration_request,
|
|
username.as_bytes(),
|
|
) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("OPAQUE registration start failed: {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
let response_bytes = result.message.serialize();
|
|
results.get().set_response(&response_bytes);
|
|
|
|
tracing::info!(user = %username, "OPAQUE registration started");
|
|
Promise::ok(())
|
|
}
|
|
|
|
pub fn handle_opaque_login_finish(
|
|
&mut self,
|
|
params: node_service::OpaqueLoginFinishParams,
|
|
mut results: node_service::OpaqueLoginFinishResults,
|
|
) -> Promise<(), capnp::Error> {
|
|
let p = match params.get() {
|
|
Ok(p) => p,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let username = match parse_username_param(p.get_username()) {
|
|
Ok(s) => s,
|
|
Err(e) => return Promise::err(e),
|
|
};
|
|
let finalization_bytes = match p.get_finalization() {
|
|
Ok(v) => v.to_vec(),
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let identity_key = p.get_identity_key().unwrap_or_default().to_vec();
|
|
|
|
if username.is_empty() {
|
|
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
|
}
|
|
|
|
let pending = match self.pending_logins.remove(&username) {
|
|
Some((_, pl)) => pl,
|
|
None => {
|
|
// Audit: login failure — do not log secrets (no token, no password).
|
|
tracing::warn!(user = %username, "audit: auth login failure (no pending login)");
|
|
metrics::record_auth_login_failure_total();
|
|
self.hooks.on_auth(&AuthEvent {
|
|
username: username.clone(),
|
|
success: false,
|
|
failure_reason: "no pending login".to_string(),
|
|
});
|
|
return Promise::err(coded_error(E019_NO_PENDING_LOGIN, "no pending login for this username"))
|
|
}
|
|
};
|
|
|
|
let server_login = match ServerLogin::<OpaqueSuite>::deserialize(&pending.state_bytes) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("corrupt login state: {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
let finalization = match CredentialFinalization::<OpaqueSuite>::deserialize(&finalization_bytes) {
|
|
Ok(f) => f,
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("invalid credential finalization: {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
let _result = match server_login.finish(finalization, Default::default()) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!(user = %username, "audit: auth login failure (OPAQUE finish failed)");
|
|
metrics::record_auth_login_failure_total();
|
|
self.hooks.on_auth(&AuthEvent {
|
|
username: username.clone(),
|
|
success: false,
|
|
failure_reason: format!("OPAQUE finish failed: {e}"),
|
|
});
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("OPAQUE login finish failed (bad password?): {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
if identity_key.is_empty() {
|
|
metrics::record_auth_login_failure_total();
|
|
return Promise::err(coded_error(
|
|
E016_IDENTITY_MISMATCH,
|
|
"identity key required to bind session token",
|
|
));
|
|
}
|
|
|
|
if let Ok(Some(stored_ik)) = self.store.get_user_identity_key(&username) {
|
|
if stored_ik != identity_key {
|
|
tracing::warn!(user = %username, "audit: auth login failure (identity mismatch)");
|
|
metrics::record_auth_login_failure_total();
|
|
self.hooks.on_auth(&AuthEvent {
|
|
username: username.clone(),
|
|
success: false,
|
|
failure_reason: "identity key mismatch".to_string(),
|
|
});
|
|
return Promise::err(coded_error(
|
|
E016_IDENTITY_MISMATCH,
|
|
"identity key does not match registered key",
|
|
));
|
|
}
|
|
}
|
|
|
|
let mut token = [0u8; 32];
|
|
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token);
|
|
let token_vec = token.to_vec();
|
|
|
|
let now = current_timestamp();
|
|
self.sessions.insert(
|
|
token_vec.clone(),
|
|
crate::auth::SessionInfo {
|
|
username: username.clone(),
|
|
identity_key,
|
|
created_at: now,
|
|
expires_at: now + SESSION_TTL_SECS,
|
|
},
|
|
);
|
|
|
|
results.get().set_session_token(&token_vec);
|
|
|
|
// Hook: on_auth — fires after successful login.
|
|
self.hooks.on_auth(&AuthEvent {
|
|
username: username.clone(),
|
|
success: true,
|
|
failure_reason: String::new(),
|
|
});
|
|
|
|
// Audit: login success — do not log session token or any secrets.
|
|
metrics::record_auth_login_success_total();
|
|
tracing::info!(user = %username, "audit: auth login success — session token issued");
|
|
Promise::ok(())
|
|
}
|
|
|
|
pub fn handle_opaque_register_finish(
|
|
&mut self,
|
|
params: node_service::OpaqueRegisterFinishParams,
|
|
mut results: node_service::OpaqueRegisterFinishResults,
|
|
) -> Promise<(), capnp::Error> {
|
|
let p = match params.get() {
|
|
Ok(p) => p,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let username = match parse_username_param(p.get_username()) {
|
|
Ok(s) => s,
|
|
Err(e) => return Promise::err(e),
|
|
};
|
|
let upload_bytes = match p.get_upload() {
|
|
Ok(v) => v.to_vec(),
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let identity_key = p.get_identity_key().unwrap_or_default().to_vec();
|
|
|
|
if username.is_empty() {
|
|
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
|
}
|
|
|
|
match self.store.has_user_record(&username) {
|
|
Ok(true) => {
|
|
return Promise::err(coded_error(
|
|
E018_USER_EXISTS,
|
|
format!("user '{}' already registered", username),
|
|
))
|
|
}
|
|
Err(e) => return Promise::err(storage_err(e)),
|
|
_ => {}
|
|
}
|
|
|
|
let upload = match RegistrationUpload::<OpaqueSuite>::deserialize(&upload_bytes) {
|
|
Ok(u) => u,
|
|
Err(e) => {
|
|
return Promise::err(coded_error(
|
|
E010_OPAQUE_ERROR,
|
|
format!("invalid registration upload: {e}"),
|
|
))
|
|
}
|
|
};
|
|
|
|
let password_file = ServerRegistration::<OpaqueSuite>::finish(upload);
|
|
let record_bytes = password_file.serialize().to_vec();
|
|
|
|
match self
|
|
.store
|
|
.store_user_record(&username, record_bytes)
|
|
{
|
|
Ok(()) => {}
|
|
Err(crate::storage::StorageError::DuplicateUser(_)) => {
|
|
return Promise::err(coded_error(
|
|
E018_USER_EXISTS,
|
|
format!("user '{}' already registered", username),
|
|
))
|
|
}
|
|
Err(e) => return Promise::err(storage_err(e)),
|
|
}
|
|
|
|
// Hook: on_user_registered — fires after successful registration.
|
|
self.hooks.on_user_registered(&username, &identity_key);
|
|
|
|
if !identity_key.is_empty() {
|
|
if let Err(e) = self
|
|
.store
|
|
.store_user_identity_key(&username, identity_key.clone())
|
|
.map_err(storage_err)
|
|
{
|
|
return Promise::err(e);
|
|
}
|
|
|
|
// Append (username, identity_key) to the Key Transparency Merkle log.
|
|
match self.kt_log.lock() {
|
|
Ok(mut log) => {
|
|
log.append(&username, &identity_key);
|
|
// Persist after each append (small extra cost, but ensures durability).
|
|
match log.to_bytes() {
|
|
Ok(bytes) => {
|
|
if let Err(e) = self.store.save_kt_log(bytes) {
|
|
tracing::warn!(user = %username, error = %e, "KT log persist failed");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(user = %username, error = %e, "KT log serialise failed");
|
|
}
|
|
}
|
|
tracing::info!(user = %username, tree_size = log.len(), "KT: appended identity binding");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(user = %username, error = %e, "KT log lock poisoned; skipping append");
|
|
}
|
|
}
|
|
}
|
|
|
|
results.get().set_success(true);
|
|
tracing::info!(user = %username, "OPAQUE registration complete");
|
|
Promise::ok(())
|
|
}
|
|
}
|