Skip to content

crypto: port upstream ncrypto correctness fixes - #33202

Merged
dylan-conway merged 3 commits into
mainfrom
farm/caefa09c/ncrypto-upstream-sync
Jul 1, 2026
Merged

dylan-conway merged 3 commits into
mainfrom
farm/caefa09c/ncrypto-upstream-sync

Conversation

@robobun

@robobun robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Bun's src/jsc/bindings/ncrypto.{cpp,h} is a port of nodejs/ncrypto@134ac40 (February 2025), which maps to Node's deps/ncrypto from late January 2025. This ports the upstream correctness fixes made since then that apply to code Bun already has, plus one Bun-local typo found while auditing the drift.

Upstream fixes

Change Upstream
DataPointer::resize(): handle OPENSSL_realloc failure instead of returning a DataPointer with a null pointer and a nonzero length. Also frees the old block on failure. Upstream's version calls a free() helper after release() has already nulled data_, which is a no-op, so the old block leaks; this keeps the intent without that. nodejs/ncrypto#37
CipherCtxPointer::{setIvLength, setAeadTag, setAeadTagLength, getAeadTag}: compare the EVP_CIPHER_CTX_ctrl return against > 0. The raw int can be negative, which converts to true. BoringSSL normalizes -1 to 0, so this is masked in Bun's build today, but it is still the wrong check. nodejs/ncrypto#36
BignumPointer::{GetWord, getWord} return std::optional so a BN_get_word overflow (reported as the all-ones word) is distinguishable from the real all-ones value. The optional wraps BN_ULONG, not upstream's unsigned long: BoringSSL's BN_ULONG is uint64_t on every 64-bit target, so unsigned long would still silently truncate 33-to-64-bit values on LLP64 (Windows x64). nodejs/node#63895
BIOPointer::New(method): return an empty pointer for a null method. nodejs/node#61788
EVPKeyCtxPointer::publicCheck(): remove an unconditional return that made the EVP_PKEY_public_check_quick branch unreachable. The whole block is non-BoringSSL, so it is dead in Bun's build. nodejs/node#59471
X509Name::Iterator::operator*(): free the buffer allocated by ASN1_STRING_to_UTF8, and return early on a negative size (the out-pointer is never written on failure). Dead code today: JSX509Certificate.cpp has its own correct loop. nodejs/node#60609

GetWord callers updated

  • JSDiffieHellmanConstructor.cpp: the generator-below-2 check becomes has_value() && *word < 2. This is the one spot that needed care: std::optional<T> < 2 compiles and evaluates nullopt < 2 as true, so a naive translation would have started rejecting any generator wider than a word.
  • JSX509Certificate.cpp (both legacy-object sites): exponent is reported as null when the RSA public exponent does not fit in a word, matching Node. This branch is unreachable under BoringSSL, which rejects RSA public exponents wider than 33 bits at SPKI parse time.

Bun-local typo

SSLCtxPointer::setCipherSuites passed ciphers.length() where SSL_CTX_set_ciphersuites expects the string. It is in the non-BoringSSL #ifndef branch so it never compiles in Bun's build; introduced by the original WTF::StringView port of the file.

Testing

No test here can fail against the unmodified source on a Linux machine, because nothing in this diff changes observable behavior on Linux. I checked each one: DataPointer::resize only diverges under OPENSSL_realloc failure, the EVP_CIPHER_CTX_ctrl returns are already normalized to 0 or 1 by BoringSSL, publicCheck and setCipherSuites are inside non-BoringSSL #ifndef branches, BIOPointer::New(method) and X509Name::Iterator have no callers, and BN_ULONG and unsigned long are the same 64-bit type on LP64. The one real before-and-after is the BN_ULONG truncation, and it only exists on Windows x64 (LLP64).

So the new tests pin invariants rather than prove a failure. node-crypto.test.js gains three DiffieHellman cases:

  • A 72-bit buffer generator is accepted. This is what a naive optional < 2 translation would have broken on every platform.
  • A 33-bit (5-byte) buffer generator is accepted. Equivalent on platforms with a 64-bit unsigned long; on Windows x64 the unsigned long version of this change truncates it to 0 and wrongly rejects it as below 2, so this is the real regression test for the BN_ULONG return type on that platform.
  • Generators below 2 are still rejected and exactly 2 is still accepted.

Node v26 agrees on all three.

