@blamejs/core 0.8.15 → 0.8.16

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.8.x
10
10
 
11
+ - **0.8.16** (2026-05-08) — Email auth + transport spec-conformance fixes (DKIM / SPF / MTA-STS / OCSP). Ten RFC-cited gaps closed against shipped primitives. **DKIM (`b.mail.dkim`)** — verifier now refuses any signature whose `h=` tag does not include `from` (RFC 6376 §3.5 cornerstone bypass — without From-coverage the signature does not bind to the visible sender; receivers were treating these as valid); refuses unrecognized `v=` tag values per RFC 6376 §3.5 (only `v=1` accepted); enforces empty `p=` as explicit key revocation per RFC 6376 §3.6.1 (verdict `fail`, not `permerror` — well-formed signature against a withdrawn key); enforces `k=` algorithm-family tag agreement with the signature's `a=` family (e.g. `k=rsa` paired with `a=ed25519-sha256` permerrors per RFC 6376 §3.6.1); selector validator now accepts multi-label selectors per RFC 6376 §3.1 ABNF (`sub-domain *("." sub-domain)` — common for time-rotated keys like `2024.s1`). **SPF (`b.mail.spf`)** — refuses domains that publish multiple `v=spf1` TXT records with `permerror` per RFC 7208 §4.5 (most operators don't realize multi-record SPF was always invalid; this surfaces the misconfig instead of silently picking the first); `include:` mechanism now permerrors when the included domain has no SPF record per RFC 7208 §5.2 (closes the silent-authorization class where `include:gone-domain.example` followed by `+all` would silently allow). **MTA-STS (`b.smtpPolicy.mtaSts.fetch`)** — now requires the `_mta-sts.<domain>` TXT precondition record per RFC 8461 §3.1 before fetching the HTTPS policy (closes the silent-escalation class where the framework would fetch policies for domains that never opted in, AND defeats operator-side rotation when the `id=` in the TXT changes); cache TTL is now bounded by the policy's `max_age` value per RFC 8461 §3.2 (clamped between 1 hour floor and 1 year ceiling) instead of the framework's hardcoded 60-min default. **OCSP (`b.network.tls.ocsp.evaluate`)** — `evaluateOcspResponse` now enforces the `thisUpdate` / `nextUpdate` time window per RFC 6960 §4.2.2.1, rejecting responses whose validity window has expired or hasn't started yet (with operator-tunable `clockSkewMs`, default 5 min). Pre-v0.8.16 a captured "good" response could replay forever even after the cert was revoked; this defeats `requireGood` posture. Bug fix only — no new operator-facing primitives, no surface change beyond the additional refusals.
12
+
11
13
  - **0.8.15** (2026-05-08) — Transport-layer CVE absorption + AI-protocol primitives. **`b.sse`** — Server-Sent Events transport with newline-injection refusal in `event:` / `id:` / `data:` fields and the `Last-Event-ID` reconnect header (CVE-2026-33128 h3, CVE-2026-29085 Hono, CVE-2026-44217 sse-channel — three CVEs published in the same vulnerability class). Channel API: `channel.send({event,id,data,retry})` validates each field, refuses LF/CR/NUL, splits multi-line `data` into per-spec multiple `data:` lines, drives a `:keepalive` heartbeat with operator-tunable interval. `b.sse.serializeEvent({...})` exposes the encoder for buffered pipelines. **`b.mcp.serverGuard`** — Model Context Protocol server-side hardening. Bearer auth required by default (CVE-2026-33032 nginx-ui auth-bypass class), `redirect_uri` exact-match allowlist enforced per OAuth 2.1 / RFC 9700 §4.1.1 (CVE-2025-6514 mcp-remote OAuth RCE class), dynamic client-registration refused unless `allowDynamicRegister: true` with operator-supplied registration allowlist (confused-deputy class), tool/resource name allowlists at the guard layer. JSON-RPC 2.0 envelope validator. **`b.graphqlFederation.guardSdl`** — refuses `_service.sdl` / `_entities` probes without a router-token Bearer + optional single-use nonce; closes the schema-leak class where operators disable introspection thinking the schema is hidden. **`b.ai.input.classify`** — pattern-based prompt-injection classifier covering OWASP LLM01:2025 + NIST COSAIS RFI shapes: instruction-override / persona-jailbreak / role-reset markers / OpenAI system-tag templates / tool-call injection / exfil-callback / encoded-bypass (base64/rot13) / markdown+HTML smuggling / BIDI/zero-width/control char density. Severity-3 hits → `verdict: "malicious"`; 2+ severity-2 hits → `"suspicious"`; otherwise `"clean"`. Inline-on-every-request perf cost — no LLM, no network. **`b.a2a`** — A2A (Linux Foundation Agentic AI Foundation) v1.x signed agent-card primitive. `signCard` produces an envelope with a detached ML-DSA-87 signature over the SHA3-512 of the canonical-JSON serialization (RFC 8785-aligned); `verifyCard` validates signature + expiry + issuer match. Endpoints HTTPS-only (or localhost) at validation time. **`b.darkPatterns`** — FTC Negative Option Rule click-to-cancel UX-parity attestation primitive. `recordSignupFlow` / `recordCancelFlow` capture operator-attested click counts, CTA contrast / font weight, channel, confirmation steps; `assertParity` returns `{ ok, breaches }` against `ftc-2024` / `ca-sb942` / `strict` postures. `middleware({lookupAttestation, resourceIdFromReq})` refuses cancel-endpoint requests with HTTP 451 if no parity attestation is on file. **WebSocket control-frame size cap** — `lib/websocket.js` `_handleFrame` refuses any control frame (opcodes ≥ 0x8: CLOSE/PING/PONG) with payload length > 125 or `fin = false` (RFC 6455 §5.5). Closes the 2× outbound-bandwidth amplification class where a 1 MiB PING was echoed verbatim as PONG. **`b.requestHelpers.safeHeadersDistinct(req)`** — defensive accessor for `req.headersDistinct` that bypasses Node's faulty getter (Node CVE-2026-21710 — reading `__proto__` on the underlying header bag throws synchronously inside the getter, escaping handler-level try/catch). Computes the same null-prototype shape directly from `req.rawHeaders`. **TLS / SNI hardening** — `router.listen()` now wraps any operator-supplied `tlsOptions.SNICallback` so synchronous throws (Node CVE-2026-21637) become a clean async `(err, null)` callback rather than crashing the listener. Inbound TLS now defaults to `minVersion: "TLSv1.3"` when the operator's `tlsOptions` doesn't pin one (closes the gap where bare `{key, cert}` inherited Node's TLSv1.2 default). **httpClient identity decoding** — `b.httpClient` now sends `Accept-Encoding: identity` by default (h1 + h2 paths) — refuses compressed responses unless the operator explicitly opts in. Closes the undici unbounded-decompression amplification class (CVE-2026-22036). Operators that need compressed responses pass an explicit `Accept-Encoding` header. **Engine pin** — `engines.node` raised from `>=24.0.0` to `>=24.4.0` to ensure the undici fix is bundled.
