Kotlin: HKDF-SHA256 and AES-256-GCM, against external vectors
Checked against RFC 5869 and NIST CAVP rather than against our JavaScript engine. An implementation validated only against our other implementation inherits our misunderstandings, and finding where the first two quietly agreed on something the spec does not say is the entire reason for writing a third. Three things pinned that a naive version gets wrong: - An empty HKDF salt means HashLen zeros, not "no HMAC key". SecretKeySpec throws on a zero-LENGTH key, so the naive version crashes on exactly the case the app uses -- §12 derives its per-context secrets with an empty salt. - 255 blocks is a hard ceiling. The counter is one byte; a naive loop wraps at 256 and silently returns key material it already returned. - The AEAD box is nonce || ciphertext || tag. CryptoKit's `combined` emits that layout and the JS engine assembles it by hand to match, so a box built any other way decrypts to garbage on the other engines rather than failing loudly. HKDF is hand-written on Mac rather than taken from a provider: Android's providers disagree about whether HKDF exists at all. Kotlin engine: 9 tests -> 27. ⚠️ Still JVM-only. These prove nothing about Android, where crypto goes through Conscrypt rather than the JVM's JCA, and nothing here has ever been compiled against the Android SDK. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7862c0d95
commit
bff7a4bbdd
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.DecryptionFailed> {
|
||||
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<Aead.DecryptionFailed>("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.DecryptionFailed> { 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.DecryptionFailed> { Aead.open(key, ByteArray(size)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `wrong key or nonce size is rejected up front`() {
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
Aead.seal(ByteArray(16), ByteArray(12), ByteArray(0))
|
||||
}
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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<IllegalArgumentException> {
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user