Files
quicproquo/crates/quicprochat-client/src/client/commands.rs
Christian Nennemann a710037dde 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.
2026-03-21 19:14:06 +01:00

1401 lines
48 KiB
Rust

use std::path::{Path, PathBuf};
use anyhow::Context;
use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
};
use quicprochat_core::{
generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite,
GroupMember, HybridKeypair, IdentityKeypair, ReceivedMessage,
};
use super::{
hex,
rpc::{
connect_node, current_timestamp_ms, enqueue, fetch_all, fetch_hybrid_key,
fetch_key_package, fetch_wait, try_hybrid_decrypt, upload_hybrid_key, upload_key_package,
},
state::{decode_identity_key, load_existing_state, load_or_init_state, save_state, sha256},
};
/// Print local identity information from the state file (no server connection).
pub fn cmd_whoami(state_path: &Path, password: Option<&str>) -> anyhow::Result<()> {
let state = load_existing_state(state_path, password)?;
let identity = IdentityKeypair::from_seed(state.identity_seed);
let pk_bytes = identity.public_key_bytes();
let fingerprint = sha256(&pk_bytes);
println!("identity_key : {}", hex::encode(pk_bytes));
println!("fingerprint : {}", hex::encode(fingerprint));
println!(
"hybrid_key : {}",
if state.hybrid_key.is_some() {
"present (X25519 + ML-KEM-768)"
} else {
"not generated"
}
);
println!(
"group : {}",
if state.group.is_some() {
"active"
} else {
"none"
}
);
println!("state_file : {}", state_path.display());
Ok(())
}
/// Check server connectivity via the health RPC.
pub async fn cmd_health(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
let sent_at = current_timestamp_ms();
let client = connect_node(server, ca_cert, server_name).await?;
let req = client.health_request();
let resp = req.send().promise.await.context("health RPC failed")?;
let status = resp
.get()
.context("health: bad response")?
.get_status()
.context("health: missing status")?
.to_str()
.unwrap_or("invalid");
let rtt_ms = current_timestamp_ms().saturating_sub(sent_at);
println!("server : {server}");
println!("status : {status}");
println!("rtt : {rtt_ms}ms");
Ok(())
}
/// Check if a peer identity has registered a hybrid public key (non-consuming).
pub async fn cmd_check_key(
server: &str,
ca_cert: &Path,
server_name: &str,
identity_key_hex: &str,
) -> anyhow::Result<()> {
let identity_key = decode_identity_key(identity_key_hex)?;
let node_client = connect_node(server, ca_cert, server_name).await?;
let hybrid_pk = fetch_hybrid_key(&node_client, &identity_key).await?;
println!("identity_key : {identity_key_hex}");
println!(
"hybrid_key : {}",
if hybrid_pk.is_some() {
"available (X25519 + ML-KEM-768)"
} else {
"not found"
}
);
Ok(())
}
/// Connect to `server`, call health, and print RTT over QUIC/TLS.
pub async fn cmd_ping(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
let sent_at = current_timestamp_ms();
let client = connect_node(server, ca_cert, server_name).await?;
let req = client.health_request();
let resp = req.send().promise.await.context("health RPC failed")?;
let status = resp
.get()
.context("health: bad response")?
.get_status()
.context("health: missing status")?
.to_str()
.unwrap_or("invalid");
let rtt_ms = current_timestamp_ms().saturating_sub(sent_at);
println!("health={status} rtt={rtt_ms}ms");
Ok(())
}
/// Register a new user account via the OPAQUE protocol.
pub async fn cmd_register_user(
server: &str,
ca_cert: &Path,
server_name: &str,
username: &str,
password: &str,
identity_key_hex: Option<&str>,
) -> anyhow::Result<()> {
let mut rng = rand::rngs::OsRng;
let node_client = connect_node(server, ca_cert, server_name).await?;
let identity_key = if let Some(hex_str) = identity_key_hex {
Some(decode_identity_key(hex_str)?)
} else {
None
};
let reg_start = ClientRegistration::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE register start: {e}"))?;
let mut req = node_client.opaque_register_start_request();
{
let mut p = req.get();
p.set_username(username);
p.set_request(&reg_start.message.serialize());
}
let resp = req
.send()
.promise
.await
.context("opaque_register_start RPC failed")?;
let response_bytes = resp
.get()
.context("register_start: bad response")?
.get_response()
.context("register_start: missing response")?
.to_vec();
let reg_response = RegistrationResponse::<OpaqueSuite>::deserialize(&response_bytes)
.map_err(|e| anyhow::anyhow!("invalid registration response: {e}"))?;
let reg_finish = reg_start
.state
.finish(
&mut rng,
password.as_bytes(),
reg_response,
ClientRegistrationFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| anyhow::anyhow!("OPAQUE register finish: {e}"))?;
let mut req = node_client.opaque_register_finish_request();
{
let mut p = req.get();
p.set_username(username);
p.set_upload(&reg_finish.message.serialize());
if let Some(ref ik) = identity_key {
p.set_identity_key(ik);
} else {
p.set_identity_key(&[]);
}
}
let resp = req
.send()
.promise
.await
.context("opaque_register_finish RPC failed")?;
let success = resp
.get()
.context("register_finish: bad response")?
.get_success();
anyhow::ensure!(success, "server rejected registration");
println!("user '{username}' registered successfully (OPAQUE)");
if let Some(ik) = identity_key {
println!("bound identity_key : {}", hex::encode(ik));
}
Ok(())
}
/// Log in via the OPAQUE protocol and receive a session token.
#[allow(clippy::too_many_arguments)]
pub async fn cmd_login(
server: &str,
ca_cert: &Path,
server_name: &str,
username: &str,
password: &str,
identity_key_hex: Option<&str>,
state_path: Option<&Path>,
state_password: Option<&str>,
) -> anyhow::Result<()> {
let mut rng = rand::rngs::OsRng;
let node_client = connect_node(server, ca_cert, server_name).await?;
let login_start = ClientLogin::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE login start: {e}"))?;
let mut req = node_client.opaque_login_start_request();
{
let mut p = req.get();
p.set_username(username);
p.set_request(&login_start.message.serialize());
}
let resp = req
.send()
.promise
.await
.context("opaque_login_start RPC failed")?;
let response_bytes = resp
.get()
.context("login_start: bad response")?
.get_response()
.context("login_start: missing response")?
.to_vec();
let credential_response = CredentialResponse::<OpaqueSuite>::deserialize(&response_bytes)
.map_err(|e| anyhow::anyhow!("invalid credential response: {e}"))?;
let login_finish = login_start
.state
.finish(
&mut rng,
password.as_bytes(),
credential_response,
ClientLoginFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| anyhow::anyhow!("OPAQUE login finish (bad password?): {e}"))?;
let identity_key = derive_identity_for_login(identity_key_hex, state_path, state_password)?;
let mut req = node_client.opaque_login_finish_request();
{
let mut p = req.get();
p.set_username(username);
p.set_finalization(&login_finish.message.serialize());
p.set_identity_key(&identity_key);
}
let resp = req
.send()
.promise
.await
.context("opaque_login_finish RPC failed")?;
let session_token = resp
.get()
.context("login_finish: bad response")?
.get_session_token()
.context("login_finish: missing session_token")?
.to_vec();
anyhow::ensure!(
!session_token.is_empty(),
"server returned empty session token"
);
println!("login successful for '{username}'");
println!("session_token: {}", hex::encode(&session_token));
println!("(use as --access-token for subsequent commands)");
Ok(())
}
fn derive_identity_for_login(
identity_key_hex: Option<&str>,
state_path: Option<&Path>,
state_password: Option<&str>,
) -> anyhow::Result<Vec<u8>> {
if let Some(hex_str) = identity_key_hex {
let bytes = super::hex::decode(hex_str.trim())
.map_err(|e| anyhow::anyhow!("identity_key must be 64 hex chars: {e}"))?;
anyhow::ensure!(
bytes.len() == 32,
"identity_key must decode to 32 bytes (got {})",
bytes.len()
);
return Ok(bytes);
}
if let Some(path) = state_path {
let state = load_existing_state(path, state_password)?;
let identity = IdentityKeypair::from_seed(state.identity_seed);
return Ok(identity.public_key_bytes().to_vec());
}
Err(anyhow::anyhow!(
"login requires an identity key; pass --identity-key or --state"
))
}
// ── OPAQUE helpers (used by both one-shot commands and REPL bootstrap) ───────
/// Perform OPAQUE registration. Returns Ok(()) on success.
/// The error message contains "E018" if the user already exists.
/// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated.
pub(crate) async fn opaque_register(
client: &quicprochat_proto::node_capnp::node_service::Client,
username: &str,
password: &str,
identity_key: Option<&[u8]>,
) -> anyhow::Result<()> {
let mut rng = rand::rngs::OsRng;
let reg_start = ClientRegistration::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE register start: {e}"))?;
let mut req = client.opaque_register_start_request();
{
let mut p = req.get();
p.set_username(username);
p.set_request(&reg_start.message.serialize());
}
let resp = req.send().promise.await.context("opaque_register_start RPC failed")?;
let response_bytes = resp
.get()
.context("register_start: bad response")?
.get_response()
.context("register_start: missing response")?
.to_vec();
let reg_response = RegistrationResponse::<OpaqueSuite>::deserialize(&response_bytes)
.map_err(|e| anyhow::anyhow!("invalid registration response: {e}"))?;
let reg_finish = reg_start
.state
.finish(
&mut rng,
password.as_bytes(),
reg_response,
ClientRegistrationFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| anyhow::anyhow!("OPAQUE register finish: {e}"))?;
let mut req = client.opaque_register_finish_request();
{
let mut p = req.get();
p.set_username(username);
p.set_upload(&reg_finish.message.serialize());
if let Some(ik) = identity_key {
p.set_identity_key(ik);
} else {
p.set_identity_key(&[]);
}
}
let resp = req.send().promise.await.context("opaque_register_finish RPC failed")?;
let success = resp
.get()
.context("register_finish: bad response")?
.get_success();
anyhow::ensure!(success, "server rejected registration");
Ok(())
}
/// Perform OPAQUE login and return the raw session token bytes.
/// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated.
pub async fn opaque_login(
client: &quicprochat_proto::node_capnp::node_service::Client,
username: &str,
password: &str,
identity_key: &[u8],
) -> anyhow::Result<Vec<u8>> {
let mut rng = rand::rngs::OsRng;
let login_start = ClientLogin::<OpaqueSuite>::start(&mut rng, password.as_bytes())
.map_err(|e| anyhow::anyhow!("OPAQUE login start: {e}"))?;
let mut req = client.opaque_login_start_request();
{
let mut p = req.get();
p.set_username(username);
p.set_request(&login_start.message.serialize());
}
let resp = req.send().promise.await.context("opaque_login_start RPC failed")?;
let response_bytes = resp
.get()
.context("login_start: bad response")?
.get_response()
.context("login_start: missing response")?
.to_vec();
let credential_response = CredentialResponse::<OpaqueSuite>::deserialize(&response_bytes)
.map_err(|e| anyhow::anyhow!("invalid credential response: {e}"))?;
let login_finish = login_start
.state
.finish(
&mut rng,
password.as_bytes(),
credential_response,
ClientLoginFinishParameters::<OpaqueSuite>::default(),
)
.map_err(|e| anyhow::anyhow!("OPAQUE login finish (bad password?): {e}"))?;
let mut req = client.opaque_login_finish_request();
{
let mut p = req.get();
p.set_username(username);
p.set_finalization(&login_finish.message.serialize());
p.set_identity_key(identity_key);
}
let resp = req.send().promise.await.context("opaque_login_finish RPC failed")?;
let session_token = resp
.get()
.context("login_finish: bad response")?
.get_session_token()
.context("login_finish: missing session_token")?
.to_vec();
anyhow::ensure!(!session_token.is_empty(), "server returned empty session token");
Ok(session_token)
}
/// Generate a KeyPackage for a fresh identity and upload it to the AS.
pub async fn cmd_register(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
let identity = IdentityKeypair::generate();
let (tls_bytes, fingerprint) =
generate_key_package(&identity).context("KeyPackage generation failed")?;
let node_client = connect_node(server, ca_cert, server_name).await?;
let mut req = node_client.upload_key_package_request();
{
let mut p = req.get();
p.set_identity_key(&identity.public_key_bytes());
p.set_package(&tls_bytes);
let mut auth = p.reborrow().init_auth();
super::rpc::set_auth(&mut auth)?;
}
let response = req
.send()
.promise
.await
.context("upload_key_package RPC failed")?;
let server_fp = response
.get()
.context("upload_key_package: bad response")?
.get_fingerprint()
.context("upload_key_package: missing fingerprint")?
.to_vec();
anyhow::ensure!(
server_fp == fingerprint,
"fingerprint mismatch: local={} server={}",
hex::encode(&fingerprint),
hex::encode(&server_fp),
);
println!(
"identity_key : {}",
hex::encode(identity.public_key_bytes())
);
println!("fingerprint : {}", hex::encode(&fingerprint));
println!("KeyPackage uploaded successfully.");
Ok(())
}
/// Generate a new KeyPackage from the member, upload it (and optionally hybrid key) to the AS, then save state.
async fn do_upload_keypackage(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
member: &mut GroupMember,
hybrid_kp: Option<&HybridKeypair>,
) -> anyhow::Result<()> {
let tls_bytes = member
.generate_key_package()
.context("KeyPackage generation failed")?;
let fingerprint = sha256(&tls_bytes);
let node_client = connect_node(server, ca_cert, server_name).await?;
let mut req = node_client.upload_key_package_request();
{
let mut p = req.get();
p.set_identity_key(&member.identity().public_key_bytes());
p.set_package(&tls_bytes);
let mut auth = p.reborrow().init_auth();
super::rpc::set_auth(&mut auth)?;
}
let response = req
.send()
.promise
.await
.context("upload_key_package RPC failed")?;
let server_fp = response
.get()
.context("upload_key_package: bad response")?
.get_fingerprint()
.context("upload_key_package: missing fingerprint")?
.to_vec();
anyhow::ensure!(server_fp == fingerprint, "fingerprint mismatch");
if let Some(hkp) = &hybrid_kp {
upload_hybrid_key(
&node_client,
&member.identity().public_key_bytes(),
&hkp.public_key(),
)
.await?;
println!("hybrid_key : uploaded (X25519 + ML-KEM-768)");
}
println!(
"identity_key : {}",
hex::encode(member.identity().public_key_bytes())
);
println!("fingerprint : {}", hex::encode(&fingerprint));
println!("KeyPackage uploaded successfully.");
save_state(state_path, member, hybrid_kp, password)?;
Ok(())
}
/// Upload the stored identity's KeyPackage to the AS.
/// Creates state (and identity) if the state file does not exist; otherwise uses existing state.
pub async fn cmd_register_state(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
do_upload_keypackage(
state_path,
server,
ca_cert,
server_name,
password,
&mut member,
hybrid_kp.as_ref(),
)
.await
}
/// Refresh the KeyPackage on the server (load existing state, generate new KeyPackage, upload).
/// Use this when your KeyPackage has expired (e.g. server TTL ~24h) or was consumed by an invite.
/// Requires an existing state file; does not create a new identity.
pub async fn cmd_refresh_keypackage(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
do_upload_keypackage(
state_path,
server,
ca_cert,
server_name,
password,
&mut member,
hybrid_kp.as_ref(),
)
.await
}
/// Fetch a peer's KeyPackage from the AS.
pub async fn cmd_fetch_key(
server: &str,
ca_cert: &Path,
server_name: &str,
identity_key_hex: &str,
) -> anyhow::Result<()> {
let identity_key = super::hex::decode(identity_key_hex)
.map_err(|e| anyhow::anyhow!(e))
.context("identity_key must be 64 hex characters (32 bytes)")?;
anyhow::ensure!(
identity_key.len() == 32,
"identity_key must be exactly 32 bytes, got {}",
identity_key.len()
);
let node_client = connect_node(server, ca_cert, server_name).await?;
let mut req = node_client.fetch_key_package_request();
{
let mut p = req.get();
p.set_identity_key(&identity_key);
let mut auth = p.reborrow().init_auth();
super::rpc::set_auth(&mut auth)?;
}
let response = req
.send()
.promise
.await
.context("fetch_key_package RPC failed")?;
let package = response
.get()
.context("fetch_key_package: bad response")?
.get_package()
.context("fetch_key_package: missing package field")?
.to_vec();
if package.is_empty() {
println!("No KeyPackage available for this identity.");
return Ok(());
}
use sha2::{Digest, Sha256};
let fingerprint = Sha256::digest(&package);
println!("fingerprint : {}", hex::encode(fingerprint));
println!("package_len : {} bytes", package.len());
println!("KeyPackage fetched successfully.");
Ok(())
}
/// Run a two-party MLS demo against the unified server.
pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
let creator_state_path = PathBuf::from("qpc-demo-creator.bin");
let joiner_state_path = PathBuf::from("qpc-demo-joiner.bin");
let (mut creator, creator_hybrid_opt) =
load_or_init_state(&creator_state_path, None)?.into_parts(&creator_state_path)?;
let (mut joiner, joiner_hybrid_opt) =
load_or_init_state(&joiner_state_path, None)?.into_parts(&joiner_state_path)?;
let creator_hybrid = creator_hybrid_opt.unwrap_or_else(HybridKeypair::generate);
let joiner_hybrid = joiner_hybrid_opt.unwrap_or_else(HybridKeypair::generate);
let creator_kp = creator
.generate_key_package()
.context("creator KeyPackage generation failed")?;
let joiner_kp = joiner
.generate_key_package()
.context("joiner KeyPackage generation failed")?;
let creator_node = connect_node(server, ca_cert, server_name).await?;
let joiner_node = connect_node(server, ca_cert, server_name).await?;
let creator_identity = creator.identity().public_key_bytes();
let joiner_identity = joiner.identity().public_key_bytes();
upload_key_package(&creator_node, &creator_identity, &creator_kp).await?;
upload_key_package(&joiner_node, &joiner_identity, &joiner_kp).await?;
upload_hybrid_key(&creator_node, &creator_identity, &creator_hybrid.public_key()).await?;
upload_hybrid_key(&joiner_node, &joiner_identity, &joiner_hybrid.public_key()).await?;
println!("hybrid public keys uploaded for creator and joiner");
let fetched_joiner_kp = fetch_key_package(&creator_node, &joiner_identity).await?;
anyhow::ensure!(
!fetched_joiner_kp.is_empty(),
"AS returned an empty KeyPackage for joiner",
);
creator
.create_group(b"demo-group")
.context("create_group failed")?;
let (_commit, welcome) = creator
.add_member(&fetched_joiner_kp)
.context("add_member failed")?;
let creator_ds = creator_node.clone();
let joiner_ds = joiner_node.clone();
let joiner_hybrid_pk = fetch_hybrid_key(&creator_node, &joiner_identity)
.await?
.context("joiner hybrid key not found")?;
let wrapped_welcome =
hybrid_encrypt(&joiner_hybrid_pk, &welcome, b"", b"").context("hybrid encrypt welcome")?;
enqueue(&creator_ds, &joiner_identity, &wrapped_welcome).await?;
let welcome_payloads = fetch_all(&joiner_ds, &joiner_identity).await?;
let raw_welcome = welcome_payloads
.first()
.map(|(_, d)| d.clone())
.context("Welcome was not delivered to joiner via DS")?;
let welcome_bytes =
hybrid_decrypt(&joiner_hybrid, &raw_welcome, b"", b"").context("hybrid decrypt welcome failed")?;
joiner
.join_group(&welcome_bytes)
.context("join_group failed")?;
let ct_creator_to_joiner = creator
.send_message(b"hello")
.context("send_message failed")?;
let wrapped_creator_joiner =
hybrid_encrypt(&joiner_hybrid_pk, &ct_creator_to_joiner, b"", b"").context("hybrid encrypt failed")?;
enqueue(&creator_ds, &joiner_identity, &wrapped_creator_joiner).await?;
let joiner_msgs = fetch_all(&joiner_ds, &joiner_identity).await?;
let (_, raw_creator_joiner) = joiner_msgs
.first()
.context("joiner: missing ciphertext from DS")?;
let inner_creator_joiner =
hybrid_decrypt(&joiner_hybrid, raw_creator_joiner, b"", b"").context("hybrid decrypt failed")?;
let plaintext_creator_joiner = match joiner.receive_message(&inner_creator_joiner)? {
ReceivedMessage::Application(pt) => pt,
other => anyhow::bail!("expected application message, got {other:?}"),
};
println!(
"creator -> joiner plaintext: {}",
String::from_utf8_lossy(&plaintext_creator_joiner)
);
let creator_hybrid_pk = fetch_hybrid_key(&joiner_node, &creator_identity)
.await?
.context("creator hybrid key not found")?;
let ct_joiner_to_creator = joiner
.send_message(b"hello back")
.context("send_message failed")?;
let wrapped_joiner_creator =
hybrid_encrypt(&creator_hybrid_pk, &ct_joiner_to_creator, b"", b"").context("hybrid encrypt failed")?;
enqueue(&joiner_ds, &creator_identity, &wrapped_joiner_creator).await?;
let creator_msgs = fetch_all(&creator_ds, &creator_identity).await?;
let (_, raw_joiner_creator) = creator_msgs
.first()
.context("creator: missing ciphertext from DS")?;
let inner_joiner_creator =
hybrid_decrypt(&creator_hybrid, raw_joiner_creator, b"", b"").context("hybrid decrypt failed")?;
let plaintext_joiner_creator = match creator.receive_message(&inner_joiner_creator)? {
ReceivedMessage::Application(pt) => pt,
other => anyhow::bail!("expected application message, got {other:?}"),
};
println!(
"joiner -> creator plaintext: {}",
String::from_utf8_lossy(&plaintext_joiner_creator)
);
println!("demo-group complete (hybrid PQ envelope active)");
Ok(())
}
/// Create a new group and persist state.
pub async fn cmd_create_group(
state_path: &Path,
_server: &str,
group_id: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
anyhow::ensure!(
member.group_ref().is_none(),
"group already exists in state"
);
member
.create_group(group_id.as_bytes())
.context("create_group failed")?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!("group created: {group_id}");
Ok(())
}
/// Invite a peer: fetch their KeyPackage, add to group, enqueue Welcome.
pub async fn cmd_invite(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
peer_key_hex: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let peer_key = decode_identity_key(peer_key_hex)?;
let node_client = connect_node(server, ca_cert, server_name).await?;
let peer_kp = fetch_key_package(&node_client, &peer_key).await?;
anyhow::ensure!(
!peer_kp.is_empty(),
"server returned empty KeyPackage for peer"
);
let _ = member
.group_ref()
.context("no active group; run create-group first")?;
let existing_members: Vec<Vec<u8>> = member
.member_identities()
.into_iter()
.filter(|k| k.as_slice() != member.identity().public_key_bytes())
.collect();
let (commit, welcome) = member.add_member(&peer_kp).context("add_member failed")?;
for mk in &existing_members {
if mk.as_slice() == peer_key.as_slice() {
continue;
}
let peer_hpk = fetch_hybrid_key(&node_client, mk).await?;
let commit_payload = if let Some(ref pk) = peer_hpk {
hybrid_encrypt(pk, &commit, b"", b"").context("hybrid encrypt commit")?
} else {
commit.clone()
};
enqueue(&node_client, mk, &commit_payload).await?;
}
let peer_hybrid_pk = fetch_hybrid_key(&node_client, &peer_key).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &welcome, b"", b"").context("hybrid encrypt welcome failed")?
} else {
welcome
};
enqueue(&node_client, &peer_key, &payload).await?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!(
"invited peer (welcome queued{}, commit sent to {} existing member(s))",
if peer_hybrid_pk.is_some() {
", hybrid-encrypted"
} else {
""
},
existing_members.len(),
);
Ok(())
}
/// Join a group by consuming a Welcome from the server queue.
/// If the queue contained [Welcome, Commit, ...] (e.g. creator invited someone else before this
/// joiner ran), we use the first payload as Welcome and process the rest in order (merge Commits)
/// so the joiner's epoch matches the creator before any later app messages are received.
pub async fn cmd_join(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
anyhow::ensure!(
member.group_ref().is_none(),
"group already active in state"
);
let node_client = connect_node(server, ca_cert, server_name).await?;
let mut payloads = fetch_all(&node_client, &member.identity().public_key_bytes()).await?;
payloads.sort_by_key(|(seq, _)| *seq);
let (_, raw_welcome) = payloads
.first()
.cloned()
.context("no Welcome found in DS for this identity")?;
let welcome_bytes = try_hybrid_decrypt(hybrid_kp.as_ref(), &raw_welcome)
.context("decrypt Welcome (hybrid required)")?;
member
.join_group(&welcome_bytes)
.context("join_group failed")?;
// Process any remaining payloads (e.g. Commit from creator adding another member) in order
// so our epoch matches before we later receive application messages.
for (_, raw) in payloads.iter().skip(1) {
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), raw) {
Ok(b) => b,
Err(_) => continue,
};
let _ = member.receive_message(&mls_payload);
}
// Auto-replenish KeyPackage after join consumed the original one.
let tls_bytes = member
.generate_key_package()
.context("KeyPackage replenishment failed")?;
upload_key_package(&node_client, &member.identity().public_key_bytes(), &tls_bytes)
.await
.context("KeyPackage replenishment upload failed")?;
println!("KeyPackage auto-replenished after join");
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!("joined group successfully");
Ok(())
}
/// Send an application message via DS (single recipient or broadcast to all other members).
#[allow(clippy::too_many_arguments)]
pub async fn cmd_send(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
peer_key_hex: Option<&str>,
send_to_all: bool,
msg: &str,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let _ = member
.group_ref()
.context("no active group; create one and invite members first")?;
let node_client = connect_node(server, ca_cert, server_name).await?;
let ct = member
.send_message(msg.as_bytes())
.context("send_message failed")?;
let my_identity = member.identity().public_key_bytes();
let recipients: Vec<Vec<u8>> = if send_to_all {
member
.member_identities()
.into_iter()
.filter(|k| k.as_slice() != my_identity)
.collect()
} else {
let peer_key = decode_identity_key(
peer_key_hex.context("peer_key required when not using --all")?,
)?;
vec![peer_key]
};
for recipient in &recipients {
let peer_hybrid_pk = fetch_hybrid_key(&node_client, recipient).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt failed")?
} else {
ct.clone()
};
enqueue(&node_client, recipient, &payload).await?;
}
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
println!(
"message sent to {} recipient(s){}",
recipients.len(),
if recipients.len() == 1 {
" (hybrid-encrypted)"
} else {
""
}
);
Ok(())
}
/// Receive and decrypt all pending messages from the server.
pub async fn cmd_recv(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
wait_ms: u64,
stream: bool,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let client = connect_node(server, ca_cert, server_name).await?;
loop {
let mut payloads =
fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?;
if payloads.is_empty() {
if !stream {
println!("no messages");
return Ok(());
}
continue;
}
// Sort by server-assigned sequence number so MLS commits arrive before
// application messages that depend on the resulting epoch.
payloads.sort_by_key(|(seq, _)| *seq);
let mut pending: Vec<(usize, Vec<u8>)> = Vec::new();
for (idx, (_, payload)) in payloads.iter().enumerate() {
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
Err(e) => {
println!("[{idx}] decrypt error: {e}");
continue;
}
};
match member.receive_message(&mls_payload) {
Ok(ReceivedMessage::Application(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => println!("[{idx}] commit applied"),
Err(_) => pending.push((idx, mls_payload)),
}
}
// Retry until no more progress (handles multi-epoch batches).
loop {
let before = pending.len();
pending.retain(|(idx, mls_payload)| {
match member.receive_message(mls_payload) {
Ok(ReceivedMessage::Application(pt)) => {
println!("[{idx}/retry] plaintext: {}", String::from_utf8_lossy(&pt));
false
}
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {
println!("[{idx}/retry] commit applied");
false
}
Err(_) => true,
}
});
if pending.len() == before {
break; // No progress — remaining messages are unprocessable
}
}
for (idx, _) in &pending {
println!("[{idx}] error: unprocessable after all retries");
}
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
if !stream {
return Ok(());
}
}
}
/// Fetch pending payloads, process in order (merge commits, collect plaintexts), save state.
/// Returns only application-message plaintexts. Used by E2E tests and callers that need returned messages.
/// Retries in a loop until no more progress, handling multi-epoch batches where commits must be
/// applied before later application messages can be decrypted.
pub async fn receive_pending_plaintexts(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
wait_ms: u64,
password: Option<&str>,
) -> anyhow::Result<Vec<Vec<u8>>> {
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let client = connect_node(server, ca_cert, server_name).await?;
let mut payloads =
fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?;
payloads.sort_by_key(|(seq, _)| *seq);
let mut plaintexts = Vec::new();
let mut pending: Vec<Vec<u8>> = Vec::new();
for (_, payload) in &payloads {
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
Err(_) => continue,
};
match member.receive_message(&mls_payload) {
Ok(ReceivedMessage::Application(pt)) => plaintexts.push(pt),
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {}
Err(_) => pending.push(mls_payload),
}
}
// Retry until no more progress (handles multi-epoch batches).
loop {
let before = pending.len();
pending.retain(|mls_payload| {
match member.receive_message(mls_payload) {
Ok(ReceivedMessage::Application(pt)) => {
plaintexts.push(pt);
false
}
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => false,
Err(_) => true,
}
});
if pending.len() == before {
break;
}
}
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
Ok(plaintexts)
}
/// JSON-returning whoami for GUI backends.
pub fn whoami_json(state_path: &Path, password: Option<&str>) -> anyhow::Result<String> {
let state = load_existing_state(state_path, password)?;
let identity = IdentityKeypair::from_seed(state.identity_seed);
let pk_bytes = identity.public_key_bytes();
let fingerprint = sha256(&pk_bytes);
Ok(format!(
r#"{{"identity_key":"{}", "fingerprint":"{}", "hybrid_key":{}, "group":{}}}"#,
hex::encode(pk_bytes),
hex::encode(fingerprint),
state.hybrid_key.is_some(),
state.group.is_some(),
))
}
/// JSON-returning health check for GUI backends.
pub async fn cmd_health_json(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<String> {
let sent_at = current_timestamp_ms();
let client = connect_node(server, ca_cert, server_name).await?;
let req = client.health_request();
let resp = req.send().promise.await.context("health RPC failed")?;
let status = resp
.get()
.context("health: bad response")?
.get_status()
.context("health: missing status")?
.to_str()
.unwrap_or("invalid");
let rtt_ms = current_timestamp_ms().saturating_sub(sent_at);
Ok(format!(r#"{{"status":"{status}","rtt_ms":{rtt_ms}}}"#))
}
/// Run an interactive 1:1 chat session.
pub async fn cmd_chat(
state_path: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
peer_key_hex: Option<&str>,
password: Option<&str>,
poll_interval_ms: u64,
) -> anyhow::Result<()> {
use std::io::Write;
use tokio::io::AsyncBufReadExt;
use tokio::sync::mpsc;
use tokio::time::interval;
let state = load_existing_state(state_path, password)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
let _group = member
.group_ref()
.context("no active group; create one and invite the peer first")?;
let my_identity = member.identity().public_key_bytes();
let peer_key: Vec<u8> = match peer_key_hex {
Some(h) => decode_identity_key(h)?,
None => {
let others: Vec<Vec<u8>> = member
.member_identities()
.into_iter()
.filter(|id| id.as_slice() != my_identity)
.collect();
match others.as_slice() {
[single] => single.clone(),
[] => anyhow::bail!(
"group has no other member; invite someone first, or pass --peer-key"
),
_ => anyhow::bail!(
"group has {} other members; pass --peer-key <identity_hex> to pick one",
others.len()
),
}
}
};
let identity_bytes = member.identity().public_key_bytes().to_vec();
let client = connect_node(server, ca_cert, server_name).await?;
let (tx, mut rx) = mpsc::unbounded_channel::<Option<String>>();
tokio::task::spawn_local({
let tx = tx.clone();
async move {
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
let mut line = String::new();
loop {
line.clear();
match stdin.read_line(&mut line).await {
Ok(0) => {
let _ = tx.send(None);
break;
}
Ok(_) => {
let trimmed = line.trim().to_string();
let _ = tx.send(Some(trimmed));
}
Err(_) => break,
}
}
}
});
let mut poll = interval(std::time::Duration::from_millis(poll_interval_ms));
poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
println!("Chat with peer {} (Ctrl+D to exit)", hex::encode(&peer_key[..8]));
print!("> ");
std::io::stdout().flush().context("flush stdout")?;
loop {
tokio::select! {
msg = rx.recv() => {
match msg {
Some(None) => break,
Some(Some(line)) => {
if !line.is_empty() {
let ct = member
.send_message(line.as_bytes())
.context("send_message failed")?;
let peer_hybrid_pk = fetch_hybrid_key(&client, &peer_key).await?;
let payload = if let Some(ref pk) = peer_hybrid_pk {
hybrid_encrypt(pk, &ct, b"", b"").context("hybrid encrypt failed")?
} else {
ct
};
enqueue(&client, &peer_key, &payload).await?;
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
}
print!("> ");
std::io::stdout().flush().context("flush stdout")?;
}
None => break,
}
}
_ = poll.tick() => {
let mut payloads = fetch_wait(&client, &identity_bytes, 0).await?;
payloads.sort_by_key(|(seq, _)| *seq);
let mut retry_payloads: Vec<Vec<u8>> = Vec::new();
for (_, payload) in &payloads {
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
Err(_) => continue,
};
match member.receive_message(&mls_payload) {
Ok(ReceivedMessage::Application(pt)) => {
let s = String::from_utf8_lossy(&pt);
println!("\r\n[peer] {s}\n> ");
std::io::stdout().flush().context("flush stdout")?;
}
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {}
Err(_) => retry_payloads.push(mls_payload),
}
}
// Retry failed messages (epoch may have advanced from commits in this batch)
loop {
let before = retry_payloads.len();
retry_payloads.retain(|mls_payload| {
match member.receive_message(mls_payload) {
Ok(ReceivedMessage::Application(pt)) => {
let s = String::from_utf8_lossy(&pt);
println!("\r\n[peer] {s}\n> ");
let _ = std::io::stdout().flush();
false
}
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => false,
Err(_) => true,
}
});
if retry_payloads.len() == before { break; }
}
if !payloads.is_empty() {
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
}
}
}
}
println!();
Ok(())
}
// ── Transcript export ─────────────────────────────────────────────────────────
/// Export the message history for a conversation to an encrypted, tamper-evident
/// transcript file.
///
/// `conv_db` is the path to the conversation SQLite database (`.convdb` file).
/// `conv_id_hex` is the 32-hex-character conversation ID to export.
/// `output` is the path for the `.qpct` transcript file to write.
/// `transcript_password` is used to derive the encryption key (Argon2id).
/// `db_password` is the optional SQLCipher password for the conversation database.
pub fn cmd_export(
conv_db: &Path,
conv_id_hex: &str,
output: &Path,
transcript_password: &str,
db_password: Option<&str>,
) -> anyhow::Result<()> {
use quicprochat_core::{TranscriptRecord, TranscriptWriter};
use super::conversation::{ConversationId, ConversationStore};
// Decode conversation ID from hex.
let id_bytes = hex::decode(conv_id_hex)
.map_err(|e| anyhow::anyhow!("conv-id must be 32 hex characters (16 bytes): {e}"))?;
let conv_id = ConversationId::from_slice(&id_bytes)
.ok_or_else(|| anyhow::anyhow!("conv-id must be exactly 16 bytes (32 hex chars), got {} bytes", id_bytes.len()))?;
// Open conversation database.
let store = ConversationStore::open(conv_db, db_password)
.context("open conversation database")?;
// Load conversation metadata (to display name in output).
let conv = store
.load_conversation(&conv_id)?
.with_context(|| format!("conversation '{conv_id_hex}' not found in database"))?;
// Load all messages (oldest first).
let messages = store.load_all_messages(&conv_id)?;
if messages.is_empty() {
println!("No messages in conversation '{}'.", conv.display_name);
return Ok(());
}
// Create output file.
if let Some(parent) = output.parent() {
std::fs::create_dir_all(parent).ok();
}
let mut file = std::fs::File::create(output)
.with_context(|| format!("create transcript file '{}'", output.display()))?;
// Write transcript header + records.
let mut writer = TranscriptWriter::new(transcript_password, &mut file)
.context("initialise transcript writer")?;
let mut written = 0u64;
for (seq, msg) in messages.iter().enumerate() {
writer
.write_record(
&TranscriptRecord {
seq: seq as u64,
sender_identity: &msg.sender_key,
timestamp_ms: msg.timestamp_ms,
plaintext: &msg.body,
},
&mut file,
)
.context("write transcript record")?;
written += 1;
}
println!(
"Exported {} message(s) from '{}' to '{}'.",
written,
conv.display_name,
output.display()
);
println!("Decrypt with: qpc export verify --input <file> --password <password>");
Ok(())
}
/// Verify the hash-chain integrity of a transcript file without decrypting content.
///
/// Prints a summary. Does not require the encryption password (structural check only).
pub fn cmd_export_verify(input: &Path) -> anyhow::Result<()> {
use quicprochat_core::{validate_transcript_structure, ChainVerdict};
let data = std::fs::read(input)
.with_context(|| format!("read transcript file '{}'", input.display()))?;
match validate_transcript_structure(&data)? {
ChainVerdict::Ok { records } => {
println!(
"OK: transcript '{}' is structurally valid. {} record(s) found, hash chain intact.",
input.display(),
records
);
}
ChainVerdict::Broken => {
anyhow::bail!(
"FAIL: hash chain is broken in '{}' — file may have been tampered with.",
input.display()
);
}
}
Ok(())
}