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:
2026-03-03 14:41:56 +01:00
parent e24497bf90
commit c8398d6cb7
27 changed files with 3375 additions and 303 deletions

View File

@@ -677,17 +677,17 @@ mod tests {
joiner.join_group(&welcome).expect("joiner join group");
let ct_creator = creator.send_message(b"hello").expect("creator send");
let pt_joiner = joiner
.receive_message(&ct_creator)
.expect("joiner recv")
.expect("application message");
let pt_joiner = match joiner.receive_message(&ct_creator).expect("joiner recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt_joiner, b"hello");
let ct_joiner = joiner.send_message(b"hello back").expect("joiner send");
let pt_creator = creator
.receive_message(&ct_joiner)
.expect("creator recv")
.expect("application message");
let pt_creator = match creator.receive_message(&ct_joiner).expect("creator recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt_creator, b"hello back");
}
@@ -718,17 +718,17 @@ mod tests {
joiner.join_group(&welcome).expect("joiner join hybrid group");
let ct_creator = creator.send_message(b"hello PQ").expect("creator send");
let pt_joiner = joiner
.receive_message(&ct_creator)
.expect("joiner recv")
.expect("application message");
let pt_joiner = match joiner.receive_message(&ct_creator).expect("joiner recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt_joiner, b"hello PQ");
let ct_joiner = joiner.send_message(b"quantum safe!").expect("joiner send");
let pt_creator = creator
.receive_message(&ct_joiner)
.expect("creator recv")
.expect("application message");
let pt_creator = match creator.receive_message(&ct_joiner).expect("creator recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt_creator, b"quantum safe!");
}
@@ -746,4 +746,278 @@ mod tests {
"group_id must match what was passed"
);
}
/// Helper: set up a 3-party group (creator + A + B).
fn setup_three_party(hybrid: bool) -> (GroupMember, GroupMember, GroupMember) {
let creator_id = Arc::new(IdentityKeypair::generate());
let a_id = Arc::new(IdentityKeypair::generate());
let b_id = Arc::new(IdentityKeypair::generate());
let (mut creator, mut a, mut b) = if hybrid {
(
GroupMember::new_hybrid(creator_id),
GroupMember::new_hybrid(a_id),
GroupMember::new_hybrid(b_id),
)
} else {
(
GroupMember::new(creator_id),
GroupMember::new(a_id),
GroupMember::new(b_id),
)
};
let a_kp = a.generate_key_package().expect("A KeyPackage");
let b_kp = b.generate_key_package().expect("B KeyPackage");
creator.create_group(b"three-party").expect("create group");
// Add A
let (_commit_a, welcome_a) = creator.add_member(&a_kp).expect("add A");
a.join_group(&welcome_a).expect("A join");
// A must process the commit that added them (it's a StateChanged for A since
// the commit itself is what brought them in — but actually A joined via Welcome,
// so A doesn't process the add-commit). The creator already merged the pending
// commit in add_member, so creator is at epoch 2.
// Add B — at this point creator is at epoch 2 (after adding A).
let (commit_b, welcome_b) = creator.add_member(&b_kp).expect("add B");
b.join_group(&welcome_b).expect("B join");
// A must process the commit that added B to stay in sync.
match a.receive_message(&commit_b).expect("A recv add-B commit") {
ReceivedMessage::StateChanged => {}
other => panic!("expected StateChanged, got {other:?}"),
}
(creator, a, b)
}
/// Three-party hybrid MLS round-trip: all members exchange messages.
#[test]
fn three_party_hybrid_mls_round_trip() {
let (mut creator, mut a, mut b) = setup_three_party(true);
// Creator sends to A and B
let ct = creator.send_message(b"hello group").expect("creator send");
let pt_a = match a.receive_message(&ct).expect("A recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
let pt_b = match b.receive_message(&ct).expect("B recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt_a, b"hello group");
assert_eq!(pt_b, b"hello group");
// A sends, creator and B receive
let ct_a = a.send_message(b"from A").expect("A send");
let pt_creator = match creator.receive_message(&ct_a).expect("creator recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
let pt_b2 = match b.receive_message(&ct_a).expect("B recv A") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt_creator, b"from A");
assert_eq!(pt_b2, b"from A");
}
/// Creator adds A and B, then removes B. A and creator can still communicate.
/// B can no longer decrypt.
#[test]
fn three_party_remove_member() {
let (mut creator, mut a, mut b) = setup_three_party(false);
// Get B's identity for removal
let b_identity = b.identity.public_key_bytes().to_vec();
// Creator removes B
let remove_commit = creator.remove_member(&b_identity).expect("remove B");
// A processes the remove commit
match a.receive_message(&remove_commit).expect("A recv remove") {
ReceivedMessage::StateChanged => {}
other => panic!("expected StateChanged, got {other:?}"),
}
// B processes the remove commit — should get SelfRemoved
match b.receive_message(&remove_commit).expect("B recv remove") {
ReceivedMessage::SelfRemoved => {}
other => panic!("expected SelfRemoved, got {other:?}"),
}
// B's group should be cleared
assert!(b.group_id().is_none(), "B's group should be None after removal");
// Creator and A can still communicate
let ct = creator.send_message(b"after removal").expect("creator send");
let pt = match a.receive_message(&ct).expect("A recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt, b"after removal");
// B cannot send (no group)
assert!(b.send_message(b"should fail").is_err());
}
/// A proposes to leave, creator commits the proposal, A receives SelfRemoved.
#[test]
fn leave_group_proposal() {
let (mut creator, mut a, _b) = setup_three_party(false);
// A proposes to leave
let leave_proposal = a.leave_group().expect("A leave");
// Creator receives the proposal (stored as pending)
match creator.receive_message(&leave_proposal).expect("creator recv proposal") {
ReceivedMessage::StateChanged => {}
other => panic!("expected StateChanged for proposal, got {other:?}"),
}
// Creator should have pending proposals
assert!(creator.has_pending_proposals(), "should have pending proposal");
// Creator commits the pending proposals
let (commit_bytes, _welcome) = creator
.commit_pending_proposals()
.expect("commit pending");
// A processes the commit — should get SelfRemoved
match a.receive_message(&commit_bytes).expect("A recv commit") {
ReceivedMessage::SelfRemoved => {}
other => panic!("expected SelfRemoved, got {other:?}"),
}
assert!(a.group_id().is_none(), "A's group should be None after leave");
}
/// Propose self-update, commit, other member processes the commit.
#[test]
fn propose_self_update_round_trip() {
let creator_id = Arc::new(IdentityKeypair::generate());
let joiner_id = Arc::new(IdentityKeypair::generate());
let mut creator = GroupMember::new(Arc::clone(&creator_id));
let mut joiner = GroupMember::new(Arc::clone(&joiner_id));
let joiner_kp = joiner.generate_key_package().expect("joiner KP");
creator.create_group(b"update-test").expect("create");
let (_commit, welcome) = creator.add_member(&joiner_kp).expect("add");
joiner.join_group(&welcome).expect("join");
// Creator proposes a self-update
let update_proposal = creator.propose_self_update().expect("propose update");
// Joiner receives the proposal
match joiner.receive_message(&update_proposal).expect("joiner recv proposal") {
ReceivedMessage::StateChanged => {}
other => panic!("expected StateChanged, got {other:?}"),
}
// Joiner commits the pending update proposal
let (commit_bytes, _) = joiner.commit_pending_proposals().expect("commit update");
// Creator processes the commit
match creator.receive_message(&commit_bytes).expect("creator recv commit") {
ReceivedMessage::StateChanged => {}
other => panic!("expected StateChanged, got {other:?}"),
}
// Both can still communicate after the update
let ct = creator.send_message(b"post-update").expect("send");
let pt = match joiner.receive_message(&ct).expect("recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt, b"post-update");
}
/// Receiving a ciphertext from a stale (lower) epoch returns an error — not a panic.
/// This is the core invariant violated by the bidirectional-/dm race condition.
#[test]
fn receive_stale_epoch_message_returns_error() {
let creator_id = Arc::new(IdentityKeypair::generate());
let joiner_a_id = Arc::new(IdentityKeypair::generate());
let joiner_b_id = Arc::new(IdentityKeypair::generate());
let mut creator = GroupMember::new(Arc::clone(&creator_id));
let mut joiner_a = GroupMember::new(Arc::clone(&joiner_a_id));
let mut joiner_b = GroupMember::new(Arc::clone(&joiner_b_id));
// Set up group with joiner_a (epoch 1 after create_group, epoch 2 after add).
let kp_a = joiner_a.generate_key_package().expect("kp_a");
creator.create_group(b"stale-epoch-test").expect("create");
let (_, welcome_a) = creator.add_member(&kp_a).expect("add a");
joiner_a.join_group(&welcome_a).expect("join a");
// Creator sends a message at the current epoch (epoch 2).
let ct_epoch2 = creator.send_message(b"epoch-2 message").expect("send");
// Creator now adds joiner_b, advancing to epoch 3. joiner_a must process the commit.
let kp_b = joiner_b.generate_key_package().expect("kp_b");
let (commit_b, welcome_b) = creator.add_member(&kp_b).expect("add b");
joiner_b.join_group(&welcome_b).expect("join b");
match joiner_a.receive_message(&commit_b).expect("a recv add-b commit") {
ReceivedMessage::StateChanged => {}
other => panic!("expected StateChanged, got {other:?}"),
}
// joiner_b joined at epoch 3 via Welcome. Attempting to decrypt ct_epoch2 (epoch 2)
// must return an error, not panic.
let result = joiner_b.receive_message(&ct_epoch2);
assert!(
result.is_err(),
"decrypting an epoch-2 ciphertext in epoch-3 context must fail, not panic"
);
}
/// 10 messages alternating Alice→Bob and Bob→Alice all decrypt successfully.
/// Verifies that epoch state stays in sync across multiple application messages.
#[test]
fn multi_message_roundtrip_epoch_stays_in_sync() {
let alice_id = Arc::new(IdentityKeypair::generate());
let bob_id = Arc::new(IdentityKeypair::generate());
let mut alice = GroupMember::new(Arc::clone(&alice_id));
let mut bob = GroupMember::new(Arc::clone(&bob_id));
let bob_kp = bob.generate_key_package().expect("bob kp");
alice.create_group(b"multi-msg-test").expect("create");
let (_, welcome) = alice.add_member(&bob_kp).expect("add bob");
bob.join_group(&welcome).expect("join");
for i in 0u32..5 {
let payload_alice = format!("alice msg {i}");
let ct = alice.send_message(payload_alice.as_bytes()).expect("alice send");
let pt = match bob.receive_message(&ct).expect("bob recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt, payload_alice.as_bytes());
let payload_bob = format!("bob reply {i}");
let ct = bob.send_message(payload_bob.as_bytes()).expect("bob send");
let pt = match alice.receive_message(&ct).expect("alice recv") {
ReceivedMessage::Application(pt) => pt,
other => panic!("expected Application, got {other:?}"),
};
assert_eq!(pt, payload_bob.as_bytes());
}
}
/// A member who has not yet joined (no group) cannot send messages.
#[test]
fn send_before_join_returns_error() {
let id = Arc::new(IdentityKeypair::generate());
let mut member = GroupMember::new(id);
assert!(
member.send_message(b"too early").is_err(),
"send_message before join must return an error"
);
}
}

View File

@@ -32,7 +32,7 @@ pub use app_message::{
serialize_typing, parse, generate_message_id, AppMessage, MessageType, VERSION as APP_MESSAGE_VERSION,
};
pub use error::CoreError;
pub use group::GroupMember;
pub use group::{GroupMember, ReceivedMessage, ReceivedMessageWithSender};
pub use hybrid_kem::{
hybrid_decrypt, hybrid_encrypt, HybridKemError, HybridKeypair, HybridKeypairBytes,
HybridPublicKey,