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

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