//! quicproquo CLI client. use std::path::PathBuf; use anyhow::Context; use clap::{Parser, Subcommand}; use quicproquo_client::{ cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_export, cmd_export_verify, cmd_fetch_key, cmd_health, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register, cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami, init_auth, run_repl, ClientAuth, }; #[cfg(feature = "tui")] use quicproquo_client::client::tui::run_tui; // ── CLI ─────────────────────────────────────────────────────────────────────── #[derive(Debug, Parser)] #[command(name = "qpq", about = "quicproquo CLI client", version)] struct Args { /// Path to the server's TLS certificate (self-signed by default). #[arg( long, global = true, default_value = "data/server-cert.der", env = "QPQ_CA_CERT" )] ca_cert: PathBuf, /// Expected TLS server name (must match the certificate SAN). #[arg( long, global = true, default_value = "localhost", env = "QPQ_SERVER_NAME" )] server_name: String, /// Bearer token or OPAQUE session token for authenticated requests. /// Not required for register-user and login commands. #[arg( long, global = true, env = "QPQ_ACCESS_TOKEN", default_value = "" )] access_token: String, /// Optional device identifier (UUID bytes encoded as hex or raw string). #[arg(long, global = true, env = "QPQ_DEVICE_ID")] device_id: Option, /// Password to encrypt/decrypt client state files (QPCE format). /// If set, state files are encrypted at rest with Argon2id + ChaCha20Poly1305. #[arg(long, global = true, env = "QPQ_STATE_PASSWORD")] state_password: Option, // ── Default-repl args (used when no subcommand is given) ───────── /// State file path (identity + MLS state). Used when running the default REPL. #[arg(long, default_value = "qpq-state.bin", env = "QPQ_STATE")] state: PathBuf, /// Server address (host:port). Used when running the default REPL. #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// OPAQUE username for automatic registration/login. #[arg(long, env = "QPQ_USERNAME")] username: Option, /// OPAQUE password (prompted securely if --username is set but --password is not). #[arg(long, env = "QPQ_PASSWORD")] password: Option, /// Do not auto-start a local qpq-server (useful when connecting to a remote server). #[arg(long, env = "QPQ_NO_SERVER")] no_server: bool, #[command(subcommand)] command: Option, } #[derive(Debug, Subcommand)] enum Command { /// Register a new user via OPAQUE (password never leaves the client). RegisterUser { #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Username for the new account. #[arg(long)] username: String, /// Password (will be used in OPAQUE PAKE; server never sees it). #[arg(long)] password: String, }, /// Log in via OPAQUE and receive a session token. Login { #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, #[arg(long)] username: String, #[arg(long)] password: String, /// Hex-encoded Ed25519 identity key (64 hex chars). Optional if --state is provided. #[arg(long)] identity_key: Option, /// State file to derive the identity key (requires same password if encrypted). #[arg(long)] state: Option, /// Password for the encrypted state file (if any). #[arg(long)] state_password: Option, }, /// Show local identity key, fingerprint, group status, and hybrid key status. Whoami { /// State file path (identity + MLS state). #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, }, /// Check server connectivity and print status. Health { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Check if a peer has registered a hybrid key (non-consuming lookup). CheckKey { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Peer's Ed25519 identity public key (64 hex chars = 32 bytes). identity_key: String, }, /// Send a Ping to the server and print the round-trip time. Ping { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Generate a fresh MLS KeyPackage and upload it to the Authentication Service. Register { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Fetch a peer's KeyPackage from the Authentication Service. FetchKey { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes). identity_key: String, }, /// Run a two-party MLS demo (creator + joiner) against live AS and DS. DemoGroup { /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Upload the persistent identity's KeyPackage to the AS (uses state file). RegisterState { /// State file path (identity + MLS state). #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, /// Authentication Service address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Refresh the KeyPackage on the server (existing state only). /// Run periodically (e.g. before server TTL ~24h) or after your KeyPackage was consumed so others can invite you. RefreshKeypackage { /// State file path (identity + MLS state). #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Create a persistent group and save state to disk. CreateGroup { /// State file path (identity + MLS state). #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, /// Server address (host:port). #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Group identifier (arbitrary bytes, typically a human-readable name). #[arg(long)] group_id: String, }, /// Invite a peer into the group and deliver a Welcome via DS. Invite { #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Peer identity public key (64 hex chars = 32 bytes). #[arg(long)] peer_key: String, }, /// Join a group by fetching the Welcome from the DS. Join { #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, }, /// Send an application message via the DS. Send { #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Recipient identity key (hex, 32 bytes -> 64 chars). Omit when using --all. #[arg(long)] peer_key: Option, /// Send to all other group members (N-way groups). #[arg(long)] all: bool, /// Plaintext message to send. #[arg(long)] msg: String, }, /// Receive and decrypt all pending messages from the DS. Recv { #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Wait for up to this many milliseconds if no messages are queued. #[arg(long, default_value_t = 0)] wait_ms: u64, /// Continuously long-poll for messages. #[arg(long)] stream: bool, }, /// Interactive multi-conversation REPL. Supports /dm, /create-group, /invite, /join, /switch, and more. /// Automatically registers and logs in if --username/--password are provided (or prompts interactively). Repl { #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// OPAQUE username for automatic registration/login. #[arg(long, env = "QPQ_USERNAME")] username: Option, /// OPAQUE password (prompted securely if --username is set but --password is not). #[arg(long, env = "QPQ_PASSWORD")] password: Option, /// Do not auto-start a local qpq-server. #[arg(long, env = "QPQ_NO_SERVER")] no_server: bool, }, /// Full-screen Ratatui TUI (requires --features tui). /// Channels sidebar, scrollable message view, and inline input bar. #[cfg(feature = "tui")] Tui { #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// OPAQUE username for automatic registration/login. #[arg(long, env = "QPQ_USERNAME")] username: Option, /// OPAQUE password (prompted securely if --username is set but --password is not). #[arg(long, env = "QPQ_PASSWORD")] password: Option, }, /// Interactive 1:1 chat: type to send, incoming messages printed as [peer] . Ctrl+D to exit. /// In a two-person group, peer is chosen automatically; use --peer-key only with 3+ members. Chat { #[arg( long, default_value = "qpq-state.bin", env = "QPQ_STATE" )] state: PathBuf, #[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")] server: String, /// Peer identity key (hex, 64 chars). Omit in a two-person group to use the only other member. #[arg(long)] peer_key: Option, /// How often to poll for incoming messages (milliseconds). #[arg(long, default_value_t = 500)] poll_interval_ms: u64, }, /// Export a conversation's message history to an encrypted, tamper-evident transcript file. /// /// The output file uses Argon2id + ChaCha20-Poly1305 encryption with a SHA-256 hash chain /// linking every record. Use `qpq export verify` to check chain integrity without decrypting. Export { /// Path to the conversation database (.convdb file). #[arg(long, default_value = "qpq-convdb.sqlite", env = "QPQ_CONV_DB")] conv_db: PathBuf, /// Conversation ID to export (32 hex chars = 16 bytes). #[arg(long)] conv_id: String, /// Output path for the .qpqt transcript file. #[arg(long, default_value = "transcript.qpqt")] output: PathBuf, /// Password used to encrypt the transcript (separate from the state/DB password). #[arg(long, env = "QPQ_TRANSCRIPT_PASSWORD")] transcript_password: Option, /// Password for the encrypted conversation database (if any). #[arg(long, env = "QPQ_STATE_PASSWORD")] db_password: Option, }, /// Verify the hash-chain integrity of a transcript file without decrypting content. ExportVerify { /// Path to the .qpqt transcript file to verify. #[arg(long)] input: PathBuf, }, } // ── Helpers ─────────────────────────────────────────────────────────────────── /// Returns `qpq-{username}.bin` when `state` is still at the default /// (`qpq-state.bin`) and a username has been provided. Otherwise returns /// `state` unchanged. This lets `qpq --username alice` automatically isolate /// Alice's state without requiring a manual `--state` flag. fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf { if state == PathBuf::from("qpq-state.bin") { if let Some(uname) = username { return PathBuf::from(format!("qpq-{uname}.bin")); } } state } // ── Entry point ─────────────────────────────────────────────────────────────── #[tokio::main] async fn main() -> anyhow::Result<()> { // 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(); // For the REPL and TUI, defer init_auth so they can resolve their own token via OPAQUE. // For all other subcommands, initialize auth immediately. #[cfg(not(feature = "tui"))] let is_repl = matches!(args.command, None | Some(Command::Repl { .. })); #[cfg(feature = "tui")] let is_repl = matches!(args.command, None | Some(Command::Repl { .. }) | Some(Command::Tui { .. })); if !is_repl { let auth_ctx = ClientAuth::from_parts(args.access_token.clone(), args.device_id.clone()); init_auth(auth_ctx); } let state_pw = args.state_password.as_deref(); // Default to REPL when no subcommand is given. let no_server = args.no_server; let command = args.command.unwrap_or_else(|| Command::Repl { state: derive_state_path(args.state, args.username.as_deref()), server: args.server, username: args.username, password: args.password, no_server, }); match command { Command::RegisterUser { server, username, password, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_register_user( &server, &args.ca_cert, &args.server_name, &username, &password, None, )) .await } Command::Login { server, username, password, identity_key, state, state_password, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_login( &server, &args.ca_cert, &args.server_name, &username, &password, identity_key.as_deref(), state.as_deref(), state_password.as_deref(), )) .await } Command::Whoami { state } => cmd_whoami(&state, state_pw), Command::Health { server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_health(&server, &args.ca_cert, &args.server_name)) .await } Command::CheckKey { server, identity_key, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_check_key( &server, &args.ca_cert, &args.server_name, &identity_key, )) .await } Command::Ping { server } => cmd_ping(&server, &args.ca_cert, &args.server_name).await, Command::Register { server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_register(&server, &args.ca_cert, &args.server_name)) .await } Command::FetchKey { server, identity_key, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_fetch_key( &server, &args.ca_cert, &args.server_name, &identity_key, )) .await } Command::DemoGroup { server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_demo_group(&server, &args.ca_cert, &args.server_name)) .await } Command::RegisterState { state, server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_register_state( &state, &server, &args.ca_cert, &args.server_name, state_pw, )) .await } Command::RefreshKeypackage { state, server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_refresh_keypackage( &state, &server, &args.ca_cert, &args.server_name, state_pw, )) .await } Command::CreateGroup { state, server, group_id, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_create_group(&state, &server, &group_id, state_pw)) .await } Command::Invite { state, server, peer_key, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_invite( &state, &server, &args.ca_cert, &args.server_name, &peer_key, state_pw, )) .await } Command::Join { state, server } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_join( &state, &server, &args.ca_cert, &args.server_name, state_pw, )) .await } Command::Send { state, server, peer_key, all, msg, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_send( &state, &server, &args.ca_cert, &args.server_name, peer_key.as_deref(), all, &msg, state_pw, )) .await } Command::Recv { state, server, wait_ms, stream, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_recv( &state, &server, &args.ca_cert, &args.server_name, wait_ms, stream, state_pw, )) .await } Command::Repl { state, server, username, password, no_server, } => { let state = derive_state_path(state, username.as_deref()); let local = tokio::task::LocalSet::new(); local .run_until(run_repl( &state, &server, &args.ca_cert, &args.server_name, state_pw, username.as_deref(), password.as_deref(), &args.access_token, args.device_id.as_deref(), no_server, )) .await } Command::Chat { state, server, peer_key, poll_interval_ms, } => { let local = tokio::task::LocalSet::new(); local .run_until(cmd_chat( &state, &server, &args.ca_cert, &args.server_name, peer_key.as_deref(), state_pw, poll_interval_ms, )) .await } #[cfg(feature = "tui")] Command::Tui { state, server, username, password, } => { let state = derive_state_path(state, username.as_deref()); let local = tokio::task::LocalSet::new(); local .run_until(run_tui( &state, &server, &args.ca_cert, &args.server_name, state_pw, username.as_deref(), password.as_deref(), &args.access_token, args.device_id.as_deref(), )) .await } Command::Export { conv_db, conv_id, output, transcript_password, db_password, } => { // Prompt for transcript password if not provided. let tp = match transcript_password { Some(p) => p, None => { eprint!("Transcript password: "); rpassword::read_password() .context("failed to read transcript password")? } }; cmd_export( &conv_db, &conv_id, &output, &tp, db_password.as_deref().or(state_pw), ) } Command::ExportVerify { input } => cmd_export_verify(&input), } }