Skip to content

webcrypto: RSA-PSS saltLength must fit in an int before it reaches OpenSSL - #39974

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/e84083a9/rsa-pss-saltlength-int
Aug 21, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
farm/e84083a9/rsa-pss-saltlength-int

Conversation

@robobun

@robobun robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • crypto.subtle.sign({ name: "RSA-PSS", saltLength: 4294967295 }) signs with a salt of the digest length, and saltLength: 4294967294 signs with the largest salt that fits. crypto.subtle.verify({ name: "RSA-PSS", saltLength: 4294967294 }) returns true for a signature made with any salt length. Node v26 rejects all of these with an OperationError. Bun 1.3.14 behaves the same as main, so this is not a regression.
  • Cause: RsaPssParams.saltLength is a WebIDL unsigned long, stored as size_t. signWithMD and verifyWithMD in src/jsc/bindings/webcrypto/CryptoAlgorithmRSA_PSSOpenSSL.cpp (lines 53 and 99) pass it to EVP_PKEY_CTX_set_rsa_pss_saltlen(), which takes an int. 4294967295 arrives as RSA_PSS_SALTLEN_DIGEST (-1) and 4294967294 as RSA_PSS_SALTLEN_AUTO (-2). Other values at or above 2^31 arrive as other negative numbers, which BoringSSL rejects, so only these two change meaning.

Fix

  • setSaltLength() converts the value in one place for sign and verify. A value above INT_MAX fails, and the callers turn that into the same OperationError they already use for every other setup failure.
  • This is correct because a salt length can never legitimately be that large (a salt has to fit in the modulus), and because a value that does fit in an int is still checked against the key by BoringSSL exactly as before: saltLength: 95 on a 1024-bit key with SHA-256 still fails to sign, and a wrong salt length still verifies as false.
  • Verified with test/js/web/crypto/web-crypto.test.ts ("RSA-PSS saltLength"). On main the four cells for 2^32 - 1 and 2^32 - 2 come back as signed, signed, false and true. The rest of web-crypto.test.ts (95 tests), web-crypto-sha3.test.ts and test/js/node/test/parallel/test-webcrypto-sign-verify.js pass.
  • Related: webcrypto: RSA-PSS saltLength range errors, key-material deep equality, supports() parity (+5 tests) #35374 adds a check in SubtleCrypto.cpp that rejects any saltLength above what the key allows, with Node's ERR_OUT_OF_RANGE cause. That check also covers these two values. This PR fixes the conversion underneath it, in the backend that performs the narrowing, and touches neither of its files. The test here holds with or without webcrypto: RSA-PSS saltLength range errors, key-material deep equality, supports() parity (+5 tests) #35374.

Background

RSA-PSS pads a message with a random salt before it is signed. The salt length is a parameter of both the signing and the verification operation. BoringSSL's setter uses negative values as selectors: -1 means "use the digest length" and -2 means "use the largest salt that fits" when signing and "accept whatever salt length the signature used" when verifying. WebCrypto has no such selectors: saltLength is always an explicit byte count. The WebIDL layer enforces the unsigned long range, so 2^32 and above are already a TypeError; the values just below 2^32 are the ones that survived the range check and then changed meaning in the implicit conversion to int.


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/crypto/web-crypto.test.ts
bun test v1.4.0 (6e906e468)

