Cursor: Apply local changes for cloud agent

This commit is contained in:
2026-02-22 22:29:52 +01:00
parent 6b8b61c6ae
commit 41c57a1181
21 changed files with 616 additions and 142 deletions

View File

@@ -48,6 +48,8 @@ tracing-subscriber = { workspace = true }
# CLI
clap = { workspace = true }
clap_complete = { workspace = true }
indicatif = { workspace = true }
[dev-dependencies]
dashmap = { workspace = true }

View File

@@ -7,7 +7,7 @@ use opaque_ke::{
};
use quicnprotochat_core::{
generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite,
GroupMember, HybridKeypair, IdentityKeypair,
HybridKeypair, IdentityKeypair,
};
use super::{
@@ -16,7 +16,10 @@ use super::{
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},
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).
@@ -45,6 +48,14 @@ pub fn cmd_whoami(state_path: &Path, password: Option<&str>) -> anyhow::Result<(
"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(())
@@ -365,7 +376,7 @@ async fn do_upload_keypackage(
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
member: &mut GroupMember,
member: &mut MemberBackend,
hybrid_kp: Option<&HybridKeypair>,
) -> anyhow::Result<()> {
let tls_bytes = member
@@ -428,8 +439,9 @@ pub async fn cmd_register_state(
ca_cert: &Path,
server_name: &str,
password: Option<&str>,
use_pq_backend: bool,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path, password)?;
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,
@@ -522,15 +534,37 @@ pub async fn cmd_fetch_key(
}
/// Run a two-party MLS demo against the unified server.
pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) -> anyhow::Result<()> {
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 (mut creator, creator_hybrid_opt) =
load_or_init_state(&creator_state_path, None)?.into_parts(&creator_state_path)?;
let (mut joiner, joiner_hybrid_opt) =
load_or_init_state(&joiner_state_path, None)?.into_parts(&joiner_state_path)?;
let 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);
@@ -552,8 +586,6 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
upload_hybrid_key(&creator_node, &creator_identity, &creator_hybrid.public_key()).await?;
upload_hybrid_key(&joiner_node, &joiner_identity, &joiner_hybrid.public_key()).await?;
println!("hybrid public keys uploaded for creator and joiner");
let fetched_joiner_kp = fetch_key_package(&creator_node, &joiner_identity).await?;
anyhow::ensure!(
!fetched_joiner_kp.is_empty(),
@@ -566,7 +598,9 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
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();
@@ -576,7 +610,9 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
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()
@@ -605,10 +641,6 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
let plaintext_creator_joiner = joiner
.receive_message(&inner_creator_joiner)?
.context("expected application message")?;
println!(
"creator -> joiner plaintext: {}",
String::from_utf8_lossy(&plaintext_creator_joiner)
);
let creator_hybrid_pk = fetch_hybrid_key(&joiner_node, &creator_identity)
.await?
@@ -629,11 +661,17 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
let plaintext_joiner_creator = creator
.receive_message(&inner_joiner_creator)?
.context("expected application message")?;
pb.inc(1);
pb.finish_and_clear();
println!(
"joiner -> creator plaintext: {}",
"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(())
@@ -645,8 +683,9 @@ pub async fn cmd_create_group(
_server: &str,
group_id: &str,
password: Option<&str>,
use_pq_backend: bool,
) -> anyhow::Result<()> {
let state = load_or_init_state(state_path, password)?;
let state = load_or_init_state(state_path, password, use_pq_backend)?;
let (mut member, hybrid_kp) = state.into_parts(state_path)?;
anyhow::ensure!(
@@ -850,11 +889,29 @@ pub async fn cmd_recv(
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?;
@@ -876,13 +933,29 @@ pub async fn cmd_recv(
let mls_payload = match try_hybrid_decrypt(hybrid_kp.as_ref(), payload) {
Ok(b) => b,
Err(e) => {
println!("[{idx}] decrypt error: {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)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
Ok(None) => println!("[{idx}] commit applied"),
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),
}
}
@@ -890,14 +963,33 @@ pub async fn cmd_recv(
// 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)) => println!("[retry] plaintext: {}", String::from_utf8_lossy(&pt)),
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) => println!("[retry] error: {e}"),
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(());
}

View File

@@ -1,4 +1,8 @@
//! Retry with exponential backoff for transient RPC failures.
//!
//! Used for `enqueue`, `fetch_all`, and `fetch_wait`. Auth and invalid-param
//! errors are not retried. Configure via `QUICNPROTOCHAT_MAX_RETRIES` and
//! `QUICNPROTOCHAT_BASE_DELAY_MS` (optional).
use std::future::Future;
use std::time::Duration;
@@ -11,6 +15,22 @@ pub const DEFAULT_MAX_RETRIES: u32 = 3;
/// Default base delay in milliseconds for exponential backoff.
pub const DEFAULT_BASE_DELAY_MS: u64 = 500;
/// Read max retries from env or use default.
pub fn max_retries_from_env() -> u32 {
std::env::var("QUICNPROTOCHAT_MAX_RETRIES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_MAX_RETRIES)
}
/// Read base delay (ms) from env or use default.
pub fn base_delay_ms_from_env() -> u64 {
std::env::var("QUICNPROTOCHAT_BASE_DELAY_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_BASE_DELAY_MS)
}
/// Runs an async operation with retries. On `Ok(t)` returns immediately.
/// On `Err(e)`: if `is_retriable(&e)` and `attempt < max_retries`, sleeps with
/// exponential backoff (plus jitter) then retries; otherwise returns the last error.
@@ -31,7 +51,7 @@ where
Ok(t) => return Ok(t),
Err(e) => {
last_err = Some(e);
let err = last_err.as_ref().unwrap();
let err = last_err.as_ref().expect("last_err just set in Err branch");
if !is_retriable(err) || attempt + 1 >= max_retries {
break;
}
@@ -48,7 +68,8 @@ where
}
}
}
Err(last_err.expect("retry_async: last_err set when we break after Err"))
// Loop runs at least once (max_retries >= 1) and we only break after storing an Err, so this is always Some.
Err(last_err.expect("retry_async: last_err is Some when breaking after Err"))
}
/// Classifies `anyhow::Error` for retry: returns `false` for auth or invalid-param

View File

@@ -15,7 +15,9 @@ use quicnprotochat_proto::node_capnp::{auth, node_service};
use crate::AUTH_CONTEXT;
use super::retry::{anyhow_is_retriable, retry_async, DEFAULT_BASE_DELAY_MS, DEFAULT_MAX_RETRIES};
use super::retry::{
anyhow_is_retriable, base_delay_ms_from_env, max_retries_from_env, retry_async,
};
/// Establish a QUIC/TLS connection and return a `NodeService` client.
///
@@ -174,8 +176,8 @@ pub async fn enqueue(
Ok(seq)
}
},
DEFAULT_MAX_RETRIES,
DEFAULT_BASE_DELAY_MS,
max_retries_from_env(),
base_delay_ms_from_env(),
anyhow_is_retriable,
)
.await
@@ -228,8 +230,8 @@ pub async fn fetch_all(
Ok(payloads)
}
},
DEFAULT_MAX_RETRIES,
DEFAULT_BASE_DELAY_MS,
max_retries_from_env(),
base_delay_ms_from_env(),
anyhow_is_retriable,
)
.await
@@ -285,8 +287,8 @@ pub async fn fetch_wait(
Ok(payloads)
}
},
DEFAULT_MAX_RETRIES,
DEFAULT_BASE_DELAY_MS,
max_retries_from_env(),
base_delay_ms_from_env(),
anyhow_is_retriable,
)
.await

