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>