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::::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::::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::::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. 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::::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::::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::::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> { 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> = 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> = 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 = 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::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>> { 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::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 { 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 { 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 = match peer_key_hex { Some(h) => decode_identity_key(h)?, None => { let others: Vec> = 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 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::>(); 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(()) }