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>
67 lines
3.0 KiB
Kotlin
67 lines
3.0 KiB
Kotlin
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")
|
|
}
|
|
}
|
|
}
|