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

@@ -184,6 +184,21 @@ pub trait Store: Send + Sync {
/// user identity key mapping, and the user record itself.
/// Does NOT delete KT log entries (append-only for auditability).
fn delete_account(&self, identity_key: &[u8]) -> Result<(), StorageError>;
// ── Device registry ─────────────────────────────────────────────────────
/// Register a device for an identity. Returns false if the device already exists.
/// Caller must check device_count < 5 before calling.
fn register_device(&self, identity_key: &[u8], device_id: &[u8], device_name: &str) -> Result<bool, StorageError>;
/// List all registered devices for an identity: (device_id, name, registered_at).
fn list_devices(&self, identity_key: &[u8]) -> Result<Vec<(Vec<u8>, String, u64)>, StorageError>;
/// Revoke (remove) a registered device. Returns false if not found.
fn revoke_device(&self, identity_key: &[u8], device_id: &[u8]) -> Result<bool, StorageError>;
/// Return the number of registered devices for an identity.
fn device_count(&self, identity_key: &[u8]) -> Result<usize, StorageError>;
}
// ── ChannelKey ───────────────────────────────────────────────────────────────
@@ -247,6 +262,8 @@ pub struct FileBackedStore {
users: Mutex<HashMap<String, Vec<u8>>>,
identity_keys: Mutex<HashMap<String, Vec<u8>>>,
endpoints: Mutex<HashMap<Vec<u8>, Vec<u8>>>,
/// Device registry: identity_key -> Vec<(device_id, device_name, registered_at)>
devices: Mutex<HashMap<Vec<u8>, Vec<(Vec<u8>, String, u64)>>>,
}
impl FileBackedStore {
@@ -289,6 +306,7 @@ impl FileBackedStore {
users,
identity_keys,
endpoints: Mutex::new(HashMap::new()),
devices: Mutex::new(HashMap::new()),
})
}
@@ -837,8 +855,49 @@ impl Store for FileBackedStore {
ep.remove(identity_key);
}
// Remove devices.
{
let mut dev = lock(&self.devices)?;
dev.remove(identity_key);
}
Ok(())
}
fn register_device(&self, identity_key: &[u8], device_id: &[u8], device_name: &str) -> Result<bool, StorageError> {
let mut map = lock(&self.devices)?;
let devices = map.entry(identity_key.to_vec()).or_default();
if devices.iter().any(|(id, _, _)| id == device_id) {
return Ok(false);
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
devices.push((device_id.to_vec(), device_name.to_string(), now));
Ok(true)
}
fn list_devices(&self, identity_key: &[u8]) -> Result<Vec<(Vec<u8>, String, u64)>, StorageError> {
let map = lock(&self.devices)?;
Ok(map.get(identity_key).cloned().unwrap_or_default())
}
fn revoke_device(&self, identity_key: &[u8], device_id: &[u8]) -> Result<bool, StorageError> {
let mut map = lock(&self.devices)?;
if let Some(devices) = map.get_mut(identity_key) {
let before = devices.len();
devices.retain(|(id, _, _)| id != device_id);
Ok(devices.len() < before)
} else {
Ok(false)
}
}
fn device_count(&self, identity_key: &[u8]) -> Result<usize, StorageError> {
let map = lock(&self.devices)?;
Ok(map.get(identity_key).map(|v| v.len()).unwrap_or(0))
}
}
#[cfg(test)]