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
71 lines
2.2 KiB
JavaScript
71 lines
2.2 KiB
JavaScript
// Produce a ts-mls-authored joiner KeyPackage for the reverse-interop test
|
|
// (Amethyst → ts-mls). Emits on stdout:
|
|
//
|
|
// {
|
|
// "cipher_suite": 1,
|
|
// "joiner": {
|
|
// "key_package_raw": hex, // inner KeyPackage bytes
|
|
// "init_priv": hex(32 B),
|
|
// "encryption_priv":hex(32 B),
|
|
// "signature_priv": hex(32 B, raw seed), // PKCS#8 header stripped
|
|
// "signature_pub": hex(32 B)
|
|
// }
|
|
// }
|
|
//
|
|
// Amethyst reads `key_package_raw` to drive its MlsGroup.addMember flow;
|
|
// verify-amethyst-fixture.mjs reads the private keys back to drive
|
|
// ts-mls joinGroup + processPrivateMessage against Amethyst's Welcome.
|
|
|
|
import {
|
|
defaultCapabilities,
|
|
defaultCryptoProvider,
|
|
defaultLifetime,
|
|
generateKeyPackage,
|
|
getCiphersuiteImpl,
|
|
keyPackageEncoder,
|
|
encode,
|
|
} from "ts-mls";
|
|
|
|
const hex = (bytes) =>
|
|
Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
|
|
const cs = await getCiphersuiteImpl(
|
|
"MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519",
|
|
defaultCryptoProvider,
|
|
);
|
|
|
|
// Mirror MDK: advertise marmot_group_data (0xF2EE) as a supported
|
|
// extension and self_remove (0x000A) as a supported proposal so
|
|
// Amethyst's required_capabilities is satisfied when our KP is added.
|
|
const caps = defaultCapabilities();
|
|
if (!caps.extensions.includes(0xf2ee)) caps.extensions.push(0xf2ee);
|
|
if (!caps.proposals.includes(0x000a)) caps.proposals.push(0x000a);
|
|
|
|
const kp = await generateKeyPackage({
|
|
credential: { credentialType: 1, identity: new TextEncoder().encode("bob") },
|
|
capabilities: caps,
|
|
lifetime: defaultLifetime(),
|
|
cipherSuite: cs,
|
|
});
|
|
|
|
const sigPriv = kp.privatePackage.signaturePrivateKey;
|
|
const sigSeed =
|
|
sigPriv.length === 32
|
|
? sigPriv
|
|
: sigPriv.length === 64
|
|
? sigPriv.slice(0, 32)
|
|
: sigPriv.slice(sigPriv.length - 32);
|
|
|
|
const out = {
|
|
cipher_suite: 1,
|
|
joiner: {
|
|
key_package_raw: hex(encode(keyPackageEncoder, kp.publicPackage)),
|
|
init_priv: hex(kp.privatePackage.initPrivateKey),
|
|
encryption_priv: hex(kp.privatePackage.hpkePrivateKey),
|
|
signature_priv: hex(sigSeed),
|
|
signature_pub: hex(kp.publicPackage.leafNode.signaturePublicKey),
|
|
},
|
|
};
|
|
|
|
process.stdout.write(JSON.stringify(out, null, 2) + "\n");
|