chore: rename quicproquo → quicprochat in Rust workspace

Rename all crate directories, package names, binary names, proto
package/module paths, ALPN strings, env var prefixes, config filenames,
mDNS service names, and plugin ABI symbols from quicproquo/qpq to
quicprochat/qpc.
This commit is contained in:
2026-03-07 18:24:52 +01:00
parent d8c1392587
commit a710037dde
212 changed files with 609 additions and 609 deletions

View File

@@ -0,0 +1,883 @@
//! quicprochat CLI client.
// ── v2 feature gate: when compiled with --features v2, use the SDK-based CLI.
#[cfg(feature = "v2")]
mod v2_commands;
#[cfg(feature = "v2")]
mod v2_main;
#[cfg(feature = "v2")]
fn main() {
v2_main::main();
}
// ── v1 CLI (default) ─────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
use std::path::{Path, PathBuf};
#[cfg(not(feature = "v2"))]
use anyhow::Context;
#[cfg(not(feature = "v2"))]
use clap::{Parser, Subcommand};
#[cfg(not(feature = "v2"))]
use quicprochat_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, set_insecure_skip_verify, ClientAuth,
};
#[cfg(all(feature = "tui", not(feature = "v2")))]
use quicprochat_client::client::tui::run_tui;
// ── CLI ───────────────────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
#[derive(Debug, Parser)]
#[command(name = "qpc", about = "quicprochat 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>,
/// DANGER: Skip TLS certificate verification. Development only.
/// Disables all certificate checks, making the connection vulnerable to MITM attacks.
#[arg(
long = "danger-accept-invalid-certs",
global = true,
env = "QPQ_DANGER_ACCEPT_INVALID_CERTS"
)]
danger_accept_invalid_certs: bool,
// ── 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 = "qpc-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 qpc-server (useful when connecting to a remote server).
#[arg(long, env = "QPQ_NO_SERVER")]
no_server: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[cfg(not(feature = "v2"))]
#[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 = "qpc-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 = "qpc-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 = "qpc-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 = "qpc-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 = "qpc-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 = "qpc-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 = "qpc-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 = "qpc-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 = "qpc-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 qpc-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 = "qpc-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 = "qpc-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 `qpc export verify` to check chain integrity without decrypting.
Export {
/// Path to the conversation database (.convdb file).
#[arg(long, default_value = "qpc-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 .qpct transcript file.
#[arg(long, default_value = "transcript.qpct")]
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 .qpct transcript file to verify.
#[arg(long)]
input: PathBuf,
},
/// Execute a YAML playbook (scripted command sequence) and exit.
/// Requires `--features playbook`.
#[cfg(feature = "playbook")]
Run {
/// Path to the YAML playbook file.
playbook: PathBuf,
/// State file path (identity + MLS state).
#[arg(long, default_value = "qpc-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,
/// OPAQUE username for automatic login.
#[arg(long, env = "QPQ_USERNAME")]
username: Option<String>,
/// OPAQUE password.
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
/// Override playbook variables: KEY=VALUE (repeatable).
#[arg(long = "var", short = 'V')]
vars: Vec<String>,
},
}
// ── Helpers ───────────────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
/// Returns `qpc-{username}.bin` when `state` is still at the default
/// (`qpc-state.bin`) and a username has been provided. Otherwise returns
/// `state` unchanged. This lets `qpc --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 == Path::new("qpc-state.bin") {
if let Some(uname) = username {
return PathBuf::from(format!("qpc-{uname}.bin"));
}
}
state
}
// ── Playbook execution ───────────────────────────────────────────────────────
#[cfg(all(feature = "playbook", not(feature = "v2")))]
async fn run_playbook(
playbook_path: &Path,
state: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
state_pw: Option<&str>,
username: Option<&str>,
password: Option<&str>,
access_token: &str,
device_id: Option<&str>,
extra_vars: &[String],
) -> anyhow::Result<()> {
use quicprochat_client::PlaybookRunner;
let insecure = std::env::var("QPQ_DANGER_ACCEPT_INVALID_CERTS").is_ok();
// Connect to server.
let client =
quicprochat_client::connect_node_opt(server, ca_cert, server_name, insecure)
.await
.context("connect to server")?;
// Build session state.
let mut session = quicprochat_client::client::session::SessionState::load(state, state_pw)
.context("load session state")?;
// If username/password provided, do OPAQUE login.
if let (Some(uname), Some(pw)) = (username, password) {
if let Err(e) =
quicprochat_client::opaque_login(&client, uname, pw, &session.identity.public_key_bytes()).await
{
eprintln!("OPAQUE login failed: {e:#}");
}
} else if !access_token.is_empty() {
let auth = ClientAuth::from_parts(access_token.to_string(), device_id.map(String::from));
init_auth(auth);
}
// Load playbook.
let mut runner = PlaybookRunner::from_file(playbook_path)
.with_context(|| format!("load playbook: {}", playbook_path.display()))?;
// Inject extra variables from --var KEY=VALUE flags.
for kv in extra_vars {
if let Some((k, v)) = kv.split_once('=') {
runner.set_var(k, v);
} else {
eprintln!("warning: ignoring malformed --var '{kv}' (expected KEY=VALUE)");
}
}
// Inject connection info as variables.
runner.set_var("_server", server);
if let Some(u) = username {
runner.set_var("_username", u);
}
let report = runner.run(&mut session, &client).await;
print!("{report}");
if report.all_passed() {
Ok(())
} else {
std::process::exit(1);
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
#[cfg(not(feature = "v2"))]
#[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();
if args.danger_accept_invalid_certs {
eprintln!("WARNING: TLS verification disabled — insecure mode");
set_insecure_skip_verify(true);
}
// 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),
#[cfg(feature = "playbook")]
Command::Run {
playbook,
state,
server,
username,
password,
vars,
} => {
let state = derive_state_path(state, username.as_deref());
let local = tokio::task::LocalSet::new();
local
.run_until(run_playbook(
&playbook,
&state,
&server,
&args.ca_cert,
&args.server_name,
state_pw,
username.as_deref(),
password.as_deref(),
&args.access_token,
args.device_id.as_deref(),
&vars,
))
.await
}
}
}