Delivery sequence numbers (MLS epoch ordering fix):
- schemas/node.capnp: add Envelope{seq,data} struct; enqueue returns seq:UInt64;
fetch/fetchWait return List(Envelope) instead of List(Data)
- storage.rs: Store trait enqueue returns u64; fetch/fetch_limited return
Vec<(u64, Vec<u8>)>; FileBackedStore gains QueueMapV3 with per-inbox seq
counters and V2→V3 on-disk migration
- migrations/002_add_seq.sql: seq column, delivery_seq_counters table, index
- sql_store.rs: atomic UPSERT counter via RETURNING, ORDER BY seq, SCHEMA_VERSION→3
- node_service/delivery.rs: builds Envelope list; returns seq from enqueue
- client/rpc.rs: enqueue→u64, fetch_all/fetch_wait→Vec<(u64,Vec<u8>)>
- client/commands.rs: sort-by-seq before MLS processing; retry loop in cmd_recv
and receive_pending_plaintexts for correct epoch ordering
Server refactor:
- Split monolithic main.rs into node_service/{mod,delivery,auth_ops,key_ops,p2p_ops}
- Add auth.rs (token validation, rate limiting), config.rs, metrics.rs, tls.rs
- Add SQL migrations runner (001_initial.sql, 002_add_seq.sql)
- OPAQUE PAKE login/registration, sealed-sender mode, queue depth limit (1000)
Client refactor:
- Split lib.rs into client/{commands,rpc,state,retry,hex,mod}
- Add cmd_whoami, cmd_health, cmd_check_key, cmd_ping subcommands
- Add cmd_register_user, cmd_login (OPAQUE), cmd_refresh_keypackage
- Hybrid PQ envelope (X25519 + ML-KEM-768) on all send/recv paths
- E2E test suite expanded
Other:
- quicnprotochat-gui: Tauri 2 desktop GUI skeleton (backend + HTML UI)
- quicnprotochat-p2p: iroh-based P2P transport stub
- quicnprotochat-core: app_message, hybrid_crypto modules; GroupMember API updates
- .github/workflows/size-lint.yml: binary size regression check
- docs: protocol comparison, roadmap updates, fully-operational checklist
598 lines
16 KiB
Rust
598 lines
16 KiB
Rust
// cargo_bin! only works for current package's binary; we spawn quicnprotochat-server from another package.
|
|
#![allow(deprecated)]
|
|
|
|
use std::{path::PathBuf, process::Command, time::Duration};
|
|
|
|
use assert_cmd::cargo::cargo_bin;
|
|
use portpicker::pick_unused_port;
|
|
use rand::RngCore;
|
|
use tempfile::TempDir;
|
|
use tokio::time::sleep;
|
|
use hex;
|
|
|
|
// Required by rustls 0.23 when QUIC/TLS is used from this process (e.g. client in test).
|
|
fn ensure_rustls_provider() {
|
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
|
}
|
|
|
|
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,
|
|
};
|
|
use quicnprotochat_core::IdentityKeypair;
|
|
|
|
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();
|
|
for _ in 0..30 {
|
|
if local
|
|
.run_until(cmd_ping(server, ca_cert, server_name))
|
|
.await
|
|
.is_ok()
|
|
{
|
|
return Ok(());
|
|
}
|
|
sleep(Duration::from_millis(200)).await;
|
|
}
|
|
anyhow::bail!("server health never became ready")
|
|
}
|
|
|
|
/// Creator and joiner register; creator creates group and invites joiner; joiner joins;
|
|
/// creator sends a message; assert joiner's mailbox receives it.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
|
|
ensure_rustls_provider();
|
|
|
|
let temp = TempDir::new()?;
|
|
let base = temp.path();
|
|
let port = pick_unused_port().expect("free port");
|
|
let listen = format!("127.0.0.1:{port}");
|
|
let server = listen.clone();
|
|
let ca_cert = base.join("server-cert.der");
|
|
let tls_key = base.join("server-key.der");
|
|
let data_dir = base.join("data");
|
|
let auth_token = "devtoken";
|
|
|
|
// Spawn server binary.
|
|
let server_bin = cargo_bin("quicnprotochat-server");
|
|
let child = Command::new(server_bin)
|
|
.arg("--listen")
|
|
.arg(&listen)
|
|
.arg("--data-dir")
|
|
.arg(&data_dir)
|
|
.arg("--tls-cert")
|
|
.arg(&ca_cert)
|
|
.arg("--tls-key")
|
|
.arg(&tls_key)
|
|
.arg("--auth-token")
|
|
.arg(auth_token)
|
|
.arg("--allow-insecure-auth")
|
|
.spawn()
|
|
.expect("spawn server");
|
|
|
|
// Ensure we always terminate the child.
|
|
struct ChildGuard(std::process::Child);
|
|
impl Drop for ChildGuard {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
}
|
|
}
|
|
let child_guard = ChildGuard(child);
|
|
let _ = child_guard;
|
|
|
|
// Wait for server to be healthy and certs to be generated.
|
|
wait_for_health(&server, &ca_cert, "localhost").await?;
|
|
|
|
// Set client auth context.
|
|
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
|
|
|
let local = tokio::task::LocalSet::new();
|
|
|
|
let creator_state = base.join("creator.bin");
|
|
let joiner_state = base.join("joiner.bin");
|
|
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&joiner_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_create_group(&creator_state, &server, "test-group", None))
|
|
.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_pk_hex = hex_encode(&joiner_identity.public_key_bytes());
|
|
|
|
local
|
|
.run_until(cmd_invite(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
&joiner_pk_hex,
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_join(&joiner_state, &server, &ca_cert, "localhost", None))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_send(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
Some(&joiner_pk_hex),
|
|
false,
|
|
"hello",
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(async {
|
|
let client = connect_node(&server, &ca_cert, "localhost").await?;
|
|
let payloads = fetch_wait(&client, &joiner_identity.public_key_bytes(), 1000).await?;
|
|
anyhow::ensure!(!payloads.is_empty(), "no payloads delivered to joiner");
|
|
Ok::<(), anyhow::Error>(())
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Three-party group: A creates group, invites B then C; B and C join; A sends, B and C receive;
|
|
/// B sends, A and C receive.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
|
|
ensure_rustls_provider();
|
|
|
|
let temp = TempDir::new()?;
|
|
let base = temp.path();
|
|
let port = pick_unused_port().expect("free port");
|
|
let listen = format!("127.0.0.1:{port}");
|
|
let server = listen.clone();
|
|
let ca_cert = base.join("server-cert.der");
|
|
let tls_key = base.join("server-key.der");
|
|
let data_dir = base.join("data");
|
|
let auth_token = "devtoken";
|
|
|
|
let server_bin = cargo_bin("quicnprotochat-server");
|
|
let child = Command::new(server_bin)
|
|
.arg("--listen")
|
|
.arg(&listen)
|
|
.arg("--data-dir")
|
|
.arg(&data_dir)
|
|
.arg("--tls-cert")
|
|
.arg(&ca_cert)
|
|
.arg("--tls-key")
|
|
.arg(&tls_key)
|
|
.arg("--auth-token")
|
|
.arg(auth_token)
|
|
.arg("--allow-insecure-auth")
|
|
.spawn()
|
|
.expect("spawn server");
|
|
|
|
struct ChildGuard(std::process::Child);
|
|
impl Drop for ChildGuard {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
}
|
|
}
|
|
let _child_guard = ChildGuard(child);
|
|
|
|
wait_for_health(&server, &ca_cert, "localhost").await?;
|
|
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
|
|
|
let local = tokio::task::LocalSet::new();
|
|
|
|
let creator_state = base.join("creator.bin");
|
|
let b_state = base.join("b.bin");
|
|
let c_state = base.join("c.bin");
|
|
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
))
|
|
.await?;
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&b_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
))
|
|
.await?;
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&c_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
))
|
|
.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 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());
|
|
|
|
local
|
|
.run_until(cmd_create_group(&creator_state, &server, "test-group", None))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_invite(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
&b_pk_hex,
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_invite(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
&c_pk_hex,
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_join(&b_state, &server, &ca_cert, "localhost", None))
|
|
.await?;
|
|
local
|
|
.run_until(cmd_join(&c_state, &server, &ca_cert, "localhost", None))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_send(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
true,
|
|
"hello",
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
sleep(Duration::from_millis(150)).await;
|
|
|
|
let b_plaintexts = local
|
|
.run_until(receive_pending_plaintexts(
|
|
&b_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
1500,
|
|
None,
|
|
))
|
|
.await?;
|
|
let c_plaintexts = local
|
|
.run_until(receive_pending_plaintexts(
|
|
&c_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
1500,
|
|
None,
|
|
))
|
|
.await?;
|
|
anyhow::ensure!(
|
|
b_plaintexts.iter().any(|p| p.as_slice() == b"hello"),
|
|
"B did not receive 'hello', got {:?}",
|
|
b_plaintexts
|
|
);
|
|
anyhow::ensure!(
|
|
c_plaintexts.iter().any(|p| p.as_slice() == b"hello"),
|
|
"C did not receive 'hello', got {:?}",
|
|
c_plaintexts
|
|
);
|
|
|
|
local
|
|
.run_until(cmd_send(
|
|
&b_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
true,
|
|
"hi",
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
sleep(Duration::from_millis(200)).await;
|
|
|
|
let a_plaintexts = local
|
|
.run_until(receive_pending_plaintexts(
|
|
&creator_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
1500,
|
|
None,
|
|
))
|
|
.await?;
|
|
let c_plaintexts2 = local
|
|
.run_until(receive_pending_plaintexts(
|
|
&c_state,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
1500,
|
|
None,
|
|
))
|
|
.await?;
|
|
anyhow::ensure!(
|
|
a_plaintexts.iter().any(|p| p.as_slice() == b"hi"),
|
|
"A did not receive 'hi', got {:?}",
|
|
a_plaintexts
|
|
);
|
|
anyhow::ensure!(
|
|
c_plaintexts2.iter().any(|p| p.as_slice() == b"hi"),
|
|
"C did not receive 'hi', got {:?}",
|
|
c_plaintexts2
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Login should refuse if the presented identity key does not match the registered key.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
|
|
ensure_rustls_provider();
|
|
|
|
let temp = TempDir::new()?;
|
|
let base = temp.path();
|
|
let port = pick_unused_port().expect("free port");
|
|
let listen = format!("127.0.0.1:{port}");
|
|
let server = listen.clone();
|
|
let ca_cert = base.join("server-cert.der");
|
|
let tls_key = base.join("server-key.der");
|
|
let data_dir = base.join("data");
|
|
let auth_token = "devtoken";
|
|
|
|
// Spawn server binary.
|
|
let server_bin = cargo_bin("quicnprotochat-server");
|
|
let child = Command::new(server_bin)
|
|
.arg("--listen")
|
|
.arg(&listen)
|
|
.arg("--data-dir")
|
|
.arg(&data_dir)
|
|
.arg("--tls-cert")
|
|
.arg(&ca_cert)
|
|
.arg("--tls-key")
|
|
.arg(&tls_key)
|
|
.arg("--auth-token")
|
|
.arg(auth_token)
|
|
.arg("--allow-insecure-auth")
|
|
.spawn()
|
|
.expect("spawn server");
|
|
|
|
struct ChildGuard(std::process::Child);
|
|
impl Drop for ChildGuard {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
}
|
|
}
|
|
let child_guard = ChildGuard(child);
|
|
let _ = child_guard;
|
|
|
|
wait_for_health(&server, &ca_cert, "localhost").await?;
|
|
|
|
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
|
|
|
let local = tokio::task::LocalSet::new();
|
|
let state_path = base.join("user.bin");
|
|
|
|
// Register and persist state (includes identity key binding).
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&state_path,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
))
|
|
.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 identity_hex = hex::encode(
|
|
IdentityKeypair::from_seed(stored_state.identity_seed).public_key_bytes(),
|
|
);
|
|
|
|
local
|
|
.run_until(cmd_register_user(
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
"user1",
|
|
"pass",
|
|
Some(&identity_hex),
|
|
))
|
|
.await?;
|
|
|
|
// Craft an unrelated identity key and attempt login with it.
|
|
let mut bogus_identity = [0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut bogus_identity);
|
|
let bogus_hex = hex::encode(bogus_identity);
|
|
|
|
let result = local
|
|
.run_until(cmd_login(
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
"user1",
|
|
"pass",
|
|
Some(&bogus_hex),
|
|
None,
|
|
None,
|
|
))
|
|
.await;
|
|
|
|
match result {
|
|
Ok(_) => anyhow::bail!("login unexpectedly succeeded with mismatched identity"),
|
|
Err(e) => {
|
|
// Show the full error chain so we can match the server's E016 response.
|
|
let msg = format!("{e:#}");
|
|
anyhow::ensure!(
|
|
msg.contains("identity") || msg.contains("E016"),
|
|
"login failed but not for identity mismatch: {msg}"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Sealed Sender: enqueue with valid token (no identity binding) succeeds; recipient can fetch.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
|
|
ensure_rustls_provider();
|
|
|
|
let temp = TempDir::new()?;
|
|
let base = temp.path();
|
|
let port = pick_unused_port().expect("free port");
|
|
let listen = format!("127.0.0.1:{port}");
|
|
let server = listen.clone();
|
|
let ca_cert = base.join("server-cert.der");
|
|
let tls_key = base.join("server-key.der");
|
|
let data_dir = base.join("data");
|
|
let auth_token = "devtoken";
|
|
|
|
let server_bin = cargo_bin("quicnprotochat-server");
|
|
let child = Command::new(server_bin)
|
|
.arg("--listen")
|
|
.arg(&listen)
|
|
.arg("--data-dir")
|
|
.arg(&data_dir)
|
|
.arg("--tls-cert")
|
|
.arg(&ca_cert)
|
|
.arg("--tls-key")
|
|
.arg(&tls_key)
|
|
.arg("--auth-token")
|
|
.arg(auth_token)
|
|
.arg("--allow-insecure-auth")
|
|
.arg("--sealed-sender")
|
|
.spawn()
|
|
.expect("spawn server");
|
|
|
|
struct ChildGuard(std::process::Child);
|
|
impl Drop for ChildGuard {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
}
|
|
}
|
|
let _child_guard = ChildGuard(child);
|
|
|
|
wait_for_health(&server, &ca_cert, "localhost").await?;
|
|
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
|
|
|
let local = tokio::task::LocalSet::new();
|
|
let state_path = base.join("recipient.bin");
|
|
|
|
local
|
|
.run_until(cmd_register_state(
|
|
&state_path,
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
let state_bytes = std::fs::read(&state_path)?;
|
|
let stored: StoredStateCompat = bincode::deserialize(&state_bytes)?;
|
|
let recipient_key = IdentityKeypair::from_seed(stored.identity_seed).public_key_bytes();
|
|
let identity_hex = hex_encode(&recipient_key);
|
|
|
|
local
|
|
.run_until(cmd_register_user(
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
"recipient",
|
|
"pass",
|
|
Some(&identity_hex),
|
|
))
|
|
.await?;
|
|
|
|
local
|
|
.run_until(cmd_login(
|
|
&server,
|
|
&ca_cert,
|
|
"localhost",
|
|
"recipient",
|
|
"pass",
|
|
Some(&identity_hex),
|
|
None,
|
|
None,
|
|
))
|
|
.await?;
|
|
|
|
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
|
local
|
|
.run_until(enqueue(&client, &recipient_key, b"sealed-payload"))
|
|
.await?;
|
|
|
|
let payloads = local
|
|
.run_until(fetch_wait(&client, &recipient_key, 500))
|
|
.await?;
|
|
anyhow::ensure!(
|
|
payloads.len() == 1 && payloads[0].1.as_slice() == b"sealed-payload",
|
|
"expected one payload 'sealed-payload', got {:?}",
|
|
payloads
|
|
);
|
|
|
|
Ok(())
|
|
}
|