Files
amethyst/quartz/tools/mdk-vector-gen/README.md
T
Claude c90851610a test(marmot): point the interop reference at mdk and generate current-profile vectors
Stage 0 of the Marmot resync: get a live reference back, so the later stages
are written against real bytes instead of a careful reading of the spec.

The vector generator pinned stock crates.io openmls 0.8. MDK builds against
erskingardner/openmls with the `extensions-draft` feature, and the whole
current Marmot profile is expressed in terms of what that feature adds —
app_data_dictionary (0x0006), app_components (0x0001), safe_aad (0x0002),
app_data_update (0x0008). Vectors from the published crate cannot reach any of
it. Pinned to MDK's exact rev instead.

Adds `marmot-profile-gen`, which builds a group the way cgka-engine does:
required capabilities of extension 0x0006 plus proposal 0x0008; GroupContext
dictionary carrying the required-component list, group profile, admin policy,
Nostr routing and lifecycle; per-leaf dictionaries carrying the supported list,
an empty safe_aad list and the 104-byte account-identity-proof v2 component;
last resort as the empty-data 0x0004 component in the KeyPackage dictionary,
not an extension type; PublicMessage handshakes. It emits the Add commit, the
Welcome, and exporter KATs for both group-event and the conformance commitment.

The identity-proof encoder is hand-rolled from the spec rather than lifted from
MDK, and asserts itself against the fixture published in
account-identity-proof-v2.md before emitting anything — so if the generator
runs at all, the kind-450 canonical serialization, its id, the BIP-340
signature and the component layout are known to match.

The interop harness cloned marmot-protocol/whitenoise-rs, which was archived on
2026-08-05 pinned to mdk-core 0.8.0: it was testing us against a frozen
MIP-era client, which is part of how the drift went unnoticed. Repointed at
marmot-protocol/mdk, building -p wn-cli. Both source patches are dropped —
mock-keyring is replaced by MDK's native --secret-store file, and
skip-unprocessable-retry targeted a path MDK does not have. The daemon socket
is now pinned via wnd --socket rather than guessed from a derived default.

The harness changes are read off MDK's DaemonArgs and wn-cli manifest, not off
a passing run; building MDK's workspace needs its pinned toolchain and a local
relay. A human run of marmot-interop-headless.sh is the acceptance test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
2026-09-08 14:42:35 +00:00

3.9 KiB

mdk-vector-gen

Rust helpers that emit MLS and Marmot interop test vectors from the same OpenMLS backend MDK builds against.

The dependency pin is the point of this tool. Cargo.toml tracks erskingardner/openmls at the exact rev MDK's root Cargo.toml names, built with the extensions-draft feature. Stock crates.io openmls does not carry app_data_dictionary / app_components / app_data_update, and the current Marmot profile is defined entirely in those terms — so vectors generated from the published crate cannot exercise it. When MDK bumps its OpenMLS rev, bump it here in the same change.

Binaries

marmot-profile-gen → marmot-current-profile.json

The current-profile Marmot vector: a group built the way MDK's cgka-engine builds one.

  • RequiredCapabilities = extension 0x0006 (app_data_dictionary) + proposal 0x0008 (app_data_update).
  • GroupContext app_data_dictionary carrying the required-component list (0x0001) plus marmot.group.profile.v1 (0x8001), marmot.group.admin-policy.v1 (0x8003), marmot.transport.nostr.routing.v1 (0x8004) and marmot.group.lifecycle.v1 (0x800c). Component 0x8009 is required but leaf-only, so it deliberately has no GroupContext data.
  • Every member LeafNode dictionary carrying the supported-component list, an empty safe_aad list, and the 104-byte marmot.member.account-identity-proof.v2 component.
  • The KeyPackage-level dictionary carrying the empty-data last_resort_key_package component (0x0004) — last resort is not an MLS extension type in this profile.
  • Handshake messages as PublicMessage, matching Marmot's pinned wire format; the Add commit is emitted so the peeler has a real one to authenticate.
  • Exporter KATs for both MLS-Exporter("marmot", "group-event", 32) and the conformance commitment from foundation/conformance.md.

The identity-proof encoder is hand-rolled from the spec text rather than pulled from MDK, and assert_spec_proof_vector() checks it against the fixed vector published in app-components/account-identity-proof-v2.md before anything else runs. If the generator starts up at all, the canonical kind-450 event serialization, its id, the BIP-340 signature and the 104-byte component layout all match the spec byte-for-byte.

cd quartz/tools/mdk-vector-gen
cargo run --release --bin marmot-profile-gen \
  > ../../src/commonTest/resources/mls/marmot-current-profile.json

mdk-vector-gen → mdk-welcome.json

The MLS-core vector, unchanged in intent: it proves Amethyst can parse and decrypt a Welcome plus application messages authored by the Rust side. It builds a plain OpenMLS group with no Marmot profile state, which is exactly what makes it a clean test of the key schedule alone.

  • joiner.init_priv / encryption_priv / signature_priv / signature_pub — the private key material the joiner needs to drive MlsGroup.processWelcome.
  • joiner.key_package (MlsMessage-wrapped) and key_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. 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.
cargo run --release --bin mdk-vector-gen \
  > ../../src/commonTest/resources/mls/mdk-welcome.json

emit-joiner-kp, verify-amethyst

Debug helpers for driving one side of a join by hand.

Regenerating

Both generators use fresh randomness each run, so the committed vectors change on regeneration — that is fine, because the Kotlin tests assert round-trip correctness against whatever is in the JSON rather than against fixed bytes. Commit the regenerated file if you change a generator.