Skip to content

feat(security): Phase 1 audit foundation — shared helpers (validateUInt, secureZero, EVP_CIPHER_CTX RAII, typed getUIntOption)#983

Merged
boorad merged 4 commits into
mainfrom
feat/security-audit-phase-1
Apr 27, 2026
Merged

feat(security): Phase 1 audit foundation — shared helpers (validateUInt, secureZero, EVP_CIPHER_CTX RAII, typed getUIntOption)#983
boorad merged 4 commits into
mainfrom
feat/security-audit-phase-1

Conversation

@boorad
Copy link
Copy Markdown
Collaborator

@boorad boorad commented Apr 27, 2026

Summary

Phase 1 of the security audit (plans/todo/security-audit.md) — adds the four shared foundation helpers that the Phase 2 / Phase 3 sweeps consume. Each helper closes a class of audit findings; this PR adds the helper, applies it to a representative reference site, and adds regression tests. The bulk sweep across all remaining sites comes in the next PRs.

# Commit Helper added Reference site applied Tests added
1.1 `dcbcd73` `validateUInt` — rejects NaN, +/-Infinity, negative, fractional, out-of-range before the cast `HybridArgon2` (parallelism / tagLength / memory / passes / version) 7
1.2 `2fae6bd` `secureZero()` — `OPENSSL_cleanse` wrapper, never elided by the compiler `XSalsa20Cipher` + `XSalsa20Poly1305Cipher` destructors — (covered by existing round-trips)
1.3 `73ea9f3` `EvpCipherCtxPtr` — `unique_ptr` for `EVP_CIPHER_CTX` in `HybridCipher` base All cipher subclasses (CCM/ChaCha20/ChaCha20-Poly1305/GCM/OCB/XSalsa20) — (covered by existing AEAD round-trips)
1.4 `7766d42` typed `getUIntOption` returning `number | undefined` The `authTagLength` parsing in `Cipheriv` constructor 4

Changes

1.1 — `validateUInt` helper

  • `static_cast<uint32_t>(NaN | +/-Infinity | -1)` is undefined behavior in C++. The audit found ~20 sites doing the cast naked at the JS↔C++ boundary; `HybridArgon2.cpp:50` was the most prominent (parallelism, memory, passes, version, tagLength all unchecked).
  • New templated `validateUInt(value, paramName, [min], [max])` in `QuickCryptoUtils.hpp` that throws `std::runtime_error` with the parameter name on any invalid input.
  • Applied as the reference site to `HybridArgon2::hashImpl`. Remaining ~19 sites (PBKDF2, Scrypt, HKDF, Hash, HMAC, KMAC, BLAKE3, RSA, ML-DSA, AES-CCM) get swept in Phase 2/3.
  • 7 regression tests covering NaN, +/-Infinity, negative, fractional, out-of-range parameters on the sync API and one async path proving Promise-rejection wiring.

1.2 — `secureZero()` helper

  • `std::memset` for wiping secret memory is unsafe under `-O2` — the compiler sees the writes are dead and elides them.
  • New `secureZero()` in `QuickCryptoUtils.hpp` wrapping `OPENSSL_cleanse` with overloads for `void* + size`, `std::vector<uint8_t>`, `std::string`, and fixed-size `uint8_t[N]` arrays.
  • Applied to:
    • `XSalsa20Cipher` destructor: previously did not zero key/nonce at all (audit HIGH); also wipes the leftover_keystream from Phase 0.2.
    • `XSalsa20Poly1305Cipher` destructor: collapses the `#ifdef BLSALLOC_SODIUM` fork that fell through to plain `std::memset` (audit MEDIUM).
  • Remaining ~28 sites (XChaCha20-Poly1305, all KDF derived bits, DH/ECDH shared secrets, RSA/EC/Ed/DSA DER private-key strings) get swept in Phase 2.

1.3 — `EVP_CIPHER_CTX` ownership in HybridCipher base

  • Three subclasses (`CCMCipher`, `ChaCha20Cipher`, `ChaCha20Poly1305Cipher`) implemented destructors as `~SubClass() { ctx = nullptr; }` — they nulled the parent's raw pointer without freeing it first, expecting the parent destructor to do the cleanup. The parent then saw `ctx == nullptr` and skipped the `EVP_CIPHER_CTX_free` entirely. Result: every cipher object of those three types leaked its OpenSSL context.
  • New `using EvpCipherCtxPtr = std::unique_ptr<EVP_CIPHER_CTX, decltype(&EVP_CIPHER_CTX_free)>` in `HybridCipher.hpp`. The base now holds the cipher context as a smart pointer with the `EVP_CIPHER_CTX_free` deleter baked in.
  • Subclasses can no longer mismanage `ctx` — destruction order (subclass → base) makes the parent's `unique_ptr` destructor run automatically and free the context exactly once.
  • The buggy three subclass destructors are removed (defaulted). `XSalsa20Cipher`'s destructor drops its now-redundant `ctx = nullptr` line while keeping the secureZero calls from 1.2.
  • Internally every `EVP_Foo(ctx, ...)` OpenSSL call uses `ctx.get()`; every `ctx = EVP_CIPHER_CTX_new()` becomes `ctx.reset(EVP_CIPHER_CTX_new())`; every manual cleanup of `EVP_CIPHER_CTX_free(ctx); ctx = nullptr;` collapses to `ctx.reset()`. ~70 touch points across 11 cipher files.

