feat: add post-quantum hybrid KEM + SQLCipher persistence
Feature 1 — Post-Quantum Hybrid KEM (X25519 + ML-KEM-768): - Create hybrid_kem.rs with keygen, encrypt, decrypt + 11 unit tests - Wire format: version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | nonce(12) | ct - Add uploadHybridKey/fetchHybridKey RPCs to node.capnp schema - Server: hybrid key storage in FileBackedStore + RPC handlers - Client: hybrid keypair in StoredState, auto-wrap/unwrap in send/recv/invite/join - demo-group runs full hybrid PQ envelope round-trip Feature 2 — SQLCipher Persistence: - Extract Store trait from FileBackedStore API - Create SqlStore (rusqlite + bundled-sqlcipher) with encrypted-at-rest SQLite - Schema: key_packages, deliveries, hybrid_keys tables with indexes - Server CLI: --store-backend=sql, --db-path, --db-key flags - 5 unit tests for SqlStore (FIFO, round-trip, upsert, channel isolation) Also includes: client lib.rs refactor, auth config, TOML config file support, mdBook documentation, and various cleanups by user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
971
crates/quicnprotochat-client/src/lib.rs
Normal file
971
crates/quicnprotochat-client/src/lib.rs
Normal file
@@ -0,0 +1,971 @@
|
||||
use std::fs;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use anyhow::Context;
|
||||
use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
use quinn::{ClientConfig, Endpoint};
|
||||
use quinn_proto::crypto::rustls::QuicClientConfig;
|
||||
use rustls::pki_types::CertificateDer;
|
||||
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
|
||||
|
||||
use quicnprotochat_core::{
|
||||
generate_key_package, hybrid_decrypt, hybrid_encrypt, DiskKeyStore, GroupMember,
|
||||
HybridKeypair, HybridKeypairBytes, HybridPublicKey, IdentityKeypair,
|
||||
};
|
||||
use quicnprotochat_proto::node_capnp::{auth, node_service};
|
||||
|
||||
// Global auth context initialized once per process.
|
||||
static AUTH_CONTEXT: OnceLock<ClientAuth> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ClientAuth {
|
||||
version: u16,
|
||||
access_token: Vec<u8>,
|
||||
device_id: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ClientAuth {
|
||||
/// Build a client auth context from optional token and device id.
|
||||
/// Requires a non-empty token; we run version=1 only (no legacy mode).
|
||||
pub fn from_parts(access_token: String, device_id: Option<String>) -> Self {
|
||||
let token = access_token.into_bytes();
|
||||
let device = device_id.unwrap_or_default().into_bytes();
|
||||
|
||||
Self {
|
||||
version: 1,
|
||||
access_token: token,
|
||||
device_id: device,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the global auth context; subsequent calls are ignored.
|
||||
pub fn init_auth(ctx: ClientAuth) {
|
||||
let _ = AUTH_CONTEXT.set(ctx);
|
||||
}
|
||||
|
||||
// ── Subcommand implementations ───────────────────────────────────────────────
|
||||
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
/// Generate a KeyPackage for a fresh identity and upload it to the AS.
|
||||
///
|
||||
/// Must run on a `LocalSet` because capnp-rpc is `!Send`.
|
||||
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();
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Upload the stored identity's KeyPackage to the AS (persists backend state).
|
||||
pub async fn cmd_register_state(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_or_init_state(state_path)?;
|
||||
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
|
||||
|
||||
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();
|
||||
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");
|
||||
|
||||
// Upload hybrid public key alongside the KeyPackage.
|
||||
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.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a peer's KeyPackage from the AS by their hex-encoded identity key.
|
||||
///
|
||||
/// Must run on a `LocalSet` because capnp-rpc is `!Send`.
|
||||
pub async fn cmd_fetch_key(
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
identity_key_hex: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let identity_key = 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();
|
||||
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 complete Alice↔Bob MLS round-trip using the unified server endpoint.
|
||||
///
|
||||
/// All payloads are wrapped in post-quantum hybrid envelopes (X25519 + ML-KEM-768).
|
||||
pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
|
||||
// Identities and MLS state must be tied to the same backend instance.
|
||||
let alice_id = Arc::new(IdentityKeypair::generate());
|
||||
let bob_id = Arc::new(IdentityKeypair::generate());
|
||||
|
||||
// Generate hybrid keypairs for both participants.
|
||||
let alice_hybrid = HybridKeypair::generate();
|
||||
let bob_hybrid = HybridKeypair::generate();
|
||||
|
||||
let mut alice = GroupMember::new(Arc::clone(&alice_id));
|
||||
let mut bob = GroupMember::new(Arc::clone(&bob_id));
|
||||
|
||||
let alice_kp = alice
|
||||
.generate_key_package()
|
||||
.context("Alice KeyPackage generation failed")?;
|
||||
let bob_kp = bob
|
||||
.generate_key_package()
|
||||
.context("Bob KeyPackage generation failed")?;
|
||||
|
||||
// Upload both KeyPackages and hybrid public keys to the server.
|
||||
let alice_node = connect_node(server, ca_cert, server_name).await?;
|
||||
let bob_node = connect_node(server, ca_cert, server_name).await?;
|
||||
|
||||
upload_key_package(&alice_node, &alice_id.public_key_bytes(), &alice_kp).await?;
|
||||
upload_key_package(&bob_node, &bob_id.public_key_bytes(), &bob_kp).await?;
|
||||
upload_hybrid_key(
|
||||
&alice_node,
|
||||
&alice_id.public_key_bytes(),
|
||||
&alice_hybrid.public_key(),
|
||||
)
|
||||
.await?;
|
||||
upload_hybrid_key(
|
||||
&bob_node,
|
||||
&bob_id.public_key_bytes(),
|
||||
&bob_hybrid.public_key(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("hybrid public keys uploaded for Alice and Bob");
|
||||
|
||||
// Alice fetches Bob's KeyPackage and creates the group.
|
||||
let fetched_bob_kp = fetch_key_package(&alice_node, &bob_id.public_key_bytes()).await?;
|
||||
anyhow::ensure!(
|
||||
!fetched_bob_kp.is_empty(),
|
||||
"AS returned an empty KeyPackage for Bob",
|
||||
);
|
||||
|
||||
alice
|
||||
.create_group(b"demo-group")
|
||||
.context("Alice create_group failed")?;
|
||||
let (_commit, welcome) = alice
|
||||
.add_member(&fetched_bob_kp)
|
||||
.context("Alice add_member failed")?;
|
||||
|
||||
let alice_ds = alice_node.clone();
|
||||
let bob_ds = bob_node.clone();
|
||||
|
||||
// Fetch Bob's hybrid PK and wrap the welcome.
|
||||
let bob_hybrid_pk = fetch_hybrid_key(&alice_node, &bob_id.public_key_bytes())
|
||||
.await?
|
||||
.context("Bob hybrid key not found")?;
|
||||
let wrapped_welcome =
|
||||
hybrid_encrypt(&bob_hybrid_pk, &welcome).context("hybrid encrypt welcome")?;
|
||||
enqueue(&alice_ds, &bob_id.public_key_bytes(), &wrapped_welcome).await?;
|
||||
|
||||
let welcome_payloads = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?;
|
||||
let raw_welcome = welcome_payloads
|
||||
.first()
|
||||
.cloned()
|
||||
.context("Welcome was not delivered to Bob via DS")?;
|
||||
|
||||
// Bob unwraps the hybrid envelope and joins the group.
|
||||
let welcome_bytes = hybrid_decrypt(&bob_hybrid, &raw_welcome)
|
||||
.context("Bob: hybrid decrypt welcome failed")?;
|
||||
bob.join_group(&welcome_bytes)
|
||||
.context("Bob join_group failed")?;
|
||||
|
||||
// Alice → Bob (hybrid-wrapped)
|
||||
let ct_ab = alice
|
||||
.send_message(b"hello bob")
|
||||
.context("Alice send_message failed")?;
|
||||
let wrapped_ab =
|
||||
hybrid_encrypt(&bob_hybrid_pk, &ct_ab).context("hybrid encrypt Alice→Bob")?;
|
||||
enqueue(&alice_ds, &bob_id.public_key_bytes(), &wrapped_ab).await?;
|
||||
|
||||
let bob_msgs = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?;
|
||||
let raw_ab = bob_msgs
|
||||
.first()
|
||||
.context("Bob: missing Alice ciphertext from DS")?;
|
||||
let inner_ab = hybrid_decrypt(&bob_hybrid, raw_ab).context("Bob: hybrid decrypt failed")?;
|
||||
let ab_plaintext = bob
|
||||
.receive_message(&inner_ab)?
|
||||
.context("Bob expected application message from Alice")?;
|
||||
println!(
|
||||
"Alice → Bob plaintext: {}",
|
||||
String::from_utf8_lossy(&ab_plaintext)
|
||||
);
|
||||
|
||||
// Bob → Alice (hybrid-wrapped)
|
||||
let alice_hybrid_pk = fetch_hybrid_key(&bob_node, &alice_id.public_key_bytes())
|
||||
.await?
|
||||
.context("Alice hybrid key not found")?;
|
||||
let ct_ba = bob
|
||||
.send_message(b"hello alice")
|
||||
.context("Bob send_message failed")?;
|
||||
let wrapped_ba =
|
||||
hybrid_encrypt(&alice_hybrid_pk, &ct_ba).context("hybrid encrypt Bob→Alice")?;
|
||||
enqueue(&bob_ds, &alice_id.public_key_bytes(), &wrapped_ba).await?;
|
||||
|
||||
let alice_msgs = fetch_all(&alice_ds, &alice_id.public_key_bytes()).await?;
|
||||
let raw_ba = alice_msgs
|
||||
.first()
|
||||
.context("Alice: missing Bob ciphertext from DS")?;
|
||||
let inner_ba =
|
||||
hybrid_decrypt(&alice_hybrid, raw_ba).context("Alice: hybrid decrypt failed")?;
|
||||
let ba_plaintext = alice
|
||||
.receive_message(&inner_ba)?
|
||||
.context("Alice expected application message from Bob")?;
|
||||
println!(
|
||||
"Bob → Alice plaintext: {}",
|
||||
String::from_utf8_lossy(&ba_plaintext)
|
||||
);
|
||||
|
||||
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,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_or_init_state(state_path)?;
|
||||
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())?;
|
||||
println!("group created: {group_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invite a peer: fetch their KeyPackage, add to group, enqueue Welcome.
|
||||
///
|
||||
/// If the peer has a hybrid public key on the server, the Welcome is wrapped
|
||||
/// in a post-quantum hybrid envelope (X25519 + ML-KEM-768).
|
||||
pub async fn cmd_invite(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
peer_key_hex: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
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 (_, welcome) = member.add_member(&peer_kp).context("add_member failed")?;
|
||||
|
||||
// Wrap welcome in hybrid envelope if peer has a hybrid public key.
|
||||
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())?;
|
||||
println!(
|
||||
"invited peer (welcome queued{})",
|
||||
if peer_hybrid_pk.is_some() { ", hybrid-encrypted" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Join a group by consuming a Welcome from the server queue.
|
||||
///
|
||||
/// Automatically detects and decrypts hybrid-wrapped Welcomes.
|
||||
pub async fn cmd_join(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
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 welcomes = fetch_all(&node_client, &member.identity().public_key_bytes()).await?;
|
||||
let raw_welcome = welcomes
|
||||
.first()
|
||||
.cloned()
|
||||
.context("no Welcome found in DS for this identity")?;
|
||||
|
||||
// Try hybrid decryption first, fall back to raw MLS welcome.
|
||||
let welcome_bytes = try_hybrid_unwrap(hybrid_kp.as_ref(), &raw_welcome);
|
||||
|
||||
member
|
||||
.join_group(&welcome_bytes)
|
||||
.context("join_group failed")?;
|
||||
|
||||
save_state(state_path, &member, hybrid_kp.as_ref())?;
|
||||
println!("joined group successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an application message via DS.
|
||||
///
|
||||
/// If the peer has a hybrid public key, the MLS ciphertext is additionally
|
||||
/// wrapped in a post-quantum hybrid envelope.
|
||||
pub async fn cmd_send(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
peer_key_hex: &str,
|
||||
msg: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
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 ct = member
|
||||
.send_message(msg.as_bytes())
|
||||
.context("send_message failed")?;
|
||||
|
||||
// Wrap in hybrid envelope if peer has a hybrid public key.
|
||||
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, &ct).context("hybrid encrypt failed")?
|
||||
} else {
|
||||
ct
|
||||
};
|
||||
|
||||
enqueue(&node_client, &peer_key, &payload).await?;
|
||||
|
||||
save_state(state_path, &member, hybrid_kp.as_ref())?;
|
||||
println!(
|
||||
"message sent{}",
|
||||
if peer_hybrid_pk.is_some() { " (hybrid-encrypted)" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive and decrypt all pending messages from the server.
|
||||
///
|
||||
/// Automatically detects and decrypts hybrid-wrapped payloads.
|
||||
pub async fn cmd_recv(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
wait_ms: u64,
|
||||
stream: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
|
||||
|
||||
let client = connect_node(server, ca_cert, server_name).await?;
|
||||
|
||||
loop {
|
||||
let payloads = fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?;
|
||||
|
||||
if payloads.is_empty() {
|
||||
if !stream {
|
||||
println!("no messages");
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (idx, payload) in payloads.iter().enumerate() {
|
||||
// Try hybrid decryption, fall back to raw MLS payload.
|
||||
let mls_payload = try_hybrid_unwrap(hybrid_kp.as_ref(), payload);
|
||||
|
||||
match member.receive_message(&mls_payload) {
|
||||
Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
|
||||
Ok(None) => println!("[{idx}] commit applied"),
|
||||
Err(e) => println!("[{idx}] error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
save_state(state_path, &member, hybrid_kp.as_ref())?;
|
||||
|
||||
if !stream {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Establish a QUIC/TLS connection and return a `NodeService` client.
|
||||
///
|
||||
/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`.
|
||||
pub async fn connect_node(
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<node_service::Client> {
|
||||
let addr: SocketAddr = server
|
||||
.parse()
|
||||
.with_context(|| format!("server must be host:port, got {server}"))?;
|
||||
|
||||
let cert_bytes = fs::read(ca_cert).with_context(|| format!("read ca_cert {ca_cert:?}"))?;
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots
|
||||
.add(CertificateDer::from(cert_bytes))
|
||||
.context("add root cert")?;
|
||||
|
||||
let tls = RustlsClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
|
||||
let crypto = QuicClientConfig::try_from(tls)
|
||||
.map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?;
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||
endpoint.set_default_client_config(ClientConfig::new(Arc::new(crypto)));
|
||||
|
||||
let connection = endpoint
|
||||
.connect(addr, server_name)
|
||||
.context("quic connect init")?
|
||||
.await
|
||||
.context("quic connect failed")?;
|
||||
|
||||
let (send, recv) = connection.open_bi().await.context("open bi stream")?;
|
||||
|
||||
let network = twoparty::VatNetwork::new(
|
||||
recv.compat(),
|
||||
send.compat_write(),
|
||||
Side::Client,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let mut rpc_system = RpcSystem::new(Box::new(network), None);
|
||||
let client: node_service::Client = rpc_system.bootstrap(Side::Server);
|
||||
|
||||
tokio::task::spawn_local(rpc_system);
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Upload a KeyPackage and verify the fingerprint echoed by the AS.
|
||||
pub async fn upload_key_package(
|
||||
client: &node_service::Client,
|
||||
identity_key: &[u8],
|
||||
package: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut req = client.upload_key_package_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_identity_key(identity_key);
|
||||
p.set_package(package);
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("upload_key_package RPC failed")?;
|
||||
|
||||
let server_fp = resp
|
||||
.get()
|
||||
.context("upload_key_package: bad response")?
|
||||
.get_fingerprint()
|
||||
.context("upload_key_package: missing fingerprint")?
|
||||
.to_vec();
|
||||
|
||||
let local_fp = sha256(package);
|
||||
anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a KeyPackage for `identity_key` from the AS.
|
||||
pub async fn fetch_key_package(
|
||||
client: &node_service::Client,
|
||||
identity_key: &[u8],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let mut req = client.fetch_key_package_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_identity_key(identity_key);
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth);
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("fetch_key_package RPC failed")?;
|
||||
|
||||
let pkg = resp
|
||||
.get()
|
||||
.context("fetch_key_package: bad response")?
|
||||
.get_package()
|
||||
.context("fetch_key_package: missing package field")?
|
||||
.to_vec();
|
||||
|
||||
Ok(pkg)
|
||||
}
|
||||
|
||||
/// Enqueue an opaque payload to the DS for `recipient_key`.
|
||||
pub async fn enqueue(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut req = client.enqueue_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_recipient_key(recipient_key);
|
||||
p.set_payload(payload);
|
||||
p.set_channel_id(&[]);
|
||||
p.set_version(1);
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth);
|
||||
}
|
||||
req.send().promise.await.context("enqueue RPC failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch and drain all payloads for `recipient_key`.
|
||||
pub async fn fetch_all(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
let mut req = client.fetch_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_recipient_key(recipient_key);
|
||||
p.set_channel_id(&[]);
|
||||
p.set_version(1);
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth);
|
||||
}
|
||||
|
||||
let resp = req.send().promise.await.context("fetch RPC failed")?;
|
||||
|
||||
let list = resp
|
||||
.get()
|
||||
.context("fetch: bad response")?
|
||||
.get_payloads()
|
||||
.context("fetch: missing payloads")?;
|
||||
|
||||
let mut payloads = Vec::with_capacity(list.len() as usize);
|
||||
for i in 0..list.len() {
|
||||
payloads.push(list.get(i).context("fetch: payload read failed")?.to_vec());
|
||||
}
|
||||
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
/// Long-poll for payloads with optional timeout (ms).
|
||||
pub async fn fetch_wait(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
timeout_ms: u64,
|
||||
) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
let mut req = client.fetch_wait_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_recipient_key(recipient_key);
|
||||
p.set_timeout_ms(timeout_ms);
|
||||
p.set_channel_id(&[]);
|
||||
p.set_version(1);
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth);
|
||||
}
|
||||
|
||||
let resp = req.send().promise.await.context("fetch_wait RPC failed")?;
|
||||
|
||||
let list = resp
|
||||
.get()
|
||||
.context("fetch_wait: bad response")?
|
||||
.get_payloads()
|
||||
.context("fetch_wait: missing payloads")?;
|
||||
|
||||
let mut payloads = Vec::with_capacity(list.len() as usize);
|
||||
for i in 0..list.len() {
|
||||
payloads.push(
|
||||
list.get(i)
|
||||
.context("fetch_wait: payload read failed")?
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
/// Upload a hybrid (X25519 + ML-KEM-768) public key for an identity.
|
||||
pub async fn upload_hybrid_key(
|
||||
client: &node_service::Client,
|
||||
identity_key: &[u8],
|
||||
hybrid_pk: &HybridPublicKey,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut req = client.upload_hybrid_key_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_identity_key(identity_key);
|
||||
p.set_hybrid_public_key(&hybrid_pk.to_bytes());
|
||||
}
|
||||
req.send()
|
||||
.promise
|
||||
.await
|
||||
.context("upload_hybrid_key RPC failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a peer's hybrid public key from the server.
|
||||
///
|
||||
/// Returns `None` if the peer has not uploaded a hybrid key.
|
||||
pub async fn fetch_hybrid_key(
|
||||
client: &node_service::Client,
|
||||
identity_key: &[u8],
|
||||
) -> anyhow::Result<Option<HybridPublicKey>> {
|
||||
let mut req = client.fetch_hybrid_key_request();
|
||||
req.get().set_identity_key(identity_key);
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("fetch_hybrid_key RPC failed")?;
|
||||
|
||||
let pk_bytes = resp
|
||||
.get()
|
||||
.context("fetch_hybrid_key: bad response")?
|
||||
.get_hybrid_public_key()
|
||||
.context("fetch_hybrid_key: missing field")?
|
||||
.to_vec();
|
||||
|
||||
if pk_bytes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let pk = HybridPublicKey::from_bytes(&pk_bytes).context("invalid hybrid public key")?;
|
||||
Ok(Some(pk))
|
||||
}
|
||||
|
||||
/// Try to decrypt a hybrid envelope. If the payload is not a hybrid envelope or
|
||||
/// decryption fails, return the original bytes unchanged (legacy plaintext MLS).
|
||||
fn try_hybrid_unwrap(hybrid_kp: Option<&HybridKeypair>, payload: &[u8]) -> Vec<u8> {
|
||||
if let Some(kp) = hybrid_kp {
|
||||
if let Ok(inner) = hybrid_decrypt(kp, payload) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
payload.to_vec()
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(bytes).to_vec()
|
||||
}
|
||||
|
||||
fn set_auth(auth: &mut auth::Builder<'_>) {
|
||||
let ctx = AUTH_CONTEXT
|
||||
.get()
|
||||
.expect("init_auth must be called with a non-empty token before RPCs");
|
||||
auth.set_version(ctx.version);
|
||||
auth.set_access_token(&ctx.access_token);
|
||||
auth.set_device_id(&ctx.device_id);
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoredState {
|
||||
identity_seed: [u8; 32],
|
||||
group: Option<Vec<u8>>,
|
||||
/// Post-quantum hybrid keypair (X25519 + ML-KEM-768). `None` for legacy state files.
|
||||
#[serde(default)]
|
||||
hybrid_key: Option<HybridKeypairBytes>,
|
||||
}
|
||||
|
||||
impl StoredState {
|
||||
fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option<HybridKeypair>)> {
|
||||
let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed));
|
||||
let group = self
|
||||
.group
|
||||
.map(|bytes| bincode::deserialize(&bytes).context("decode group"))
|
||||
.transpose()?;
|
||||
let key_store = DiskKeyStore::persistent(keystore_path(state_path))?;
|
||||
let member = GroupMember::new_with_state(identity, key_store, group);
|
||||
|
||||
let hybrid_kp = self
|
||||
.hybrid_key
|
||||
.map(|bytes| HybridKeypair::from_bytes(&bytes).context("decode hybrid key"))
|
||||
.transpose()?;
|
||||
|
||||
Ok((member, hybrid_kp))
|
||||
}
|
||||
|
||||
fn from_parts(
|
||||
member: &GroupMember,
|
||||
hybrid_kp: Option<&HybridKeypair>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let group = member
|
||||
.group_ref()
|
||||
.map(|g| bincode::serialize(g).context("serialize group"))
|
||||
.transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
identity_seed: member.identity_seed(),
|
||||
group,
|
||||
hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_init_state(path: &Path) -> anyhow::Result<StoredState> {
|
||||
if path.exists() {
|
||||
let mut state = load_existing_state(path)?;
|
||||
// Upgrade legacy state files: generate hybrid keypair if missing.
|
||||
if state.hybrid_key.is_none() {
|
||||
state.hybrid_key = Some(HybridKeypair::generate().to_bytes());
|
||||
write_state(path, &state)?;
|
||||
}
|
||||
return Ok(state);
|
||||
}
|
||||
|
||||
let identity = IdentityKeypair::generate();
|
||||
let hybrid_kp = HybridKeypair::generate();
|
||||
let key_store = DiskKeyStore::persistent(keystore_path(path))?;
|
||||
let member = GroupMember::new_with_state(Arc::new(identity), key_store, None);
|
||||
let state = StoredState::from_parts(&member, Some(&hybrid_kp))?;
|
||||
write_state(path, &state)?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn load_existing_state(path: &Path) -> anyhow::Result<StoredState> {
|
||||
let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?;
|
||||
bincode::deserialize(&bytes).context("decode state")
|
||||
}
|
||||
|
||||
fn save_state(
|
||||
path: &Path,
|
||||
member: &GroupMember,
|
||||
hybrid_kp: Option<&HybridKeypair>,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = StoredState::from_parts(member, hybrid_kp)?;
|
||||
write_state(path, &state)
|
||||
}
|
||||
|
||||
fn write_state(path: &Path, state: &StoredState) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?;
|
||||
}
|
||||
let bytes = bincode::serialize(state).context("encode state")?;
|
||||
std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_identity_key(hex_str: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let bytes = hex::decode(hex_str)
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
.context("identity key must be hex")?;
|
||||
anyhow::ensure!(bytes.len() == 32, "identity key must be 32 bytes");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn keystore_path(state_path: &Path) -> PathBuf {
|
||||
let mut path = state_path.to_path_buf();
|
||||
path.set_extension("ks");
|
||||
path
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
// ── Hex encoding helper ─────────────────────────────────────────────────────
|
||||
//
|
||||
// We use a tiny inline module rather than adding `hex` as a dependency.
|
||||
|
||||
mod hex {
|
||||
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn decode(s: &str) -> Result<Vec<u8>, &'static str> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err("odd-length hex string");
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,13 @@
|
||||
//! quicnprotochat CLI client.
|
||||
//!
|
||||
//! # Subcommands
|
||||
//!
|
||||
//! | Subcommand | Description |
|
||||
//! |--------------|----------------------------------------------------------|
|
||||
//! | `ping` | Send a Ping to the server, print RTT |
|
||||
//! | `register` | Generate a KeyPackage and upload it to the AS |
|
||||
//! | `fetch-key` | Fetch a peer's KeyPackage from the AS by identity key |
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! | Env var | CLI flag | Default |
|
||||
//! |-----------------|--------------|---------------------|
|
||||
//! | `QUICNPROTOCHAT_SERVER`| `--server` | `127.0.0.1:4201` |
|
||||
//! | `RUST_LOG` | — | `warn` |
|
||||
|
||||
use std::fs;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use capnp_rpc::{rpc_twoparty_capnp::Side, twoparty, RpcSystem};
|
||||
use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
use quinn::{ClientConfig, Endpoint};
|
||||
use quinn_proto::crypto::rustls::QuicClientConfig;
|
||||
use rustls::pki_types::CertificateDer;
|
||||
use rustls::{ClientConfig as RustlsClientConfig, RootCertStore};
|
||||
|
||||
use quicnprotochat_core::{generate_key_package, DiskKeyStore, GroupMember, IdentityKeypair};
|
||||
use quicnprotochat_proto::node_capnp::node_service;
|
||||
use quicnprotochat_client::{
|
||||
cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_invite, cmd_join, cmd_ping, cmd_recv,
|
||||
cmd_register, cmd_register_state, cmd_send, ClientAuth, init_auth,
|
||||
};
|
||||
|
||||
// ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -57,6 +32,14 @@ struct Args {
|
||||
)]
|
||||
server_name: String,
|
||||
|
||||
/// Bearer token for authenticated requests (version 1, required).
|
||||
#[arg(long, global = true, env = "QUICNPROTOCHAT_ACCESS_TOKEN", required = true)]
|
||||
access_token: String,
|
||||
|
||||
/// Optional device identifier (UUID bytes encoded as hex or raw string).
|
||||
#[arg(long, global = true, env = "QUICNPROTOCHAT_DEVICE_ID")]
|
||||
device_id: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
@@ -66,7 +49,7 @@ enum Command {
|
||||
/// Send a Ping to the server and print the round-trip time.
|
||||
Ping {
|
||||
/// Server address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
|
||||
server: String,
|
||||
},
|
||||
|
||||
@@ -76,7 +59,7 @@ enum Command {
|
||||
/// Ed25519 identity public key bytes (hex), which peers need to fetch it.
|
||||
Register {
|
||||
/// Server address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
|
||||
server: String,
|
||||
},
|
||||
|
||||
@@ -86,7 +69,7 @@ enum Command {
|
||||
/// hex characters (32 bytes).
|
||||
FetchKey {
|
||||
/// Server address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
|
||||
server: String,
|
||||
|
||||
/// Target peer's Ed25519 identity public key (64 hex chars = 32 bytes).
|
||||
@@ -96,7 +79,7 @@ enum Command {
|
||||
/// Run a full Alice↔Bob MLS round-trip against live AS and DS endpoints.
|
||||
DemoGroup {
|
||||
/// Server address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
|
||||
server: String,
|
||||
},
|
||||
|
||||
@@ -111,7 +94,7 @@ enum Command {
|
||||
state: PathBuf,
|
||||
|
||||
/// Authentication Service address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
|
||||
server: String,
|
||||
},
|
||||
|
||||
@@ -126,7 +109,7 @@ enum Command {
|
||||
state: PathBuf,
|
||||
|
||||
/// Server address (host:port).
|
||||
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
|
||||
server: String,
|
||||
|
||||
/// Group identifier (arbitrary bytes, typically a human-readable name).
|
||||
@@ -142,7 +125,7 @@ enum Command {
|
||||
env = "QUICNPROTOCHAT_STATE"
|
||||
)]
|
||||
state: PathBuf,
|
||||
#[arg(long, default_value = "127.0.0.1:4201", env = "QUICNPROTOCHAT_SERVER")]
|
||||
#[arg(long, default_value = "127.0.0.1:7000", env = "QUICNPROTOCHAT_SERVER")]
|
||||
server: String,
|
||||
/// Peer identity public key (64 hex chars = 32 bytes).
|
||||
#[arg(long)]
|
||||
@@ -213,6 +196,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
// Initialize auth context once for all RPCs.
|
||||
let auth_ctx = ClientAuth::from_parts(args.access_token.clone(), args.device_id.clone());
|
||||
init_auth(auth_ctx);
|
||||
|
||||
match args.command {
|
||||
Command::Ping { server } => cmd_ping(&server, &args.ca_cert, &args.server_name).await,
|
||||
Command::Register { server } => {
|
||||
@@ -321,699 +308,4 @@ async fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Subcommand implementations ────────────────────────────────────────────────
|
||||
|
||||
/// Connect to `server`, call health, and print RTT over QUIC/TLS.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Generate a KeyPackage for a fresh identity and upload it to the AS.
|
||||
///
|
||||
/// Must run on a `LocalSet` because capnp-rpc is `!Send`.
|
||||
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();
|
||||
req.get().set_identity_key(&identity.public_key_bytes());
|
||||
req.get().set_package(&tls_bytes);
|
||||
|
||||
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();
|
||||
|
||||
// Verify the server echoed the same fingerprint.
|
||||
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(())
|
||||
}
|
||||
|
||||
/// Upload the stored identity's KeyPackage to the AS (persists backend state).
|
||||
async fn cmd_register_state(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_or_init_state(state_path)?;
|
||||
let mut member = state.into_member(state_path)?;
|
||||
|
||||
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();
|
||||
req.get()
|
||||
.set_identity_key(&member.identity().public_key_bytes());
|
||||
req.get().set_package(&tls_bytes);
|
||||
|
||||
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");
|
||||
|
||||
println!(
|
||||
"identity_key : {}",
|
||||
hex::encode(member.identity().public_key_bytes())
|
||||
);
|
||||
println!("fingerprint : {}", hex::encode(&fingerprint));
|
||||
println!("KeyPackage uploaded successfully.");
|
||||
|
||||
save_state(state_path, &member)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a peer's KeyPackage from the AS by their hex-encoded identity key.
|
||||
///
|
||||
/// Must run on a `LocalSet` because capnp-rpc is `!Send`.
|
||||
async fn cmd_fetch_key(
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
identity_key_hex: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let identity_key = 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();
|
||||
req.get().set_identity_key(&identity_key);
|
||||
|
||||
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 complete Alice↔Bob MLS round-trip using the unified server endpoint.
|
||||
async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
|
||||
// Identities and MLS state must be tied to the same backend instance.
|
||||
let alice_id = Arc::new(IdentityKeypair::generate());
|
||||
let bob_id = Arc::new(IdentityKeypair::generate());
|
||||
|
||||
let mut alice = GroupMember::new(Arc::clone(&alice_id));
|
||||
let mut bob = GroupMember::new(Arc::clone(&bob_id));
|
||||
|
||||
let alice_kp = alice
|
||||
.generate_key_package()
|
||||
.context("Alice KeyPackage generation failed")?;
|
||||
let bob_kp = bob
|
||||
.generate_key_package()
|
||||
.context("Bob KeyPackage generation failed")?;
|
||||
|
||||
// Upload both KeyPackages to the server.
|
||||
let alice_node = connect_node(server, ca_cert, server_name).await?;
|
||||
let bob_node = connect_node(server, ca_cert, server_name).await?;
|
||||
|
||||
upload_key_package(&alice_node, &alice_id.public_key_bytes(), &alice_kp).await?;
|
||||
upload_key_package(&bob_node, &bob_id.public_key_bytes(), &bob_kp).await?;
|
||||
|
||||
// Alice fetches Bob's KeyPackage and creates the group.
|
||||
let fetched_bob_kp = fetch_key_package(&alice_node, &bob_id.public_key_bytes()).await?;
|
||||
anyhow::ensure!(
|
||||
!fetched_bob_kp.is_empty(),
|
||||
"AS returned an empty KeyPackage for Bob",
|
||||
);
|
||||
|
||||
alice
|
||||
.create_group(b"demo-group")
|
||||
.context("Alice create_group failed")?;
|
||||
let (_commit, welcome) = alice
|
||||
.add_member(&fetched_bob_kp)
|
||||
.context("Alice add_member failed")?;
|
||||
|
||||
let alice_ds = alice_node.clone();
|
||||
let bob_ds = bob_node.clone();
|
||||
|
||||
enqueue(&alice_ds, &bob_id.public_key_bytes(), &welcome).await?;
|
||||
|
||||
let welcome_payloads = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?;
|
||||
let welcome_bytes = welcome_payloads
|
||||
.first()
|
||||
.cloned()
|
||||
.context("Welcome was not delivered to Bob via DS")?;
|
||||
|
||||
bob.join_group(&welcome_bytes)
|
||||
.context("Bob join_group failed")?;
|
||||
|
||||
// Alice → Bob
|
||||
let ct_ab = alice
|
||||
.send_message(b"hello bob")
|
||||
.context("Alice send_message failed")?;
|
||||
enqueue(&alice_ds, &bob_id.public_key_bytes(), &ct_ab).await?;
|
||||
let bob_msgs = fetch_all(&bob_ds, &bob_id.public_key_bytes()).await?;
|
||||
let ab_plaintext = bob
|
||||
.receive_message(
|
||||
bob_msgs
|
||||
.first()
|
||||
.context("Bob: missing Alice ciphertext from DS")?,
|
||||
)?
|
||||
.context("Bob expected application message from Alice")?;
|
||||
println!(
|
||||
"Alice → Bob plaintext: {}",
|
||||
String::from_utf8_lossy(&ab_plaintext)
|
||||
);
|
||||
|
||||
// Bob → Alice
|
||||
let ct_ba = bob
|
||||
.send_message(b"hello alice")
|
||||
.context("Bob send_message failed")?;
|
||||
enqueue(&bob_ds, &alice_id.public_key_bytes(), &ct_ba).await?;
|
||||
let alice_msgs = fetch_all(&alice_ds, &alice_id.public_key_bytes()).await?;
|
||||
let ba_plaintext = alice
|
||||
.receive_message(
|
||||
alice_msgs
|
||||
.first()
|
||||
.context("Alice: missing Bob ciphertext from DS")?,
|
||||
)?
|
||||
.context("Alice expected application message from Bob")?;
|
||||
println!(
|
||||
"Bob → Alice plaintext: {}",
|
||||
String::from_utf8_lossy(&ba_plaintext)
|
||||
);
|
||||
|
||||
println!("demo-group complete ✔");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new group and persist state.
|
||||
async fn cmd_create_group(state_path: &Path, _server: &str, group_id: &str) -> anyhow::Result<()> {
|
||||
let state = load_or_init_state(state_path)?;
|
||||
let mut member = state.into_member(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)?;
|
||||
println!("group created: {group_id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invite a peer: fetch their KeyPackage, add to group, enqueue Welcome.
|
||||
async fn cmd_invite(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
peer_key_hex: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
let mut member = state.into_member(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 (_, welcome) = member.add_member(&peer_kp).context("add_member failed")?;
|
||||
|
||||
enqueue(&node_client, &peer_key, &welcome).await?;
|
||||
|
||||
save_state(state_path, &member)?;
|
||||
println!("invited peer (welcome queued)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Join a group by consuming a Welcome from the server queue.
|
||||
async fn cmd_join(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
let mut member = state.into_member(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 welcomes = fetch_all(&node_client, &member.identity().public_key_bytes()).await?;
|
||||
let welcome_bytes = welcomes
|
||||
.first()
|
||||
.cloned()
|
||||
.context("no Welcome found in DS for this identity")?;
|
||||
|
||||
member
|
||||
.join_group(&welcome_bytes)
|
||||
.context("join_group failed")?;
|
||||
|
||||
save_state(state_path, &member)?;
|
||||
println!("joined group successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send an application message via DS.
|
||||
async fn cmd_send(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
peer_key_hex: &str,
|
||||
msg: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
let mut member = state.into_member(state_path)?;
|
||||
|
||||
let peer_key = decode_identity_key(peer_key_hex)?;
|
||||
let node_client = connect_node(server, ca_cert, server_name).await?;
|
||||
|
||||
let ct = member
|
||||
.send_message(msg.as_bytes())
|
||||
.context("send_message failed")?;
|
||||
enqueue(&node_client, &peer_key, &ct).await?;
|
||||
|
||||
save_state(state_path, &member)?;
|
||||
println!("message sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive and decrypt all pending messages from the server.
|
||||
async fn cmd_recv(
|
||||
state_path: &Path,
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
wait_ms: u64,
|
||||
stream: bool,
|
||||
) -> anyhow::Result<()> {
|
||||
let state = load_existing_state(state_path)?;
|
||||
let mut member = state.into_member(state_path)?;
|
||||
|
||||
let client = connect_node(server, ca_cert, server_name).await?;
|
||||
|
||||
loop {
|
||||
let payloads = fetch_wait(&client, &member.identity().public_key_bytes(), wait_ms).await?;
|
||||
|
||||
if payloads.is_empty() {
|
||||
if !stream {
|
||||
println!("no messages");
|
||||
return Ok(());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (idx, payload) in payloads.iter().enumerate() {
|
||||
match member.receive_message(payload) {
|
||||
Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
|
||||
Ok(None) => println!("[{idx}] commit applied"),
|
||||
Err(e) => println!("[{idx}] error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
save_state(state_path, &member)?;
|
||||
|
||||
if !stream {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Establish a QUIC/TLS connection and return a `NodeService` client.
|
||||
///
|
||||
/// Must be called from within a `LocalSet` because capnp-rpc is `!Send`.
|
||||
async fn connect_node(
|
||||
server: &str,
|
||||
ca_cert: &Path,
|
||||
server_name: &str,
|
||||
) -> anyhow::Result<node_service::Client> {
|
||||
let addr: SocketAddr = server
|
||||
.parse()
|
||||
.with_context(|| format!("server must be host:port, got {server}"))?;
|
||||
|
||||
let cert_bytes = fs::read(ca_cert).with_context(|| format!("read ca_cert {ca_cert:?}"))?;
|
||||
let mut roots = RootCertStore::empty();
|
||||
roots
|
||||
.add(CertificateDer::from(cert_bytes))
|
||||
.context("add root cert")?;
|
||||
|
||||
let tls = RustlsClientConfig::builder()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
|
||||
let crypto = QuicClientConfig::try_from(tls)
|
||||
.map_err(|e| anyhow::anyhow!("invalid client TLS config: {e}"))?;
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||
endpoint.set_default_client_config(ClientConfig::new(Arc::new(crypto)));
|
||||
|
||||
let connection = endpoint
|
||||
.connect(addr, server_name)
|
||||
.context("quic connect init")?
|
||||
.await
|
||||
.context("quic connect failed")?;
|
||||
|
||||
let (send, recv) = connection.open_bi().await.context("open bi stream")?;
|
||||
|
||||
let network = twoparty::VatNetwork::new(
|
||||
recv.compat(),
|
||||
send.compat_write(),
|
||||
Side::Client,
|
||||
Default::default(),
|
||||
);
|
||||
|
||||
let mut rpc_system = RpcSystem::new(Box::new(network), None);
|
||||
let client: node_service::Client = rpc_system.bootstrap(Side::Server);
|
||||
|
||||
tokio::task::spawn_local(rpc_system);
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Upload a KeyPackage and verify the fingerprint echoed by the AS.
|
||||
async fn upload_key_package(
|
||||
client: &node_service::Client,
|
||||
identity_key: &[u8],
|
||||
package: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut req = client.upload_key_package_request();
|
||||
req.get().set_identity_key(identity_key);
|
||||
req.get().set_package(package);
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("upload_key_package RPC failed")?;
|
||||
|
||||
let server_fp = resp
|
||||
.get()
|
||||
.context("upload_key_package: bad response")?
|
||||
.get_fingerprint()
|
||||
.context("upload_key_package: missing fingerprint")?
|
||||
.to_vec();
|
||||
|
||||
let local_fp = sha256(package);
|
||||
anyhow::ensure!(server_fp == local_fp, "fingerprint mismatch");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a KeyPackage for `identity_key` from the AS.
|
||||
async fn fetch_key_package(
|
||||
client: &node_service::Client,
|
||||
identity_key: &[u8],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
let mut req = client.fetch_key_package_request();
|
||||
req.get().set_identity_key(identity_key);
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("fetch_key_package RPC failed")?;
|
||||
|
||||
let pkg = resp
|
||||
.get()
|
||||
.context("fetch_key_package: bad response")?
|
||||
.get_package()
|
||||
.context("fetch_key_package: missing package field")?
|
||||
.to_vec();
|
||||
|
||||
Ok(pkg)
|
||||
}
|
||||
|
||||
/// Enqueue an opaque payload to the DS for `recipient_key`.
|
||||
async fn enqueue(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
let mut req = client.enqueue_request();
|
||||
req.get().set_recipient_key(recipient_key);
|
||||
req.get().set_payload(payload);
|
||||
req.send().promise.await.context("enqueue RPC failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch and drain all payloads for `recipient_key`.
|
||||
async fn fetch_all(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
let mut req = client.fetch_request();
|
||||
req.get().set_recipient_key(recipient_key);
|
||||
|
||||
let resp = req.send().promise.await.context("fetch RPC failed")?;
|
||||
|
||||
let list = resp
|
||||
.get()
|
||||
.context("fetch: bad response")?
|
||||
.get_payloads()
|
||||
.context("fetch: missing payloads")?;
|
||||
|
||||
let mut payloads = Vec::with_capacity(list.len() as usize);
|
||||
for i in 0..list.len() {
|
||||
payloads.push(list.get(i).context("fetch: payload read failed")?.to_vec());
|
||||
}
|
||||
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
/// Long-poll for payloads with optional timeout (ms).
|
||||
async fn fetch_wait(
|
||||
client: &node_service::Client,
|
||||
recipient_key: &[u8],
|
||||
timeout_ms: u64,
|
||||
) -> anyhow::Result<Vec<Vec<u8>>> {
|
||||
let mut req = client.fetch_wait_request();
|
||||
req.get().set_recipient_key(recipient_key);
|
||||
req.get().set_timeout_ms(timeout_ms);
|
||||
|
||||
let resp = req.send().promise.await.context("fetch_wait RPC failed")?;
|
||||
|
||||
let list = resp
|
||||
.get()
|
||||
.context("fetch_wait: bad response")?
|
||||
.get_payloads()
|
||||
.context("fetch_wait: missing payloads")?;
|
||||
|
||||
let mut payloads = Vec::with_capacity(list.len() as usize);
|
||||
for i in 0..list.len() {
|
||||
payloads.push(
|
||||
list.get(i)
|
||||
.context("fetch_wait: payload read failed")?
|
||||
.to_vec(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(bytes).to_vec()
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoredState {
|
||||
identity_seed: [u8; 32],
|
||||
group: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl StoredState {
|
||||
fn into_member(self, state_path: &Path) -> anyhow::Result<GroupMember> {
|
||||
let identity = Arc::new(IdentityKeypair::from_seed(self.identity_seed));
|
||||
let group = self
|
||||
.group
|
||||
.map(|bytes| bincode::deserialize(&bytes).context("decode group"))
|
||||
.transpose()?;
|
||||
let key_store = DiskKeyStore::persistent(keystore_path(state_path))?;
|
||||
Ok(GroupMember::new_with_state(identity, key_store, group))
|
||||
}
|
||||
|
||||
fn from_member(member: &GroupMember) -> anyhow::Result<Self> {
|
||||
let group = member
|
||||
.group_ref()
|
||||
.map(|g| bincode::serialize(g).context("serialize group"))
|
||||
.transpose()?;
|
||||
|
||||
Ok(Self {
|
||||
identity_seed: member.identity_seed(),
|
||||
group,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn load_or_init_state(path: &Path) -> anyhow::Result<StoredState> {
|
||||
if path.exists() {
|
||||
return load_existing_state(path);
|
||||
}
|
||||
|
||||
let identity = IdentityKeypair::generate();
|
||||
let key_store = DiskKeyStore::persistent(keystore_path(path))?;
|
||||
let member = GroupMember::new_with_state(Arc::new(identity), key_store, None);
|
||||
let state = StoredState::from_member(&member)?;
|
||||
write_state(path, &state)?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn load_existing_state(path: &Path) -> anyhow::Result<StoredState> {
|
||||
let bytes = std::fs::read(path).with_context(|| format!("read state file {path:?}"))?;
|
||||
bincode::deserialize(&bytes).context("decode state")
|
||||
}
|
||||
|
||||
fn save_state(path: &Path, member: &GroupMember) -> anyhow::Result<()> {
|
||||
let state = StoredState::from_member(member)?;
|
||||
write_state(path, &state)
|
||||
}
|
||||
|
||||
fn write_state(path: &Path, state: &StoredState) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("create dir {parent:?}"))?;
|
||||
}
|
||||
let bytes = bincode::serialize(state).context("encode state")?;
|
||||
std::fs::write(path, bytes).with_context(|| format!("write state {path:?}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decode_identity_key(hex_str: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let bytes = hex::decode(hex_str)
|
||||
.map_err(|e| anyhow::anyhow!(e))
|
||||
.context("identity key must be hex")?;
|
||||
anyhow::ensure!(bytes.len() == 32, "identity key must be 32 bytes");
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn keystore_path(state_path: &Path) -> PathBuf {
|
||||
let mut path = state_path.to_path_buf();
|
||||
path.set_extension("ks");
|
||||
path
|
||||
}
|
||||
|
||||
/// Format the first `n` bytes as lowercase hex with a trailing `…`.
|
||||
fn fmt_hex(bytes: &[u8]) -> String {
|
||||
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
|
||||
format!("{hex}…")
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
|
||||
// ── Hex encoding helper ───────────────────────────────────────────────────────
|
||||
//
|
||||
// We use a tiny inline module rather than adding `hex` as a dependency.
|
||||
|
||||
mod hex {
|
||||
pub fn encode(bytes: impl AsRef<[u8]>) -> String {
|
||||
bytes.as_ref().iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn decode(s: &str) -> Result<Vec<u8>, &'static str> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err("odd-length hex string");
|
||||
}
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| "invalid hex character"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
//! M1 integration test: Noise_XX handshake + Ping/Pong round-trip.
|
||||
//!
|
||||
//! Both the server-side and client-side logic run in the same Tokio runtime
|
||||
//! using `tokio::spawn`. The test verifies:
|
||||
//!
|
||||
//! 1. The Noise_XX handshake completes from both sides.
|
||||
//! 2. A Ping sent by the client arrives as a Ping on the server side.
|
||||
//! 3. The server's Pong arrives correctly on the client side.
|
||||
//! 4. Mutual authentication: each peer's observed remote static key matches the
|
||||
//! other peer's actual public key (the core security property of XX).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use quicnprotochat_core::{handshake_initiator, handshake_responder, NoiseKeypair};
|
||||
use quicnprotochat_proto::{MsgType, ParsedEnvelope};
|
||||
|
||||
/// Completes a full Noise_XX handshake and Ping/Pong exchange, then verifies
|
||||
/// mutual authentication by comparing observed vs. actual static public keys.
|
||||
#[tokio::test]
|
||||
async fn noise_xx_ping_pong_round_trip() {
|
||||
let server_keypair = Arc::new(NoiseKeypair::generate());
|
||||
let client_keypair = NoiseKeypair::generate();
|
||||
|
||||
// Bind the listener *before* spawning so the port is ready when the client
|
||||
// calls connect — no sleep or retry needed.
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("failed to bind test listener");
|
||||
let server_addr = listener.local_addr().expect("failed to get local addr");
|
||||
|
||||
// ── Server task ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Handles exactly one connection: completes the handshake, asserts that it
|
||||
// receives a Ping, sends a Pong, then returns the client's observed key.
|
||||
let server_kp = Arc::clone(&server_keypair);
|
||||
let server_task = tokio::spawn(async move {
|
||||
let (stream, _peer) = listener.accept().await.expect("server accept failed");
|
||||
|
||||
let mut transport = handshake_responder(stream, &server_kp)
|
||||
.await
|
||||
.expect("server Noise_XX handshake failed");
|
||||
|
||||
let env = transport
|
||||
.recv_envelope()
|
||||
.await
|
||||
.expect("server recv_envelope failed");
|
||||
|
||||
match env.msg_type {
|
||||
MsgType::Ping => {}
|
||||
_ => panic!("server expected Ping, received a different message type"),
|
||||
}
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Pong,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("server send_envelope failed");
|
||||
|
||||
// Return the client's public key as authenticated by the server.
|
||||
transport
|
||||
.remote_static_public_key()
|
||||
.expect("server: no remote static key after completed XX handshake")
|
||||
.to_vec()
|
||||
});
|
||||
|
||||
// ── Client side ───────────────────────────────────────────────────────────
|
||||
let stream = tokio::net::TcpStream::connect(server_addr)
|
||||
.await
|
||||
.expect("client connect failed");
|
||||
|
||||
let mut transport = handshake_initiator(stream, &client_keypair)
|
||||
.await
|
||||
.expect("client Noise_XX handshake failed");
|
||||
|
||||
// Capture the server's public key as authenticated by the client.
|
||||
let server_key_seen_by_client = transport
|
||||
.remote_static_public_key()
|
||||
.expect("client: no remote static key after completed XX handshake")
|
||||
.to_vec();
|
||||
|
||||
transport
|
||||
.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 1_700_000_000_000,
|
||||
})
|
||||
.await
|
||||
.expect("client send_envelope failed");
|
||||
|
||||
let pong = tokio::time::timeout(std::time::Duration::from_secs(5), transport.recv_envelope())
|
||||
.await
|
||||
.expect("timed out waiting for Pong — server task likely panicked")
|
||||
.expect("client recv_envelope failed");
|
||||
|
||||
match pong.msg_type {
|
||||
MsgType::Pong => {}
|
||||
_ => panic!("client expected Pong, received a different message type"),
|
||||
}
|
||||
|
||||
// ── Mutual authentication assertions ──────────────────────────────────────
|
||||
let client_key_seen_by_server = server_task
|
||||
.await
|
||||
.expect("server task panicked — see output above");
|
||||
|
||||
// The server authenticated the client's static public key correctly.
|
||||
assert_eq!(
|
||||
client_key_seen_by_server,
|
||||
client_keypair.public_bytes().to_vec(),
|
||||
"server's authenticated view of client key does not match client's actual public key"
|
||||
);
|
||||
|
||||
// The client authenticated the server's static public key correctly.
|
||||
assert_eq!(
|
||||
server_key_seen_by_client,
|
||||
server_keypair.public_bytes().to_vec(),
|
||||
"client's authenticated view of server key does not match server's actual public key"
|
||||
);
|
||||
}
|
||||
|
||||
/// A second independent connection on the same server must also succeed,
|
||||
/// confirming that the server keypair reuse across connections is correct.
|
||||
#[tokio::test]
|
||||
async fn two_sequential_connections_both_authenticate() {
|
||||
let server_keypair = Arc::new(NoiseKeypair::generate());
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind failed");
|
||||
let server_addr = listener.local_addr().expect("local_addr failed");
|
||||
|
||||
let server_kp = Arc::clone(&server_keypair);
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..2_u8 {
|
||||
let (stream, _) = listener.accept().await.expect("accept failed");
|
||||
let kp = Arc::clone(&server_kp);
|
||||
tokio::spawn(async move {
|
||||
let mut t = handshake_responder(stream, &kp)
|
||||
.await
|
||||
.expect("server handshake failed");
|
||||
let env = t.recv_envelope().await.expect("recv failed");
|
||||
match env.msg_type {
|
||||
MsgType::Ping => {}
|
||||
_ => panic!("expected Ping"),
|
||||
}
|
||||
t.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Pong,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("server send failed");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for _ in 0..2_u8 {
|
||||
let kp = NoiseKeypair::generate();
|
||||
let stream = tokio::net::TcpStream::connect(server_addr)
|
||||
.await
|
||||
.expect("connect failed");
|
||||
let mut t = handshake_initiator(stream, &kp)
|
||||
.await
|
||||
.expect("client handshake failed");
|
||||
|
||||
t.send_envelope(&ParsedEnvelope {
|
||||
msg_type: MsgType::Ping,
|
||||
group_id: vec![],
|
||||
sender_id: vec![],
|
||||
payload: vec![],
|
||||
timestamp_ms: 0,
|
||||
})
|
||||
.await
|
||||
.expect("client send failed");
|
||||
|
||||
let pong = tokio::time::timeout(std::time::Duration::from_secs(5), t.recv_envelope())
|
||||
.await
|
||||
.expect("timeout")
|
||||
.expect("recv failed");
|
||||
|
||||
match pong.msg_type {
|
||||
MsgType::Pong => {}
|
||||
_ => panic!("expected Pong"),
|
||||
}
|
||||
|
||||
// Each client sees the *same* server public key (key reuse across connections).
|
||||
let seen = t
|
||||
.remote_static_public_key()
|
||||
.expect("no remote key")
|
||||
.to_vec();
|
||||
assert_eq!(seen, server_keypair.public_bytes().to_vec());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user