feat(security): Phase 1 audit foundation — shared helpers (validateUInt, secureZero, EVP_CIPHER_CTX RAII, typed getUIntOption)#983
Merged
Conversation
`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.
Contributor
🤖 End-to-End Test Results - AndroidStatus: ✅ Passed 📸 Final Test ScreenshotScreenshot automatically captured from End-to-End tests and will expire in 30 days This comment is automatically updated on each test run. |
Contributor
🤖 End-to-End Test Results - iOSStatus: ✅ Passed 📸 Final Test ScreenshotScreenshot automatically captured from End-to-End tests and will expire in 30 days This comment is automatically updated on each test run. |
5 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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.Changes
1.1 — `validateUInt` helper
1.2 — `secureZero()` helper
1.3 — `EVP_CIPHER_CTX` ownership in HybridCipher base
1.4 — typed `getUIntOption`
Test plan
Tests run in the example RN app (project convention — not a Node test runner). Verified locally on iOS:
Notes