Files
amethyst/quartz/tools/mdk-vector-gen
Claude 1d66e4e2f6 feat(marmot): implement account identity proof v2, the current profile's leaf binding
Stage 2 of the Marmot resync. A member leaf carries two unrelated keys — the
MLS BasicCredential identity, which is the member's Nostr account key, and the
MLS leaf signature key MLS generates per device — and MLS never checks that the
account agreed to the leaf key next to it. Without a proof, anyone able to
author a leaf can claim any account's identity.

This is also what makes us classifiable at all. MDK decides Legacy vs Current
purely on whether a group requires extension 0xf2f1 or component 0x8009; we
required neither, so profile classification errored out before any component
check ran.

Adds the common authorization-proof envelope (foundation/authorization-proofs.md):
104 fixed-width bytes of signer pubkey, big-endian uint64 timestamp and BIP-340
signature, with event-id reconstruction. The created_at bounds are load-bearing
twice over — the lower bound rejects zero, and the upper bound (2^53-1) catches
a uint64 whose top bit is set, which reads back negative as a Kotlin Long.

Deliberately absent: any comparison of created_at against a local clock. A proof
authorizes a long-lived key binding, not a one-time operation, and a wall-clock
rule would let skew make two members reach different verdicts on the same Commit.

The component itself signs a kind-450 template through NostrSigner rather than
raw BIP-340, which is the whole point of the indirection: a NIP-46 bunker or
NIP-55 app can produce a proof without exposing arbitrary signing. create()
therefore re-verifies everything the signer returned — pubkey, timestamp, kind,
tags, content, recomputed id, signature — since an external signer is free to
substitute a stale or altered event.

Also adds the app-component id registry, and the RFC 9420 signature-scheme
mapping to MlsCiphersuite (declared outside the companion: an enum's entries
initialize before its companion object, so entry constructor arguments cannot
read companion properties).

Tested two ways. Sixteen tests pin the spec's published fixture — canonical
event serialization, event id, signature, the 104-byte layout — and check that
every signed input actually binds, including a ciphersuite change that leaves
the signature scheme untouched. Six more validate the proofs in
marmot-current-profile.json: those come from a separate implementation, for
randomly generated keys, which is the interop property a fixed vector cannot
establish. Full quartz marmot suite: 395 tests, 0 failures.

Nothing reads or writes these on a real leaf yet — the carrier is the
app_data_dictionary, which is Stage 1.

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

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.