Skip to content

node:tls: check each { pem, passphrase } key entry the way Node's setKey() does - #48132

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/b79b9382/tls-validate-pem-key-entries
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/b79b9382/tls-validate-pem-key-entries

Conversation

@robobun

@robobun robobun commented Nov 7, 2026

Copy link
Copy Markdown
Collaborator

Draft until #46522 merges: this needs its https.createServer() order, HTTP options before TLS options.

Problem

  • An invalid { pem, passphrase } entry of key throws the error of crypto.createPrivateKey(): The property 'options.passphrase' is invalid. Received 5, or one that names "key.key". Node throws ERR_INVALID_ARG_TYPE for options.key or options.passphrase.
  • The cause is normalizePemKeyOption (src/js/internal/tls.ts:339). It checks nothing itself.

Fix

  • normalizePemKeyOption checks the key of each entry, then its passphrase, as Node's setKey() does.
  • A per-key passphrase: null no longer becomes the context passphrase.
  • Verified: node-tls-create-secure-context-args.test.ts (24 rows at 8 entry points, in Bun and in Node, 137 tests fail without the fix), ws.test.ts, node-fetch.test.js.
  • Self-reviewed: 21 concerns raised, 19 addressed. Not done: a test for ws:// URLs, a tracking issue (Notes).

Background

  • The native TLS config has one passphrase for all keys, so Bun decrypts an entry with a passphrase in JS.
  • Considered the checks in the throwOnInvalidTLSArray gate, ahead of the ca check and the pfx keys: 30 of 252 doubly-invalid rows move away from Node.

Downsides

Notes

There is no issue for this. A differential run against Node found it.

The report, tls.createSecureContext({ key: [entry] }):

entry Node v26.3.0 and v26.12.1 main 7ca0042 this PR
{ pem: enc, passphrase: 5 } ERR_INVALID_ARG_TYPE The "options.passphrase" property must be of type string. Received type number (5) ERR_INVALID_ARG_VALUE The property 'options.passphrase' is invalid. Received 5 as Node
{ pem: 5, passphrase: "x" } ERR_INVALID_ARG_TYPE The "options.key" property must be of type string or an instance of Buffer, TypedArray, or DataView. Received type number (5) ERR_INVALID_ARG_TYPE The "key.key" property must be of type string or an instance of ArrayBuffer, Buffer, TypedArray, or DataView. Received type number (5) as Node
{ pem: 5, passphrase: 5 } the same options.key error the same key.key error as Node
{ pem: enc, passphrase: Buffer.from("pw") } ERR_INVALID_ARG_TYPE ... Received an instance of Buffer accepted as Node
{ pem: 5 } the same options.key error ERR_INVALID_ARG_TYPE element of TLSOptions.key must be of type string, ArrayBuffer, or Blob as Node
{ pem: enc, passphrase: "pw" } accepted accepted accepted

Every entry point shares the function and shows the same rows: tls.createSecureContext(), tls.connect(), new tls.TLSSocket(), tls.createServer(), server.setSecureContext(), server.addContext(), https.createServer(), http2.createSecureServer(), and the agent options of ws and node-fetch.

What the function does now. For each entry, in order: the key is entry.pem, or the entry itself when pem is undefined. The passphrase is entry.passphrase, or the passphrase of the context when it is undefined. The key must be a string, a view, an ArrayBuffer, or a BunFile. Then the passphrase must be a string. An entry with a passphrase in effect is decrypted there, so its decrypt error comes before the next entry, as in Node. The native config loads an entry with no passphrase after every entry is checked, so its load error comes after the type error of a later entry (Node: before).

Measured (debug build, merge base 7ca0042 against this branch, Node v26.3.0 and v26.12.1 give identical output):

  • 24-row probe on createSecureContext: 16 -> 8 rows differ from Node.
  • 19 key kinds x 17 passphrase kinds x 4 context passphrases on createSecureContext (1292 cells): equal to Node 152 -> 1001. 228 cells go from accepted to refused (the three kinds under Downsides). 0 go from refused to accepted.
  • 30 entry rows x 8 entry points (240 cells): equal to Node 94 -> 206, 0 from right to wrong.
  • Which error wins with a second invalid TLS option (2308 rows): 0 from right to wrong, 30 from wrong to right.
  • Which error wins when the entry is one that this PR newly refuses and the other option fails only when it is loaded (a certificate that does not parse, an earlier encrypted entry with no passphrase): 77 of 192 cells go from right to wrong. Bun checks types in JS before the native config loads anything. Node loads the certificate and each key in turn.
  • https.createServer / http.createServer with a second invalid HTTP option (432 cells): equal to Node 164 -> 114 on main's layout, 64 from right to wrong. With the HTTP options checked first, as net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests #46522 does: 164 -> 366, 8 from right to wrong.
  • createPrivateKey() calls per entry: unchanged (1 with a passphrase in effect, 0 without). One more for an entry with passphrase: null next to a context passphrase.
  • Cells allocated per tls.createSecureContext() with the JIT off: 8.86 -> 7.86 with no key, 8.44 -> 7.44 with a string key. The loop has no closure. With the JIT on, main loses that allocation too after it tiers up.
  • Release build: bundled builtin JS 2,702,556 -> 2,703,247 bytes (+691, all in internal/tls.js). size build/release/bun: text 140,289,307 -> 140,289,998 (+691), data and bss unchanged, file size unchanged.

