@blamejs/core 0.7.39 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.39",
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:3c8db39e-745e-4810-ae2a-7b527c808492",
5
+ "serialNumber": "urn:uuid:bd5fd6e1-f474-44da-9714-adb6fbf43b34",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-05T19:17:04.824Z",
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.39",
22
+ "bom-ref": "@blamejs/core@0.7.40",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.39",
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.39",
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.39",
57
+ "ref": "@blamejs/core@0.7.40",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]