@blamejs/core 0.16.23 → 0.16.24

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.16.x
10
10
 
11
+ - v0.16.24 (2026-07-12) — **Restore the file-persistence mode of the outbound HTTP cookie jar, which failed on its first run before any cookies were saved.** b.httpClient.cookieJar.create({ persist: "file", file }) threw a CookieJarError with code LOAD_FAILED on the first run -- when the persist file does not yet exist -- instead of starting with an empty jar as documented ("loaded at create() if the file exists"). The load path recognized only a raw ENOENT for the missing file, but the safe reader it uses reports a missing file as the typed code atomic-file/enoent, so the normal first-use case fell through to LOAD_FAILED and the file-persistence mode never worked from a clean state. A missing persist file now starts an empty jar; a genuine read failure (symlink, over-size, or a concurrent-truncation TOCTOU) still fails closed as LOAD_FAILED. This surfaced while covering the cookie jar's previously-untested parse, match, and persistence branches. **Fixed:** *b.httpClient.cookieJar file persistence starts an empty jar on first run* — A cookie jar created with persist: "file" loads the persist file at create() time. On the very first run the file does not exist yet, and the safe file reader surfaces that missing file as the typed code atomic-file/enoent; the jar's load handler only treated a raw ENOENT as "start empty", so every first use threw CookieJarError LOAD_FAILED and the advertised file-persistence mode could never be initialized. The handler now treats both the raw ENOENT and the typed atomic-file/enoent as an empty starting state, while any other read failure (a symlink where a regular file was expected, an over-size file, or a truncation race) still fails closed as LOAD_FAILED. **Security:** *Continuous fuzzing now covers the DER/ASN.1, CMS, and HTTP Link-header parsers* — The DER/ASN.1 byte parser -- which sits under every untrusted-certificate path in the framework (peer TLS certificates, S/MIME, BIMI VMCs, CMS, ACME and TSA responses) -- the CMS SignedData / EnvelopedData parser (b.cms), and the HTTP Link response-header parser (b.linkHeader.parse) all consume attacker-controlled bytes, but because they are not named safe-* or guard-* they had sat outside the fuzz-harness discipline every other parser in the framework follows and had no fuzz coverage. Each now ships a coverage-guided fuzz harness run in CI, and the fuzz gate now requires a harness for an untrusted-byte parser regardless of its filename, so a regression that crashes one of these parsers instead of refusing the input cannot ship unnoticed. The current parsers refuse malformed input with typed errors under this fuzzing; the change is preventive coverage for the whole hostile-input parsing surface, not a fix for a present crash.
12
+
11
13
  - v0.16.23 (2026-07-12) — **Merge an OpenID-Federation trust chain's per-level metadata policies into one combined policy and validate the operator combination, so a subordinate can only narrow a superior's constraint and cannot downgrade it through a different operator.** b.auth.openidFederation.applyMetadataPolicy resolved a chain's metadata_policy by applying each level's block in sequence, anchor-first then leaf-ward, so a leaf-ward level could override a value the anchor pinned -- and even after that was closed, a subordinate could still escape a superior's constraint by expressing the override with a DIFFERENT operator, because the resolution never checked that the combined operator set was consistent. An attacker controlling an intermediate could pin token_endpoint_auth_method with value 'none' against an anchor's one_of ['private_key_jwt'], widen a scope set past an anchor's subset_of ceiling with add, or replace an anchor's superset_of-mandated grant with a weaker one via value -- each resolved to the downgraded metadata with no error. OpenID Federation 1.0 §6.1.3 defines a fixed operator processing order and a set of operator-combination rules (value combines only with essential; add must stay within subset_of and cover superset_of; default must satisfy a co-present one_of / subset_of / superset_of; one_of does not combine with the array operators; superset_of must be within subset_of). applyMetadataPolicy now gathers every superior-signed subordinate statement's policy for the metadata kind, merges them superior-first (value / default equal-or-conflict, one_of / subset_of intersect, add / superset_of union, essential OR), validates the merged operator combination for every claim -- refusing the chain when a subordinate's operator is inconsistent with a superior's -- and applies the combined policy once in the fixed operator order so the value-check operators validate the final value. Consistent narrowings (a subordinate tightening a one_of or subset_of, adding within a subset_of, defaulting within a one_of) are unaffected. **Security:** *b.auth.openidFederation resolves metadata policy by merging every chain level and validating the operator combination, blocking cross-operator trust downgrades* — applyMetadataPolicy walked the chain applying each level's metadata_policy block in turn, so the leaf-ward block won any disagreement -- a subordinate could override a value the anchor pinned. Merging the levels closes the same-operator case, but a subordinate could still escape a superior's constraint expressed with a different operator: an anchor's one_of ['private_key_jwt'] on token_endpoint_auth_method was overridden by a leaf-ward value 'none' (client authentication removed at the token endpoint), an anchor's subset_of scope ceiling was widened by a subordinate add, and an anchor's superset_of-mandated grant was dropped by a subordinate value substituting the implicit flow -- each resolved to the downgraded metadata with no error, because the resolution validated only same-operator value/default conflicts and applied operators in object-key order rather than the OpenID Federation 1.0 §6.1.3 fixed order, so the value-check operators ran before the value they were meant to guard was set. Policy resolution now gathers each superior-signed subordinate statement's policy for the metadata kind (the leaf's own self-published policy is never read), merges them superior-first (value / default must agree across levels or the chain is refused, one_of / subset_of intersect, add / superset_of union, essential OR), and applies the single combined policy in the OpenID Federation 1.0 §6.1.3 fixed operator order (value, add, default, then the one_of / subset_of / superset_of / essential checks) so the merged, most-restrictive constraints validate the final value. A value outside a merged one_of, an add that widens past a merged subset_of, or a value that drops a superset_of-mandated member is therefore refused; a subordinate cannot widen an exact pinned value by adding a value not already in it; and a one_of whose values are disjoint across chain levels leaves no admissible value and is refused. A subordinate can only narrow a superior's constraint, never override it through any operator; a value pinned inside a superior's allowed set, and consistent narrowings generally, are unaffected. Resolution also refuses a prototype-pollution key (__proto__, constructor, prototype) in a policy block's claim names or in the base metadata, since those are never valid metadata parameter names and merging them into the combined policy would write Object.prototype. · *b.auth.openidFederation enforces the array metadata-policy operators on a claim the entity declares as a space-delimited string* — The subset_of metadata-policy operator restricts an array-valued claim (grant_types, response_types, redirect_uris, and similar) to an allow-list, but the check only ran when the claim was already an array. Because an entity fully controls its own self-published metadata, a leaf could declare a subset_of-constrained claim as a single string and the array guard skipped, so the anchor's allow-list was silently not enforced and the entity kept a value the trust anchor forbade. The array operators (subset_of, superset_of, add) now process a space-delimited string as an array of strings, per OpenID Federation 1.0 §6.1.3.1.8 -- so an OAuth scope like "openid email" is checked (and each token in a leaf's string is validated against the allow-list, catching a smuggled value), while a genuine non-array, non-string value is refused. An absent claim under subset_of is still left absent, since subset_of constrains a value that is present rather than requiring presence.
12
14
 
13
15
  - v0.16.22 (2026-07-12) — **Bind an OpenID-Federation entity's configuration to the keys its superior attests, bind the COSE null-digest signature algorithms (EdDSA / ML-DSA-87) to the key type, make the verifiable-credential COSE media-type header mandatory, and turn two PGP parse crashes into typed refusals.** Covering untested adversarial branches across the federation, COSE, verifiable-credential, and PGP verifiers surfaced a set of fail-open and crash defects, fixed at the root. b.auth.openidFederation.buildTrustChain verified each entity configuration only against its own self-published keys (integrity, not provenance) and never re-verified it against the keys the superior's subordinate statement attests, so an attacker controlling an entity's .well-known endpoint (but not its attested signing key) could serve forged metadata and have the chain accept it; each non-anchor entity's configuration is now bound to its superior-attested keys. b.cose.verify bound only the ECDSA algorithms to their curve and left the null-digest algorithms (EdDSA, ML-DSA-87) with no key binding, so node:crypto -- which dispatches on the key type, not the declared COSE alg -- accepted an EdDSA signature under an ML-DSA-only allowlist against an Ed25519 key (and the reverse); every signable COSE algorithm is now bound to its key type or curve, on both sign and verify. b.vc enforced the vc+cose / vp+cose media-type header only when present, so a COSE credential omitting it was accepted with no media-type binding; the header is now mandatory, symmetric with the JOSE path. And two PGP parsers (an rsa-pss key type that cannot express the module's signatures, and a truncated post-quantum decrypt envelope) crashed with an untyped Error instead of a typed refusal. **Fixed:** *b.mail.crypto.pgp turns an rsa-pss key and a truncated decrypt envelope into typed refusals* — Two adversarial inputs escaped as an untyped Error (which an operator catch keyed on b.mail.crypto.isMailCryptoError never catches) instead of the module's typed refusal. An rsa-pss key type was declared-accepted by sign() and verify() but cannot express the module's PKCS#1-v1.5 signatures and crashes in the shared JWK extraction before any crypto runs; it is now refused as a typed bad-key-type / key-alg-mismatch. And the post-quantum decrypt envelope parser advanced its offset using attacker-controlled length fields without bounds-checking, so a truncated envelope threw a RangeError; every read and slice in the envelope parser is now bounds-checked and a truncated envelope fails closed with a typed refusal. Valid signatures and envelopes are unaffected. · *b.vc rejects a non-XSD validity date* — The validFrom / validUntil parser used Date.parse, which is lenient -- a bare year, a slash-date, a date without a time, and locale strings all parsed to some instant and were given a heuristic validity window, weakening the expiry decision the verifier relies on (Verifiable Credentials Data Model 2.0 §4.9 requires an XSD dateTime). The parser now asserts an XSD dateTime shape before parsing, so a non-conformant validity value fails closed at both issue and verify. · *b.audit.checkpoint is idempotent on an already-anchored tip* — Anchoring a checkpoint read the audit-log tip, optionally skipped if unchanged, then inserted a checkpoint row whose atMonotonicCounter is unique -- but the read and the insert were not atomic, so two checkpoints of the same tip (concurrent anchors, or a second checkpoint() call without skipIfUnchanged) both computed the same counter and the loser threw a raw UNIQUE-constraint error instead of the documented no-op. Because two anchors of one tip sign the identical payload, the collision means the counter is already anchored: checkpoint() now returns null in that case (matching the skipIfUnchanged already-anchored path) and any unrecognized database error still surfaces. **Security:** *b.auth.openidFederation binds each entity configuration to its superior-attested keys* — buildTrustChain verified every entity configuration against its own self-published JWKS (integrity) and separately verified the superior-signed subordinate statement, but never re-verified an entity's own configuration -- the source of the effective metadata resolveLeaf returns -- against the keys the superior attests. An attacker who controls an entity's <entity_id>/.well-known/openid-federation endpoint but not its attested signing key could serve a self-signed configuration carrying forged metadata and attacker keys; the honest superior's subordinate statement pinned the entity's real keys, the chain was accepted, and the forged metadata was returned (OpenID Federation 1.0 §9 requires the leaf/intermediate configuration to be verified with the JWKS the superior pins). Each non-anchor entity's configuration is now verified against its superior-attested JWKS before those keys are adopted, so the operator-pinned trust anchor flows down to gate every entity's own configuration; the anchor is already verified against the operator-pinned keys. · *b.cose binds the null-digest signature algorithms (EdDSA / ML-DSA-87) to the key type* — verify() honored the declared COSE algorithm allowlist but bound only the ECDSA algorithms to their curve; the null-digest algorithms (EdDSA -8, ML-DSA-87 -50) had no key binding at all. node:crypto.verify(null, ...) dispatches on the key type and ignores the declared COSE alg, so an EdDSA signature was accepted and reported as ML-DSA-87 when the verifier's allowlist was PQC-only and it held an Ed25519 key (and the reverse), fully bypassing the algorithm allowlist; sign() had the mirror defect and could emit an EdDSA-labeled token signed with an ML-DSA key. Every signable algorithm is now bound to its key -- ECDSA by curve, EdDSA / ML-DSA-87 by key type -- on both sign and verify. The AEAD and HMAC paths bind the algorithm into the AAD / MAC structure, so no key-dispatch confusion exists there. · *b.vc requires the COSE media-type header on a verifiable credential and presentation* — The COSE verify path enforced the vc+cose / vp+cose type header (RFC 9596) only when it was present, while the JOSE path mandated it -- so an issuer-signed COSE credential or presentation that omitted the header was accepted with no binding to its media type (a cross-media-type-confusion fail-open on the verify seam, where two securing forms diverged on the same should-be-mandatory check). The COSE path now requires the type header unconditionally, symmetric with the JOSE path; b.vc.issue / b.vc.present always emit it, so no round-trip regresses.
@@ -185,6 +185,16 @@ function create(opts) {
185
185
  throw _err("BAD_OPT",
186
186
  "cookieJar.create: opts.file must be an absolute path, got " + JSON.stringify(filePath));
187
187
  }
188
+ // A vault is optional for file mode (it seals the on-disk bytes); when
189
+ // supplied it MUST be a real sealer, validated up front like the
190
+ // persist:"vault" path. Otherwise a malformed vault is not caught until a
191
+ // later debounced flush -- and on a first run (the persist file absent) the
192
+ // jar returns empty before any seal call could surface it, so writes would
193
+ // be silently dropped.
194
+ if (vault) {
195
+ validateOpts.requireMethods(vault, ["seal", "unseal"],
196
+ "cookieJar.create: persist: 'file' opts.vault (pass b.vault to seal the on-disk bytes)", CookieJarError, "BAD_OPT");
197
+ }
188
198
  }
189
199
  if (opts.flushDebounceMs !== undefined && !numericBounds.isNonNegativeFiniteInt(opts.flushDebounceMs)) {
190
200
  throw _err("BAD_OPT", "cookieJar.create: flushDebounceMs must be a non-negative finite integer; got " +
@@ -492,7 +502,12 @@ function create(opts) {
492
502
  maxBytes: C.BYTES.mib(16), encoding: "utf8", refuseSymlink: true, inodeCheck: true,
493
503
  });
494
504
  } catch (e) {
495
- if (e && e.code === "ENOENT") { raw = null; } // first run no persist file yet
505
+ // First run no persist file yet. A missing file surfaces as a raw
506
+ // ENOENT, or (when fdSafeReadSync's default errorFor maps it) as the
507
+ // typed "atomic-file/enoent" code; both mean "start empty", not "load
508
+ // failed". Any other kind (symlink / too-large / toctou) is a real
509
+ // load failure and falls through to LOAD_FAILED.
510
+ if (e && (e.code === "ENOENT" || e.code === "atomic-file/enoent")) { raw = null; }
496
511
  else {
497
512
  throw _err("LOAD_FAILED",
498
513
  "cookieJar.create: failed to load persist file '" + filePath + "': " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.16.23",
3
+ "version": "0.16.24",
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:45b7028c-dc33-4f08-92b5-8735beeb8354",
5
+ "serialNumber": "urn:uuid:f74a0d53-dc0f-44ba-acf7-65a73a199e8d",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-12T22:51:20.079Z",
8
+ "timestamp": "2026-07-13T01:48:47.178Z",
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.16.23",
22
+ "bom-ref": "@blamejs/core@0.16.24",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.16.23",
25
+ "version": "0.16.24",
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.16.23",
29
+ "purl": "pkg:npm/%40blamejs/core@0.16.24",
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.16.23",
57
+ "ref": "@blamejs/core@0.16.24",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]