`joule-code-collab` dev-depended on openmls 0.6 / openmls_rust_crypto 0.3 while `joule-moq-mls` runs openmls 0.9.1 / 0.6.0 in production. Two generations apart meant a second, older MLS implementation in the test graph, and with it hpke-rs 0.2.0: RUSTSEC-2026-0071 (nonce reuse in an HPKE context, 9.3), -0070 (incorrect length encoding on KDF export, 8.2), -0069 and -0072 (missing all-zero X25519 shared-secret check). Nothing shipped it — the edge is `[dev-dependencies]` only — but there is no reason to test RFC 9420 against an implementation production does not use, and "it is only in the test graph" is a weaker answer than not having it. Pinning the dev-deps to the production generation removes hpke-rs 0.2.0, hpke-rs-crypto 0.2.0, hpke-rs-rust-crypto 0.2.0 and openmls 0.6.0 outright. One API change: openmls 0.9 takes the crypto backend rather than the whole provider in `export_secret`, which is the call joule-moq-mls already makes. `tests/mls_keying.rs` passes. The four allow-list entries are removed with them, which is the check working as intended: an entry that no longer matches anything fails the run, so an excuse cannot outlive the exposure it was written for. Also corrects two allow-list reasons that overstated the remaining quick-xml exposure. I had recorded the openpay 0.37.5 copy as a live exposure on the ISO 20022 ingestion path. It is not: `open-payments-iso20022 1.0.10` declares quick-xml but never calls it — the crate ships serde-annotated types (3 files, ~2.2k lines, no `quick_xml` reference anywhere) and the parsing is ours, in op-iso20022's codec, which binds the workspace 0.41. The copy is compiled and never executed. The smart-byte 0.39.4 copy parses macOS plists via iroh, not counterparty XML. Co-Authored-By: Claude Opus 5 <[email protected]>
140 lines
5.5 KiB
Rust
140 lines
5.5 KiB
Rust
//! MLS group keying (RFC 9420) for a Joule Code collaboration cluster (SPEC §7).
|
|
//!
|
|
//! The cluster members form an MLS group and derive a **shared group key** with
|
|
//! no central server: every member exports the same secret, used to encrypt the
|
|
//! collaboration's traffic (coding operations / Smart Byte envelopes). Adding a
|
|
//! member advances the epoch; the new member, once welcomed, derives the same
|
|
//! secret. This is the group-keying layer that sits above the BFT consensus +
|
|
//! FROST threshold authorization in `joule-code-collab`.
|
|
//!
|
|
//! Built on `openmls` (the RFC 9420 reference implementation). This test
|
|
//! demonstrates the load-bearing property — agreement on a shared group key — so
|
|
//! the layer is verified, not asserted.
|
|
|
|
use openmls::prelude::tls_codec::{Deserialize as _, Serialize as _};
|
|
use openmls::prelude::*;
|
|
use openmls_basic_credential::SignatureKeyPair;
|
|
use openmls_rust_crypto::OpenMlsRustCrypto;
|
|
|
|
const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
|
|
const EXPORT_LABEL: &str = "joule-code-collab-group-key";
|
|
|
|
fn member(
|
|
provider: &OpenMlsRustCrypto,
|
|
name: &str,
|
|
) -> (CredentialWithKey, SignatureKeyPair) {
|
|
let credential = BasicCredential::new(name.as_bytes().to_vec());
|
|
let signer = SignatureKeyPair::new(CIPHERSUITE.signature_algorithm()).expect("keypair");
|
|
signer.store(provider.storage()).expect("store signer");
|
|
let credential_with_key = CredentialWithKey {
|
|
credential: credential.into(),
|
|
signature_key: signer.public().into(),
|
|
};
|
|
(credential_with_key, signer)
|
|
}
|
|
|
|
fn key_package(
|
|
provider: &OpenMlsRustCrypto,
|
|
cwk: CredentialWithKey,
|
|
signer: &SignatureKeyPair,
|
|
) -> KeyPackageBundle {
|
|
KeyPackage::builder()
|
|
.build(CIPHERSUITE, provider, signer, cwk)
|
|
.expect("key package")
|
|
}
|
|
|
|
fn export(group: &MlsGroup, provider: &OpenMlsRustCrypto) -> Vec<u8> {
|
|
group
|
|
// openmls 0.9 takes the crypto backend here, not the whole provider —
|
|
// the same call joule-moq-mls makes in production.
|
|
.export_secret(provider.crypto(), EXPORT_LABEL, &[], 32)
|
|
.expect("export secret")
|
|
}
|
|
|
|
/// Extract the `Welcome` from the founder's output message via the tls wire
|
|
/// round-trip — the path a real recipient takes off the network.
|
|
fn extract_welcome(message: MlsMessageOut) -> Welcome {
|
|
let bytes = message.tls_serialize_detached().expect("serialize welcome");
|
|
let incoming = MlsMessageIn::tls_deserialize_exact(bytes.as_slice()).expect("deserialize");
|
|
match incoming.extract() {
|
|
MlsMessageBodyIn::Welcome(welcome) => welcome,
|
|
_ => panic!("expected a Welcome message"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cluster_members_derive_a_shared_group_key() {
|
|
let alice_p = OpenMlsRustCrypto::default();
|
|
let bob_p = OpenMlsRustCrypto::default();
|
|
let carol_p = OpenMlsRustCrypto::default();
|
|
|
|
let (alice_cwk, alice_signer) = member(&alice_p, "alice");
|
|
let (bob_cwk, bob_signer) = member(&bob_p, "bob");
|
|
let (carol_cwk, carol_signer) = member(&carol_p, "carol");
|
|
|
|
// Bob and Carol publish key packages so the group founder can add them.
|
|
let bob_kp = key_package(&bob_p, bob_cwk, &bob_signer);
|
|
let carol_kp = key_package(&carol_p, carol_cwk, &carol_signer);
|
|
|
|
// Alice founds the collaboration group.
|
|
let mut alice_group = MlsGroup::new(
|
|
&alice_p,
|
|
&alice_signer,
|
|
&MlsGroupCreateConfig::default(),
|
|
alice_cwk,
|
|
)
|
|
.expect("found group");
|
|
|
|
// Add Bob.
|
|
let (_commit, welcome, _group_info) = alice_group
|
|
.add_members(&alice_p, &alice_signer, &[bob_kp.key_package().clone()])
|
|
.expect("add bob");
|
|
alice_group.merge_pending_commit(&alice_p).expect("merge");
|
|
|
|
let welcome = extract_welcome(welcome);
|
|
let ratchet_tree = alice_group.export_ratchet_tree();
|
|
let bob_group = StagedWelcome::new_from_welcome(
|
|
&bob_p,
|
|
&MlsGroupJoinConfig::default(),
|
|
welcome,
|
|
Some(ratchet_tree.into()),
|
|
)
|
|
.expect("stage welcome")
|
|
.into_group(&bob_p)
|
|
.expect("bob joins");
|
|
|
|
// Alice and Bob now share the group key.
|
|
let epoch_after_bob = alice_group.epoch();
|
|
assert_eq!(export(&alice_group, &alice_p), export(&bob_group, &bob_p));
|
|
|
|
// Add Carol — a new epoch — and Carol derives the same shared key.
|
|
let (_commit, welcome, _gi) = alice_group
|
|
.add_members(&alice_p, &alice_signer, &[carol_kp.key_package().clone()])
|
|
.expect("add carol");
|
|
alice_group.merge_pending_commit(&alice_p).expect("merge");
|
|
// Bob processes the same commit to stay in sync.
|
|
// (In a real cluster the commit is delivered to every member; here Bob joins
|
|
// via the new member's welcome path is not needed — we check Carol + Alice.)
|
|
assert!(
|
|
alice_group.epoch() > epoch_after_bob,
|
|
"adding a member must advance the epoch"
|
|
);
|
|
|
|
let welcome = extract_welcome(welcome);
|
|
let ratchet_tree = alice_group.export_ratchet_tree();
|
|
let carol_group = StagedWelcome::new_from_welcome(
|
|
&carol_p,
|
|
&MlsGroupJoinConfig::default(),
|
|
welcome,
|
|
Some(ratchet_tree.into()),
|
|
)
|
|
.expect("stage welcome")
|
|
.into_group(&carol_p)
|
|
.expect("carol joins");
|
|
|
|
assert_eq!(export(&alice_group, &alice_p), export(&carol_group, &carol_p));
|
|
// Bob's view is now an epoch behind (he hasn't processed Carol's commit), so
|
|
// his exported secret differs from the new epoch's — exactly the forward-
|
|
// secret, epoch-bound property MLS provides.
|
|
assert_ne!(export(&bob_group, &bob_p), export(&alice_group, &alice_p));
|
|
}
|