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
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/node_modules
|
||||
/package-lock.json
|
||||
@@ -0,0 +1,26 @@
|
||||
# tsmls-vector-gen
|
||||
|
||||
Node helper that emits MLS interop test vectors from `ts-mls`, the
|
||||
TypeScript MLS implementation that powers
|
||||
[marmot-ts](https://github.com/marmot-protocol/marmot-ts) (the Marmot
|
||||
protocol client that runs in the browser / Node / Bun / Deno).
|
||||
|
||||
Produces `quartz/src/commonTest/resources/mls/tsmls-welcome.json`, which
|
||||
`TsMlsWelcomeInteropTest` consumes to prove Amethyst can parse and
|
||||
decrypt a Welcome + application messages authored by the TypeScript
|
||||
side. Together with `mdk-vector-gen` (OpenMLS/Rust) the Marmot suite now
|
||||
has cross-implementation KATs from two independent MLS backends.
|
||||
|
||||
## Regenerating
|
||||
|
||||
```
|
||||
cd quartz/tools/tsmls-vector-gen
|
||||
npm install --legacy-peer-deps
|
||||
node generate.mjs > ../../src/commonTest/resources/mls/tsmls-welcome.json
|
||||
```
|
||||
|
||||
The generator uses fresh randomness each run; commit the regenerated
|
||||
JSON if you change the generator. `ts-mls` returns Ed25519 signature
|
||||
private keys as PKCS#8-DER envelopes (16-byte header + 32-byte seed);
|
||||
we strip the header before emitting so the vector's `signature_priv`
|
||||
field is directly comparable to the one `mdk-vector-gen` produces.
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "tsmls-vector-gen",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"ts-mls": "2.0.0-rc.10"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user