diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Aead.kt b/engine/src/main/kotlin/io/flamenet/e2e/Aead.kt new file mode 100644 index 0000000..30d162e --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Aead.kt @@ -0,0 +1,66 @@ +package io.flamenet.e2e + +import java.security.GeneralSecurityException +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * AES-256-GCM, in the layout the protocol uses: `nonce || ciphertext || tag`. + * + * That layout is not incidental. CryptoKit's `AES.GCM.SealedBox.combined` emits + * exactly this, the JavaScript engine assembles it by hand to match, and a box + * built any other way decrypts to garbage on the other two engines rather than + * failing loudly. So it is pinned here and tested, not left to a convention. + * + * 12-byte nonces and 16-byte tags throughout — the only sizes the other engines + * produce, and the sizes GCM is actually safe at. + */ +object Aead { + + const val NONCE_LEN = 12 + const val TAG_LEN = 16 + + class DecryptionFailed(message: String) : GeneralSecurityException(message) + + /** + * Seal [plaintext], returning `nonce || ciphertext || tag`. + * + * The nonce is supplied rather than generated so callers cannot accidentally + * reuse one and so this is testable against fixed vectors. **A nonce must + * never repeat under one key** — GCM does not degrade gracefully there, it + * leaks the authentication subkey and with it the ability to forge. + */ + fun seal(key: ByteArray, nonce: ByteArray, plaintext: ByteArray, + aad: ByteArray = ByteArray(0)): ByteArray { + require(key.size == 32) { "AES-256-GCM needs a 32-byte key, got ${key.size}" } + require(nonce.size == NONCE_LEN) { "GCM nonce must be $NONCE_LEN bytes, got ${nonce.size}" } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_LEN * 8, nonce)) + if (aad.isNotEmpty()) cipher.updateAAD(aad) + return nonce + cipher.doFinal(plaintext) + } + + /** Open a `nonce || ciphertext || tag` box. */ + fun open(key: ByteArray, box: ByteArray, aad: ByteArray = ByteArray(0)): ByteArray { + require(key.size == 32) { "AES-256-GCM needs a 32-byte key, got ${key.size}" } + // Refuse a box too short to contain what it claims, rather than letting + // the array slicing below throw something unrelated. + if (box.size < NONCE_LEN + TAG_LEN) { + throw DecryptionFailed("box is ${box.size} bytes, too short to hold a nonce and a tag") + } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), + GCMParameterSpec(TAG_LEN * 8, box, 0, NONCE_LEN)) + if (aad.isNotEmpty()) cipher.updateAAD(aad) + return try { + cipher.doFinal(box, NONCE_LEN, box.size - NONCE_LEN) + } catch (e: GeneralSecurityException) { + // Deliberately one message for every failure. Distinguishing "bad + // tag" from "wrong key" hands an attacker a decision oracle, and + // there is nothing a caller can usefully do differently. + throw DecryptionFailed("could not open the box") + } + } +} diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Hkdf.kt b/engine/src/main/kotlin/io/flamenet/e2e/Hkdf.kt new file mode 100644 index 0000000..7ab7353 --- /dev/null +++ b/engine/src/main/kotlin/io/flamenet/e2e/Hkdf.kt @@ -0,0 +1,67 @@ +package io.flamenet.e2e + +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +/** + * HKDF-SHA256 (RFC 5869). + * + * Hand-written on top of [Mac] rather than taken from a provider, because + * Android's providers disagree about whether HKDF exists at all: it is absent + * from the platform on older API levels and available under different names + * elsewhere. HMAC-SHA256 is present everywhere this will ever run, and extract + * plus expand is twenty lines on top of it. + * + * Checked against RFC 5869's own test vectors rather than against the + * JavaScript engine. That matters: an implementation validated only against our + * other implementation inherits our misunderstandings, and the entire reason + * this third engine is being written is to find the places where the other two + * quietly agreed on something the specification does not say. + */ +object Hkdf { + + private const val HASH_LEN = 32 + + /** + * Extract-then-expand. [salt] may be empty, which RFC 5869 defines as a + * string of HashLen zeros — not as "skip the extract step". + */ + fun derive(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray { + require(length > 0) { "HKDF length must be positive" } + // 255 * HashLen is the hard ceiling in the RFC: the counter is one byte + // and starts at 1, so asking for more silently wraps in a naive + // implementation and returns repeated key material. + require(length <= 255 * HASH_LEN) { "HKDF cannot produce more than ${255 * HASH_LEN} bytes" } + return expand(extract(ikm, salt), info, length) + } + + fun extract(ikm: ByteArray, salt: ByteArray): ByteArray { + val effectiveSalt = if (salt.isEmpty()) ByteArray(HASH_LEN) else salt + return hmac(effectiveSalt, ikm) + } + + fun expand(prk: ByteArray, info: ByteArray, length: Int): ByteArray { + val out = ByteArray(length) + var previous = ByteArray(0) + var offset = 0 + var counter = 1 + while (offset < length) { + val block = hmac(prk, previous + info + byteArrayOf(counter.toByte())) + val take = minOf(block.size, length - offset) + block.copyInto(out, offset, 0, take) + offset += take + previous = block + counter++ + } + return out + } + + private fun hmac(key: ByteArray, data: ByteArray): ByteArray { + val mac = Mac.getInstance("HmacSHA256") + // An all-zero key is legal input here (RFC 5869 salt), and + // SecretKeySpec rejects a zero-LENGTH key rather than a zero-valued + // one — so the empty-salt case must be widened before it reaches here. + mac.init(SecretKeySpec(key, "HmacSHA256")) + return mac.doFinal(data) + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/AeadTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/AeadTest.kt new file mode 100644 index 0000000..4d7e26a --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/AeadTest.kt @@ -0,0 +1,137 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * AES-256-GCM against NIST's published vectors, plus the layout the protocol + * depends on. + * + * The vectors are from NIST CAVP `gcmEncryptExtIV256`, not from our other + * engines — same reasoning as [HkdfTest]. The layout assertions ARE about our + * protocol, and they are the ones that would otherwise be discovered by two + * engines failing to talk to each other. + */ +class AeadTest { + + private fun hex(s: String): ByteArray = + if (s.isEmpty()) ByteArray(0) else s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } + + /** NIST CAVP, AES-256-GCM, 96-bit IV, empty plaintext and AAD. */ + @Test + fun `nist vector - empty plaintext`() { + val key = hex("b52c505a37d78eda5dd34f20c22540ea1b58963cf8e5bf8ffa85f9f2492505b4") + val nonce = hex("516c33929df5a3284ff463d7") + val box = Aead.seal(key, nonce, ByteArray(0)) + assertEquals("bdc1ac884d332457a1d2664f168c76f0", box.copyOfRange(12, box.size).hex()) + } + + /** NIST CAVP, AES-256-GCM, 96-bit IV, 128-bit plaintext, no AAD. */ + @Test + fun `nist vector - one block`() { + val key = hex("31bdadd96698c204aa9ce1448ea94ae1fb4a9a0b3c9d773b51bb1822666b8f22") + val nonce = hex("0d18e06c7c725ac9e362e1ce") + val plaintext = hex("2db5168e932556f8089a0622981d017d") + val box = Aead.seal(key, nonce, plaintext) + val body = box.copyOfRange(12, box.size) + assertEquals( + "fa4362189661d163fcd6a56d8bf0405ad636ac1bbedd5cc3ee727dc2ab4a9489", + body.hex(), + "ciphertext || tag should match the published vector", + ) + } + + @Test + fun `round trip returns the plaintext`() { + val key = ByteArray(32) { it.toByte() } + val nonce = ByteArray(12) { (it * 7).toByte() } + val message = "the quick brown fox".toByteArray() + assertContentEquals(message, Aead.open(key, Aead.seal(key, nonce, message))) + } + + /** + * The layout the other two engines assume. CryptoKit's `combined` emits + * nonce first; a box assembled any other way decrypts to garbage on iOS + * rather than failing, which is the worst kind of interop bug. + */ + @Test + fun `box is nonce then ciphertext then tag`() { + val key = ByteArray(32) { 1 } + val nonce = ByteArray(12) { 2 } + val message = ByteArray(5) { 3 } + val box = Aead.seal(key, nonce, message) + assertContentEquals(nonce, box.copyOfRange(0, 12), "the nonce must come first") + assertEquals(12 + message.size + 16, box.size, "nonce + ciphertext + 16-byte tag") + } + + @Test + fun `additional data is authenticated`() { + val key = ByteArray(32) { 4 } + val nonce = ByteArray(12) { 5 } + val box = Aead.seal(key, nonce, "hello".toByteArray(), aad = "context".toByteArray()) + assertContentEquals("hello".toByteArray(), + Aead.open(key, box, aad = "context".toByteArray())) + assertFailsWith { + Aead.open(key, box, aad = "different".toByteArray()) + } + } + + @Test + fun `a flipped bit anywhere is refused`() { + val key = ByteArray(32) { 6 } + val nonce = ByteArray(12) { 7 } + val box = Aead.seal(key, nonce, "tamper with me".toByteArray()) + // Every position: nonce, ciphertext and tag alike. + for (index in box.indices) { + val broken = box.copyOf() + broken[index] = (broken[index].toInt() xor 0x01).toByte() + assertFailsWith("byte $index was not authenticated") { + Aead.open(key, broken) + } + } + } + + @Test + fun `the wrong key is refused`() { + val box = Aead.seal(ByteArray(32) { 8 }, ByteArray(12) { 9 }, "secret".toByteArray()) + assertFailsWith { Aead.open(ByteArray(32) { 10 }, box) } + } + + /** A truncated box must be a clean refusal, not an index-out-of-bounds. */ + @Test + fun `a box too short to hold a nonce and tag is refused cleanly`() { + val key = ByteArray(32) { 11 } + for (size in 0..(Aead.NONCE_LEN + Aead.TAG_LEN - 1)) { + assertFailsWith { Aead.open(key, ByteArray(size)) } + } + } + + @Test + fun `wrong key or nonce size is rejected up front`() { + assertFailsWith { + Aead.seal(ByteArray(16), ByteArray(12), ByteArray(0)) + } + assertFailsWith { + Aead.seal(ByteArray(32), ByteArray(16), ByteArray(0)) + } + } + + /** Failures must not tell an attacker which kind of failure it was. */ + @Test + fun `every failure reports the same thing`() { + val key = ByteArray(32) { 12 } + val box = Aead.seal(key, ByteArray(12) { 13 }, "x".toByteArray()) + val badKey = runCatching { Aead.open(ByteArray(32) { 14 }, box) }.exceptionOrNull() + val badTag = runCatching { + val broken = box.copyOf(); broken[broken.size - 1] = 0 + Aead.open(key, broken) + }.exceptionOrNull() + assertTrue(badKey is Aead.DecryptionFailed && badTag is Aead.DecryptionFailed) + assertEquals(badKey.message, badTag.message, "the failures are distinguishable") + } +} diff --git a/engine/src/test/kotlin/io/flamenet/e2e/HkdfTest.kt b/engine/src/test/kotlin/io/flamenet/e2e/HkdfTest.kt new file mode 100644 index 0000000..e6517ed --- /dev/null +++ b/engine/src/test/kotlin/io/flamenet/e2e/HkdfTest.kt @@ -0,0 +1,129 @@ +package io.flamenet.e2e + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * HKDF-SHA256 against RFC 5869's published test vectors. + * + * Deliberately the RFC's numbers and not ours. A third engine checked only + * against the first two learns whatever they both got wrong; checked against + * the document, it can disagree with them — which is the only way it is worth + * writing. + */ +class HkdfTest { + + private fun hex(s: String): ByteArray = + s.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } + + /** RFC 5869 Appendix A.1 — basic case with salt and info. */ + @Test + fun `case 1 - sha256 with salt and info`() { + val prk = Hkdf.extract(ikm = hex("0b".repeat(22)), salt = hex("000102030405060708090a0b0c")) + assertEquals( + "077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5", + prk.hex(), + ) + val okm = Hkdf.expand(prk, hex("f0f1f2f3f4f5f6f7f8f9"), 42) + assertEquals( + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf" + + "34007208d5b887185865", + okm.hex(), + ) + } + + /** RFC 5869 Appendix A.2 — longer inputs and outputs. */ + @Test + fun `case 2 - long inputs`() { + val ikm = ByteArray(80) { it.toByte() } + val salt = ByteArray(80) { (0x60 + it).toByte() } + val info = ByteArray(80) { (0xb0 + it).toByte() } + val prk = Hkdf.extract(ikm, salt) + assertEquals( + "06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244", + prk.hex(), + ) + val okm = Hkdf.expand(prk, info, 82) + assertEquals( + "b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c" + + "59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71" + + "cc30c58179ec3e87c14c01d5c1f3434f1d87", + okm.hex(), + ) + } + + /** RFC 5869 Appendix A.3 — zero-length salt and info. */ + @Test + fun `case 3 - empty salt and info`() { + val prk = Hkdf.extract(ikm = hex("0b".repeat(22)), salt = ByteArray(0)) + assertEquals( + "19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04", + prk.hex(), + ) + val okm = Hkdf.expand(prk, ByteArray(0), 42) + assertEquals( + "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d" + + "9d201395faa4b61a96c8", + okm.hex(), + ) + } + + /** + * An empty salt must mean HashLen zeros, not "no HMAC key". The distinction + * matters because `SecretKeySpec` throws on a zero-LENGTH key, so the naive + * version of this crashes on exactly the case §12 of the spec uses — the + * app derives its per-context secrets with an empty HKDF salt. + */ + @Test + fun `empty salt is hashlen zeros, not an error`() { + assertEquals( + Hkdf.extract(hex("0b".repeat(22)), ByteArray(32)).hex(), + Hkdf.extract(hex("0b".repeat(22)), ByteArray(0)).hex(), + ) + } + + @Test + fun `derive is extract then expand`() { + val ikm = hex("0b".repeat(22)) + val salt = hex("000102030405060708090a0b0c") + val info = hex("f0f1f2f3f4f5f6f7f8f9") + assertEquals( + Hkdf.expand(Hkdf.extract(ikm, salt), info, 42).hex(), + Hkdf.derive(ikm, salt, info, 42).hex(), + ) + } + + /** + * The counter is one byte starting at 1, so 255 blocks is the ceiling. A + * naive loop wraps at 256 and starts returning key material it has already + * returned — silently. + */ + @Test + fun `refuses more than 255 blocks rather than repeating itself`() { + val prk = Hkdf.extract(hex("0b".repeat(22)), ByteArray(0)) + assertEquals(255 * 32, Hkdf.expand(prk, ByteArray(0), 255 * 32).size) + assertFailsWith { + Hkdf.derive(hex("0b".repeat(22)), ByteArray(0), ByteArray(0), 255 * 32 + 1) + } + } + + @Test + fun `output length is honoured exactly`() { + for (n in listOf(1, 31, 32, 33, 64, 100)) { + assertEquals(n, Hkdf.derive(hex("0b".repeat(22)), ByteArray(0), ByteArray(0), n).size) + } + } + + /** Different info must give unrelated output — this is what §12 relies on. */ + @Test + fun `different contexts give unrelated keys`() { + val ikm = hex("0b".repeat(22)) + val a = Hkdf.derive(ikm, ByteArray(0), "flamenet/files/auth/v1".toByteArray(), 32) + val b = Hkdf.derive(ikm, ByteArray(0), "flamenet/messenger/identity/v1".toByteArray(), 32) + assertTrue(!a.contentEquals(b), "two contexts produced the same key") + } +}