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