4feea50ed2
Answers "can MDK and marmot-ts see and talk to a user whose group was
created on Amethyst?" — yes, after four more spec-conformance fixes:
1. parent_hash chain (RFC 9420 §7.9.2): Amethyst's commit() and
externalJoin() now compute the parent_hash for every parent node on
the committer's direct path and seal the committer's LeafNode with
the correct leaf parent_hash. Amethyst↔Amethyst used to work only
because both sides stored empty parent_hash values; against a
strict peer (ts-mls, openmls) every Amethyst-authored commit was
rejected with "Unable to verify parent hash". processCommit()
now also patches the computed parent_hashes back into its tree so
treeHash() agrees with the sender — otherwise the epoch key
schedule diverges and AEAD tags mismatch.
2. REQUIRED_CAPABILITIES extension type: 0x0002 → 0x0003 per
RFC 9420 §13.3. The old value was ratchet_tree's slot, so Amethyst's
GroupContext.extensions carried a required_capabilities blob
labelled as ratchet_tree, and openmls rejected the GroupInfo
as Malformed (ratchet_tree is not valid in GroupContext). This
completes the extension-ID set from d7114fc (ratchet_tree,
external_pub) now aligned with the IANA registry.
3. verifyParentHash on the receive side now computes the expected
chain top-down from the post-update tree rather than reading
parent_hash fields that applyUpdatePath leaves as empty
placeholders.
4. An explicit parentHash parameter on buildLeafNode so COMMIT-source
leaves include the computed value in their TBS signature.
Test harness — reverse interop (Amethyst → outside world):
- quartz/tools/{mdk,tsmls}-vector-gen/emit-joiner-kp.{rs,mjs}
generate an MDK/openmls and a marmot-ts/ts-mls KeyPackage with
marmot_group_data (0xF2EE) and self_remove (0x000A) advertised in
capabilities so Amethyst's required_capabilities is satisfiable.
- AmethystAuthoredVectorGen.kt (env-var gated JUnit test) takes the
foreign KP, adds it to a fresh Amethyst group, and emits the
Welcome plus three application PrivateMessages as JSON.
- verify-amethyst.{rs,mjs} replay the joiner's private state,
call the foreign MLS library's join + process_message, and
assert the plaintexts match.
Both verifiers now print ALL PASS end-to-end:
* openmls ← Amethyst: joinGroup + 3× process_message ✓
* ts-mls ← Amethyst: joinGroup + 3× processPrivateMessage ✓
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
154 lines
5.0 KiB
JavaScript
154 lines
5.0 KiB
JavaScript
// Reverse-interop verifier: Amethyst → ts-mls.
|
|
//
|
|
// Reads two JSON files:
|
|
// $1 — the joiner-KP handoff produced by emit-joiner-kp.mjs
|
|
// (contains Bob's private keys in ts-mls-compatible form).
|
|
// $2 — the Amethyst-authored fixture (produced by the Kotlin
|
|
// AmethystAuthoredVectorGen test) containing Alice's Welcome
|
|
// and three PrivateMessages she sent to Bob.
|
|
//
|
|
// Succeeds (exit 0) if Bob can:
|
|
// 1. decode Amethyst's KeyPackage wrapper (basic wire-format sanity),
|
|
// 2. join Alice's group from Amethyst's Welcome,
|
|
// 3. decrypt each of Alice's application messages and match the
|
|
// expected plaintext.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import {
|
|
decode,
|
|
defaultCryptoProvider,
|
|
getCiphersuiteImpl,
|
|
joinGroup,
|
|
keyPackageDecoder,
|
|
mlsMessageDecoder,
|
|
processPrivateMessage,
|
|
unsafeTestingAuthenticationService,
|
|
wireformats,
|
|
} from "ts-mls";
|
|
|
|
function assert(cond, msg) {
|
|
if (!cond) {
|
|
console.error("FAIL:", msg);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const hexToBytes = (s) =>
|
|
Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
|
|
|
|
const [handoffPath, fixturePath] = process.argv.slice(2);
|
|
if (!handoffPath || !fixturePath) {
|
|
console.error(
|
|
"usage: verify-amethyst-fixture.mjs <joiner-handoff.json> <amethyst-fixture.json>",
|
|
);
|
|
process.exit(2);
|
|
}
|
|
const handoff = JSON.parse(readFileSync(handoffPath, "utf8"));
|
|
const fixture = JSON.parse(readFileSync(fixturePath, "utf8"));
|
|
|
|
const cs = await getCiphersuiteImpl(
|
|
"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519",
|
|
defaultCryptoProvider,
|
|
);
|
|
const ctx = {
|
|
cipherSuite: cs,
|
|
authService: unsafeTestingAuthenticationService,
|
|
};
|
|
|
|
// Rebuild Bob's public+private KP from the handoff.
|
|
const kpBytes = hexToBytes(handoff.joiner.key_package_raw);
|
|
const kpPub = decode(keyPackageDecoder, kpBytes);
|
|
assert(kpPub, "failed to decode joiner key_package_raw");
|
|
|
|
// ts-mls signature_priv is a PKCS#8 Ed25519 DER envelope. Reconstruct
|
|
// it from the raw 32-byte seed emitted by the handoff.
|
|
const PKCS8_ED25519_PREFIX = hexToBytes("302e020100300506032b657004220420");
|
|
const seed = hexToBytes(handoff.joiner.signature_priv);
|
|
const sigPrivDer = new Uint8Array(PKCS8_ED25519_PREFIX.length + seed.length);
|
|
sigPrivDer.set(PKCS8_ED25519_PREFIX, 0);
|
|
sigPrivDer.set(seed, PKCS8_ED25519_PREFIX.length);
|
|
|
|
const privateKeys = {
|
|
initPrivateKey: hexToBytes(handoff.joiner.init_priv),
|
|
hpkePrivateKey: hexToBytes(handoff.joiner.encryption_priv),
|
|
signaturePrivateKey: sigPrivDer,
|
|
};
|
|
|
|
// Decode Amethyst's Welcome (wrapped in MlsMessage).
|
|
const welcomeBytes = hexToBytes(fixture.welcome);
|
|
const welcomeMsg = decode(mlsMessageDecoder, welcomeBytes);
|
|
assert(welcomeMsg, "failed to decode Amethyst Welcome bytes");
|
|
assert(
|
|
welcomeMsg.wireformat === wireformats.mls_welcome,
|
|
"Amethyst Welcome wire-format mismatch",
|
|
);
|
|
|
|
// Join.
|
|
let state;
|
|
try {
|
|
state = await joinGroup({
|
|
context: ctx,
|
|
welcome: welcomeMsg.welcome,
|
|
keyPackage: kpPub,
|
|
privateKeys,
|
|
});
|
|
} catch (e) {
|
|
console.error("FAIL: joinGroup threw:", e?.message ?? e);
|
|
process.exit(1);
|
|
}
|
|
console.log("PASS: joined Amethyst-authored Welcome at epoch", state.groupContext.epoch);
|
|
|
|
// Dump the decoded GroupInfo for diagnostic use by other verifiers.
|
|
// ts-mls already unwrapped it during joinGroup, so we just print a
|
|
// compact summary + the full extensions array.
|
|
{
|
|
const gc = state.groupContext;
|
|
const ext = state.publicGroupState?.groupInfoExtensions;
|
|
// (state layout varies; these fields may or may not exist. Best-effort.)
|
|
if (process.env.DUMP_GROUP_INFO) {
|
|
console.log(JSON.stringify({ groupContext: gc, extensions: ext }, (_k, v) =>
|
|
v instanceof Uint8Array ? Array.from(v).map(b => b.toString(16).padStart(2, "0")).join("") : v,
|
|
2));
|
|
}
|
|
}
|
|
|
|
// Decrypt each application message.
|
|
for (const [idx, msg] of fixture.app_messages_alice_to_bob.entries()) {
|
|
const msgBytes = hexToBytes(msg.private_message);
|
|
const framedMsg = decode(mlsMessageDecoder, msgBytes);
|
|
assert(framedMsg, `failed to decode application message ${idx}`);
|
|
assert(
|
|
framedMsg.wireformat === wireformats.mls_private_message,
|
|
`application message ${idx} wire-format mismatch`,
|
|
);
|
|
|
|
let res;
|
|
try {
|
|
res = await processPrivateMessage({
|
|
context: ctx,
|
|
state,
|
|
privateMessage: framedMsg.privateMessage,
|
|
});
|
|
} catch (e) {
|
|
console.error(`FAIL: processPrivateMessage(${idx}) threw:`, e?.message ?? e);
|
|
process.exit(1);
|
|
}
|
|
state = res.newState;
|
|
if (res.kind !== "applicationMessage") {
|
|
console.error(`FAIL: processPrivateMessage(${idx}) returned kind=${res.kind}, expected applicationMessage`);
|
|
process.exit(1);
|
|
}
|
|
const gotHex = Array.from(res.message ?? new Uint8Array(), (b) =>
|
|
b.toString(16).padStart(2, "0"),
|
|
).join("");
|
|
if (gotHex !== msg.plaintext) {
|
|
console.error(
|
|
`FAIL: app message ${idx} plaintext mismatch\n want: ${msg.plaintext}\n got : ${gotHex}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log(`PASS: decrypted Amethyst app message ${idx}`);
|
|
}
|
|
|
|
console.log("ALL PASS — ts-mls read every Amethyst-authored artifact.");
|