Skip to content

feat(security): Phase 3 audit — TS-boundary validation + WebCrypto hardening#985

Merged
boorad merged 10 commits intomainfrom
feat/security-audit-phase-3
Apr 27, 2026
Merged

feat(security): Phase 3 audit — TS-boundary validation + WebCrypto hardening#985
boorad merged 10 commits intomainfrom
feat/security-audit-phase-3

Conversation

@boorad
Copy link
Copy Markdown
Collaborator

@boorad boorad commented Apr 27, 2026

Summary

Phase 3 of the security audit: pre-validate cryptographic parameters at the TypeScript ↔ C++ boundary so invalid inputs surface as clear RangeError / TypeError / DOMException before reaching native, instead of as opaque OpenSSL / libsodium error strings (or, in a few cases, undefined behavior). Also brings WebCrypto subtle closer to spec on four under-enforced edges, and ensures stream _transform / _flush errors propagate as 'error' events instead of throwing through the Transform plumbing.

Plus: a CI sweep that fixes the 6 GitHub Actions warnings that surfaced on PR #984's runs.

Sub-tasks

# Area Spec / source
3.1 Cipher algorithm / key / IV byte-length validation n/a — pre-OpenSSL guard
3.2 KDF parameter validation: scrypt, HKDF, Argon2 RFC 7914 §6, RFC 5869 §2.3, RFC 9106 §3.1
3.3 RSA modulus minimum 256 → 2048 bits NIST SP 800-131A Rev. 2; RFC 8017
3.4 DSA modulus minimum > 0 → 1024 bits FIPS 186-4 §4.2
3.5 WebCrypto subtle hardening §18.4.4, §25.7.6, §28.7.6, SubtleCrypto-deriveBits step 11
3.6 Stream _transform / _flush error propagation n/a — Transform callback contract
CI Node 20 deprecation; cache-key races; AGP bump github.blog/changelog/2025-09-19-deprecation-of-node-20…

Breaking changes

These are intentional and aligned with current cryptographic best practice — call them out in release notes:

  • RSA: callers using modulusLength < 2048 will now get an explicit rejection (was 256). Aligns with NIST SP 800-131A.
  • DSA: callers using modulusLength < 1024 will now be rejected (previously only ≤ 0 was rejected). 1024 retained over the stricter 2048 for legacy interop. Aligns with FIPS 186-4 §4.2.
  • HKDF importKey: extractable: true now throws SyntaxError. The resulting CryptoKey is forced to extractable: false regardless of input. WebCrypto §28.7.6.
  • subtle.deriveBits: now strictly requires the literal deriveBits usage on the base key (was deriveBits || deriveKey). subtle.deriveKey is unaffected and still accepts either, since it decomposes to deriveBits + importKey.
  • JWK import: jwk.ext === false with extractable: true is now rejected; jwk.key_ops missing any requested usage is now rejected.
  • WebCrypto algorithm names: now case-insensitive ('aes-gcm''AES-GCM'). Behavior fix only — existing canonical-form callers are unaffected.

Changes

TS sources

  • src/cipher.tsvalidateCipherParams() with single-call fast-path; libsodium nonce wording; wired into Cipheriv / Decipheriv constructors and the xsalsa20() shim.
  • src/scrypt.tsvalidateScryptParams() (RFC 7914 §6) and validateScryptKeylen(); covers async + sync paths.
  • src/hkdf.tsvalidateHkdfKeylen() (RFC 5869 §2.3); rejects unsupported digests (e.g. SHAKE128, since HKDF requires HMAC); covers sync, async, and WebCrypto hkdfDeriveBits.
  • src/argon2.tsvalidateArgon2Params() (RFC 9106 §3.1); single-resolution of params.nonce shared with the native call.
  • src/rsa.tsRSA_MIN_MODULUS_LENGTH = 2048 shared between WebCrypto and Node-API entry points; WebCrypto path remains a DOMException for JOSE callers.
  • src/dsa.tsDSA_MIN_MODULUS_LENGTH = 1024; generic ErrorRangeError with offending value in the message.
  • src/subtle.ts — case-insensitive normalizeAlgorithm with lazy lower → canonical Map<string, AnyAlgorithm>; validateJwkExtAndKeyOps() helper wired into KMAC / RSA / HMAC / AES / Ed/CFRG JWK import; strict deriveBits usage gate; HKDF extractable: false invariant.
  • src/hash.ts, src/hmac.ts, src/cipher.ts_transform / _flush wrap in try/catch and forward errors via the stream callback so they emit as regular 'error' events.

