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

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