View File

@@ -10,13 +10,21 @@ use chacha20poly1305::{
use rand::RngCore;
use serde::{Deserialize, Serialize};
use quicnprotochat_core::{DiskKeyStore, GroupMember, HybridKeypair, HybridKeypairBytes, IdentityKeypair};
use quicnprotochat_core::{
CoreError, DiskKeyStore, GroupMember, HybridCryptoProvider, HybridKeypair, HybridKeypairBytes,
IdentityKeypair, MlsGroup, StoreCrypto,
};
/// Magic bytes for encrypted client state files.
const STATE_MAGIC: &[u8; 4] = b"QPCE";
const STATE_SALT_LEN: usize = 16;
const STATE_NONCE_LEN: usize = 12;
/// Persisted client state (identity, MLS group, optional PQ key).
///
/// **Production note:** When loading state, use the same `use_pq_backend` value that was used when
/// the state was created. Loading PQ state with classical backend (or vice versa) will fail or
/// produce incorrect behavior.
#[derive(Serialize, Deserialize)]
pub struct StoredState {
pub identity_seed: [u8; 32],
@@ -27,17 +35,115 @@ pub struct StoredState {
/// Cached member public keys for group participants.
#[serde(default)]
pub member_keys: Vec<Vec<u8>>,
/// If true, MLS uses post-quantum hybrid KEM (HybridCryptoProvider) for HPKE. M7.
#[serde(default)]
pub use_pq_backend: bool,
}
/// MLS member backend: classical (StoreCrypto) or post-quantum hybrid (HybridCryptoProvider).
pub enum MemberBackend {
Classical(GroupMember<StoreCrypto>),
Hybrid(GroupMember<HybridCryptoProvider>),
}
impl MemberBackend {
pub fn generate_key_package(&mut self) -> Result<Vec<u8>, CoreError> {
match self {
MemberBackend::Classical(m) => m.generate_key_package(),
MemberBackend::Hybrid(m) => m.generate_key_package(),
}
}
pub fn create_group(&mut self, group_id: &[u8]) -> Result<(), CoreError> {
match self {
MemberBackend::Classical(m) => m.create_group(group_id),
MemberBackend::Hybrid(m) => m.create_group(group_id),
}
}
pub fn add_member(&mut self, key_package_bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), CoreError> {
match self {
MemberBackend::Classical(m) => m.add_member(key_package_bytes),
MemberBackend::Hybrid(m) => m.add_member(key_package_bytes),
}
}
pub fn join_group(&mut self, welcome: &[u8]) -> Result<(), CoreError> {
match self {
MemberBackend::Classical(m) => m.join_group(welcome),
MemberBackend::Hybrid(m) => m.join_group(welcome),
}
}
pub fn send_message(&mut self, plaintext: &[u8]) -> Result<Vec<u8>, CoreError> {
match self {
MemberBackend::Classical(m) => m.send_message(plaintext),
MemberBackend::Hybrid(m) => m.send_message(plaintext),
}
}
pub fn receive_message(&mut self, bytes: &[u8]) -> Result<Option<Vec<u8>>, CoreError> {
match self {
MemberBackend::Classical(m) => m.receive_message(bytes),
MemberBackend::Hybrid(m) => m.receive_message(bytes),
}
}
pub fn receive_message_with_sender(
&mut self,
bytes: &[u8],
) -> Result<Option<(Vec<u8>, Vec<u8>)>, CoreError> {
match self {
MemberBackend::Classical(m) => m.receive_message_with_sender(bytes),
MemberBackend::Hybrid(m) => m.receive_message_with_sender(bytes),
}
}
pub fn group_id(&self) -> Option<Vec<u8>> {
match self {
MemberBackend::Classical(m) => m.group_id(),
MemberBackend::Hybrid(m) => m.group_id(),
}
}
pub fn identity(&self) -> &IdentityKeypair {
match self {
MemberBackend::Classical(m) => m.identity(),
MemberBackend::Hybrid(m) => m.identity(),
}
}
pub fn identity_seed(&self) -> [u8; 32] {
match self {
MemberBackend::Classical(m) => m.identity_seed(),
MemberBackend::Hybrid(m) => m.identity_seed(),
}
}
pub fn group_ref(&self) -> Option<&MlsGroup> {
match self {
MemberBackend::Classical(m) => m.group_ref(),
MemberBackend::Hybrid(m) => m.group_ref(),
}
}
pub fn member_identities(&self) -> Vec<Vec<u8>> {
match self {
MemberBackend::Classical(m) => m.member_identities(),
MemberBackend::Hybrid(m) => m.member_identities(),
}
}
pub fn is_pq(&self) -> bool {
matches!(self, MemberBackend::Hybrid(_))
}
}
impl StoredState {
pub fn into_parts(self, state_path: &Path) -> anyhow::Result<(GroupMember, Option<HybridKeypair>)> {
/// Rebuild member and hybrid key from stored state. Uses PQ backend if `use_pq_backend` is true.
pub fn into_parts(self, state_path: &Path) -> anyhow::Result<(MemberBackend, 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 member = if self.use_pq_backend {
MemberBackend::Hybrid(GroupMember::<HybridCryptoProvider>::new_with_state_hybrid(
identity, key_store, group,
))
} else {
MemberBackend::Classical(GroupMember::new_with_state(identity, key_store, group))
};
let hybrid_kp = self
.hybrid_key
@@ -47,7 +153,11 @@ impl StoredState {
Ok((member, hybrid_kp))
}
pub fn from_parts(member: &GroupMember, hybrid_kp: Option<&HybridKeypair>) -> anyhow::Result<Self> {
/// Build state from a classical GroupMember (backward compat / tests). Prefer [`from_member_backend`](Self::from_member_backend) in production.
pub fn from_parts(
member: &GroupMember<StoreCrypto>,
hybrid_kp: Option<&HybridKeypair>,
) -> anyhow::Result<Self> {
let group = member
.group_ref()
.map(|g| bincode::serialize(g).context("serialize group"))
@@ -58,6 +168,26 @@ impl StoredState {
group,
hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()),
member_keys: Vec::new(),
use_pq_backend: false,
})
}
/// Build state from MemberBackend (classical or PQ).
pub fn from_member_backend(
member: &MemberBackend,
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()),
member_keys: Vec::new(),
use_pq_backend: member.is_pq(),
})
}
}
@@ -124,22 +254,49 @@ pub fn is_encrypted_state(bytes: &[u8]) -> bool {
bytes.len() >= 4 && &bytes[..4] == STATE_MAGIC
}
pub fn load_or_init_state(path: &Path, password: Option<&str>) -> anyhow::Result<StoredState> {
/// Create new state with optional post-quantum MLS backend (M7). When `use_pq_backend` is true,
/// new state uses `HybridCryptoProvider` for MLS HPKE (X25519 + ML-KEM-768).
pub fn load_or_init_state(
path: &Path,
password: Option<&str>,
use_pq_backend: bool,
) -> anyhow::Result<StoredState> {
if path.exists() {
let mut state = load_existing_state(path, password)?;
// Generate hybrid keypair if missing (upgrade from older state).
if state.hybrid_key.is_none() {
let pb = indicatif::ProgressBar::new_spinner();
pb.set_message("Generating post-quantum keypair\u{2026}");
pb.enable_steady_tick(std::time::Duration::from_millis(80));
state.hybrid_key = Some(HybridKeypair::generate().to_bytes());
pb.finish_and_clear();
write_state(path, &state, password)?;
}
return Ok(state);
}
let pb = indicatif::ProgressBar::new_spinner();
pb.set_message("Generating post-quantum keypair\u{2026}");
pb.enable_steady_tick(std::time::Duration::from_millis(80));
let identity = IdentityKeypair::generate();
let hybrid_kp = HybridKeypair::generate();
pb.finish_and_clear();
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))?;
let member = if use_pq_backend {
MemberBackend::Hybrid(GroupMember::<HybridCryptoProvider>::new_with_state_hybrid(
Arc::new(identity),
key_store,
None,
))
} else {
MemberBackend::Classical(GroupMember::new_with_state(
Arc::new(identity),
key_store,
None,
))
};
let state = StoredState::from_member_backend(&member, Some(&hybrid_kp))?;
write_state(path, &state, password)?;
Ok(state)
}
@@ -159,11 +316,11 @@ pub fn load_existing_state(path: &Path, password: Option<&str>) -> anyhow::Resul
pub fn save_state(
path: &Path,
member: &GroupMember,
member: &MemberBackend,
hybrid_kp: Option<&HybridKeypair>,
password: Option<&str>,
) -> anyhow::Result<()> {
let state = StoredState::from_parts(member, hybrid_kp)?;
let state = StoredState::from_member_backend(member, hybrid_kp)?;
write_state(path, &state, password)
}

