Files
quicproquo/crates/quicproquo-client/src/v2_main.rs
Christian Nennemann 12b19b6931 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).
2026-03-04 20:12:20 +01:00

564 lines
17 KiB
Rust

//! v2 CLI entry point — thin shell over `quicproquo_sdk::QpqClient`.
//!
//! Activated via `--features v2`. Replaces the v1 Cap'n Proto RPC main
//! with a simplified command surface backed by the SDK.
use std::path::{Path, PathBuf};
use std::process::Command as ProcessCommand;
use std::time::Duration;
use anyhow::Context;
use clap::{Parser, Subcommand};
use quicproquo_sdk::client::QpqClient;
use quicproquo_sdk::config::ClientConfig;
use crate::v2_commands;
// ── CLI ───────────────────────────────────────────────────────────────────────
#[derive(Debug, Parser)]
#[command(name = "qpq", about = "quicproquo CLI client (v2)", version)]
struct Args {
/// Server address (host:port).
#[arg(long, global = true, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// TLS server name (must match certificate SAN).
#[arg(long, global = true, default_value = "localhost", env = "QPQ_SERVER_NAME")]
server_name: String,
/// Path to local conversation database.
#[arg(long, global = true, default_value = "conversations.db", env = "QPQ_CONV_DB")]
db_path: PathBuf,
/// Password for encrypting the local database.
#[arg(long, global = true, env = "QPQ_DB_PASSWORD")]
db_password: Option<String>,
/// Path to the client state file (identity key, MLS state).
#[arg(long, global = true, default_value = "qpq-state.bin", env = "QPQ_STATE")]
state: PathBuf,
/// DANGER: Skip TLS certificate verification. Development only.
#[arg(
long = "danger-accept-invalid-certs",
global = true,
env = "QPQ_DANGER_ACCEPT_INVALID_CERTS"
)]
danger_accept_invalid_certs: bool,
/// Do not auto-start a local qpq-server.
#[arg(long, global = true, env = "QPQ_NO_SERVER")]
no_server: bool,
#[command(subcommand)]
command: Cmd,
}
#[derive(Debug, Subcommand)]
enum Cmd {
/// Register a new user via OPAQUE (password never leaves the client).
RegisterUser {
/// Username for the new account.
#[arg(long)]
username: String,
/// Password (used in OPAQUE PAKE; server never sees it).
#[arg(long)]
password: String,
},
/// Log in via OPAQUE and receive a session token.
Login {
#[arg(long)]
username: String,
#[arg(long)]
password: String,
},
/// Show local identity info.
Whoami,
/// Server health check.
Health,
/// Send a message to a conversation.
Send {
/// Conversation name (group name or DM peer username).
#[arg(long)]
to: String,
/// Message text.
#[arg(long)]
msg: String,
},
/// Receive pending messages from a conversation.
Recv {
/// Conversation name.
#[arg(long)]
from: String,
},
/// Start or resume a DM with a user.
Dm {
/// Peer username.
username: String,
},
/// Group management commands.
Group {
#[command(subcommand)]
action: GroupCmd,
},
/// Resolve a username to its identity key.
Resolve {
/// Username to look up.
username: String,
},
/// Device management.
Devices {
#[command(subcommand)]
action: DevicesCmd,
},
/// Account recovery management.
Recovery {
#[command(subcommand)]
action: RecoveryCmd,
},
/// Offline outbox management.
Outbox {
#[command(subcommand)]
action: OutboxCmd,
},
}
#[derive(Debug, Subcommand)]
enum GroupCmd {
/// Create a new group.
Create {
/// Group name.
name: String,
},
/// Invite a user to a group.
Invite {
/// Group name.
#[arg(long)]
group: String,
/// Username to invite.
#[arg(long)]
user: String,
},
}
#[derive(Debug, Subcommand)]
enum DevicesCmd {
/// List registered devices.
List,
/// Register a new device.
Register {
/// Device ID (hex).
#[arg(long)]
id: String,
/// Human-readable device name.
#[arg(long)]
name: String,
},
/// Revoke a device.
Revoke {
/// Device ID (hex).
#[arg(long)]
id: String,
},
}
#[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.
struct ServerGuard(Option<std::process::Child>);
impl Drop for ServerGuard {
fn drop(&mut self) {
if let Some(ref mut child) = self.0 {
let _ = child.kill();
let _ = child.wait();
}
}
}
/// Find the `qpq-server` binary: same directory as current exe, then PATH.
fn find_server_binary() -> Option<PathBuf> {
if let Ok(exe) = std::env::current_exe() {
let sibling = exe.with_file_name("qpq-server");
if sibling.exists() {
return Some(sibling);
}
}
std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|dir| dir.join("qpq-server"))
.find(|p| p.exists())
})
}
/// Try a QUIC health probe to the server address.
async fn probe_server(server_addr: &str) -> bool {
use std::net::ToSocketAddrs;
let addr = match server_addr.to_socket_addrs() {
Ok(mut addrs) => match addrs.next() {
Some(a) => a,
None => return false,
},
Err(_) => return false,
};
// Simple TCP probe — if the port is open, the server is likely running.
tokio::net::TcpStream::connect(addr)
.await
.is_ok()
}
/// Start a local qpq-server if one isn't already running.
/// Returns a guard that kills the child on drop (if we started one).
async fn ensure_server_running(
server_addr: &str,
data_dir: &Path,
no_server: bool,
) -> anyhow::Result<ServerGuard> {
if no_server {
return Ok(ServerGuard(None));
}
if probe_server(server_addr).await {
return Ok(ServerGuard(None));
}
let binary = find_server_binary().ok_or_else(|| {
anyhow::anyhow!(
"server at {server_addr} is not reachable and qpq-server binary not found; \
start a server manually or install qpq-server"
)
})?;
let cert_path = data_dir.join("server-cert.der");
let key_path = data_dir.join("server-key.der");
eprintln!("starting server on {server_addr}...");
let child = ProcessCommand::new(&binary)
.args([
"--allow-insecure-auth",
"--listen",
server_addr,
"--tls-cert",
&cert_path.to_string_lossy(),
"--tls-key",
&key_path.to_string_lossy(),
])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.with_context(|| format!("failed to spawn {}", binary.display()))?;
let guard = ServerGuard(Some(child));
// Poll until the server is ready.
let mut delay = Duration::from_millis(100);
let max_wait = Duration::from_secs(3);
let start = std::time::Instant::now();
loop {
tokio::time::sleep(delay).await;
if probe_server(server_addr).await {
eprintln!("server ready");
return Ok(guard);
}
if start.elapsed() > max_wait {
anyhow::bail!(
"auto-started qpq-server but it did not become ready within {max_wait:?}"
);
}
delay = (delay * 2).min(Duration::from_secs(1));
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Build a `ClientConfig` from CLI args.
fn build_config(args: &Args) -> anyhow::Result<ClientConfig> {
let server_addr = args
.server
.parse()
.with_context(|| format!("invalid server address: {}", args.server))?;
Ok(ClientConfig {
server_addr,
server_name: args.server_name.clone(),
db_path: args.db_path.clone(),
db_password: args.db_password.clone(),
state_path: args.state.clone(),
accept_invalid_certs: args.danger_accept_invalid_certs,
..ClientConfig::default()
})
}
/// Build, connect, and return a `QpqClient`. Loads identity from state file
/// if it exists.
async fn connect_client(args: &Args) -> anyhow::Result<QpqClient> {
let config = build_config(args)?;
let mut client = QpqClient::new(config);
// Try loading identity from state file.
if args.state.exists() {
match quicproquo_sdk::state::load_state(&args.state, args.db_password.as_deref()) {
Ok(stored) => {
let keypair = quicproquo_core::IdentityKeypair::from_seed(stored.identity_seed);
client.set_identity_key(keypair.public_key_bytes().to_vec());
}
Err(e) => {
tracing::debug!("could not load state from {}: {e}", args.state.display());
}
}
}
client.connect().await.context("failed to connect to server")?;
Ok(client)
}
// ── Entry point ──────────────────────────────────────────────────────────────
pub fn main() {
// Install the rustls crypto provider before any TLS operations.
let _ = rustls::crypto::ring::default_provider().install_default();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
)
.init();
let args = Args::parse();
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap_or_else(|e| {
eprintln!("fatal: {e}");
std::process::exit(1);
});
if let Err(e) = rt.block_on(run(args)) {
eprintln!("error: {e:#}");
std::process::exit(1);
}
}
async fn run(args: Args) -> anyhow::Result<()> {
// Auto-start server if needed (except for whoami which is local-only).
let data_dir = args.state.parent().unwrap_or_else(|| Path::new("."));
let _server_guard = match args.command {
Cmd::Whoami => ServerGuard(None),
_ => ensure_server_running(&args.server, data_dir, args.no_server).await?,
};
match args.command {
Cmd::RegisterUser {
ref username,
ref password,
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_register_user(&mut client, username, password)
.await
.context("register-user failed")?;
}
Cmd::Login {
ref username,
ref password,
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_login(&mut client, username, password)
.await
.context("login failed")?;
}
Cmd::Whoami => {
// Whoami is local-only — create client without connecting.
let config = build_config(&args)?;
let mut client = QpqClient::new(config);
if args.state.exists() {
match quicproquo_sdk::state::load_state(
&args.state,
args.db_password.as_deref(),
) {
Ok(stored) => {
let keypair =
quicproquo_core::IdentityKeypair::from_seed(stored.identity_seed);
client.set_identity_key(keypair.public_key_bytes().to_vec());
}
Err(e) => {
eprintln!("warning: could not load state: {e}");
}
}
}
v2_commands::cmd_whoami(&client);
}
Cmd::Health => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_health(&mut client)
.await
.context("health check failed")?;
}
Cmd::Resolve { ref username } => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_resolve(&mut client, username)
.await
.context("resolve failed")?;
}
Cmd::Dm { ref username } => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_resolve(&mut client, username)
.await
.context("dm setup failed")?;
// For now, print the resolved key. Full DM creation requires
// MLS group state, which will be handled in the REPL flow.
println!("(DM creation with full MLS setup is available in the REPL)");
}
Cmd::Send { ref to, ref msg } => {
let _ = (to, msg);
let _client = connect_client(&args).await?;
// Full send requires MLS group state restoration — deferred to REPL.
println!("(send is currently available in the REPL; one-shot send coming soon)");
}
Cmd::Recv { ref from } => {
let _ = from;
let _client = connect_client(&args).await?;
println!("(recv is currently available in the REPL; one-shot recv coming soon)");
}
Cmd::Group {
action: GroupCmd::Create { ref name },
} => {
let _ = name;
let _client = connect_client(&args).await?;
println!("(group create is currently available in the REPL; one-shot coming soon)");
}
Cmd::Group {
action:
GroupCmd::Invite {
ref group,
ref user,
},
} => {
let _ = (group, user);
let _client = connect_client(&args).await?;
println!("(group invite is currently available in the REPL; one-shot coming soon)");
}
Cmd::Devices {
action: DevicesCmd::List,
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_devices_list(&mut client)
.await
.context("devices list failed")?;
}
Cmd::Devices {
action: DevicesCmd::Register { ref id, ref name },
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_devices_register(&mut client, id, name)
.await
.context("device register failed")?;
}
Cmd::Devices {
action: DevicesCmd::Revoke { ref id },
} => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_devices_revoke(&mut client, id)
.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(())
}