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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user