fix(marmot): compute parent_hash chain + align required_capabilities id

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
This commit is contained in:
Claude
2026-04-21 16:13:42 +00:00
parent cd12018e08
commit 4feea50ed2
8 changed files with 969 additions and 56 deletions
+15
View File
@@ -7,8 +7,23 @@ edition = "2021"
openmls = { version = "0.8.1", features = ["test-utils"] }
openmls_rust_crypto = "0.5.1"
openmls_basic_credential = { version = "0.5", features = ["test-utils"] }
openmls_memory_storage = { version = "0.5", features = ["persistence"] }
openmls_traits = "0.5"
tls_codec = "0.4"
hex = "0.4"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
base64 = "0.22"
env_logger = "0.11"
[[bin]]
name = "mdk-vector-gen"
path = "src/main.rs"
[[bin]]
name = "emit-joiner-kp"
path = "src/emit_joiner_kp.rs"
[[bin]]
name = "verify-amethyst"
path = "src/verify_amethyst.rs"
@@ -0,0 +1,96 @@
// Produce an MDK/OpenMLS-authored joiner KeyPackage for the reverse-interop
// test (Amethyst → MDK). Emits TWO artifacts:
// - a binary storage snapshot at $1 (openmls_memory_storage save_to_file
// format) so verify_amethyst.rs can restore Bob's provider exactly as
// it was when the KP was generated;
// - JSON on stdout with the public KP bytes plus the private keys
// (hex-encoded) so Amethyst can drive its addMember and the reader
// can sanity-check shapes:
//
// {
// "cipher_suite": 1,
// "joiner": {
// "key_package_raw": hex,
// "init_priv": hex(32 B),
// "encryption_priv": hex(32 B),
// "signature_priv": hex(32 B, raw Ed25519 seed),
// "signature_pub": hex(32 B)
// }
// }
use std::env;
use std::fs::File;
use std::process;
use openmls::prelude::*;
use openmls_basic_credential::SignatureKeyPair;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::OpenMlsProvider;
use tls_codec::Serialize;
const CS: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("usage: emit-joiner-kp <storage-snapshot-path>");
process::exit(2);
}
let snapshot_path = &args[1];
let provider = OpenMlsRustCrypto::default();
let cred = BasicCredential::new(b"bob".to_vec());
let sig = SignatureKeyPair::new(CS.signature_algorithm()).unwrap();
sig.store(provider.storage()).unwrap();
let cwk = CredentialWithKey {
credential: cred.into(),
signature_key: sig.public().into(),
};
// Advertise the Marmot extensions a real MDK KeyPackage would carry,
// so Amethyst's required_capabilities (which lists marmot_group_data
// 0xF2EE and the self_remove proposal 0x000A) is satisfied when our
// KP is added to an Amethyst group.
let capabilities = Capabilities::new(
None,
Some(&[CS]),
Some(&[
ExtensionType::from(0xF2EE), // marmot_group_data
ExtensionType::ApplicationId,
ExtensionType::LastResort,
]),
Some(&[openmls::prelude::ProposalType::SelfRemove]),
None,
);
let bundle = KeyPackage::builder()
.leaf_node_capabilities(capabilities)
.mark_as_last_resort()
.build(CS, &provider, &sig, cwk)
.unwrap();
let kp = bundle.key_package().clone();
let kp_bytes = kp.tls_serialize_detached().unwrap();
let init_priv: Vec<u8> = (**bundle.init_private_key()).to_vec();
let enc_priv: Vec<u8> = (**bundle.encryption_private_key()).to_vec();
// Persist the entire provider storage — validate_amethyst restores it
// byte-for-byte so the KeyPackage + bundle + signature key can be used
// to process a Welcome.
let file = File::create(snapshot_path).expect("open snapshot file");
provider
.storage()
.save_to_file(&file)
.expect("save storage snapshot");
let out = serde_json::json!({
"cipher_suite": 1,
"joiner": {
"key_package_raw": hex::encode(&kp_bytes),
"init_priv": hex::encode(&init_priv),
"encryption_priv": hex::encode(&enc_priv),
"signature_priv": hex::encode(sig.private()),
"signature_pub": hex::encode(sig.public()),
}
});
println!("{}", serde_json::to_string_pretty(&out).unwrap());
}
@@ -0,0 +1,153 @@
// Reverse-interop verifier: Amethyst → MDK/OpenMLS.
//
// Takes three paths on argv:
// arg1 — path to the binary storage snapshot written by emit_joiner_kp.rs
// (restores Bob's openmls provider exactly as it was when the
// KeyPackage + bundle were generated);
// arg2 — the joiner-handoff JSON (we only read the signature_pub here,
// to look up Bob's own leaf after joining);
// arg3 — the Amethyst-authored fixture (Welcome + PrivateMessages).
//
// Prints PASS/... lines on success and exits non-zero on any failure.
use std::env;
use std::fs::{self, File};
use std::process;
use base64::Engine;
use openmls::prelude::*;
use openmls_rust_crypto::OpenMlsRustCrypto;
use openmls_traits::OpenMlsProvider;
use serde::Deserialize;
use std::collections::HashMap;
use tls_codec::Deserialize as TlsDeserialize;
#[derive(Deserialize)]
struct Handoff {
cipher_suite: u16,
joiner: HandoffJoiner,
}
#[derive(Deserialize)]
struct HandoffJoiner {
signature_pub: String,
}
#[derive(Deserialize)]
struct Fixture {
cipher_suite: u16,
welcome: String,
app_messages_alice_to_bob: Vec<AppMessage>,
}
#[derive(Deserialize)]
struct AppMessage {
plaintext: String,
private_message: String,
}
fn hex_bytes(s: &str) -> Vec<u8> {
hex::decode(s).expect("bad hex in fixture")
}
fn fail(msg: &str) -> ! {
eprintln!("FAIL: {msg}");
process::exit(1);
}
fn main() {
env_logger::init();
let args: Vec<String> = env::args().collect();
if args.len() != 4 {
eprintln!(
"usage: verify-amethyst <storage-snapshot.bin> \
<joiner-handoff.json> <amethyst-fixture.json>"
);
process::exit(2);
}
let handoff: Handoff = serde_json::from_str(&fs::read_to_string(&args[2]).unwrap()).unwrap();
let fixture: Fixture = serde_json::from_str(&fs::read_to_string(&args[3]).unwrap()).unwrap();
assert_eq!(handoff.cipher_suite, 1);
assert_eq!(fixture.cipher_suite, 1);
// Restore Bob's MemoryStorage key/value map from the snapshot. We
// replicate the on-disk format here (it's a simple base64-encoded
// HashMap) instead of going through MemoryStorage::load_from_file —
// that API wants `&mut self` on a field we can't move into the
// OpenMlsRustCrypto provider (its fields are private and there's no
// constructor that accepts a pre-populated storage).
#[derive(Deserialize)]
struct SerializableKeyStore {
values: HashMap<String, String>,
}
let snap: SerializableKeyStore =
serde_json::from_reader(File::open(&args[1]).expect("open snapshot for read"))
.expect("parse snapshot JSON");
let provider = OpenMlsRustCrypto::default();
{
let mut map = provider.storage().values.write().unwrap();
for (k, v) in snap.values {
map.insert(
base64::prelude::BASE64_STANDARD.decode(k).unwrap(),
base64::prelude::BASE64_STANDARD.decode(v).unwrap(),
);
}
}
// Parse Amethyst's Welcome.
let welcome_bytes = hex_bytes(&fixture.welcome);
let msg_in = MlsMessageIn::tls_deserialize(&mut welcome_bytes.as_slice())
.unwrap_or_else(|e| fail(&format!("welcome decode: {e:?}")));
let welcome = match msg_in.extract() {
MlsMessageBodyIn::Welcome(w) => w,
other => fail(&format!("expected Welcome, got {other:?}")),
};
let cfg = MlsGroupJoinConfig::builder().build();
let staged = StagedWelcome::new_from_welcome(&provider, &cfg, welcome, None)
.unwrap_or_else(|e| fail(&format!("StagedWelcome::new_from_welcome: {e:?}")));
let mut group = staged
.into_group(&provider)
.unwrap_or_else(|e| fail(&format!("StagedWelcome::into_group: {e:?}")));
println!(
"PASS: joined Amethyst-authored Welcome at epoch {}",
group.epoch().as_u64()
);
// Sanity-check: the signature_pub we advertised must appear in the
// ratchet tree as OUR leaf.
let expected_sig_pub = hex_bytes(&handoff.joiner.signature_pub);
let own_leaf = group.own_leaf_node().unwrap();
if own_leaf.signature_key().as_slice() != expected_sig_pub.as_slice() {
fail("own leaf signature key does not match handoff signature_pub");
}
for (idx, app) in fixture.app_messages_alice_to_bob.iter().enumerate() {
let bytes = hex_bytes(&app.private_message);
let msg = MlsMessageIn::tls_deserialize(&mut bytes.as_slice())
.unwrap_or_else(|e| fail(&format!("app msg {idx} decode: {e:?}")));
let protocol = msg
.try_into_protocol_message()
.unwrap_or_else(|e| fail(&format!("app msg {idx} not a protocol message: {e:?}")));
let processed = group
.process_message(&provider, protocol)
.unwrap_or_else(|e| fail(&format!("app msg {idx} process_message: {e:?}")));
match processed.into_content() {
ProcessedMessageContent::ApplicationMessage(app_msg) => {
let got = app_msg.into_bytes();
let want = hex_bytes(&app.plaintext);
if got != want {
fail(&format!(
"app msg {idx} plaintext mismatch\n want: {}\n got : {}",
hex::encode(&want),
hex::encode(&got),
));
}
println!("PASS: decrypted Amethyst app message {idx}");
}
other => fail(&format!("app msg {idx} unexpected content: {other:?}")),
}
}
println!("ALL PASS — openmls read every Amethyst-authored artifact.");
}