chore: rename project quicnprotochat -> quicproquo (binaries: qpq)
Rename the entire workspace:
- Crate packages: quicnprotochat-{core,proto,server,client,gui,p2p,mobile} -> quicproquo-*
- Binary names: quicnprotochat -> qpq, quicnprotochat-server -> qpq-server,
quicnprotochat-gui -> qpq-gui
- Default files: *-state.bin -> qpq-state.bin, *-server.toml -> qpq-server.toml,
*.db -> qpq.db
- Environment variable prefix: QUICNPROTOCHAT_* -> QPQ_*
- App identifier: chat.quicnproto.gui -> chat.quicproquo.gui
- Proto package: quicnprotochat.bench -> quicproquo.bench
- All documentation, Docker, CI, and script references updated
HKDF domain-separation strings and P2P ALPN remain unchanged for
backward compatibility with existing encrypted state and wire protocol.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
567
crates/quicproquo-client/src/main.rs
Normal file
567
crates/quicproquo-client/src/main.rs
Normal file
@@ -0,0 +1,567 @@
|
||||
//! quicproquo CLI client.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use quicproquo_client::{
|
||||
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, 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,
|
||||
};
|
||||
|
||||
// ── 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>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: 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>,
|
||||
},
|
||||
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
// ── 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, defer init_auth so it can resolve its own token via OPAQUE.
|
||||
// For all other subcommands, initialize auth immediately.
|
||||
let is_repl = matches!(args.command, Command::Repl { .. });
|
||||
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();
|
||||
|
||||
match args.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,
|
||||
} => {
|
||||
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(),
|
||||
))
|
||||
.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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user