@blamejs/core 0.17.12 → 0.17.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.17.x
10
10
 
11
+ - v0.17.13 (2026-07-23) — **A cross-surface hardening sweep: the log pipeline redacts secrets embedded in a message string, the SSRF guard gains a hostname-aware loopback classifier, crypto gains an RFC 7469 SPKI pin, and a batch of cross-platform lifecycle bugs across the watcher, self-update, daemon, and WebSocket-client primitives are fixed.** The structured-log pipeline redacted metadata but shipped the free-text message verbatim, so a secret interpolated into a log message reached file and remote sinks unscrubbed; a new in-place free-text redactor closes that at the emit chokepoint and every sink. The SSRF guard grows a hostname-aware loopback classifier so consumers stop re-deriving the localhost/127.0.0.1/::1 triad, crypto grows an RFC 7469 SubjectPublicKeyInfo pin plus a pin-verifying checkServerIdentity builder, and the self-update verifier now accepts the same SHA3-512 / IEEE-P1363 ECDSA signatures its standalone counterpart does. Alongside: cross-platform lifecycle fixes in the recursive watcher (long-path stat, lost-root signalling, dead-handle classification, case-folded ignore matching), the self-updater (Windows in-use-binary replacement, asset-to-signature pairing, post-install probation), the daemon (detached-spawn failure reporting, a Windows cooperative-stop channel, a read-only status probe, a pid-lock adopt window), and the WebSocket client (a close during a slow initial dial no longer opens an orphan socket). **Added:** *New primitives across the watcher, self-update, daemon, crypto, SSRF, redaction, and object surfaces* — b.watcher exports its ignore-pattern length and wildcard caps so a consumer pre-filter aligns with what create() enforces. b.selfUpdate gains a post-install probation lifecycle (beginProbation / confirmHealthy / evaluateOnBoot) that distinguishes a clean stop from a crash and decides a next-boot rollback. b.daemon gains a read-only status() PID-probe and, on Windows, a cooperative stop-request channel so stop() can drain the graceful-shutdown orchestration instead of a hard terminate. b.crypto adds spkiPin / spkiPinVerifier (above). b.ssrfGuard adds isLoopbackHost / isExactLoopbackName (above). b.redact adds redactText (above). And b.safeObject.ownProp / ownSet provide one prototype-pollution-safe own-property get/set that the framework's interpolators compose instead of re-rolling a guarded read. **Changed:** *Public Suffix List refreshed to the current upstream snapshot* — The vendored Mozilla Public Suffix List is updated to the current upstream snapshot, so organizational-domain derivation reflects the latest registry delegations wherever the framework draws a registrable-domain boundary -- DMARC identifier alignment, BIMI issuer scoping, cookie-scope confinement, and same-site policy. The .data.js carrier is regenerated and re-signed across all four integrity layers. **Fixed:** *Recursive watcher: long paths, lost roots, dead handles, case-folded ignores* — b.watcher no longer misreads a change on a Windows path over MAX_PATH as a delete (the post-event stat is long-path-aware); a poll walk that can no longer read the ROOT surfaces a distinct fatal `watcher/root-lost` signal and keeps its prior snapshot instead of diffing an empty tree as a delete of every tracked entry; a native-handle error whose code leaves change detection permanently off is classified fatal (`watcher/handle-dead`) so a wrapper can recreate rather than treat it as transient; and an opt-in `ignoreCaseFold` folds the walk-prune to match a case-insensitive filesystem so an on-disk `Node_Modules` is pruned by a `node_modules/**` ignore. · *Self-update: Windows in-use-binary replacement and asset-to-signature pairing* — swap/rollback install by moving the existing target aside and placing the new bytes at the freed path, so a self-updating Windows daemon can replace its own running image (which the OS locks against an overwrite-in-place); the backup and rollback share the same relocation. And poll now pairs the returned signature to the returned asset by the detached-signature name relationship rather than two independent first-matches, failing closed when the pairing is ambiguous, so a release with several sidecars per binary can no longer hand a foreign signature to the verifier. · *Daemon: detached-spawn failure is reported, pid-lock adopts without a gap* — b.daemon.start's detached mode validated the child pid before writing the pidfile and now subscribes the child's async error so a failed spawn (a bad command) throws instead of returning success and writing an `undefined` pid. And appShutdown's pid-lock adopts an existing pidfile that already records the current process id (the detached parent-handoff) instead of unlinking and recreating it -- closing a window in which the pidfile briefly did not exist and a concurrent stop misread a running daemon as not-running. · *WebSocket client: a close during a slow initial dial opens no orphan socket* — b.wsClient.connect ran its post-SSRF-resolve dial continuation even when close() was called during the (asynchronous, over-a-second) SSRF re-resolve, opening a socket and heartbeat nobody owned. The reconnect path already re-checked the closed flag after its await; the initial dial now does too (guarded at the shared dial funnel), and a close() while still connecting retires immediately rather than leaving the flag unset across the graceful-close window. · *crypto hash error preserves its errno; PQC agent mirrors the curve into TLS groups* — b.crypto.hashFilesParallel now preserves the underlying fs error's `code` / `errno` / `path` on the pre-open stat failure (and gives the symlink-refusal, non-regular-file, and size-cap rejections stable codes) so a caller can tell a benign save-then-delete race from a real read failure without string-matching a message. And b.pqcAgent.create mirrors a caller's `ecdhCurve` into the TLS `groups` option, so narrowing or reordering the curve preference is actually advertised on builds that honor `groups`. · *Reserved-word identifier parity for operator-supplied table and column names* — Several sites that quote an operator- or schema-supplied identifier -- CSV export, the outbox and webhook table-name guards, the DSR ticket store, the Postgres row-level-security emitter, and the view declarator -- rejected a schema-valid name that collides with a SQL keyword, stricter than b.db.from() which permits it. Those sites now match db.from()'s parity (still failing closed on shape, length, null bytes, and the reserved table prefix). **Security:** *The log pipeline redacts secrets embedded in a message string* — b.logStream.emit ran the redactor over structured `meta` but wrote the free-text `message` verbatim, so a credential interpolated into a log line -- a JWT, an AWS access key, a URL-embedded password -- reached file, webhook, syslog, CloudWatch, and OTLP sinks unscrubbed. And the built-in value detectors are whole-value anchored, so even redacting the message with the structured redactor would miss a token embedded mid-sentence. A new `b.redact.redactText` scrubs credentials in place with word-boundary matching (PEM blocks, JWTs, AWS keys, URL-userinfo passwords, bearer tokens, key=secret assignments, SSN/EIN, Luhn-valid PANs) while keeping the surrounding message, deliberately excluding the high-entropy shape detector that would eat ordinary identifiers in prose. It is applied at the emit chokepoint (covering every sink) and at the OTLP encoders' direct path, and is drop-safe on the hot path. · *The SSRF guard gains a hostname-aware loopback classifier* — b.ssrfGuard.classify recognized only IP literals, so `localhost`, `*.localhost`, and bracketed IPv6 forms were not classified as loopback and every consumer re-derived the strip-brackets / strip-trailing-dot / special-case triad by hand. `b.ssrfGuard.isLoopbackHost` composes canonicalization plus classification plus the RFC 6761 reserved names (`localhost` and `*.localhost`); a companion `b.ssrfGuard.isExactLoopbackName` omits the subdomain reservation for the OAuth loopback-redirect exception, which must accept only the exact `localhost` name and never widen to a `*.localhost` an attacker could register. Neither resolves DNS. · *Crypto gains an RFC 7469 SPKI pin and a pin-verifying identity check* — There was no primitive producing the HPKP / RFC 7469 pin -- the base64 SHA-256 over a certificate's SubjectPublicKeyInfo -- so consumers hand-derived it at both the verify and the report site, where drift mints a never-matching pin. `b.crypto.spkiPin` returns that pin (composing the certificate's SPKI export), and `b.crypto.spkiPinVerifier` returns a checkServerIdentity-compatible function that runs the RFC 9525 hostname check first, then constant-time-compares the peer's SPKI pin against a supplied set (requiring the RFC 7469 backup pin). The pin binds the certificate's long-term key, not the ephemeral post-quantum key-exchange group. · *The self-update verifier accepts the release-signing ECDSA form* — b.selfUpdate.verify delegated to an algorithm-agnostic verify that, for an EC P-384 key, pinned neither the SHA3-512 digest nor the raw IEEE-P1363 signature encoding the release pipeline emits -- so a sidecar signed the way the standalone verifier and install script expect only verified through that standalone path, even inside a fully-installed framework. verify now routes through the same streaming SHA3-512 / DER-or-P1363 verifier, collapsing the two into one acceptance set and dropping the whole-asset in-memory buffer.
12
+
11
13
  - v0.17.12 (2026-07-22) — **The vendored Public Suffix List is refreshed to the current upstream snapshot.** The vendored Mozilla Public Suffix List is updated to the current upstream build, so organizational-domain derivation for DMARC identifier alignment, BIMI issuer scoping, cookie-scope confinement, and same-site policy reflects the current registry delegations. The data module is regenerated and re-signed across all four integrity layers, the manifest hashes are refreshed, and the NOTICE attribution date is updated. **Security:** *Public Suffix List refreshed to the current upstream snapshot* — The vendored Mozilla Public Suffix List is updated to the current upstream snapshot, so organizational-domain derivation reflects the current registry delegations everywhere the framework draws a registrable-domain boundary -- DMARC identifier alignment, BIMI issuer scoping, cookie-scope confinement, and same-site policy. The precise upstream commit and version are recorded in the signed data file and the vendor manifest. The .data.js carrier is regenerated and re-signed across all four integrity layers (SHA-256 + SHA3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary), the manifest hashes are refreshed, and the NOTICE attribution date is updated.
