From 9ab306d891be900b94352b3758eace405078f433 Mon Sep 17 00:00:00 2001 From: Chris Nennemann Date: Tue, 3 Mar 2026 23:37:24 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=202=20=E2=80=94=20security=20har?= =?UTF-8?q?dening,=20MLS=20key=20rotation,=20E2E=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- crates/quicproquo-client/src/client/repl.rs | 50 +++ crates/quicproquo-client/tests/e2e.rs | 409 +++++++++++++++++- .../src/node_service/delivery.rs | 24 +- .../src/node_service/user_ops.rs | 34 +- 4 files changed, 508 insertions(+), 9 deletions(-) diff --git a/crates/quicproquo-client/src/client/repl.rs b/crates/quicproquo-client/src/client/repl.rs index 7f959a0..ea19992 100644 --- a/crates/quicproquo-client/src/client/repl.rs +++ b/crates/quicproquo-client/src/client/repl.rs @@ -59,6 +59,8 @@ enum SlashCommand { MeshServer { addr: String }, /// Display safety number for out-of-band key verification with a contact. Verify { username: String }, + /// Rotate own MLS leaf key in the active group. + UpdateKey, } fn parse_input(line: &str) -> Input { @@ -144,6 +146,7 @@ fn parse_input(line: &str) -> Input { Input::Empty } }, + "/update-key" | "/rotate-key" => Input::Slash(SlashCommand::UpdateKey), _ => { display::print_error(&format!("unknown command: {cmd}. Try /help")); Input::Empty @@ -613,6 +616,7 @@ async fn handle_slash( Ok(()) } SlashCommand::Verify { username } => cmd_verify(session, client, &username).await, + SlashCommand::UpdateKey => cmd_update_key(session, client).await, }; if let Err(e) = result { display::print_error(&format!("{e:#}")); @@ -634,6 +638,7 @@ fn print_help() { display::print_status(" /whoami - Show your identity"); display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS"); display::print_status(" /mesh server - Show how to reconnect to a mesh node"); + display::print_status(" /update-key - Rotate your MLS leaf key in the active group"); display::print_status(" /verify - Show safety number for key verification"); display::print_status(" /quit - Exit"); } @@ -1076,6 +1081,51 @@ async fn cmd_leave( Ok(()) } +async fn cmd_update_key( + session: &mut SessionState, + client: &node_service::Client, +) -> anyhow::Result<()> { + let conv_id = session + .active_conversation + .as_ref() + .context("no active conversation; switch to a group first")? + .clone(); + + let my_key = session.identity_bytes(); + + let member = session + .get_member_mut(&conv_id) + .context("no group member for active conversation")?; + + anyhow::ensure!( + member.group_ref().is_some(), + "active conversation has no MLS group" + ); + + // Propose a self-update (leaf key rotation). + let proposal = member.propose_self_update()?; + + // Immediately commit the pending proposal. + let (commit, _welcome) = member.commit_pending_proposals()?; + + // Fan out the commit to all other group members. + let others: Vec> = member + .member_identities() + .into_iter() + .filter(|id| id.as_slice() != my_key.as_slice()) + .collect(); + + // Send proposal followed by commit so recipients can process in order. + for rk in &others { + enqueue(client, rk, &proposal).await?; + enqueue(client, rk, &commit).await?; + } + + session.save_member(&conv_id)?; + display::print_status("key rotation complete"); + Ok(()) +} + async fn cmd_join( session: &mut SessionState, client: &node_service::Client, diff --git a/crates/quicproquo-client/tests/e2e.rs b/crates/quicproquo-client/tests/e2e.rs index 3253078..8edd8f5 100644 --- a/crates/quicproquo-client/tests/e2e.rs +++ b/crates/quicproquo-client/tests/e2e.rs @@ -20,7 +20,7 @@ use quicproquo_client::{ cmd_register_user, cmd_send, connect_node, create_channel, enqueue, fetch_wait, init_auth, opaque_login, receive_pending_plaintexts, resolve_user, ClientAuth, }; -use quicproquo_core::IdentityKeypair; +use quicproquo_core::{GroupMember, HybridKeypair, IdentityKeypair, ReceivedMessage}; /// Serialises all tests that call `init_auth` with a non-devtoken session to prevent /// the global `AUTH_CONTEXT` from being overwritten by concurrent tests. @@ -935,3 +935,410 @@ async fn e2e_dm_multi_message_epoch_synchronized() -> anyhow::Result<()> { Ok(()) } + +// ─── new tests: round-trip message delivery, key rotation, oversized payload, multi-party ───── + +/// Helper: load a state file and reconstruct a GroupMember with its keystore. +fn load_member(state_path: &std::path::Path) -> (GroupMember, Option) { + let bytes = std::fs::read(state_path).expect("read state"); + let state: quicproquo_client::client::state::StoredState = + bincode::deserialize(&bytes).expect("decode state"); + state.into_parts(state_path).expect("into_parts") +} + +/// Helper: save a GroupMember back to its state file. +fn save_member(state_path: &std::path::Path, member: &GroupMember, hybrid: Option<&HybridKeypair>) { + quicproquo_client::client::state::save_state(state_path, member, hybrid, None) + .expect("save state"); +} + +/// Basic happy-path: Alice registers, Bob registers, Alice creates a DM channel + MLS group, +/// invites Bob, sends "ping", Bob fetches and decrypts "ping". +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_message_delivery_round_trip() -> anyhow::Result<()> { + ensure_rustls_provider(); + let _auth = AUTH_LOCK.lock().unwrap(); + + let temp = TempDir::new()?; + let base = temp.path(); + + let (server, ca_cert, _child) = spawn_server(base, &[]); + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + + let local = tokio::task::LocalSet::new(); + + let alice_state = base.join("alice.bin"); + let bob_state = base.join("bob.bin"); + + // Register identity states (KeyPackage + hybrid key upload). + local + .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + let alice_seed = bincode::deserialize::(&std::fs::read(&alice_state)?)?.identity_seed; + let bob_seed = bincode::deserialize::(&std::fs::read(&bob_state)?)?.identity_seed; + let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); + let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec(); + let alice_pk_hex = hex_encode(&alice_pk); + let bob_pk_hex = hex_encode(&bob_pk); + + // OPAQUE register both. + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex))) + .await?; + local + .run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex))) + .await?; + + // Alice logs in, creates DM channel, MLS group, invites Bob. + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + let session_alice = local + .run_until(opaque_login(&client, "alice", "pass", &alice_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_alice.clone(), None)); + + let (channel_id, was_new) = local + .run_until(create_channel(&client, &bob_pk)) + .await?; + anyhow::ensure!(was_new, "Alice must get was_new=true"); + + local + .run_until(cmd_create_group(&alice_state, &server, &hex_encode(&channel_id), None)) + .await?; + local + .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None)) + .await?; + + // Bob logs in and joins. + let session_bob = local + .run_until(opaque_login(&client, "bob", "pass", &bob_pk)) + .await?; + init_auth(ClientAuth::from_raw(session_bob.clone(), None)); + local + .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + // Alice sends "ping". + init_auth(ClientAuth::from_raw(session_alice, None)); + local + .run_until(cmd_send( + &alice_state, + &server, + &ca_cert, + "localhost", + Some(&bob_pk_hex), + false, + "ping", + None, + )) + .await?; + + // Bob receives and decrypts. + init_auth(ClientAuth::from_raw(session_bob, None)); + let plaintexts = local + .run_until(receive_pending_plaintexts( + &bob_state, + &server, + &ca_cert, + "localhost", + 1500, + None, + )) + .await?; + + anyhow::ensure!( + plaintexts.iter().any(|p| p.as_slice() == b"ping"), + "Bob did not receive 'ping'; got {:?}", + plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + + Ok(()) +} + +/// Alice proposes a self-update (MLS key rotation) in a DM group with Bob. +/// Alice commits the pending proposal and fans out the proposal + commit. +/// Bob processes them. After rotation, Alice sends a message and Bob decrypts it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_key_rotation_update_path() -> anyhow::Result<()> { + ensure_rustls_provider(); + let _auth = AUTH_LOCK.lock().unwrap(); + + let temp = TempDir::new()?; + let base = temp.path(); + + let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + + let local = tokio::task::LocalSet::new(); + + let alice_state = base.join("alice.bin"); + let bob_state = base.join("bob.bin"); + + local + .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + let alice_seed = bincode::deserialize::(&std::fs::read(&alice_state)?)?.identity_seed; + let bob_seed = bincode::deserialize::(&std::fs::read(&bob_state)?)?.identity_seed; + let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec(); + let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec(); + let bob_pk_hex = hex_encode(&bob_pk); + + // Set up the MLS group: Alice creates, invites Bob, Bob joins. + local + .run_until(cmd_create_group(&alice_state, &server, "rotation-test", None)) + .await?; + local + .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None)) + .await?; + local + .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + + // --- Key rotation via core MLS API --- + // Load Alice's GroupMember, propose self-update, commit, save. + let (mut alice_member, alice_hybrid) = load_member(&alice_state); + let proposal = alice_member.propose_self_update()?; + let (commit, _welcome) = alice_member.commit_pending_proposals()?; + save_member(&alice_state, &alice_member, alice_hybrid.as_ref()); + + // Fan out proposal + commit to Bob via enqueue. + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + local.run_until(enqueue(&client, &bob_pk, &proposal)).await?; + local.run_until(enqueue(&client, &bob_pk, &commit)).await?; + + // Bob fetches and processes the proposal + commit. + let (mut bob_member, bob_hybrid) = load_member(&bob_state); + let mut raw_payloads = + local.run_until(fetch_wait(&client, &bob_pk, 1000)).await?; + raw_payloads.sort_by_key(|(seq, _)| *seq); + + for (_, payload) in &raw_payloads { + match bob_member.receive_message(payload) { + Ok(ReceivedMessage::StateChanged) => {} + Ok(other) => anyhow::bail!("expected StateChanged, got {other:?}"), + Err(e) => anyhow::bail!("Bob failed to process rotation message: {e}"), + } + } + save_member(&bob_state, &bob_member, bob_hybrid.as_ref()); + + // After rotation, Alice sends a message and Bob decrypts it. + local + .run_until(cmd_send( + &alice_state, + &server, + &ca_cert, + "localhost", + Some(&bob_pk_hex), + false, + "post-rotation", + None, + )) + .await?; + + let plaintexts = local + .run_until(receive_pending_plaintexts( + &bob_state, + &server, + &ca_cert, + "localhost", + 1500, + None, + )) + .await?; + + anyhow::ensure!( + plaintexts.iter().any(|p| p.as_slice() == b"post-rotation"), + "Bob did not receive 'post-rotation' after key rotation; got {:?}", + plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + + Ok(()) +} + +/// Sending a payload larger than 5 MB must be rejected by the server with E006. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_hook_rejects_oversized_payload() -> anyhow::Result<()> { + ensure_rustls_provider(); + + let temp = TempDir::new()?; + let base = temp.path(); + + let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + + let local = tokio::task::LocalSet::new(); + + // Register a recipient so enqueue has a valid target. + let state_path = base.join("recipient.bin"); + local + .run_until(cmd_register_state(&state_path, &server, &ca_cert, "localhost", None)) + .await?; + let state_bytes = std::fs::read(&state_path)?; + let stored: StoredStateCompat = bincode::deserialize(&state_bytes)?; + let recipient_key = IdentityKeypair::from_seed(stored.identity_seed).public_key_bytes(); + + let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; + + // Payload just over the 5 MB limit (5 * 1024 * 1024 + 1 bytes). + let oversized = vec![0xAAu8; 5 * 1024 * 1024 + 1]; + let result = local.run_until(enqueue(&client, &recipient_key, &oversized)).await; + + match result { + Ok(_) => anyhow::bail!("enqueue with oversized payload should have been rejected"), + Err(e) => { + let msg = format!("{e:#}"); + anyhow::ensure!( + msg.contains("payload exceeds max size") || msg.contains("E006"), + "expected E006 / payload size error, got: {msg}" + ); + } + } + + Ok(()) +} + +/// Three-party group: Alice creates, invites Bob, then Carol. +/// All three exchange messages and verify cross-member delivery: +/// Alice -> group, Bob -> group, Carol -> group. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_multi_party_group() -> anyhow::Result<()> { + ensure_rustls_provider(); + + let temp = TempDir::new()?; + let base = temp.path(); + + let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); + wait_for_health(&server, &ca_cert, "localhost").await?; + init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); + + let local = tokio::task::LocalSet::new(); + + let alice_state = base.join("alice.bin"); + let bob_state = base.join("bob.bin"); + let carol_state = base.join("carol.bin"); + + // Register all three. + local + .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_register_state(&carol_state, &server, &ca_cert, "localhost", None)) + .await?; + + let bob_bytes = std::fs::read(&bob_state)?; + let bob_compat: StoredStateCompat = bincode::deserialize(&bob_bytes)?; + let bob_pk_hex = hex_encode(&IdentityKeypair::from_seed(bob_compat.identity_seed).public_key_bytes()); + + let carol_bytes = std::fs::read(&carol_state)?; + let carol_compat: StoredStateCompat = bincode::deserialize(&carol_bytes)?; + let carol_pk_hex = hex_encode(&IdentityKeypair::from_seed(carol_compat.identity_seed).public_key_bytes()); + + // Alice creates group, invites Bob then Carol. + local + .run_until(cmd_create_group(&alice_state, &server, "trio", None)) + .await?; + local + .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None)) + .await?; + local + .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &carol_pk_hex, None)) + .await?; + + // Bob and Carol join. + local + .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) + .await?; + local + .run_until(cmd_join(&carol_state, &server, &ca_cert, "localhost", None)) + .await?; + + // Alice sends to all members. + local + .run_until(cmd_send(&alice_state, &server, &ca_cert, "localhost", None, true, "from-alice", None)) + .await?; + + sleep(Duration::from_millis(200)).await; + + let bob_pt = local + .run_until(receive_pending_plaintexts(&bob_state, &server, &ca_cert, "localhost", 1500, None)) + .await?; + let carol_pt = local + .run_until(receive_pending_plaintexts(&carol_state, &server, &ca_cert, "localhost", 1500, None)) + .await?; + + anyhow::ensure!( + bob_pt.iter().any(|p| p.as_slice() == b"from-alice"), + "Bob did not receive 'from-alice'; got {:?}", + bob_pt.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + anyhow::ensure!( + carol_pt.iter().any(|p| p.as_slice() == b"from-alice"), + "Carol did not receive 'from-alice'; got {:?}", + carol_pt.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + + // Bob sends to all. + local + .run_until(cmd_send(&bob_state, &server, &ca_cert, "localhost", None, true, "from-bob", None)) + .await?; + + sleep(Duration::from_millis(200)).await; + + let alice_pt = local + .run_until(receive_pending_plaintexts(&alice_state, &server, &ca_cert, "localhost", 1500, None)) + .await?; + let carol_pt2 = local + .run_until(receive_pending_plaintexts(&carol_state, &server, &ca_cert, "localhost", 1500, None)) + .await?; + + anyhow::ensure!( + alice_pt.iter().any(|p| p.as_slice() == b"from-bob"), + "Alice did not receive 'from-bob'; got {:?}", + alice_pt.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + anyhow::ensure!( + carol_pt2.iter().any(|p| p.as_slice() == b"from-bob"), + "Carol did not receive 'from-bob'; got {:?}", + carol_pt2.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + + // Carol sends to all. + local + .run_until(cmd_send(&carol_state, &server, &ca_cert, "localhost", None, true, "from-carol", None)) + .await?; + + sleep(Duration::from_millis(200)).await; + + let alice_pt2 = local + .run_until(receive_pending_plaintexts(&alice_state, &server, &ca_cert, "localhost", 1500, None)) + .await?; + let bob_pt2 = local + .run_until(receive_pending_plaintexts(&bob_state, &server, &ca_cert, "localhost", 1500, None)) + .await?; + + anyhow::ensure!( + alice_pt2.iter().any(|p| p.as_slice() == b"from-carol"), + "Alice did not receive 'from-carol'; got {:?}", + alice_pt2.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + anyhow::ensure!( + bob_pt2.iter().any(|p| p.as_slice() == b"from-carol"), + "Bob did not receive 'from-carol'; got {:?}", + bob_pt2.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() + ); + + Ok(()) +} diff --git a/crates/quicproquo-server/src/node_service/delivery.rs b/crates/quicproquo-server/src/node_service/delivery.rs index 9bfa255..77fd717 100644 --- a/crates/quicproquo-server/src/node_service/delivery.rs +++ b/crates/quicproquo-server/src/node_service/delivery.rs @@ -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" diff --git a/crates/quicproquo-server/src/node_service/user_ops.rs b/crates/quicproquo-server/src/node_service/user_ops.rs index 8934f53..f002340 100644 --- a/crates/quicproquo-server/src/node_service/user_ops.rs +++ b/crates/quicproquo-server/src/node_service/user_ops.rs @@ -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(