Local verification against the debug build
  • test/js/node/crypto/{crypto,node-crypto,crypto.key-objects,crypto-rsa,x509-subclass}.test.*: 677 pass, 0 fail
  • test/js/node/test/parallel/test-crypto-{x509,dh,dh-errors,dh-constructor,dh-odd-key,dh-generate-keys,dh-shared,dh-padding,cipheriv-decipheriv,gcm-explicit-short-tag,gcm-implicit-short-tag,aes-wrap,padding-aes256,getcipherinfo,rsa-dsa}.js, test-webcrypto-encrypt-decrypt-aes.js, test-crypto-webcrypto-aes-decrypt-tag-too-small.js: all pass

Deliberately not included

  • Cipher::MAX_AUTH_TAG_LENGTH (nodejs/node#57803): of the three macros upstream's static_assert uses, BoringSSL only defines EVP_GCM_TLS_TAG_LEN, so the assert degenerates to 16 <= 16.
  • ECKeyPointer::setPublicKeyRaw (nodejs/node#62396): the expensive check that rewrite avoids (the order * Q == infinity step of EC_KEY_check_key) does not exist in BoringSSL's EC_KEY_check_key, so there is no perf win for Bun.
  • The BoringSSL build guards the standalone repo added (NCRYPTO_NO_DSA_KEYGEN, NCRYPTO_NO_EVP_DH, rejecting DSA in NewFromID, ...): those exist for vanilla BoringSSL CI, and Bun's DSA and DH paths work today against its BoringSSL.

Bun's ncrypto.{cpp,h} is a port of nodejs/ncrypto from February 2025.
Upstream has landed several small correctness fixes since then; this
brings over the ones that apply to code Bun already has.

- DataPointer::resize(): handle OPENSSL_realloc failure instead of
  returning a DataPointer with a null data pointer and a nonzero
  length (nodejs/ncrypto#37).
- CipherCtxPointer setIvLength / setAeadTag / setAeadTagLength /
  getAeadTag: compare the EVP_CIPHER_CTX_ctrl return against > 0. The
  raw int can be negative, which is truthy as a bool
  (nodejs/ncrypto#36).
- BignumPointer::{GetWord,getWord}: return std::optional so a
  BN_get_word overflow (reported as ULONG_MAX) is distinguishable from
  a real value (nodejs/node#63895). Callers updated: the DiffieHellman
  generator-below-2 check, and the two X509 legacy-object exponent
  sites, which now report null for an exponent too wide for a word,
  matching Node.
- BIOPointer::New(method): return an empty pointer for a null method
  (nodejs/node#61788).
- EVPKeyCtxPointer::publicCheck(): remove an unconditional return that
  made the EVP_PKEY_public_check_quick branch unreachable
  (nodejs/node#59471).
- X509Name::Iterator::operator*(): free the buffer allocated by
  ASN1_STRING_to_UTF8, and bail out on a negative size
  (nodejs/node#60609).

Also fixes a typo from the original WTF::StringView port:
SSLCtxPointer::setCipherSuites passed ciphers.length() where
SSL_CTX_set_ciphersuites expects the string.
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: df087982-f264-4965-9389-7365469b1f40

📥 Commits

Reviewing files that changed from the base of the PR and between b124ed3 and 72738d2.

📒 Files selected for processing (5)
  • src/jsc/bindings/JSX509Certificate.cpp
  • src/jsc/bindings/ncrypto.cpp
  • src/jsc/bindings/ncrypto.h
  • src/jsc/bindings/node/crypto/JSDiffieHellmanConstructor.cpp
  • test/js/node/crypto/node-crypto.test.js

Walkthrough

This PR changes BignumPointer::GetWord/getWord to return std::optional<BN_ULONG>, updates JSX509Certificate and JSDiffieHellmanConstructor callers to handle the optional, and applies several unrelated correctness fixes in ncrypto.cpp covering memory reallocation, null checks, cipher suite configuration, AEAD control return values, and X509 name UTF-8 buffer management, with new DiffieHellman generator tests.

Changes

BignumPointer optional word API and callers

Layer / File(s) Summary
BignumPointer::GetWord/getWord optional return type
src/jsc/bindings/ncrypto.h, src/jsc/bindings/ncrypto.cpp
GetWord/getWord now return std::optional<BN_ULONG>, yielding std::nullopt when the bignum is null or BN_get_word returns the sentinel value.
X509Certificate legacy exponent serialization
src/jsc/bindings/JSX509Certificate.cpp
Both legacy-object builders emit exponent: null when the exponent word is unavailable, otherwise format the hex string as before.
DiffieHellman generator validation and tests
src/jsc/bindings/node/crypto/JSDiffieHellmanConstructor.cpp, test/js/node/crypto/node-crypto.test.js
Generator validation rejects only when getWord() has a value and is less than 2; new tests cover wide buffer generators and boundary values.

Unrelated ncrypto.cpp memory/return-value fixes

Layer / File(s) Summary
OpenSSL wrapper correctness fixes
src/jsc/bindings/ncrypto.cpp
DataPointer::resize handles realloc failure, BIOPointer::New guards null method, setCipherSuites passes UTF-8 data, AEAD controls convert results via > 0, and X509Name::Iterator frees the UTF-8 buffer and checks conversion failure.

Sequence Diagram(s)

sequenceDiagram
  participant Test as node-crypto.test.js
  participant Constructor as constructDiffieHellman
  participant Bignum as BignumPointer
  Test->>Constructor: createDiffieHellman(p, g)
  Constructor->>Bignum: getWord() on generator
  Bignum-->>Constructor: optional<BN_ULONG>
  Constructor->>Constructor: reject if has_value() and value < 2
  Constructor-->>Test: DH instance or bad generator error
Loading

Related Issues: None found in the provided context.

Related PRs: None found in the provided context.

Suggested labels: crypto, native, needs-review

Suggested reviewers: None identified from the provided context.

🐰 A word optional, once assumed, now checked twice before consumed,
The BIO guards its null-shy gate, the cipher suites now compensate,
And DH's generator, wide or small, rejects the bad, accepts them all.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: porting upstream ncrypto correctness fixes.
Description check ✅ Passed The description covers what the PR does and how it was verified, with clear change and testing details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 1, 2026
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:25 PM PT - Jul 1st, 2026

@dylan-conway, your commit 72738d2 is building: #67736

Comment thread src/jsc/bindings/ncrypto.cpp Outdated
BoringSSL's BN_ULONG is uint64_t on every 64-bit target, but unsigned
long is 32 bits on LLP64 (Windows x64). The previous commit compared
against the 64-bit overflow sentinel correctly, but still narrowed the
result through std::optional<unsigned long>, so on Windows a BIGNUM in
the 33-64 bit range truncated: a 5-byte DiffieHellman generator such as
0x0100000000 came back as 0 and was rejected as below 2, and a 33-bit
X509 RSA public exponent (which BoringSSL permits) was misreported.

Return std::optional<BN_ULONG> instead. No caller changes are needed:
the DiffieHellman site compares against 2 and the X509 sites already
cast to uint64_t. Adds a 33-bit generator case alongside the existing
72-bit one; it is the same on 64-bit-unsigned-long platforms but
exercises the truncation on Windows.

Upstream ncrypto keeps unsigned long because OpenSSL defines BN_ULONG
as unsigned long on LP64; under BoringSSL the two types differ.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the BN_ULONG truncation — the fix and the added 33-bit test look right. I didn't find anything else, but since this touches crypto primitives (DH generator validation, X509, AEAD tag handling) it's worth a human sign-off.

Extended reasoning...

Overview

This PR ports six upstream ncrypto correctness fixes from Node.js into Bun's src/jsc/bindings/ncrypto.{cpp,h}, updates the three call sites of BignumPointer::GetWord (JSDiffieHellmanConstructor.cpp and two spots in JSX509Certificate.cpp), fixes a Bun-local typo in SSLCtxPointer::setCipherSuites, and adds DiffieHellman generator boundary tests. On my previous pass I flagged that returning std::optional<unsigned long> would still truncate 33–64-bit values on LLP64 (Windows x64); the author changed the return type to std::optional<BN_ULONG> in fbdebd6 and added a 33-bit generator test case that pins the fix on Windows. That thread is resolved and I verified the current diff reflects it.

Security risks

The touched code is security-adjacent: DH generator validation, X509 RSA exponent reporting, AEAD tag get/set, and TLS 1.3 cipher-suite configuration. Each individual change tightens rather than loosens behavior (fail-on-realloc-null, > 0 instead of implicit-bool on EVP_CIPHER_CTX_ctrl, null-method guard, overflow signaling via std::nullopt), and the PR description accurately notes which branches are dead under BoringSSL. I don't see a way any of these introduce a bypass or downgrade — but crypto code is exactly where a subtle misreading of an OpenSSL/BoringSSL contract has outsized consequences, so a human familiar with Bun's BoringSSL fork should confirm.

Level of scrutiny

High. Even though most changes are OOM-only paths, non-BoringSSL #ifndef branches, or otherwise unreachable in Bun's build, the one live path — the DH buffer-generator < 2 check — sits directly on user-facing key-agreement setup. The author's analysis of std::optional < 2 semantics and the LLP64 narrowing is careful and correct, and the new tests cover the exact edge cases (0/1 rejected, 2 accepted, 33-bit and 72-bit accepted).

Other factors

The bug-hunting system found nothing on this revision. The PR description cites upstream references for every change and explicitly lists what was deliberately excluded and why. Test coverage was added in the right file. My only prior concern has been addressed with both a code fix and a targeted test. Deferring solely because auto-approval guidelines exclude security-sensitive crypto changes.

@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI analysis for Buildkite build 67667 at fbdebd6 (the build was still running when this was written; these are the lanes that have finished or reported so far).

The dominant failure is test/bake/dev/production.test.ts, where production > works with sourcemaps - error thrown in React component hangs to its 90 s timeout on every platform it has run on (Windows, Linux, macOS, and the ASAN lane). That is not this PR: the bake test harness installs an unpinned React experimental build from npm, a new one broke the test repo-wide, and #33204 pins it. Once that lands on main, a branch update here makes those lanes go green.

The remaining failures are single-lane flakes with no overlap with this diff (ncrypto.{cpp,h}, JSX509Certificate.cpp, JSDiffieHellmanConstructor.cpp, node-crypto.test.js), and the word crypto does not appear in any of their output:

  • test/js/bun/s3/s3.test.ts on Windows 2019 x64-baseline: 15 s R2 upload timeout.
  • test/cli/install/bun-install-registry.test.ts on Windows 11 aarch64: wrong hoisted version.
  • test/js/node/zlib/leak.test.ts on Windows 2019 x64: RSS grew 12.4 MB against a 10 MB threshold.
  • test/napi/napi.test.ts on Windows 2019 x64: napi_wrap > has the right lifetime.
  • test/cli/install/bun-security-scanner-matrix-with-node-modules.test.ts, test/js/bun/webview/webview-chrome.test.ts, and test/js/node/test/parallel/test-net-connect-memleak.js on Alpine lanes.

One result from those lanes is directly relevant to this PR: test/js/node/crypto/node-crypto.test.js ran and passed on all three Windows platforms (it is not in any failure annotation, and every Windows test shard has finished). That includes the new 33-bit buffer-generator DiffieHellman case, which is the one test here with a real before-and-after only on Windows, where unsigned long is 32 bits. Windows CI passing it is the empirical confirmation of the BN_ULONG return-type change that I could not produce on Linux, where unsigned long is already 64 bits.

@dylan-conway
dylan-conway merged commit 27009bf into main Jul 1, 2026
4 of 37 checks passed
@dylan-conway
dylan-conway deleted the farm/caefa09c/ncrypto-upstream-sync branch July 1, 2026 22:28

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the BN_ULONG return type — the fix and the 33-bit Windows regression test look right. Deferring to a human for final sign-off since this touches crypto primitives (DH generator validation, AEAD tag handling) and intentionally diverges from upstream in DataPointer::resize.

Extended reasoning...

Overview

This PR ports six upstream ncrypto correctness fixes from Node.js into src/jsc/bindings/ncrypto.{cpp,h}, updates the three GetWord callers in JSX509Certificate.cpp and JSDiffieHellmanConstructor.cpp for the new std::optional<BN_ULONG> return type, fixes a Bun-local typo in dead non-BoringSSL code, and adds three DiffieHellman generator tests. Each change is individually small and cites the upstream PR.

Prior review

On the first revision I flagged that returning std::optional<unsigned long> still truncated 33–64-bit values on LLP64 (Windows x64). The author fixed this in fbdebd6 by switching to std::optional<BN_ULONG> and added a 33-bit generator test that pins the fix on Windows CI. That thread is resolved and I verified all four GetWord/getWord call sites are within the diff — no stale callers remain.

Security risks

The changes are defensive hardening (null checks, > 0 comparisons, realloc-failure handling, leak fix) plus one semantic change to BN_get_word overflow signalling. None weaken validation — the DH generator check is strictly more permissive only for values that were previously rejected due to truncation/sentinel confusion, which matches Node's intended behaviour. No new attack surface is introduced. However, DataPointer::resize deliberately diverges from upstream (upstream's version leaks the old block on realloc failure; this version frees it with OPENSSL_clear_free) — that divergence is well-reasoned in the description but is exactly the kind of judgment call a human reviewer should confirm.

Level of scrutiny

Crypto bindings warrant human review by policy even when the changes are mechanical upstream ports. Several hunks are in #ifndef OPENSSL_IS_BORINGSSL branches that never compile in Bun's build, and the PR description transparently documents which changes have observable effects vs. which are defensive/dead — but the live changes still touch DH parameter validation and AEAD tag control-return handling.

Other factors

No CODEOWNERS cover these files. The bug-hunter found nothing this run. The author's CI analysis confirms node-crypto.test.js (including the new tests) passed on all Windows lanes, and the remaining CI failures are unrelated repo-wide flakes. The PR description is unusually thorough about what was ported, what was deliberately excluded, and why each change is or isn't observable under BoringSSL.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants