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

@@ -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 <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(" /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<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(
session: &mut SessionState,
client: &node_service::Client,