feat: Sprint 10+11 — privacy hardening and multi-device support

Privacy Hardening (Sprint 10):
- Server --redact-logs flag: SHA-256 hashed identity prefixes in audit
  logs, payload_len omitted when enabled
- Client /privacy command suite: redact-keys on|off, auto-clear with
  duration parsing, padding on|off for traffic analysis resistance
- Forward secrecy: /verify-fs checks MLS epoch advancement,
  /rotate-all-keys rotates MLS leaf + hybrid KEM keypair
- Dummy message type (0x09): constant-rate traffic padding every 30s,
  silently discarded by recipients, serialize_dummy() + parse support
- delete_messages_before() for auto-clear in ConversationStore

Multi-Device Support (Sprint 11):
- Device registry: registerDevice @24, listDevices @25, revokeDevice @26
  RPCs with Device struct (deviceId, deviceName, registeredAt)
- Server storage: devices table (migration 008), max 5 per identity,
  E029_DEVICE_LIMIT and E030_DEVICE_NOT_FOUND error codes
- Device cleanup integrated into deleteAccount transaction
- Client REPL: /devices, /register-device <name>, /revoke-device <id>

72 core + 35 server tests pass.
This commit is contained in:
2026-03-04 01:55:23 +01:00
parent 1b61b7ee8f
commit 9244e80ec7
16 changed files with 958 additions and 45 deletions

View File

