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).
This commit is contained in:
2026-03-04 20:12:20 +01:00
parent 5b6d8209f0
commit 12b19b6931
14 changed files with 1120 additions and 1 deletions

View File

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

View File

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

View File

@@ -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,

View File

@@ -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<u8>,
/// Random 16-byte salt for Argon2id key derivation.
pub salt: Vec<u8>,
/// Random 12-byte nonce for ChaCha20-Poly1305.
pub nonce: Vec<u8>,
/// Encrypted payload: bincode-serialised `RecoveryPayload`.
pub ciphertext: Vec<u8>,
}
/// 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<Vec<u8>>,
}
/// Result of recovery code generation.
pub struct RecoverySetup {
/// The 8 recovery codes to show to the user (shown once, never stored).
pub codes: Vec<String>,
/// Encrypted bundles — one per code — to upload to the server.
pub bundles: Vec<RecoveryBundle>,
}
/// 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<Zeroizing<[u8; 32]>, 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<u8>],
) -> Result<RecoverySetup, CoreError> {
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<RecoveryPayload, CoreError> {
// 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<u8> {
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());
}
}

View File

@@ -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<PathBuf> = proto_files.iter().map(|f| proto_dir.join(f)).collect();

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<u8>],
) -> Result<Vec<String>, 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<Vec<u8>>), 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(())
}

View File

@@ -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'))
);

View File

@@ -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<dyn Store>,
}
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<u8>,
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<Option<Vec<u8>>, 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<bool, DomainError> {
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)
}
}

View File

@@ -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<Option<(String, String, Vec<u8>, Vec<u8>, u64)>, StorageError>;
/// Store a group membership record.
@@ -276,6 +277,7 @@ pub trait Store: Send + Sync {
fn is_banned(&self, identity_key: &[u8]) -> Result<Option<String>, StorageError>;
/// List all currently banned users: (identity_key, reason, banned_at, expires_at).
#[allow(clippy::type_complexity)]
fn list_banned(&self) -> Result<Vec<(Vec<u8>, String, u64, u64)>, StorageError>;
// ── Session persistence ────────────────────────────────────────────────

View File

@@ -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<std::sync::Mutex<quicproquo_kt::MerkleLog>>,
pub data_dir: PathBuf,
pub redact_logs: bool,
/// Idempotency dedup: message_id -> (seq, timestamp). TTL-cleaned by cleanup task.
pub seen_message_ids: Arc<DashMap<Vec<u8>, (u64, u64)>>,
/// Banned users: identity_key -> BanRecord.
pub banned_users: Arc<DashMap<Vec<u8>, BanRecord>>,
/// Moderation reports (append-only).
pub moderation_reports: Arc<std::sync::Mutex<Vec<ModerationReport>>>,
}
/// 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<u8>,
pub conversation_id: Vec<u8>,
pub reporter_identity: Vec<u8>,
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<Vec<u8>
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<ServerState> {
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<ServerState> {
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,

View File

@@ -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<ServerState>,
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<ServerState>,
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<ServerState>,
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),
}
}