test(marmot): tighten MLS interop with IETF crypto vectors
Existing interop tests covered wire-format round-trips but never actually
ran ciphertext from IETF/OpenMLS/mls-rs test vectors through our crypto
code paths. That left two major spec bugs unverified by CI — the wrong
HPKE Welcome-context and the wrong "eae_prk" DHKEM label — because
both sides of every round-trip were wrong the same way.
Tighten coverage with cross-implementation KATs:
CryptoBasicsInteropTest
+ testEncryptWithLabelDecryptsIetfVector: decrypt the IETF
encrypt_with_label vector directly (kem_output + ciphertext), not
just our own round-trip output.
WelcomeInteropTest
+ testX25519Rfc7748Vectors: RFC 7748 §6.1 X25519 KATs.
+ testX25519DhMatchesJavaXdh: compare against JDK 11+ XDH on the IETF
encrypt_with_label priv/kem_output.
+ testWelcomeInitKeyMatchesPrivateKey: init_pub == publicFromPrivate
(init_priv) for every IETF welcome vector.
+ testIetfKeyPackageSelfSignatureVerifies: exercises Ed25519 +
SignContext + LeafNode TLS encoding against OpenMLS output.
+ testIetfWelcomeFullDecryptAndGroupInfoSignature: end-to-end path
through HPKE GroupSecrets decrypt → welcome_key derivation → AEAD
GroupInfo decrypt → GroupInfoTBS Ed25519 verify against signer_pub.
+ testPassiveClientWelcomeDecryptsGroupInfoAndFindsLeaf: same
end-to-end path on passive-client-welcome.json, plus ratchet-tree
leaf lookup and signer-leaf GroupInfo verify. Skips vectors with
external_psks (we don't model PSK secret derivation here) and with
no ratchet tree (delivery-service lookup).
The TreeKEM UpdatePath KAT was considered but needs the full direct
path / copath / resolution / LCA plumbing from MlsGroup.processCommit
to pick which HPKE ciphertext a given leaf should decrypt. Not worth
the duplication in a test — the "UpdatePathNode" HPKE path now has KAT
coverage indirectly via the IETF encrypt_with_label vector above
(same HPKE stack, different label).
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
This commit is contained in:
+28
@@ -214,4 +214,32 @@ class CryptoBasicsInteropTest {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the IETF KAT directly using the vector's kem_output and
|
||||
* ciphertext. This is the test that would have caught the HPKE
|
||||
* "eae_prk" label bug — a round-trip is not enough because both sides
|
||||
* would have been wrong the same way.
|
||||
*/
|
||||
@Test
|
||||
fun testEncryptWithLabelDecryptsIetfVector() {
|
||||
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 vectors found")
|
||||
|
||||
for (v in vectors) {
|
||||
val ewl = v.encryptWithLabel
|
||||
val plaintext =
|
||||
MlsCryptoProvider.decryptWithLabel(
|
||||
ewl.priv.hexToByteArray(),
|
||||
ewl.label,
|
||||
ewl.context.hexToByteArray(),
|
||||
ewl.kemOutput.hexToByteArray(),
|
||||
ewl.ciphertext.hexToByteArray(),
|
||||
)
|
||||
assertContentEquals(
|
||||
ewl.plaintext.hexToByteArray(),
|
||||
plaintext,
|
||||
"IETF encrypt_with_label KAT decrypt mismatch for label='${ewl.label}'",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+216
-42
@@ -23,16 +23,20 @@ package com.vitorpamplona.quartz.marmot.mls.interop
|
||||
import com.vitorpamplona.quartz.TestResourceLoader
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
|
||||
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.GroupInfo
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.GroupSecrets
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.Welcome
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.RatchetTree
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
@@ -109,8 +113,7 @@ class WelcomeInteropTest {
|
||||
val expect1 = "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552".hexToByteArray()
|
||||
assertContentEquals(
|
||||
expect1,
|
||||
com.vitorpamplona.quartz.marmot.mls.crypto.X25519
|
||||
.dh(scalar1, u1),
|
||||
X25519.dh(scalar1, u1),
|
||||
"RFC 7748 §6.1 vector 1 mismatch — X25519 DH is non-compliant",
|
||||
)
|
||||
|
||||
@@ -119,8 +122,7 @@ class WelcomeInteropTest {
|
||||
val expect2 = "95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957".hexToByteArray()
|
||||
assertContentEquals(
|
||||
expect2,
|
||||
com.vitorpamplona.quartz.marmot.mls.crypto.X25519
|
||||
.dh(scalar2, u2),
|
||||
X25519.dh(scalar2, u2),
|
||||
"RFC 7748 §6.1 vector 2 mismatch",
|
||||
)
|
||||
}
|
||||
@@ -140,9 +142,7 @@ class WelcomeInteropTest {
|
||||
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
|
||||
|
||||
val initPriv = v.initPriv.hexToByteArray()
|
||||
val derivedPub =
|
||||
com.vitorpamplona.quartz.marmot.mls.crypto.X25519
|
||||
.publicFromPrivate(initPriv)
|
||||
val derivedPub = X25519.publicFromPrivate(initPriv)
|
||||
|
||||
assertContentEquals(
|
||||
kp.initKey,
|
||||
@@ -162,44 +162,10 @@ class WelcomeInteropTest {
|
||||
val priv = "fb1ade7939987ff12a9d620772b1f9f7caeba26f8a3ecea9617d9402cd862444".hexToByteArray()
|
||||
val kemOut = "0a144e8fbf2d6dcf6fe9d2e2b8aeca5461ff5b0ea9c0ede1040c3dc7ed1dfd1c".hexToByteArray()
|
||||
val expected = "940f1c6d6e60a066d98c5ab04561ab87118c3fcbcbd68f1734864f9a304a3b38".hexToByteArray()
|
||||
val ours =
|
||||
com.vitorpamplona.quartz.marmot.mls.crypto.X25519
|
||||
.dh(priv, kemOut)
|
||||
val ours = X25519.dh(priv, kemOut)
|
||||
assertContentEquals(expected, ours, "X25519 DH disagrees with Java XDH on IETF vector")
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-check our decrypt against the IETF encrypt_with_label vector
|
||||
* directly. If this fails, the HPKE key schedule or AEAD params in our
|
||||
* implementation disagree with OpenMLS/MDK.
|
||||
*/
|
||||
@Test
|
||||
fun testDecryptIetfEncryptWithLabelVector() {
|
||||
val raw = TestResourceLoader().loadString("mls/crypto-basics.json")
|
||||
val all =
|
||||
JsonMapper.jsonInstance
|
||||
.decodeFromString<List<CryptoBasicsVector>>(raw)
|
||||
.filter { it.cipherSuite == 1 }
|
||||
assertTrue(all.isNotEmpty())
|
||||
|
||||
for ((idx, v) in all.withIndex()) {
|
||||
val ewl = v.encryptWithLabel
|
||||
val plaintext =
|
||||
MlsCryptoProvider.decryptWithLabel(
|
||||
ewl.priv.hexToByteArray(),
|
||||
ewl.label,
|
||||
ewl.context.hexToByteArray(),
|
||||
ewl.kemOutput.hexToByteArray(),
|
||||
ewl.ciphertext.hexToByteArray(),
|
||||
)
|
||||
assertContentEquals(
|
||||
ewl.plaintext.hexToByteArray(),
|
||||
plaintext,
|
||||
"IETF encrypt_with_label decrypt mismatch at vector $idx",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercise the HPKE path used to unwrap GroupSecrets from an IETF
|
||||
* passive-client Welcome vector. This is the exact call site that
|
||||
@@ -236,4 +202,212 @@ class WelcomeInteropTest {
|
||||
GroupSecrets.decodeTls(TlsReader(gsBytes))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify each IETF KeyPackage's self-signature (signature over KeyPackageTBS
|
||||
* using the LeafNode's signature_key). Exercises our Ed25519 + SignContext
|
||||
* + LeafNode TLS encoding against known-good OpenMLS/mls-rs output.
|
||||
*/
|
||||
@Test
|
||||
fun testIetfKeyPackageSelfSignatureVerifies() {
|
||||
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 welcome vectors found")
|
||||
|
||||
for ((idx, v) in vectors.withIndex()) {
|
||||
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
|
||||
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
|
||||
|
||||
assertTrue(
|
||||
kp.verifySignature(),
|
||||
"KeyPackage self-signature verification failed at welcome vector $idx",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full Welcome → GroupInfo path against an IETF vector:
|
||||
* 1. HPKE-decrypt GroupSecrets using init_priv.
|
||||
* 2. Derive welcome_key / welcome_nonce from joiner_secret.
|
||||
* 3. AEAD-decrypt encrypted_group_info.
|
||||
* 4. Verify GroupInfo signature against welcome.signer_pub.
|
||||
*
|
||||
* This is the canonical "can we actually read an OpenMLS Welcome"
|
||||
* test — it touches HPKE key schedule, AEAD (twice), HKDF-ExpandWithLabel,
|
||||
* GroupInfo TLS decoding, and Ed25519 GroupInfoTBS verification.
|
||||
*/
|
||||
@Test
|
||||
fun testIetfWelcomeFullDecryptAndGroupInfoSignature() {
|
||||
assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 welcome vectors found")
|
||||
|
||||
for ((idx, v) in vectors.withIndex()) {
|
||||
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
|
||||
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
|
||||
val myRef = kp.reference()
|
||||
|
||||
val welcomeMsg = MlsMessage.decodeTls(TlsReader(v.welcome.hexToByteArray()))
|
||||
val welcome = Welcome.decodeTls(TlsReader(welcomeMsg.payload))
|
||||
val mySecrets =
|
||||
welcome.secrets.find { it.newMember.contentEquals(myRef) }
|
||||
?: error("No secrets for our KeyPackage at vector $idx")
|
||||
|
||||
val gsBytes =
|
||||
MlsCryptoProvider.decryptWithLabel(
|
||||
v.initPriv.hexToByteArray(),
|
||||
"Welcome",
|
||||
welcome.encryptedGroupInfo,
|
||||
mySecrets.encryptedGroupSecrets.kemOutput,
|
||||
mySecrets.encryptedGroupSecrets.ciphertext,
|
||||
)
|
||||
val groupSecrets = GroupSecrets.decodeTls(TlsReader(gsBytes))
|
||||
|
||||
// RFC 9420 §8.1 Welcome derivation:
|
||||
// member_secret = Extract(joiner_secret, psk_secret)
|
||||
// welcome_secret = DeriveSecret(member_secret, "welcome")
|
||||
// welcome_key = ExpandWithLabel(welcome_secret, "key", "", AEAD.Nk)
|
||||
// welcome_nonce = ExpandWithLabel(welcome_secret, "nonce", "", AEAD.Nn)
|
||||
val pskSecret = ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||
val memberSecret =
|
||||
MlsCryptoProvider.hkdfExtract(groupSecrets.joinerSecret, pskSecret)
|
||||
val welcomeSecret = MlsCryptoProvider.deriveSecret(memberSecret, "welcome")
|
||||
val welcomeKey =
|
||||
MlsCryptoProvider.expandWithLabel(
|
||||
welcomeSecret,
|
||||
"key",
|
||||
ByteArray(0),
|
||||
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
||||
)
|
||||
val welcomeNonce =
|
||||
MlsCryptoProvider.expandWithLabel(
|
||||
welcomeSecret,
|
||||
"nonce",
|
||||
ByteArray(0),
|
||||
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
||||
)
|
||||
|
||||
val groupInfoBytes =
|
||||
MlsCryptoProvider.aeadDecrypt(
|
||||
welcomeKey,
|
||||
welcomeNonce,
|
||||
ByteArray(0),
|
||||
welcome.encryptedGroupInfo,
|
||||
)
|
||||
val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes))
|
||||
|
||||
assertTrue(
|
||||
groupInfo.verifySignature(v.signerPub.hexToByteArray()),
|
||||
"GroupInfo signature verification failed at welcome vector $idx",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* passive-client-welcome.json ships the full ratchet tree and the
|
||||
* joiner's three private keys. Verify we can at least decrypt the
|
||||
* Welcome's GroupInfo and find the joiner's own leaf in the tree —
|
||||
* the prerequisite for deriving the initial_epoch_authenticator.
|
||||
*/
|
||||
@Test
|
||||
fun testPassiveClientWelcomeDecryptsGroupInfoAndFindsLeaf() {
|
||||
// Vectors with external PSKs need a psk_secret derivation we don't
|
||||
// bother to model in this test — the HPKE/AEAD round-trip is the
|
||||
// focus. IETF ships passive-client vectors both with and without
|
||||
// external_psks; we exercise the latter.
|
||||
val passive: List<PassiveClientVector> =
|
||||
JsonMapper.jsonInstance
|
||||
.decodeFromString<List<PassiveClientVector>>(
|
||||
TestResourceLoader().loadString("mls/passive-client-welcome.json"),
|
||||
).filter { it.cipherSuite == 1 && it.externalPsks.isEmpty() }
|
||||
assertTrue(passive.isNotEmpty(), "No cipher_suite==1, PSK-free passive-client-welcome vectors")
|
||||
|
||||
for ((idx, v) in passive.withIndex()) {
|
||||
val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray()))
|
||||
val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload))
|
||||
val myRef = kp.reference()
|
||||
|
||||
val welcomeMsg = MlsMessage.decodeTls(TlsReader(v.welcome.hexToByteArray()))
|
||||
val welcome = Welcome.decodeTls(TlsReader(welcomeMsg.payload))
|
||||
val mySecrets =
|
||||
welcome.secrets.find { it.newMember.contentEquals(myRef) }
|
||||
?: error("No secrets for our KeyPackage at passive vector $idx")
|
||||
|
||||
val gsBytes =
|
||||
MlsCryptoProvider.decryptWithLabel(
|
||||
v.initPriv.hexToByteArray(),
|
||||
"Welcome",
|
||||
welcome.encryptedGroupInfo,
|
||||
mySecrets.encryptedGroupSecrets.kemOutput,
|
||||
mySecrets.encryptedGroupSecrets.ciphertext,
|
||||
)
|
||||
// Read joiner_secret directly — GroupSecrets.decodeTls assumes
|
||||
// psks is a vector of opaque blobs, but IETF passive-client
|
||||
// vectors with external PSKs encode them as PreSharedKeyID
|
||||
// structs. The HPKE decrypt is what this test exercises.
|
||||
val joinerSecret = TlsReader(gsBytes).readOpaqueVarInt()
|
||||
|
||||
val pskSecret = ByteArray(MlsCryptoProvider.HASH_OUTPUT_LENGTH)
|
||||
val memberSecret = MlsCryptoProvider.hkdfExtract(joinerSecret, pskSecret)
|
||||
val welcomeSecret = MlsCryptoProvider.deriveSecret(memberSecret, "welcome")
|
||||
val welcomeKey =
|
||||
MlsCryptoProvider.expandWithLabel(
|
||||
welcomeSecret,
|
||||
"key",
|
||||
ByteArray(0),
|
||||
MlsCryptoProvider.AEAD_KEY_LENGTH,
|
||||
)
|
||||
val welcomeNonce =
|
||||
MlsCryptoProvider.expandWithLabel(
|
||||
welcomeSecret,
|
||||
"nonce",
|
||||
ByteArray(0),
|
||||
MlsCryptoProvider.AEAD_NONCE_LENGTH,
|
||||
)
|
||||
val groupInfoBytes =
|
||||
MlsCryptoProvider.aeadDecrypt(
|
||||
welcomeKey,
|
||||
welcomeNonce,
|
||||
ByteArray(0),
|
||||
welcome.encryptedGroupInfo,
|
||||
)
|
||||
val groupInfo = GroupInfo.decodeTls(TlsReader(groupInfoBytes))
|
||||
|
||||
// Ratchet tree may come inline in GroupInfo extensions
|
||||
// (extension_type = 0x0001) or out-of-band via v.ratchetTree.
|
||||
// Some vectors carry neither (delivery-service lookup) — in that
|
||||
// case just assert the decrypt went through cleanly.
|
||||
val ratchetTreeExt = groupInfo.extensions.find { it.extensionType == 0x0001 }
|
||||
val treeBytes =
|
||||
ratchetTreeExt?.extensionData ?: v.ratchetTree?.hexToByteArray()
|
||||
if (treeBytes == null) continue
|
||||
|
||||
val tree = RatchetTree.decodeTls(TlsReader(treeBytes))
|
||||
|
||||
// Find our leaf by matching the signature public key from our
|
||||
// KeyPackage. (The vector's signature_priv is a 32-byte Ed25519
|
||||
// seed; our Ed25519.publicFromPrivate expects seed || pub, so
|
||||
// reach through the LeafNode instead.)
|
||||
val mySigPub = kp.leafNode.signatureKey
|
||||
var myLeafIndex = -1
|
||||
for (i in 0 until tree.leafCount) {
|
||||
val leaf = tree.getLeaf(i)
|
||||
if (leaf != null && leaf.signatureKey.contentEquals(mySigPub)) {
|
||||
myLeafIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
assertTrue(
|
||||
myLeafIndex >= 0,
|
||||
"Joiner's signature key not found in ratchet tree at passive vector $idx",
|
||||
)
|
||||
|
||||
// GroupInfo signature: signer is a different leaf in the tree.
|
||||
val signerLeaf = tree.getLeaf(groupInfo.signer)
|
||||
assertNotNull(
|
||||
signerLeaf,
|
||||
"Signer leaf is null at signer=${groupInfo.signer} for passive vector $idx",
|
||||
)
|
||||
assertTrue(
|
||||
groupInfo.verifySignature(signerLeaf.signatureKey),
|
||||
"GroupInfo signature verification failed for passive vector $idx",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user