diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt index 9d723649a..fcc7990cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/crypto/Hpke.kt @@ -134,7 +134,9 @@ object Hpke { kemContext: ByteArray, ): ByteArray { val suiteId = KEM_SUITE_ID - val prk = labeledExtract(suiteId, ByteArray(0), "shared_secret", dh) + // RFC 9180 §4.1: eae_prk = LabeledExtract("", "eae_prk", dh); + // shared_secret = LabeledExpand(eae_prk, "shared_secret", kem_context, Nsecret). + val prk = labeledExtract(suiteId, ByteArray(0), "eae_prk", dh) return labeledExpand(suiteId, prk, "shared_secret", kemContext, N_SECRET) } 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 d1593f22c..2342a8a7f 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 @@ -189,12 +189,6 @@ class CryptoBasicsInteropTest { for (v in vectors) { val ewl = v.encryptWithLabel - // Test HPKE round-trip: encrypt then decrypt with the same key pair. - // The IETF test vector decryption fails due to a platform-specific X25519 DH - // discrepancy (all Python/Java X25519 libs produce a different DH result than - // the Rust implementation that generated the test vector, despite identical - // public key derivation). Our HPKE key schedule is verified correct against - // the IETF RFC 9180 test vectors. val plaintext = ewl.plaintext.hexToByteArray() val ciphertext = MlsCryptoProvider.encryptWithLabel( 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 183196718..e51ad3021 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 @@ -22,8 +22,12 @@ 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.framing.MlsMessage import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat +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.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test @@ -91,4 +95,145 @@ class WelcomeInteropTest { ) } } + + /** + * RFC 7748 §6.1 X25519 known-answer vectors. If any of these fail, our + * DH output disagrees with every spec-compliant implementation (MDK, + * OpenMLS, OpenSSL, BoringSSL, BouncyCastle), which is exactly the + * failure mode that makes interop-Welcome fail with BAD_DECRYPT. + */ + @Test + fun testX25519Rfc7748Vectors() { + val scalar1 = "a546e36bf0527c9d3b16154b82465edd62144c0ac1fc5a18506a2244ba449ac4".hexToByteArray() + val u1 = "e6db6867583030db3594c1a424b15f7c726624ec26b3353b10a903a6d0ab1c4c".hexToByteArray() + val expect1 = "c3da55379de9c6908e94ea4df28d084f32eccf03491c71f754b4075577a28552".hexToByteArray() + assertContentEquals( + expect1, + com.vitorpamplona.quartz.marmot.mls.crypto.X25519 + .dh(scalar1, u1), + "RFC 7748 §6.1 vector 1 mismatch — X25519 DH is non-compliant", + ) + + val scalar2 = "4b66e9d4d1b4673c5ad22691957d6af5c11b6421e0ea01d42ca4169e7918ba0d".hexToByteArray() + val u2 = "e5210f12786811d3f4b7959d0538ae2c31dbe7106fc03c3efc4cd549c715a493".hexToByteArray() + val expect2 = "95cbde9476e8907d7aade45cb4b873f88b595a68799fa152e6f8f7647aac7957".hexToByteArray() + assertContentEquals( + expect2, + com.vitorpamplona.quartz.marmot.mls.crypto.X25519 + .dh(scalar2, u2), + "RFC 7748 §6.1 vector 2 mismatch", + ) + } + + /** + * Sanity check: the init_pub in each Welcome vector's KeyPackage must + * match X25519.publicFromPrivate(init_priv). If they don't, the HPKE + * kem_context (enc || pkRm) computed during decapsulation will not + * match what the sender used, leading to key-schedule divergence. + */ + @Test + fun testWelcomeInitKeyMatchesPrivateKey() { + 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 initPriv = v.initPriv.hexToByteArray() + val derivedPub = + com.vitorpamplona.quartz.marmot.mls.crypto.X25519 + .publicFromPrivate(initPriv) + + assertContentEquals( + kp.initKey, + derivedPub, + "init_key mismatch at welcome vector $idx: KeyPackage.init_key != X25519.publicFromPrivate(init_priv)", + ) + } + } + + /** + * Compare our X25519 DH against Java's built-in XDH (JDK 11+) using the + * IETF encrypt_with_label vector's priv and kem_output. Any disagreement + * here explains the BAD_DECRYPT on Welcomes from MDK/OpenMLS. + */ + @Test + fun testX25519DhMatchesJavaXdh() { + val priv = "fb1ade7939987ff12a9d620772b1f9f7caeba26f8a3ecea9617d9402cd862444".hexToByteArray() + val kemOut = "0a144e8fbf2d6dcf6fe9d2e2b8aeca5461ff5b0ea9c0ede1040c3dc7ed1dfd1c".hexToByteArray() + val expected = "940f1c6d6e60a066d98c5ab04561ab87118c3fcbcbd68f1734864f9a304a3b38".hexToByteArray() + val ours = + com.vitorpamplona.quartz.marmot.mls.crypto.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 + * interop-fails against MDK/OpenMLS when the context is wrong. + */ + @Test + fun testWelcomeGroupSecretsDecryptAgainstIetfVector() { + assertTrue(vectors.isNotEmpty(), "No cipher_suite==1 welcome vectors found") + + for ((idx, v) in vectors.withIndex()) { + val kpMsg = MlsMessage.decodeTls(TlsReader(v.keyPackage.hexToByteArray())) + assertEquals(WireFormat.KEY_PACKAGE, kpMsg.wireFormat) + val kp = MlsKeyPackage.decodeTls(TlsReader(kpMsg.payload)) + val myRef = kp.reference() + + val welcomeMsg = MlsMessage.decodeTls(TlsReader(v.welcome.hexToByteArray())) + assertEquals(WireFormat.WELCOME, welcomeMsg.wireFormat) + val welcome = Welcome.decodeTls(TlsReader(welcomeMsg.payload)) + + val mySecrets = welcome.secrets.find { it.newMember.contentEquals(myRef) } + assertTrue(mySecrets != null, "Welcome vector $idx has no secrets for our KeyPackage") + + val initPriv = v.initPriv.hexToByteArray() + val gsBytes = + MlsCryptoProvider.decryptWithLabel( + initPriv, + "Welcome", + welcome.encryptedGroupInfo, + mySecrets.encryptedGroupSecrets.kemOutput, + mySecrets.encryptedGroupSecrets.ciphertext, + ) + + // Should parse cleanly as GroupSecrets + GroupSecrets.decodeTls(TlsReader(gsBytes)) + } + } }