12
14
 
13
15
  - **0.8.14** (2026-05-07) — `b.vault.sealPemFile` — auto-resealing wrapper for at-rest PEM files. Operators with ACME / Let's Encrypt renewals get fresh certs every 30-60 days; the renewal writes plaintext PEM to disk, signals the application to reload, and leaves the cleartext file unencrypted between the renewal write and the next manual re-seal. `b.vault.sealPemFile({ source, destination })` closes that window: the framework reads the source, vault-seals it, atomically writes `<destination>` (`.tmp` + `fsync` + `rename` + `fsyncDir`), and registers an `fs.watchFile` poll on the source. Every mtime change triggers an automatic re-seal — the operator-visible `<destination>.rewriting` marker is created before the rename and removed after, giving crash recovery a signal: when `sealPemFile()` starts and the marker is present, it re-seals from source idempotently. Returns `{ stop, generation, lastResealedAt, lastError, watching, forceReseal }` so operators can wire the watcher into existing lifecycle hooks (`b.appShutdown.addPhase({ name: "pem-watcher", run: () => watcher.stop() })`). `pollInterval` defaults to 2s — ACME renewal cadence is days, so polling latency is irrelevant against the renewal interval; operators with sub-second requirements override. `fs.watchFile` (the polling backend) is used instead of `fs.watch` (inotify / kqueue) because watchFile is consistent across platforms — Linux fires multiple change events per rename, macOS doesn't fire on renamed-into files, and the polling cadence is acceptable here.
package/lib/mail-auth.js CHANGED
@@ -109,8 +109,12 @@ function _parseSpfRecord(text) {
109
109
  return mechanisms;
110
110
  }
111
111
 
