4feea50ed2
Answers "can MDK and marmot-ts see and talk to a user whose group was
created on Amethyst?" — yes, after four more spec-conformance fixes:
1. parent_hash chain (RFC 9420 §7.9.2): Amethyst's commit() and
externalJoin() now compute the parent_hash for every parent node on
the committer's direct path and seal the committer's LeafNode with
the correct leaf parent_hash. Amethyst↔Amethyst used to work only
because both sides stored empty parent_hash values; against a
strict peer (ts-mls, openmls) every Amethyst-authored commit was
rejected with "Unable to verify parent hash". processCommit()
now also patches the computed parent_hashes back into its tree so
treeHash() agrees with the sender — otherwise the epoch key
schedule diverges and AEAD tags mismatch.
2. REQUIRED_CAPABILITIES extension type: 0x0002 → 0x0003 per
RFC 9420 §13.3. The old value was ratchet_tree's slot, so Amethyst's
GroupContext.extensions carried a required_capabilities blob
labelled as ratchet_tree, and openmls rejected the GroupInfo
as Malformed (ratchet_tree is not valid in GroupContext). This
completes the extension-ID set from d7114fc (ratchet_tree,
external_pub) now aligned with the IANA registry.
3. verifyParentHash on the receive side now computes the expected
chain top-down from the post-update tree rather than reading
parent_hash fields that applyUpdatePath leaves as empty
placeholders.
4. An explicit parentHash parameter on buildLeafNode so COMMIT-source
leaves include the computed value in their TBS signature.
Test harness — reverse interop (Amethyst → outside world):
- quartz/tools/{mdk,tsmls}-vector-gen/emit-joiner-kp.{rs,mjs}
generate an MDK/openmls and a marmot-ts/ts-mls KeyPackage with
marmot_group_data (0xF2EE) and self_remove (0x000A) advertised in
capabilities so Amethyst's required_capabilities is satisfiable.
- AmethystAuthoredVectorGen.kt (env-var gated JUnit test) takes the
foreign KP, adds it to a fresh Amethyst group, and emits the
Welcome plus three application PrivateMessages as JSON.
- verify-amethyst.{rs,mjs} replay the joiner's private state,
call the foreign MLS library's join + process_message, and
assert the plaintexts match.
Both verifiers now print ALL PASS end-to-end:
* openmls ← Amethyst: joinGroup + 3× process_message ✓
* ts-mls ← Amethyst: joinGroup + 3× processPrivateMessage ✓
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
mdk-vector-gen
Rust helper that emits MLS interop test vectors from the same openmls 0.8
backend MDK/whitenoise uses. Produces quartz/src/commonTest/resources/mls/mdk-welcome.json,
which [MdkWelcomeInteropTest] consumes to prove Amethyst can parse and
decrypt a Welcome + application messages authored by the Rust side.
What the vector contains
joiner.init_priv/encryption_priv/signature_priv/signature_pub— all the private key material the joiner needs to driveMlsGroup.processWelcome.joiner.key_package(MlsMessage-wrapped) andkey_package_raw.welcome(MlsMessage-wrapped) — Alice's Welcome for Bob.committer.signer_pub— Alice's Ed25519 signature public key.exporter.{label,context,length,secret}—MLS-Exporter("marmot", "group-event", 32)derived from Bob's post-join state. This is the exporter Marmot uses to derive the outer ChaCha20 key for kind:445 events, so a match here means Amethyst's whole post-join key schedule agrees with openmls byte-for-byte.app_messages_alice_to_bob[]— Alice-sent PrivateMessage bytes plus the expected plaintext. (Amethyst decrypt currently skipsPrivateMessageContentframing; the matching test is@Ignored until that is fixed.)
Regenerating
cd quartz/tools/mdk-vector-gen
cargo run --release > ../../src/commonTest/resources/mls/mdk-welcome.json
The generator uses fresh randomness each run, so the committed vector changes on regeneration — that is fine because the Kotlin test only asserts round-trip correctness against whatever is in the JSON. Commit the regenerated file if you change the generator.