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:
2026-03-03 23:37:24 +01:00
parent 612b06aa8e
commit 9ab306d891
4 changed files with 508 additions and 9 deletions

View File

@@ -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(