@blamejs/core 0.7.38 → 0.7.40

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.40** (2026-05-05) — RFC 8617 ARC chain-validity hardening: duplicate-instance refusal, gap detection, hop ceiling, per-hop `cv=` rule enforcement. Closes a class of chain-injection holes the prior `arcVerify` would silently accept. **Duplicate instance** — two `ARC-Seal` (or AMS / AAR) headers at the same `i=N` now fail with `reason: "duplicate-instance"` instead of silently letting the second header overwrite the first signer's record (the latter is a known forwarder-injection attack). **Non-contiguous chain** — gap detection (`i=1, i=3` with no `i=2`) now correctly fails with `reason: "incomplete-or-non-contiguous"`. The prior `.some()` check skipped sparse-array empty slots and let the gap through. **Hop ceiling** — RFC 8617 §5.1.2 caps the chain at 50 sets; verifier now refuses with `reason: "too-many-hops"`. **Per-hop `cv=` rules** — i=1 MUST be `cv=none` (no upstream chain to validate); i≥2 MUST be `cv=pass` or `cv=fail` (cv=none invalid past hop 1); a downstream `cv=pass` after an upstream `cv=fail` is invalid (a hop can't claim chain-pass when an earlier hop saw it fail). Each violation surfaces with a structured `reason` field. **Result shape gains** `perHopCv: [string|null,...]` (one entry per hop) and `reason: string` when `chainStatus === "fail"` so operators can distinguish "signature failed" from "chain rules violated" without parsing free-text errors. Smoke 8642 → 8648 / wiki e2e 178 / Linux container smoke 8648 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
12
+
13
+ - **0.7.39** (2026-05-05) — RFC 8954 OCSP nonce extension default-on, plus persistent-log reliability fix in the smoke runner. **`b.network.tls.ocsp.buildRequest(opts)`** (NEW) — constructs a DER-encoded OCSPRequest for a single (`leafCertDer`, `issuerCertDer`) pair. The RFC 8954 nonce extension is **on by default** (security-defaults-on rule); operators talking to responders that ignore nonces opt out via `nonce: false`. Default nonce length is 16 bytes (RFC 8954 §2.1 floor 1, ceiling 32 — `nonceLen` overrides). CertID hashes are SHA-1 per RFC 6960 §4.1.1 (the universally-supported algorithm; SHA-256 in OCSP requests is §4.3 optional and many responders reject). Returns `{ requestDer, nonce }`. Operators send `requestDer` to the OCSP responder URL via `b.httpClient` (Content-Type: `application/ocsp-request`) and pass the returned `nonce` Buffer to `evaluate(responseDer, { issuerPem, expectedNonce })` — defends against replay attacks where an attacker captures a "good" OCSP response and replays it after the cert is revoked. **DER writer surface added to `lib/asn1-der.js`** — `writeNode` / `writeSequence` / `writeOctetString` / `writeInteger` / `writeNull` / `writeOid` / `writeContextExplicit` (minimal, ~80 lines, X.690 short + long-form length encoding). **`evaluate(der, opts)` accepts `expectedNonce`** — when supplied, the response's nonce extension MUST match (else `tls/ocsp-nonce-mismatch`). Result shape gains a `nonce` field: `"matched"` / `"present-not-checked"` / `"n/a"`. **Smoke-runner persistent log reliability fix** — the `.test-output/smoke.log` tee was using `fs.createWriteStream` (async); when the runner threw on a test failure the buffer didn't flush before `process.exit`, so the failure detail never reached the log. Replaced with synchronous `fs.writeSync` against an open fd — every byte hits disk before exit. Surface change for diagnosing future failures: the log now ALWAYS captures the failure stack instead of truncating mid-run. Smoke 8635 → 8642 / wiki e2e 178 / Linux container smoke 8642 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
14
+
11
15
  - **0.7.38** (2026-05-05) — RFC 6698 + RFC 7672 DANE certificate-chain verification, plus smoke-runner LPT scheduling and a wiki failure-detail bugfix. **`b.network.smtp.dane.verifyChain(certChain, tlsaRecords, opts?)`** (NEW) — walks the peer cert chain (leaf-first DER buffers — typically `sock.getPeerCertificate(true).raw`) and confirms at least one TLSA record matches per the record's usage / selector / mtype. SMTP outbound (RFC 7672) only honors `DANE-TA` (2) and `DANE-EE` (3); `PKIX-TA` (0) and `PKIX-EE` (1) require a full PKIX path validator + CA-bundle and are refused unless the operator opts in via `allowPkixModes`. Selector `Cert` (0) compares the full DER; selector `SPKI` (1) compares the SubjectPublicKeyInfo bytes (extracted via the framework ASN.1 walker). Matching types: `Full`, sha-two-family at the short-digest length, sha-two-family at the long-digest length. Returns `{ ok, matches: [{ tlsaIndex, certIndex, usage, mtype }], errors }`. **ASN.1 walker** — `lib/asn1-der.js` `readNode` now surfaces a `.raw` field on each node (header + value bytes from the source buffer) so SPKI extraction can return the exact wire bytes for byte-identical TLSA matching. **Smoke-runner LPT scheduling** — `test/smoke.js` parallel mode now uses Longest-Processing-Time-first scheduling with a continuous worker queue. Per-test durations persist under `.test-output/smoke-timings.json` keyed by `process.platform` (so host win32/darwin and Linux container don't pollute each other's medians) with a 5-run history per test. Replaces the prior batched `Promise.all(slice(i, i + PARALLEL))` scheduling that left workers idle when one batch contained the long-tail test. **Wiki failure-detail bugfix** — `examples/wiki/test/e2e.js` was printing the per-example failure list AFTER the assert that throws on failure → operators only saw "1 of 178 failed" without WHICH example. Reordered so failure detail prints first. Smoke 8627 → 8635 / wiki e2e 178 / Linux container smoke 8635 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