12
14
 
13
15
  - v0.17.11 (2026-07-17) — **The DER decoder rejects a non-minimal length encoding, and the OAuth client-attestation verifier pins the JWT type so a JWT minted for another purpose can't be replayed into the attestation slot.** The shared ASN.1 DER decoder accepted a non-minimal long-form length encoding (a long form used for a value that fits the short form, or a length carrying a leading-zero octet), a BER/DER parser-differential an attacker could use to smuggle an alternate encoding of a certificate, CMS structure, or OCSP response past the strict decoder. And the OAuth client-attestation verifier did not pin the JOSE type header of the attestation and proof-of-possession JWTs, so a JWT minted for another purpose but signed by the same key could be replayed into the attestation slot. **Security:** *DER decoder rejects a non-minimal length encoding* — b.asn1Der is the shared DER reader every certificate, CMS, and OCSP parse in the framework routes through. X.690 §10.1 requires the minimum number of length octets: the long form may be used only for lengths of 128 or more, and must carry no leading-zero octet. The decoder accepted both non-minimal forms -- a long-form length for a value that fits the short form, and a long-form length with a leading zero -- which is a BER/DER parser-differential an attacker can use to encode the same structure two ways and slip an alternate encoding past a strict verifier that canonicalizes differently. The decoder already refused every other BER-ism it encounters (indefinite-length, non-minimal OID sub-identifiers, non-minimal high-tag-number tags); it now also refuses a non-minimal length, closing the remaining differential. · *OAuth client-attestation verifier pins the JWT type* — b.auth.oauth's attestation-based client authentication (draft-ietf-oauth-attestation-based-client-auth) verifies two JWTs -- the client-attestation JWT and its proof-of-possession JWT -- each of which carries a REQUIRED, distinct JOSE type header (oauth-client-attestation+jwt and oauth-client-attestation-pop+jwt). The verifier checked the signature and claims but not the type header, so a JWT minted for a different purpose but signed by the same key -- a private_key_jwt client assertion, or the other proof-of-possession JWT -- could be replayed into the attestation or PoP slot (the cross-JWT confused-deputy class RFC 8725 §3.11 explicit typing defends against). The verifier now pins the expected type for each slot, and the builders emit the same literals so a produced and a checked type can never drift; the framework's DPoP and back-channel-logout JWT verifiers already pin their type, and the attestation verifier now matches.
