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.
1401 lines
48 KiB
Rust
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(®_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(®_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(®_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(®_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(())
|
|
}
|