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

@@ -126,3 +126,229 @@ pub async fn cmd_devices_revoke(
}
Ok(())
}
/// Set up account recovery — generate codes and upload encrypted bundles.
pub async fn cmd_recovery_setup(client: &mut QpqClient) -> Result<(), SdkError> {
// Load identity seed from state file.
let state_path = client.config_state_path();
let stored = quicproquo_sdk::state::load_state(&state_path, None)
.map_err(|e| SdkError::Crypto(format!("load identity for recovery: {e}")))?;
let rpc = client.rpc()?;
let codes =
quicproquo_sdk::recovery::setup_recovery(rpc, &stored.identity_seed, &[]).await?;
println!("=== RECOVERY CODES ===");
println!("Save these codes securely. They will NOT be shown again.");
println!("Each code can independently recover your account.");
println!();
for (i, code) in codes.iter().enumerate() {
println!(" {}. {}", i + 1, code);
}
println!();
println!("{} codes generated and uploaded.", codes.len());
Ok(())
}
// ── Outbox commands ──────────────────────────────────────────────────────────
/// List pending outbox entries.
pub fn cmd_outbox_list(client: &QpqClient) -> Result<(), SdkError> {
let store = client.conversations()?;
let entries = quicproquo_sdk::outbox::list_pending(store)?;
if entries.is_empty() {
println!("outbox is empty — no pending messages");
} else {
println!("{:<6} {:<34} {:<8} PAYLOAD SIZE", "ID", "CONVERSATION", "RETRIES");
for e in &entries {
println!(
"{:<6} {:<34} {:<8} {} bytes",
e.id,
e.conversation_id.hex(),
e.retry_count,
e.payload.len(),
);
}
println!("\n{} pending entries", entries.len());
}
Ok(())
}
/// Retry sending all pending outbox entries.
pub async fn cmd_outbox_retry(client: &mut QpqClient) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let (sent, failed) = quicproquo_sdk::outbox::flush_outbox(rpc, store).await?;
println!("outbox flush: {sent} sent, {failed} permanently failed");
Ok(())
}
/// Clear permanently failed outbox entries.
pub fn cmd_outbox_clear(client: &QpqClient) -> Result<(), SdkError> {
let store = client.conversations()?;
let cleared = quicproquo_sdk::outbox::clear_failed(store)?;
println!("cleared {cleared} failed outbox entries");
Ok(())
}
// ── Group lifecycle commands ─────────────────────────────────────────────────
/// List members of a group.
pub async fn cmd_group_members(
client: &mut QpqClient,
group_id_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
let members = quicproquo_sdk::groups::get_group_members(rpc, &conv_id).await?;
if members.is_empty() {
println!("no members found (or group not registered server-side)");
} else {
println!("{:<40} {:<20} JOINED AT", "IDENTITY KEY", "USERNAME");
for m in &members {
println!(
"{:<40} {:<20} {}",
hex::encode(&m.identity_key),
m.username,
m.joined_at,
);
}
println!("\n{} members", members.len());
}
Ok(())
}
/// Rename a group (update metadata).
pub async fn cmd_group_rename(
client: &mut QpqClient,
group_id_hex: &str,
new_name: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
quicproquo_sdk::groups::set_group_metadata(rpc, store, &conv_id, new_name, "", &[]).await?;
println!("group renamed to: {new_name}");
Ok(())
}
/// Rotate keys for a group.
pub async fn cmd_group_rotate_keys(
client: &mut QpqClient,
group_id_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
// Load MLS state from conversation.
let conv = store
.load_conversation(&conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?
.ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?;
let identity = client.identity_arc()?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
quicproquo_sdk::groups::rotate_group_keys(rpc, store, &mut member, &conv_id).await?;
println!("keys rotated for group {group_id_hex}");
Ok(())
}
/// Remove a member from a group.
pub async fn cmd_group_remove_member(
client: &mut QpqClient,
group_id_hex: &str,
member_key_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
let member_key = hex::decode(member_key_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid member key hex: {e}")))?;
// Load MLS state from conversation.
let conv = store
.load_conversation(&conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?
.ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?;
let identity = client.identity_arc()?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
quicproquo_sdk::groups::remove_member_from_group(rpc, store, &mut member, &conv_id, &member_key).await?;
println!("removed member {member_key_hex} from group");
Ok(())
}
/// Leave a group.
pub async fn cmd_group_leave(
client: &mut QpqClient,
group_id_hex: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let store = client.conversations()?;
let group_id_bytes = hex::decode(group_id_hex)
.map_err(|e| SdkError::Other(anyhow::anyhow!("invalid group_id hex: {e}")))?;
let conv_id = quicproquo_sdk::conversation::ConversationId::from_slice(&group_id_bytes)
.ok_or_else(|| SdkError::Other(anyhow::anyhow!("group_id must be 16 bytes")))?;
let conv = store
.load_conversation(&conv_id)
.map_err(|e| SdkError::Storage(e.to_string()))?
.ok_or_else(|| SdkError::ConversationNotFound(conv_id.hex()))?;
let identity = client.identity_arc()?;
let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
quicproquo_sdk::groups::leave_group(rpc, store, &mut member, &conv_id).await?;
println!("left group {group_id_hex}");
Ok(())
}
/// Recover an account from a recovery code.
pub async fn cmd_recovery_restore(
client: &mut QpqClient,
code: &str,
) -> Result<(), SdkError> {
let rpc = client.rpc()?;
let (identity_seed, conversation_ids) =
quicproquo_sdk::recovery::recover_account(rpc, code).await?;
// Restore identity.
let keypair = quicproquo_core::IdentityKeypair::from_seed(identity_seed);
client.set_identity_key(keypair.public_key_bytes().to_vec());
println!("account recovered successfully");
println!("identity key: {}", hex::encode(keypair.public_key_bytes()));
if !conversation_ids.is_empty() {
println!(
"{} conversations need rejoin (peers must re-invite this device)",
conversation_ids.len()
);
}
// Save recovered state.
let state = quicproquo_sdk::state::StoredState {
identity_seed,
group: None,
hybrid_key: None,
member_keys: Vec::new(),
};
let state_path = client.config_state_path();
quicproquo_sdk::state::save_state(&state_path, &state, None)?;
println!("state saved to {}", state_path.display());
Ok(())
}

View File

@@ -122,6 +122,18 @@ enum Cmd {
#[command(subcommand)]
action: DevicesCmd,
},
/// Account recovery management.
Recovery {
#[command(subcommand)]
action: RecoveryCmd,
},
/// Offline outbox management.
Outbox {
#[command(subcommand)]
action: OutboxCmd,
},
}
#[derive(Debug, Subcommand)]
@@ -163,6 +175,27 @@ enum DevicesCmd {
},
}
#[derive(Debug, Subcommand)]
enum RecoveryCmd {
/// Generate recovery codes and upload encrypted bundles.
Setup,
/// Recover account from a recovery code.
Restore {
/// Recovery code (e.g. "A3B7K9").
code: String,
},
}
#[derive(Debug, Subcommand)]
enum OutboxCmd {
/// Show pending outbox entries.
List,
/// Retry sending all pending outbox entries.
Retry,
/// Clear permanently failed outbox entries.
Clear,
}
// ── Auto-server launch ───────────────────────────────────────────────────────
/// RAII guard that kills an auto-started server process on drop.
@@ -481,6 +514,49 @@ async fn run(args: Args) -> anyhow::Result<()> {
.await
.context("device revoke failed")?;
}
Cmd::Recovery {
action: RecoveryCmd::Setup,
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_recovery_setup(&mut client)
.await
.context("recovery setup failed")?;
}
Cmd::Recovery {
action: RecoveryCmd::Restore { ref code },
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_recovery_restore(&mut client, code)
.await
.context("recovery restore failed")?;
}
Cmd::Outbox {
action: OutboxCmd::List,
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_outbox_list(&client)
.context("outbox list failed")?;
}
Cmd::Outbox {
action: OutboxCmd::Retry,
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_outbox_retry(&mut client)
.await
.context("outbox retry failed")?;
}
Cmd::Outbox {
action: OutboxCmd::Clear,
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_outbox_clear(&client)
.context("outbox clear failed")?;
}
}
Ok(())