CI / tooling

  • All workflows opt into FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' (silences Node 20 deprecation for setup-java@v4, upload/download-artifact@v4, setup-android@v3, peter-evans/*, McCzarny/*, reviewdog/*).
  • actions/checkout@v4@v5 and actions/setup-node@v4@v5 everywhere.
  • Cache keys: ${{ github.run_id }}${{ hashFiles(...) }} for Gradle / node_modules; stable avd-pixel7pro-34-x86_64-v1 for the AVD cache. Fixes the "another job may be creating this cache" save failures.
  • Library AGP classpath 8.7.3 → 8.12.2 (matches Nitro). AndroidGradlePluginVersion and GradleDependency lint checks disabled, since AGP 9.x requires Gradle 9 + JDK 21 which RN 0.81's toolchain can't supply.

Tests

~50 new regressions across:

  • tests/cipher/cipher_tests.ts — 11 param-validation tests + 2 stream-error event tests; 'strings' test fixed to use 16-char ASCII; DES-EDE3 / DES-EDE detection order fix in the allCiphers.forEach loop; getCipherInfo-driven IV-length lookup.
  • tests/scrypt/scrypt_tests.ts — 8 RFC 7914 minimum-bound + async callback tests.
  • tests/hkdf/hkdf_tests.ts — 5 RFC 5869 ceiling tests + HKDF non-extractable / force-false invariant + unsupported-digest test.
  • tests/argon2/argon2_tests.ts — 7 RFC 9106 minimum-bound tests; existing validateUInt-style assertions refreshed to match the new wording (the JS-side check now fires first).
  • tests/keys/generate_keypair.ts — 1 RSA + 2 DSA modulus-rejection tests.
  • tests/subtle/{generateKey,import_export,deriveBits,digest}.ts — 12 RSA fixtures bumped 1024 → 2048; explicit modulusLength=1024 rejection at both boundaries; lowercase-digest test; deriveBits-usage gate test; AES JWK ext / key_ops triad.
  • tests/hash/hash_tests.ts, tests/hmac/hmac_tests.ts — 2 stream-error event tests each.

bun tsc passes clean across both packages.

Commits

e7e1aa3 ci: fix GitHub Actions warnings on PR runs
86b09f8 feat(security): Phase 3.1 audit — TS-layer cipher param validation
146d4e8 feat(security): Phase 3.2 audit — KDF parameter validation at TS layer
29ba338 feat(security): Phase 3.3 audit — RSA modulus min 2048 bits
118e015 feat(security): Phase 3.4 audit — DSA modulus min 1024 bits
6eee715 feat(security): Phase 3.5 audit — WebCrypto subtle hardening
5b272f4 feat(security): Phase 3.6 audit — stream _transform/_flush error propagation
a1a3eda docs(security): mark Phase 3 complete + log CI fixes and 6 sub-tasks
e980749 fix(cipher): align libsodium error wording, fix DES-EDE order, query getCipherInfo for IV length
5c54492 refactor(security-audit): Phase 3 review polish — cipher fast-path, HKDF unknown-digest throw, type tightening, idiomatic stream tests

Test plan

boorad added 10 commits April 27, 2026 09:22
Resolves the 6 annotations surfaced on PR #984 runs:

