Files
quicproquo/crates/quicnprotochat-client/src/client/commands.rs

1205 lines
39 KiB
Rust

use std::path::{Path, PathBuf};
use anyhow::Context;
use opaque_ke::{
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse,
};
use quicnprotochat_core::{
generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite,
HybridKeypair, IdentityKeypair,
};
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,
MemberBackend,
},
};
/// 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!(
"pq_backend : {}",
if state.use_pq_backend {
"yes (MLS HPKE: X25519 + ML-KEM-768)"
} else {
"no (classical)"
}
);
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.
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"
))
}
/// 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 MemberBackend,
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(ref 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>,
use_pq_backend: bool,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path, password, use_pq_backend)?;
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,
use_pq_backend: bool,
) -> anyhow::Result<()> {
use indicatif::{ProgressBar, ProgressStyle};
let creator_state_path = PathBuf::from("quicnprotochat-demo-creator.bin");
let joiner_state_path = PathBuf::from("quicnprotochat-demo-joiner.bin");
let pb = ProgressBar::new(5);
pb.set_style(
ProgressStyle::with_template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
.expect("demo progress template is valid")
.tick_chars("\u{2801}\u{2802}\u{2804}\u{2840}\u{2820}\u{2810}\u{2808} ")
.progress_chars("=>-"),
);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb.set_message("Generating Alice keys\u{2026}");
let (mut creator, creator_hybrid_opt) =
load_or_init_state(&creator_state_path, None, use_pq_backend)?.into_parts(&creator_state_path)?;
pb.inc(1);
pb.set_message("Generating Bob keys\u{2026}");
let (mut joiner, joiner_hybrid_opt) =
load_or_init_state(&joiner_state_path, None, use_pq_backend)?.into_parts(&joiner_state_path)?;
pb.inc(1);
pb.set_message("Creating group\u{2026}");
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?;
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")?;
pb.inc(1);
pb.set_message("Encrypting\u{2026}");
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).context("hybrid encrypt welcome")?;
enqueue(&creator_ds, &joiner_identity, &wrapped_welcome).await?;
pb.inc(1);
pb.set_message("Delivering\u{2026}");
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).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).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).context("hybrid decrypt failed")?;
let plaintext_creator_joiner = joiner
.receive_message(&inner_creator_joiner)?
.context("expected application message")?;
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).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).context("hybrid decrypt failed")?;
let plaintext_joiner_creator = creator
.receive_message(&inner_joiner_creator)?
.context("expected application message")?;
pb.inc(1);
pb.finish_and_clear();
println!(
"creator -> joiner: {}",
String::from_utf8_lossy(&plaintext_creator_joiner)
);
println!(
"joiner -> creator: {}",
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>,
use_pq_backend: bool,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path, password, use_pq_backend)?;
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).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).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);
}
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).
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).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<()> {
use indicatif::{ProgressBar, ProgressStyle};
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 stream_pb: Option<ProgressBar> = if stream {
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::with_template("{spinner:.green} {msg}")
.expect("recv progress template is valid")
.tick_chars("\u{2801}\u{2802}\u{2804}\u{2840}\u{2820}\u{2810}\u{2808} "),
);
pb.set_message("Listening for messages (0 received)\u{2026}");
pb.enable_steady_tick(std::time::Duration::from_millis(100));
Some(pb)
} else {
None
};
let mut total_received: usize = 0;
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 retry_mls: Vec<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) => {
match &stream_pb {
Some(pb) => pb.println(format!("[{idx}] decrypt error: {e}")),
None => println!("[{idx}] decrypt error: {e}"),
}
continue;
}
};
match member.receive_message(&mls_payload) {
Ok(Some(pt)) => {
total_received += 1;
let line = format!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt));
match &stream_pb {
Some(pb) => pb.println(line),
None => println!("{line}"),
}
}
Ok(None) => {
let line = format!("[{idx}] commit applied");
match &stream_pb {
Some(pb) => pb.println(line),
None => println!("{line}"),
}
}
Err(_) => retry_mls.push(mls_payload),
}
}
// Retry messages that failed on the first pass (e.g. app messages whose
// epoch was not yet advanced until a commit earlier in the batch was applied).
for mls_payload in &retry_mls {
match member.receive_message(mls_payload) {
Ok(Some(pt)) => {
total_received += 1;
let line = format!("[retry] plaintext: {}", String::from_utf8_lossy(&pt));
match &stream_pb {
Some(pb) => pb.println(line),
None => println!("{line}"),
}
}
Ok(None) => {}
Err(e) => {
let line = format!("[retry] error: {e}");
match &stream_pb {
Some(pb) => pb.println(line),
None => println!("{line}"),
}
}
}
}
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
if let Some(ref pb) = stream_pb {
pb.set_message(format!(
"Listening for messages ({total_received} received)\u{2026}"
));
}
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.
/// Uses two passes so that if the server delivers an application message before a Commit, the second pass
/// processes it after commits are merged.
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 retry_mls: 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(Some(pt)) => plaintexts.push(pt),
Ok(None) => {}
Err(_) => retry_mls.push(mls_payload),
}
}
for mls_payload in &retry_mls {
if let Ok(Some(pt)) = member.receive_message(mls_payload) {
plaintexts.push(pt);
}
}
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).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);
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(Some(pt)) => {
let s = String::from_utf8_lossy(&pt);
println!("\r\n[peer] {s}\n> ");
std::io::stdout().flush().context("flush stdout")?;
}
Ok(None) => {}
Err(_) => {}
}
}
if !payloads.is_empty() {
save_state(state_path, &member, hybrid_kp.as_ref(), password)?;
}
}
}
}
println!();
Ok(())
}