112
- // Fetch the SPF TXT record for a domain. Returns the joined record
113
- // text or null if no v=spf1 record found.
112
+ // Fetch the SPF TXT record for a domain. Returns:
113
+ // { kind: "found", record: "<text>" } — exactly one v=spf1 record
114
+ // { kind: "none" } — zero v=spf1 records
115
+ // { kind: "permerror", reason: "<msg>" } — multiple v=spf1 records
116
+ // (RFC 7208 §4.5 — domain
117
+ // MUST publish at most one)
114
118
  async function _fetchSpfRecord(domain, dnsLookup) {
115
119
  var records;
116
120
  try {
@@ -118,17 +122,24 @@ async function _fetchSpfRecord(domain, dnsLookup) {
118
122
  ? await dnsLookup(domain, "TXT")
119
123
  : await dnsPromises.resolveTxt(domain);
120
124
  } catch (e) {
121
- if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
125
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return { kind: "none" };
122
126
  throw new MailAuthError("mail-auth/spf-lookup-failed",
123
127
  "SPF TXT lookup for " + domain + " failed: " +
124
128
  ((e && e.message) || String(e)));
125
129
  }
126
- if (!Array.isArray(records)) return null;
130
+ if (!Array.isArray(records)) return { kind: "none" };
131
+ var matches = [];
127
132
  for (var i = 0; i < records.length; i += 1) {
128
133
  var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
129
- if (typeof rec === "string" && rec.indexOf("v=spf1") === 0) return rec;
134
+ if (typeof rec === "string" && rec.indexOf("v=spf1") === 0) matches.push(rec);
130
135
  }
131
- return null;
136
+ if (matches.length === 0) return { kind: "none" };
137
+ if (matches.length > 1) {
138
+ return { kind: "permerror",
139
+ reason: "domain " + domain + " publishes " + matches.length +
140
+ " v=spf1 records; RFC 7208 §4.5 requires at most one" };
141
+ }
142
+ return { kind: "found", record: matches[0] };
132
143
  }
133
144
 
134
145
  // SPF verify — recursive include resolution + ip4/ip6/all/+a/+mx
@@ -166,17 +177,20 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
166
177
  }
167
178
  lookups.count += 1;
168
179
 
169
- var record;
170
- try { record = await _fetchSpfRecord(domain, dnsLookup); }
180
+ var fetched;
181
+ try { fetched = await _fetchSpfRecord(domain, dnsLookup); }
171
182
  catch (e) {
172
183
  return { verdict: "temperror", explanation: e.message };
173
184
  }
