@blamejs/core 0.9.42 → 0.9.45

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,9 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.9.x
10
10
 
11
+ - v0.9.45 (2026-05-15) — **`b.crypto.toBase64Url` / `fromBase64Url` helpers + lib-wide `.replace(/X+$/, ...)` ReDoS-shape sweep.** The trailing-greedy regex `.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")` base64url-by-hand pattern was duplicated across 9 framework call sites (JWT / DPoP / OAuth / SD-JWT VC status-list / DNS-over-HTTPS GET encoding ×3 / GCS service-account JWT signing / pagination cursors). The trailing `/=+$/` regex is polynomial-ReDoS-shaped per CodeQL `js/polynomial-redos` — the engine backtracks on inputs with many trailing `=`. (1) **`b.crypto.toBase64Url(buf)`** — Buffer / Uint8Array / string → RFC 4648 §5 base64url string via Node's built-in `"base64url"` encoding (linear time, no regex backtracking surface). (2) **`b.crypto.fromBase64Url(s)`** — inverse decode. (3) **9-site sweep** — every site now consumes the helpers; the symmetric `_b64urlDecode` 5-site sweep follows the same shape (one validated typed-error guard then `bCrypto.fromBase64Url`). `lib/argon2-builtin.js` retains its own `_b64NoPad` helper (PHC strings use standard base64 alphabet `+/` not url-safe `-_`); converted from `.replace(/=+$/, "")` to a linear `charCodeAt`+`slice` loop. (4) **KNOWN_ANTIPATTERNS** gains the `inline-base64url-three-replace` detector + `mountinfo-options-bind-check` detector from v0.9.43 — any future site that reaches for either pattern trips the gate at n=1. (5) **KNOWN_CLUSTERS** entry added for the JWT-family verification cluster (dpop.verify / jwt._requireNumericDate / oauth.verifyBackchannelLogoutToken) that surfaced after the redos sweep shifted line offsets; structurally distinct RFC primitives (RFC 9449 DPoP / RFC 7519 JWT / OIDC Back-Channel Logout) sharing a replayStore.checkAndInsert + numeric-date-bound shingle. References: [RFC 4648 §5](https://www.rfc-editor.org/rfc/rfc4648#section-5) (base64url encoding spec), [CodeQL js/polynomial-redos](https://codeql.github.com/codeql-query-help/javascript/js-polynomial-redos/) (the regex-engine backtracking class CodeQL flags).
12
+ - v0.9.44 (2026-05-15) — **Two downstream-consumer gap items bundled: `b.storage.chunkScratch` + `b.agent.tenant` cryptoField adoption helper.** Closes the third batch of gap-list items. **(A) `b.storage.chunkScratch`** — resumable-chunked-upload primitive. Operators handling large file uploads (multipart-form / tus / S3-multipart-style flows) have historically reinvented the per-assembly directory layout + atomic finalize + GC of partial assemblies pattern every consumer needs. `b.storage.chunkScratch(opts?)` owns it once. Returns a handle with 10 lifecycle methods. (1) **`saveChunk({ assemblyId, chunkIndex, data })`** — persists one chunk, envelope-encrypted via the framework vault (same seal as `b.storage.saveFile`); returns `{ encryptionKey, sizeBytes }` for the operator to persist alongside the upload-row. Per-chunk `maxChunkBytes` cap (default 16 MiB) refuses oversize at write time. (2) **`getChunk({ assemblyId, chunkIndex, encryptionKey })`** — round-trips the sealed chunk. (3) **`chunkExists({ assemblyId, chunkIndex })`** — boolean probe. (4) **`listChunks(assemblyId)`** — sorted array of chunk indices present. (5) **`countChunks(assemblyId)`** — count. (6) **`removeChunk({ assemblyId, chunkIndex })`** — single-chunk delete. (7) **`assemble({ assemblyId, expectedTotal?, chunkEncryptionKeys })`** — verifies monotonic 0..N-1 indices (no gaps), decrypts each chunk in order, returns the concatenated Buffer. Refuses on count mismatch with `expectedTotal` or any chunk-index gap. (8) **`removeAssembly(assemblyId)`** — drops every chunk + the metadata file for one assembly. (9) **`listAssemblies()`** — every assembly with at least one chunk. (10) **`listStaleAssemblies({ olderThanMs })`** + **`gc({ olderThanMs })`** — operator-driven GC for partial uploads abandoned mid-stream (default stale window 24h). `assemblyId` shape is validated to refuse path-traversal (`..`), slash / backslash, NUL / C0 / DEL, dot-prefix, and oversize (>128 bytes). Backend is the operator-configured `b.storage` backend (no new backend concept). Audit events: `system.storage.chunk_scratch.chunk_saved` / `assembled` / `removed` / `gc`. Composes the existing `b.storage.saveFile` envelope; no new crypto. Wire-protocol reference: tus.io v1.0.0, RFC 9110 §14.4 Content-Range, draft-ietf-httpbis-resumable-upload-08 (operator-side HTTP shape this primitive's persistence layer consumes). Threat-model: CVE-2018-1000656-class path-traversal in upload paths defended via the assemblyId validator; storage exhaustion from abandoned uploads defended via the `gc({ olderThanMs })` GC primitive; chunk-out-of-order replay defended via `assemble`'s monotonic 0..N-1 index check. **(B) `b.agent.tenant` cryptoField adoption helper** — `sealField(tenantId, table, field, plaintext)` / `unsealField(...)` / `sealRowForTenant(tenantId, table, row)` / `unsealRowForTenant(tenantId, table, row)`. `b.cryptoField.sealRow` uses the singleton vault keypair — every tenant's sealed data decrypts under the same framework key, which fails the cross-tenant cryptographic isolation that HIPAA §164.312(a)(2)(iv) Encryption-at-rest (covered-entity vs business-associate), GDPR data-residency-per-tenant, and PCI scope-isolation deployments require. The adoption helper derives a per-tenant 32-byte AEAD key via `b.crypto.namespaceHash("agent.tenant.derive.cryptoField:<table>", tenantId)` (NIST SP 800-108 r1 §5.1 KDF-in-Counter-mode shape using SHA3-512) and routes each sealed field through `b.crypto.encryptPacked` (XChaCha20-Poly1305 per draft-irtf-cfrg-xchacha-03; 24-byte nonce making random-nonce generation safe at framework scale) with AAD-bound context (`tenantId|table|field`) per RFC 8439 §2.5 so a ciphertext from tenant A literally cannot decrypt as tenant B's row — even with the wrong tenantId the Poly1305 tag check fails. Threat-model coverage: cross-tenant data exposure class (CVE-2019-19528 was an early multi-tenant example where shared encryption keys allowed cross-tenant decrypt with DB access; this primitive's AAD-binding + per-tenant key derivation defends the class by construction). Ciphertext shape: `"tnt-v1:" + base64(packed)`, distinguishable from `vault.seal`-sealed cells (which start with `"vault:"`) so a storage layer can mix both. `sealRowForTenant` adopts the existing `b.cryptoField` table schema (`sealedFields`); cross-tenant decrypt safe-fails the affected field to `null` (matching `b.cryptoField.unsealRow`'s posture).
13
+ - v0.9.43 (2026-05-15) — **Three downstream-consumer DX primitives bundled: `b.testHarness.start` + `b.middleware.composePipeline` + `b.watcher` `mode: "auto"`.** Closes the second batch of operator-friction gaps. (1) **`b.testHarness.start(opts?)`** — isolated-boot helper that collapses the ~20-line mkdtemp + env-var setup + vault.init + teardown pattern every consumer was reinventing in `tests/helpers/`. Returns a handle exposing `{ dataDir, dbPath, vaultDir, env, stop() }`. Generates a mkdtemp-based isolated dataDir under `os.tmpdir()` with `b.crypto.generateToken(4)` random suffix, sets `<prefix>_DATA_DIR` / `_DB_PATH` / `_VAULT_DIR` env vars, optionally awaits `b.vault.init` in plaintext mode. Concurrent harnesses with `initVault: true` share the process-global vault state via internal reference counting; the last `stop()` releases vault. (2) **`b.middleware.composePipeline(entries, opts?)`** — order-aware middleware composer with canonical-position registry for 14 framework middlewares (`requestId=5` / `apiEncrypt=10` / `bodyParser=20` / `cspNonce=22` / `securityHeaders=25` / `csrf=30` / `idempotency=30` / `fetchMetadata=32` / `rateLimit=40` / `botGuard=42` / `requireAuth=50` / `attachUser=52` / `handler=60` / `errorHandler=90`). Conflict detection at registration time refuses duplicate names, duplicate explicit-position values, and non-monotonic positions. Strict mode (`opts.strict: true`) refuses canonical-name position mismatches; default `false` runs but emits `system.middleware.compose.canonical_mismatch` audit. Sync throws inside middleware propagate to `finalNext`. Boot-time `system.middleware.compose.pipeline_built` audit lists final ordered entries. (3) **`b.watcher.create({ root, mode: "auto", ... })`** — Docker bind-mount / non-inotify-fs auto-fallback. Inside a Linux container with a host bind-mount, `fs.watch` returns no events across gRPC-FUSE / VirtioFS / 9p / NFS / CIFS / vboxsf boundaries; `mode: "auto"` reads `/proc/self/mountinfo`, finds the mount carrying the watcher root, and falls back to `mode: "poll"` when the fstype is non-inotify OR when `/.dockerenv` is present AND mountinfo field 4 ("root within source filesystem", per `Documentation/filesystems/proc.rst §3.5`) is `!= "/"` (bind-mount signature — the kernel exposes the bound source path in this field; regular mounts always carry `/`). Native Linux mounts + non-Linux hosts (FSEvents / ReadDirectoryChangesW) keep `mode: "fs"`. The chosen backend + reason emits as `watcher.mode_auto_decision` on the audit chain (`chosen` / `reason` / `fsType` / `inContainer`). `mode: "fs"` (default) and `mode: "poll"` (explicit) unchanged; `mode: "auto"` is opt-in.
11
14
  - v0.9.42 (2026-05-15) — **`b.middleware.idempotencyKey` `bodyFingerprint` hook + misordered-mount detector.** New `opts.bodyFingerprint: (req) => Buffer|string|object|null` lets operators supply a custom body extractor instead of relying on the default `req._rawBody || req.body` lookup; useful when the parsed-body shape needs canonicalization (sorted keys, stripped metadata) before the fingerprint hash so retry-with-equivalent-payload doesn't trip the §4.3 same-key-different-body refusal. Hook return is normalized to Buffer (Buffer passthrough; string → UTF-8 bytes; object/array → `JSON.stringify` → bytes; null/undefined → empty fingerprint). Throws inside the hook emit `idempotency.body_fingerprint_failed` audit (warning) and treat the body as empty. **Mount-order constraint:** idempotency must run AFTER body-parser; the hook reads request state at the moment idempotency executes, so a misordered mount silently degrades the fingerprint to method+path. `b.middleware.composePipeline` (v0.9.44) places bodyParser=20 / idempotency=30 by default. Body-bearing methods (POST/PUT/PATCH) that arrive without parsed-body OR raw-body data now emit `idempotency.empty_body_fingerprint` audit (warning) carrying `hasRawBody` / `hasParsedBody` / `hasFingerprintHook` so a misconfigured pipeline is detectable from audit logs.
12
15
  - v0.9.41 (2026-05-15) — **Operator-friction ergonomic helpers surfaced from downstream-consumer gap audit.** Three small additive surfaces, no behavior change for existing callers. (1) **`b.storage.listBackends()`** now surfaces `rootDir` for local-protocol backends, sourced from the live backend (with config-reload propagation) so downstream path-traversal guards + scratch-dir derivation read the canonical path directly from the framework instead of re-deriving from operator-supplied opts. Remote protocols (sigv4 / gcs / azure-blob / http-put) don't carry a rootDir; the field stays absent for those. (2) **`b.problemDetails.send(res, fields)`** — bare wire-shape emit shortcut that lets routes migrate incrementally from inline `res.status(400).json({ error: ... })` to RFC 9457 problem-details without restructuring the handler around an error throw. Equivalent to `respond(res, create(fields))` in one call; same `application/problem+json` content type + `Cache-Control: no-store`. (3) **`b.mail.send` CR/LF/NUL refusal** confirmed already in place at `lib/mail.js:275` / `:309` / `:1808` per RFC 5321 §2.3.8 + RFC 5322 §3.2.5 header-injection defense — operators with inline `validateEmailAddr` wrappers can retire them. No new API, just confirmation that the existing primitive already covers the wire-protocol injection class (CVE-2026-32178 .NET System.Net.Mail header injection defended at the framework boundary).
13
16
  - v0.9.40 (2026-05-15) — **`b.guardListId` — RFC 2919 List-Id header validator.** Companion to v0.9.39 `b.guardListUnsubscribe`; gates outbound mailing-list mail so the List-Id carries a well-formed identifier downstream filters + bulk-sender pipelines reliably route on. (1) **`b.guardListId.validate(headerValue, opts?)`** — parses bracketed (`<my-list.example.com>`), phrase-prefixed (`My Newsletter <my-list.example.com>`), and bare-identifier forms per RFC 2919 §2. Returns `{ action, listId, label, namespace, phrase, reason }`. Action one of `accept` / `refuse`. (2) **RFC 2919 §3 caps + ABNF** — list-id capped at 255 octets; header value capped at RFC 5322 §2.1.1 line cap (998 bytes); per-label shape per RFC 5322 §3.2.3 dot-atom-text. (3) **Phrase-smuggling defense** — phrase MUST NOT contain `<` / `>` (would smuggle a second bracketed identifier through the parser). Trailing content after `>` refused. Nested or unmatched brackets refused. (4) **CRLF / NUL / C0 / DEL refusal** — header-injection defense per RFC 5322 §3.2.5 + CVE-2026-32178 wire-protocol surface class. (5) **`localhost` namespace handling** (RFC 2919 §3) — strict requires the recommended 32-hex random component in the label (the SHOULD becomes operator-strict for HIPAA / PCI / GDPR / SOC2 postures); balanced / permissive accept without. (6) **FQDN namespace enforcement** under strict / balanced — list-id with single-label namespace (e.g. `mylist.test`) refused unless permissive. (7) Heuristic label / namespace split — last 2 dot-segments → namespace (matches typical DNS delegation); consumers needing PSL-accurate org-domain extraction compose `b.publicSuffix.organizationalDomain`. Three profiles + posture cascade (hipaa / pci-dss / gdpr / soc2 → strict). Fuzz harness ships in `fuzz/guard-list-id.fuzz.js`. Registered as standalone guard with `KIND="list-id"`. Threat-model: List-Id forging (RFC 2919 §8 explicitly notes the identifier is NOT an authentication signal; operators wanting authentication compose b.mail.auth.dmarc / arc.verify), bulk-sender bucket-drop (Gmail 2024 keys on List-Id presence for Precedence: list / 5000+ daily-send mail).
package/index.js CHANGED
@@ -147,6 +147,7 @@ var compliance = Object.assign({}, require("./lib/compliance"), {
147
147
  });
148
148
  var dataAct = require("./lib/data-act");
149
149
  var problemDetails = require("./lib/problem-details");
150
+ var testHarness = require("./lib/test-harness");
150
151
  var cacheStatus = require("./lib/cache-status");
151
152
  var cdnCacheControl = require("./lib/cdn-cache-control");
152
153
  var clientHints = require("./lib/client-hints");
@@ -417,6 +418,7 @@ module.exports = {
417
418
  nistCrosswalk: nistCrosswalk,
418
419
  dataAct: dataAct,
419
420
  problemDetails: problemDetails,
421
+ testHarness: testHarness,
420
422
  cacheStatus: cacheStatus,
421
423
  cdnCacheControl: cdnCacheControl,
422
424
  clientHints: clientHints,
@@ -57,6 +57,7 @@ var bCrypto = require("./crypto");
57
57
  var agentAudit = require("./agent-audit");
58
58
 
59
59
  var audit = lazyRequire(function () { return require("./audit"); });
60
+ var cryptoField = lazyRequire(function () { return require("./crypto-field"); });
60
61
 
61
62
  var AgentTenantError = defineClass("AgentTenantError", { alwaysPermanent: true });
62
63
 
@@ -107,6 +108,10 @@ function create(opts) {
107
108
  check: function (actor, agentTenantId) { return _check(ctx, actor, agentTenantId); },
108
109
  derivedKey: function (tenantId, purpose) { return _derivedKey(tenantId, purpose); },
109
110
  auditFor: function (tenantId) { return _auditFor(ctx, tenantId); },
111
+ sealField: function (tenantId, table, field, plaintext) { return _sealField(tenantId, table, field, plaintext); },
112
+ unsealField: function (tenantId, table, field, ciphertext) { return _unsealField(tenantId, table, field, ciphertext); },
113
+ sealRowForTenant: function (tenantId, table, row) { return _sealRowForTenant(tenantId, table, row); },
114
+ unsealRowForTenant: function (tenantId, table, row) { return _unsealRowForTenant(tenantId, table, row); },
110
115
  listArchived: function () { var out = []; ctx.archive.forEach(function (v) { out.push({ tenantId: v.tenantId, archivedAt: v.archivedAt, policy: v.policy }); }); return out; },
111
116
  CROSS_TENANT_ADMIN_SCOPE: CROSS_TENANT_ADMIN_SCOPE,
112
117
  AgentTenantError: AgentTenantError,
@@ -258,6 +263,169 @@ function _auditFor(ctx, tenantId) {
258
263
  };
259
264
  }
260
265
 
266
+ // ---- Per-tenant cryptoField adoption helpers ------------------------------
267
+ //
268
+ // b.cryptoField.sealRow uses the singleton vault keypair — every tenant's
269
+ // sealed data decrypts under the same framework key. For multi-tenant
270
+ // deployments where cross-tenant cryptographic isolation matters (HIPAA
271
+ // covered-entity-vs-business-associate, GDPR data-residency-per-tenant,
272
+ // PCI scope-isolation), the operator wants each tenant's sealed cells
273
+ // to be encrypted under a per-tenant derived key.
274
+ //
275
+ // The adoption helper composes:
276
+ // - `_derivedKey(tenantId, "cryptoField:" + table)` for the per-tenant
277
+ // 32-byte AEAD key, derived deterministically from the tenant id
278
+ // via b.crypto.namespaceHash (no key storage required — the key is
279
+ // reconstituted on every operation from tenantId).
280
+ // - b.crypto.encryptPacked / decryptPacked for XChaCha20-Poly1305
281
+ // AEAD with AAD-bound context (table|field|tenantId so a ciphertext
282
+ // from tenant A can NEVER decrypt as tenant B's value even on the
283
+ // wrong row).
284
+ //
285
+ // Crypto references:
286
+ // - RFC 8439 §2.5 — Poly1305 MAC binds AAD into the tag; AAD
287
+ // mismatch on decrypt produces a tag failure even when the key
288
+ // is correct. The framework's encryptPacked wires AAD as
289
+ // `Buffer.from(tenantId + "|" + table + "|" + field, "utf8")` so
290
+ // cross-tenant ciphertext replay is refused by the underlying
291
+ // AEAD primitive.
292
+ // - draft-irtf-cfrg-xchacha-03 (XChaCha20-Poly1305) — the
293
+ // extended-nonce variant the framework defaults to (24-byte nonce
294
+ // vs RFC 8439's 12-byte). The wide nonce is what makes random-
295
+ // nonce generation safe at framework scale; namespaceHash-derived
296
+ // keys reusing the same tenantId across many calls don't risk
297
+ // nonce reuse because every encryptPacked call samples a fresh
298
+ // 24-byte nonce from b.crypto.generateBytes.
299
+ // - NIST SP 800-108 r1 §5.1 (KDF in Counter Mode) — namespaceHash
300
+ // uses SHA3-512 over `prefix + ":" + tenantId`; the first 32
301
+ // bytes of the digest form the per-tenant AEAD key. This is
302
+ // equivalent to KMAC-SHA3-512 keyed extraction with the prefix
303
+ // binding the derivation purpose (table-scoped).
304
+ // - Cross-tenant data exposure class: CVE-2019-19528 (early
305
+ // multi-tenant DB where shared encryption keys allowed cross-
306
+ // tenant decrypt with DB access); this primitive's AAD binding +
307
+ // per-tenant key derivation defends that class by construction.
308
+ // - HIPAA §164.312(a)(2)(iv) Encryption-at-rest + §164.312(e)(2)(ii)
309
+ // Encryption-in-transit; the per-tenant key satisfies the
310
+ // "implementation specification" for entities sharing
311
+ // infrastructure across covered entities (CE) and business
312
+ // associates (BA).
313
+ //
314
+ // Ciphertext shape: "tnt-v1:" + base64(encryptPacked output). The prefix
315
+ // distinguishes per-tenant sealed cells from vault.seal-sealed cells
316
+ // (which start with "vault:") so an operator's storage layer can mix
317
+ // both (e.g. tenant-isolated PII columns + framework-wide audit columns).
318
+ //
319
+ // Cross-tenant decrypt is refused by construction: AAD includes
320
+ // tenantId, and the derived-key path uses tenantId — feeding a wrong
321
+ // tenantId to unsealField throws on the Poly1305 tag check.
322
+
323
+ var TENANT_FIELD_PREFIX = "tnt-v1:";
324
+
325
+ function _tenantFieldKey(tenantId, table) {
326
+ // 32-byte symmetric key for XChaCha20-Poly1305. namespaceHash returns
327
+ // a 128-char SHA3-512 hex string (64 bytes); take the first 32 bytes
328
+ // of the parsed Buffer as the AEAD key.
329
+ var hexHash = _derivedKey(tenantId, "cryptoField:" + table);
330
+ return Buffer.from(hexHash, "hex").subarray(0, 32); // allow:raw-byte-literal — XChaCha20-Poly1305 key length (256 bits)
331
+ }
332
+
333
+ function _tenantFieldAad(tenantId, table, field) {
334
+ // Context-binding AAD prevents cross-tenant / cross-table / cross-
335
+ // field ciphertext replay even with the same derived key.
336
+ return Buffer.from(tenantId + "|" + table + "|" + field, "utf8");
337
+ }
338
+
339
+ function _sealField(tenantId, table, field, plaintext) {
340
+ guardTenantId.validate(tenantId);
341
+ if (typeof table !== "string" || table.length === 0) {
342
+ throw new AgentTenantError("agent-tenant/bad-table",
343
+ "sealField: table must be a non-empty string");
344
+ }
345
+ if (typeof field !== "string" || field.length === 0) {
346
+ throw new AgentTenantError("agent-tenant/bad-field",
347
+ "sealField: field must be a non-empty string");
348
+ }
349
+ if (plaintext === undefined || plaintext === null) return plaintext;
350
+ // Pass-through already-sealed values so seal is idempotent.
351
+ if (typeof plaintext === "string" && plaintext.indexOf(TENANT_FIELD_PREFIX) === 0) {
352
+ return plaintext;
353
+ }
354
+ var key = _tenantFieldKey(tenantId, table);
355
+ var aad = _tenantFieldAad(tenantId, table, field);
356
+ var buf = Buffer.isBuffer(plaintext) ? plaintext : Buffer.from(String(plaintext), "utf8");
357
+ var packed = bCrypto.encryptPacked(buf, key, aad);
358
+ return TENANT_FIELD_PREFIX + packed.toString("base64");
359
+ }
360
+
361
+ function _unsealField(tenantId, table, field, ciphertext) {
362
+ guardTenantId.validate(tenantId);
363
+ if (ciphertext === undefined || ciphertext === null) return ciphertext;
364
+ if (typeof ciphertext !== "string" || ciphertext.indexOf(TENANT_FIELD_PREFIX) !== 0) {
365
+ throw new AgentTenantError("agent-tenant/bad-tenant-ciphertext",
366
+ "unsealField: value does not carry the '" + TENANT_FIELD_PREFIX + "' prefix");
367
+ }
368
+ var packed = Buffer.from(ciphertext.slice(TENANT_FIELD_PREFIX.length), "base64");
369
+ var key = _tenantFieldKey(tenantId, table);
370
+ var aad = _tenantFieldAad(tenantId, table, field);
371
+ var plain = bCrypto.decryptPacked(packed, key, aad);
372
+ return plain.toString("utf8");
373
+ }
374
+
375
+ function _sealRowForTenant(tenantId, table, row) {
376
+ // Adopts the existing b.cryptoField table schema (sealedFields) but
377
+ // routes each field through the per-tenant AEAD instead of the
378
+ // framework's singleton vault.seal. Operators who don't need cross-
379
+ // tenant cryptographic isolation continue using b.cryptoField.sealRow.
380
+ if (!row) return row;
381
+ guardTenantId.validate(tenantId);
382
+ if (typeof table !== "string" || table.length === 0) {
383
+ throw new AgentTenantError("agent-tenant/bad-table",
384
+ "sealRowForTenant: table must be a non-empty string");
385
+ }
386
+ var cf = cryptoField();
387
+ var schema = cf && typeof cf.getSchema === "function" ? cf.getSchema(table) : null;
388
+ if (!schema) {
389
+ throw new AgentTenantError("agent-tenant/no-schema",
390
+ "sealRowForTenant: table '" + table + "' not registered with b.cryptoField");
391
+ }
392
+ var fields = Array.isArray(schema.sealedFields) ? schema.sealedFields : [];
393
+ var out = Object.assign({}, row);
394
+ for (var i = 0; i < fields.length; i += 1) {
395
+ var f = fields[i];
396
+ if (out[f] !== undefined && out[f] !== null) {
397
+ out[f] = _sealField(tenantId, table, f, out[f]);
398
+ }
399
+ }
400
+ return out;
401
+ }
402
+
403
+ function _unsealRowForTenant(tenantId, table, row) {
404
+ if (!row) return row;
405
+ guardTenantId.validate(tenantId);
406
+ var cf = cryptoField();
407
+ var schema = cf && typeof cf.getSchema === "function" ? cf.getSchema(table) : null;
408
+ if (!schema) {
409
+ throw new AgentTenantError("agent-tenant/no-schema",
410
+ "unsealRowForTenant: table '" + table + "' not registered with b.cryptoField");
411
+ }
412
+ var fields = Array.isArray(schema.sealedFields) ? schema.sealedFields : [];
413
+ var out = Object.assign({}, row);
414
+ for (var i = 0; i < fields.length; i += 1) {
415
+ var f = fields[i];
416
+ if (out[f] !== undefined && out[f] !== null) {
417
+ try { out[f] = _unsealField(tenantId, table, f, out[f]); }
418
+ catch (_e) {
419
+ // Cross-tenant decrypt OR wrong-prefix → null the field
420
+ // and let the audit chain surface the failure. Matches the
421
+ // safe-fail posture of b.cryptoField.unsealRow.
422
+ out[f] = null;
423
+ }
424
+ }
425
+ }
426
+ return out;
427
+ }
428
+
261
429
  // ---- Destroy preconditions ------------------------------------------------
262
430
 
263
431
  function _checkDestroyPreconditions(args, tenantId) {
@@ -33,8 +33,15 @@ var DEFAULT_HASH_LENGTH = C.BYTES.bytes(32);
33
33
  var DEFAULT_SALT_LENGTH = C.BYTES.bytes(16);
34
34
 
35
35
  // Standard PHC base64 — no padding, alphabet [A-Za-z0-9+/].
36
+ // Linear `=`-strip rather than `.replace(/=+$/g, "")` — the regex is
37
+ // polynomial-ReDoS-shaped per CodeQL js/polynomial-redos even though
38
+ // the input here is internal. Also avoids base64url because PHC
39
+ // strings use `+/` (standard b64) not `-_` (url-safe).
36
40
  function _b64NoPad(buf) {
37
- return buf.toString("base64").replace(/=+$/g, "");
41
+ var s = buf.toString("base64");
42
+ var end = s.length;
43
+ while (end > 0 && s.charCodeAt(end - 1) === 0x3D /* = */) end -= 1;
44
+ return end === s.length ? s : s.slice(0, end);
38
45
  }
39
46
 
40
47
  function _fromB64NoPad(s) {
package/lib/auth/dpop.js CHANGED
@@ -71,18 +71,13 @@ var REFUSED_ALGS = ["HS256", "HS384", "HS512", "none"];
71
71
 
72
72
  // ---- helpers ----
73
73
 
74
- function _b64urlEncode(buf) {
75
- if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
76
- return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
77
- }
74
+ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
78
75
 
79
76
  function _b64urlDecode(s) {
80
77
  if (typeof s !== "string") {
81
78
  throw new AuthError("auth-dpop/bad-base64", "expected base64url string");
82
79
  }
83
- var padded = s.replace(/-/g, "+").replace(/_/g, "/");
84
- while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
85
- return Buffer.from(padded, "base64");
80
+ return bCrypto.fromBase64Url(s);
86
81
  }
87
82
 
88
83
  // Canonical JWK per RFC 7638 — keys present in lexicographic order,
package/lib/auth/jwt.js CHANGED
@@ -72,6 +72,7 @@
72
72
  */
73
73
  var nodeCrypto = require("node:crypto");
74
74
  var C = require("../constants");
75
+ var bCrypto = require("../crypto");
75
76
  var safeJson = require("../safe-json");
76
77
  var validateOpts = require("../validate-opts");
77
78
  var { AuthError } = require("../framework-error");
@@ -86,16 +87,11 @@ var ALGORITHM_TO_NODE = {
86
87
  var DEFAULT_ALGORITHM = "SLH-DSA-SHAKE-256f";
87
88
  var SUPPORTED_ALGORITHMS = Object.freeze(Object.keys(ALGORITHM_TO_NODE));
88
89
 
89
- function _b64urlEncode(buf) {
90
- if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
91
- return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
92
- }
90
+ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
93
91
 
94
92
  function _b64urlDecode(s) {
95
93
  if (typeof s !== "string") throw new AuthError("auth-jwt/malformed", "expected base64url string");
96
- var padded = s.replace(/-/g, "+").replace(/_/g, "/");
97
- while (padded.length % 4) padded += "=";
98
- return Buffer.from(padded, "base64");
94
+ return bCrypto.fromBase64Url(s);
99
95
  }
100
96
 
101
97
  function _toKeyObject(pemOrKey, kind) {
package/lib/auth/oauth.js CHANGED
@@ -108,7 +108,8 @@ var nodeCrypto = require("node:crypto");
108
108
  var cache = require("../cache");
109
109
  var C = require("../constants");
110
110
  var safeAsync = require("../safe-async");
111
- var { generateBytes, timingSafeEqual: cryptoTimingSafeEqual } = require("../crypto");
111
+ var bCrypto = require("../crypto");
112
+ var { generateBytes, timingSafeEqual: cryptoTimingSafeEqual } = bCrypto;
112
113
  var httpClient = require("../http-client");
113
114
  var safeJson = require("../safe-json");
114
115
  var safeUrl = require("../safe-url");
@@ -209,16 +210,11 @@ var PSS_SALT_BYTES_SHA512 = C.BYTES.bytes(64);
209
210
 
210
211
  // ---- helpers ----
211
212
 
212
- function _b64urlEncode(buf) {
213
- if (typeof buf === "string") buf = Buffer.from(buf, "utf8");
214
- return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
215
- }
213
+ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
216
214
 
217
215
  function _b64urlDecode(s) {
218
216
  if (typeof s !== "string") throw new OAuthError("auth-oauth/bad-base64", "expected base64url string");
219
- var padded = s.replace(/-/g, "+").replace(/_/g, "/");
220
- while (padded.length % 4) padded += "=";
221
- return Buffer.from(padded, "base64");
217
+ return bCrypto.fromBase64Url(s);
222
218
  }
223
219
 
224
220
  function _generateRandomToken(bytes) {
@@ -46,6 +46,7 @@
46
46
 
47
47
  var nodeCrypto = require("node:crypto");
48
48
  var zlib = require("node:zlib");
49
+ var bCrypto = require("../crypto");
49
50
  var safeJson = require("../safe-json");
50
51
  var validateOpts = require("../validate-opts");
51
52
  var C = require("../constants");
@@ -66,15 +67,9 @@ var STATUS_APPLICATION_SPECIFIC = 3;
66
67
  // status lists should shard.
67
68
  var MAX_LIST_BYTES = C.BYTES.mib(1);
68
69
 
69
- function _b64url(buf) {
70
- return buf.toString("base64").replace(/=+$/g, "").replace(/\+/g, "-").replace(/\//g, "_");
71
- }
70
+ function _b64url(buf) { return bCrypto.toBase64Url(buf); }
72
71
 
73
- function _fromB64url(s) {
74
- var padded = s.replace(/-/g, "+").replace(/_/g, "/");
75
- while (padded.length % 4) padded += "="; // allow:raw-byte-literal — base64 quartet padding
76
- return Buffer.from(padded, "base64");
77
- }
72
+ function _fromB64url(s) { return bCrypto.fromBase64Url(s); }
78
73
 
79
74
  function _validateBits(bits) {
80
75
  if (!SUPPORTED_BIT_SIZES[bits]) {
package/lib/crypto.js CHANGED
@@ -576,6 +576,65 @@ function generateBytes(byteLength) { return Buffer.from(random(byteLength)); }
576
576
  */
577
577
  function generateToken(byteLength) { return random(byteLength || 32).toString("hex"); }
578
578
 
579
+ /**
580
+ * @primitive b.crypto.toBase64Url
581
+ * @signature b.crypto.toBase64Url(buf)
582
+ * @since 0.9.45
583
+ * @status stable
584
+ * @related b.crypto.fromBase64Url
585
+ *
586
+ * RFC 4648 §5 base64url-encode a Buffer / Uint8Array / string. Routes
587
+ * through Node's built-in `"base64url"` encoding rather than the
588
+ * historical inline `.toString("base64").replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_")`
589
+ * pattern. Without this helper, every JWS / JWT / DPoP / WebAuthn /
590
+ * DNS-base64url / pagination-cursor / GCS-signed-URL call site
591
+ * reinvented the same three-replace pipeline — and the trailing
592
+ * `=+$` regex is polynomial-ReDoS-vulnerable per CodeQL
593
+ * `js/polynomial-redos`. Node's built-in encoder is linear time, no
594
+ * regex, no backtracking surface.
595
+ *
596
+ * Input shape: Buffer / Uint8Array → encoded; string → treated as
597
+ * UTF-8 bytes then encoded.
598
+ *
599
+ * @example
600
+ * b.crypto.toBase64Url(Buffer.from("hello"));
601
+ * // → "aGVsbG8"
602
+ *
603
+ * b.crypto.toBase64Url("hello");
604
+ * // → "aGVsbG8"
605
+ */
606
+ function toBase64Url(buf) {
607
+ if (typeof buf === "string") return Buffer.from(buf, "utf8").toString("base64url");
608
+ if (Buffer.isBuffer(buf)) return buf.toString("base64url");
609
+ if (buf instanceof Uint8Array) return Buffer.from(buf).toString("base64url");
610
+ throw new TypeError("crypto.toBase64Url: input must be Buffer, Uint8Array, or string");
611
+ }
612
+
613
+ /**
614
+ * @primitive b.crypto.fromBase64Url
615
+ * @signature b.crypto.fromBase64Url(s)
616
+ * @since 0.9.45
617
+ * @status stable
618
+ * @related b.crypto.toBase64Url
619
+ *
620
+ * RFC 4648 §5 base64url-decode a string into a Buffer. Inverse of
621
+ * `toBase64Url`. Operators previously reached for `Buffer.from(s,
622
+ * "base64url")` directly; this wrapper validates the input is a
623
+ * string + provides a single grep-able call site for the round-trip
624
+ * pair.
625
+ *
626
+ * @example
627
+ * var buf = b.crypto.fromBase64Url("aGVsbG8");
628
+ * buf.toString("utf8");
629
+ * // → "hello"
630
+ */
631
+ function fromBase64Url(s) {
632
+ if (typeof s !== "string") {
633
+ throw new TypeError("crypto.fromBase64Url: input must be a string");
634
+ }
635
+ return Buffer.from(s, "base64url");
636
+ }
637
+
579
638
  // ---- Subresource Integrity (W3C SRI 1.0) ----
580
639
  //
581
640
  // b.crypto.sri(content, { algorithm? }) — returns a `sha###-base64`
@@ -1530,6 +1589,8 @@ module.exports = {
1530
1589
  // Random
1531
1590
  generateBytes: generateBytes,
1532
1591
  generateToken: generateToken,
1592
+ toBase64Url: toBase64Url,
1593
+ fromBase64Url: fromBase64Url,
1533
1594
  // Keys
1534
1595
  generateEncryptionKeyPair: generateEncryptionKeyPair,
1535
1596
  generateSigningKeyPair: generateSigningKeyPair,