Skip to content

Multiple api/crypto and node/crypto* updates#6341

Open
jasnell wants to merge 18 commits intomainfrom
jasnell/crypto-migrate-from-buffersource
Open

Multiple api/crypto and node/crypto* updates#6341
jasnell wants to merge 18 commits intomainfrom
jasnell/crypto-migrate-from-buffersource

Conversation

@jasnell
Copy link
Collaborator

@jasnell jasnell commented Mar 17, 2026

Replace jsg::BufferSource with lightweight Js types in crypto*

Replaces all uses of jsg::BufferSource and jsg::BackingStore in src/workerd/api/crypto/ and src/workerd/api/node/crypto* with the new lightweight typed wrappers: JsArrayBuffer, JsUint8Array, JsBufferSource, and JsArrayBufferView.

The changes are split into individual commits.

The second (and largest) commit is most mechanical, and focuses on changing the BufferSource -> Js* type.

It's going to be better to review this PR one commit at a time, in order.

Ref: similar migration in #6326 for the Node.js Buffer module.

New jsg infrastructure (jsvalue.h / jsvalue.c++)

  • JsArrayBuffer: create(js, length), create(js, data), create(js, backingStore), slice(), size(), copy(), const/non-const asArrayPtr()
  • JsUint8Array: create(js, data), create(js, JsArrayBuffer&), copy(), const asArrayPtr()
  • JsBufferSource: new JsBase<v8::Value> type accepting ArrayBuffer | ArrayBufferView, with constructors from typed handles, asArrayPtr(), size(), isIntegerType()
  • JsArrayBufferView: size(), isIntegerType()
  • All asArrayPtr() methods return 0-length ptr on detached buffers
  • RTTI registrations for all new types
  • tryCast<JsBufferSource>() special case + JsValueWrapper overloads
  • IsJsValue concept updated, JsBase requires constraint for
    v8::Value base

Security fixes (pre-existing bugs found during review)

  • toBignumUnowned: now throws on null BN_bin2bn return; added toBignumOwned for RAII-safe ownership transfer to RSA_set0_key/DH_set0_key
  • DH setPublicKey/computeSecret: proactive DH_check_pub_key validation before use
  • EC JWK import: added EC_KEY_check_key for private key consistency
  • EdDSA: OPENSSL_cleanse on stack private key buffers
  • EVP_PKEY_set1_RSA: unchecked return values → OSSLCALL
  • OpenSSLDigestContext::close: KJ_ASSERT(size, buf.size()) comma → ==
  • AES-CTR process(): KJ_DASSERTKJ_ASSERT for output bounds
  • RSA JWK import: simdutfBase64UrlDecodeChecked temporaries stored
    in named variables to avoid fragile lifetime dependency

@jasnell jasnell requested review from a team as code owners March 17, 2026 04:09
ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

This comment was marked as resolved.

@github-actions
Copy link

github-actions bot commented Mar 17, 2026

The generated output of @cloudflare/workers-types matches the snapshot in types/generated-snapshot 🎉

@jasnell jasnell marked this pull request as draft March 17, 2026 13:19
@jasnell jasnell marked this pull request as ready for review March 17, 2026 14:05
@jasnell
Copy link
Collaborator Author

jasnell commented Mar 17, 2026

/bigbonk I've addressed your review comments in additional prs... update your review

jasnell added 14 commits March 17, 2026 07:07
`toBignumUnowned()` calls `BN_bin2bn()` which can return nullptr on
allocation failure. The raw pointer was passed directly to BoringSSL
ownership-transfer functions (`RSA_set0_key`, `DH_set0_key`, etc.)
without null checking, leading to silent no-ops or undefined behavior.

Additionally, when multiple `toBignumUnowned()` calls appeared in
sequence (RSA JWK import with 8 parameters), a throw from any
allocation or base64 decode would leak all previously-allocated raw
BIGNUM pointers that hadn't yet been transferred to the RSA key.

Changes:
- `toBignumUnowned()` now throws `DOMOperationError` on null return
- Added `toBignumOwned()` returning `UniqueBignum` (`std::unique_ptr`
  with `BN_clear_free`) for RAII-safe allocation
- Converted all RSA JWK import sites (`Rsa::fromJwk`, `rsaJwkReader`)
  to use `toBignumOwned` — BIGNUM is freed on any throw, ownership
  transferred via `.release()` only after successful `RSA_set0_*` call
- Converted DH `setPrivateKey`/`setPublicKey` to use `toBignumOwned`
  with `.get()` then `.release()` after successful `DH_set0_key`
`JsArrayBuffer::asArrayPtr()`, `JsArrayBufferView::asArrayPtr()`,
`JsUint8Array::asArrayPtr()`, and `JsBufferSource::asArrayPtr()` did
not check for detached `ArrayBuffer`s. For `ArrayBufferView`-based types,
a detached underlying buffer has `Data()` returning `nullptr` while
`ByteOffset()` may still be non-zero, producing a dangling pointer
(`nullptr + offset`).

All `asArrayPtr()` implementations now check `WasDetached()` and
return a 0-length `kj::ArrayPtr` for detached buffers.
`JsBufferSource::size()` similarly returns 0 for detached buffers.

Current call sites are safe in practice — user-provided buffers are
consumed synchronously within the same C++ call frame as the JS-to-C++
type unwrap, with no intervening JS execution that could detach the
buffer. The checks serve as defense-in-depth against future
refactoring.