12
16
 
13
17
  - **0.7.37** (2026-05-05) — RFC 8460 TLS-RPT submission transport: `b.network.smtp.tlsRpt.fetchPolicy` + `submit`. The previous `recordShape` only generated the JSON; operators had to wire submission themselves. **`b.network.smtp.tlsRpt.fetchPolicy(domain, opts)`** (NEW) — reads the RFC 8460 §3 `_smtp._tls.<domain>` TXT record and returns `{ version, rua: [string,...] }` where `rua` is the comma-separated list of report URIs (`https://` and `mailto:`) the recipient publishes. Returns `null` when no record is published. `opts.dnsLookup` is operator-supplied so the call composes with `b.network.dns` (DoH / DoT) without a hard dep; falls back to `node:dns/promises.resolveTxt`. **`b.network.smtp.tlsRpt.submit(report, opts)`** (NEW) — submits a TLS-RPT report to the published rua endpoints. `https://` URIs receive an HTTPS `POST` with `Content-Type: application/tlsrpt+gzip` and a gzip-compressed JSON body per RFC 8460 §6.2. `mailto:` URIs return a prepared `{ to, subject, contentType, encoding, body }` object so operators hand it to `b.mail` with their configured relay (the framework doesn't bake an SMTP relay in — operators wire transport per their environment). Per-endpoint result records carry `{ uri, kind, ok, status, error }` so a failure on one rua doesn't cascade. Routes through `b.httpClient` so SSRF + DNS-pin + retry policy come for free. Smoke 8619 → 8627 / wiki e2e 178 / Linux container smoke 8627 / Linux container wiki e2e 178 / eslint clean / api-snapshot baseline refreshed.
package/lib/asn1-der.js CHANGED
@@ -250,6 +250,81 @@ function unwrapExplicit(node, expectedTag) {
250
250
  return readNode(node.value, 0);
251
251
  }
252
252
 
253
+ // ---- DER writers (minimal — just the shapes the framework needs) ----
254
+ //
255
+ // Encoders for the small ASN.1 set the framework currently constructs:
256
+ // SEQUENCE / OCTET STRING / OID / INTEGER / NULL / context-specific
257
+ // [N] EXPLICIT. Fewer than 100 lines because we only emit the DER that
258
+ // crosses the wire (OCSP requests today; future TLSA encoding tomorrow).
259
+ // All length fields use the standard X.690 short / long-form encoding.
260
+
261
+ function _encodeLength(n) {
262
+ if (n < 128) return Buffer.from([n]); // allow:raw-byte-literal — X.690 short-form length boundary
263
+ // Long-form: first byte is 0x80 | numLengthOctets, then the length
264
+ // big-endian.
265
+ var bytes = [];
266
+ while (n > 0) {
267
+ bytes.unshift(n & 0xff); // allow:raw-byte-literal — base-256 length encoding mask
268
+ n = n >>> 8; // allow:raw-byte-literal — base-256 length encoding shift
269
+ }
270
+ return Buffer.concat([Buffer.from([0x80 | bytes.length]), Buffer.from(bytes)]);
271
+ }
272
+
273
+ function writeNode(tagByte, value) {
274
+ return Buffer.concat([Buffer.from([tagByte]), _encodeLength(value.length), value]);
275
+ }
276
+
277
+ function writeSequence(children) {
278
+ // children: Array<Buffer> of already-encoded child nodes.
279
+ return writeNode(TAG.SEQUENCE | 0x20, Buffer.concat(children)); // allow:raw-byte-literal — DER constructed bit
280
+ }
281
+
282
+ function writeOctetString(value) {
283
+ return writeNode(TAG.OCTET_STRING, value);
284
+ }
285
+
286
+ function writeNull() {
287
+ return writeNode(TAG.NULL, Buffer.alloc(0));
288
+ }
289
+
290
+ function writeInteger(buf) {
291
+ // INTEGER values are big-endian. If high bit of leading byte is set
292
+ // and the value is positive (cert serials always are here), prepend
293
+ // 0x00 to disambiguate from a negative two's complement.
294
+ if (buf.length === 0) return writeNode(TAG.INTEGER, Buffer.from([0]));
295
+ if (buf[0] & 0x80) { // allow:raw-byte-literal — sign-bit disambiguation
296
+ return writeNode(TAG.INTEGER, Buffer.concat([Buffer.from([0]), buf]));
297
+ }
298
+ return writeNode(TAG.INTEGER, buf);
299
+ }
300
+
301
+ function writeOid(dotted) {
302
+ // Encode dotted-decimal OID per X.690 §8.19.
303
+ var parts = String(dotted).split(".").map(function (s) { return parseInt(s, 10); });
304
+ if (parts.length < 2) {
305
+ throw new Asn1Error("asn1/oid-too-short", "OID needs at least 2 arcs");
306
+ }
307
+ var bytes = [parts[0] * 40 + parts[1]]; // allow:raw-byte-literal — OID first-arc encoding
308
+ for (var i = 2; i < parts.length; i += 1) {
309
+ var arc = parts[i];
310
+ if (arc === 0) { bytes.push(0); continue; }
311
+ var stack = [];
312
+ while (arc > 0) {
313
+ stack.unshift(arc & 0x7f); // allow:raw-byte-literal — base-128 mask
314
+ arc = arc >>> 7; // allow:raw-byte-literal — base-128 shift
315
+ }
316
+ for (var j = 0; j < stack.length - 1; j += 1) stack[j] |= 0x80; // allow:raw-byte-literal — continuation bit
317
+ for (var k = 0; k < stack.length; k += 1) bytes.push(stack[k]);
318
+ }
319
+ return writeNode(TAG.OID, Buffer.from(bytes));
320
+ }
321
+
322
+ function writeContextExplicit(tagNumber, child) {
323
+ // [N] EXPLICIT — context-specific class (0xA0 | tag) + constructed.
324
+ var tagByte = 0xa0 | (tagNumber & 0x1f); // allow:raw-byte-literal — context-specific constructed mask
325
+ return writeNode(tagByte, child);
326
+ }
327
+
253
328
  // Find a child node of a SEQUENCE / SET by predicate. Returns null if
254
329
  // no child matches.
255
330
  function findChild(children, predicate) {
@@ -260,15 +335,22 @@ function findChild(children, predicate) {
260
335
  }
261
336
 
262
337
  module.exports = {
263
- TAG_CLASS: TAG_CLASS,
264
- TAG: TAG,
265
- readNode: readNode,
266
- readSequence: readSequence,
267
- readOid: readOid,
268
- readOctetString: readOctetString,
269
- readUnsignedInt: readUnsignedInt,
270
- readBitString: readBitString,
271
- unwrapExplicit: unwrapExplicit,
272
- findChild: findChild,
273
- Asn1Error: Asn1Error,
338
+ TAG_CLASS: TAG_CLASS,
339
+ TAG: TAG,
340
+ readNode: readNode,
341
+ readSequence: readSequence,
342
+ readOid: readOid,
343
+ readOctetString: readOctetString,
344
+ readUnsignedInt: readUnsignedInt,
345
+ readBitString: readBitString,
346
+ unwrapExplicit: unwrapExplicit,
347
+ findChild: findChild,
348
+ writeNode: writeNode,
349
+ writeSequence: writeSequence,
350
+ writeOctetString: writeOctetString,
351
+ writeInteger: writeInteger,
352
+ writeNull: writeNull,
353
+ writeOid: writeOid,
354
+ writeContextExplicit: writeContextExplicit,
355
+ Asn1Error: Asn1Error,
274
356
  };
package/lib/mail-auth.js CHANGED
@@ -376,6 +376,10 @@ function _parseHeaderLines(headerSection) {
376
376
  return unfolded;
377
377
  }
378
378
 
379
+ // RFC 8617 §5.1.2 caps the chain at 50 sets to bound verifier work and
380
+ // limit how far an attacker can push junk headers.
381
+ var ARC_MAX_HOPS = 50; // allow:raw-byte-literal — RFC 8617 §5.1.2 chain ceiling
382
+
379
383
  async function arcVerify(rfc822, opts) {
380
384
  if (typeof rfc822 !== "string" || rfc822.length === 0) {
381
385
  throw new MailAuthError("mail-auth/arc-bad-input",
@@ -384,8 +388,16 @@ async function arcVerify(rfc822, opts) {
384
388
  opts = opts || {};
385
389
  var headers = _parseHeaderLines(_splitHeaders(rfc822));
386
390
  var hops = [];
387
-
388
- // 1. Index ARC headers by instance number.
391
+ var seenSlot = {}; // {`<instance>:<name>`: true} — duplicate detection
392
+
393
+ // 1. Index ARC headers by instance number. Refuse duplicates: a single
394
+ // hop has exactly one ARC-Seal / ARC-Message-Signature /
395
+ // ARC-Authentication-Results. A second copy at the same instance is
396
+ // a malformed chain (per RFC 8617 §5.1 implicit, and a known
397
+ // injection vector — a forwarder that re-signs with a duplicate
398
+ // instance would silently overwrite the original signer).
399
+ var duplicate = false;
400
+ var maxInstanceSeen = 0;
389
401
  for (var i = 0; i < headers.length; i += 1) {
390
402
  var line = headers[i];
391
403
  var colonAt = line.indexOf(":");
@@ -396,7 +408,11 @@ async function arcVerify(rfc822, opts) {
396
408
  name !== "arc-authentication-results") continue;
397
409
  var iMatch = value.match(/(?:^|[;,\s])i=(\d+)/); // allow:regex-no-length-cap — header bounded by RFC 5322 998
398
410
  var inst = iMatch ? parseInt(iMatch[1], 10) : null;
399
- if (inst === null) continue;
411
+ if (inst === null || !isFinite(inst) || inst < 1) continue;
412
+ if (inst > maxInstanceSeen) maxInstanceSeen = inst;
413
+ var slotKey = inst + ":" + name;
414
+ if (seenSlot[slotKey]) { duplicate = true; continue; }
415
+ seenSlot[slotKey] = true;
400
416
  if (!hops[inst - 1]) hops[inst - 1] = { instance: inst };
401
417
  hops[inst - 1][name] = value;
402
418
  }
@@ -405,14 +421,48 @@ async function arcVerify(rfc822, opts) {
405
421
  return { chainStatus: "none", hopCount: 0, hops: [] };
406
422
  }
407
423
 
408
- // 2. Structural check — every hop must carry all three headers.
409
- var structuralFail = hops.some(function (h) {
410
- return !h || !h["arc-seal"] || !h["arc-message-signature"] ||
411
- !h["arc-authentication-results"];
412
- });
424
+ if (duplicate) {
425
+ return {
426
+ chainStatus: "fail",
427
+ reason: "duplicate-instance",
428
+ hopCount: hops.filter(Boolean).length,
429
+ hops: hops.filter(Boolean).map(function (h) {
430
+ return { instance: h.instance,
431
+ hasSeal: !!h["arc-seal"],
432
+ hasMessageSignature: !!h["arc-message-signature"],
433
+ hasAuthenticationResults: !!h["arc-authentication-results"],
434
+ amsResult: "skipped", asResult: "skipped" };
435
+ }),
436
+ };
437
+ }
438
+
439
+ if (maxInstanceSeen > ARC_MAX_HOPS) {
440
+ return {
441
+ chainStatus: "fail",
442
+ reason: "too-many-hops",
443
+ hopCount: maxInstanceSeen,
444
+ hops: [],
445
+ };
446
+ }
447
+
448
+ // 2. Structural check — every hop must carry all three headers AND
449
+ // the chain must start at i=1 with no gaps. RFC 8617 §5.1 requires
450
+ // instances to form a contiguous 1..N sequence. Indexed loop (not
451
+ // .some) because sparse arrays skip empty slots in callbacks —
452
+ // a non-contiguous chain ([hop1, , hop3]) would silently pass.
453
+ var structuralFail = false;
454
+ for (var sci = 0; sci < hops.length; sci += 1) {
455
+ var sch = hops[sci];
456
+ if (!sch || !sch["arc-seal"] || !sch["arc-message-signature"] ||
457
+ !sch["arc-authentication-results"]) {
458
+ structuralFail = true;
459
+ break;
460
+ }
461
+ }
413
462
  if (structuralFail) {
414
463
  return {
415
464
  chainStatus: "fail",
465
+ reason: "incomplete-or-non-contiguous",
416
466
  hopCount: hops.filter(Boolean).length,
417
467
  hops: hops.filter(Boolean).map(function (h) {
418
468
  return { instance: h.instance,
@@ -454,23 +504,73 @@ async function arcVerify(rfc822, opts) {
454
504
  if (amsResult.result !== "pass" || asResult.result !== "pass") anyFail = true;
455
505
  }
456
506
 
457
- // 4. Chain Validation per §5.2: the most recent AS's cv= must
458
- // reflect the validity of every prior hop.
459
- var lastAs = hops[hops.length - 1]["arc-seal"];
460
- var cvMatch = lastAs.match(/(?:^|[;,\s])cv=(none|pass|fail)/);
461
- var cv = cvMatch ? cvMatch[1] : null;
507
+ // 4. Chain Validation per RFC 8617 §5.2.
508
+ //
509
+ // Per-hop cv= self-attestation rules:
510
+ // i=1 — cv=none REQUIRED (no upstream chain to validate)
511
+ // i>=2 — cv=pass or cv=fail; cv=none is invalid at i>=2
512
+ //
513
+ // Once any hop's AS reports cv=fail, the chain is permanently
514
+ // broken — downstream cv=pass claims after an upstream cv=fail
515
+ // are malformed (a hop can't claim the chain validates when it
516
+ // knows an earlier hop saw it fail).
517
+ var perHopCv = [];
518
+ var hopRuleViolation = null;
519
+ var sawFail = false;
520
+ for (var hi = 0; hi < hops.length; hi += 1) {
521
+ var as = hops[hi]["arc-seal"];
522
+ var hopCvMatch = as.match(/(?:^|[;,\s])cv=(none|pass|fail)/);
523
+ var hopCv = hopCvMatch ? hopCvMatch[1] : null;
524
+ perHopCv.push(hopCv);
525
+ if (hopCv === null) {
526
+ hopRuleViolation = "missing-cv-at-i=" + (hi + 1);
527
+ break;
528
+ }
529
+ if (hi === 0 && hopCv !== "none") {
530
+ hopRuleViolation = "i=1-cv-must-be-none-got-" + hopCv;
531
+ break;
532
+ }
533
+ if (hi >= 1 && hopCv === "none") {
534
+ hopRuleViolation = "i=" + (hi + 1) + "-cv=none-invalid-after-hop-1";
535
+ break;
536
+ }
537
+ if (hopCv === "fail") sawFail = true;
538
+ if (hopCv === "pass" && sawFail) {
539
+ hopRuleViolation = "i=" + (hi + 1) + "-cv=pass-after-upstream-fail";
540
+ break;
541
+ }
542
+ }
462
543
 
463
- var chainStatus = anyFail ? "fail" :
464
- (hops.length === 1 && cv === "none") ? "pass" :
465
- (hops.length > 1 && cv === "pass") ? "pass" :
466
- "fail";
544
+ var lastCv = perHopCv[perHopCv.length - 1];
545
+ var chainStatus;
546
+ var reasonOut = null;
547
+ if (hopRuleViolation) {
548
+ chainStatus = "fail";
549
+ reasonOut = hopRuleViolation;
550
+ } else if (anyFail) {
551
+ chainStatus = "fail";
552
+ reasonOut = "signature-verification-failed";
553
+ } else if (lastCv === "fail") {
554
+ chainStatus = "fail";
555
+ reasonOut = "last-as-cv=fail";
556
+ } else if (hops.length === 1 && lastCv === "none") {
557
+ chainStatus = "pass";
558
+ } else if (hops.length > 1 && lastCv === "pass") {
559
+ chainStatus = "pass";
560
+ } else {
561
+ chainStatus = "fail";
562
+ reasonOut = "unexpected-cv-state";
563
+ }
467
564
 
468
- return {
565
+ var out = {
469
566
  chainStatus: chainStatus,
470
567
  hopCount: hops.length,
471
- cv: cv,
568
+ cv: lastCv,
569
+ perHopCv: perHopCv,
472
570
  hops: perHop,
473
571
  };
572
+ if (reasonOut) out.reason = reasonOut;
573
+ return out;
474
574
  }
475
575
 
476
576
  // Verify a single AMS or AS within the chain by reconstructing the
@@ -412,6 +412,9 @@ function _connectAndCheckOcsp(opts, requireStapled) {
412
412
  // validation (operators relying on those wire their own parser).
413
413
 
414
414
  var OID_BASIC_OCSP_RESPONSE = "1.3.6.1.5.5.7.48.1.1";
415
+ // OCSP nonce extension — id-pkix-ocsp-nonce.
416
+ var OID_OCSP_NONCE = "1.3.6.1.5.5.7.48.1.2";
417
+ var OID_SHA1 = "1.3.14.3.2.26"; // allow:raw-byte-literal — SHA-1 algorithm OID arc
415
418
  var OID_RSA_SHA256 = "1.2.840.113549.1.1.11";
416
419
  var OID_RSA_SHA384 = "1.2.840.113549.1.1.12";
417
420
  var OID_RSA_SHA512 = "1.2.840.113549.1.1.13";
@@ -533,17 +536,53 @@ function parseOcspResponse(der) {
533
536
  // ResponderID is itself a CHOICE (byName [1] / byKey [2]), then a
534
537
  // GeneralizedTime, then the responses SEQUENCE-OF.
535
538
  var responsesNode = null;
539
+ var responseExtensionsNode = null;
536
540
  for (var rdi = rdChildren.length - 1; rdi >= 0; rdi -= 1) {
537
541
  var ch = rdChildren[rdi];
538
542
  if (ch.tag === asn1.TAG.SEQUENCE && ch.tagClass === asn1.TAG_CLASS.UNIVERSAL) {
539
543
  responsesNode = ch;
540
544
  break;
541
545
  }
546
+ if (ch.tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC && ch.tag === 1) { // [1] EXPLICIT responseExtensions
547
+ responseExtensionsNode = asn1.readNode(ch.value, 0);
548
+ }
542
549
  }
543
550
  if (!responsesNode) {
544
551
  throw new TlsTrustError("tls/ocsp-bad-shape",
545
552
  "ResponseData missing responses SEQUENCE OF");
546
553
  }
554
+ // Walk responseExtensions for the OCSP nonce (RFC 8954 / RFC 6960
555
+ // §4.4.1). Returns the raw nonce bytes when present, or null.
556
+ var responseNonce = null;
557
+ if (responseExtensionsNode && responseExtensionsNode.tag === asn1.TAG.SEQUENCE) {
558
+ var extKids = asn1.readSequence(responseExtensionsNode.value);
559
+ for (var ei = 0; ei < extKids.length; ei += 1) {
560
+ var ext = extKids[ei];
561
+ if (ext.tag !== asn1.TAG.SEQUENCE) continue;
562
+ var extChildren = asn1.readSequence(ext.value);
563
+ if (extChildren.length === 0) continue;
564
+ var extOid;
565
+ try { extOid = asn1.readOid(extChildren[0]); }
566
+ catch (_e3) { continue; }
567
+ if (extOid !== OID_OCSP_NONCE) continue;
568
+ var extnValue = asn1.readOctetString(extChildren[extChildren.length - 1]);
569
+ // RFC 8954 §2.1 — the nonce extension value is the raw bytes
570
+ // wrapped in an OCTET STRING (the value here). RFC 6960 §4.4.1
571
+ // historically wrapped the nonce in another OCTET STRING; tolerate
572
+ // both shapes.
573
+ try {
574
+ var inner = asn1.readNode(extnValue);
575
+ if (inner.tag === asn1.TAG.OCTET_STRING) {
576
+ responseNonce = inner.value;
577
+ } else {
578
+ responseNonce = extnValue;
579
+ }
580
+ } catch (_e4) {
581
+ responseNonce = extnValue;
582
+ }
583
+ break;
584
+ }
585
+ }
547
586
  var singleResponses = asn1.readSequence(responsesNode.value);
548
587
  var responses = [];
549
588
  for (var sri = 0; sri < singleResponses.length; sri += 1) {
@@ -586,6 +625,7 @@ function parseOcspResponse(der) {
586
625
  signatureAlgorithmOid: sigAlgOid,
587
626
  signature: signatureBytes,
588
627
  responses: responses,
628
+ nonce: responseNonce,
589
629
  },
590
630
  };
591
631
  }
@@ -669,6 +709,28 @@ function evaluateOcspResponse(ocspDer, opts) {
669
709
  return { ok: false, status: parsed.status, signatureValid: true,
670
710
  errors: ["OCSP response has no entry for the requested cert serial"] };
671
711
  }
712
+ // Optional nonce echo verification (RFC 8954 / RFC 6960 §4.4.1).
713
+ // When opts.expectedNonce is supplied, the response MUST carry an
714
+ // OCSP nonce extension equal to the expected bytes — defends against
715
+ // replay of a stale "good" response captured before revocation.
716
+ var nonceCheck = "n/a";
717
+ if (opts.expectedNonce !== undefined && opts.expectedNonce !== null) {
718
+ if (!Buffer.isBuffer(opts.expectedNonce)) {
719
+ return { ok: false, status: parsed.status, signatureValid: true,
720
+ errors: ["evaluateOcspResponse: opts.expectedNonce must be a Buffer when supplied"] };
721
+ }
722
+ if (!parsed.basic.nonce) {
723
+ return { ok: false, status: parsed.status, signatureValid: true,
724
+ errors: ["OCSP response missing nonce extension (expected for replay defense)"] };
725
+ }
726
+ if (!parsed.basic.nonce.equals(opts.expectedNonce)) {
727
+ return { ok: false, status: parsed.status, signatureValid: true,
728
+ errors: ["OCSP nonce mismatch — possible replay or wrong responder"] };
729
+ }
730
+ nonceCheck = "matched";
731
+ } else if (parsed.basic.nonce) {
732
+ nonceCheck = "present-not-checked";
733
+ }
672
734
  return {
673
735
  ok: match.certStatus === "good",
674
736
  status: parsed.status,
@@ -676,11 +738,146 @@ function evaluateOcspResponse(ocspDer, opts) {
676
738
  thisUpdate: match.thisUpdate,
677
739
  nextUpdate: match.nextUpdate,
678
740
  signatureValid: true,
741
+ nonce: nonceCheck,
679
742
  errors: match.certStatus === "good" ? [] :
680
743
  ["certStatus=" + match.certStatus],
681
744
  };
682
745
  }
683
746
 
747
+ // ---- OCSPRequest builder (RFC 6960 §4.1 + RFC 8954 nonce ext) ----
748
+ //
749
+ // Constructs a DER-encoded OCSPRequest for a single (leafCertDer,
750
+ // issuerCertDer) pair, optionally with an RFC 8954 nonce extension.
751
+ // Operators send the returned `requestDer` to the OCSP responder URL
752
+ // (e.g. via b.httpClient with `Content-Type: application/ocsp-request`)
753
+ // and pass `nonce` to `ocsp.evaluate(responseDer, { expectedNonce })`
754
+ // to defend against replay attacks.
755
+ //
756
+ // Nonce DEFAULT ON — defense in depth. RFC 6960 §4.4.1 marks nonce
757
+ // optional and some public responders (notably Let's Encrypt's) ignore
758
+ // it; operators explicitly targeting those responders opt out via
759
+ // `opts.nonce: false`. The framework default is RFC 8954 with 16 random
760
+ // bytes (RFC 8954 §2.1 floor; ceiling 32).
761
+
762
+ function _extractIssuerNameDerAndKeyBitString(certDer) {
763
+ // From the leaf cert's tbsCertificate, pull the issuer Name (DER) +
764
+ // the issuer's SubjectPublicKey BIT STRING content. For OCSP CertID,
765
+ // RFC 6960 §4.1.1 specifies hash(issuerName) and hash(issuerKey).
766
+ // This helper operates on the ISSUER cert (not the leaf): the issuer's
767
+ // Name and SubjectPublicKey are what get hashed.
768
+ var top = asn1.readNode(certDer);
769
+ if (top.tag !== asn1.TAG.SEQUENCE) {
770
+ throw new TlsTrustError("tls/ocsp-bad-issuer-cert", "issuer cert is not a SEQUENCE");
771
+ }
772
+ var children = asn1.readSequence(top.value);
773
+ if (children.length === 0) {
774
+ throw new TlsTrustError("tls/ocsp-bad-issuer-cert", "issuer cert has no children");
775
+ }
776
+ var tbs = children[0];
777
+ if (tbs.tag !== asn1.TAG.SEQUENCE) {
778
+ throw new TlsTrustError("tls/ocsp-bad-issuer-cert", "tbsCertificate is not a SEQUENCE");
779
+ }
780
+ var tbsKids = asn1.readSequence(tbs.value);
781
+ // Skip optional [0] EXPLICIT version, then serialNumber, signature,
782
+ // then issuer (the cert's own subject in a self-signed CA, or its
783
+ // issuer field for a sub-CA — we just want THIS cert's subject).
784
+ var idx = 0;
785
+ if (tbsKids.length > 0 &&
786
+ tbsKids[0].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
787
+ tbsKids[0].tag === 0) { // allow:raw-byte-literal — X.509 [0] EXPLICIT version tag
788
+ idx = 1;
789
+ }
790
+ // After version: serialNumber, signature, issuer, validity, subject, SPKI.
791
+ var subjectIdx = idx + 4; // allow:raw-byte-literal — X.509 TBSCertificate field count
792
+ var spkiIdx = idx + 5; // allow:raw-byte-literal — X.509 TBSCertificate field count
793
+ if (spkiIdx >= tbsKids.length) {
794
+ throw new TlsTrustError("tls/ocsp-bad-issuer-cert", "issuer cert lacks SPKI field");
795
+ }
796
+ var subject = tbsKids[subjectIdx];
797
+ var spki = tbsKids[spkiIdx];
798
+ // Within SPKI: SEQUENCE { algorithm AlgorithmIdentifier, subjectPublicKey BIT STRING }
799
+ var spkiKids = asn1.readSequence(spki.value);
800
+ if (spkiKids.length < 2) { // allow:raw-byte-literal — minimum SPKI fields
801
+ throw new TlsTrustError("tls/ocsp-bad-issuer-cert", "SPKI missing subjectPublicKey BIT STRING");
802
+ }
803
+ var keyBytes = asn1.readBitString(spkiKids[1]);
804
+ return {
805
+ issuerNameDer: subject.raw, // the DER of the Name SEQUENCE (header + value)
806
+ issuerKey: keyBytes,
807
+ };
808
+ }
809
+
810
+ function _extractLeafSerial(leafCertDer) {
811
+ var top = asn1.readNode(leafCertDer);
812
+ if (top.tag !== asn1.TAG.SEQUENCE) {
813
+ throw new TlsTrustError("tls/ocsp-bad-leaf-cert", "leaf cert is not a SEQUENCE");
814
+ }
815
+ var children = asn1.readSequence(top.value);
816
+ var tbs = children[0];
817
+ var tbsKids = asn1.readSequence(tbs.value);
818
+ var idx = 0;
819
+ if (tbsKids.length > 0 &&
820
+ tbsKids[0].tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC &&
821
+ tbsKids[0].tag === 0) { // allow:raw-byte-literal — X.509 [0] EXPLICIT version tag
822
+ idx = 1;
823
+ }
824
+ // serialNumber is the next field after the optional version.
825
+ return tbsKids[idx].value;
826
+ }
827
+
828
+ function buildOcspRequest(opts) {
829
+ opts = opts || {};
830
+ if (!Buffer.isBuffer(opts.leafCertDer)) {
831
+ throw new TlsTrustError("tls/ocsp-bad-input",
832
+ "buildRequest: opts.leafCertDer must be a Buffer (peer cert raw DER)");
833
+ }
834
+ if (!Buffer.isBuffer(opts.issuerCertDer)) {
835
+ throw new TlsTrustError("tls/ocsp-bad-input",
836
+ "buildRequest: opts.issuerCertDer must be a Buffer (issuer cert raw DER)");
837
+ }
838
+ var iss = _extractIssuerNameDerAndKeyBitString(opts.issuerCertDer);
839
+ var serial = _extractLeafSerial(opts.leafCertDer);
840
+ // CertID hashes — SHA-1 per RFC 6960 §4.1.1 (the only universally
841
+ // supported algorithm; SHA-256 in OCSP requests is RFC 6960 §4.3
842
+ // optional and many responders reject).
843
+ var nameHash = nodeCrypto.createHash("sha1").update(iss.issuerNameDer).digest();
844
+ var keyHash = nodeCrypto.createHash("sha1").update(iss.issuerKey).digest();
845
+ // hashAlgorithm AlgorithmIdentifier ::= SEQUENCE { algorithm OID, NULL }
846
+ var algId = asn1.writeSequence([asn1.writeOid(OID_SHA1), asn1.writeNull()]);
847
+ var certId = asn1.writeSequence([
848
+ algId,
849
+ asn1.writeOctetString(nameHash),
850
+ asn1.writeOctetString(keyHash),
851
+ asn1.writeInteger(serial),
852
+ ]);
853
+ var requestNode = asn1.writeSequence([certId]);
854
+ var requestList = asn1.writeSequence([requestNode]);
855
+ var nonceBytes = null;
856
+ var tbsChildren = [requestList];
857
+ // Default ON per the framework security-defaults-on rule. Operators
858
+ // talking to a responder that ignores nonces opt out via nonce: false.
859
+ var includeNonce = opts.nonce !== false;
860
+ if (includeNonce) {
861
+ var nonceLen = typeof opts.nonceLen === "number" ? opts.nonceLen : 16; // allow:raw-byte-literal — RFC 8954 §2.1 nonce length floor
862
+ if (nonceLen < 1 || nonceLen > 32) { // allow:raw-byte-literal — RFC 8954 §2.1 nonce length ceiling
863
+ throw new TlsTrustError("tls/ocsp-bad-nonce-len",
864
+ "nonce length out of RFC 8954 range (1..32)");
865
+ }
866
+ nonceBytes = nodeCrypto.randomBytes(nonceLen);
867
+ // Extension ::= SEQUENCE { extnID OID, critical BOOL DEFAULT FALSE, extnValue OCTET STRING }
868
+ // For nonce, extnValue is OCTET STRING wrapping the raw nonce bytes (RFC 8954 §2.1 — outer OCTET STRING only).
869
+ var nonceExt = asn1.writeSequence([
870
+ asn1.writeOid(OID_OCSP_NONCE),
871
+ asn1.writeOctetString(nonceBytes),
872
+ ]);
873
+ var extensions = asn1.writeSequence([nonceExt]);
874
+ tbsChildren.push(asn1.writeContextExplicit(2, extensions)); // [2] EXPLICIT requestExtensions
875
+ }
876
+ var tbs = asn1.writeSequence(tbsChildren);
877
+ var requestDer = asn1.writeSequence([tbs]);
878
+ return { requestDer: requestDer, nonce: nonceBytes };
879
+ }
880
+
684
881
  var ocsp = Object.freeze({
685
882
  // Connect with OCSP requested. Returns { authorized, ocspBytes,
686
883
  // peerCert }. requireStapled: true makes empty / not-stapled responses
@@ -721,6 +918,13 @@ var ocsp = Object.freeze({
721
918
  },
722
919
  parseResponse: parseOcspResponse,
723
920
  evaluate: evaluateOcspResponse,
921
+ // buildRequest — construct a DER-encoded OCSPRequest for a single
922
+ // (leafCertDer, issuerCertDer) pair. RFC 8954 nonce extension is ON
923
+ // by default (16 random bytes; opts.nonceLen overrides within RFC
924
+ // 8954's 1..32 range; opts.nonce: false opts out for responders that
925
+ // ignore nonces). Returns { requestDer, nonce }; pass `nonce` to
926
+ // evaluate({ expectedNonce }).
927
+ buildRequest: buildOcspRequest,
724
928
  // inspectMustStaple — read the RFC 7633 TLS Feature extension on a
725
929
  // peer cert. Returns { mustStaple, features }. mustStaple === true
726
930
  // when status_request (5) is in the feature list; the cert is then
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.38",
3
+ "version": "0.7.40",
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:3adb7877-ed69-4d8b-8275-eccb0ef99166",
5
+ "serialNumber": "urn:uuid:bd5fd6e1-f474-44da-9714-adb6fbf43b34",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-05T19:15:46.191Z",
8
+ "timestamp": "2026-05-05T19:17:14.991Z",
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.7.38",
22
+ "bom-ref": "@blamejs/core@0.7.40",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.38",
25
+ "version": "0.7.40",
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.7.38",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.40",
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.7.38",
57
+ "ref": "@blamejs/core@0.7.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]