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::::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. #[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::::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" )) } // ── 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::::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::::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 = 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> { let mut rng = rand::rngs::OsRng; let login_start = ClientLogin::::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::::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 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> = 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> = 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)> = 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>> { 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::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 { 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, 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::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 --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(()) }