1.4 — typed `getUIntOption`

  • Old signature: `getUIntOption(options: Record<string, any>, key: string)` returning `number | -1` (sentinel).
  • `any` defeated the type checker; the cryptic `value >>> 0 !== value` validation was opaque; the `-1` sentinel forced every caller into a 4-line double-evaluating conditional.
  • New signature: `getUIntOption(options: Readonly<Record<string, unknown>> | undefined, key: string): number | undefined` with explicit `Number.isFinite/isInteger + range [0, 2^32-1]` validation that throws `RangeError` with a descriptive message.
  • Single call site collapses from a 4-line conditional to `getUIntOption(options, 'authTagLength') ?? 16`.
  • 4 regression tests covering negative, NaN, fractional, missing-defaults-to-16.

Test plan

Tests run in the example RN app (project convention — not a Node test runner). Verified locally on iOS:

  • 1.1 — `argon2` suite: 7 new "rejects ..." tests pass + existing argon2 round-trips still pass.
  • 1.2 — `cipher` suite (xsalsa20 + xsalsa20-poly1305): existing round-trips still pass; destructor refactor is invisible from JS.
  • 1.3 — `cipher` suite (every AEAD: GCM, CCM, OCB, ChaCha20-Poly1305, ChaCha20): all existing round-trip tests still pass — biggest refactor of the PR.
  • 1.4 — `cipher` suite: 4 new `createCipheriv: rejects ...` tests pass + existing GCM/CCM/OCB tests still pass.

Notes

  • Each commit is independent and bisectable; the PR is broken into 4 commits matching plan items 1.1–1.4.
  • Phase 1 closes the "Depends on Phase 1" precondition for Phase 2 (memory-safety sweep) and Phase 3 (TS-boundary validation) — those phases now have the helpers ready to consume.
  • Test-verification rule (`.claude/rules/test-verification.xml` from PR fix(security): Phase 0 audit fixes — actively exploitable findings #982) was honored: each commit was verified by the user in the example app before this push.

boorad added 4 commits April 26, 2026 22:02
`static_cast<uint32_t>(NaN | +/-Infinity | -1)` is undefined behavior
in C++. The audit found ~20 sites doing the cast naked at the JS↔C++
boundary; HybridArgon2.cpp:50 was the most prominent (parallelism,
memory, passes, version, tagLength all unchecked).

Add a templated `validateUInt<T>` helper to QuickCryptoUtils.hpp that
rejects NaN, +/-Infinity, negative values, fractional values, and
out-of-range inputs BEFORE the cast and throws std::runtime_error
with the parameter name on failure. Apply it as the reference site
to HybridArgon2's hashImpl. The remaining ~19 sites (PBKDF2, Scrypt,
HKDF, Hash, HMAC, KMAC, BLAKE3, RSA, ML-DSA, AES-CCM) get swept in
the Phase 2 / Phase 3 follow-ups that consume this helper.

Adds 7 regression tests covering NaN, +/-Infinity, negative,
fractional, out-of-range parameters on the sync API and one async
path proving the validation also rejects via the Promise rejection.

Phase 1.1 of plans/todo/security-audit.md.
`std::memset` is the wrong tool for wiping secret memory: under -O2 the
compiler will see that the writes are dead (the buffer is freed or
leaving scope right after) and elide them, leaving keys / nonces /
plaintext sitting on the heap. The audit found ~30 sites that need a
guaranteed-not-elided zeroize.

Add a `secureZero()` helper to QuickCryptoUtils.hpp that wraps
`OPENSSL_cleanse` (designed for exactly this purpose, available
unconditionally on OpenSSL 3.x). Provide overloads for the common
shapes: raw pointer + size, std::vector<uint8_t>, std::string,
fixed-size uint8_t arrays.

Apply to two reference sites:
- XSalsa20Cipher destructor: previously did NOT zero key/nonce at all
  (audit HIGH finding), and Phase 0.2 added a leftover_keystream
  buffer that also needs wiping.
