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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user