Files
amethyst/quartz/tools/tsmls-vector-gen/generate.mjs
T
Claude cd12018e08 test(marmot): add ts-mls interop vector + decryptor
marmot-ts (the TypeScript Marmot client — browsers, Node, Bun, Deno)
wraps a completely different MLS implementation: ts-mls. That backend
shares no code with OpenMLS, which is what MDK/whitenoise use, so
Amethyst passing on both is a strong signal that the on-wire MLS layer
is spec-correct rather than coincidentally self-consistent.

Add a Node-based generator under quartz/tools/tsmls-vector-gen/ that
uses ts-mls 2.0.0-rc.10 directly to emit:
  - Alice's KeyPackage + Bob's joiner KeyPackage
  - Alice's Welcome after add_member + commit
  - Bob's init/encryption/signature private keys (PKCS#8 Ed25519 unwrapped
    to the raw 32-byte seed for parity with the MDK vector shape)
  - Post-join MLS-Exporter("marmot","group-event",32) KAT
  - Three application PrivateMessages from Alice → Bob with the
    expected plaintexts

TsMlsWelcomeInteropTest mirrors MdkWelcomeInteropTest but consumes the
new vector. It proves:
  - KeyPackage self-signature verifies under our Ed25519 + SignContent.
  - Amethyst.processWelcome unwraps the group secrets, derives the
    welcome_key/nonce, AEAD-decrypts GroupInfo, verifies GroupInfoTBS
    with signer_pub, decodes the ratchet tree, and binds the joiner's
    leaf — end-to-end.
  - Post-join exporter secret matches ts-mls byte-for-byte.
  - Amethyst decrypt() parses the RFC 9420 §6.3.1 PrivateMessageContent
    framing (application_data<V> + FramedContentAuthData.signature<V> +
    zero padding) and verifies the sender's FramedContentTBS signature
    against Alice's leaf, for all three ciphertexts.

https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
2026-04-21 13:34:32 +00:00

191 lines
6.2 KiB
JavaScript

// Generate marmot-ts / ts-mls interop vectors for the Amethyst Marmot module.
//
// ts-mls is the TypeScript MLS implementation that powers marmot-ts (the
// Marmot protocol client that runs in the browser and on Node/Bun/Deno).
// This generator uses ts-mls directly at the MLS layer — the Nostr wrapping
// marmot-ts adds is orthogonal to the cipher, so an MLS-level interop match
// against ts-mls is the same interop property we'd get from running the
// marmot-ts client end-to-end.
//
// Output: a JSON document on stdout with the same shape as mdk-welcome.json,
// so the Amethyst MdkWelcomeInteropTest test harness can consume either.
import {
ciphersuites,
createApplicationMessage,
createCommit,
createGroup,
decode,
defaultCryptoProvider,
defaultLifetime,
encode,
generateKeyPackage,
getCiphersuiteImpl,
joinGroup,
keyPackageEncoder,
mlsExporter,
mlsMessageDecoder,
mlsMessageEncoder,
protocolVersions,
unsafeTestingAuthenticationService,
wireformats,
} from "ts-mls";
const hex = (bytes) =>
Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
async function main() {
const cs = await getCiphersuiteImpl(
"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519",
defaultCryptoProvider,
);
const ctx = {
cipherSuite: cs,
authService: unsafeTestingAuthenticationService,
};
// Alice + Bob KeyPackages
const aliceKp = await generateKeyPackage({
credential: { credentialType: 1 /* basic */, identity: new TextEncoder().encode("alice") },
lifetime: defaultLifetime(),
cipherSuite: cs,
});
const bobKp = await generateKeyPackage({
credential: { credentialType: 1 /* basic */, identity: new TextEncoder().encode("bob") },
lifetime: defaultLifetime(),
cipherSuite: cs,
});
// Alice creates the group
const groupId = crypto.getRandomValues(new Uint8Array(32));
const aliceState0 = await createGroup({
context: ctx,
groupId,
keyPackage: aliceKp.publicPackage,
privateKeyPackage: aliceKp.privatePackage,
extensions: [],
});
// Alice adds Bob via a commit containing an Add proposal
const { newState: aliceState1, welcome, commit } = await createCommit({
context: ctx,
state: aliceState0,
extraProposals: [{ proposalType: 1 /* add */, add: { keyPackage: bobKp.publicPackage } }],
ratchetTreeExtension: true,
});
if (!welcome) throw new Error("createCommit did not produce a Welcome");
const welcomeBytes = encode(mlsMessageEncoder, welcome);
// Bob joins from the Welcome
const welcomeRoundTrip = decode(mlsMessageDecoder, welcomeBytes);
if (!welcomeRoundTrip || welcomeRoundTrip.wireformat !== wireformats.mls_welcome) {
throw new Error("round-trip welcome not of wire_format mls_welcome");
}
const bobState1 = await joinGroup({
context: ctx,
welcome: welcomeRoundTrip.welcome,
keyPackage: bobKp.publicPackage,
privateKeys: bobKp.privatePackage,
});
// MLS-Exporter KAT: the "marmot" / "group-event" exporter used to seal
// kind:445 outer envelopes.
const exporterLabel = "marmot";
const exporterContext = new TextEncoder().encode("group-event");
const exporterLength = 32;
const exporterSecret = await mlsExporter(
bobState1.keySchedule.exporterSecret,
exporterLabel,
exporterContext,
exporterLength,
cs,
);
// Alice sends three application messages that Bob's side should decrypt.
// We ratchet aliceState forward between sends.
let aliceCursor = aliceState1;
const plaintexts = [
"Hello from ts-mls",
"Second message in the same epoch.",
"Unicode works too: ☕ ❤",
];
const appMessages = [];
for (const pt of plaintexts) {
const ptBytes = new TextEncoder().encode(pt);
const { newState, message } = await createApplicationMessage({
context: ctx,
state: aliceCursor,
message: ptBytes,
});
aliceCursor = newState;
const msgBytes = encode(mlsMessageEncoder, message);
appMessages.push({
plaintext: hex(ptBytes),
private_message: hex(msgBytes),
});
}
// Bob's exported signature public key (we stored Ed25519 seed ourselves)
// ts-mls generateKeyPackage keeps the full signing-key pair in the
// PrivateKeyPackage.signaturePrivateKey field as seed || pub.
const bobSigPriv = bobKp.privatePackage.signaturePrivateKey;
const bobSigPub = bobKp.publicPackage.leafNode.signaturePublicKey;
// Wrap Bob's KeyPackage in an MlsMessage (matches the shape Amethyst decodes)
const bobKpWire = {
version: protocolVersions.mls10,
wireformat: wireformats.mls_key_package,
keyPackage: bobKp.publicPackage,
};
const bobKpMsgBytes = encode(mlsMessageEncoder, bobKpWire);
const bobKpRawBytes = encode(keyPackageEncoder, bobKp.publicPackage);
const aliceSigPub = aliceKp.publicPackage.leafNode.signaturePublicKey;
// ts-mls returns Ed25519 signature private keys as PKCS#8-DER envelopes
// (~48 bytes: a fixed 16-byte ASN.1 header then the 32-byte seed at the
// tail). openmls returns the raw 32-byte seed, and Amethyst expects
// seed || pub. Normalise to the raw 32-byte seed here; the Kotlin test
// appends signature_pub back on to rebuild the 64-byte form.
const sigSeed =
bobSigPriv.length === 32
? bobSigPriv
: bobSigPriv.length === 64
? bobSigPriv.slice(0, 32)
: bobSigPriv.slice(bobSigPriv.length - 32); // PKCS#8: seed is at the end
const vector = {
cipher_suite: 1,
description:
"Alice creates a group and welcomes Bob via ts-mls 2.0.0-rc.10 (marmot-ts's MLS backend).",
joiner: {
init_priv: hex(bobKp.privatePackage.initPrivateKey),
encryption_priv: hex(bobKp.privatePackage.hpkePrivateKey),
signature_priv: hex(sigSeed),
signature_pub: hex(bobSigPub),
key_package: hex(bobKpMsgBytes),
key_package_raw: hex(bobKpRawBytes),
},
committer: {
signer_pub: hex(aliceSigPub),
},
welcome: hex(welcomeBytes),
exporter: {
label: exporterLabel,
context: hex(exporterContext),
length: exporterLength,
secret: hex(exporterSecret),
},
app_messages_alice_to_bob: appMessages,
};
process.stdout.write(JSON.stringify(vector, null, 2) + "\n");
}
main().catch((e) => {
console.error(e);
process.exit(1);
});