1205 lines
39 KiB
Rust
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(®_start.message.serialize());
|
|
}
|
|
let resp = req
|
|
.send()
|
|
.promise
|
|
.await
|
|
.context("opaque_register_start RPC failed")?;
|
|
let response_bytes = resp
|
|
.get()
|
|
.context("register_start: bad response")?
|
|
.get_response()
|
|
.context("register_start: missing response")?
|
|
.to_vec();
|
|
|
|
let reg_response = RegistrationResponse::<OpaqueSuite>::deserialize(&response_bytes)
|
|
.map_err(|e| anyhow::anyhow!("invalid registration response: {e}"))?;
|
|
|
|
let reg_finish = reg_start
|
|
.state
|
|
.finish(
|
|
&mut rng,
|
|
password.as_bytes(),
|
|
reg_response,
|
|
ClientRegistrationFinishParameters::<OpaqueSuite>::default(),
|
|
)
|
|
.map_err(|e| anyhow::anyhow!("OPAQUE register finish: {e}"))?;
|
|
|
|
let mut req = node_client.opaque_register_finish_request();
|
|
{
|
|
let mut p = req.get();
|
|
p.set_username(username);
|
|
p.set_upload(®_finish.message.serialize());
|
|
if let Some(ref ik) = identity_key {
|
|
p.set_identity_key(ik);
|
|
} else {
|
|
p.set_identity_key(&[]);
|
|
}
|
|
}
|
|
let resp = req
|
|
.send()
|
|
.promise
|
|
.await
|
|
.context("opaque_register_finish RPC failed")?;
|
|
let success = resp
|
|
.get()
|
|
.context("register_finish: bad response")?
|
|
.get_success();
|
|
|
|
anyhow::ensure!(success, "server rejected registration");
|
|
|
|
println!("user '{username}' registered successfully (OPAQUE)");
|
|
if let Some(ik) = identity_key {
|
|
println!("bound identity_key : {}", hex::encode(ik));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Log in via the OPAQUE protocol and receive a session token.
|
|
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(())
|
|
}
|