feat: DM epoch fix, federation relay, and mDNS mesh discovery
- schema: createChannel returns wasNew :Bool to elect the MLS initiator unambiguously; prevents duplicate group creation on concurrent /dm calls - core: group helpers for epoch tracking and key-package lifecycle - server: federation subsystem — mTLS QUIC server-to-server relay with Cap'n Proto RPC; enqueue/batchEnqueue relay unknown recipients to their home domain via FederationClient - server: mDNS _quicproquo._udp.local. service announcement on startup - server: storage + sql_store — identity_exists, peek/ack, federation home-server lookup helpers - client: /mesh peers REPL command (mDNS discovery, feature = "mesh") - client: MeshDiscovery — background mDNS browse with ServiceDaemon - client: was_new=false path in cmd_dm waits for peer Welcome instead of creating a duplicate initiator group - p2p: fix ALPN from quicnprotochat/p2p/1 → quicproquo/p2p/1 - workspace: re-include quicproquo-p2p in members
This commit is contained in:
@@ -59,6 +59,17 @@ hex = { workspace = true }
|
||||
# Secure password prompting (no echo)
|
||||
rpassword = "5"
|
||||
|
||||
# mDNS discovery for mesh mode (Freifunk). Only compiled with --features mesh.
|
||||
mdns-sd = { version = "0.12", optional = true }
|
||||
|
||||
# Optional P2P transport for direct node-to-node messaging.
|
||||
quicproquo-p2p = { path = "../quicproquo-p2p", optional = true }
|
||||
|
||||
[features]
|
||||
# Enable mesh-mode features: mDNS local peer discovery + P2P transport.
|
||||
# Build: cargo build -p quicproquo-client --features mesh
|
||||
mesh = ["dep:mdns-sd", "dep:quicproquo-p2p"]
|
||||
|
||||
[dev-dependencies]
|
||||
dashmap = { workspace = true }
|
||||
assert_cmd = "2"
|
||||
|
||||
@@ -7,7 +7,7 @@ use opaque_ke::{
|
||||
};
|
||||
use quicproquo_core::{
|
||||
generate_key_package, hybrid_decrypt, hybrid_encrypt, opaque_auth::OpaqueSuite,
|
||||
GroupMember, HybridKeypair, IdentityKeypair,
|
||||
GroupMember, HybridKeypair, IdentityKeypair, ReceivedMessage,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -376,7 +376,7 @@ pub(crate) async fn opaque_register(
|
||||
|
||||
/// Perform OPAQUE login and return the raw session token bytes.
|
||||
/// Does NOT require init_auth() — OPAQUE RPCs are unauthenticated.
|
||||
pub(crate) async fn opaque_login(
|
||||
pub async fn opaque_login(
|
||||
client: &quicproquo_proto::node_capnp::node_service::Client,
|
||||
username: &str,
|
||||
password: &str,
|
||||
@@ -725,9 +725,10 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
|
||||
.context("joiner: missing ciphertext from DS")?;
|
||||
let inner_creator_joiner =
|
||||
hybrid_decrypt(&joiner_hybrid, raw_creator_joiner, b"", b"").context("hybrid decrypt failed")?;
|
||||
let plaintext_creator_joiner = joiner
|
||||
.receive_message(&inner_creator_joiner)?
|
||||
.context("expected application message")?;
|
||||
let plaintext_creator_joiner = match joiner.receive_message(&inner_creator_joiner)? {
|
||||
ReceivedMessage::Application(pt) => pt,
|
||||
other => anyhow::bail!("expected application message, got {other:?}"),
|
||||
};
|
||||
println!(
|
||||
"creator -> joiner plaintext: {}",
|
||||
String::from_utf8_lossy(&plaintext_creator_joiner)
|
||||
@@ -749,9 +750,10 @@ pub async fn cmd_demo_group(server: &str, ca_cert: &Path, server_name: &str) ->
|
||||
.context("creator: missing ciphertext from DS")?;
|
||||
let inner_joiner_creator =
|
||||
hybrid_decrypt(&creator_hybrid, raw_joiner_creator, b"", b"").context("hybrid decrypt failed")?;
|
||||
let plaintext_joiner_creator = creator
|
||||
.receive_message(&inner_joiner_creator)?
|
||||
.context("expected application message")?;
|
||||
let plaintext_joiner_creator = match creator.receive_message(&inner_joiner_creator)? {
|
||||
ReceivedMessage::Application(pt) => pt,
|
||||
other => anyhow::bail!("expected application message, got {other:?}"),
|
||||
};
|
||||
println!(
|
||||
"joiner -> creator plaintext: {}",
|
||||
String::from_utf8_lossy(&plaintext_joiner_creator)
|
||||
@@ -1013,8 +1015,8 @@ pub async fn cmd_recv(
|
||||
}
|
||||
};
|
||||
match member.receive_message(&mls_payload) {
|
||||
Ok(Some(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
|
||||
Ok(None) => println!("[{idx}] commit applied"),
|
||||
Ok(ReceivedMessage::Application(pt)) => println!("[{idx}] plaintext: {}", String::from_utf8_lossy(&pt)),
|
||||
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => println!("[{idx}] commit applied"),
|
||||
Err(_) => pending.push((idx, mls_payload)),
|
||||
}
|
||||
}
|
||||
@@ -1023,11 +1025,11 @@ pub async fn cmd_recv(
|
||||
let before = pending.len();
|
||||
pending.retain(|(idx, mls_payload)| {
|
||||
match member.receive_message(mls_payload) {
|
||||
Ok(Some(pt)) => {
|
||||
Ok(ReceivedMessage::Application(pt)) => {
|
||||
println!("[{idx}/retry] plaintext: {}", String::from_utf8_lossy(&pt));
|
||||
false
|
||||
}
|
||||
Ok(None) => {
|
||||
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {
|
||||
println!("[{idx}/retry] commit applied");
|
||||
false
|
||||
}
|
||||
@@ -1078,8 +1080,8 @@ pub async fn receive_pending_plaintexts(
|
||||
Err(_) => continue,
|
||||
};
|
||||
match member.receive_message(&mls_payload) {
|
||||
Ok(Some(pt)) => plaintexts.push(pt),
|
||||
Ok(None) => {}
|
||||
Ok(ReceivedMessage::Application(pt)) => plaintexts.push(pt),
|
||||
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {}
|
||||
Err(_) => pending.push(mls_payload),
|
||||
}
|
||||
}
|
||||
@@ -1088,11 +1090,11 @@ pub async fn receive_pending_plaintexts(
|
||||
let before = pending.len();
|
||||
pending.retain(|mls_payload| {
|
||||
match member.receive_message(mls_payload) {
|
||||
Ok(Some(pt)) => {
|
||||
Ok(ReceivedMessage::Application(pt)) => {
|
||||
plaintexts.push(pt);
|
||||
false
|
||||
}
|
||||
Ok(None) => false,
|
||||
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
});
|
||||
@@ -1250,12 +1252,12 @@ pub async fn cmd_chat(
|
||||
Err(_) => continue,
|
||||
};
|
||||
match member.receive_message(&mls_payload) {
|
||||
Ok(Some(pt)) => {
|
||||
Ok(ReceivedMessage::Application(pt)) => {
|
||||
let s = String::from_utf8_lossy(&pt);
|
||||
println!("\r\n[peer] {s}\n> ");
|
||||
std::io::stdout().flush().context("flush stdout")?;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => {}
|
||||
Err(_) => retry_payloads.push(mls_payload),
|
||||
}
|
||||
}
|
||||
@@ -1264,13 +1266,13 @@ pub async fn cmd_chat(
|
||||
let before = retry_payloads.len();
|
||||
retry_payloads.retain(|mls_payload| {
|
||||
match member.receive_message(mls_payload) {
|
||||
Ok(Some(pt)) => {
|
||||
Ok(ReceivedMessage::Application(pt)) => {
|
||||
let s = String::from_utf8_lossy(&pt);
|
||||
println!("\r\n[peer] {s}\n> ");
|
||||
let _ = std::io::stdout().flush();
|
||||
false
|
||||
}
|
||||
Ok(None) => false,
|
||||
Ok(ReceivedMessage::StateChanged) | Ok(ReceivedMessage::SelfRemoved) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -71,6 +71,10 @@ pub struct Conversation {
|
||||
pub unread_count: u32,
|
||||
pub last_activity_ms: u64,
|
||||
pub created_at_ms: u64,
|
||||
/// Whether this conversation uses hybrid (X25519 + ML-KEM-768) MLS keys.
|
||||
pub is_hybrid: bool,
|
||||
/// Highest server-side delivery sequence number seen.
|
||||
pub last_seen_seq: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -251,9 +255,26 @@ impl ConversationStore {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conv
|
||||
ON messages(conversation_id, timestamp_ms);",
|
||||
ON messages(conversation_id, timestamp_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id BLOB NOT NULL,
|
||||
recipient_key BLOB NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
retry_count INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_outbox_status
|
||||
ON outbox(status, created_at_ms);",
|
||||
)
|
||||
.context("migrate conversation db")?;
|
||||
|
||||
// Additive migrations for new columns (safe to re-run; errors ignored if column already exists).
|
||||
conn.execute_batch("ALTER TABLE conversations ADD COLUMN is_hybrid INTEGER NOT NULL DEFAULT 0;").ok();
|
||||
conn.execute_batch("ALTER TABLE conversations ADD COLUMN last_seen_seq INTEGER NOT NULL DEFAULT 0;").ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -274,15 +295,17 @@ impl ConversationStore {
|
||||
"INSERT INTO conversations
|
||||
(id, kind, display_name, peer_key, peer_username, group_name,
|
||||
mls_group_blob, keystore_blob, member_keys, unread_count,
|
||||
last_activity_ms, created_at_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||
last_activity_ms, created_at_ms, is_hybrid, last_seen_seq)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
display_name = excluded.display_name,
|
||||
mls_group_blob = excluded.mls_group_blob,
|
||||
keystore_blob = excluded.keystore_blob,
|
||||
member_keys = excluded.member_keys,
|
||||
unread_count = excluded.unread_count,
|
||||
last_activity_ms = excluded.last_activity_ms",
|
||||
last_activity_ms = excluded.last_activity_ms,
|
||||
is_hybrid = excluded.is_hybrid,
|
||||
last_seen_seq = excluded.last_seen_seq",
|
||||
params![
|
||||
conv.id.0.as_slice(),
|
||||
kind_str,
|
||||
@@ -296,6 +319,8 @@ impl ConversationStore {
|
||||
conv.unread_count,
|
||||
conv.last_activity_ms,
|
||||
conv.created_at_ms,
|
||||
conv.is_hybrid as i32,
|
||||
conv.last_seen_seq as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
@@ -306,7 +331,7 @@ impl ConversationStore {
|
||||
.query_row(
|
||||
"SELECT kind, display_name, peer_key, peer_username, group_name,
|
||||
mls_group_blob, keystore_blob, member_keys, unread_count,
|
||||
last_activity_ms, created_at_ms
|
||||
last_activity_ms, created_at_ms, is_hybrid, last_seen_seq
|
||||
FROM conversations WHERE id = ?1",
|
||||
params![id.0.as_slice()],
|
||||
|row| {
|
||||
@@ -321,6 +346,8 @@ impl ConversationStore {
|
||||
let unread_count: u32 = row.get(8)?;
|
||||
let last_activity_ms: u64 = row.get(9)?;
|
||||
let created_at_ms: u64 = row.get(10)?;
|
||||
let is_hybrid_int: i32 = row.get(11)?;
|
||||
let last_seen_seq: i64 = row.get(12)?;
|
||||
|
||||
let kind = if kind_str == "dm" {
|
||||
ConversationKind::Dm {
|
||||
@@ -347,6 +374,8 @@ impl ConversationStore {
|
||||
unread_count,
|
||||
last_activity_ms,
|
||||
created_at_ms,
|
||||
is_hybrid: is_hybrid_int != 0,
|
||||
last_seen_seq: last_seen_seq as u64,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -358,7 +387,7 @@ impl ConversationStore {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, kind, display_name, peer_key, peer_username, group_name,
|
||||
mls_group_blob, keystore_blob, member_keys, unread_count,
|
||||
last_activity_ms, created_at_ms
|
||||
last_activity_ms, created_at_ms, is_hybrid, last_seen_seq
|
||||
FROM conversations ORDER BY last_activity_ms DESC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
@@ -374,6 +403,8 @@ impl ConversationStore {
|
||||
let unread_count: u32 = row.get(9)?;
|
||||
let last_activity_ms: u64 = row.get(10)?;
|
||||
let created_at_ms: u64 = row.get(11)?;
|
||||
let is_hybrid_int: i32 = row.get(12)?;
|
||||
let last_seen_seq: i64 = row.get(13)?;
|
||||
|
||||
let id = ConversationId::from_slice(&id_blob).unwrap_or(ConversationId([0; 16]));
|
||||
let kind = if kind_str == "dm" {
|
||||
@@ -400,6 +431,8 @@ impl ConversationStore {
|
||||
unread_count,
|
||||
last_activity_ms,
|
||||
created_at_ms,
|
||||
is_hybrid: is_hybrid_int != 0,
|
||||
last_seen_seq: last_seen_seq as u64,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -553,6 +586,103 @@ impl ConversationStore {
|
||||
msgs.reverse();
|
||||
Ok(msgs)
|
||||
}
|
||||
|
||||
/// Save a message, deduplicating by message_id within the same conversation.
|
||||
/// Returns `true` if the message was saved (new), `false` if it was a duplicate.
|
||||
pub fn save_message_dedup(&self, msg: &StoredMessage) -> anyhow::Result<bool> {
|
||||
if let Some(ref mid) = msg.message_id {
|
||||
let exists: bool = self.conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM messages WHERE message_id = ?1 AND conversation_id = ?2)",
|
||||
params![mid.as_slice(), msg.conversation_id.0.as_slice()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if exists {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
self.save_message(msg)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
// ── Sequence tracking ──────────────────────────────────────────────
|
||||
|
||||
pub fn update_last_seen_seq(&self, id: &ConversationId, seq: u64) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE conversations SET last_seen_seq = ?2 WHERE id = ?1 AND last_seen_seq < ?2",
|
||||
params![id.0.as_slice(), seq as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Outbox (offline queue) ────────────────────────────────────────
|
||||
|
||||
pub fn enqueue_outbox(
|
||||
&self,
|
||||
conv_id: &ConversationId,
|
||||
recipient_key: &[u8],
|
||||
payload: &[u8],
|
||||
) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"INSERT INTO outbox (conversation_id, recipient_key, payload, created_at_ms)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![conv_id.0.as_slice(), recipient_key, payload, now_ms() as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_pending_outbox(&self) -> anyhow::Result<Vec<OutboxEntry>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, conversation_id, recipient_key, payload, retry_count
|
||||
FROM outbox WHERE status = 'pending' ORDER BY created_at_ms",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let id: i64 = row.get(0)?;
|
||||
let conv_blob: Vec<u8> = row.get(1)?;
|
||||
let recipient_key: Vec<u8> = row.get(2)?;
|
||||
let payload: Vec<u8> = row.get(3)?;
|
||||
let retry_count: u32 = row.get(4)?;
|
||||
Ok(OutboxEntry {
|
||||
id,
|
||||
conversation_id: ConversationId::from_slice(&conv_blob)
|
||||
.unwrap_or(ConversationId([0; 16])),
|
||||
recipient_key,
|
||||
payload,
|
||||
retry_count,
|
||||
})
|
||||
})?;
|
||||
let mut entries = Vec::new();
|
||||
for row in rows {
|
||||
entries.push(row?);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn mark_outbox_sent(&self, id: i64) -> anyhow::Result<()> {
|
||||
self.conn.execute(
|
||||
"UPDATE outbox SET status = 'sent' WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn mark_outbox_failed(&self, id: i64, retry_count: u32) -> anyhow::Result<()> {
|
||||
let new_status = if retry_count > 5 { "failed" } else { "pending" };
|
||||
self.conn.execute(
|
||||
"UPDATE outbox SET retry_count = ?2, status = ?3 WHERE id = ?1",
|
||||
params![id, retry_count, new_status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry in the offline outbox queue.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OutboxEntry {
|
||||
pub id: i64,
|
||||
pub conversation_id: ConversationId,
|
||||
pub recipient_key: Vec<u8>,
|
||||
pub payload: Vec<u8>,
|
||||
pub retry_count: u32,
|
||||
}
|
||||
|
||||
pub fn now_ms() -> u64 {
|
||||
|
||||
148
crates/quicproquo-client/src/client/mesh_discovery.rs
Normal file
148
crates/quicproquo-client/src/client/mesh_discovery.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! mDNS-based peer discovery for Freifunk / community mesh deployments.
|
||||
//!
|
||||
//! Browse for `_quicproquo._udp.local.` services on the local network and
|
||||
//! surface them as [`DiscoveredPeer`] structs. Servers announce themselves
|
||||
//! automatically on startup; this module lets clients find them without manual
|
||||
//! configuration.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use quicproquo_client::client::mesh_discovery::MeshDiscovery;
|
||||
//!
|
||||
//! let disc = MeshDiscovery::start()?;
|
||||
//! // Give mDNS time to collect announcements before reading.
|
||||
//! std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
//! for peer in disc.peers() {
|
||||
//! println!("found: {} at {}", peer.domain, peer.server_addr);
|
||||
//! }
|
||||
//! # Ok::<(), quicproquo_client::client::mesh_discovery::MeshDiscoveryError>(())
|
||||
//! ```
|
||||
|
||||
#[cfg(feature = "mesh")]
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use std::net::SocketAddr;
|
||||
#[cfg(feature = "mesh")]
|
||||
use std::sync::{Arc, Mutex};
|
||||
#[cfg(feature = "mesh")]
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A qpq server discovered on the local network via mDNS.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredPeer {
|
||||
/// Federation domain of the remote server (e.g. `"node1.freifunk.net"`).
|
||||
pub domain: String,
|
||||
/// QUIC RPC address to connect to.
|
||||
pub server_addr: SocketAddr,
|
||||
}
|
||||
|
||||
/// A running mDNS browse session.
|
||||
///
|
||||
/// Starts immediately on construction; drop to stop browsing.
|
||||
pub struct MeshDiscovery {
|
||||
#[cfg(feature = "mesh")]
|
||||
_daemon: ServiceDaemon,
|
||||
#[cfg(feature = "mesh")]
|
||||
peers: Arc<Mutex<HashMap<String, DiscoveredPeer>>>,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum MeshDiscoveryError {
|
||||
#[error("mDNS daemon failed to start: {0}")]
|
||||
DaemonError(String),
|
||||
#[error("mDNS browse failed: {0}")]
|
||||
BrowseError(String),
|
||||
#[error("mesh feature not compiled (rebuild with --features mesh)")]
|
||||
FeatureDisabled,
|
||||
}
|
||||
|
||||
impl MeshDiscovery {
|
||||
/// Start browsing for `_quicproquo._udp.local.` services.
|
||||
///
|
||||
/// Returns immediately; peers are collected in the background.
|
||||
/// Returns [`MeshDiscoveryError::FeatureDisabled`] when built without the
|
||||
/// `mesh` feature.
|
||||
pub fn start() -> Result<Self, MeshDiscoveryError> {
|
||||
#[cfg(feature = "mesh")]
|
||||
{
|
||||
Self::start_inner()
|
||||
}
|
||||
#[cfg(not(feature = "mesh"))]
|
||||
{
|
||||
Err(MeshDiscoveryError::FeatureDisabled)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mesh")]
|
||||
fn start_inner() -> Result<Self, MeshDiscoveryError> {
|
||||
let daemon = ServiceDaemon::new()
|
||||
.map_err(|e| MeshDiscoveryError::DaemonError(e.to_string()))?;
|
||||
|
||||
let receiver = daemon
|
||||
.browse("_quicproquo._udp.local.")
|
||||
.map_err(|e| MeshDiscoveryError::BrowseError(e.to_string()))?;
|
||||
|
||||
let peers: Arc<Mutex<HashMap<String, DiscoveredPeer>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
let peers_bg = Arc::clone(&peers);
|
||||
|
||||
// Process mDNS events in a background thread (ServiceDaemon is sync).
|
||||
std::thread::spawn(move || {
|
||||
for event in receiver {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
// Extract the qpq server address from TXT records.
|
||||
let server_addr_str = info
|
||||
.get_property_val_str("server")
|
||||
.map(|s| s.to_string());
|
||||
let domain = info
|
||||
.get_property_val_str("domain")
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| info.get_fullname().to_string());
|
||||
|
||||
if let Some(addr_str) = server_addr_str {
|
||||
if let Ok(addr) = addr_str.parse::<SocketAddr>() {
|
||||
let peer = DiscoveredPeer {
|
||||
domain: domain.clone(),
|
||||
server_addr: addr,
|
||||
};
|
||||
if let Ok(mut map) = peers_bg.lock() {
|
||||
map.insert(domain, peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||
if let Ok(mut map) = peers_bg.lock() {
|
||||
map.retain(|_, p| {
|
||||
!fullname.contains(&p.domain)
|
||||
});
|
||||
}
|
||||
}
|
||||
// Other events (SearchStarted, SearchStopped) are informational.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
_daemon: daemon,
|
||||
peers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return a snapshot of all peers discovered so far.
|
||||
pub fn peers(&self) -> Vec<DiscoveredPeer> {
|
||||
#[cfg(feature = "mesh")]
|
||||
{
|
||||
self.peers
|
||||
.lock()
|
||||
.map(|m| m.values().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
#[cfg(not(feature = "mesh"))]
|
||||
{
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod commands;
|
||||
pub mod conversation;
|
||||
pub mod display;
|
||||
pub mod hex;
|
||||
pub mod mesh_discovery;
|
||||
pub mod repl;
|
||||
pub mod retry;
|
||||
pub mod rpc;
|
||||
|
||||
@@ -54,6 +54,9 @@ enum SlashCommand {
|
||||
Join,
|
||||
Members,
|
||||
History { count: usize },
|
||||
/// Mesh subcommands: /mesh peers, /mesh server <addr>
|
||||
MeshPeers,
|
||||
MeshServer { addr: String },
|
||||
}
|
||||
|
||||
fn parse_input(line: &str) -> Input {
|
||||
@@ -116,6 +119,22 @@ fn parse_input(line: &str) -> Input {
|
||||
let count = arg.and_then(|s| s.parse().ok()).unwrap_or(20);
|
||||
Input::Slash(SlashCommand::History { count })
|
||||
}
|
||||
"/mesh" => match arg.as_deref() {
|
||||
Some("peers") => Input::Slash(SlashCommand::MeshPeers),
|
||||
Some(rest) if rest.starts_with("server ") => {
|
||||
let addr = rest.trim_start_matches("server ").trim().to_string();
|
||||
if addr.is_empty() {
|
||||
display::print_error("usage: /mesh server <host:port>");
|
||||
Input::Empty
|
||||
} else {
|
||||
Input::Slash(SlashCommand::MeshServer { addr })
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
display::print_error("usage: /mesh peers | /mesh server <host:port>");
|
||||
Input::Empty
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
display::print_error(&format!("unknown command: {cmd}. Try /help"));
|
||||
Input::Empty
|
||||
@@ -575,6 +594,13 @@ async fn handle_slash(
|
||||
SlashCommand::Join => cmd_join(session, client).await,
|
||||
SlashCommand::Members => cmd_members(session),
|
||||
SlashCommand::History { count } => cmd_history(session, count),
|
||||
SlashCommand::MeshPeers => cmd_mesh_peers(),
|
||||
SlashCommand::MeshServer { addr } => {
|
||||
display::print_status(&format!(
|
||||
"mesh server hint: reconnect with --server {addr} to use this node"
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
display::print_error(&format!("{e:#}"));
|
||||
@@ -583,18 +609,49 @@ async fn handle_slash(
|
||||
|
||||
fn print_help() {
|
||||
display::print_status("Commands:");
|
||||
display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
|
||||
display::print_status(" /create-group <name> - Create a new group");
|
||||
display::print_status(" /invite <username> - Invite user to current group");
|
||||
display::print_status(" /remove <username> - Remove a member from the current group");
|
||||
display::print_status(" /leave - Leave the current group");
|
||||
display::print_status(" /join - Join a group from pending Welcome");
|
||||
display::print_status(" /switch <@user|#group> - Switch conversation");
|
||||
display::print_status(" /list - List all conversations");
|
||||
display::print_status(" /members - Show members of current conversation");
|
||||
display::print_status(" /history [N] - Show last N messages (default: 20)");
|
||||
display::print_status(" /whoami - Show your identity");
|
||||
display::print_status(" /quit - Exit");
|
||||
display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
|
||||
display::print_status(" /create-group <name> - Create a new group");
|
||||
display::print_status(" /invite <username> - Invite user to current group");
|
||||
display::print_status(" /remove <username> - Remove a member from the current group");
|
||||
display::print_status(" /leave - Leave the current group");
|
||||
display::print_status(" /join - Join a group from pending Welcome");
|
||||
display::print_status(" /switch <@user|#group> - Switch conversation");
|
||||
display::print_status(" /list - List all conversations");
|
||||
display::print_status(" /members - Show members of current conversation");
|
||||
display::print_status(" /history [N] - Show last N messages (default: 20)");
|
||||
display::print_status(" /whoami - Show your identity");
|
||||
display::print_status(" /mesh peers - Discover nearby qpq nodes via mDNS");
|
||||
display::print_status(" /mesh server <host:port> - Show how to reconnect to a mesh node");
|
||||
display::print_status(" /quit - Exit");
|
||||
}
|
||||
|
||||
/// Discover nearby qpq servers via mDNS (requires `--features mesh` build).
|
||||
fn cmd_mesh_peers() -> anyhow::Result<()> {
|
||||
use super::mesh_discovery::MeshDiscovery;
|
||||
|
||||
match MeshDiscovery::start() {
|
||||
Err(e) => {
|
||||
display::print_error(&format!("mesh discovery: {e}"));
|
||||
return Ok(());
|
||||
}
|
||||
Ok(disc) => {
|
||||
display::print_status("scanning for nearby qpq nodes (2s)...");
|
||||
// Block briefly to collect mDNS announcements from the local network.
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let peers = disc.peers();
|
||||
if peers.is_empty() {
|
||||
display::print_status("no qpq nodes found on the local network");
|
||||
} else {
|
||||
display::print_status(&format!("found {} node(s):", peers.len()));
|
||||
for p in &peers {
|
||||
display::print_status(&format!(" {} at {}", p.domain, p.server_addr));
|
||||
}
|
||||
display::print_status("use: /mesh server <host:port> to note the address,");
|
||||
display::print_status("then reconnect with: qpq --server <host:port>");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
|
||||
@@ -725,9 +782,23 @@ async fn cmd_dm(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create server-side channel.
|
||||
// Create or look up the server-side channel.
|
||||
// was_new=true → this call created the channel; we are the MLS initiator.
|
||||
// was_new=false → channel already existed; peer is the MLS initiator and has
|
||||
// sent (or will send) us a Welcome. Wait for try_auto_join.
|
||||
display::print_status("creating channel...");
|
||||
let channel_id = create_channel(client, &peer_key).await?;
|
||||
let (channel_id, was_new) = create_channel(client, &peer_key).await?;
|
||||
|
||||
if !was_new {
|
||||
// Peer is the MLS initiator. Their Welcome is en route; the background
|
||||
// poller's try_auto_join will process it within the next poll interval
|
||||
// and auto-switch to the conversation automatically.
|
||||
display::print_status(&format!(
|
||||
"DM channel with @{username} exists — peer is initiator, auto-joining via Welcome (arrives within ~1 s)"
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let conv_id = ConversationId::from_slice(&channel_id)
|
||||
.context("server returned invalid channel_id length")?;
|
||||
|
||||
|
||||
@@ -645,12 +645,16 @@ pub async fn resolve_identity(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a 1:1 DM channel with a peer. Returns the 16-byte channel ID.
|
||||
/// If a channel already exists between the two users, returns the existing ID.
|
||||
/// Create a 1:1 DM channel with a peer.
|
||||
///
|
||||
/// Returns `(channel_id, was_new)` where `channel_id` is the stable 16-byte identifier and
|
||||
/// `was_new` is `true` iff this call created the channel for the first time. When `was_new` is
|
||||
/// `false`, the channel already existed (created by the peer), and the caller should wait for
|
||||
/// the peer's MLS Welcome to arrive via the background poller rather than creating a new MLS group.
|
||||
pub async fn create_channel(
|
||||
client: &node_service::Client,
|
||||
peer_key: &[u8],
|
||||
) -> anyhow::Result<Vec<u8>> {
|
||||
) -> anyhow::Result<(Vec<u8>, bool)> {
|
||||
let mut req = client.create_channel_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
@@ -665,14 +669,14 @@ pub async fn create_channel(
|
||||
.await
|
||||
.context("create_channel RPC failed")?;
|
||||
|
||||
let channel_id = resp
|
||||
.get()
|
||||
.context("create_channel: bad response")?
|
||||
let reader = resp.get().context("create_channel: bad response")?;
|
||||
let channel_id = reader
|
||||
.get_channel_id()
|
||||
.context("create_channel: missing channel_id")?
|
||||
.to_vec();
|
||||
let was_new = reader.get_was_new();
|
||||
|
||||
Ok(channel_id)
|
||||
Ok((channel_id, was_new))
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
|
||||
@@ -133,6 +133,8 @@ impl SessionState {
|
||||
unread_count: 0,
|
||||
last_activity_ms: now_ms(),
|
||||
created_at_ms: now_ms(),
|
||||
is_hybrid: false,
|
||||
last_seen_seq: 0,
|
||||
};
|
||||
|
||||
self.conv_store.save_conversation(&conv)?;
|
||||
@@ -171,7 +173,7 @@ impl SessionState {
|
||||
Arc::clone(&self.identity),
|
||||
ks,
|
||||
group,
|
||||
false, // existing conversations default to classical
|
||||
conv.is_hybrid,
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ pub use client::commands::{
|
||||
cmd_chat, cmd_check_key, cmd_create_group, cmd_demo_group, cmd_fetch_key, cmd_health,
|
||||
cmd_health_json, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_recv, cmd_register,
|
||||
cmd_register_state, cmd_refresh_keypackage, cmd_register_user, cmd_send, cmd_whoami,
|
||||
receive_pending_plaintexts, whoami_json,
|
||||
opaque_login, receive_pending_plaintexts, whoami_json,
|
||||
};
|
||||
|
||||
pub use client::repl::run_repl;
|
||||
pub use client::rpc::{connect_node, enqueue, fetch_wait};
|
||||
pub use client::rpc::{connect_node, create_channel, enqueue, fetch_wait, resolve_user};
|
||||
|
||||
// Global auth context — RwLock so the REPL can set it after OPAQUE login.
|
||||
pub(crate) static AUTH_CONTEXT: RwLock<Option<ClientAuth>> = RwLock::new(None);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// cargo_bin! only works for current package's binary; we spawn qpq-server from another package.
|
||||
#![allow(deprecated)]
|
||||
|
||||
use std::{path::PathBuf, process::Command, time::Duration};
|
||||
use std::{path::PathBuf, process::Command, sync::Mutex, time::Duration};
|
||||
|
||||
use assert_cmd::cargo::cargo_bin;
|
||||
use portpicker::pick_unused_port;
|
||||
@@ -17,11 +17,15 @@ fn ensure_rustls_provider() {
|
||||
|
||||
use quicproquo_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,
|
||||
cmd_register_user, cmd_send, connect_node, create_channel, enqueue, fetch_wait, init_auth,
|
||||
opaque_login, receive_pending_plaintexts, resolve_user, ClientAuth,
|
||||
};
|
||||
use quicproquo_core::IdentityKeypair;
|
||||
|
||||
/// Serialises all tests that call `init_auth` with a non-devtoken session to prevent
|
||||
/// the global `AUTH_CONTEXT` from being overwritten by concurrent tests.
|
||||
static AUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -33,6 +37,13 @@ struct StoredStateCompat {
|
||||
group: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
struct ChildGuard(std::process::Child);
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -48,26 +59,17 @@ async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) ->
|
||||
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();
|
||||
/// Spawns a server with the given extra args and returns (listen_addr, ca_cert_path, ChildGuard).
|
||||
fn spawn_server(base: &std::path::Path, extra_args: &[&str]) -> (String, PathBuf, ChildGuard) {
|
||||
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("qpq-server");
|
||||
let child = Command::new(server_bin)
|
||||
.arg("--listen")
|
||||
let mut cmd = Command::new(server_bin);
|
||||
cmd.arg("--listen")
|
||||
.arg(&listen)
|
||||
.arg("--data-dir")
|
||||
.arg(&data_dir)
|
||||
@@ -76,25 +78,30 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
.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();
|
||||
}
|
||||
.arg("devtoken")
|
||||
.arg("--allow-insecure-auth");
|
||||
for arg in extra_args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
let child_guard = ChildGuard(child);
|
||||
let _ = child_guard;
|
||||
let child = cmd.spawn().expect("spawn server");
|
||||
(listen, ca_cert, ChildGuard(child))
|
||||
}
|
||||
|
||||
// ─── existing tests (fixed: add --sealed-sender so enqueue works with bearer token) ─────────────
|
||||
|
||||
/// 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 auth_token = "devtoken";
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
|
||||
// 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();
|
||||
@@ -179,37 +186,9 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
|
||||
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("qpq-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 (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
||||
@@ -388,46 +367,16 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
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("qpq-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;
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
|
||||
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
let state_path = base.join("user.bin");
|
||||
@@ -482,7 +431,6 @@ async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
|
||||
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"),
|
||||
@@ -501,41 +449,11 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
|
||||
|
||||
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("qpq-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);
|
||||
let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
let state_path = base.join("recipient.bin");
|
||||
@@ -595,3 +513,425 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── new tests: was_new semantics, resolve_user, DM MLS flow ─────────────────────────────────
|
||||
|
||||
/// `create_channel` returns `was_new=true` for the first caller and `was_new=false` for the
|
||||
/// second, and both callers receive the same stable `channel_id` regardless of argument order.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_create_channel_was_new_semantics() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
// Holds AUTH_CONTEXT for the duration of this test.
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
// No --sealed-sender: create_channel requires identity-bound session.
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
|
||||
// Register identity states (uses devtoken / allow-insecure for upload_key_package).
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
let alice_state = base.join("alice.bin");
|
||||
let bob_state = base.join("bob.bin");
|
||||
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed: [u8; 32] = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let bob_seed: [u8; 32] = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&bob_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
let bob_pk_hex = hex_encode(&bob_pk);
|
||||
|
||||
// OPAQUE register (unauthenticated — no AUTH_CONTEXT needed).
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex)))
|
||||
.await?;
|
||||
|
||||
// Alice OPAQUE login → identity-bound session.
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
let session_alice = local
|
||||
.run_until(opaque_login(&client, "alice", "pass", &alice_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_alice, None));
|
||||
|
||||
let (ch_alice, was_new_alice) = local
|
||||
.run_until(create_channel(&client, &bob_pk))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(was_new_alice, "Alice's create_channel must return was_new=true");
|
||||
anyhow::ensure!(ch_alice.len() == 16, "channel_id must be 16 bytes");
|
||||
|
||||
// Bob OPAQUE login → identity-bound session.
|
||||
let session_bob = local
|
||||
.run_until(opaque_login(&client, "bob", "pass", &bob_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_bob, None));
|
||||
|
||||
let (ch_bob, was_new_bob) = local
|
||||
.run_until(create_channel(&client, &alice_pk))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(!was_new_bob, "Bob's create_channel must return was_new=false (channel already exists)");
|
||||
anyhow::ensure!(
|
||||
ch_alice == ch_bob,
|
||||
"Both callers must receive the same channel_id (got alice={} bob={})",
|
||||
hex_encode(&ch_alice),
|
||||
hex_encode(&ch_bob)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `resolve_user` returns the identity key when the user registered WITH one,
|
||||
/// and returns `None` when the user registered WITHOUT an identity key.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_resolve_user_requires_identity_key_binding() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
// Generate Alice's identity (bound) and Bob's identity (unbound).
|
||||
let alice_state = base.join("alice.bin");
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
|
||||
// Alice registers WITH identity key.
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
|
||||
// Bob registers WITHOUT identity key.
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", None))
|
||||
.await?;
|
||||
|
||||
// resolve_user needs a valid auth context (devtoken is sufficient — just needs bearer token).
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
|
||||
let alice_resolved = local
|
||||
.run_until(resolve_user(&client, "alice"))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
alice_resolved == Some(alice_pk.clone()),
|
||||
"resolve_user('alice') must return alice's identity key, got {:?}",
|
||||
alice_resolved.as_ref().map(|k| hex_encode(k))
|
||||
);
|
||||
|
||||
let bob_resolved = local
|
||||
.run_until(resolve_user(&client, "bob"))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
bob_resolved.is_none(),
|
||||
"resolve_user('bob') must return None (no identity key bound), got {:?}",
|
||||
bob_resolved.as_ref().map(|k| hex_encode(k))
|
||||
);
|
||||
|
||||
let ghost_resolved = local
|
||||
.run_until(resolve_user(&client, "nonexistent"))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
ghost_resolved.is_none(),
|
||||
"resolve_user('nonexistent') must return None"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Both Alice and Bob call `/dm` on each other (simultaneous DM initiation).
|
||||
/// Only the first caller (was_new=true) creates the MLS group and sends a Welcome.
|
||||
/// The second caller (was_new=false) joins via the Welcome.
|
||||
/// After joining, Alice sends a message and Bob decrypts it with no epoch error.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_bidirectional_dm_mls_no_epoch_conflict() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
// No --sealed-sender: tests the production path where enqueue requires identity session.
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
|
||||
// Register state files (uploads KeyPackages + hybrid keys) using devtoken.
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
let alice_state = base.join("alice.bin");
|
||||
let bob_state = base.join("bob.bin");
|
||||
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let bob_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&bob_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
let bob_pk_hex = hex_encode(&bob_pk);
|
||||
|
||||
// OPAQUE register both users.
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex)))
|
||||
.await?;
|
||||
|
||||
// Alice logs in and calls create_channel → must get was_new=true.
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
let session_alice = local
|
||||
.run_until(opaque_login(&client, "alice", "pass", &alice_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
|
||||
let (channel_id, was_new_alice) = local
|
||||
.run_until(create_channel(&client, &bob_pk))
|
||||
.await?;
|
||||
anyhow::ensure!(was_new_alice, "Alice must get was_new=true");
|
||||
|
||||
// Alice creates MLS group (channel_id as group name) and invites Bob.
|
||||
local
|
||||
.run_until(cmd_create_group(&alice_state, &server, &hex_encode(&channel_id), None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None))
|
||||
.await?;
|
||||
|
||||
// Bob logs in and calls create_channel → must get was_new=false with same channel_id.
|
||||
let session_bob = local
|
||||
.run_until(opaque_login(&client, "bob", "pass", &bob_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
|
||||
let (channel_id_bob, was_new_bob) = local
|
||||
.run_until(create_channel(&client, &alice_pk))
|
||||
.await?;
|
||||
anyhow::ensure!(!was_new_bob, "Bob must get was_new=false (Alice created first)");
|
||||
anyhow::ensure!(
|
||||
channel_id == channel_id_bob,
|
||||
"Both sides must see the same channel_id"
|
||||
);
|
||||
|
||||
// Bob joins via Welcome that Alice sent (was_new=false path: no group creation, just join).
|
||||
local
|
||||
.run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
// Alice sends "hello" to Bob.
|
||||
init_auth(ClientAuth::from_raw(session_alice, None));
|
||||
local
|
||||
.run_until(cmd_send(
|
||||
&alice_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
Some(&bob_pk_hex),
|
||||
false,
|
||||
"hello from alice",
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Bob receives and decrypts — no epoch conflict.
|
||||
init_auth(ClientAuth::from_raw(session_bob, None));
|
||||
let plaintexts = local
|
||||
.run_until(receive_pending_plaintexts(
|
||||
&bob_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
1000,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
plaintexts.iter().any(|p| p.as_slice() == b"hello from alice"),
|
||||
"Bob must decrypt Alice's message without epoch error; got {:?}",
|
||||
plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send 10 messages alternating Alice→Bob and Bob→Alice through an MLS DM channel.
|
||||
/// All messages must decrypt successfully, proving epoch stays in sync.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_dm_multi_message_epoch_synchronized() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
let alice_state = base.join("alice.bin");
|
||||
let bob_state = base.join("bob.bin");
|
||||
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let bob_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&bob_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
let bob_pk_hex = hex_encode(&bob_pk);
|
||||
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex)))
|
||||
.await?;
|
||||
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
|
||||
// Alice creates the DM channel and invites Bob.
|
||||
let session_alice = local
|
||||
.run_until(opaque_login(&client, "alice", "pass", &alice_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
|
||||
let (channel_id, was_new) = local
|
||||
.run_until(create_channel(&client, &bob_pk))
|
||||
.await?;
|
||||
anyhow::ensure!(was_new, "first create_channel must be was_new=true");
|
||||
|
||||
local
|
||||
.run_until(cmd_create_group(&alice_state, &server, &hex_encode(&channel_id), None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None))
|
||||
.await?;
|
||||
|
||||
// Bob joins.
|
||||
let session_bob = local
|
||||
.run_until(opaque_login(&client, "bob", "pass", &bob_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
local
|
||||
.run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
// 10 messages: Alice→Bob on even, Bob→Alice on odd.
|
||||
for i in 0u32..10 {
|
||||
let msg = format!("msg_{i}");
|
||||
if i % 2 == 0 {
|
||||
// Alice sends to Bob.
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
local
|
||||
.run_until(cmd_send(
|
||||
&alice_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
Some(&bob_pk_hex),
|
||||
false,
|
||||
&msg,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Bob receives.
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
let plaintexts = local
|
||||
.run_until(receive_pending_plaintexts(
|
||||
&bob_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
1000,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
plaintexts.iter().any(|p| p.as_slice() == msg.as_bytes()),
|
||||
"Bob did not receive '{msg}' at iteration {i}; got {:?}",
|
||||
plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
} else {
|
||||
// Bob sends to Alice.
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
local
|
||||
.run_until(cmd_send(
|
||||
&bob_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
Some(&alice_pk_hex),
|
||||
false,
|
||||
&msg,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Alice receives.
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
let plaintexts = local
|
||||
.run_until(receive_pending_plaintexts(
|
||||
&alice_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
1000,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
plaintexts.iter().any(|p| p.as_slice() == msg.as_bytes()),
|
||||
"Alice did not receive '{msg}' at iteration {i}; got {:?}",
|
||||
plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user