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