feat: Sprint 2 — security hardening, MLS key rotation, E2E tests
- DS sender identity binding (Phase 4.3): explicit audit logging of sender_prefix in enqueue/batch_enqueue, documenting that sender identity is always derived from authenticated session - Username enumeration mitigation (Phase 4.5): 5ms timing floor on resolveUser responses + rate limiting to prevent bulk enumeration - Add /update-key REPL command for MLS leaf key rotation via propose_self_update + auto-commit + fan-out to group members - Add 4 new E2E tests: message delivery round-trip, key rotation update path, oversized payload rejection, multi-party group (12 total)
This commit is contained in:
@@ -119,9 +119,12 @@ impl NodeServiceImpl {
|
||||
return Promise::err(e);
|
||||
}
|
||||
|
||||
// When sealed_sender is true, enqueue does not require identity; valid token only.
|
||||
// Otherwise, the sender must have an identity-bound session (but their identity
|
||||
// does NOT need to match the recipient — they're sending TO the recipient).
|
||||
// Phase 4.3 — DS sender identity binding.
|
||||
// When sealed_sender is false, the sender MUST have an identity-bound session.
|
||||
// The sender_identity used for audit/hooks is ALWAYS derived from
|
||||
// auth_ctx.identity_key (populated by OPAQUE session lookup in validate_auth_context),
|
||||
// never from any client-supplied field. This guarantees that the server only
|
||||
// attributes messages to the cryptographically authenticated identity.
|
||||
if !self.sealed_sender {
|
||||
if let Err(e) = crate::auth::require_identity(&auth_ctx) {
|
||||
return Promise::err(e);
|
||||
@@ -201,11 +204,16 @@ impl NodeServiceImpl {
|
||||
}
|
||||
|
||||
let payload_len = payload.len();
|
||||
// sender_identity is derived solely from auth_ctx (server-side session state).
|
||||
let sender_identity = if self.sealed_sender {
|
||||
None
|
||||
} else {
|
||||
crate::auth::require_identity(&auth_ctx).ok().map(|v| v.to_vec())
|
||||
};
|
||||
let sender_prefix = sender_identity
|
||||
.as_deref()
|
||||
.filter(|id| id.len() >= 4)
|
||||
.map(|id| fmt_hex(&id[..4]));
|
||||
|
||||
// Hook: on_message_enqueue — fires after validation, before storage.
|
||||
let hook_event = MessageEvent {
|
||||
@@ -245,6 +253,7 @@ impl NodeServiceImpl {
|
||||
metrics::record_delivery_queue_depth(depth);
|
||||
}
|
||||
tracing::info!(
|
||||
sender_prefix = sender_prefix.as_deref().unwrap_or("sealed"),
|
||||
recipient_prefix = %fmt_hex(&recipient_key[..4]),
|
||||
payload_len = payload_len,
|
||||
seq = seq,
|
||||
@@ -658,7 +667,8 @@ impl NodeServiceImpl {
|
||||
return Promise::err(e);
|
||||
}
|
||||
|
||||
// When sealed_sender is false, require an identity-bound session.
|
||||
// Phase 4.3 — DS sender identity binding (same guarantee as handle_enqueue).
|
||||
// sender_identity is derived solely from auth_ctx.identity_key, never client data.
|
||||
if !self.sealed_sender {
|
||||
if let Err(e) = crate::auth::require_identity(&auth_ctx) {
|
||||
return Promise::err(e);
|
||||
@@ -733,11 +743,16 @@ impl NodeServiceImpl {
|
||||
}
|
||||
|
||||
// Hook: on_message_enqueue for each recipient — fires before storage.
|
||||
// sender_identity is derived solely from auth_ctx (server-side session state).
|
||||
let sender_identity = if self.sealed_sender {
|
||||
None
|
||||
} else {
|
||||
crate::auth::require_identity(&auth_ctx).ok().map(|v| v.to_vec())
|
||||
};
|
||||
let sender_prefix = sender_identity
|
||||
.as_deref()
|
||||
.filter(|id| id.len() >= 4)
|
||||
.map(|id| fmt_hex(&id[..4]));
|
||||
let mut hook_events = Vec::with_capacity(recipient_key_vecs.len());
|
||||
for rk in &recipient_key_vecs {
|
||||
let event = MessageEvent {
|
||||
@@ -821,6 +836,7 @@ impl NodeServiceImpl {
|
||||
hooks.on_batch_enqueue(&hook_events);
|
||||
|
||||
tracing::info!(
|
||||
sender_prefix = sender_prefix.as_deref().unwrap_or("sealed"),
|
||||
recipient_count = n,
|
||||
payload_len = payload.len(),
|
||||
"audit: batch_enqueue"
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
use capnp::capability::Promise;
|
||||
use quicproquo_proto::node_capnp::node_service;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
use crate::auth::{coded_error, validate_auth_context};
|
||||
use crate::auth::{check_rate_limit, coded_error, validate_auth_context};
|
||||
use crate::error_codes::*;
|
||||
use crate::metrics;
|
||||
use crate::storage::StorageError;
|
||||
|
||||
use super::NodeServiceImpl;
|
||||
|
||||
/// Minimum response time for resolveUser to mask DB lookup timing differences.
|
||||
const RESOLVE_TIMING_FLOOR: Duration = Duration::from_millis(5);
|
||||
|
||||
fn storage_err(err: StorageError) -> capnp::Error {
|
||||
coded_error(E009_STORAGE_ERROR, err)
|
||||
}
|
||||
@@ -27,7 +33,7 @@ impl NodeServiceImpl {
|
||||
Ok(u) => u,
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
let _auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {
|
||||
let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {
|
||||
Ok(ctx) => ctx,
|
||||
Err(e) => return Promise::err(e),
|
||||
};
|
||||
@@ -77,12 +83,28 @@ impl NodeServiceImpl {
|
||||
return Promise::ok(());
|
||||
}
|
||||
|
||||
// Rate-limit resolve requests to prevent bulk enumeration.
|
||||
if let Err(e) = check_rate_limit(&self.rate_limits, &auth_ctx.token) {
|
||||
tracing::warn!("rate_limit_hit");
|
||||
metrics::record_rate_limit_hit_total();
|
||||
return Promise::err(e);
|
||||
}
|
||||
|
||||
// Timing floor: record the start time so we can pad the response to a
|
||||
// fixed minimum duration, masking DB-lookup timing differences between
|
||||
// existing and non-existing usernames.
|
||||
let deadline = Instant::now() + RESOLVE_TIMING_FLOOR;
|
||||
|
||||
// Local resolution.
|
||||
let identity_key = match self.store.get_user_identity_key(&addr.username) {
|
||||
Ok(Some(key)) => key,
|
||||
Ok(None) => {
|
||||
// Return empty Data — caller checks length to detect "not found".
|
||||
return Promise::ok(());
|
||||
// Pad to timing floor before responding.
|
||||
return Promise::from_future(async move {
|
||||
tokio::time::sleep_until(deadline).await;
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
Err(e) => return Promise::err(storage_err(e)),
|
||||
};
|
||||
@@ -110,7 +132,11 @@ impl NodeServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
Promise::ok(())
|
||||
// Pad to timing floor before responding.
|
||||
Promise::from_future(async move {
|
||||
tokio::time::sleep_until(deadline).await;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_resolve_identity(
|
||||
|
||||
Reference in New Issue
Block a user