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:
@@ -59,6 +59,8 @@ enum SlashCommand {
|
|||||||
MeshServer { addr: String },
|
MeshServer { addr: String },
|
||||||
/// Display safety number for out-of-band key verification with a contact.
|
/// Display safety number for out-of-band key verification with a contact.
|
||||||
Verify { username: String },
|
Verify { username: String },
|
||||||
|
/// Rotate own MLS leaf key in the active group.
|
||||||
|
UpdateKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_input(line: &str) -> Input {
|
fn parse_input(line: &str) -> Input {
|
||||||
@@ -144,6 +146,7 @@ fn parse_input(line: &str) -> Input {
|
|||||||
Input::Empty
|
Input::Empty
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/update-key" | "/rotate-key" => Input::Slash(SlashCommand::UpdateKey),
|
||||||
_ => {
|
_ => {
|
||||||
display::print_error(&format!("unknown command: {cmd}. Try /help"));
|
display::print_error(&format!("unknown command: {cmd}. Try /help"));
|
||||||
Input::Empty
|
Input::Empty
|
||||||
@@ -613,6 +616,7 @@ async fn handle_slash(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
SlashCommand::Verify { username } => cmd_verify(session, client, &username).await,
|
SlashCommand::Verify { username } => cmd_verify(session, client, &username).await,
|
||||||
|
SlashCommand::UpdateKey => cmd_update_key(session, client).await,
|
||||||
};
|
};
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
display::print_error(&format!("{e:#}"));
|
display::print_error(&format!("{e:#}"));
|
||||||
@@ -634,6 +638,7 @@ fn print_help() {
|
|||||||
display::print_status(" /whoami - Show your identity");
|
display::print_status(" /whoami - Show your identity");
|
||||||
display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS");
|
display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS");
|
||||||
display::print_status(" /mesh server <host:port> - Show how to reconnect to a mesh node");
|
display::print_status(" /mesh server <host:port> - 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 <username> - Show safety number for key verification");
|
display::print_status(" /verify <username> - Show safety number for key verification");
|
||||||
display::print_status(" /quit - Exit");
|
display::print_status(" /quit - Exit");
|
||||||
}
|
}
|
||||||
@@ -1076,6 +1081,51 @@ async fn cmd_leave(
|
|||||||
Ok(())
|
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<Vec<u8>> = 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(
|
async fn cmd_join(
|
||||||
session: &mut SessionState,
|
session: &mut SessionState,
|
||||||
client: &node_service::Client,
|
client: &node_service::Client,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use quicproquo_client::{
|
|||||||
cmd_register_user, cmd_send, connect_node, create_channel, enqueue, fetch_wait, init_auth,
|
cmd_register_user, cmd_send, connect_node, create_channel, enqueue, fetch_wait, init_auth,
|
||||||
opaque_login, receive_pending_plaintexts, resolve_user, ClientAuth,
|
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
|
/// Serialises all tests that call `init_auth` with a non-devtoken session to prevent
|
||||||
/// the global `AUTH_CONTEXT` from being overwritten by concurrent tests.
|
/// 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(())
|
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<HybridKeypair>) {
|
||||||
|
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::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||||
|
let bob_seed = bincode::deserialize::<StoredStateCompat>(&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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
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::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||||
|
let bob_seed = bincode::deserialize::<StoredStateCompat>(&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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
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::<Vec<_>>()
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,9 +119,12 @@ impl NodeServiceImpl {
|
|||||||
return Promise::err(e);
|
return Promise::err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// When sealed_sender is true, enqueue does not require identity; valid token only.
|
// Phase 4.3 — DS sender identity binding.
|
||||||
// Otherwise, the sender must have an identity-bound session (but their identity
|
// When sealed_sender is false, the sender MUST have an identity-bound session.
|
||||||
// does NOT need to match the recipient — they're sending TO the recipient).
|
// 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 !self.sealed_sender {
|
||||||
if let Err(e) = crate::auth::require_identity(&auth_ctx) {
|
if let Err(e) = crate::auth::require_identity(&auth_ctx) {
|
||||||
return Promise::err(e);
|
return Promise::err(e);
|
||||||
@@ -201,11 +204,16 @@ impl NodeServiceImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let payload_len = payload.len();
|
let payload_len = payload.len();
|
||||||
|
// sender_identity is derived solely from auth_ctx (server-side session state).
|
||||||
let sender_identity = if self.sealed_sender {
|
let sender_identity = if self.sealed_sender {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
crate::auth::require_identity(&auth_ctx).ok().map(|v| v.to_vec())
|
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.
|
// Hook: on_message_enqueue — fires after validation, before storage.
|
||||||
let hook_event = MessageEvent {
|
let hook_event = MessageEvent {
|
||||||
@@ -245,6 +253,7 @@ impl NodeServiceImpl {
|
|||||||
metrics::record_delivery_queue_depth(depth);
|
metrics::record_delivery_queue_depth(depth);
|
||||||
}
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
sender_prefix = sender_prefix.as_deref().unwrap_or("sealed"),
|
||||||
recipient_prefix = %fmt_hex(&recipient_key[..4]),
|
recipient_prefix = %fmt_hex(&recipient_key[..4]),
|
||||||
payload_len = payload_len,
|
payload_len = payload_len,
|
||||||
seq = seq,
|
seq = seq,
|
||||||
@@ -658,7 +667,8 @@ impl NodeServiceImpl {
|
|||||||
return Promise::err(e);
|
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 !self.sealed_sender {
|
||||||
if let Err(e) = crate::auth::require_identity(&auth_ctx) {
|
if let Err(e) = crate::auth::require_identity(&auth_ctx) {
|
||||||
return Promise::err(e);
|
return Promise::err(e);
|
||||||
@@ -733,11 +743,16 @@ impl NodeServiceImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hook: on_message_enqueue for each recipient — fires before storage.
|
// 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 {
|
let sender_identity = if self.sealed_sender {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
crate::auth::require_identity(&auth_ctx).ok().map(|v| v.to_vec())
|
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());
|
let mut hook_events = Vec::with_capacity(recipient_key_vecs.len());
|
||||||
for rk in &recipient_key_vecs {
|
for rk in &recipient_key_vecs {
|
||||||
let event = MessageEvent {
|
let event = MessageEvent {
|
||||||
@@ -821,6 +836,7 @@ impl NodeServiceImpl {
|
|||||||
hooks.on_batch_enqueue(&hook_events);
|
hooks.on_batch_enqueue(&hook_events);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
sender_prefix = sender_prefix.as_deref().unwrap_or("sealed"),
|
||||||
recipient_count = n,
|
recipient_count = n,
|
||||||
payload_len = payload.len(),
|
payload_len = payload.len(),
|
||||||
"audit: batch_enqueue"
|
"audit: batch_enqueue"
|
||||||
|
|||||||
@@ -2,13 +2,19 @@
|
|||||||
|
|
||||||
use capnp::capability::Promise;
|
use capnp::capability::Promise;
|
||||||
use quicproquo_proto::node_capnp::node_service;
|
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::error_codes::*;
|
||||||
|
use crate::metrics;
|
||||||
use crate::storage::StorageError;
|
use crate::storage::StorageError;
|
||||||
|
|
||||||
use super::NodeServiceImpl;
|
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 {
|
fn storage_err(err: StorageError) -> capnp::Error {
|
||||||
coded_error(E009_STORAGE_ERROR, err)
|
coded_error(E009_STORAGE_ERROR, err)
|
||||||
}
|
}
|
||||||
@@ -27,7 +33,7 @@ impl NodeServiceImpl {
|
|||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
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,
|
Ok(ctx) => ctx,
|
||||||
Err(e) => return Promise::err(e),
|
Err(e) => return Promise::err(e),
|
||||||
};
|
};
|
||||||
@@ -77,12 +83,28 @@ impl NodeServiceImpl {
|
|||||||
return Promise::ok(());
|
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.
|
// Local resolution.
|
||||||
let identity_key = match self.store.get_user_identity_key(&addr.username) {
|
let identity_key = match self.store.get_user_identity_key(&addr.username) {
|
||||||
Ok(Some(key)) => key,
|
Ok(Some(key)) => key,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
// Return empty Data — caller checks length to detect "not found".
|
// 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)),
|
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(
|
pub fn handle_resolve_identity(
|
||||||
|
|||||||
Reference in New Issue
Block a user