Cursor: Apply local changes for cloud agent
This commit is contained in:
@@ -48,6 +48,8 @@ tracing-subscriber = { workspace = true }
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true }
|
||||
clap_complete = { workspace = true }
|
||||
indicatif = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
dashmap = { workspace = true }
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user