Behaviour changes, all of them toward Node:

  • Accepted before, refused now: a per-key passphrase that is a Buffer, a TypedArray, a DataView, an ArrayBuffer or a SharedArrayBuffer (ERR_INVALID_ARG_TYPE). passphrase: null on an encrypted key next to a context passphrase (ERR_OSSL_BAD_DECRYPT). A SharedArrayBuffer pem with a passphrase in effect (ERR_INVALID_ARG_TYPE).
  • Another error for an input that already threw: a passphrase that is a number, a boolean, an object or a String object. A pem that is not a key.
  • Another channel: https.createServer() and http.createServer() throw from the constructor for an entry with no passphrase whose pem is not a key. They emitted 'error' at listen(). The error of a key that does not load still arrives at listen().
  • http.createServer({ key, cert }) serves TLS in Bun on purpose, so it refuses the same entries. Node ignores the key there.
  • ws and node-fetch check the options for ws:// and http:// URLs too. Node never reads key for those. The inputs that are newly refused now throw there.
  • Kept: an ArrayBuffer pem and a BunFile pem stay accepted, as for a key that is not in an entry. Only the native config reads a file, so a BunFile with a passphrase of its own is refused, and a BunFile with passphrase: null still gets the passphrase of the context.

Tests. Without the internal/tls.ts change these fail: 16 rows x 8 entry points, 2 rows x 4 entry points next to an empty pfx list, the http.createServer() test (137 tests), the ws test and the node-fetch test. The rest are guards: the accepted rows, the handshakes, the rows where main already gave Node's error, the caller's array left as written, and no key handed to a setter of Array.prototype. 32 todo tests hold Node's answer for 4 rows that Bun does not give yet. "node gives the same answers" runs every row in Node 26 where it is installed. 23 single-clause mutants of the function each fail at least one test.

Self-review, the two items not done. A test that pins the new throw on a ws:// URL: Node never reads key there, so the test would pin a difference from Node. A tracking issue: the list below is the record.

Not in this change:

  • An array that mixes a PEM and an entry is valid in Node. throwOnInvalidTLSArray refuses it. The same check refuses { pfx, key: [entry] } on tls.createServer(), setSecureContext(), https.createServer() and http2.createSecureServer().
  • { pem } objects in cert, ca and crl, and nested key arrays, still get the native text.
  • pfx: [{ buf, passphrase }] entries are not checked.
  • A context passphrase that no key uses is still checked. Node checks it only when a key uses it.
  • A top-level DataView key is still refused.
  • ws and node-fetch take an entry that is not in an array. Node refuses it.
  • ws throws from the constructor for an option of its own. npm ws emits 'error'.
  • The https-proxy-agent path of ws goes through the same function. No test pins it.

Order with #46522 and #46528. Only src/js/internal/tls.ts changes, and the hunk applies to both heads (ddfd2d3, edb9ce8). A trial merge of this branch adds no conflicted file to either. On #46522's layout newNativeSecureContext calls the function before nativeCiphersAndVersions(), so the cut on top of it has to put the version checks first. Commit d2ee57f on branch robobun/b79b9382/tls-key-entries-with-http-order is an earlier cut for main alone: it also moves storeHTTPOptions ahead of the TLS options in http.Server.

#33535 (not merged) had the same two checks in another place. The variant with the checks in the gate moved 30 of 252 rows away from Node and 40 toward it, refused [{ pem: DataView }], and took an entry outside an array next to pfx at 4 entry points. Native per-key passphrases were also considered: the reported errors come from JS, so that change alone corrects none of them.

Also run: test-tls-passphrase.js, test-tls-basic-validations.js, test-tls-options-boolean-check.js, test-https-options-boolean-check.js and 16 more vendored TLS files. node-tls-server.test.ts, node-tls-context.test.ts, node-https-server.test.ts, ws-proxy.test.ts.

…} entry like Node's setKey()

normalizePemKeyOption() checks the type of the key of an entry and then
the type of its passphrase, with Node's property names, before the key
goes to createPrivateKey() or to the native config. An entry with a
passphrase in effect is decrypted there, so its decrypt error comes
before the next entry, as in Node. The native config loads an entry
with no passphrase after every entry is checked.

A per-key passphrase that is null no longer becomes the passphrase of
the context.
@github-actions github-actions Bot added the claude label Nov 7, 2026
@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:26 PM PT - Nov 7th, 2026

❌ @robobun, your commit 1094f94 has 38 failures in Build #138359 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 48132

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

bun-48132 --bun

@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status (checked on 2026-11-07)

  • Reproduced on main 7ca0042 against Node v26.3.0 and v26.12.1 with tls.createSecureContext({ key: [{ pem: encryptedPem, passphrase: 5 }] }). Bun throws ERR_INVALID_ARG_VALUE: The property 'options.passphrase' is invalid. Received 5. Node throws ERR_INVALID_ARG_TYPE: The "options.passphrase" property must be of type string. Received type number (5).
  • This PR stays a draft until net, tls: consolidate the open node:net, node:tls, Bun.connect, Bun.listen and usockets pull requests #46522 merges. Then I cut it again on top of that layout.
  • CI on 1094f94 (build 138359, finished): the red tests are 27 files under test/aot/ and test/bundler/compile-aot-size.test.ts. Main fails them too. No test of this PR failed on any lane.

This branch has not been deployed

No deployments
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.

1 participant