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

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