- XSalsa20Poly1305Cipher destructor: had a `#ifdef BLSALLOC_SODIUM`
  fork that fell through to plain `std::memset` when sodium was off
  (audit MEDIUM finding); collapse both branches to secureZero.

The remaining ~28 sites (XChaCha20-Poly1305, all KDFs derived bits,
DH/ECDH shared secrets, RSA/EC/Ed/DSA DER private-key strings) get
swept in Phase 2 alongside the unique_ptr replacements.

Phase 1.2 of plans/todo/security-audit.md.
Three subclasses (CCMCipher, ChaCha20Cipher, ChaCha20Poly1305Cipher)
implemented destructors as `~SubClass() { ctx = nullptr; }` — they
nulled the parent's raw pointer without freeing it first, expecting
the parent destructor to do the cleanup. The parent then saw `ctx ==
nullptr` and skipped the EVP_CIPHER_CTX_free call entirely. Result:
every cipher object of those three types leaked its OpenSSL context.

Move ownership into the base class with a unique_ptr that has the
EVP_CIPHER_CTX_free deleter baked in. Subclasses can no longer
mismanage ctx because the destruction order (subclass → base) makes
the parent's unique_ptr destructor run automatically and free the
context exactly once. The buggy three subclass destructors are
removed (defaulted), and XSalsa20Cipher's destructor drops its now-
redundant `ctx = nullptr` line while keeping the secureZero calls
from Phase 1.2.

Internally every `EVP_*Foo*(ctx, ...)` OpenSSL call now uses
`ctx.get()`; every `ctx = EVP_CIPHER_CTX_new()` becomes
`ctx.reset(EVP_CIPHER_CTX_new())`; every manual cleanup of
`EVP_CIPHER_CTX_free(ctx); ctx = nullptr;` collapses to `ctx.reset()`.
Touched files: HybridCipher, CCMCipher, ChaCha20Cipher,
ChaCha20Poly1305Cipher, GCMCipher, OCBCipher, XSalsa20Cipher.

No new tests — the existing AEAD round-trip tests exercise both the
constructor (ctx.reset(EVP_CIPHER_CTX_new())) and the destructor
(unique_ptr cleanup) in every test that uses these ciphers.

Phase 1.3 of plans/todo/security-audit.md.
…helper

The previous getUIntOption signature defeated the type checker:

    function getUIntOption(options: Record<string, any>, key: string)

`any` everywhere meant the value extracted by `options[key]` was typed
as `any` and passed to the cipher constructor with zero validation
beyond the cryptic `value >>> 0 !== value` check. The function also
returned `-1` as a sentinel for "missing", forcing every caller into
the awkward `getUIntOption(...) !== -1 ? getUIntOption(...) : default`
pattern (which double-evaluates).

Replace with a typed helper:
- Input: `Readonly<Record<string, unknown>> | undefined` — narrows from
  `any` to `unknown`, requiring runtime checks before use.
- Return: `number | undefined` — the missing case is type-encoded.
- Validation: explicit `Number.isFinite/Integer + range [0, 2^32-1]`
  with a typed `RangeError` and a descriptive message.

The single call site collapses from a 4-line conditional to:

    const authTagLen = getUIntOption(options, 'authTagLength') ?? 16;

Adds 4 regression tests covering negative, NaN, fractional, and
missing-defaults-to-16 paths.

Phase 1.4 of plans/todo/security-audit.md.
@boorad boorad self-assigned this Apr 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🤖 End-to-End Test Results - Android

Status: ✅ Passed
Platform: Android
Run: 24973841834

📸 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.

@github-actions
Copy link
Copy Markdown
Contributor

🤖 End-to-End Test Results - iOS

Status: ✅ Passed
Platform: iOS
Run: 24973841839

📸 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.

@boorad boorad merged commit 78a4f9e into main Apr 27, 2026
7 checks passed
@boorad boorad deleted the feat/security-audit-phase-1 branch April 27, 2026 02:46
boorad added a commit that referenced this pull request Apr 27, 2026
The e2e-android-test.yml and e2e-ios-test.yml workflows referenced
'cpp/**', 'nitrogen/**', and 'src/**' at repo root — directories that
no longer exist after the workspace migration to
'packages/react-native-quick-crypto/'. Result: every C++-only PR
silently skipped both E2E suites (PR #982 Phase 0, PR #983 Phase 1,
and PR #984 Phase 2 all hit this).

Updates both pull_request and push path filters to point at the
workspace locations. Each workflow file is itself in its own paths
filter, so this commit triggers the workflows on PR #984 to run for
the first time on this branch's C++ changes.
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