174
- if (!record) {
185
+ if (fetched.kind === "permerror") {
186
+ return { verdict: "permerror", explanation: fetched.reason };
187
+ }
188
+ if (fetched.kind === "none") {
175
189
  return { verdict: "none", explanation: "no SPF record at " + domain };
176
190
  }
177
191
 
178
192
  var mechanisms;
179
- try { mechanisms = _parseSpfRecord(record); }
193
+ try { mechanisms = _parseSpfRecord(fetched.record); }
180
194
  catch (e) {
181
195
  return { verdict: "permerror", explanation: e.message };
182
196
  }
@@ -201,6 +215,15 @@ async function _spfEvaluateDomain(domain, ip, dnsLookup, lookups) {
201
215
  else if (inner.verdict === "permerror" || inner.verdict === "temperror") {
202
216
  return inner;
203
217
  }
218
+ // RFC 7208 §5.2 — when the included domain has no SPF record at
219
+ // all, the include itself MUST permerror (the included policy is
220
+ // missing, the operator's intent is unverifiable). Without this
221
+ // check `include:gone-domain.example` silently authorizes whatever
222
+ // mechanism follows, including `+all`.
223
+ else if (inner.verdict === "none") {
224
+ return { verdict: "permerror",
225
+ explanation: "include:" + m.arg + " has no SPF record (RFC 7208 §5.2)" };
226
+ }
204
227
  } else if (m.mechanism === "a" || m.mechanism === "mx" ||
205
228
  m.mechanism === "exists" || m.mechanism === "ptr" ||
206
229
  m.mechanism === "redirect") {
package/lib/mail-dkim.js CHANGED
@@ -216,9 +216,15 @@ function create(opts) {
216
216
  throw new DkimError("dkim/bad-domain",
217
217
  "domain must be a valid DNS name (e.g. 'example.com')");
218
218
  }
219
- if (typeof opts.selector !== "string" || !/^[a-z0-9_-]+$/i.test(opts.selector)) {
219
+ // RFC 6376 §3.1 ABNF: selector = sub-domain *("." sub-domain). Multi-
220
+ // label selectors like "2024.s1" are valid (and common for time-rotated
221
+ // keys). Each label is the LDH set; refuse leading/trailing dots and
222
+ // empty labels.
223
+ if (typeof opts.selector !== "string" ||
224
+ opts.selector.length === 0 || opts.selector.length > 253 || // allow:raw-byte-literal — DNS label length cap (RFC 1035)
225
+ !/^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/i.test(opts.selector)) {
220
226
  throw new DkimError("dkim/bad-selector",
221
- "selector must be a non-empty token of [A-Za-z0-9_-]");
227
+ "selector must be a non-empty LDH token, optionally dot-separated (e.g. 's1', '2024.s1') (RFC 6376 §3.1)");
222
228
  }
223
229
  if (!opts.privateKey || (typeof opts.privateKey !== "string" &&
224
230
  typeof opts.privateKey !== "object")) {
@@ -566,6 +572,14 @@ function _verifySingleSignature(rfc822, parsedHeaders, sigHeader, keyTags, sigTa
566
572
  var headerNames = (sigTags.h || "").split(":").map(function (s) {
567
573
  return s.trim().toLowerCase();
568
574
  });
575
+ // RFC 6376 §3.5 — "from" MUST be in h=. Without From-coverage the
576
+ // signature does not bind to the visible sender, and the receiver's
577
+ // "this domain signed for that From" claim is meaningless. Cornerstone
578
+ // bypass class — refuse the signature outright.
579
+ if (headerNames.indexOf("from") === -1) {
580
+ return { result: "permerror",
581
+ errors: ["DKIM-Signature h= tag does not include 'from' (RFC 6376 §3.5)"] };
582
+ }
569
583
  var lcNames = parsedHeaders.map(function (h) { return h.name.toLowerCase(); });
570
584
  var canonicalizedHeaders = "";
571
585
  for (var j = 0; j < headerNames.length; j += 1) {
@@ -659,6 +673,13 @@ async function verify(rfc822, opts) {
659
673
  var d = sigTags.d;
660
674
  var s = sigTags.s;
661
675
  var alg = sigTags.a;
676
+ // RFC 6376 §3.5 — v= tag is REQUIRED and MUST be "1". Unrecognized
677
+ // version → permerror per spec; refuse rather than guess at intent.
678
+ if (sigTags.v !== undefined && sigTags.v !== "1") {
679
+ results.push({ d: d || null, s: s || null, alg: alg || null,
680
+ result: "permerror", errors: ["DKIM-Signature v=" + sigTags.v + " unsupported (RFC 6376 §3.5 — only v=1)"] });
681
+ continue;
682
+ }
662
683
  if (!d || !s) {
663
684
  results.push({ d: d || null, s: s || null, alg: alg || null,
664
685
  result: "permerror", errors: ["DKIM-Signature missing d= or s="] });
@@ -671,11 +692,32 @@ async function verify(rfc822, opts) {
671
692
  results.push({ d: d, s: s, alg: alg, result: verdict, errors: [e.message] });
672
693
  continue;
673
694
  }
695
+ if (keyTags.p === "") {
696
+ // RFC 6376 §3.6.1 — empty p= explicitly revokes the key. Verdict
697
+ // is "fail" (not "permerror") — the signature is well-formed but
698
+ // the key authority intentionally withdrew it.
699
+ results.push({ d: d, s: s, alg: alg, result: "fail",
700
+ errors: ["DKIM key revoked (empty p= per RFC 6376 §3.6.1)"] });
701
+ continue;
702
+ }
674
703
  if (!keyTags.p) {
675
704
  results.push({ d: d, s: s, alg: alg, result: "permerror",
676
705
  errors: ["DKIM key record missing p="] });
677
706
  continue;
678
707
  }
708
+ // RFC 6376 §3.6.1 — k= tag declares the key's algorithm family.
709
+ // Default is "rsa" when absent. If the key's k= disagrees with the
710
+ // signature's a= family, the operator who published the key intends
711
+ // a different algorithm; refuse rather than guess.
712
+ if (keyTags.k !== undefined) {
713
+ var kFamily = String(keyTags.k).toLowerCase();
714
+ var sigFamily = String(alg || "").toLowerCase().split("-")[0];
715
+ if (kFamily !== sigFamily) {
716
+ results.push({ d: d, s: s, alg: alg, result: "permerror",
717
+ errors: ["DKIM key k=" + kFamily + " does not match signature a=" + alg + " (RFC 6376 §3.6.1)"] });
718
+ continue;
719
+ }
720
+ }
679
721
  var rv = _verifySingleSignature(rfc822, parsedHeaders, sigHeaders[i], keyTags, sigTags);
680
722
  results.push(Object.assign({ d: d, s: s, alg: alg }, rv));
681
723
  }
@@ -117,13 +117,51 @@ function _parseStsPolicy(text) {
117
117
  return policy;
118
118
  }
119
119
 
120
- async function mtaStsFetch(domain) {
120
+ // RFC 8461 §3.1 precondition. The TXT record at _mta-sts.<domain> is
121
+ // the rotation signal: receivers re-fetch the HTTPS policy when the
122
+ // `id=` value changes. Without it the fetcher would re-pull the same
123
+ // cached policy forever (defeating operator rotation), and would also
124
+ // fetch policies from domains that don't publish one.
125
+ async function _fetchStsTxt(domain, dnsLookup) {
126
+ var records;
127
+ try {
128
+ records = dnsLookup
129
+ ? await dnsLookup("_mta-sts." + domain, "TXT")
130
+ : await dnsPromises.resolveTxt("_mta-sts." + domain);
131
+ } catch (e) {
132
+ if (e && (e.code === "ENOTFOUND" || e.code === "ENODATA")) return null;
133
+ throw new SmtpPolicyError("smtp/mta-sts-txt-lookup-failed",
134
+ "_mta-sts." + domain + " TXT lookup failed: " +
135
+ ((e && e.message) || String(e)));
136
+ }
137
+ if (!Array.isArray(records)) return null;
138
+ for (var i = 0; i < records.length; i += 1) {
139
+ var rec = Array.isArray(records[i]) ? records[i].join("") : records[i];
140
+ if (typeof rec !== "string") continue;
141
+ if (rec.indexOf("v=STSv1") === -1) continue;
142
+ var idMatch = /\bid=([A-Za-z0-9]{1,32})/.exec(rec);
143
+ return { record: rec, id: idMatch ? idMatch[1] : null };
144
+ }
145
+ return null;
146
+ }
147
+
148
+ async function mtaStsFetch(domain, opts) {
121
149
  if (typeof domain !== "string" || domain.length === 0) {
122
150
  throw new SmtpPolicyError("smtp/bad-domain",
123
151
  "mtaSts.fetch: domain must be a non-empty string");
124
152
  }
153
+ opts = opts || {};
125
154
  var lcDomain = domain.toLowerCase();
126
- return await _getStsCache().wrap(lcDomain, async function () {
155
+ // RFC 8461 §3.1 refuse to fetch the HTTPS policy if the
156
+ // _mta-sts TXT record is absent. Closes the silent-escalation
157
+ // class.
158
+ var txt = await _fetchStsTxt(lcDomain, opts.dnsLookup);
159
+ if (!txt) return null;
160
+
161
+ // Cache key includes the policy id so operator-side rotations (id
162
+ // changes) invalidate the cached policy without operator action.
163
+ var cacheKey = lcDomain + "|" + (txt.id || "noid");
164
+ return await _getStsCache().wrap(cacheKey, async function () {
127
165
  var url = "https://mta-sts." + lcDomain + "/.well-known/mta-sts.txt";
128
166
  safeUrl.parse(url, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
129
167
  var res;
@@ -135,8 +173,6 @@ async function mtaStsFetch(domain) {
135
173
  timeoutMs: C.TIME.seconds(10),
136
174
  });
137
175
  } catch (_e) {
138
- // Domain doesn't publish MTA-STS — return null (not an error;
139
- // operators decide policy via their own gate).
140
176
  return null;
141
177
  }
142
178
  if (res.statusCode === 404) return null; // allow:raw-byte-literal — HTTP 404
@@ -144,7 +180,23 @@ async function mtaStsFetch(domain) {
144
180
  throw new SmtpPolicyError("smtp/mta-sts-fetch-failed",
145
181
  "MTA-STS fetch returned " + res.statusCode + " for " + url);
146
182
  }
147
- return _parseStsPolicy(res.body.toString("utf8"));
183
+ var parsed = _parseStsPolicy(res.body.toString("utf8"));
184
+ parsed.id = txt.id || null;
185
+ parsed.fetchedAt = Date.now();
186
+ // RFC 8461 §3.2 — max_age caps the cache TTL. Bound between 1 hour
187
+ // (floor — operators using shorter values are below the spec
188
+ // recommended floor) and 31557600 seconds (RFC 8461 ceiling). When
189
+ // max_age is missing, fall back to the framework default.
190
+ var maxAgeSec = parsed.max_age;
191
+ if (typeof maxAgeSec === "number" && isFinite(maxAgeSec) && maxAgeSec > 0) {
192
+ var hourSec = C.TIME.hours(1) / C.TIME.seconds(1);
193
+ var ceilingSec = C.TIME.weeks(52) / C.TIME.seconds(1); // RFC 8461 §3.2 — ~1 year ceiling
194
+ var clamped = Math.max(hourSec, Math.min(ceilingSec, maxAgeSec));
195
+ parsed._cacheTtlMs = clamped * C.TIME.seconds(1);
196
+ } else {
197
+ parsed._cacheTtlMs = DEFAULT_POLICY_CACHE_MS;
198
+ }
199
+ return parsed;
148
200
  });
149
201
  }
150
202
 
@@ -882,6 +882,39 @@ function evaluateOcspResponse(ocspDer, opts) {
882
882
  } else if (parsed.basic.nonce) {
883
883
  nonceCheck = "present-not-checked";
884
884
  }
885
+ // RFC 6960 §4.2.2.1 — time-window enforcement. A "good" response is
886
+ // valid only between thisUpdate and nextUpdate (with operator-tunable
887
+ // skew). Without this check a stapled response is replayable forever:
888
+ // an attacker captures a pre-revocation "good" reply, the cert later
889
+ // gets revoked, the attacker keeps presenting the cached "good" and
890
+ // the framework keeps accepting it. requireGood postures depend on
891
+ // freshness — reject expired or future-dated responses outright.
892
+ var clockSkewMs = typeof opts.clockSkewMs === "number" && opts.clockSkewMs >= 0 // allow:numeric-opt-Infinity — operator-supplied skew, default 5 min if absent or invalid
893
+ ? opts.clockSkewMs : C.TIME.minutes(5);
894
+ var now = typeof opts.now === "number" ? opts.now : Date.now();
895
+ var thisUpdateMs = match.thisUpdate ? Date.parse(match.thisUpdate) : NaN;
896
+ var nextUpdateMs = match.nextUpdate ? Date.parse(match.nextUpdate) : NaN;
897
+ if (!isFinite(thisUpdateMs)) {
898
+ return { ok: false, status: parsed.status, signatureValid: true,
899
+ certStatus: match.certStatus,
900
+ thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
901
+ nonce: nonceCheck,
902
+ errors: ["OCSP response missing thisUpdate (RFC 6960 §4.2.2.1)"] };
903
+ }
904
+ if (thisUpdateMs - clockSkewMs > now) {
905
+ return { ok: false, status: parsed.status, signatureValid: true,
906
+ certStatus: match.certStatus,
907
+ thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
908
+ nonce: nonceCheck,
909
+ errors: ["OCSP thisUpdate is in the future (RFC 6960 §4.2.2.1 — possible clock skew or response replay)"] };
910
+ }
911
+ if (isFinite(nextUpdateMs) && nextUpdateMs + clockSkewMs < now) {
912
+ return { ok: false, status: parsed.status, signatureValid: true,
913
+ certStatus: match.certStatus,
914
+ thisUpdate: match.thisUpdate, nextUpdate: match.nextUpdate,
915
+ nonce: nonceCheck,
916
+ errors: ["OCSP response is past nextUpdate (RFC 6960 §4.2.2.1 — stale response, possible replay)"] };
917
+ }
885
918
  return {
886
919
  ok: match.certStatus === "good",
887
920
  status: parsed.status,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.8.15",
3
+ "version": "0.8.16",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:4f393a0e-4209-4c8e-8e7b-be64de2e522f",
5
+ "serialNumber": "urn:uuid:9d91dfe3-4449-4315-ac6c-d78f5dd92d0c",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-07T07:06:37.437Z",
8
+ "timestamp": "2026-05-07T07:18:43.056Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.8.15",
22
+ "bom-ref": "@blamejs/core@0.8.16",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.8.15",
25
+ "version": "0.8.16",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.8.15",
29
+ "purl": "pkg:npm/%40blamejs/core@0.8.16",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.8.15",
57
+ "ref": "@blamejs/core@0.8.16",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]