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).
120 lines
3.9 KiB
Rust
120 lines
3.9 KiB
Rust
//! 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(())
|
|
}
|