@blamejs/core 0.9.43 → 0.9.46

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.46 (2026-05-15) — **`b.mail.server.mx` — inbound SMTP / MX listener + `b.safeSmtp` parser + `b.guardSmtpCommand.detectBodySmuggling`.** The wire-protocol primitives extracted from the listener inline copy into reusable safe/guard modules — `b.safeSmtp.findDotTerminator(buf)` + `b.safeSmtp.dotUnstuff(buf)` for the parsing concerns (where the body terminator is, how to reverse dot-stuffing per RFC 5321 §4.5.2), and `b.guardSmtpCommand.detectBodySmuggling(buf)` for the security concern (CVE-2023-51764 / -51765 / -51766 / 2024-32178 bare-LF dot-terminator detection). The MX listener consumes both. Same primitives ship for the upcoming submission / IMAP / JMAP listeners and for any operator-side tooling that needs to parse SMTP bytes (proxies, log analyzers, test fixtures) without booting a full server. Closes downstream-consumer gap item #11. Composes the existing mail-gate substrates (`b.mail.helo`, `b.mail.rbl`, `b.mail.greylist`, `b.guardEnvelope`, `b.mail.auth.dmarc`, `b.safeMime`, `b.guardEmail`, `b.guardSmtpCommand`, `b.mail.agent`) into one operator-facing inbound listener that drives the RFC 5321 CONNECT → EHLO → [STARTTLS → EHLO] → MAIL → RCPT → DATA → DATA-body → QUIT state machine. **Defenses baked in:** (1) **SMTP smuggling** (CVE-2023-51764 / CVE-2024-32178 / RFC 5321 §2.3.8) — every wire line passes through `b.guardSmtpCommand.validate` refusing bare LF / bare CR / NUL / C0 / DEL / oversize; the DATA body's `\r\n.\r\n` terminator is matched on canonical CRLF only — bare-LF dot-terminators are detected via `_detectSmugglingShape` and refused with 554 5.7.0 + an `mail.server.mx.smtp_smuggling_detected` audit event. (2) **Open-relay defense** — `localDomains` allowlist with default-deny posture; RCPT TO non-local refused with 550 5.7.1 unless `relayAllowedFor: [{ cidr, scope }]` opts the destination in explicitly. (3) **STARTTLS-injection defense (CVE-2021-38371 Exim, CVE-2021-33515 Dovecot)** — command buffer + body collector cleared at upgrade time so pre-handshake pipelined commands (RFC 2920 PIPELINING) can't take effect post-handshake. (4) **TLS posture** — `tlsContext` is required (no implicit plaintext-only mode); pre-STARTTLS plain commands limited to EHLO / HELO / STARTTLS / NOOP / QUIT / RSET under strict + balanced profiles; MAIL / RCPT / DATA refused with 530 5.7.0 Must issue a STARTTLS command first. Permissive profile accepts plaintext for legacy operator-acknowledged downgrade. (5) **Resource exhaustion** — per-command line cap (default 1 KiB), DATA body cap (default 50 MiB per RFC 5321 §4.5.3.1.7), per-recipient cap (default 100 per RFC 5321 §4.5.3.1.8), idle timeout (default 5 minutes per RFC 5321 §4.5.3.2.7). RFC 5321 §4.5.2 dot-stuffing reversal via `_dotUnstuff`. RFC 1870 §3 SIZE param parsed at MAIL FROM time + refused with 552 5.3.4 if oversize. RFC 2920 PIPELINING + RFC 6152 8BITMIME (obsoletes RFC 1652) + RFC 2034 ENHANCEDSTATUSCODES advertised in EHLO capabilities. RFC 3463 enhanced status codes embedded in every reply for operator-side observability. RFC 6531 SMTPUTF8 / RFC 5891 IDN deliberately NOT advertised — non-ASCII MAIL FROM / RCPT TO bytes refused via `b.guardSmtpCommand` until the operator's downstream (mail-store + delivery agent) accepts Unicode mailbox-local-part bytes. Audit lifecycle: `mail.server.mx.{connect,helo,mail_from,rcpt_to,data_accepted,data_refused,delivered,tls_handshake_failed,smtp_smuggling_detected,relay_refused,listening,closed,handler_threw,socket_error}`. **What v1 does NOT ship:** AUTH / submission auth (port-587 listener is its own slice), Sieve filtering (composes via `b.mail.agent` at delivery), outbound DSN generation (deferred to submission slice), 8BITMIME / SMTPUTF8 transcoding (advertised but parser-agnostic).
12
+ - 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).
13
+ - 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).
11
14
  - 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.
