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:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
342
crates/quicproquo-core/src/recovery.rs
Normal file
342
crates/quicproquo-core/src/recovery.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
119
crates/quicproquo-sdk/src/recovery.rs
Normal file
119
crates/quicproquo-sdk/src/recovery.rs
Normal 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(())
|
||||
}
|
||||
@@ -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'))
|
||||
);
|
||||
76
crates/quicproquo-server/src/domain/recovery.rs
Normal file
76
crates/quicproquo-server/src/domain/recovery.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
99
crates/quicproquo-server/src/v2_handlers/recovery.rs
Normal file
99
crates/quicproquo-server/src/v2_handlers/recovery.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
37
proto/qpq/v1/recovery.proto
Normal file
37
proto/qpq/v1/recovery.proto
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user