Skip to content

fix(security): Phase 0 audit fixes — actively exploitable findings#982

Merged
boorad merged 8 commits intomainfrom
feat/security-audit
Apr 27, 2026
Merged

fix(security): Phase 0 audit fixes — actively exploitable findings#982
boorad merged 8 commits intomainfrom
feat/security-audit

Conversation

@boorad
Copy link
Copy Markdown
Collaborator

@boorad boorad commented Apr 27, 2026

Summary

Phase 0 of the ongoing security audit (plans/todo/security-audit.md) — fixes the four actively-exploitable findings before continuing with the deeper Phase 1+ sweeps. Each fix landed as its own commit with an independent crypto-specialist review and regression tests.

# Commit Issue closed
0.1 f99a865 abvToArrayBuffer byte-offset bug → timingSafeEqual HIGH, all AEAD setAAD HIGH, X.509 string-ctor pool-leak (newly found)
0.2 61d00aa XSalsa20 keystream restarted at block 0 on every update() → catastrophic two-time pad whenever the streaming Cipheriv API was used
0.3 827f0a1 DH/ECDH computeSecret accepted invalid peer keys → DH small-subgroup attack (peer 0/1/p-1), ECDH invalid-curve attack
0.4 82c6ac1 RSA PKCS#1 v1.5 distinguishable errors → Bleichenbacher (Million Message) padding oracle

Changes

0.1 — Byte-offset bug (abvToArrayBuffer)

  • timingSafeEqual and AEAD setAAD were passing the entire backing ArrayBuffer to native instead of the requested view. For sliced/offset buffers this compared/authenticated the wrong bytes.
  • Fixed by routing both through binaryLikeToArrayBuffer (offset-aware), and hardened abvToArrayBuffer doc to flag the zero-copy semantic.
  • Discovered while sweeping: X509Certificate(string) had the same shape via Buffer.from(str).buffer returning a pool-backed AB with non-zero byteOffset — fixed in the same pass.
  • 5 regression tests (3 timingSafeEqual view cases, 2 GCM sliced-AAD cases).

0.2 — XSalsa20 keystream restart

  • crypto_stream_xor always starts at block 0; calling it per-update() produced N copies of the same keystream XORed against different plaintext.
  • Replaced with crypto_stream_xsalsa20_xor_ic plus per-instance block_counter and a 64-byte leftover_keystream so chunked updates resume cleanly.
  • Output now uses unique_ptr for exception safety.
  • 6 streaming regression tests covering block-aligned splits, mid-block splits, many-small-chunk splits, drain-to-boundary continuation, the catastrophic two-time-pad regression, and a streaming round-trip.