* Node.js 20 deprecations — add `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'`
  to every workflow so actions still pinned to Node 20 (setup-java,
  upload/download-artifact, setup-android, peter-evans/*, McCzarny/*,
  reviewdog/*) run on the runner's Node 24 instead. Silences the
  deprecation warning until each action ships a v5.
* `actions/checkout@v4` → `@v5` and `actions/setup-node@v4` → `@v5` across
  all workflows (these have native Node 24 builds).
* Cache save failures ("Unable to reserve cache with key … another job
  may be creating this cache") — replace `\${{ github.run_id }}` keys with
  `\${{ hashFiles(...) }}` keys for Gradle and node_modules caches, and a
  stable `avd-pixel7pro-34-x86_64-v1` key for the AVD cache. Re-runs and
  cross-job races no longer collide on the same key.
* Obsolete AGP 8.7.3 — bump library `classpath` to 8.12.2 to match
  react-native-nitro-modules. AGP 9.x requires Gradle 9 + JDK 21, which
  RN 0.81 + the example app's Gradle 8.14.3 + JDK 17 toolchain cannot
  support yet, so disable the `AndroidGradlePluginVersion` and
  `GradleDependency` lint checks on the library to stop the
  yutailang0119/action-android-lint annotation from re-firing on the
  newer-but-still-not-9 baseline.

Bumping `actions/setup-node` `node-version` from 20 to 24 in the Android
e2e workflow keeps it consistent with the runner default.
Pre-validate cipher algorithm, key, and IV byte-lengths at the JS↔C++
boundary before reaching the native factory. Previously, wrong sizes
fell through to OpenSSL which produced opaque error strings and (for
algorithm typos) only failed inside the C++ factory.

`validateCipherParams()` in `cipher.ts`:

* Rejects empty / non-string `cipherType` with `TypeError`.
* Rejects empty key (`byteLength === 0`) with `RangeError` upfront.
* For OpenSSL ciphers, splits the existing `getCipherInfo(name, k, iv)`
  probe into three calls — name-only, name+keyLen, name+ivLen — so the
  thrown error names exactly which parameter is wrong (key vs iv) and
  reports the offending byte-length.
* Rejects empty IV when the cipher requires one, and rejects non-empty
  IV for ciphers that take none (covers WRAP-style ciphers if a caller
  ever invokes one directly even though `getCiphers()` filters them).
* Hard-codes (key=32, iv=24) for the libsodium ciphers `xsalsa20`,
  `xsalsa20-poly1305`, and `xchacha20-poly1305`, which OpenSSL's
  `EVP_CIPHER_fetch` does not see.

Wired into both `Cipheriv` / `Decipheriv` constructors and the
`xsalsa20()` `@noble/ciphers` shim, all of which now compute the
ArrayBuffer once, validate, and reuse the exact buffer for the native
call.

Tests: 11 new regressions in `example/src/tests/cipher/cipher_tests.ts`:
empty-name, unknown-name, too-short / too-long / empty key for AES-CBC,
wrong IV for CBC and CCM, accepted variable IV for GCM,
createDecipheriv mirror, and wrong-key + wrong-nonce for xsalsa20.
Pre-validate scrypt, HKDF, and Argon2 parameters at the JS↔C++ boundary
so invalid inputs surface as clear RangeErrors before the native call,
not as opaque OpenSSL/libsodium errors after.

* **Scrypt** (`scrypt.ts`) — RFC 7914 §6: N must be a power of 2 > 1, r
  and p must be positive integers, r * p < 2^30, and the working set
  128 * r * N must fit in maxmem. Both async (`scrypt`) and sync
  (`scryptSync`) paths share `validateScryptParams()`. Async errors
  flow through the callback as RangeError.
* **HKDF** (`hkdf.ts`) — RFC 5869 §2.3: L (output keylen in bytes) must
  be ≤ 255 * HashLen. The new `validateHkdfKeylen()` looks the digest
  up in a static `HKDF_HASH_BYTES` table covering sha1/224/256/384/512,
  sha3-256/384/512, ripemd160. Wired into `hkdf`, `hkdfSync`, and the
  WebCrypto `hkdfDeriveBits` path.
* **Argon2** (`argon2.ts`) — RFC 9106 §3.1 minimums: 1 ≤ p ≤ 2^24-1,
  T ≥ 4, m ≥ 8 * p (in KiB), t ≥ 1, salt 8..2^32-1 bytes,
  version ∈ {0x10, 0x13}. Async surfaces param errors via callback.

Existing argon2 tests that asserted the C++ `validateUInt`-style
messages ("non-negative", "out of range", "integer") are updated to
match the new RFC 9106 wording, since the JS-side check now fires
first. Adds 7 new RFC 9106 minimum-bound tests (parallelism = 0,
tagLength < 4, passes = 0, memory < 8*p, nonce < 8 bytes,
unsupported version, plus an existing-message refresh).

Tests: 8 new scrypt regressions (N=1, N not power of 2, fractional N,
negative r, p=0, working-set > maxmem, negative keylen, async
callback path), 5 new HKDF regressions (negative keylen, two
ceiling-violation cases for sha256 and sha1, ceiling-equal accepted,
async callback path), and 7 new Argon2 RFC 9106 minimum-bound checks
on top of the message refresh.
The TS layer previously rejected only `modulusLength < 256`. NIST
SP 800-131A Rev. 2 has deprecated all RSA keys < 2048 bits for new
cryptographic uses, and academic / hardware factoring of 768- and
1024-bit moduli has been demonstrated. Lift the minimum to 2048.

`rsa.ts` — share `RSA_MIN_MODULUS_LENGTH = 2048` between the WebCrypto
(`rsa_generateKeyPair`) and Node-API (`rsa_prepareKeyGenParams`) entry
points. Both throw with a clear "RSA modulusLength must be at least
2048 bits (got N)" message; WebCrypto path remains a DOMException so
existing JOSE / spec-conformant callers see the same exception type.

Tests: bump 1024 → 2048 in `subtle/generateKey.ts` (3 RSA matrix
entries) and `subtle/import_export.ts` (9 generateKey call-sites used
to seed export round-trips). Refresh the existing modulusLength=0
rejection assertion to match the new error message, plus an explicit
modulusLength=1024 case both at the WebCrypto boundary
(`subtle.generateKey`) and the Node boundary (`generateKeyPairSync`).
The TS layer previously accepted any modulusLength > 0. FIPS 186-4
§ 4.2 sanctions only L = 1024, 2048, 3072 bits for DSA, and DSA below
1024 bits is well below modern security minimums. We retain 1024 as
the floor (rather than 2048) so legacy interop callers have a fallback
path; new applications should still use 2048 or 3072.

`dsa.ts` — pull the existing `modulusLength <= 0` check up to a shared
`DSA_MIN_MODULUS_LENGTH = 1024` constant, swap the generic Error for
RangeError, and emit a "DSA modulusLength must be at least 1024 bits
(got N)" message that names the offending value.

Tests: 2 new regressions in `keys/generate_keypair.ts`:
modulusLength = 512 (between 0 and 1024) and the existing
modulusLength = 0 path now both throw the new message.
Bring `subtle.ts` closer to the WebCrypto spec on four under-enforced
edges flagged in the audit:

* **Case-insensitive `normalizeAlgorithm`** (§18.4.4). The previous
  implementation was a no-op, so `'aes-gcm'` reached the
  `SUPPORTED_ALGORITHMS` set comparisons (which only know the canonical
  mixed-case form) and failed downstream with confusing not-supported
  errors. We now build a lazy lowercase→canonical map from
  `SUPPORTED_ALGORITHMS` on first call and rewrite the algorithm name
  to its canonical form. Lookup is lazy because `SUPPORTED_ALGORITHMS`
  is declared after `normalizeAlgorithm` in the file.
* **JWK `ext` and `key_ops` validation** (§25.7.6). Added a shared
  `validateJwkExtAndKeyOps()` helper that throws a `DataError` when
  `jwk.ext === false` and the requested `extractable` is `true`, and
  when `jwk.key_ops` is present but does not cover every requested
  usage. Wired into KMAC, RSA, HMAC, AES, and Ed/CFRG JWK import
  branches.
* **`subtle.deriveBits` strict usage gate** (§SubtleCrypto-method
  -deriveBits step 11). The previous `deriveBits || deriveKey`
  accept-either branch silently promoted `deriveKey`-only keys into
  `deriveBits` use, contradicting the spec. Now requires `deriveBits`
  on `baseKey.[[usages]]`. `subtle.deriveKey` still accepts either, in
  line with Node-compat.
* **HKDF `extractable: false`** (§28.7.6). `hkdfImportKey` now throws
  `SyntaxError` if the caller requests `extractable: true`, and forces
  `extractable: false` on the resulting `CryptoKey` regardless of input
  — matching the spec invariant that prevents input keying material
  from round-tripping via `exportKey`.

Tests:
* `subtle/digest.ts` — `digest('sha-256')` (lowercase) round-trips.
* `subtle/deriveBits.ts` — PBKDF2 baseKey with only `deriveKey` usage
  must reject the `deriveBits` operation.
* `hkdf/hkdf_tests.ts` — `extractable=true` rejection plus an
  invariant check that `extractable=false` propagates.
* `subtle/import_export.ts` — three new AES JWK import paths:
  `ext=false + extractable=true` rejection, `key_ops` missing the
  requested usage rejection, and a positive-path `key_ops` superset.
…agation

Synchronous failures inside `Hash`, `Hmac`, and `Cipher` `_transform` /
`_flush` previously threw out of the Transform plumbing because the
callback was only invoked on the success path. Throws are eventually
converted to 'error' events by readable-stream, but the stream is left
in a half-written state — the callback contract requires exactly one
invocation, and a missing call can hang downstream consumers.

Wrap each `_transform` and `_flush` body in a try/catch that forwards
the thrown error through the callback so it emits as a regular stream
'error' event and the Transform sees the callback once on every code
path.

Also widen the callback parameter type from `() => void` to
`(err?: Error | null) => void` so the new error-forwarding usage type-
checks.

Tests: 6 new regressions — Hash and Hmac `_transform` after digest()
(invokes update() on a finalized native context) and `_flush` after
digest() (double-finalize), plus Cipher `_transform` after final() and
Decipher `_flush` with a tampered AES-GCM auth tag.
Tick the Phase 3 boxes (cipher params, KDFs, RSA/DSA modulus mins,
subtle hardening, stream error propagation) and append progress-log
entries for the CI warning sweep (PR #984 follow-up) and each of
3.1–3.6 with branch reference.
…getCipherInfo for IV length

Three follow-on tweaks to Phase 3.1 cipher param validation that landed
together but post-dated the audit commit:

* libsodium error messages — change "Invalid iv length" → "Invalid
  nonce length" and "(expected N)" → "(nonce must be N bytes)" /
  "(key must be N bytes)" so the wording matches the libsodium
  parlance that callers expect for xsalsa20 / xsalsa20-poly1305 /
  xchacha20-poly1305. Test regex is updated in lockstep.
* allCiphers loop key-length detection — add an explicit `DES-EDE3`
  branch ahead of `DES-EDE`, since `'DES-EDE3-CBC'.includes('DES-EDE')`
  is also true and the loop was silently picking the 16-byte 2-key
  3DES path for 24-byte 3-key 3DES ciphers.
* allCiphers loop IV-length detection — replace the hard-coded
  iv12 / iv16 split (only correct for AEAD vs everything else) with a
  per-cipher `getCipherInfo(name)?.ivLength` query, so the loop covers
  ECB (0), DES-family (8), AES (16), etc. without further hard-coding.

Also fix the `'strings'` test, which was passing `key16.toString('hex')`
(32 UTF-8 bytes) into `aes-128-cbc` — fine before validation, but now
rejected by the new key-length check. Use 16-char ASCII so the byte
length matches the cipher's (16, 16) sizes.
…KDF unknown-digest throw, type tightening, idiomatic stream tests

Six follow-ups from the Phase 3 self-review:

* **(2) cipher fast-path.** `validateCipherParams` now caches the
  `getCipherInfo(name)` result and short-circuits the common case where
  `(key, iv)` match the cipher's default lengths exactly (AES-CBC,
  ECB, AES-GCM with the canonical 12-byte IV, etc.). Native round-trip
  count drops from 3 → 1 on the happy path while still producing the
  per-parameter error message on failure.

* **(3) drop unreachable Number.isFinite check.** `ArrayBuffer.byteLength`
  is always a non-negative integer; the only meaningful guard is
  `keyByteLength === 0`.

* **(4) HKDF unsupported-digest throw.** `validateHkdfKeylen` previously
  silently skipped the RFC 5869 ceiling check for digests not in the
  `HKDF_HASH_BYTES` table — including XOFs like SHAKE128/256, which are
  invalid HKDF inputs (HKDF builds on HMAC, which requires a
  fixed-length hash). Throw `TypeError: Unsupported HKDF digest: …`
  instead, with a regression test for `shake128`.

* **(5) Argon2 nonce single-resolution.** `validateArgon2Params` now
  returns the resolved `nonceAB: ArrayBuffer` and the caller passes it
  straight to native, dropping the second `binaryLikeToArrayBuffer`
  round-trip on `params.nonce`.

* **(6) canonical-name map type.** Type the lazy
  `_canonicalAlgorithmNames` map as `Map<string, AnyAlgorithm>` and
  do the cast once at insertion (where `SUPPORTED_ALGORITHMS` is the
  contract source) rather than on every `normalizeAlgorithm` lookup.
  All `as AnyAlgorithm` casts in `normalizeAlgorithm` go away.

* **(7) idiomatic stream tests.** Re-express the Phase 3.6 Hash / Hmac /
  Cipher / Decipher stream-error regressions through the public stream
  API: drive `_transform` via `stream.write()` and `_flush` via
  `stream.end()`, then `await` an `'error'` event. Removes every
  `(stream as any)._transform / _flush(...)` cast and the matching
  `eslint-disable @typescript-eslint/no-explicit-any` annotations.
@boorad boorad self-assigned this Apr 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🤖 End-to-End Test Results - iOS

Status: ✅ Passed
Platform: iOS
Run: 25003196068

📸 Final Test Screenshot

Maestro Test Results - ios

Screenshot automatically captured from End-to-End tests and will expire in 30 days


This comment is automatically updated on each test run.

@github-actions
Copy link
Copy Markdown
Contributor

🤖 End-to-End Test Results - Android

Status: ✅ Passed
Platform: Android
Run: 25003196178

📸 Final Test Screenshot

Maestro Test Results - android

Screenshot automatically captured from End-to-End tests and will expire in 30 days


This comment is automatically updated on each test run.

@boorad boorad merged commit 6870c90 into main Apr 27, 2026
8 checks passed
@boorad boorad deleted the feat/security-audit-phase-3 branch April 27, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant