feat: wire traffic resistance, implement v2 CLI commands, add auth expiry detection

Server:
- Wire traffic resistance decoy generator into main.rs startup behind
  --traffic-resistance flag + --decoy-interval-ms config (feature-gated)

Client:
- Implement v2 CLI one-shot commands: send, recv, dm, group create, group invite
  All previously printed "coming soon" — now fully functional with MLS state
  restoration, peer resolution, KeyPackage fetch, and MLS encryption pipeline

SDK:
- Add SdkError::SessionExpired variant + is_auth_expired() helper for
  detecting expired session tokens (RpcStatus::Unauthorized)
- Add ClientEvent::AuthExpired for UI-layer session expiry notification
This commit is contained in:
2026-04-05 00:03:12 +02:00
parent f58ce2529d
commit a856f9bb53
4 changed files with 175 additions and 21 deletions

View File

@@ -351,6 +351,25 @@ async fn connect_client(args: &Args) -> anyhow::Result<QpqClient> {
Ok(client)
}
/// Connect and return client + identity keypair (needed for MLS one-shot commands).
async fn connect_with_identity(
args: &Args,
) -> anyhow::Result<(QpqClient, std::sync::Arc<quicprochat_core::IdentityKeypair>)> {
let client = connect_client(args).await?;
let keypair = if args.state.exists() {
let stored =
quicprochat_sdk::state::load_state(&args.state, args.db_password.as_deref())
.context("load identity state — register or login first")?;
std::sync::Arc::new(quicprochat_core::IdentityKeypair::from_seed(
stored.identity_seed,
))
} else {
anyhow::bail!("no state file found at {} — register or login first", args.state.display());
};
Ok((client, keypair))
}
// ── Entry point ──────────────────────────────────────────────────────────────
pub fn main() {
@@ -446,34 +465,89 @@ async fn run(args: Args) -> anyhow::Result<()> {
}
Cmd::Dm { ref username } => {
let mut client = connect_client(&args).await?;
v2_commands::cmd_resolve(&mut client, username)
.await
.context("dm setup failed")?;
// For now, print the resolved key. Full DM creation requires
// MLS group state, which will be handled in the REPL flow.
println!("(DM creation with full MLS setup is available in the REPL)");
let (client, identity) = connect_with_identity(&args).await?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let peer_key = quicprochat_sdk::users::resolve_user(rpc, username)
.await?
.ok_or_else(|| anyhow::anyhow!("user '{username}' not found"))?;
let key_package = quicprochat_sdk::keys::fetch_key_package(rpc, &peer_key)
.await?
.ok_or_else(|| anyhow::anyhow!("no KeyPackage available for peer"))?;
let mut member = quicprochat_core::GroupMember::new(identity.clone());
let (conv_id, was_new) = quicprochat_sdk::groups::create_dm(
rpc, conv_store, &mut member, &identity,
&peer_key, &key_package, None, None,
).await?;
if was_new {
println!("DM with {username} created (id: {})", hex::encode(conv_id.0));
} else {
println!("DM with {username} resumed (id: {})", hex::encode(conv_id.0));
}
}
Cmd::Send { ref to, ref msg } => {
let _ = (to, msg);
let _client = connect_client(&args).await?;
// Full send requires MLS group state restoration — deferred to REPL.
println!("(send is currently available in the REPL; one-shot send coming soon)");
let (client, identity) = connect_with_identity(&args).await?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_id = quicprochat_sdk::conversation::ConversationId::from_group_name(to);
let conv = conv_store
.load_conversation(&conv_id)?
.ok_or_else(|| anyhow::anyhow!("conversation '{to}' not found"))?;
let mut member = quicprochat_sdk::groups::restore_mls_state(&conv, &identity)?;
let my_pub = identity.public_key_bytes();
let recipients: Vec<Vec<u8>> = conv
.member_keys
.iter()
.filter(|k| k.as_slice() != my_pub.as_slice())
.cloned()
.collect();
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let hybrid_keys = vec![None; recipients.len()];
quicprochat_sdk::messaging::send_message(
rpc, &mut member, &identity, msg, &recipients, &hybrid_keys, conv_id.0.as_slice(),
).await?;
quicprochat_sdk::groups::save_mls_state(conv_store, &conv_id, &member)?;
println!("sent to {to}");
}
Cmd::Recv { ref from } => {
let _ = from;
let _client = connect_client(&args).await?;
println!("(recv is currently available in the REPL; one-shot recv coming soon)");
let (client, identity) = connect_with_identity(&args).await?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_id = quicprochat_sdk::conversation::ConversationId::from_group_name(from);
let conv = conv_store
.load_conversation(&conv_id)?
.ok_or_else(|| anyhow::anyhow!("conversation '{from}' not found"))?;
let mut member = quicprochat_sdk::groups::restore_mls_state(&conv, &identity)?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let my_key = identity.public_key_bytes();
let messages = quicprochat_sdk::messaging::receive_messages(
rpc, &mut member, my_key.as_slice(), None, conv_id.0.as_slice(), &[],
).await?;
quicprochat_sdk::groups::save_mls_state(conv_store, &conv_id, &member)?;
if messages.is_empty() {
println!("no new messages");
} else {
for msg in &messages {
let sender_short = hex::encode(&msg.sender_key[..4]);
let body = match &msg.message {
quicprochat_core::AppMessage::Chat { body, .. } => {
String::from_utf8_lossy(body).to_string()
}
other => format!("{other:?}"),
};
println!("[{sender_short}] {body}");
}
}
}
Cmd::Group {
action: GroupCmd::Create { ref name },
} => {
let _ = name;
let _client = connect_client(&args).await?;
println!("(group create is currently available in the REPL; one-shot coming soon)");
let (_client, identity) = connect_with_identity(&args).await?;
let conv_store = _client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let mut member = quicprochat_core::GroupMember::new(identity.clone());
let conv_id = quicprochat_sdk::groups::create_group(conv_store, &mut member, name)?;
println!("group '{name}' created (id: {})", hex::encode(conv_id.0));
}
Cmd::Group {
@@ -483,9 +557,26 @@ async fn run(args: Args) -> anyhow::Result<()> {
ref user,
},
} => {
let _ = (group, user);
let _client = connect_client(&args).await?;
println!("(group invite is currently available in the REPL; one-shot coming soon)");
let (client, identity) = connect_with_identity(&args).await?;
let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
let conv_id = quicprochat_sdk::conversation::ConversationId::from_group_name(group);
let conv = conv_store
.load_conversation(&conv_id)?
.ok_or_else(|| anyhow::anyhow!("group '{group}' not found"))?;
let mut member = quicprochat_sdk::groups::restore_mls_state(&conv, &identity)?;
// Resolve peer identity key and fetch their KeyPackage.
let peer_key = quicprochat_sdk::users::resolve_user(rpc, user)
.await?
.ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?;
let key_package = quicprochat_sdk::keys::fetch_key_package(rpc, &peer_key)
.await?
.ok_or_else(|| anyhow::anyhow!("no KeyPackage available for peer"))?;
quicprochat_sdk::groups::invite_to_group(
rpc, conv_store, &mut member, &identity, &conv_id,
&peer_key, &key_package, None, None,
).await?;
println!("invited {user} to '{group}'");
}
Cmd::Devices {