12
15
  - 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.
13
16
  - 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).
package/index.js CHANGED
@@ -88,6 +88,7 @@ var safeJson = require("./lib/safe-json");
88
88
  var safeJsonPath = require("./lib/safe-jsonpath");
89
89
  var safeMime = require("./lib/safe-mime");
90
90
  var safeDns = require("./lib/safe-dns");
91
+ var safeSmtp = require("./lib/safe-smtp");
91
92
  var mailStore = require("./lib/mail-store");
92
93
  var ntpCheck = require("./lib/ntp-check");
93
94
  var auditSign = require("./lib/audit-sign");
@@ -260,6 +261,8 @@ var mail = require("./lib/mail");
260
261
  mail.rbl = require("./lib/mail-rbl");
261
262
  mail.greylist = require("./lib/mail-greylist");
262
263
  mail.helo = require("./lib/mail-helo");
264
+ mail.server = mail.server || {};
265
+ mail.server.mx = require("./lib/mail-server-mx");
263
266
  var mailArf = require("./lib/mail-arf");
264
267
  var mailBounce = require("./lib/mail-bounce");
265
268
  var mailMdn = require("./lib/mail-mdn");
@@ -535,6 +538,7 @@ module.exports = {
535
538
  safeJsonPath: safeJsonPath,
536
539
  safeMime: safeMime,
537
540
  safeDns: safeDns,
541
+ safeSmtp: safeSmtp,
538
542
  mailStore: mailStore,
539
543
  safeSchema: safeSchema,
540
544
  pagination: pagination,
@@ -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/fal.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * @module b.auth.fal
4
- * @nav Identity & Access
4
+ * @nav Identity
5
5
  * @title NIST 800-63-4 FAL Classifier
6
6
  * @order 120
7
7
  *
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,
@@ -215,15 +215,27 @@ function validate(line, opts) {
215
215
  "guardSmtpCommand.validate: verb '" + verb + "' takes no arguments");
216
216
  }
217
217
 
218
- if (verb === "EHLO" || verb === "HELO") return _validateGreeting(verb, rest, caps);
219
- if (verb === "MAIL") return _validatePath(verb, rest, caps, "FROM:");
220
- if (verb === "RCPT") return _validatePath(verb, rest, caps, "TO:");
221
- if (verb === "BDAT") return _validateBdat(rest);
222
- if (verb === "VRFY" || verb === "EXPN") return _validateMailbox(verb, rest, caps);
223
- if (verb === "AUTH") return _validateAuth(rest);
224
- if (verb === "NOOP" || verb === "HELP") return { verb: verb, args: rest ? [rest] : [], params: {} };
225
-
226
- return { verb: verb, args: [], params: {} };
218
+ // Verb→parser dispatch via switch the switch arms are not a
219
+ // dynamic call: each `case` invokes a statically-resolved function
220
+ // by name, so CodeQL's js/unvalidated-dynamic-method-call tracker
221
+ // sees a fixed call graph rather than user-controlled dispatch.
222
+ // (KNOWN_VERBS gates `verb` upstream to the closed set below; the
223
+ // KNOWN_VERBS check itself is a property read on a frozen
224
+ // Object.create(null)-equivalent table, which CodeQL accepts as
225
+ // boolean data access.)
226
+ switch (verb) {
227
+ case "EHLO":
228
+ case "HELO": return _validateGreeting(verb, rest, caps);
229
+ case "MAIL": return _validatePath(verb, rest, caps, "FROM:");
230
+ case "RCPT": return _validatePath(verb, rest, caps, "TO:");
231
+ case "BDAT": return _validateBdat(rest);
232
+ case "VRFY":
233
+ case "EXPN": return _validateMailbox(verb, rest, caps);
234
+ case "AUTH": return _parseAuthCommandSyntax(rest);
235
+ case "NOOP":
236
+ case "HELP": return { verb: verb, args: rest ? [rest] : [], params: {} };
237
+ default: return { verb: verb, args: [], params: {} };
238
+ }
227
239
  }
