diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt index 2342a8a7f..a4df60ed3 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/CryptoBasicsInteropTest.kt @@ -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}'", + ) + } + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt index e51ad3021..e87e4425a 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mls/interop/WelcomeInteropTest.kt @@ -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>(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 = + JsonMapper.jsonInstance + .decodeFromString>( + 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", + ) + } + } }