fix(marmot): use "eae_prk" label in DHKEM ExtractAndExpand
RFC 9180 §4.1 specifies DHKEM derives the shared secret as:
eae_prk = LabeledExtract("", "eae_prk", dh)
shared_secret = LabeledExpand(eae_prk, "shared_secret",
kem_context, Nsecret)
Hpke.extractAndExpand was using "shared_secret" for both the Extract and
Expand labels, so every HPKE decapsulation produced a key schedule that
disagreed with every spec-compliant implementation (OpenMLS, MDK/
whitenoise, OpenSSL, BoringSSL, Java XDH). Round-trips within Amethyst
worked because both sides were wrong the same way; cross-implementation
Welcome HPKE decryption always failed with AEAD BAD_DECRYPT.
Add IETF-vector interop tests locking in the fix:
- decrypt the crypto-basics encrypt_with_label KAT end-to-end,
- decrypt GroupSecrets from a passive-client Welcome vector,
- X25519 DH matches RFC 7748 §6.1 and Java XDH on a KAT input,
- init_pub in each Welcome vector matches publicFromPrivate(init_priv).
Drop the stale comment in CryptoBasicsInteropTest that claimed the vector
decryption was impossible due to an "X25519 DH discrepancy" — the DH was
always fine; the eae_prk label was the bug.
https://claude.ai/code/session_01HfHdd5S5rvxUW2ihEpLGJr
This commit is contained in:
-6
@@ -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(
|
||||
|
||||
+145
@@ -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<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
|
||||
* 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user