Files
quicproquo/crates/quicproquo-client/src/main.rs
Chris Nennemann dc4e4e49a0 feat: Phase 9 — developer experience, extensibility, and community growth
New crates:
- quicproquo-bot: Bot SDK with polling API + JSON pipe mode
- quicproquo-kt: Key Transparency Merkle log (RFC 9162 subset)
- quicproquo-plugin-api: no_std C-compatible plugin vtable API
- quicproquo-gen: scaffolding tool (qpq-gen plugin/bot/rpc/hook)

Server features:
- ServerHooks trait wired into all RPC handlers (enqueue, fetch, auth,
  channel, registration) with plugin rejection support
- Dynamic plugin loader (libloading) with --plugin-dir config
- Delivery proof canary tokens (Ed25519 server signatures on enqueue)
- Key Transparency Merkle log with inclusion proofs on resolveUser

Core library:
- Safety numbers (60-digit HMAC-SHA256 key verification codes)
- Verifiable transcript archive (CBOR + ChaCha20-Poly1305 + hash chain)
- Delivery proof verification utility
- Criterion benchmarks (hybrid KEM, MLS, identity, sealed sender, padding)

Client:
- /verify REPL command for out-of-band key verification
- Full-screen TUI via Ratatui (feature-gated --features tui)
- qpq export / qpq export-verify CLI subcommands
- KT inclusion proof verification on user resolution

Also: ROADMAP Phase 9 added, bot SDK docs, server hooks docs,
crate-responsibilities updated, example plugins (rate_limit, logging).
2026-03-03 22:47:38 +01:00

727 lines
24 KiB
Rust

//! 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<String>,
/// 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<String>,
// ── 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<String>,
/// OPAQUE password (prompted securely if --username is set but --password is not).
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
/// 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<Command>,
}
#[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<String>,
/// State file to derive the identity key (requires same password if encrypted).
#[arg(long)]
state: Option<PathBuf>,
/// Password for the encrypted state file (if any).
#[arg(long)]
state_password: Option<String>,
},
/// 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<String>,
/// 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<String>,
/// OPAQUE password (prompted securely if --username is set but --password is not).
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
/// 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<String>,
/// OPAQUE password (prompted securely if --username is set but --password is not).
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
},
/// Interactive 1:1 chat: type to send, incoming messages printed as [peer] <msg>. 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<String>,
/// 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<String>,
/// Password for the encrypted conversation database (if any).
#[arg(long, env = "QPQ_STATE_PASSWORD")]
db_password: Option<String>,
},
/// 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),
}
}