package/NOTICE CHANGED
@@ -90,7 +90,7 @@ Used for: Top-10000 most-common (breach-derived) passwords. Loaded by
90
90
  baseline.
91
91
  --------------------------------------------------------------------------------
92
92
  Component: publicsuffix-list (Mozilla Public Suffix List)
93
- Version: master snapshot (bundled 2026-07-22)
93
+ Version: master snapshot (bundled 2026-07-23)
94
94
  Source: https://publicsuffix.org/list/public_suffix_list.dat
95
95
  License: MPL-2.0
96
96
  Copyright: Copyright (c) Mozilla Foundation and Public Suffix List contributors
package/index.js CHANGED
@@ -128,6 +128,7 @@ var sql = require("./lib/sql");
128
128
  var chainWriter = require("./lib/chain-writer");
129
129
  var codepointClass = require("./lib/codepoint-class");
130
130
  var safeBuffer = require("./lib/safe-buffer");
131
+ var safeObject = require("./lib/safe-object");
131
132
  var safeDecompress = require("./lib/safe-decompress").safeDecompress;
132
133
  var safeMountInfo = require("./lib/safe-mount-info");
133
134
  var lazyRequire = require("./lib/lazy-require");
@@ -539,6 +540,7 @@ module.exports = {
539
540
  chainWriter: chainWriter,
540
541
  codepointClass: codepointClass,
541
542
  safeBuffer: safeBuffer,
543
+ safeObject: safeObject,
542
544
  safeDecompress: safeDecompress,
543
545
  safeMountInfo: safeMountInfo,
544
546
  lazyRequire: lazyRequire,
@@ -45,6 +45,7 @@
45
45
 
46
46
  var safeAsync = require("./safe-async");
47
47
  var numericBounds = require("./numeric-bounds");
48
+ var pidProbe = require("./pid-probe");
48
49
  var validateOpts = require("./validate-opts");
49
50
  var tracing = null;
50
51
  try { tracing = require("./tracing"); } catch (_e) { /* tracing optional */ }
@@ -547,35 +548,37 @@ function pidLock(lockPath) {
547
548
  var fd = null;
548
549
  var ownsLock = false;
549
550
 
550
- function _isLivePid(pid) {
551
- if (!pid || pid <= 0) return false;
552
- try { process.kill(pid, 0); return true; } // signal 0 = existence-check
553
- catch (e) { return e.code === "EPERM"; } // EPERM means process exists, just no rights
554
- }
555
-
556
- function _readExisting() {
557
- try {
558
- // fd-safe + capped + symlink-refusing read: a PID lockfile is never a
559
- // legitimate symlink (unlike a k8s/certbot secret mount), so refuseSymlink
560
- // is safe here and stops a planted symlink/oversized file from redirecting
561
- // or OOM-ing the read. Any throw (symlink/too-large/enoent) → null, the
562
- // existing "no live lock" semantic.
563
- var raw = atomicFile.fdSafeReadSync(lockPath, { maxBytes: C.BYTES.kib(1), refuseSymlink: true, encoding: "utf8" });
564
- var pid = parseInt(String(raw).trim(), 10);
565
- return isFinite(pid) && pid > 0 ? pid : null;
566
- } catch (_e) { return null; }
567
- }
551
+ // Signal-0 liveness probe + fd-safe pidfile reader are the shared
552
+ // lib/pid-probe primitive (b.daemon carried a byte-identical copy).
553
+ function _readExisting() { return pidProbe.readPidFile(lockPath); }
568
554
 
569
555
  function acquire() {
570
556
  if (ownsLock) return;
571
557
  nodeFs.mkdirSync(nodePath.dirname(lockPath), { recursive: true });
572
558
  var existing = _readExisting();
573
- if (existing && _isLivePid(existing) && existing !== process.pid) {
559
+ if (existing && pidProbe.isLivePid(existing) && existing !== process.pid) {
574
560
  throw new AppShutdownError("app-shutdown/pidlock-held",
575
561
  "pidLock: '" + lockPath + "' already held by live PID " + existing);
576
562
  }
563
+ if (existing === process.pid) {
564
+ // The lockfile already records OUR PID — a parent wrote it and handed off,
565
+ // or start() re-acquires after leaving its own live pidfile. ADOPT the
566
+ // existing inode in place: the reap path below would unlink + O_EXCL-
567
+ // recreate, opening a no-pidfile window a concurrent daemon.stop misreads
568
+ // as "no-pidfile". openNoFollowSync holds the inode (a symlink at lockPath
569
+ // is refused with ELOOP) so we own the lock without ever removing the
570
+ // file. release() closes this fd + unlinks as usual.
571
+ try {
572
+ fd = atomicFile.openNoFollowSync(lockPath);
573
+ } catch (e) {
574
+ throw new AppShutdownError("app-shutdown/pidlock-open-failed",
575
+ "pidLock: failed to adopt self-held '" + lockPath + "': " + e.message);
576
+ }
577
+ ownsLock = true;
578
+ return;
579
+ }
577
580
  if (existing) {
578
- // Stale lock — owner is dead. Reap.
581
+ // Stale lock — owner is dead (foreign PID that is no longer live). Reap.
579
582
  try { nodeFs.unlinkSync(lockPath); } catch (_e) { /* race: someone else reaped it */ }
580
583
  }
581
584
  try {
package/lib/audit.js CHANGED
@@ -1799,12 +1799,14 @@ function generateActorBindingTriggerSql(opts) {
1799
1799
  // so operator-supplied opts.column / opts.tableName / opts.roleMappingFn
1800
1800
  // can't reach raw concatenation. PostgreSQL + SQLite both use the
1801
1801
  // double-quote dialect.
1802
- var qColumn = safeSql.quoteIdentifier(columnRaw, "postgres");
1803
- var qTable = safeSql.quoteIdentifier(tableNameRaw, "postgres");
1804
- var qFn = safeSql.quoteIdentifier(fnNameRaw, "postgres");
1805
- var qTrig = safeSql.quoteIdentifier(trigNameRaw, "postgres");
1802
+ // allowReserved: parity with b.db.from() — an operator column/table/function
1803
+ // named as a SQL keyword must quote here too (safe once quoted).
1804
+ var qColumn = safeSql.quoteIdentifier(columnRaw, "postgres", { allowReserved: true });
1805
+ var qTable = safeSql.quoteIdentifier(tableNameRaw, "postgres", { allowReserved: true });
1806
+ var qFn = safeSql.quoteIdentifier(fnNameRaw, "postgres", { allowReserved: true });
1807
+ var qTrig = safeSql.quoteIdentifier(trigNameRaw, "postgres", { allowReserved: true });
1806
1808
  var qRoleMapFn = opts.roleMappingFn
1807
- ? safeSql.quoteIdentifier(opts.roleMappingFn, "postgres")
1809
+ ? safeSql.quoteIdentifier(opts.roleMappingFn, "postgres", { allowReserved: true })
1808
1810
  : null;
1809
1811
  var allowList = allowRoles.length === 0 ? "" :
1810
1812
  " IF current_user IN (" +
@@ -32,6 +32,7 @@
32
32
  var nodeCrypto = require("node:crypto");
33
33
  var bCrypto = require("./crypto");
34
34
  var safeBuffer = require("./safe-buffer");
35
+ var safeObject = require("./safe-object");
35
36
  var structuredFields = require("./structured-fields");
36
37
  var validateOpts = require("./validate-opts");
37
38
  var { defineClass } = require("./framework-error");
@@ -104,7 +105,7 @@ function create(body, opts) {
104
105
  // hasOwnProperty: the algorithm name is operator/caller input; a bracket
105
106
  // lookup lets "constructor"/"toString" inherit a truthy value off the
106
107
  // prototype and pass the support check (proto shadowing).
107
- var nodeAlg = Object.prototype.hasOwnProperty.call(ACTIVE, name) ? ACTIVE[name] : undefined;
108
+ var nodeAlg = safeObject.ownProp(ACTIVE, name);
108
109
  if (!nodeAlg) {
109
110
  if (Object.prototype.hasOwnProperty.call(DEPRECATED, name)) throw new ContentDigestError("content-digest/insecure-algorithm", "contentDigest.create: '" + name + "' is a deprecated/insecure digest algorithm (RFC 9530 §6); use sha-256 or sha-512");
110
111
  throw new ContentDigestError("content-digest/unsupported-algorithm", "contentDigest.create: unsupported digest algorithm '" + name + "'");
package/lib/crypto.js CHANGED
@@ -68,6 +68,10 @@ var safeBuffer = lazyRequire(function () { return require("./safe-buffer"); });
68
68
  // pqc-software requires this module (b.crypto) — lazy-load to break the
69
69
  // cycle. Only the power-on self-test needs it here.
70
70
  var pqcSoftware = lazyRequire(function () { return require("./pqc-software"); });
71
+ // network-tls requires this module (b.crypto) at top-of-file — lazy-load
72
+ // to break the cycle. Only spkiPinVerifier composes its RFC 9525 strict
73
+ // hostname/SAN matcher.
74
+ var networkTls = lazyRequire(function () { return require("./network-tls"); });
71
75
 
72
76
  // Streaming-hash algorithm allowlist. Mirrors the framework's PQC-
73
77
  // first crypto policy: SHA3 / SHAKE family is the default surface;
@@ -198,19 +202,38 @@ function _hashFileMulti(filePath, algorithms, opts) {
198
202
  var st;
199
203
  try { st = nodeFs.lstatSync(filePath); }
200
204
  catch (statErr) {
201
- reject(new Error("crypto.hashFilesParallel: stat failed for '" +
202
- filePath + "': " + (statErr && statErr.message ? statErr.message : String(statErr))));
205
+ // Preserve the underlying fs error metadata (.code / .errno /
206
+ // .syscall / .path) so callers branch on ENOENT vs EACCES vs
207
+ // ELOOP rather than string-matching the wrapped message. The
208
+ // mid-read `stream.on("error", reject)` path below rejects the
209
+ // raw fs error and already carries .code; wrapping the pre-open
210
+ // stat error WITHOUT copying it forward made the observable
211
+ // failure shape nondeterministic by timing. Match atomic-file's
212
+ // posture of surfacing the raw fs code on the ENOENT-class path.
213
+ var statWrapped = new Error("crypto.hashFilesParallel: stat failed for '" +
214
+ filePath + "': " + (statErr && statErr.message ? statErr.message : String(statErr)));
215
+ if (statErr) {
216
+ statWrapped.code = statErr.code;
217
+ statWrapped.errno = statErr.errno;
218
+ statWrapped.syscall = statErr.syscall;
219
+ statWrapped.cause = statErr;
220
+ }
221
+ statWrapped.path = filePath;
222
+ reject(statWrapped);
203
223
  return;
204
224
  }
205
225
  if (st.isSymbolicLink() && !followSymlink) {
206
- reject(new Error("crypto.hashFilesParallel: refusing symlink '" +
226
+ var symErr = new Error("crypto.hashFilesParallel: refusing symlink '" +
207
227
  filePath + "' — pass {followSymlinks: true} to opt in (an attacker " +
208
228
  "with write access to the input list can otherwise direct the hasher " +
209
- "to files the caller cannot read directly)"));
229
+ "to files the caller cannot read directly)");
230
+ symErr.code = "crypto/hash-refused-symlink";
231
+ symErr.path = filePath;
232
+ reject(symErr);
210
233
  return;
211
234
  }
212
235
  if (!st.isFile() && !st.isSymbolicLink()) {
213
- reject(new Error("crypto.hashFilesParallel: refusing non-regular file '" +
236
+ var nonRegularErr = new Error("crypto.hashFilesParallel: refusing non-regular file '" +
214
237
  filePath + "' (FIFOs / sockets / character / block devices read indefinitely " +
215
238
  "or return platform-undefined bytes; hashing them is meaningless and " +
216
239
  "DoS-prone). Type: " +
@@ -218,7 +241,10 @@ function _hashFileMulti(filePath, algorithms, opts) {
218
241
  st.isSocket() ? "socket" :
219
242
  st.isBlockDevice() ? "block-device" :
220
243
  st.isCharacterDevice() ? "char-device" :
221
- st.isDirectory() ? "directory" : "unknown")));
244
+ st.isDirectory() ? "directory" : "unknown"));
245
+ nonRegularErr.code = "crypto/hash-refused-non-regular-file";
246
+ nonRegularErr.path = filePath;
247
+ reject(nonRegularErr);
222
248
  return;
223
249
  }
224
250
  var hashers = new Array(algorithms.length);
@@ -240,9 +266,12 @@ function _hashFileMulti(filePath, algorithms, opts) {
240
266
  if (maxBytes && byteLength > maxBytes) {
241
267
  aborted = true;
242
268
  try { stream.destroy(); } catch (_e) { /* best-effort */ }
243
- reject(new Error("crypto.hashFilesParallel: file '" + filePath +
269
+ var maxBytesErr = new Error("crypto.hashFilesParallel: file '" + filePath +
244
270
  "' exceeded opts.maxBytesPerFile (" + maxBytes +
245
- " bytes); refusing to continue hashing"));
271
+ " bytes); refusing to continue hashing");
272
+ maxBytesErr.code = "crypto/hash-max-bytes-exceeded";
273
+ maxBytesErr.path = filePath;
274
+ reject(maxBytesErr);
246
275
  return;
247
276
  }
248
277
  for (var j = 0; j < hashers.length; j += 1) hashers[j].update(chunk);
@@ -2042,6 +2071,219 @@ function isCertRevoked(pemOrDer, denyList) {
2042
2071
  return false;
2043
2072
  }
2044
2073
 
2074
+ // ---- RFC 7469 SubjectPublicKeyInfo pinning ----
2075
+ //
2076
+ // The pin is base64(SHA-256(SPKI DER)) — RFC 7469 §2.4 / HPKP. This
2077
+ // deliberately uses SHA-256, which is otherwise absent from the b.crypto
2078
+ // surface (see the @module intro): it is an interop WIRE CONSTANT, not a
2079
+ // framework hashing choice. Browsers, curl `--pinnedpubkey sha256//...`,
2080
+ // OpenSSL, and every HPKP-style pin store speak SHA-256(SPKI); a SHA3-512
2081
+ // pin would not interoperate with any of them. `hashCertFingerprint`
2082
+ // documents the same "framework-canonical hash vs interop rendering"
2083
+ // split. The pin binds the certificate's LONG-TERM SubjectPublicKeyInfo
2084
+ // (which survives certificate reissue on the same key) — it says nothing
2085
+ // about the ephemeral PQC TLS key-exchange group negotiated per
2086
+ // handshake; those are orthogonal (identity of the peer key vs secrecy
2087
+ // of the session).
2088
+ var SPKI_PIN_PREFIX = "sha256/";
2089
+
2090
+ // Decode a `sha256/<base64>` pin string into its raw 32-byte digest
2091
+ // Buffer, throwing a TypeError on any malformed entry (config-time /
2092
+ // entry-point tier — the operator catches the typo at construction).
2093
+ function _decodeSpkiPin(pinStr, ctx) {
2094
+ if (typeof pinStr !== "string" || pinStr.length === 0) {
2095
+ throw new TypeError(ctx + ": each pin must be a non-empty 'sha256/<base64>' string");
2096
+ }
2097
+ if (pinStr.indexOf(SPKI_PIN_PREFIX) !== 0) {
2098
+ throw new TypeError(ctx + ": pin '" + pinStr +
2099
+ "' must carry the RFC 7469 'sha256/' prefix");
2100
+ }
2101
+ var body = pinStr.slice(SPKI_PIN_PREFIX.length);
2102
+ var buf = Buffer.from(body, "base64");
2103
+ // SHA-256 digest = 32 bytes; require a canonical base64 round-trip so a
2104
+ // silently-truncated / non-canonical body is refused rather than
2105
+ // matching a shorter buffer.
2106
+ if (buf.length !== 32 || buf.toString("base64") !== body) {
2107
+ throw new TypeError(ctx + ": pin '" + pinStr +
2108
+ "' must be base64 of a 32-byte SHA-256 SPKI digest");
2109
+ }
2110
+ return buf;
2111
+ }
2112
+
2113
+ /**
2114
+ * @primitive b.crypto.spkiPin
2115
+ * @signature b.crypto.spkiPin(pemOrDer)
2116
+ * @since 0.17.13
2117
+ * @status stable
2118
+ * @related b.crypto.spkiPinVerifier, b.crypto.hashCertFingerprint
2119
+ *
2120
+ * Computes the RFC 7469 (HPKP §2.4) public-key pin of an X.509
2121
+ * certificate: `base64(SHA-256(SubjectPublicKeyInfo DER))`. Accepts DER
2122
+ * bytes (Buffer) or a PEM string (BEGIN/END envelope stripped, base64
2123
+ * body decoded — same 64 KiB ReDoS cap as `hashCertFingerprint`).
2124
+ * Returns `{ sha256, b64, hex }`: `sha256` is the `sha256/<base64>` wire
2125
+ * form browsers and `curl --pinnedpubkey` render, `b64` the bare base64
2126
+ * body, `hex` the lowercase-hex digest.
2127
+ *
2128
+ * Unlike `hashCertFingerprint` (SHA3-512 over the WHOLE certificate), an
2129
+ * SPKI pin binds only the public key, so it survives certificate reissue
2130
+ * on the same key pair — the property RFC 7469 pinning relies on.
2131
+ *
2132
+ * This is the one place SHA-256 appears on the b.crypto surface: it is an
2133
+ * RFC 7469 interop wire constant (browsers / curl / OpenSSL pin stores),
2134
+ * not a framework hashing default. The pin binds the peer's long-term
2135
+ * SPKI, not the ephemeral PQC key-exchange group negotiated per TLS
2136
+ * handshake.
2137
+ *
2138
+ * @example
2139
+ * var fs = require("fs");
2140
+ * var pem = fs.readFileSync("/etc/ssl/peer.cert.pem", "utf8");
2141
+ * var pin = b.crypto.spkiPin(pem);
2142
+ * pin.sha256;
2143
+ * // → "sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg="
2144
+ */
2145
+ function spkiPin(pemOrDer) {
2146
+ var der = _pemToDer(pemOrDer);
2147
+ // SubjectPublicKeyInfo DER — the exact bytes RFC 7469 §2.4 hashes.
2148
+ var spkiDer = new nodeCrypto.X509Certificate(der)
2149
+ .publicKey.export({ type: "spki", format: "der" });
2150
+ var digest = nodeCrypto.createHash("sha256").update(spkiDer).digest();
2151
+ var b64 = digest.toString("base64");
2152
+ return { sha256: SPKI_PIN_PREFIX + b64, b64: b64, hex: digest.toString("hex") };
2153
+ }
2154
+
2155
+ /**
2156
+ * @primitive b.crypto.spkiPinVerifier
2157
+ * @signature b.crypto.spkiPinVerifier(opts)
2158
+ * @since 0.17.13
2159
+ * @status stable
2160
+ * @related b.crypto.spkiPin, b.network.tls.checkServerIdentity9525
2161
+ *
2162
+ * Builds a `tls.checkServerIdentity`-compatible `(host, cert) =>
2163
+ * Error | undefined` that enforces RFC 7469 public-key pinning on top of
2164
+ * RFC 9525 strict hostname verification. Pass the returned function as
2165
+ * `tls.connect({ checkServerIdentity })`.
2166
+ *
2167
+ * The verifier runs hostname/SAN identity FIRST (via
2168
+ * `b.network.tls.checkServerIdentity9525` — SAN-required, no Common Name
2169
+ * fallback), so a pin match on a certificate issued for the wrong name is
2170
+ * still refused. Only when identity passes does it derive the peer's SPKI
2171
+ * pin from the presented DER and constant-time-compare
2172
+ * (`crypto.timingSafeEqual`) it against every configured pin. Returns the
2173
+ * identity `Error` on a hostname/SAN failure, an `Error` with code
2174
+ * `crypto/spki-pin-mismatch` when no pin matches, or `undefined` when
2175
+ * both identity and pin check out.
2176
+ *
2177
+ * `opts.pins` MUST be an array of at least two `sha256/<base64>` pins:
2178
+ * RFC 7469 §4.3 requires a backup pin corresponding to a key not in the
2179
+ * current chain so key rotation does not brick the pinned endpoint. When
2180
+ * `opts.hostname` is set, the certificate identity is checked against it
2181
+ * instead of the host argument Node supplies — pin the expected name
2182
+ * explicitly. Produce pin strings with `b.crypto.spkiPin(...).sha256`.
2183
+ *
2184
+ * @opts
2185
+ * pins: string[], // required — >= 2 'sha256/<base64>' pins (RFC 7469 backup-pin rule)
2186
+ * hostname: string, // optional — verify cert identity against this name instead of the host arg
2187
+ *
2188
+ * @example
2189
+ * var tls = require("node:tls");
2190
+ * var verify = b.crypto.spkiPinVerifier({
2191
+ * pins: [
2192
+ * "sha256/YLh1dUR9y6Kja30RrAn7JKnbQG/uEtLMkBgFF2Fuihg=",
2193
+ * "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", // backup key
2194
+ * ],
2195
+ * });
2196
+ * var sock = tls.connect({
2197
+ * host: "api.example.com",
2198
+ * port: 443,
2199
+ * checkServerIdentity: verify,
2200
+ * });
2201
+ */
2202
+ function spkiPinVerifier(opts) {
2203
+ if (!opts || typeof opts !== "object") {
2204
+ throw new TypeError("crypto.spkiPinVerifier: opts is required");
2205
+ }
2206
+ if (!Array.isArray(opts.pins)) {
2207
+ throw new TypeError(
2208
+ "crypto.spkiPinVerifier: opts.pins must be an array of 'sha256/<base64>' pins"
2209
+ );
2210
+ }
2211
+ // RFC 7469 §4.3 requires a backup pin corresponding to a key NOT in the
2212
+ // current chain. Two IDENTICAL pins are not a backup (the same key repeated),
2213
+ // so require at least two DISTINCT pins — a length-only check would accept a
2214
+ // duplicate and silently leave the endpoint with no rotation key.
2215
+ var distinctPins = [];
2216
+ for (var dpi = 0; dpi < opts.pins.length; dpi += 1) {
2217
+ if (distinctPins.indexOf(opts.pins[dpi]) === -1) distinctPins.push(opts.pins[dpi]);
2218
+ }
2219
+ if (distinctPins.length < 2) {
2220
+ throw new TypeError(
2221
+ "crypto.spkiPinVerifier: opts.pins must contain at least two DISTINCT " +
2222
+ "'sha256/<base64>' pins — RFC 7469 §4.3 requires a backup pin " +
2223
+ "corresponding to a key not in the current chain so key rotation " +
2224
+ "does not brick the pinned endpoint"
2225
+ );
2226
+ }
2227
+ var pinBufs = new Array(distinctPins.length);
2228
+ for (var pi = 0; pi < distinctPins.length; pi += 1) {
2229
+ pinBufs[pi] = _decodeSpkiPin(distinctPins[pi], "crypto.spkiPinVerifier");
2230
+ }
2231
+ var expectedHost = opts.hostname;
2232
+ if (expectedHost !== undefined &&
2233
+ (typeof expectedHost !== "string" || expectedHost.length === 0)) {
2234
+ throw new TypeError(
2235
+ "crypto.spkiPinVerifier: opts.hostname must be a non-empty string when supplied"
2236
+ );
2237
+ }
2238
+ return function verifySpkiPin(host, cert) {
2239
+ // Identity (hostname / SAN) FIRST — a pin match on a cert issued for
2240
+ // the wrong name is still impersonation. checkServerIdentity9525 is
2241
+ // RFC 9525 strict (SAN-required, no CN fallback) and returns
2242
+ // Error | undefined.
2243
+ var effectiveHost = expectedHost !== undefined ? expectedHost : host;
2244
+ var identityError = networkTls().checkServerIdentity9525(effectiveHost, cert);
2245
+ if (identityError) return identityError;
2246
+ // Peer SPKI pin — hash the DER the peer actually presented. Node's
2247
+ // detailed cert object exposes the raw DER as cert.raw.
2248
+ if (!cert || !Buffer.isBuffer(cert.raw)) {
2249
+ var noDerErr = new Error(
2250
+ "crypto.spkiPinVerifier: peer cert object carries no DER `raw` bytes to pin"
2251
+ );
2252
+ noDerErr.code = "crypto/spki-pin-no-peer-der";
2253
+ return noDerErr;
2254
+ }
2255
+ var peerBuf;
2256
+ try {
2257
+ peerBuf = Buffer.from(spkiPin(cert.raw).b64, "base64");
2258
+ } catch (parseErr) {
2259
+ var peerErr = new Error(
2260
+ "crypto.spkiPinVerifier: failed to derive peer SPKI pin (" +
2261
+ (parseErr && parseErr.message ? parseErr.message : String(parseErr)) + ")"
2262
+ );
2263
+ peerErr.code = "crypto/spki-pin-peer-parse-failed";
2264
+ return peerErr;
2265
+ }
2266
+ var matched = false;
2267
+ for (var mi = 0; mi < pinBufs.length; mi += 1) {
2268
+ // No early exit — comparing against every pin keeps the match
2269
+ // timing from leaking which configured pin matched.
2270
+ if (peerBuf.length === pinBufs[mi].length &&
2271
+ nodeCrypto.timingSafeEqual(peerBuf, pinBufs[mi])) {
2272
+ matched = true;
2273
+ }
2274
+ }
2275
+ if (!matched) {
2276
+ var mismatchErr = new Error(
2277
+ "crypto.spkiPinVerifier: peer SPKI pin (" + SPKI_PIN_PREFIX +
2278
+ peerBuf.toString("base64") + ") does not match any configured pin"
2279
+ );
2280
+ mismatchErr.code = "crypto/spki-pin-mismatch";
2281
+ return mismatchErr;
2282
+ }
2283
+ return undefined;
2284
+ };
2285
+ }
2286
+
2045
2287
  var SUPPORTED_KEM_ALGORITHMS = Object.freeze([
2046
2288
  { id: "ml-kem-1024", envelopeId: C.KEM_IDS.ML_KEM_1024, description: "ML-KEM-1024 KEM-only (legacy single-component)" },
2047
2289
  { id: "ml-kem-1024-p384", envelopeId: C.KEM_IDS.ML_KEM_1024_P384, description: "ML-KEM-1024 + ECDH P-384 hybrid (framework default)" },
@@ -2191,6 +2433,8 @@ module.exports = {
2191
2433
  // Cert fingerprint helpers
2192
2434
  hashCertFingerprint: hashCertFingerprint,
2193
2435
  isCertRevoked: isCertRevoked,
2436
+ spkiPin: spkiPin,
2437
+ spkiPinVerifier: spkiPinVerifier,
2194
2438
  // Random
2195
2439
  generateBytes: generateBytes,
2196
2440
  generateToken: generateToken,