View File

@@ -26,6 +26,7 @@ pub use client::commands::{
};
pub use client::rpc::{connect_node, enqueue, fetch_wait};
pub use client::state::{load_existing_state, StoredState};
// Global auth context initialized once per process.
pub(crate) static AUTH_CONTEXT: OnceLock<ClientAuth> = OnceLock::new();

View File

@@ -52,6 +52,10 @@ struct Args {
#[arg(long, global = true, env = "QUICNPROTOCHAT_STATE_PASSWORD")]
state_password: Option<String>,
/// Use post-quantum MLS backend (X25519 + ML-KEM-768) for new state. M7.
#[arg(long, global = true, env = "QUICNPROTOCHAT_PQ")]
pq: bool,
#[command(subcommand)]
command: Command,
}
@@ -284,6 +288,13 @@ enum Command {
#[arg(long, default_value_t = 500)]
poll_interval_ms: u64,
},
/// Generate shell completions for the given shell and print to stdout.
#[command(hide = true)]
Completions {
shell: clap_complete::Shell,
},
}
// ── Entry point ───────────────────────────────────────────────────────────────
@@ -390,7 +401,7 @@ async fn main() -> anyhow::Result<()> {
Command::DemoGroup { server } => {
let local = tokio::task::LocalSet::new();
local
.run_until(cmd_demo_group(&server, &args.ca_cert, &args.server_name))
.run_until(cmd_demo_group(&server, &args.ca_cert, &args.server_name, args.pq))
.await
}
Command::RegisterState { state, server } => {
@@ -402,6 +413,7 @@ async fn main() -> anyhow::Result<()> {
&args.ca_cert,
&args.server_name,
state_pw,
args.pq,
))
.await
}
@@ -424,7 +436,7 @@ async fn main() -> anyhow::Result<()> {
} => {
let local = tokio::task::LocalSet::new();
local
.run_until(cmd_create_group(&state, &server, &group_id, state_pw))
.run_until(cmd_create_group(&state, &server, &group_id, state_pw, args.pq))
.await
}
Command::Invite {
@@ -515,5 +527,15 @@ async fn main() -> anyhow::Result<()> {
))
.await
}
Command::Completions { shell } => {
use clap::CommandFactory;
clap_complete::generate(
shell,
&mut Args::command(),
"quicnprotochat",
&mut std::io::stdout(),
);
Ok(())
}
}
}