Also moved `JsArrayBuffer::asArrayPtr()` out of the header into
`jsvalue.c++`.
`simdutfBase64UrlDecodeChecked()` returns a `JsUint8Array` backed by
the V8 heap. Chaining `.asArrayPtr()` on the temporary produced a
`kj::ArrayPtr` pointing into V8 memory that was only kept alive by
C++ temporary lifetime rules. While technically safe (the temporary
lives until the end of the full-expression), this is fragile — any
refactoring that stores the `ArrayPtr` separately would create a
dangling pointer.

Store each `JsUint8Array` in a named variable before extracting the
`ArrayPtr`, making the lifetime relationship explicit.
`DiffieHellman::setPublicKey()` accepted arbitrary byte arrays without
validation, allowing keys of 0, 1, or values >= p that would cause
`computeSecret()` to produce predictable or weak shared secrets.
`computeSecret()` only called `DH_check_pub_key` on the error path
after `DH_compute_key` had already failed.

Add `DH_check_pub_key` validation in both `setPublicKey()` (rejects
invalid keys at the point they're set) and `computeSecret()` (rejects
invalid peer keys before computing the shared secret, not after).
This catches small subgroup attacks and out-of-range keys with clear
error messages.
Stack buffers containing Ed25519/X25519 private key bytes were not
zeroed after use. In the JWK export path, `rawPrivateKey` held the
32-byte private key on the stack and was never cleansed. In the keygen
path, `rawPrivateKey` held the generated private key and persisted on
the stack after the EVP_PKEY was constructed.

Add `OPENSSL_cleanse` to zero private key buffers:
- JWK export: inline cleanse after base64url encoding
- Keygen: `KJ_DEFER(OPENSSL_cleanse(...))` to ensure zeroing even if
  key construction throws
The raw EC import path validated key consistency via `EC_KEY_check_key`
but the JWK import path did not. A JWK with valid public key
coordinates (x, y) but a private key component (d) that does not
correspond to the public point would be silently accepted, potentially
leading to incorrect signatures or key agreement results.

Add `EC_KEY_check_key` after setting all key components in the JWK
import path, matching the validation already present in the raw import
path.
Three `EVP_PKEY_set1_RSA` calls in RSA `toPem`/`toDer` export paths had
their return values silently ignored, and one used `KJ_ASSERT` instead
of the project convention. Wrap all four with `OSSLCALL` for
consistent BoringSSL error handling.
`JsArrayBuffer::asArrayPtr()` and `JsUint8Array::asArrayPtr()` were
only available as non-const, requiring const_cast when used in const
contexts (e.g., `RsaKeyAlgorithm::clone()`).
Add const overloads returning `kj::ArrayPtr<const kj::byte>` for
both types. Remove the `const_cast` in `RsaKeyAlgorithm::clone()`.
`OpenSSLDigestContext::close()` used `KJ_ASSERT(size, buf.size())`
which asserts `size != 0` with `buf.size()` as debug context (comma
operator). This was intended to verify `size == buf.size()` — the
safety check was a no-op. All other digest finalization sites use `==`.

`AesCtrKey::process()` used `KJ_DASSERT` (debug-only) for output
length bounds checks after `EVP_CipherUpdate` and `EVP_CipherFinal`.
The equivalent checks in AES-GCM and AES-CBC use `KJ_ASSERT`. In
release builds, a buffer overrun from OpenSSL would go undetected.
@jasnell jasnell force-pushed the jasnell/crypto-migrate-from-buffersource branch from a52b905 to a109891 Compare March 17, 2026 14:07
anonrig
anonrig previously approved these changes Mar 17, 2026
ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk
Copy link
Contributor

ask-bonk bot commented Mar 17, 2026

Review posted successfully on PR #6341. Here's a summary:

Previous findings status:

  • [HIGH] DH_check_pub_key — Fixed. Both setPublicKey and computeSecret now properly check the return value with JSG_REQUIRE.
  • [MEDIUM] BIGNUM leak on RSA_set0_key — Fixed. All 8 call sites use the safe .get() + .release() pattern.
  • [LOW] cryptoPreservePublicExponent — Retracted. The flag is actively used in RsaKeyAlgorithm::clone() and must be maintained per compat flag policy.

New findings posted (5 inline comments):

  1. [HIGH] JsArrayBufferView::asArrayPtr<T>() and JsUint8Array::asArrayPtr<T>() have incorrect byte-offset arithmetic and element-count calculation when sizeof(T) > 1 — currently latent since only T = kj::byte is used, but the template is unconstrained.

  2. [HIGH] CipherHandle and AeadHandle in node/crypto.h store jsg::Ref<CryptoKey>, jsg::JsRef<jsg::JsBufferSource>, and Maybe<JsRef<...>> fields without visitForGc() — GC corruption risk. Pre-existing but made more critical by the JsRef migration.

  3. [MEDIUM] JsBufferSource's public using JsBase::JsBase constructor allows wrapping any v8::Local<v8::Value>, bypassing type safety.

  4. [MEDIUM] JsBufferSource is missing a const overload of asArrayPtr(), inconsistent with JsArrayBuffer and JsUint8Array.

  5. [MEDIUM] JsArrayBuffer::slice() and JsUint8Array::slice() lack detached-buffer checks (noted in review body).

github run

@jasnell
Copy link
Collaborator Author

jasnell commented Mar 17, 2026

/bigbonk I've addressed your new finding 1... however, finding 2 is unnecessary, these do not need to be gc visited as they do not create cycles; finding 3, 4, and 5 aren't necessary and can be addressed later if needed. Please update your review

@jasnell jasnell dismissed anonrig’s stale review March 17, 2026 15:38

There are still issues with the PR

ask-bonk[bot]

This comment was marked as resolved.

@ask-bonk

This comment was marked as resolved.

@jasnell jasnell requested a review from anonrig March 17, 2026 16:27
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.

2 participants