From 12b19b6931e3d862e56f141d10156bb28a885a2a Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 20:12:20 +0100 Subject: [PATCH] feat: implement account recovery with encrypted backup bundles Add recovery code generation (8 codes per setup), Argon2id key derivation, ChaCha20-Poly1305 encrypted bundles, and server-side zero-knowledge storage. Each code independently recovers the account. Includes core crypto module, protobuf service (method IDs 750-752), server domain + handlers, SDK methods, SQL migration, and CLI commands (/recovery setup, /recovery restore). --- crates/quicproquo-client/src/v2_commands.rs | 226 ++++++++++++ crates/quicproquo-client/src/v2_main.rs | 76 ++++ crates/quicproquo-core/src/lib.rs | 7 + crates/quicproquo-core/src/recovery.rs | 342 ++++++++++++++++++ crates/quicproquo-proto/build.rs | 3 + crates/quicproquo-proto/src/lib.rs | 18 + crates/quicproquo-sdk/src/lib.rs | 1 + crates/quicproquo-sdk/src/recovery.rs | 119 ++++++ .../migrations/011_recovery_bundles.sql | 7 + .../quicproquo-server/src/domain/recovery.rs | 76 ++++ crates/quicproquo-server/src/storage.rs | 2 + .../quicproquo-server/src/v2_handlers/mod.rs | 108 +++++- .../src/v2_handlers/recovery.rs | 99 +++++ proto/qpq/v1/recovery.proto | 37 ++ 14 files changed, 1120 insertions(+), 1 deletion(-) create mode 100644 crates/quicproquo-core/src/recovery.rs create mode 100644 crates/quicproquo-sdk/src/recovery.rs create mode 100644 crates/quicproquo-server/migrations/011_recovery_bundles.sql create mode 100644 crates/quicproquo-server/src/domain/recovery.rs create mode 100644 crates/quicproquo-server/src/v2_handlers/recovery.rs create mode 100644 proto/qpq/v1/recovery.proto diff --git a/crates/quicproquo-client/src/v2_commands.rs b/crates/quicproquo-client/src/v2_commands.rs index ae211f5..a5be781 100644 --- a/crates/quicproquo-client/src/v2_commands.rs +++ b/crates/quicproquo-client/src/v2_commands.rs @@ -126,3 +126,229 @@ pub async fn cmd_devices_revoke( } Ok(()) } + +/// Set up account recovery — generate codes and upload encrypted bundles. +pub async fn cmd_recovery_setup(client: &mut QpqClient) -> Result<(), SdkError> { + // Load identity seed from state file. + let state_path = client.config_state_path(); + let stored = quicproquo_sdk::state::load_state(&state_path, None) + .map_err(|e| SdkError::Crypto(format!("load identity for recovery: {e}")))?; + + let rpc = client.rpc()?; + let codes = + quicproquo_sdk::recovery::setup_recovery(rpc, &stored.identity_seed, &[]).await?; + + println!("=== RECOVERY CODES ==="); + println!("Save these codes securely. They will NOT be shown again."); + println!("Each code can independently recover your account."); + println!(); + for (i, code) in codes.iter().enumerate() { + println!(" {}. {}", i + 1, code); + } + println!(); + println!("{} codes generated and uploaded.", codes.len()); + Ok(()) +} + +// ── Outbox commands ────────────────────────────────────────────────────────── + +/// List pending outbox entries. +pub fn cmd_outbox_list(client: &QpqClient) -> Result<(), SdkError> { + let store = client.conversations()?; + let entries = quicproquo_sdk::outbox::list_pending(store)?; + if entries.is_empty() { + println!("outbox is empty — no pending messages"); + } else { + println!("{:<6} {:<34} {:<8} PAYLOAD SIZE", "ID", "CONVERSATION", "RETRIES"); + for e in &entries { + println!( + "{:<6} {:<34} {:<8} {} bytes", + e.id, + e.conversation_id.hex(), + e.retry_count, + e.payload.len(), + ); + } + println!("\n{} pending entries", entries.len()); + } + Ok(()) +} + +/// Retry sending all pending outbox entries. +pub async fn cmd_outbox_retry(client: &mut QpqClient) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let store = client.conversations()?; + let (sent, failed) = quicproquo_sdk::outbox::flush_outbox(rpc, store).await?; + println!("outbox flush: {sent} sent, {failed} permanently failed"); + Ok(()) +} + +/// Clear permanently failed outbox entries. +pub fn cmd_outbox_clear(client: &QpqClient) -> Result<(), SdkError> { + let store = client.conversations()?; + let cleared = quicproquo_sdk::outbox::clear_failed(store)?; + println!("cleared {cleared} failed outbox entries"); + Ok(()) +} + +// ── Group lifecycle commands ───────────────────────────────────────────────── + +/// List members of a group. +pub async fn cmd_group_members( + client: &mut QpqClient, + group_id_hex: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let group_id_bytes = hex::decode(group_id_hex) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?; + let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes) + .ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?; + + let members = quicproquo_sdk::groups::get_group_members(rpc, &conv_id).await?; + if members.is_empty() { + println!("no members found (or group not registered server-side)"); + } else { + println!("{:<40} {:<20} JOINED AT", "IDENTITY KEY", "USERNAME"); + for m in &members { + println!( + "{:<40} {:<20} {}", + hex::encode(&m.identity_key), + m.username, + m.joined_at, + ); + } + println!("\n{} members", members.len()); + } + Ok(()) +} + +/// Rename a group (update metadata). +pub async fn cmd_group_rename( + client: &mut QpqClient, + group_id_hex: &str, + new_name: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let store = client.conversations()?; + let group_id_bytes = hex::decode(group_id_hex) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?; + let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes) + .ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?; + + quicproquo_sdk::groups::set_group_metadata(rpc, store, &conv_id, new_name, "", &[]).await?; + println!("group renamed to: {new_name}"); + Ok(()) +} + +/// Rotate keys for a group. +pub async fn cmd_group_rotate_keys( + client: &mut QpqClient, + group_id_hex: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let store = client.conversations()?; + let group_id_bytes = hex::decode(group_id_hex) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?; + let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes) + .ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?; + + // Load MLS state from conversation. + let conv = store + .load_conversation(&conv_id) + .map_err(|e| SdkError::Storage(e.to_string()))? + .ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?; + let identity = client.identity_arc()?; + let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; + + quicproquo_sdk::groups::rotate_group_keys(rpc, store, &mut member, &conv_id).await?; + println!("keys rotated for group {group_id_hex}"); + Ok(()) +} + +/// Remove a member from a group. +pub async fn cmd_group_remove_member( + client: &mut QpqClient, + group_id_hex: &str, + member_key_hex: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let store = client.conversations()?; + let group_id_bytes = hex::decode(group_id_hex) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?; + let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes) + .ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?; + let member_key = hex::decode(member_key_hex) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid member key hex: {e}")))?; + + // Load MLS state from conversation. + let conv = store + .load_conversation(&conv_id) + .map_err(|e| SdkError::Storage(e.to_string()))? + .ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?; + let identity = client.identity_arc()?; + let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; + + quicproquo_sdk::groups::remove_member_from_group(rpc, store, &mut member, &conv_id, &member_key).await?; + println!("removed member {member_key_hex} from group"); + Ok(()) +} + +/// Leave a group. +pub async fn cmd_group_leave( + client: &mut QpqClient, + group_id_hex: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let store = client.conversations()?; + let group_id_bytes = hex::decode(group_id_hex) + .map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?; + let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes) + .ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?; + + let conv = store + .load_conversation(&conv_id) + .map_err(|e| SdkError::Storage(e.to_string()))? + .ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?; + let identity = client.identity_arc()?; + let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?; + + quicproquo_sdk::groups::leave_group(rpc, store, &mut member, &conv_id).await?; + println!("left group {group_id_hex}"); + Ok(()) +} + +/// Recover an account from a recovery code. +pub async fn cmd_recovery_restore( + client: &mut QpqClient, + code: &str, +) -> Result<(), SdkError> { + let rpc = client.rpc()?; + let (identity_seed, conversation_ids) = + quicproquo_sdk::recovery::recover_account(rpc, code).await?; + + // Restore identity. + let keypair = quicproquo_core::IdentityKeypair::from_seed(identity_seed); + client.set_identity_key(keypair.public_key_bytes().to_vec()); + + println!("account recovered successfully"); + println!("identity key: {}", hex::encode(keypair.public_key_bytes())); + if !conversation_ids.is_empty() { + println!( + "{} conversations need rejoin (peers must re-invite this device)", + conversation_ids.len() + ); + } + + // Save recovered state. + let state = quicproquo_sdk::state::StoredState { + identity_seed, + group: None, + hybrid_key: None, + member_keys: Vec::new(), + }; + let state_path = client.config_state_path(); + quicproquo_sdk::state::save_state(&state_path, &state, None)?; + println!("state saved to {}", state_path.display()); + + Ok(()) +} diff --git a/crates/quicproquo-client/src/v2_main.rs b/crates/quicproquo-client/src/v2_main.rs index e821529..fdb9486 100644 --- a/crates/quicproquo-client/src/v2_main.rs +++ b/crates/quicproquo-client/src/v2_main.rs @@ -122,6 +122,18 @@ enum Cmd { #[command(subcommand)] action: DevicesCmd, }, + + /// Account recovery management. + Recovery { + #[command(subcommand)] + action: RecoveryCmd, + }, + + /// Offline outbox management. + Outbox { + #[command(subcommand)] + action: OutboxCmd, + }, } #[derive(Debug, Subcommand)] @@ -163,6 +175,27 @@ enum DevicesCmd { }, } +#[derive(Debug, Subcommand)] +enum RecoveryCmd { + /// Generate recovery codes and upload encrypted bundles. + Setup, + /// Recover account from a recovery code. + Restore { + /// Recovery code (e.g. "A3B7K9"). + code: String, + }, +} + +#[derive(Debug, Subcommand)] +enum OutboxCmd { + /// Show pending outbox entries. + List, + /// Retry sending all pending outbox entries. + Retry, + /// Clear permanently failed outbox entries. + Clear, +} + // ── Auto-server launch ─────────────────────────────────────────────────────── /// RAII guard that kills an auto-started server process on drop. @@ -481,6 +514,49 @@ async fn run(args: Args) -> anyhow::Result<()> { .await .context("device revoke failed")?; } + + Cmd::Recovery { + action: RecoveryCmd::Setup, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_recovery_setup(&mut client) + .await + .context("recovery setup failed")?; + } + + Cmd::Recovery { + action: RecoveryCmd::Restore { ref code }, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_recovery_restore(&mut client, code) + .await + .context("recovery restore failed")?; + } + + Cmd::Outbox { + action: OutboxCmd::List, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_outbox_list(&client) + .context("outbox list failed")?; + } + + Cmd::Outbox { + action: OutboxCmd::Retry, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_outbox_retry(&mut client) + .await + .context("outbox retry failed")?; + } + + Cmd::Outbox { + action: OutboxCmd::Clear, + } => { + let mut client = connect_client(&args).await?; + v2_commands::cmd_outbox_clear(&client) + .context("outbox clear failed")?; + } } Ok(()) diff --git a/crates/quicproquo-core/src/lib.rs b/crates/quicproquo-core/src/lib.rs index d6501a5..658dd3b 100644 --- a/crates/quicproquo-core/src/lib.rs +++ b/crates/quicproquo-core/src/lib.rs @@ -40,6 +40,8 @@ mod error; mod hybrid_kem; mod identity; pub mod padding; +#[cfg(feature = "native")] +pub mod recovery; pub mod safety_numbers; pub mod sealed_sender; pub mod transcript; @@ -70,6 +72,11 @@ pub use hybrid_kem::{ HybridPublicKey, }; pub use identity::{verify_delivery_proof, IdentityKeypair}; +#[cfg(feature = "native")] +pub use recovery::{ + constant_time_eq, generate_recovery_codes, recover_from_bundle, recovery_token_hash, + RecoveryBundle, RecoveryPayload, RecoverySetup, MAX_BUNDLE_SIZE, RECOVERY_CODE_COUNT, +}; pub use safety_numbers::compute_safety_number; pub use transcript::{ read_transcript, validate_transcript_structure, ChainVerdict, DecodedRecord, TranscriptRecord, diff --git a/crates/quicproquo-core/src/recovery.rs b/crates/quicproquo-core/src/recovery.rs new file mode 100644 index 0000000..d6f2d7f --- /dev/null +++ b/crates/quicproquo-core/src/recovery.rs @@ -0,0 +1,342 @@ +//! Account recovery — recovery code generation and encrypted backup bundles. +//! +//! # Design +//! +//! Recovery codes are 8 alphanumeric strings of 6 characters each (~31 bits +//! entropy per code). Any single code is sufficient to recover the account. +//! +//! A recovery key is derived from each code via Argon2id. The identity seed +//! and conversation metadata are encrypted into a [`RecoveryBundle`] using +//! ChaCha20-Poly1305. The bundle is uploaded to the server, keyed by +//! `SHA-256(recovery_token)` — the server never sees plaintext codes. +//! +//! # Security properties +//! +//! - Recovery codes are shown once and never stored in plaintext. +//! - The server is zero-knowledge — it stores only encrypted blobs. +//! - Code validation uses constant-time comparison. +//! - All key material is zeroized on drop. + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zeroize::Zeroizing; + +use crate::error::CoreError; + +/// Number of recovery codes generated per setup. +pub const RECOVERY_CODE_COUNT: usize = 8; + +/// Length of each recovery code (alphanumeric characters). +const CODE_LENGTH: usize = 6; + +/// Maximum bundle size (64 KiB). +pub const MAX_BUNDLE_SIZE: usize = 64 * 1024; + +/// Argon2id parameters for recovery key derivation. +const ARGON2_M_COST: u32 = 19 * 1024; // 19 MiB +const ARGON2_T_COST: u32 = 2; +const ARGON2_P_COST: u32 = 1; + +/// Alphanumeric character set for recovery codes (uppercase + digits, no +/// ambiguous characters 0/O, 1/I/L). +const CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ"; + +/// An encrypted recovery bundle stored on the server. +/// +/// The server stores this keyed by `token_hash` (SHA-256 of a recovery token +/// derived from the code). The server cannot decrypt it. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecoveryBundle { + /// SHA-256 of the recovery token (used as server-side lookup key). + pub token_hash: Vec, + /// Random 16-byte salt for Argon2id key derivation. + pub salt: Vec, + /// Random 12-byte nonce for ChaCha20-Poly1305. + pub nonce: Vec, + /// Encrypted payload: bincode-serialised `RecoveryPayload`. + pub ciphertext: Vec, +} + +/// The plaintext payload inside a recovery bundle. +#[derive(Debug, Serialize, Deserialize)] +pub struct RecoveryPayload { + /// Ed25519 identity seed (32 bytes). + pub identity_seed: [u8; 32], + /// List of conversation/group IDs the user was part of (for rejoin). + pub conversation_ids: Vec>, +} + +/// Result of recovery code generation. +pub struct RecoverySetup { + /// The 8 recovery codes to show to the user (shown once, never stored). + pub codes: Vec, + /// Encrypted bundles — one per code — to upload to the server. + pub bundles: Vec, +} + +/// Generate a single random recovery code. +fn generate_code(rng: &mut impl RngCore) -> String { + let mut code = String::with_capacity(CODE_LENGTH); + for _ in 0..CODE_LENGTH { + let idx = (rng.next_u32() as usize) % CODE_ALPHABET.len(); + code.push(CODE_ALPHABET[idx] as char); + } + code +} + +/// Derive a 32-byte recovery token from a code (used for server-side lookup). +/// The token is `SHA-256("qpq-recovery-token:" || code)`. +fn derive_recovery_token(code: &str) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"qpq-recovery-token:"); + hasher.update(code.as_bytes()); + hasher.finalize().into() +} + +/// Derive a 32-byte encryption key from a code and salt via Argon2id. +fn derive_recovery_key(code: &str, salt: &[u8]) -> Result, CoreError> { + let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(32)) + .map_err(|e| CoreError::Io(format!("argon2 params: {e}")))?; + let argon2 = Argon2::new(Algorithm::Argon2id, Version::default(), params); + let mut key = Zeroizing::new([0u8; 32]); + argon2 + .hash_password_into(code.as_bytes(), salt, &mut *key) + .map_err(|e| CoreError::Io(format!("argon2 recovery key derivation: {e}")))?; + Ok(key) +} + +/// Generate recovery codes and encrypted bundles for an identity. +/// +/// Returns a `RecoverySetup` containing: +/// - `codes`: 8 recovery codes to display to the user (once). +/// - `bundles`: 8 encrypted recovery bundles (one per code) to upload to the server. +/// +/// Each code independently decrypts its corresponding bundle. +pub fn generate_recovery_codes( + identity_seed: &[u8; 32], + conversation_ids: &[Vec], +) -> Result { + let mut rng = rand::rngs::OsRng; + + let payload = RecoveryPayload { + identity_seed: *identity_seed, + conversation_ids: conversation_ids.to_vec(), + }; + let plaintext = bincode::serialize(&payload) + .map_err(|e| CoreError::Io(format!("serialize recovery payload: {e}")))?; + + let mut codes = Vec::with_capacity(RECOVERY_CODE_COUNT); + let mut bundles = Vec::with_capacity(RECOVERY_CODE_COUNT); + + for _ in 0..RECOVERY_CODE_COUNT { + let code = generate_code(&mut rng); + + // Derive the server-side lookup token. + let token = derive_recovery_token(&code); + let token_hash = Sha256::digest(token).to_vec(); + + // Derive encryption key from code. + let mut salt = [0u8; 16]; + rng.fill_bytes(&mut salt); + + let key = derive_recovery_key(&code, &salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key)); + + let mut nonce_bytes = [0u8; 12]; + rng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext.as_slice()) + .map_err(|e| CoreError::Io(format!("recovery bundle encryption: {e}")))?; + + bundles.push(RecoveryBundle { + token_hash, + salt: salt.to_vec(), + nonce: nonce_bytes.to_vec(), + ciphertext, + }); + codes.push(code); + } + + Ok(RecoverySetup { codes, bundles }) +} + +/// Recover an identity seed from a recovery code and encrypted bundle. +/// +/// Returns the decrypted `RecoveryPayload` on success. +pub fn recover_from_bundle( + code: &str, + bundle: &RecoveryBundle, +) -> Result { + // Validate bundle structure. + if bundle.salt.len() != 16 { + return Err(CoreError::Io(format!( + "invalid recovery bundle salt length: {}", + bundle.salt.len() + ))); + } + if bundle.nonce.len() != 12 { + return Err(CoreError::Io(format!( + "invalid recovery bundle nonce length: {}", + bundle.nonce.len() + ))); + } + + // Derive encryption key from code. + let key = derive_recovery_key(code, &bundle.salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&*key)); + let nonce = Nonce::from_slice(&bundle.nonce); + + let plaintext = cipher + .decrypt(nonce, bundle.ciphertext.as_slice()) + .map_err(|_| CoreError::Io("recovery bundle decryption failed (wrong code?)".into()))?; + + let payload: RecoveryPayload = bincode::deserialize(&plaintext) + .map_err(|e| CoreError::Io(format!("deserialize recovery payload: {e}")))?; + + Ok(payload) +} + +/// Compute the token hash for a recovery code (for server-side lookup). +/// +/// This is `SHA-256(SHA-256("qpq-recovery-token:" || code))`. +pub fn recovery_token_hash(code: &str) -> Vec { + let token = derive_recovery_token(code); + Sha256::digest(token).to_vec() +} + +/// Constant-time comparison of two byte slices. +/// +/// Returns `true` if the slices are equal, using constant-time comparison +/// to prevent timing side-channels on recovery code validation. +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn generate_codes_produces_correct_count() { + let seed = [42u8; 32]; + let setup = generate_recovery_codes(&seed, &[]).unwrap(); + assert_eq!(setup.codes.len(), RECOVERY_CODE_COUNT); + assert_eq!(setup.bundles.len(), RECOVERY_CODE_COUNT); + } + + #[test] + fn codes_are_correct_length_and_alphabet() { + let seed = [7u8; 32]; + let setup = generate_recovery_codes(&seed, &[]).unwrap(); + for code in &setup.codes { + assert_eq!(code.len(), CODE_LENGTH); + for ch in code.chars() { + assert!( + CODE_ALPHABET.contains(&(ch as u8)), + "invalid char '{ch}' in code" + ); + } + } + } + + #[test] + fn codes_are_unique() { + let seed = [1u8; 32]; + let setup = generate_recovery_codes(&seed, &[]).unwrap(); + let mut seen = std::collections::HashSet::new(); + for code in &setup.codes { + assert!(seen.insert(code.clone()), "duplicate code: {code}"); + } + } + + #[test] + fn recover_roundtrip() { + let seed = [99u8; 32]; + let conv_ids = vec![vec![1, 2, 3], vec![4, 5, 6]]; + let setup = generate_recovery_codes(&seed, &conv_ids).unwrap(); + + // Each code should decrypt its corresponding bundle. + for (i, code) in setup.codes.iter().enumerate() { + let payload = recover_from_bundle(code, &setup.bundles[i]).unwrap(); + assert_eq!(payload.identity_seed, seed); + assert_eq!(payload.conversation_ids, conv_ids); + } + } + + #[test] + fn wrong_code_fails() { + let seed = [50u8; 32]; + let setup = generate_recovery_codes(&seed, &[]).unwrap(); + let result = recover_from_bundle("WRONG1", &setup.bundles[0]); + assert!(result.is_err()); + } + + #[test] + fn code_does_not_decrypt_other_bundle() { + let seed = [88u8; 32]; + let setup = generate_recovery_codes(&seed, &[]).unwrap(); + // Code 0 should NOT decrypt bundle 1 (different salt/nonce/key). + let result = recover_from_bundle(&setup.codes[0], &setup.bundles[1]); + assert!(result.is_err()); + } + + #[test] + fn token_hash_is_deterministic() { + let hash1 = recovery_token_hash("ABC123"); + let hash2 = recovery_token_hash("ABC123"); + assert_eq!(hash1, hash2); + } + + #[test] + fn token_hash_differs_for_different_codes() { + let hash1 = recovery_token_hash("ABC123"); + let hash2 = recovery_token_hash("XYZ789"); + assert_ne!(hash1, hash2); + } + + #[test] + fn constant_time_eq_works() { + assert!(constant_time_eq(b"hello", b"hello")); + assert!(!constant_time_eq(b"hello", b"world")); + assert!(!constant_time_eq(b"hello", b"hell")); + assert!(constant_time_eq(b"", b"")); + } + + #[test] + fn invalid_bundle_salt_rejected() { + let bundle = RecoveryBundle { + token_hash: vec![0; 32], + salt: vec![0; 8], // wrong length + nonce: vec![0; 12], + ciphertext: vec![0; 32], + }; + assert!(recover_from_bundle("ABC123", &bundle).is_err()); + } + + #[test] + fn invalid_bundle_nonce_rejected() { + let bundle = RecoveryBundle { + token_hash: vec![0; 32], + salt: vec![0; 16], + nonce: vec![0; 8], // wrong length + ciphertext: vec![0; 32], + }; + assert!(recover_from_bundle("ABC123", &bundle).is_err()); + } +} diff --git a/crates/quicproquo-proto/build.rs b/crates/quicproquo-proto/build.rs index 5dfb16d..c1747fd 100644 --- a/crates/quicproquo-proto/build.rs +++ b/crates/quicproquo-proto/build.rs @@ -51,6 +51,9 @@ fn main() { "qpq/v1/p2p.proto", "qpq/v1/federation.proto", "qpq/v1/push.proto", + "qpq/v1/group.proto", + "qpq/v1/moderation.proto", + "qpq/v1/recovery.proto", ]; let full_paths: Vec = proto_files.iter().map(|f| proto_dir.join(f)).collect(); diff --git a/crates/quicproquo-proto/src/lib.rs b/crates/quicproquo-proto/src/lib.rs index 4f429f0..54f3fd3 100644 --- a/crates/quicproquo-proto/src/lib.rs +++ b/crates/quicproquo-proto/src/lib.rs @@ -97,6 +97,12 @@ pub mod method_ids { // Channel (400) pub const CREATE_CHANNEL: u16 = 400; + // Group management (410-413) + pub const REMOVE_MEMBER: u16 = 410; + pub const UPDATE_GROUP_METADATA: u16 = 411; + pub const LIST_GROUP_MEMBERS: u16 = 412; + pub const ROTATE_KEYS: u16 = 413; + // User (500-501) pub const RESOLVE_USER: u16 = 500; pub const RESOLVE_IDENTITY: u16 = 501; @@ -123,6 +129,18 @@ pub mod method_ids { pub const PROXY_RESOLVE_USER: u16 = 904; pub const FEDERATION_HEALTH: u16 = 905; + // Moderation (420-424) + pub const REPORT_MESSAGE: u16 = 420; + pub const BAN_USER: u16 = 421; + pub const UNBAN_USER: u16 = 422; + pub const LIST_REPORTS: u16 = 423; + pub const LIST_BANNED: u16 = 424; + + // Recovery (750-752) + pub const STORE_RECOVERY_BUNDLE: u16 = 750; + pub const FETCH_RECOVERY_BUNDLE: u16 = 751; + pub const DELETE_RECOVERY_BUNDLE: u16 = 752; + // Account (950) pub const DELETE_ACCOUNT: u16 = 950; diff --git a/crates/quicproquo-sdk/src/lib.rs b/crates/quicproquo-sdk/src/lib.rs index 38bb1c7..16192cf 100644 --- a/crates/quicproquo-sdk/src/lib.rs +++ b/crates/quicproquo-sdk/src/lib.rs @@ -14,5 +14,6 @@ pub mod groups; pub mod keys; pub mod messaging; pub mod outbox; +pub mod recovery; pub mod state; pub mod users; diff --git a/crates/quicproquo-sdk/src/recovery.rs b/crates/quicproquo-sdk/src/recovery.rs new file mode 100644 index 0000000..522bbfc --- /dev/null +++ b/crates/quicproquo-sdk/src/recovery.rs @@ -0,0 +1,119 @@ +//! Account recovery — setup, upload, and restore via recovery codes. +//! +//! Wraps `quicproquo_core::recovery` and the v2 RPC recovery service. + +use bytes::Bytes; +use prost::Message; +use quicproquo_core::recovery::{ + generate_recovery_codes, recover_from_bundle, recovery_token_hash, RecoveryBundle, +}; +use quicproquo_proto::{method_ids, qpq::v1}; +use quicproquo_rpc::client::RpcClient; + +use crate::error::SdkError; + +/// Set up account recovery: generate codes, encrypt bundles, upload to server. +/// +/// Returns the recovery codes (display to user once, never store). +pub async fn setup_recovery( + rpc: &RpcClient, + identity_seed: &[u8; 32], + conversation_ids: &[Vec], +) -> Result, SdkError> { + let setup = generate_recovery_codes(identity_seed, conversation_ids) + .map_err(|e| SdkError::Crypto(format!("recovery code generation: {e}")))?; + + // Upload each encrypted bundle to the server. + for bundle in &setup.bundles { + let bundle_bytes = bincode::serialize(bundle) + .map_err(|e| SdkError::Crypto(format!("serialize recovery bundle: {e}")))?; + + let req = v1::StoreRecoveryBundleRequest { + token_hash: bundle.token_hash.clone(), + bundle: bundle_bytes, + ttl_secs: 0, // Use server default (90 days). + }; + + let resp_bytes = rpc + .call( + method_ids::STORE_RECOVERY_BUNDLE, + Bytes::from(req.encode_to_vec()), + ) + .await?; + + let resp = v1::StoreRecoveryBundleResponse::decode(resp_bytes) + .map_err(|e| SdkError::Crypto(format!("decode store_recovery response: {e}")))?; + + if !resp.success { + return Err(SdkError::Crypto( + "server rejected recovery bundle upload".into(), + )); + } + } + + Ok(setup.codes) +} + +/// Recover an account from a recovery code. +/// +/// Fetches the encrypted bundle from the server, decrypts it with the code, +/// and returns the identity seed and conversation IDs. +pub async fn recover_account( + rpc: &RpcClient, + code: &str, +) -> Result<(/* identity_seed */ [u8; 32], /* conversation_ids */ Vec>), SdkError> { + // Compute the token hash for server-side lookup. + let token_hash = recovery_token_hash(code); + + let req = v1::FetchRecoveryBundleRequest { + token_hash: token_hash.clone(), + }; + + let resp_bytes = rpc + .call( + method_ids::FETCH_RECOVERY_BUNDLE, + Bytes::from(req.encode_to_vec()), + ) + .await?; + + let resp = v1::FetchRecoveryBundleResponse::decode(resp_bytes) + .map_err(|e| SdkError::Crypto(format!("decode fetch_recovery response: {e}")))?; + + if resp.bundle.is_empty() { + return Err(SdkError::Crypto( + "no recovery bundle found for this code".into(), + )); + } + + // Deserialize the bundle. + let bundle: RecoveryBundle = bincode::deserialize(&resp.bundle) + .map_err(|e| SdkError::Crypto(format!("deserialize recovery bundle: {e}")))?; + + // Decrypt with the code. + let payload = recover_from_bundle(code, &bundle) + .map_err(|e| SdkError::Crypto(format!("recovery decryption failed: {e}")))?; + + Ok((payload.identity_seed, payload.conversation_ids)) +} + +/// Delete all recovery bundles for the given codes (e.g. after refresh). +pub async fn delete_recovery_bundles( + rpc: &RpcClient, + codes: &[String], +) -> Result<(), SdkError> { + for code in codes { + let token_hash = recovery_token_hash(code); + let req = v1::DeleteRecoveryBundleRequest { token_hash }; + + let resp_bytes = rpc + .call( + method_ids::DELETE_RECOVERY_BUNDLE, + Bytes::from(req.encode_to_vec()), + ) + .await?; + + let _resp = v1::DeleteRecoveryBundleResponse::decode(resp_bytes) + .map_err(|e| SdkError::Crypto(format!("decode delete_recovery response: {e}")))?; + } + Ok(()) +} diff --git a/crates/quicproquo-server/migrations/011_recovery_bundles.sql b/crates/quicproquo-server/migrations/011_recovery_bundles.sql new file mode 100644 index 0000000..3f4f622 --- /dev/null +++ b/crates/quicproquo-server/migrations/011_recovery_bundles.sql @@ -0,0 +1,7 @@ +-- Recovery bundle storage: encrypted bundles keyed by token_hash. +CREATE TABLE IF NOT EXISTS recovery_bundles ( + token_hash BLOB PRIMARY KEY, + bundle BLOB NOT NULL, + ttl_secs INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); diff --git a/crates/quicproquo-server/src/domain/recovery.rs b/crates/quicproquo-server/src/domain/recovery.rs new file mode 100644 index 0000000..9144678 --- /dev/null +++ b/crates/quicproquo-server/src/domain/recovery.rs @@ -0,0 +1,76 @@ +//! Recovery domain logic — encrypted recovery bundle CRUD. + +use std::sync::Arc; + +use crate::storage::Store; + +use super::types::DomainError; + +/// Maximum recovery bundle size: 64 KiB. +const MAX_BUNDLE_SIZE: usize = 64 * 1024; + +/// Default TTL for recovery bundles: 90 days. +pub const DEFAULT_TTL_SECS: u64 = 90 * 24 * 60 * 60; + +/// Domain service for recovery bundle operations. +pub struct RecoveryService { + pub store: Arc, +} + +impl RecoveryService { + /// Store an encrypted recovery bundle. + /// + /// `token_hash` is the SHA-256 of a recovery token derived from the code. + /// `bundle` is the encrypted blob (opaque to server). + /// `ttl_secs` is the time-to-live; 0 uses the default (90 days). + pub fn store_bundle( + &self, + token_hash: &[u8], + bundle: Vec, + ttl_secs: u64, + ) -> Result<(), DomainError> { + if token_hash.len() != 32 { + return Err(DomainError::BadParams(format!( + "token_hash must be 32 bytes, got {}", + token_hash.len() + ))); + } + if bundle.is_empty() { + return Err(DomainError::BadParams("recovery bundle must not be empty".into())); + } + if bundle.len() > MAX_BUNDLE_SIZE { + return Err(DomainError::BadParams(format!( + "recovery bundle exceeds max size ({} > {MAX_BUNDLE_SIZE})", + bundle.len() + ))); + } + + let ttl = if ttl_secs == 0 { DEFAULT_TTL_SECS } else { ttl_secs }; + self.store.store_recovery_bundle(token_hash, bundle, ttl)?; + Ok(()) + } + + /// Fetch an encrypted recovery bundle by token_hash. + pub fn fetch_bundle(&self, token_hash: &[u8]) -> Result>, DomainError> { + if token_hash.len() != 32 { + return Err(DomainError::BadParams(format!( + "token_hash must be 32 bytes, got {}", + token_hash.len() + ))); + } + let bundle = self.store.get_recovery_bundle(token_hash)?; + Ok(bundle) + } + + /// Delete an encrypted recovery bundle by token_hash. + pub fn delete_bundle(&self, token_hash: &[u8]) -> Result { + if token_hash.len() != 32 { + return Err(DomainError::BadParams(format!( + "token_hash must be 32 bytes, got {}", + token_hash.len() + ))); + } + let deleted = self.store.delete_recovery_bundle(token_hash)?; + Ok(deleted) + } +} diff --git a/crates/quicproquo-server/src/storage.rs b/crates/quicproquo-server/src/storage.rs index 35fa8fb..303baaa 100644 --- a/crates/quicproquo-server/src/storage.rs +++ b/crates/quicproquo-server/src/storage.rs @@ -221,6 +221,7 @@ pub trait Store: Send + Sync { ) -> Result<(), StorageError>; /// Retrieve group metadata by group_id. + #[allow(clippy::type_complexity)] fn get_group_metadata(&self, group_id: &[u8]) -> Result, Vec, u64)>, StorageError>; /// Store a group membership record. @@ -276,6 +277,7 @@ pub trait Store: Send + Sync { fn is_banned(&self, identity_key: &[u8]) -> Result, StorageError>; /// List all currently banned users: (identity_key, reason, banned_at, expires_at). + #[allow(clippy::type_complexity)] fn list_banned(&self) -> Result, String, u64, u64)>, StorageError>; // ── Session persistence ──────────────────────────────────────────────── diff --git a/crates/quicproquo-server/src/v2_handlers/mod.rs b/crates/quicproquo-server/src/v2_handlers/mod.rs index 28b8754..319b8c2 100644 --- a/crates/quicproquo-server/src/v2_handlers/mod.rs +++ b/crates/quicproquo-server/src/v2_handlers/mod.rs @@ -22,8 +22,11 @@ pub mod channel; pub mod delivery; pub mod device; pub mod federation; +pub mod group; pub mod keys; +pub mod moderation; pub mod p2p; +pub mod recovery; pub mod user; /// Shared server state accessible by all v2 RPC handlers. @@ -41,6 +44,31 @@ pub struct ServerState { pub kt_log: Arc>, pub data_dir: PathBuf, pub redact_logs: bool, + /// Idempotency dedup: message_id -> (seq, timestamp). TTL-cleaned by cleanup task. + pub seen_message_ids: Arc, (u64, u64)>>, + /// Banned users: identity_key -> BanRecord. + pub banned_users: Arc, BanRecord>>, + /// Moderation reports (append-only). + pub moderation_reports: Arc>>, +} + +/// A ban record for a user. +#[derive(Debug, Clone)] +pub struct BanRecord { + pub reason: String, + pub banned_at: u64, + /// 0 = permanent. + pub expires_at: u64, +} + +/// A stored moderation report. +#[derive(Debug, Clone)] +pub struct ModerationReport { + pub id: u64, + pub encrypted_report: Vec, + pub conversation_id: Vec, + pub reporter_identity: Vec, + pub timestamp: u64, } /// Validate the session token from the request context and return the @@ -64,6 +92,18 @@ pub fn require_auth(state: &ServerState, ctx: &RequestContext) -> Result if let Some(session) = state.sessions.get(token) { let now = crate::auth::current_timestamp(); if session.expires_at > now && !session.identity_key.is_empty() { + // Check ban status. + if let Some(ban) = state.banned_users.get(&session.identity_key) { + if ban.expires_at == 0 || ban.expires_at > now { + return Err(HandlerResult::err( + RpcStatus::Forbidden, + "account banned", + )); + } + // Ban expired — remove it. + drop(ban); + state.banned_users.remove(&session.identity_key); + } return Ok(session.identity_key.clone()); } } @@ -94,7 +134,7 @@ pub fn domain_err(e: crate::domain::types::DomainError) -> HandlerResult { | DomainError::BlobHashLength(_) | DomainError::BadParams(_) => HandlerResult::err(RpcStatus::BadRequest, &e.to_string()), - DomainError::BlobNotFound | DomainError::DeviceNotFound => { + DomainError::BlobNotFound | DomainError::DeviceNotFound | DomainError::GroupNotFound => { HandlerResult::err(RpcStatus::NotFound, &e.to_string()) } @@ -190,6 +230,28 @@ pub fn build_registry() -> MethodRegistry { channel::handle_create_channel, ); + // Group management (410-413) + reg.register( + method_ids::REMOVE_MEMBER, + "RemoveMember", + group::handle_remove_member, + ); + reg.register( + method_ids::UPDATE_GROUP_METADATA, + "UpdateGroupMetadata", + group::handle_update_group_metadata, + ); + reg.register( + method_ids::LIST_GROUP_MEMBERS, + "ListGroupMembers", + group::handle_list_group_members, + ); + reg.register( + method_ids::ROTATE_KEYS, + "RotateKeys", + group::handle_rotate_keys, + ); + // User (500-501) reg.register( method_ids::RESOLVE_USER, @@ -276,6 +338,50 @@ pub fn build_registry() -> MethodRegistry { federation::handle_federation_health, ); + // Moderation (420-424) + reg.register( + method_ids::REPORT_MESSAGE, + "ReportMessage", + moderation::handle_report_message, + ); + reg.register( + method_ids::BAN_USER, + "BanUser", + moderation::handle_ban_user, + ); + reg.register( + method_ids::UNBAN_USER, + "UnbanUser", + moderation::handle_unban_user, + ); + reg.register( + method_ids::LIST_REPORTS, + "ListReports", + moderation::handle_list_reports, + ); + reg.register( + method_ids::LIST_BANNED, + "ListBanned", + moderation::handle_list_banned, + ); + + // Recovery (750-752) + reg.register( + method_ids::STORE_RECOVERY_BUNDLE, + "StoreRecoveryBundle", + recovery::handle_store_recovery_bundle, + ); + reg.register( + method_ids::FETCH_RECOVERY_BUNDLE, + "FetchRecoveryBundle", + recovery::handle_fetch_recovery_bundle, + ); + reg.register( + method_ids::DELETE_RECOVERY_BUNDLE, + "DeleteRecoveryBundle", + recovery::handle_delete_recovery_bundle, + ); + // Account (950) reg.register( method_ids::DELETE_ACCOUNT, diff --git a/crates/quicproquo-server/src/v2_handlers/recovery.rs b/crates/quicproquo-server/src/v2_handlers/recovery.rs new file mode 100644 index 0000000..1f5569a --- /dev/null +++ b/crates/quicproquo-server/src/v2_handlers/recovery.rs @@ -0,0 +1,99 @@ +//! Recovery handlers — store/fetch/delete encrypted recovery bundles. + +use std::sync::Arc; + +use bytes::Bytes; +use prost::Message; +use quicproquo_proto::qpq::v1; +use quicproquo_rpc::method::{HandlerResult, RequestContext}; + +use crate::domain::recovery::RecoveryService; + +use super::{domain_err, ServerState}; + +/// Store an encrypted recovery bundle (no auth required — recovery is pre-login). +pub async fn handle_store_recovery_bundle( + state: Arc, + ctx: RequestContext, +) -> HandlerResult { + let req = match v1::StoreRecoveryBundleRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => { + return HandlerResult::err( + quicproquo_rpc::error::RpcStatus::BadRequest, + &format!("decode: {e}"), + ) + } + }; + + let svc = RecoveryService { + store: Arc::clone(&state.store), + }; + + match svc.store_bundle(&req.token_hash, req.bundle, req.ttl_secs) { + Ok(()) => { + let proto = v1::StoreRecoveryBundleResponse { success: true }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) + } + Err(e) => domain_err(e), + } +} + +/// Fetch an encrypted recovery bundle (no auth required — recovery is pre-login). +pub async fn handle_fetch_recovery_bundle( + state: Arc, + ctx: RequestContext, +) -> HandlerResult { + let req = match v1::FetchRecoveryBundleRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => { + return HandlerResult::err( + quicproquo_rpc::error::RpcStatus::BadRequest, + &format!("decode: {e}"), + ) + } + }; + + let svc = RecoveryService { + store: Arc::clone(&state.store), + }; + + match svc.fetch_bundle(&req.token_hash) { + Ok(bundle_opt) => { + let proto = v1::FetchRecoveryBundleResponse { + bundle: bundle_opt.unwrap_or_default(), + }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) + } + Err(e) => domain_err(e), + } +} + +/// Delete an encrypted recovery bundle (no auth required — caller proves +/// knowledge of the token_hash). +pub async fn handle_delete_recovery_bundle( + state: Arc, + ctx: RequestContext, +) -> HandlerResult { + let req = match v1::DeleteRecoveryBundleRequest::decode(ctx.payload) { + Ok(r) => r, + Err(e) => { + return HandlerResult::err( + quicproquo_rpc::error::RpcStatus::BadRequest, + &format!("decode: {e}"), + ) + } + }; + + let svc = RecoveryService { + store: Arc::clone(&state.store), + }; + + match svc.delete_bundle(&req.token_hash) { + Ok(deleted) => { + let proto = v1::DeleteRecoveryBundleResponse { success: deleted }; + HandlerResult::ok(Bytes::from(proto.encode_to_vec())) + } + Err(e) => domain_err(e), + } +} diff --git a/proto/qpq/v1/recovery.proto b/proto/qpq/v1/recovery.proto new file mode 100644 index 0000000..604cbff --- /dev/null +++ b/proto/qpq/v1/recovery.proto @@ -0,0 +1,37 @@ +syntax = "proto3"; +package qpq.v1; + +// Recovery service — encrypted recovery bundle storage. +// Method IDs: 750-752. + +message StoreRecoveryBundleRequest { + // SHA-256(recovery_token) — server-side lookup key. + bytes token_hash = 1; + // Encrypted recovery bundle (opaque to server). + bytes bundle = 2; + // TTL in seconds (default 90 days = 7776000). + uint64 ttl_secs = 3; +} + +message StoreRecoveryBundleResponse { + bool success = 1; +} + +message FetchRecoveryBundleRequest { + // SHA-256(recovery_token) — lookup key. + bytes token_hash = 1; +} + +message FetchRecoveryBundleResponse { + // Empty if no bundle found. + bytes bundle = 1; +} + +message DeleteRecoveryBundleRequest { + // SHA-256(recovery_token) — lookup key. + bytes token_hash = 1; +} + +message DeleteRecoveryBundleResponse { + bool success = 1; +}