View File

@@ -18,7 +18,7 @@ fn ensure_rustls_provider() {
use quicnprotochat_client::{
cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state,
cmd_register_user, cmd_send, connect_node, enqueue, fetch_wait, init_auth,
receive_pending_plaintexts, ClientAuth,
load_existing_state, receive_pending_plaintexts, ClientAuth,
};
use quicnprotochat_core::IdentityKeypair;
@@ -26,12 +26,6 @@ fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[derive(serde::Deserialize)]
struct StoredStateCompat {
identity_seed: [u8; 32],
#[allow(dead_code)]
group: Option<Vec<u8>>,
}
async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::Result<()> {
let local = tokio::task::LocalSet::new();
@@ -109,6 +103,7 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
&ca_cert,
"localhost",
None,
false,
))
.await?;
@@ -119,16 +114,16 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
&ca_cert,
"localhost",
None,
false,
))
.await?;
local
.run_until(cmd_create_group(&creator_state, &server, "test-group", None))
.run_until(cmd_create_group(&creator_state, &server, "test-group", None, false))
.await?;
let joiner_bytes = std::fs::read(&joiner_state)?;
let joiner_state_compat: StoredStateCompat = bincode::deserialize(&joiner_bytes)?;
let joiner_identity = IdentityKeypair::from_seed(joiner_state_compat.identity_seed);
let joiner_state_loaded = load_existing_state(&joiner_state, None)?;
let joiner_identity = IdentityKeypair::from_seed(joiner_state_loaded.identity_seed);
let joiner_pk_hex = hex_encode(&joiner_identity.public_key_bytes());
local
@@ -227,6 +222,7 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
&ca_cert,
"localhost",
None,
false,
))
.await?;
local
@@ -236,6 +232,7 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
&ca_cert,
"localhost",
None,
false,
))
.await?;
local
@@ -245,19 +242,18 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
&ca_cert,
"localhost",
None,
false,
))
.await?;
let b_bytes = std::fs::read(&b_state)?;
let b_compat: StoredStateCompat = bincode::deserialize(&b_bytes)?;
let b_pk_hex = hex_encode(&IdentityKeypair::from_seed(b_compat.identity_seed).public_key_bytes());
let b_loaded = load_existing_state(&b_state, None)?;
let b_pk_hex = hex_encode(&IdentityKeypair::from_seed(b_loaded.identity_seed).public_key_bytes());
let c_bytes = std::fs::read(&c_state)?;
let c_compat: StoredStateCompat = bincode::deserialize(&c_bytes)?;
let c_pk_hex = hex_encode(&IdentityKeypair::from_seed(c_compat.identity_seed).public_key_bytes());
let c_loaded = load_existing_state(&c_state, None)?;
let c_pk_hex = hex_encode(&IdentityKeypair::from_seed(c_loaded.identity_seed).public_key_bytes());
local
.run_until(cmd_create_group(&creator_state, &server, "test-group", None))
.run_until(cmd_create_group(&creator_state, &server, "test-group", None, false))
.await?;
local
@@ -440,12 +436,12 @@ async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
&ca_cert,
"localhost",
None,
false,
))
.await?;
// Register the user with the bound identity so login can enforce mismatches.
let state_bytes = std::fs::read(&state_path)?;
let stored_state: StoredStateCompat = bincode::deserialize(&state_bytes)?;
let stored_state = load_existing_state(&state_path, None)?;
let identity_hex = hex::encode(
IdentityKeypair::from_seed(stored_state.identity_seed).public_key_bytes(),
);
@@ -547,11 +543,11 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
&ca_cert,
"localhost",
None,
false,
))
.await?;
let state_bytes = std::fs::read(&state_path)?;
let stored: StoredStateCompat = bincode::deserialize(&state_bytes)?;
let stored = load_existing_state(&state_path, None)?;
let recipient_key = IdentityKeypair::from_seed(stored.identity_seed).public_key_bytes();
let identity_hex = hex_encode(&recipient_key);