@@ -12,7 +12,7 @@ use anyhow::Context;
use quicproquo_core::{
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage,
compute_safety_number, hybrid_encrypt, parse as parse_app_msg, serialize_chat,
serialize_delete, serialize_edit, serialize_file_ref, serialize_reaction,
serialize_delete, serialize_dummy, serialize_edit, serialize_file_ref, serialize_reaction,
serialize_read_receipt, serialize_typing,
};
use quicproquo_proto::node_capnp::node_service;
@@ -27,8 +27,9 @@ use super::conversation::{
use super::display;
use super::rpc::{
connect_node, create_channel, delete_account, download_blob_chunk, enqueue,
enqueue_with_ttl, fetch_hybrid_key, fetch_key_package, fetch_wait, resolve_identity,
resolve_user, try_hybrid_decrypt, upload_blob_chunk, upload_hybrid_key, upload_key_package,
enqueue_with_ttl, fetch_hybrid_key, fetch_key_package, fetch_wait, list_devices,
register_device, resolve_identity, resolve_user, revoke_device, try_hybrid_decrypt,
upload_blob_chunk, upload_hybrid_key, upload_key_package,
};
use super::session::SessionState;
use super::state::{decode_identity_key, load_or_init_state};
@@ -89,6 +90,18 @@ enum SlashCommand {
DeleteAccount,
/// Set or query disappearing message TTL for the active conversation.
Disappear { arg: Option<String> },
/// Privacy controls: redact-keys, auto-clear, padding.
Privacy { arg: Option<String> },
/// Verify that MLS epoch has advanced since last send (forward secrecy check).
VerifyFs,
/// Rotate MLS leaf key AND regenerate + upload hybrid KEM keypair.
RotateAllKeys,
/// List all registered devices.
Devices,
/// Register this device with a name.
RegisterDevice { name: String },
/// Revoke a device by hex ID prefix.
RevokeDevice { id_prefix: String },
}
fn parse_input(line: &str) -> Input {
@@ -284,6 +297,24 @@ fn parse_input(line: &str) -> Input {
},
"/delete-account" => Input::Slash(SlashCommand::DeleteAccount),
"/disappear" => Input::Slash(SlashCommand::Disappear { arg }),
"/privacy" => Input::Slash(SlashCommand::Privacy { arg }),
"/verify-fs" => Input::Slash(SlashCommand::VerifyFs),
"/rotate-all-keys" => Input::Slash(SlashCommand::RotateAllKeys),
"/devices" => Input::Slash(SlashCommand::Devices),
"/register-device" => match arg {
Some(name) => Input::Slash(SlashCommand::RegisterDevice { name }),
None => {
display::print_error("usage: /register-device <name>");
Input::Empty
}
},
"/revoke-device" => match arg {
Some(id_prefix) => Input::Slash(SlashCommand::RevokeDevice { id_prefix }),
None => {
display::print_error("usage: /revoke-device <hex-id-prefix>");
Input::Empty
}
},
_ => {
display::print_error(&format!("unknown command: {cmd}. Try /help"));
Input::Empty
@@ -566,6 +597,28 @@ pub async fn run_repl(
let now = std::time::Instant::now();
session.typing_indicators.retain(|_, ts| now.duration_since(*ts).as_secs() < 10);
// Auto-clear: delete messages older than the configured duration.
if let Some(max_age_secs) = session.auto_clear_secs {
let cutoff_ms = now_ms().saturating_sub(max_age_secs as u64 * 1000);
if let Err(e) = session.conv_store.delete_messages_before(cutoff_ms) {
tracing::debug!(error = %e, "auto-clear failed");
}
}
// Traffic padding: send a dummy message every ~30 seconds.
if session.padding_enabled {
static LAST_PADDING: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let last = LAST_PADDING.load(std::sync::atomic::Ordering::Relaxed);
if now_secs.saturating_sub(last) >= 30 {
LAST_PADDING.store(now_secs, std::sync::atomic::Ordering::Relaxed);
send_dummy_message(&mut session, &client).await;
}
}
match poll_messages(&mut session, &client).await {
Ok(()) => {
consecutive_errors = 0;
@@ -782,6 +835,12 @@ async fn handle_slash(
SlashCommand::Download { index } => cmd_download(session, client, index).await,
SlashCommand::DeleteAccount => cmd_delete_account(session, client).await,
SlashCommand::Disappear { arg } => cmd_disappear(session, arg.as_deref()),
SlashCommand::Privacy { arg } => cmd_privacy(session, arg.as_deref()),
SlashCommand::VerifyFs => cmd_verify_fs(session),
SlashCommand::RotateAllKeys => cmd_rotate_all_keys(session, client).await,
SlashCommand::Devices => cmd_devices(client).await,
SlashCommand::RegisterDevice { name } => cmd_register_device(client, &name).await,
SlashCommand::RevokeDevice { id_prefix } => cmd_revoke_device(client, &id_prefix).await,
};
if let Err(e) = result {
display::print_error(&format!("{e:#}"));
@@ -824,6 +883,15 @@ fn print_help() {
display::print_status(" /disappear <duration> - Set disappearing messages (1h, 30m, 1d, 300)");
display::print_status(" /disappear off - Disable disappearing messages");
display::print_status(" /disappear - Show current setting");
display::print_status(" /privacy - Show current privacy settings");
display::print_status(" /privacy redact-keys on|off - Redact identity keys in /members, /group-info");
display::print_status(" /privacy auto-clear <dur> - Auto-clear local messages older than <dur>");
display::print_status(" /privacy padding on|off - Toggle dummy traffic padding");
display::print_status(" /verify-fs - Verify MLS forward secrecy (epoch advancement)");
display::print_status(" /rotate-all-keys - Rotate MLS key + regenerate hybrid KEM keypair");
display::print_status(" /devices - List all registered devices");
display::print_status(" /register-device <name> - Register this device with a name");
display::print_status(" /revoke-device <hex-prefix> - Remove a device by hex ID prefix");
display::print_status(" /quit - Exit");
}
@@ -898,6 +966,148 @@ fn cmd_disappear(
Ok(())
}
fn cmd_privacy(
session: &mut SessionState,
arg: Option<&str>,
) -> anyhow::Result<()> {
match arg {
None => {
display::print_status(&format!(
"redact-keys: {}",
if session.redact_keys { "on" } else { "off" }
));
display::print_status(&format!(
"auto-clear: {}",
match session.auto_clear_secs {
Some(secs) => format_ttl(secs),
None => "off".to_string(),
}
));
display::print_status(&format!(
"padding: {}",
if session.padding_enabled { "on" } else { "off" }
));
}
Some(s) if s.starts_with("redact-keys ") => {
let val = s.trim_start_matches("redact-keys ").trim();
match val {
"on" => {
session.redact_keys = true;
display::print_status("key redaction enabled");
}
"off" => {
session.redact_keys = false;
display::print_status("key redaction disabled");
}
_ => display::print_error("usage: /privacy redact-keys on|off"),
}
}
Some(s) if s.starts_with("auto-clear ") => {
let val = s.trim_start_matches("auto-clear ").trim();
if val.eq_ignore_ascii_case("off") {
session.auto_clear_secs = None;
display::print_status("auto-clear disabled");
} else {
let secs = parse_duration_secs(val)
.context("invalid duration; use e.g. 30m, 1h, 1d, or 300")?;
if secs == 0 {
anyhow::bail!("auto-clear duration must be greater than 0");
}
session.auto_clear_secs = Some(secs);
display::print_status(&format!(
"auto-clear enabled: messages older than {} will be deleted",
format_ttl(secs)
));
}
}
Some(s) if s.starts_with("padding ") => {
let val = s.trim_start_matches("padding ").trim();
match val {
"on" => {
session.padding_enabled = true;
display::print_status("traffic padding enabled (dummy messages every 30s)");
}
"off" => {
session.padding_enabled = false;
display::print_status("traffic padding disabled");
}
"status" => {
display::print_status(&format!(
"padding: {}",
if session.padding_enabled { "on" } else { "off" }
));
}
_ => display::print_error("usage: /privacy padding on|off|status"),
}
}
Some(_) => {
display::print_error("usage: /privacy [redact-keys on|off | auto-clear <dur>|off | padding on|off|status]");
}
}
Ok(())
}
fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
.context("no active conversation")?;
let member = session
.members
.get(conv_id)
.context("no group member for active conversation")?;
let current_epoch = member.epoch().context("no MLS group in active conversation")?;
match session.last_send_epoch {
Some(last) if current_epoch > last => {
display::print_status(&format!(
"forward secrecy OK: epoch advanced from {} to {}",
last, current_epoch
));
}
Some(last) if current_epoch == last => {
display::print_status(&format!(
"warning: MLS epoch has NOT advanced since last send (epoch {}). \
Use /rotate-all-keys or /update-key to rotate keys.",
current_epoch
));
}
Some(last) => {
display::print_status(&format!(
"unexpected: current epoch {} < last send epoch {}",
current_epoch, last
));
}
None => {
display::print_status(&format!(
"no previous send recorded. Current epoch: {}",
current_epoch
));
}
}
Ok(())
}
async fn cmd_rotate_all_keys(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
// Step 1: MLS leaf key rotation (same as /update-key).
cmd_update_key(session, client).await?;
// Step 2: Generate new hybrid KEM keypair and upload.
let new_kp = quicproquo_core::HybridKeypair::generate();
let id_key = session.identity.public_key_bytes();
upload_hybrid_key(client, &id_key, &new_kp.public_key()).await?;
session.hybrid_kp = Some(new_kp);
display::print_status("all keys rotated: MLS leaf key + hybrid KEM keypair");
Ok(())
}
/// Discover nearby qpq servers via mDNS (requires `--features mesh` build).
fn cmd_mesh_peers() -> anyhow::Result<()> {
use super::mesh_discovery::MeshDiscovery;
@@ -1626,11 +1836,20 @@ async fn cmd_members(
let ids = member.member_identities();
let mut names = Vec::with_capacity(ids.len());
for id in &ids {
let mut name = resolve_or_hex(client, id).await;
if id.as_slice() == my_key.as_slice() {
name.push_str(" (you)");
if session.redact_keys {
let short = &hex::encode(&id[..4.min(id.len())]);
let mut name = format!("[redacted-{short}]");
if id.as_slice() == my_key.as_slice() {
name.push_str(" (you)");
}
names.push(name);
} else {
let mut name = resolve_or_hex(client, id).await;
if id.as_slice() == my_key.as_slice() {
name.push_str(" (you)");
}
names.push(name);
}
names.push(name);
}
display::print_status(&format!("Members ({}): {}", names.len(), names.join(", ")));
Ok(())
@@ -1665,11 +1884,20 @@ async fn cmd_group_info(
let mut names = Vec::with_capacity(ids.len());
for id in &ids {
let mut name = resolve_or_hex(client, id).await;
if id.as_slice() == my_key.as_slice() {
name.push_str(" (you)");
if session.redact_keys {
let short = &hex::encode(&id[..4.min(id.len())]);
let mut name = format!("[redacted-{short}]");
if id.as_slice() == my_key.as_slice() {
name.push_str(" (you)");
}
names.push(name);
} else {
let mut name = resolve_or_hex(client, id).await;
if id.as_slice() == my_key.as_slice() {
name.push_str(" (you)");
}
names.push(name);
}
names.push(name);
}
display::print_status(&format!(" {}", names.join(", ")));
@@ -2451,12 +2679,19 @@ async fn do_send(
.send_message(&padded)
.context("MLS send_message failed")?;
// Collect epoch and recipients before releasing the mutable borrow on session.
let epoch = member.epoch();
let recipients: Vec<Vec<u8>> = member
.member_identities()
.into_iter()
.filter(|id| id.as_slice() != my_key.as_slice())
.collect();
// Track epoch for /verify-fs (must be after member borrow is released).
if let Some(epoch) = epoch {
session.last_send_epoch = Some(epoch);
}
let ttl = session.disappear_ttl.get(&conv_id).copied();
for recipient_key in &recipients {
@@ -2496,6 +2731,60 @@ async fn do_send(
Ok(())
}
/// Send a dummy message for traffic analysis resistance.
async fn send_dummy_message(
session: &mut SessionState,
client: &node_service::Client,
) {
let conv_id = match session.active_conversation.as_ref() {
Some(id) => id.clone(),
None => return,
};
let my_key = session.identity_bytes();
let identity = std::sync::Arc::clone(&session.identity);
let member = match session.get_member_mut(&conv_id) {
Some(m) => m,
None => return,
};
if member.group_ref().is_none() {
return;
}
let recipients: Vec<Vec<u8>> = member
.member_identities()
.into_iter()
.filter(|id| id.as_slice() != my_key.as_slice())
.collect();
if recipients.is_empty() {
return;
}
let dummy_payload = serialize_dummy();
let sealed = quicproquo_core::sealed_sender::seal(&identity, &dummy_payload);
let padded = quicproquo_core::padding::pad(&sealed);
let ct = match member.send_message(&padded) {
Ok(ct) => ct,
Err(_) => return,
};
let idx = (now_ms() as usize) % recipients.len();
let rk = &recipients[idx];
let payload = match fetch_hybrid_key(client, rk).await {
Ok(Some(ref pk)) => match hybrid_encrypt(pk, &ct, b"", b"") {
Ok(p) => p,
Err(_) => ct,
},
_ => ct,
};
let _ = enqueue(client, rk, &payload).await;
let _ = session.save_member(&conv_id);
}
// ── Outbox drain ─────────────────────────────────────────────────────────────
async fn drain_outbox(
@@ -2600,6 +2889,13 @@ async fn poll_messages(
break;
}
// Dummy messages: silently discard (traffic padding).
if let Ok((_, AppMessage::Dummy)) = &parsed {
any_changed = true;
handled = true;
break;
}
// Read receipts: ephemeral, show subtle notification.
if let Ok((_, AppMessage::ReadReceipt { .. })) = &parsed {
let is_active = session
@@ -2941,3 +3237,81 @@ async fn replenish_pending_key(
}
}
}
// ── Device management commands ──────────────────────────────────────────────
async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
let devices = list_devices(client).await?;
if devices.is_empty() {
display::print_status("No devices registered.");
return Ok(());
}
display::print_status(&format!("{} device(s):", devices.len()));
for (device_id, name, registered_at) in &devices {
let id_hex = hex::encode(device_id);
let name_display = if name.is_empty() { "(unnamed)" } else { name.as_str() };
display::print_status(&format!(
" {} - {} (registered: {})",
&id_hex[..16.min(id_hex.len())],
name_display,
registered_at
));
}
Ok(())
}
async fn cmd_register_device(
client: &node_service::Client,
name: &str,
) -> anyhow::Result<()> {
let mut device_id = [0u8; 16];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut device_id);
let success = register_device(client, &device_id, name).await?;
if success {
display::print_status(&format!(
"Device registered: {} ({})",
hex::encode(device_id),
name
));
} else {
display::print_error("Device already exists with that ID.");
}
Ok(())
}
async fn cmd_revoke_device(
client: &node_service::Client,
id_prefix: &str,
) -> anyhow::Result<()> {
let devices = list_devices(client).await?;
let prefix_lower = id_prefix.to_lowercase();
let matches: Vec<_> = devices
.iter()
.filter(|(id, _, _)| hex::encode(id).starts_with(&prefix_lower))
.collect();
match matches.len() {
0 => {
display::print_error(&format!("No device matching prefix '{id_prefix}'"));
}
1 => {
let (device_id, name, _) = matches[0];
let success = revoke_device(client, device_id).await?;
if success {
display::print_status(&format!(
"Device revoked: {} ({})",
hex::encode(device_id),
if name.is_empty() { "(unnamed)" } else { name.as_str() }
));
} else {
display::print_error("Device not found on server.");
}
}
n => {
display::print_error(&format!(
"Ambiguous prefix '{id_prefix}' matches {n} devices. Be more specific."
));
}
}
Ok(())
}