0.3 — DH/ECDH peer-key validation

  • EVP_PKEY_derive_set_peer does not call DH_check_pub_key or EVP_PKEY_public_check, so off-curve / small-subgroup peers were silently accepted.
  • DH: explicit DH_check_pub_key with TOO_SMALL / TOO_LARGE / INVALID error distinction (matches ncrypto's DHPointer::checkPublicKey).
  • ECDH: explicit EC_POINT_oct2pointEC_POINT_is_at_infinityEC_POINT_is_on_curve against the configured group.
  • 4 DH and 5 ECDH regression tests including a cross-curve attack (P-384 pubkey sent to a P-256 instance) and a bit-flipped y-coordinate test.

0.4 — RSA Bleichenbacher oracle

  • Two-layer fix: enable OpenSSL 3.2+ implicit rejection (EVP_PKEY_CTX_ctrl_str(ctx, "rsa_pkcs1_implicit_rejection", "1")) for PKCS#1 v1.5 + collapse every decrypt-failure path to a single opaque "RSA decryption failed" message with the OpenSSL error stack cleared.
  • Hard-fails PKCS#1 v1.5 decryption if the implicit-rejection knob is unsupported (BoringSSL or pre-3.2) rather than silently leaving the timing-side oracle open — matches Node.js's crypto_cipher.cc policy.
  • TS wrapper drops the : ${error.message} interpolation in privateDecrypt / publicDecrypt.
  • 5 regression tests covering implicit-rejection determinism, opaque OAEP errors, and publicDecrypt opacity.

Plan

  • plans/todo/security-audit.md is added in this branch and tracks Phase 0–5. Phase 0 rows are now [x]; Phase 1 (shared validateDouble / secureZero / EVP_CIPHER_CTX RAII helpers) is the natural next PR.

Test plan

Tests run in the example RN app (per project convention — not standard Node test runners).

  • bun run example and run the cipher, dh, ecdh, and keys.* test suites; verify all new and existing tests pass on iOS and Android.
  • Confirm metro log (/tmp/rnqc-metro.log) shows no failures from the 20 new regression tests across the 4 phases.
  • Spot-check that none of the new tests would pass against pre-fix behavior — each was authored to fail loudly if its specific defect regressed.
  • No behavioral change for callers using OAEP (round-trip / wrong-label tests still pass with the new opaque error message).
  • Streaming xsalsa20 callers via createCipheriv now produce different output than before for chunked update() patterns — review whether any consumers depend on the (broken) prior behavior.

Notes

  • Each commit includes an independent crypto-specialist review captured in the commit message and progress log; reviewers should sanity-check the C++ paths against ~/dev/ncrypto patterns referenced in the audit doc.
  • Diff is large mostly because plans/todo/security-audit.md (+1257 lines) is the audit tracker itself; actual fix code is ~250 lines of C++/TS.

boorad added 4 commits April 26, 2026 20:18
…l, X.509

Closes Phase 0.1 of the security audit. Three call sites passed the full
backing ArrayBuffer to native code, ignoring view byteOffset/byteLength:

- timingSafeEqual: switched from abvToArrayBuffer to binaryLikeToArrayBuffer
  so TypedArray / Buffer views are sliced to their window. Previously
  compared the entire backing, which both walked unrelated bytes and
  spuriously failed the byte-length check for any view smaller than its
  backing.
- Cipher.setAAD: stopped passing buffer.buffer; now slices via
  binaryLikeToArrayBuffer. Sliced AAD silently authenticated the wrong
  bytes — a direct AEAD integrity violation across GCM/CCM/OCB/Poly1305.
- X509Certificate(string): Buffer.from(str).buffer can return a pool-backed
  ArrayBuffer with byteOffset > 0, exposing unrelated pool bytes to native.
  Now routes through binaryLikeToArrayBuffer.

abvToArrayBuffer's doc-comment is hardened to flag the zero-copy backing
semantic and steer future callers toward binaryLikeToArrayBuffer for crypto
data paths. randomFill's intentional use of the zero-copy path is preserved.

Adds 5 regression tests: 3 timingSafeEqual view cases (offset views with
matching content, offset views with differing content, view-vs-view across
differently-sized backings) and 2 GCM sliced-AAD cases (verifies the slice
is what gets authenticated, and that wrong-AAD on decrypt still throws).

Also adds the implementation plan to plans/todo/security-audit.md, which
sequences the remaining audit findings into 6 phases.
The previous implementation called crypto_stream_xor() on every update(),
which restarts the XSalsa20 keystream at block 0. Streaming a message in
N chunks therefore produced N copies of the same keystream XORed against
different plaintext — a textbook two-time pad. Any caller using the
streaming Cipheriv API for XSalsa20 was catastrophically vulnerable.

Switch to crypto_stream_xsalsa20_xor_ic() with an explicit block counter,
plus a 64-byte leftover-keystream buffer so partial-block chunks can
resume cleanly on the next update(). init() resets all streaming state
so re-init is safe. Output now uses unique_ptr / release() to avoid
leaking on the failure path.

Adds 6 streaming regression tests: block-aligned splits, mid-block
splits, many-small-chunk splits, drain-to-boundary continuation, the
two-time-pad regression (same plaintext → distinct ciphertexts), and a
streaming round-trip across separate encrypt/decrypt instances.

Closes Phase 0.2 in plans/todo/security-audit.md.
DH and ECDH both relied on EVP_PKEY_derive_set_peer() to "validate"
the peer key, but that EVP entrypoint does not call DH_check_pub_key
or EVP_PKEY_public_check, so:

  - DH silently accepted peer pubkeys of 0, 1, or p-1 and produced a
    "shared secret" of 0, 1, or +/-1 — the small-subgroup attack.
  - ECDH silently accepted peer points on a related/weaker curve,
    enabling the invalid-curve attack and leaking bits of our private
    key in the resulting shared secret.

Add explicit validation up front in both computeSecret paths:

  - DH: BN_bin2bn the peer key, call DH_check_pub_key against our DH
    parameters, and surface TOO_SMALL / TOO_LARGE / INVALID flags as
    distinct error messages (matching ncrypto's DHPointer::checkPublicKey).
  - ECDH: EC_POINT_oct2point against the configured group, then
    EC_POINT_is_at_infinity (reject identity), then EC_POINT_is_on_curve
    (defensive — oct2point validates internally on OpenSSL 3.x but a
    second check protects against regressions and partial implementations).

Adds 4 DH and 5 ECDH regression tests covering each rejection path,
including a cross-curve test (P-384 pubkey sent to a P-256 instance)
and a bit-flipped-coordinate test that exercises is_on_curve.

Closes Phase 0.3 in plans/todo/security-audit.md.
RSA decryption surfaced OpenSSL error strings verbatim through both the
C++ and TS layers, so a remote attacker could distinguish "padding
invalid" from "data too large", "bad version", "wrong key", etc. — a
classic Million Message Attack padding oracle.

Two-layer fix:

1. Implicit rejection. For PKCS#1 v1.5 decryption, enable OpenSSL 3.2+
   implicit rejection via
   EVP_PKEY_CTX_ctrl_str(ctx, "rsa_pkcs1_implicit_rejection", "1").
   Corrupted ciphertexts now deterministically decrypt to random-looking
   bytes instead of throwing. If the underlying OpenSSL build does not
   support the knob (BoringSSL, pre-3.2) we hard-fail PKCS#1 v1.5
   decryption with a build-config error rather than silently leaving
   the timing-side oracle open — matches Node.js's crypto_cipher.cc
   policy.

2. Opaque errors. Every decrypt-failure path in decrypt(),
   privateDecrypt(), and publicDecrypt() (verify-recover) routes through
   a single throwOpaqueDecryptFailure() helper that emits the same
   "RSA decryption failed" message and clears the OpenSSL error stack so
   the underlying reason never reaches the caller. The TS wrapper drops
   the `: ${error.message}` interpolation and just throws
   "privateDecrypt failed" / "publicDecrypt failed".

Adds 5 regression tests: corrupted PKCS#1 v1.5 doesn't throw; the
implicit-rejection output is deterministic per (key, ciphertext) and
distinct across different ciphertexts; OAEP/wrong-label errors are
opaque (no "openssl/padding/oaep/label" terms); OAEP and PKCS#1
wrong-padding errors are equivalent; publicDecrypt errors are opaque.

Closes Phase 0.4 in plans/todo/security-audit.md.
@boorad boorad self-assigned this Apr 27, 2026
Phase 0.4 collapsed every decrypt-failure path through the new opaque
error helper, which removed the special case in publicDecrypt's second
verify_recover call that returned an empty buffer when the recovered
payload was zero bytes. That broke
`publicDecrypt(privateEncrypt(""))` round-trips — the existing test
"privateEncrypt/publicDecrypt empty plaintext" now reports
"publicDecrypt failed".

Restore the narrow OpenSSL-error-code match so empty-plaintext recovery
works again. publicDecrypt is signature verification with the PUBLIC
key (anyone can perform it), so this special case is not a
Bleichenbacher target. The fall-through still routes through the
opaque-error helper when no match is hit.

Loosen the new "Bleichenbacher: publicDecrypt errors are opaque" test
to accept either the silent-empty path or the opaque-throw path —
both are info-leak-free. The throw branch still asserts the message
contains no OpenSSL details.

Note: the broader "masks real failures" concern flagged in the audit
as MEDIUM (HybridRsaCipher.cpp:264) is preserved as a separate finding
to be tightened in a later pass with a precise OpenSSL 3.x reason-code
match.
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

🤖 End-to-End Test Results - Android

Status: ✅ Passed
Platform: Android
Run: 24972313443

📸 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

github-actions Bot commented Apr 27, 2026

🤖 End-to-End Test Results - iOS

Status: ✅ Passed
Platform: iOS
Run: 24972313458

📸 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 added 3 commits April 26, 2026 21:23
The previous fixup used ERR_peek_last_error which returns the NEWEST
error in OpenSSL's FIFO queue. For verify_recover failures the queue
typically holds an outer wrapper error on top of the inner
padding-check error, so the narrow constants from the original code
(0x1C880004, low-byte 0x04) never matched and every recovery
went through throwOpaqueDecryptFailure.

Switch to ERR_get_error to read the OLDEST queued error — same
behavior as the original code that worked, restoring the empty
plaintext round-trip.
Tests in this project run only in the example React Native app and the
assistant cannot execute them. The default /commit and /pr skills both
push immediately after committing, which has produced a "fix the fix"
loop in this very session: a runtime regression shipped to the remote
PR, was found by the user, the fix was committed and pushed without
verification, and the fix-of-the-fix had to be pushed too.

Codify the implicit norm as a HIGH/STRICT rule: after any commit that
needs runtime validation in the example app, wait for the user to run
the relevant test suite and explicitly confirm it passes before
pushing. Iterate locally on failure; batch the validated commits into
a single push when the user gives the go-ahead.
Switch the example app from the published "1.1.0" version of
react-native-quick-crypto to "workspace:*" so local C++ / TS edits
land on the next bun ios / bun android without a publish round-trip.
Refresh Podfile.lock with the new QuickCrypto pod hash that pops out
of the Phase 0 C++ changes.
@boorad boorad merged commit 7d9b0a9 into main Apr 27, 2026
8 checks passed
@boorad boorad deleted the feat/security-audit branch April 27, 2026 01:54
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