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