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).
564 lines
17 KiB
Rust
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(())
|
|
}
|