test/js/web/crypto/web-crypto.test.ts:
(pass) crypto.subtle setter should not throw [3.78ms]
(pass) Web Crypto > keeps event loop alive [329.94ms]
(pass) Web Crypto > has globals [3.95ms]
(pass) Web Crypto > should encrypt and decrypt [16.88ms]
(pass) Web Crypto > should verify and sign [38.19ms]
(pass) Web Crypto > unwrapKey JWK error handling > rejects when wrapped bytes are not valid JSON [15.60ms]
(pass) Web Crypto > unwrapKey JWK error handling > rejects when wrapped bytes are valid JSON but not a valid JWK [10.30ms]
(pass) Web Crypto > unwrapKey JWK error handling > settles when JsonWebKey dictionary conversion itself throws [10.56ms]
(pass) Web Crypto > unwrapKey JWK error handling > does not leak DeferredPromise in m_pendingPromises on JWK parse errors [2555.40ms]
(pass) oversized inputs > rejects >2 GiB inputs instead of aborting [347.39ms]
379 |         "2**32 - 2": await verifySalt20Signature(2 ** 32 - 2),
380 |         "2**31": await verifySalt20Signature(2 
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (6e906e468)

test/js/web/crypto/web-crypto.test.ts:
(pass) crypto.subtle setter should not throw [0.05ms]
(pass) Web Crypto > keeps event loop alive [8.44ms]
(pass) Web Crypto > has globals [0.06ms]
(pass) Web Crypto > should encrypt and decrypt [0.35ms]
(pass) Web Crypto > should verify and sign [0.66ms]
(pass) Web Crypto > unwrapKey JWK error handling > rejects when wrapped bytes are not valid JSON [0.30ms]
(pass) Web Crypto > unwrapKey JWK error handling > rejects when wrapped bytes are valid JSON but not a valid JWK [0.20ms]
(pass) Web Crypto > unwrapKey JWK error handling > settles when JsonWebKey dictionary conversion itself throws [0.18ms]
(pass) Web Crypto > unwrapKey JWK error handling > does not leak DeferredPromise in m_pendingPromises on JWK parse errors [26.05ms]
(pass) oversized inputs > rejects >2 GiB inputs instead of aborting [7.45ms]
379 |         "2**32 - 2": await verifySalt20Signature(2 ** 32 - 2),
380 |         "2**31": await verifySalt20Signature(2 ** 31),
381 |         "32": await verifySalt20Signature(32),
382 |         "20": await verifySalt20Signature(20),
383 |       },
384 |     }).toEqual({
             ^
error:
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/crypto/web-crypto.test.ts
bun test v1.4.0 (6e906e468)

test/js/web/crypto/web-crypto.test.ts:
(pass) crypto.subtle setter should not throw [3.85ms]
(pass) Web Crypto > keeps event loop alive [336.44ms]
(pass) Web Crypto > has globals [3.51ms]
(pass) Web Crypto > should encrypt and decrypt [17.56ms]
(pass) Web Crypto > should verify and sign [40.39ms]
(pass) Web Crypto > unwrapKey JWK error handling > rejects when wrapped bytes are not valid JSON [18.89ms]
(pass) Web Crypto > unwrapKey JWK error handling > rejects when wrapped bytes are valid JSON but not a valid JWK [12.78ms]
(pass) Web Crypto > unwrapKey JWK error handling > settles when JsonWebKey dictionary conversion itself throws [11.03ms]
(pass) Web Crypto > unwrapKey JWK error handling > does not leak DeferredPromise in m_pendingPromises on JWK parse errors [2601.80ms]
(pass) oversized inputs > rejects >2 GiB inputs instead of aborting [353.32ms]
(pass) RSA-PSS saltLength > rejects values that do not fit in an int instead of treating them as the -1/-2 selectors [108.
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 657ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcrypto-0.cpp.o
[2/6] gen cpp.rs (cppbind)
[2/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 2m 58s
[3/6] link bun-profile
[5/6] strip bun
[5/6] bun-profile --revision
1.4.0-canary.1+21d75372c
[build] done
bun test v1.4.0-canary.1 (21d75372c)

test/js/web/crypto/web-crypto.test.ts:
(pass) crypto.subtle setter should not throw [0.05ms]
(pass) Web Crypto > keeps event loop alive [7.94ms]
(pass) Web Crypto > has globals [0.07ms]
(pass) Web Crypto > should encrypt and decrypt [0.38ms]
(pass) Web Crypto > should verify and sign [0.74ms]
(pass) Web Crypto > unwrapKey JWK error handling > rejects when wrapped bytes are not valid JSON [0.44ms]
(pass) Web Crypto > unwrapKey JWK error han
... (truncated)
diff hotspot
.../webcrypto/CryptoAlgorithmRSA_PSSOpenSSL.cpp    | 16 +++++-
 test/js/web/crypto/web-crypto.test.ts              | 64 ++++++++++++++++++++++
 2 files changed, 78 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
…sc/bindings/webcrypto/CryptoAlgorithmRSA_PSSOpenSSL.cpp      1      4      0
test/js/web/crypto/web-crypto.test.ts                         1      1      0

…enSSL

RsaPssParams.saltLength is an unsigned long, but both sign and verify
passed it straight to EVP_PKEY_CTX_set_rsa_pss_saltlen(), which takes
an int. 4294967295 became RSA_PSS_SALTLEN_DIGEST and 4294967294 became
RSA_PSS_SALTLEN_AUTO: sign used a salt of the digest length or the
maximum length and reported success, and verify with 4294967294
accepted a signature made with any salt length. Node rejects both with
an OperationError.

Convert the value in one place and fail with OperationError when it
does not fit. Values that fit are still checked against the key by
BoringSSL as before.
@coderabbitai

coderabbitai Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Your included review limit has been reached.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 30 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset (next review available in 14 minutes), then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 548cae13-adae-46e2-91f9-1cf47e4c5d26

📥 Commits

Reviewing files that changed from the base of the PR and between 4448a2e and 21d7537.

📒 Files selected for processing (2)
  • src/jsc/bindings/webcrypto/CryptoAlgorithmRSA_PSSOpenSSL.cpp
  • test/js/web/crypto/web-crypto.test.ts

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

@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:32 AM PT - Aug 21st, 2026

❌ @robobun, your commit 21d7537 has 2 failures in Build #102689 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 39974

That installs a local version of the PR into your bun-39974 executable, so you can run:

bun-39974 --bun

@robobun

robobun commented Aug 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green on its own tests on every lane. The red suites in the CI run (test/cli/install/migration/complex-workspace.test.ts on every lane, test/cli/install/bun-install.test.ts gitlab clones returning HTTP 403) do not touch this change and are reported separately.

Reproduced with (Bun 1.4 main and 1.3.14; Node v26.3.0 rejects every line below with an OperationError):

const k = await crypto.subtle.generateKey({ name: "RSA-PSS", modulusLength: 1024, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, false, ["sign", "verify"]);
const d = new TextEncoder().encode("hello");
const s20 = await crypto.subtle.sign({ name: "RSA-PSS", saltLength: 20 }, k.privateKey, d);
const sX = await crypto.subtle.sign({ name: "RSA-PSS", saltLength: 2 ** 32 - 1 }, k.privateKey, d);            // bun: signs
console.log(await crypto.subtle.verify({ name: "RSA-PSS", saltLength: 32 }, k.publicKey, sX, d));             // bun: true, salt 32 was used
console.log(await crypto.subtle.verify({ name: "RSA-PSS", saltLength: 2 ** 32 - 2 }, k.publicKey, s20, d));   // bun: true, any salt accepted

Test: test/js/web/crypto/web-crypto.test.ts, "RSA-PSS saltLength". Fails on main in the four 2^32 - 1 / 2^32 - 2 cells.

Related: #35374 adds a key-size range check above this layer; the two changes are independent.

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

LGTM — focused bounds check that stops the size_t→int narrowing from turning saltLength into BoringSSL's -1/-2 selectors.

What was reviewed

  • setSaltLength returns 0 for values > INT_MAX; both callers already treat <= 0 as OperationError, so error handling is unchanged.
  • Confirmed CryptoAlgorithmRsaPssParams::saltLength is size_t and the OpenSSL setter takes int with -1/-2 as sentinels — the wrap-to-negative was real.
  • Test covers the 2^32-1 / 2^32-2 selectors, the 2^31 / 2^31-1 boundary, the key-size boundary (94/95), and that a wrong-but-valid salt length still verifies as false rather than erroring.
Extended reasoning...

Overview

Two files: CryptoAlgorithmRSA_PSSOpenSSL.cpp gains a static setSaltLength helper (bounds-check + explicit cast) and both call sites in signWithMD/verifyWithMD switch to it; web-crypto.test.ts gains one describe block exercising the boundaries. Net native change is ~10 lines.

Security risks

This is crypto code, but the change strictly tightens input validation. Before the fix, saltLength: 2**32-2 on verify wrapped to RSA_PSS_SALTLEN_AUTO and accepted a signature made with any salt length — a correctness/security relaxation the caller never asked for. The fix rejects out-of-int-range values with the same OperationError the surrounding setup calls already use. No new attack surface, no weakened checks. Values that fit in an int continue to reach BoringSSL unchanged, so the existing key-size check (95 on a 1024-bit SHA-256 key still fails) is preserved.

Level of scrutiny

Low-to-medium. The mechanism is a textbook implicit-narrowing bug and the fix is the textbook remedy (compare in the wide type, cast last, fail on overflow). The helper matches file-local conventions exactly: same <= 0 → Exception { OperationError } pattern as every neighboring EVP call. std::numeric_limits<int>::max() cast to size_t is well-defined on every supported target. No CODEOWNERS entries cover either file.

Other factors

The test is well-constructed: it asserts exact outcomes for both sign and verify across the wraparound values (2^32-1, 2^32-2), the int boundary (2^31 vs 2^31-1), the key-capacity boundary (94 vs 95), and confirms normal operation still works (salt 20 signs and verifies true, salt 32 verifies false — not an error). PR description states it fails on main in the four expected cells. No prior reviews from me; no outstanding human comments.

@Jarred-Sumner
Jarred-Sumner merged commit b4e645c into main Aug 21, 2026
7 of 9 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/e84083a9/rsa-pss-saltlength-int branch August 21, 2026 21:18
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