cd12018e08
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
191 lines
6.2 KiB
JavaScript
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);
|
|
});
|