228
240
 
229
241
  /**
@@ -377,7 +389,7 @@ function _validateMailbox(verb, rest, caps) {
377
389
  return { verb: verb, args: [rest], params: {} };
378
390
  }
379
391
 
380
- function _validateAuth(rest) {
392
+ function _parseAuthCommandSyntax(rest) {
381
393
  // RFC 4954: `AUTH <SASL-mech> [<initial-response>]`
382
394
  var parts = rest.split(/\s+/).filter(Boolean);
383
395
  if (parts.length === 0 || parts.length > 2) {
@@ -461,8 +473,51 @@ function gate(opts) {
461
473
  });
462
474
  }
463
475
 
476
+ /**
477
+ * @primitive b.guardSmtpCommand.detectBodySmuggling
478
+ * @signature b.guardSmtpCommand.detectBodySmuggling(buf)
479
+ * @since 0.9.46
480
+ * @status stable
481
+ * @related b.guardSmtpCommand.validate, b.safeSmtp.findDotTerminator
482
+ *
483
+ * Scan a DATA-body byte buffer for the SMTP smuggling shape per
484
+ * CVE-2023-51764 (Postfix), CVE-2023-51765 (Sendmail), CVE-2023-51766
485
+ * (Exim), CVE-2024-32178 (.NET System.Net.Mail). RFC 5321 §2.3.8
486
+ * mandates canonical CRLF line termination; the smuggling exploit
487
+ * relies on parsers that accept `\n.\n` (bare LF before / after the
488
+ * dot) as an alternate body terminator and then resume parsing the
489
+ * NEXT bytes as a new SMTP transaction.
490
+ *
491
+ * Returns `true` if the buffer contains a bare-LF dot-line (a `\n`
492
+ * NOT preceded by `\r`, immediately followed by `.\n`), `false`
493
+ * otherwise. Operators wiring an MX / submission listener call this
494
+ * on every DATA chunk + refuse the whole transaction on `true` per
495
+ * the framework's strict-CRLF posture.
496
+ *
497
+ * @example
498
+ * b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\r\n.\r\n"));
499
+ * // → false
500
+ *
501
+ * b.guardSmtpCommand.detectBodySmuggling(Buffer.from("body\n.\n"));
502
+ * // → true (bare-LF dot-line — CVE-2023-51764 shape)
503
+ */
504
+ function detectBodySmuggling(buf) {
505
+ if (!Buffer.isBuffer(buf)) {
506
+ throw new GuardSmtpCommandError("guard-smtp-command/bad-input",
507
+ "detectBodySmuggling: input must be a Buffer");
508
+ }
509
+ for (var i = 1; i < buf.length - 2; i += 1) {
510
+ if (buf[i] === 0x0a /* LF */ && buf[i - 1] !== 0x0d /* CR */ &&
511
+ buf[i + 1] === 0x2e /* . */ && buf[i + 2] === 0x0a /* LF */) {
512
+ return true;
513
+ }
514
+ }
515
+ return false;
516
+ }
517
+
464
518
  module.exports = {
465
519
  validate: validate,
520
+ detectBodySmuggling: detectBodySmuggling,
466
521
  gate: gate,
467
522
  compliancePosture: compliancePosture,
468
523
  PROFILES: PROFILES,