@blamejs/core 0.17.16 → 0.17.17

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.17.x
10
10
 
11
+ - v0.17.17 (2026-07-23) — **Three verifier hardening fixes: timestamp trust anchors are required by default, OIDC id_token verification requires a configured issuer, and ed25519 DKIM uses the RFC 8463 raw-key format.** An audit of the signature-verifier surface found three verifiers that accepted or rejected the wrong thing. b.tsa.verifyToken authenticated a timestamp against a certificate embedded in the token itself and only ran the trust-anchor chain when the caller supplied one, so a self-signed token carrying the timestamping EKU was accepted as a valid timestamp; trust anchors are now required by default. b.auth.oauth's verifyIdToken skipped the CVE-2026-23552 cross-realm iss check entirely when the client was created without an issuer, so any OIDC id_token verified regardless of its issuer; an OIDC client must now be configured with an issuer to verify id_tokens. And the ed25519 DKIM verifier could not read the RFC 8463 raw 32-byte key format that every conformant sender publishes -- while the framework's own bootstrap published the non-standard SPKI form -- so ed25519, the default DKIM algorithm, was non-interoperable in both directions. **Fixed:** *ed25519 DKIM uses the RFC 8463 raw-key format on both sign and verify* — RFC 8463 §3-4 publishes an ed25519 DKIM public key as the raw 32-byte key, base64'd -- the form every conformant sender uses -- but the verifier wrapped that raw key in SubjectPublicKeyInfo PEM markers, which is not valid SPKI, so createPublicKey threw and a valid ed25519 signature returned permerror. Compounding it, b.mail.dkim.bootstrap published the key as SPKI DER (60 base64 chars) instead of the raw form (44 chars), so the framework's own default-algorithm signatures would not verify at a conformant receiver. The verifier now accepts a raw 32-byte key (wrapping it in the ed25519 SPKI header) and bootstrap publishes the raw key; RSA is unchanged. The same raw-key handling is applied on the ARC (b.mail.arc.verify) message-signature path. **Security:** *Timestamp verification requires a trust anchor by default* — b.tsa.verifyToken verified an RFC 3161 timestamp against the signer certificate embedded in the token -- which is attacker-controlled -- and ran the certificate-chain / trust-anchor check only when the caller passed trustAnchorsPem. With it omitted, a token whose self-signed leaf carried a critical, sole id-kp-timeStamping EKU was accepted as a valid timestamp: a forged proof that data existed at an arbitrary time (RFC 3161 §2.4.2 requires a trusted TSA). trustAnchorsPem is now required by default; an operator who deliberately wants trust-anchor-free verification sets allowUntrustedIssuer:true and the result carries issuerTrusted:false so the unauthenticated posture is visible (mirrors b.mdoc.verifyIssuerSigned). · *OIDC id_token verification requires a configured issuer* — b.auth.oauth.verifyIdToken wrapped the expected-issuer comparison -- the CVE-2026-23552 cross-realm / cross-issuer defense -- in a check that ran only when the client was configured with an issuer. An OIDC client (the default) created without an issuer therefore accepted an id_token with any iss, or none, as long as the signature, aud, and exp were valid: exactly the cross-realm acceptance the verifier exists to close, dangerous against a shared-signing-key multi-tenant OP. verifyIdToken now refuses to verify an OIDC id_token when no expected issuer is configured (OIDC Core §3.1.3.7); pass issuer to b.auth.oauth.create().
12
+
11
13
  - v0.17.16 (2026-07-23) — **A shared path-containment primitive backs both the strict resolver and static file serving.** b.safePath.resolve and b.staticServe each hand-rolled the same lexical traversal-containment check -- resolve a request path against a base and confirm it stays strictly inside. That core is now one primitive, b.safePath.confineToBase, which both compose: resolve layers its user-input strictness (reserved names, NTFS ADS markers, bidi, control chars) on top, while static serving composes only the bare containment and keeps its own separate basename gate. Static serving keeps that separation deliberately -- its containment barrier and its per-file guardFilename validation are distinct concerns -- and inherits the resolver's cross-platform-aware containment, which the runtime path module missed for a backslash traversal on a POSIX host. Static serving's basename policy is unchanged. **Added:** *b.safePath.confineToBase -- the lexical traversal-containment core* — b.safePath.confineToBase(base, rel, opts?) resolves rel against base using the target platform's path semantics and returns the confined absolute path, or null if it escapes. It is the containment barrier b.safePath.resolve layers its user-input strictness on top of, exposed for a consumer that wants ONLY traversal containment and applies its own, differently-calibrated filename validation -- so it does not reject a reserved name, NTFS ADS marker, or trailing dot the way resolve does for untrusted input. opts.platform forces windows path semantics on any host. **Changed:** *Static file serving composes the shared containment primitive* — b.staticServe's path-traversal barrier now composes b.safePath.confineToBase for its final lexical containment instead of a hand-rolled join + base-prefix check, so both the strict resolver and static serving route through one implementation. Static serving gains the cross-platform-aware containment (the runtime path module treats the other platform's separator as an ordinary filename character and missed a backslash traversal on a POSIX host). Serve behavior is unchanged: containment and the separate per-file guardFilename basename gate remain distinct steps, and static composes only the containment core rather than resolve so it does not fuse resolve's all-segment user-input strictness into the barrier (which would reject a legitimate colon-named intermediate directory that the basename gate permits). The basename policy itself is untouched.
12
14
 
13
15
  - v0.17.15 (2026-07-23) — **The standalone multipart parser honors storage: memory and every other documented upload knob.** b.parsers.multipart(req, opts) is documented as a thin wrapper over the same engine b.middleware.bodyParser drives, but its option resolver carried a hand-maintained passthrough list that had drifted: storage ("disk" | "memory") and filenameCharsets were silently dropped, so a serverless / read-only-filesystem handler passing storage: "memory" got disk mode instead and threw when the parser tried to open a temp file. The passthrough is now derived from the parser's own defaults, so every documented knob reaches the standalone path and a future knob cannot silently vanish, and an invalid storage value throws at the call the same way the middleware does instead of falling through to disk. **Fixed:** *b.parsers.multipart honors storage: memory and filenameCharsets* — The standalone multipart parser's option resolver passed through a hand-maintained subset of the multipart knobs, omitting storage and filenameCharsets: b.parsers.multipart(req, { storage: "memory" }) silently used disk mode and then threw on a read-only / serverless filesystem, and a filename*=ISO-8859-1'' part could not be opted in via filenameCharsets. The passthrough is now derived from the parser's DEFAULTS so every documented knob reaches the standalone path (only the maxBytes/maxFiles aliases and the dispatch-only contentTypes are excluded), and an invalid storage value throws a TypeError at the call rather than falling back to disk -- matching b.middleware.bodyParser.
package/lib/auth/oauth.js CHANGED
@@ -2027,6 +2027,16 @@ function create(opts) {
2027
2027
  if (typeof payload.nbf === "number" && payload.nbf - skewSec > now) {
2028
2028
  throw new OAuthError("auth-oauth/nbf-future", "ID token nbf is in the future");
2029
2029
  }
2030
+ if (isOidc && !issuer) {
2031
+ // An OIDC id_token carries an `iss` that MUST be validated against a
2032
+ // configured expected issuer (OIDC Core §3.1.3.7). With no issuer set the
2033
+ // iss comparison below would be skipped entirely — the CVE-2026-23552
2034
+ // cross-realm acceptance this verifier exists to close. Fail closed
2035
+ // rather than accept an id_token whose issuer can't be authenticated.
2036
+ throw new OAuthError("auth-oauth/issuer-required",
2037
+ "verifyIdToken: an OIDC client must be configured with `issuer` to validate the " +
2038
+ "id_token's iss (OIDC Core 3.1.3.7 / cross-realm-JWT defense); pass issuer to b.auth.oauth.create()");
2039
+ }
2030
2040
  if (issuer) {
2031
2041
  // CVE-2026-23552 — cross-realm / cross-issuer JWT acceptance. The
2032
2042
  // expected issuer is operator-supplied; payload.iss is attacker-
package/lib/mail-auth.js CHANGED
@@ -1849,6 +1849,19 @@ async function _verifyArc(rfc822, hop, allHops, kind, dnsLookup, dkim) {
1849
1849
  var asUnsigned = dkim._stripBTagValue(sigValue);
1850
1850
  canonicalized += _canonRelaxedHeader("ARC-Seal", asUnsigned).replace(/\r\n$/, "");
1851
1851
 
1852
+ // RFC 6376 §3.6.1 / RFC 8617 — the ARC key record's k= (DEFAULT "rsa" when
1853
+ // absent) must match the seal's a= family. Without this, a record
1854
+ // `p=<raw-32-byte-key>` with no k= is an RSA record that would be misread as
1855
+ // Ed25519 and validate an a=ed25519-sha256 seal the record never authorized
1856
+ // (a key-family confusion that marks a forged chain as cryptographically
1857
+ // passing).
1858
+ var asKFamily = keyTags.k !== undefined ? String(keyTags.k).toLowerCase() : "rsa";
1859
+ var asSigFamily = String(tags.a || "").toLowerCase().split("-")[0];
1860
+ if (asKFamily !== asSigFamily) {
1861
+ return { result: "permerror",
1862
+ errors: [kind + ": key k=" + asKFamily + " does not match seal a=" + tags.a + " (RFC 6376 §3.6.1)"] };
1863
+ }
1864
+
1852
1865
  // Verify the AS signature.
1853
1866
  return _runVerify(canonicalized, tags.b, tags.a, keyTags.p, "as");
1854
1867
  }
@@ -1932,7 +1945,18 @@ function _canonRelaxedHeader(name, value) {
1932
1945
  return dkim.canonHeaderRelaxed(name, value);
1933
1946
  }
1934
1947
 
1948
+ // RFC 8410 Ed25519 SubjectPublicKeyInfo header prepended to a raw 32-byte key.
1949
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
1950
+
1935
1951
  function _pemFromB64KeyMaterial(b64) {
1952
+ // RFC 8463 §3-4 publishes an ed25519 DKIM key as the RAW 32-byte key
1953
+ // (base64'd), which is not valid SPKI DER; wrap it in the ed25519 SPKI header
1954
+ // first. Already-SPKI ed25519 (44 bytes) / RSA SPKI (larger) pass through.
1955
+ var raw = null;
1956
+ try { raw = Buffer.from(b64, "base64"); } catch (_e) { raw = null; }
1957
+ if (raw && raw.length === 32) {
1958
+ b64 = Buffer.concat([ED25519_SPKI_PREFIX, raw]).toString("base64");
1959
+ }
1936
1960
  var pem = "-----BEGIN PUBLIC KEY-----\n";
1937
1961
  for (var i = 0; i < b64.length; i += 64) { // PEM wrap width
1938
1962
  pem += b64.slice(i, i + 64) + "\n"; // PEM wrap width
package/lib/mail-dkim.js CHANGED
@@ -645,10 +645,21 @@ async function _safeResolveTxt(qname, operatorLookup) {
645
645
 
646
646
  function _resetDkimKeyCacheForTest() { DKIM_KEY_CACHE.clear(); }
647
647
 
648
+ // RFC 8410 Ed25519 SubjectPublicKeyInfo header (algorithm id + BIT STRING
649
+ // wrapper) prepended to a raw 32-byte key to form valid SPKI DER.
650
+ var ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
651
+
648
652
  function _pemFromB64KeyMaterial(b64) {
649
- // RSA: SubjectPublicKeyInfo DER in base64. Ed25519: raw 32-byte key
650
- // OR SPKI DER. Wrap in PEM markers so node:crypto.createPublicKey
651
- // accepts it.
653
+ // RSA: SubjectPublicKeyInfo DER in base64. Ed25519: RFC 8463 §3-4 publishes
654
+ // the RAW 32-byte key (base64'd) the canonical form every conformant
655
+ // ed25519 sender uses — which is NOT valid SPKI DER; wrap it in the ed25519
656
+ // SPKI header first. An already-SPKI ed25519 key (44 bytes) or an RSA SPKI
657
+ // key (larger) is passed through unchanged.
658
+ var raw = null;
659
+ try { raw = Buffer.from(b64, "base64"); } catch (_e) { raw = null; }
660
+ if (raw && raw.length === 32) {
661
+ b64 = Buffer.concat([ED25519_SPKI_PREFIX, raw]).toString("base64");
662
+ }
652
663
  var pem = "-----BEGIN PUBLIC KEY-----\n";
653
664
  // 64-char wrap (PEM convention).
654
665
  for (var i = 0; i < b64.length; i += 64) { // PEM wrap width
@@ -1113,18 +1124,18 @@ async function verify(rfc822, opts) {
1113
1124
  errors: ["DKIM key record missing p="] });
1114
1125
  continue;
1115
1126
  }
1116
- // RFC 6376 §3.6.1 — k= tag declares the key's algorithm family.
1117
- // Default is "rsa" when absent. If the key's k= disagrees with the
1118
- // signature's a= family, the operator who published the key intends
1119
- // a different algorithm; refuse rather than guess.
1120
- if (keyTags.k !== undefined) {
1121
- var kFamily = String(keyTags.k).toLowerCase();
1122
- var sigFamily = String(alg || "").toLowerCase().split("-")[0];
1123
- if (kFamily !== sigFamily) {
1124
- results.push({ d: d, s: s, alg: alg, result: "permerror",
1125
- errors: ["DKIM key k=" + kFamily + " does not match signature a=" + alg + " (RFC 6376 §3.6.1)"] });
1126
- continue;
1127
- }
1127
+ // RFC 6376 §3.6.1 — k= tag declares the key's algorithm family, DEFAULT
1128
+ // "rsa" when absent. If it disagrees with the signature's a= family the
1129
+ // operator who published the key intends a different algorithm; refuse
1130
+ // rather than guess. The default matters: a record `p=<raw-32-byte-key>`
1131
+ // with no k= is an RSA record, and must NOT be read as an Ed25519 key just
1132
+ // because the bytes are 32 long (a key-family confusion).
1133
+ var kFamily = keyTags.k !== undefined ? String(keyTags.k).toLowerCase() : "rsa";
1134
+ var sigFamily = String(alg || "").toLowerCase().split("-")[0];
1135
+ if (kFamily !== sigFamily) {
1136
+ results.push({ d: d, s: s, alg: alg, result: "permerror",
1137
+ errors: ["DKIM key k=" + kFamily + " does not match signature a=" + alg + " (RFC 6376 §3.6.1)"] });
1138
+ continue;
1128
1139
  }
1129
1140
  var rv = _verifySingleSignature(rfc822, parsedHeaders, sigHeaders[i], keyTags, sigTags, verifyOpts);
1130
1141
  results.push(Object.assign({ d: d, s: s, alg: alg }, rv));
@@ -1310,7 +1321,17 @@ function _bootstrapSingle(algorithm, domain, selector, rsaBits) {
1310
1321
  }
1311
1322
  var publicKeyPemObj = nodeCrypto.createPublicKey({ key: keyPair.publicKey, type: "spki", format: "der" });
1312
1323
  var publicKeyPem = publicKeyPemObj.export({ type: "spki", format: "pem" });
1313
- var pBase64 = Buffer.from(keyPair.publicKey).toString("base64");
1324
+ // RFC 8463 §3-4: an ed25519 DKIM key MUST be published as the RAW 32-byte key
1325
+ // (base64'd), not SPKI DER — the SPKI form (60 base64 chars) does not verify
1326
+ // at a conformant receiver. The raw key is the trailing 32 bytes of the SPKI
1327
+ // encoding. RSA keeps the SPKI DER form (RFC 6376).
1328
+ var pBase64;
1329
+ if (k === "ed25519") {
1330
+ var spkiDer = Buffer.from(keyPair.publicKey);
1331
+ pBase64 = spkiDer.subarray(spkiDer.length - 32).toString("base64");
1332
+ } else {
1333
+ pBase64 = Buffer.from(keyPair.publicKey).toString("base64");
1334
+ }
1314
1335
  var dnsName = selector + "._domainkey." + domain;
1315
1336
  // RFC 6376 §3.6.1 record syntax: v=DKIM1; k=<alg>; p=<base64>
1316
1337
  // The optional t/s/g/n/h/k tags omitted (operator can re-edit
package/lib/tsa.js CHANGED
@@ -567,9 +567,13 @@ function _assertValidAt(cert, atMs) {
567
567
  * <code>id-ct-TSTInfo</code>, the message imprint equals the hash of
568
568
  * <code>opts.data</code> (or <code>opts.hash</code>), a sent nonce
569
569
  * round-trips, the signer cert's extendedKeyUsage is a critical, sole
570
- * <code>id-kp-timeStamping</code>, and the CMS signature verifies. Pass
571
- * <code>opts.trustAnchorsPem</code> to also verify the certificate
572
- * chain and validity at the asserted time.
570
+ * <code>id-kp-timeStamping</code>, and the CMS signature verifies. The
571
+ * signer certificate is embedded in the token (and therefore
572
+ * attacker-controlled), so <code>opts.trustAnchorsPem</code> is
573
+ * <strong>required by default</strong> — without it a self-signed token
574
+ * would pass on its self-contained signature alone. To accept an
575
+ * unauthenticated timestamp, set <code>opts.allowUntrustedIssuer:true</code>;
576
+ * the result then carries <code>issuerTrusted:false</code>.
573
577
  *
574
578
  * @opts
575
579
  * {
@@ -577,17 +581,18 @@ function _assertValidAt(cert, atMs) {
577
581
  * hash: Buffer, // OR a pre-computed digest (with hashAlg)
578
582
  * hashAlg: string, // default "SHA-512" — must match the imprint
579
583
  * nonce: Buffer, // require the token nonce to match (from buildRequest)
580
- * trustAnchorsPem: string|string[], // PEM root(s) enables chain + validity verification
584
+ * trustAnchorsPem: string|string[], // PEM trust root(s) to authenticate the TSA REQUIRED unless allowUntrustedIssuer
585
+ * allowUntrustedIssuer: boolean, // accept a token with no trust anchor (result carries issuerTrusted:false)
581
586
  * at: Date, // validity instant for chain check (default: genTime); must be a valid Date
582
587
  * }
583
588
  *
584
589
  * @example
585
- * var out = b.tsa.verifyToken(resp.token, { data: tarball, hashAlg: "SHA-512", nonce: req.nonce });
586
- * // → { genTime, policy, serialHex, accuracy, hashAlg, signerCertPem }
590
+ * var out = b.tsa.verifyToken(resp.token, { data: tarball, hashAlg: "SHA-512", nonce: req.nonce, trustAnchorsPem: caPem });
591
+ * // → { genTime, policy, serialHex, accuracy, hashAlg, signerCertPem, issuerTrusted: true }
587
592
  */
588
593
  function verifyToken(token, opts) {
589
594
  validateOpts.requireObject(opts, "tsa.verifyToken", TsaError);
590
- validateOpts(opts, ["data", "hash", "hashAlg", "nonce", "trustAnchorsPem", "at"], "tsa.verifyToken");
595
+ validateOpts(opts, ["data", "hash", "hashAlg", "nonce", "trustAnchorsPem", "allowUntrustedIssuer", "at"], "tsa.verifyToken");
591
596
  if (opts.data == null && opts.hash == null) {
592
597
  throw new TsaError("tsa/no-data", "tsa.verifyToken: pass opts.data or opts.hash to bind the token");
593
598
  }
@@ -654,10 +659,25 @@ function verifyToken(token, opts) {
654
659
  "tsa.verifyToken: no certificate in the token both carries the timestamping EKU and verifies the signature");
655
660
  }
656
661
 
657
- // (7) optional chain + validity. Accept a single PEM string or an
658
- // array never silently skip chain verification when the caller
659
- // supplied an anchor in an unexpected shape (a fail-open).
660
- if (opts.trustAnchorsPem !== undefined && opts.trustAnchorsPem !== null) {
662
+ // (7) Trust anchors REQUIRED by default. The signer certificate is embedded
663
+ // in the token (sd.certificates) and attacker-controlled, so verifying only
664
+ // the self-contained CMS signature + the timestamping EKU proves the token is
665
+ // self-consistent a forged timestamp (attacker keypair, self-signed leaf
666
+ // with the timeStamping EKU) passes unless the chain is anchored to a
667
+ // configured trust root (RFC 3161 §2.4.2). Require trustAnchorsPem by default;
668
+ // an operator who genuinely wants trust-anchor-free verification must opt in
669
+ // EXPLICITLY (and gets issuerTrusted:false so the unauthenticated posture is
670
+ // visible). Mirrors b.mdoc.verifyIssuerSigned; fail closed.
671
+ var issuerTrusted = opts.trustAnchorsPem !== undefined && opts.trustAnchorsPem !== null;
672
+ if (!issuerTrusted && opts.allowUntrustedIssuer !== true) {
673
+ throw new TsaError("tsa/trust-anchors-required",
674
+ "tsa.verifyToken: opts.trustAnchorsPem is required to authenticate the timestamping TSA " +
675
+ "(the signer certificate is embedded in the token and attacker-controlled); pass trustAnchorsPem, " +
676
+ "or set allowUntrustedIssuer:true to explicitly accept an unauthenticated timestamp (issuerTrusted:false)");
677
+ }
678
+ // Accept a single PEM string or an array — never silently skip chain
679
+ // verification when the caller supplied an anchor in an unexpected shape.
680
+ if (issuerTrusted) {
661
681
  var anchors = typeof opts.trustAnchorsPem === "string" ? [opts.trustAnchorsPem] : opts.trustAnchorsPem;
662
682
  if (!Array.isArray(anchors) || anchors.length === 0 ||
663
683
  !anchors.every(function (a) { return typeof a === "string" && a.length > 0; })) {
@@ -678,6 +698,7 @@ function verifyToken(token, opts) {
678
698
  accuracy: tst.accuracy,
679
699
  hashAlg: imp.hashName,
680
700
  signerCertPem: new nodeCrypto.X509Certificate(signerCertDer).toString(),
701
+ issuerTrusted: issuerTrusted,
681
702
  };
682
703
  }
683
704
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.17.16",
3
+ "version": "0.17.17",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -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:361e1563-6d41-499d-b266-962cb2dc64ef",
5
+ "serialNumber": "urn:uuid:7533261e-9d68-4674-b24d-8a05ae89f2aa",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-24T03:55:41.218Z",
8
+ "timestamp": "2026-07-24T06:49:37.276Z",
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.17.16",
22
+ "bom-ref": "@blamejs/core@0.17.17",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.17.16",
25
+ "version": "0.17.17",
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.17.16",
29
+ "purl": "pkg:npm/%40blamejs/core@0.17.17",
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.17.16",
57
+ "ref": "@blamejs/core@0.17.17",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]