@blamejs/core 0.14.0 → 0.14.1

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.14.x
10
10
 
11
+ - v0.14.1 (2026-05-29) — **Correctness fixes: JAR request-object typ enforcement, byte-faithful PGP multipart/signed, and SAML InResponseTo binding.** A set of correctness fixes across the auth, mail-crypto, TLS, and encoder surfaces. The most important: JWT-secured authorization requests (RFC 9101) now require the request object to carry the registered `oauth-authz-req+jwt` typ, closing a cross-JWT-confusion vector; the PGP multipart/signed wrapper is now assembled byte-faithfully so non-ASCII signed content can't be corrupted; and SAML response verification now returns the InResponseTo of the SubjectConfirmation that actually validated. Two of these change behavior — see Changed — and are bug fixes rather than new features. **Changed:** *JAR parsing rejects untyped request objects (breaking)* — Following the typ enforcement above, a request object whose header omits `typ` is now refused. An authorization server whose clients sign request objects without the `oauth-authz-req+jwt` typ must update those clients to set it. · *PGP sign() returns multipartSigned as a Buffer (breaking)* — `b.mail.crypto.pgp.sign(...).multipartSigned` is now a Buffer instead of a string. The OpenPGP signature covers the signed-part bytes exactly, and a JS-string round trip through latin1/utf8 could corrupt non-ASCII signed content and break verification, so the RFC 3156 multipart/signed wrapper is now assembled as bytes. Code that wrote the previous string to the wire works unchanged when it writes the Buffer; code that did string operations on the value should treat it as a Buffer (its `indexOf` / `toString` still work). **Fixed:** *SAML response verification binds InResponseTo to the validated confirmation* — `verifyResponse` returned the InResponseTo of the first SubjectConfirmationData in the assertion rather than of the SubjectConfirmation that actually passed bearer validation. When a response carried more than one SubjectConfirmation, the returned value could come from a non-validated confirmation. It is now the InResponseTo of the confirmation that validated. · *checkServerIdentity9525 emits the documented CN-fallback audit code* — A CN-only legacy certificate (a Common Name present, no subjectAltName) is now refused by the exported `b.network.tls.checkServerIdentity9525` with the distinct `tls/pkix-cn-fallback-refused` code its documentation promised, so audit logs can tell a CN-only certificate apart from one carrying neither a SAN nor a CN (which still yields `tls/pkix-san-required`). The accept/refuse outcome is unchanged — both are refused; only the audit granularity improved. · *OIDC back-channel logout / JARM no longer accept dead override parameters* — `verifyBackchannelLogoutToken` and `parseJarmResponse` passed `acceptedAlgs` / `jwksUri` / `maxClockSkewMs` through to the ID-token verifier, which ignored them and applied the configured (create()-time) values. The pass-throughs are removed so the code no longer reads as if those can be overridden per call; the configured trust anchor was — and remains — what applies. · *Queue lease failures are logged* — The consumer loop's lease-acquisition error path swallowed the backend error silently; it now logs at debug so a flapping backend that has not yet tripped the circuit breaker is visible. · *Protobuf and ASN.1 encoders reject out-of-range tags* — The protobuf tag encoder rejected nothing and would have emitted a wrong tag for field numbers at or above 2^28 (where the 32-bit shift overflows); the ASN.1 context-tag writers silently truncated tag numbers above 30 (which require the multi-byte high-tag-number form). Both now throw a RangeError rather than encode silently-wrong output. The values these encoders serve are well within range, so no current caller is affected. · *oid4vci proofAlgorithms default documented accurately* — The documented default proof-algorithm list now matches the code (`["ES256", "ES384", "EdDSA"]`); the doc previously omitted EdDSA, which the runtime has always accepted by default. **Security:** *JAR request objects must carry the registered typ (RFC 9101)* — `b.auth.jar.parse` now requires the request-object JWS header to carry `typ: "oauth-authz-req+jwt"` (with or without the `application/` prefix); a request object with an absent or different typ is refused with `auth-jar/bad-typ`. RFC 9101 §10.8 specifies this media type precisely to stop a JWT minted for another purpose (an ID token, an access token, a logout token) and signed by the same client key from being replayed as a request object. The existing `iss` / `aud` / `client_id` bindings already constrained that; this restores the explicit type check as well. This is stricter than before — see Changed.
12
+
11
13
  - v0.14.0 (2026-05-29) — **Operator-configurable header and field names across SSE, request-id, rate-limit, age-gate, AI-Act disclosure, GraphQL federation, and the HTTP cache.** This release makes operator-facing identifiers that were hardcoded configurable. The framework already let operators rename most names (CSRF cookie/field, cookie parser, i18n header/query/cookie, mTLS CA name, and so on); this closes the remaining gaps so a custom or framework-specific name is never frozen. Every new option defaults to the value emitted today, so upgrading changes no behavior — these are additive knobs. It also fixes a request-id asymmetry (the response header is now written on the same name the inbound id is read from) and wires an SSE proxy-buffering option whose escape hatch was documented but never implemented. **Added:** *Configurable cache-status header on the HTTP client* — The outbound HTTP client annotated every cached response with a hardcoded `x-blamejs-cache: HIT|MISS|STALE|REVALIDATED` header. `b.httpClient.cache.create` now takes `statusHeader` (default "x-blamejs-cache") — pass a custom name (e.g. "x-cache") to rename it, or null/false to suppress it entirely. The decision remains available programmatically on `res.cacheStatus`. · *Configurable rate-limit header names* — `b.middleware.rateLimit` emitted the de-facto `X-RateLimit-Limit` / `X-RateLimit-Remaining` headers (which are not RFC-pinned). It now accepts `headerPrefix` (default "X-RateLimit-") so operators can match the unprefixed IETF-draft `RateLimit-*` names or an upstream gateway's convention; the limit/remaining pair is always built from the same prefix. · *Configurable age-gate and AI-Act disclosure header names* — `b.middleware.ageGate` now takes `privacyPostureHeader` (default "X-Privacy-Posture"; null/false to suppress), and `b.middleware.aiActDisclosure` takes `headerPrefix` (default "AI-Act-") that prefixes the emitted Notice / Article / Policy headers. The EU AI Act mandates the disclosure, not the HTTP spelling, so operators matching a downstream convention can rename these. · *Configurable GraphQL-federation replay-nonce header* — `b.graphqlFederation.guardSdl` read the replay nonce from the Apollo-vendor `x-apollographql-router-nonce` header with no override. It now accepts `nonceHeader` (default unchanged) so an operator fronting the gateway with a non-Apollo router can point the replay check at their own header. · *SSE proxy-buffering opt-out* — `b.sse.create` and `b.middleware.sse` set `X-Accel-Buffering: no` (the nginx hint that disables proxy buffering). They now accept `proxyBuffer` (default true) — pass false when not behind nginx, or when buffering is controlled at the load balancer, to suppress the nginx-specific header. The opt-out was previously referenced in the documentation but not implemented. **Fixed:** *Request-id middleware reflects the configured header name* — `b.log.middleware` read the inbound request id from a configurable `headerName` but always wrote the response on the literal `X-Request-Id`. An operator who set a custom `headerName` (e.g. `X-Correlation-Id`) therefore read from one header and emitted another. The response is now written on the same configured name; the default remains `X-Request-Id`, so deployments that did not set `headerName` are unaffected.
12
14
 
13
15
  ## v0.13.x
package/lib/asn1-der.js CHANGED
@@ -321,6 +321,13 @@ function writeOid(dotted) {
321
321
 
322
322
  function writeContextExplicit(tagNumber, child) {
323
323
  // [N] EXPLICIT — context-specific class (0xA0 | tag) + constructed.
324
+ // Tag numbers > 30 need the multi-byte high-tag-number form, which this
325
+ // single-byte encoder does not emit — refuse rather than silently
326
+ // truncate via `& 0x1f`.
327
+ if (tagNumber < 0 || tagNumber > 30) {
328
+ throw new RangeError("asn1: context tag number " + tagNumber +
329
+ " out of range (0..30); high-tag-number form is not supported");
330
+ }
324
331
  var tagByte = 0xa0 | (tagNumber & 0x1f); // allow:raw-byte-literal — context-specific constructed mask
325
332
  return writeNode(tagByte, child);
326
333
  }
@@ -331,6 +338,10 @@ function writeContextImplicit(tagNumber, value, opts) {
331
338
  // wrapping a structured value (e.g. IMPLICIT [0] OCTET STRING vs
332
339
  // IMPLICIT [0] SEQUENCE OF). Value is the raw inner bytes (already
333
340
  // encoded for constructed cases).
341
+ if (tagNumber < 0 || tagNumber > 30) {
342
+ throw new RangeError("asn1: context tag number " + tagNumber +
343
+ " out of range (0..30); high-tag-number form is not supported");
344
+ }
334
345
  var tagByte = 0x80 | (tagNumber & 0x1f); // allow:raw-byte-literal — context-specific primitive mask
335
346
  if (opts && opts.constructed) tagByte |= 0x20; // allow:raw-byte-literal — constructed bit
336
347
  return writeNode(tagByte, value);
package/lib/auth/jar.js CHANGED
@@ -130,6 +130,17 @@ async function parse(jar, opts) {
130
130
  audience: opts.audience,
131
131
  clockSkewMs: opts.clockSkewMs,
132
132
  });
133
+ // RFC 9101 §10.8 — the request object MUST be explicitly typed so a JWT
134
+ // minted for another purpose (id_token / access-token / logout-token)
135
+ // and signed by the same client key cannot be replayed here as a request
136
+ // object (cross-JWT confusion). Require the registered media type, with or
137
+ // without the "application/" prefix; an absent or mismatched typ is refused.
138
+ var jarTyp = verified.header && verified.header.typ;
139
+ if (jarTyp !== JAR_TYP && jarTyp !== "application/" + JAR_TYP) {
140
+ throw new AuthJarError("auth-jar/bad-typ",
141
+ "jar.parse: request object header.typ must be \"" + JAR_TYP +
142
+ "\" (RFC 9101 §10.8 — cross-JWT-confusion defense)");
143
+ }
133
144
  var payload = verified.claims;
134
145
 
135
146
  // RFC 9101 §5.2 — the request object MUST carry a client_id claim,
package/lib/auth/oauth.js CHANGED
@@ -836,10 +836,10 @@ function create(opts) {
836
836
  // only in claim validation (no nonce, audience = clientId, no
837
837
  // ID-token-specific claims). We wrap verifyIdToken with the
838
838
  // skip-nonce flag and apply JARM-specific claim checks below.
839
+ // verifyIdToken applies the create()-level accepted algorithms / JWKS /
840
+ // clock-skew; only the JARM-specific skip-nonce flag is passed here.
839
841
  var verified = await verifyIdToken(responseJwt, {
840
842
  skipNonceCheck: true,
841
- acceptedAlgs: jopts.acceptedAlgs,
842
- maxClockSkewMs: jopts.maxClockSkewMs,
843
843
  });
844
844
  var c = verified.claims;
845
845
  // Per JARM §4: `iss` MUST match the OP issuer; `aud` MUST contain
@@ -1419,12 +1419,10 @@ function create(opts) {
1419
1419
  }
1420
1420
  // Reuse verifyIdToken's signature-verification path. It looks up
1421
1421
  // the IdP JWKS and checks the JWS — same trust anchor.
1422
+ // verifyIdToken applies the create()-level issuer / clientId / accepted
1423
+ // algorithms / JWKS / clock-skew — the same trust anchor as id_tokens.
1424
+ // Only the per-call logout-token semantics are passed here.
1422
1425
  var verified = await verifyIdToken(logoutToken, {
1423
- issuer: issuer,
1424
- clientId: clientId,
1425
- acceptedAlgs: vopts.acceptedAlgs,
1426
- jwksUri: vopts.jwksUri,
1427
- maxClockSkewMs: vopts.maxClockSkewMs,
1428
1426
  // Logout tokens have no nonce — disable the nonce check that
1429
1427
  // verifyIdToken would otherwise enforce on id_tokens.
1430
1428
  skipNonceCheck: true,
@@ -240,7 +240,7 @@ function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId
240
240
  * tokenEndpoint: string, // public URL for /token (re-used by the pre-auth flow)
241
241
  * sdJwtIssuer: <b.auth.sdJwtVc.issuer instance>, // mints the SD-JWT VC
242
242
  * supportedCredentials: { [id]: { format, vct, claims, ... } },
243
- * proofAlgorithms: string[], // default ["ES256", "ES384"]
243
+ * proofAlgorithms: string[], // default ["ES256", "ES384", "EdDSA"]
244
244
  * preAuthCodeTtlMs?: number, // default 5m
245
245
  * accessTokenTtlMs?: number, // default 15m
246
246
  * cNonceTtlMs?: number, // default 5m
package/lib/auth/saml.js CHANGED
@@ -596,6 +596,10 @@ function create(opts) {
596
596
  var confirmations = _findAllChildren(subject, "SubjectConfirmation", SAML_NS.assertion);
597
597
  var bearerOk = false;
598
598
  var hokOk = false;
599
+ // InResponseTo of the SubjectConfirmation that actually passed bearer
600
+ // validation — captured so the returned value can't be sourced from a
601
+ // different (non-validated) confirmation when several are present.
602
+ var matchedInResponseTo = null;
599
603
  var hokFingerprint = null;
600
604
  // Holder-of-Key SubjectConfirmation per SAML 2.0 Profile §3.1
601
605
  // (urn:oasis:names:tc:SAML:2.0:cm:holder-of-key). The IdP binds
@@ -721,6 +725,7 @@ function create(opts) {
721
725
  "AuthnRequest ID (replay defense)");
722
726
  }
723
727
  }
728
+ matchedInResponseTo = inResponseTo;
724
729
  bearerOk = true;
725
730
  break;
726
731
  }
@@ -787,8 +792,7 @@ function create(opts) {
787
792
  sessionIndex: sessionIndex,
788
793
  attributes: attributes,
789
794
  audience: audience,
790
- inResponseTo: bearerOk ? _attr(_findChild(_findChild(subject, "SubjectConfirmation", SAML_NS.assertion),
791
- "SubjectConfirmationData", SAML_NS.assertion), "InResponseTo") : null,
795
+ inResponseTo: bearerOk ? matchedInResponseTo : null,
792
796
  issuer: issuer,
793
797
  };
794
798
  }
@@ -84,7 +84,7 @@
84
84
  * audit: opts.audit, // optional b.audit handle
85
85
  * });
86
86
  * // → { armored: "-----BEGIN PGP SIGNATURE----- ...",
87
- * // multipartSigned: "Content-Type: multipart/signed; ...",
87
+ * // multipartSigned: <Buffer ...>, // RFC 3156 wrapper bytes
88
88
  * // signedAt: epochSeconds, fingerprint: "abcd..." }
89
89
  *
90
90
  * var rv = b.mail.crypto.pgp.verify({
@@ -564,19 +564,27 @@ function sign(opts) {
564
564
  // key/cert material flows through createSign/verify, not this path.
565
565
  // allow:raw-randombytes-token — boundary string, not auth credential
566
566
  var boundary = "blamejs-pgp-" + nodeCrypto.randomBytes(12).toString("hex");
567
- var multipartSigned =
568
- 'Content-Type: multipart/signed; micalg="pgp-' + hashName + '"; ' +
569
- 'protocol="application/pgp-signature"; boundary="' + boundary + '"\r\n' +
570
- "\r\n" +
571
- "--" + boundary + "\r\n" +
572
- (Buffer.isBuffer(message) ? message.toString("binary") : message) +
573
- "\r\n--" + boundary + "\r\n" +
574
- 'Content-Type: application/pgp-signature; name="signature.asc"\r\n' +
575
- "Content-Description: OpenPGP digital signature\r\n" +
576
- 'Content-Disposition: attachment; filename="signature.asc"\r\n' +
577
- "\r\n" +
578
- armored +
579
- "--" + boundary + "--\r\n";
567
+ // The OpenPGP signature covers the signed-part bytes exactly, so the
568
+ // multipart/signed wrapper is assembled as a Buffer — a JS-string round
569
+ // trip through latin1/utf8 could corrupt non-ASCII signed content and
570
+ // break signature verification. `multipartSigned` is therefore a Buffer.
571
+ var messageBytes = Buffer.isBuffer(message) ? message : Buffer.from(message, "utf8");
572
+ var multipartSigned = Buffer.concat([
573
+ Buffer.from(
574
+ 'Content-Type: multipart/signed; micalg="pgp-' + hashName + '"; ' +
575
+ 'protocol="application/pgp-signature"; boundary="' + boundary + '"\r\n' +
576
+ "\r\n" +
577
+ "--" + boundary + "\r\n", "utf8"),
578
+ messageBytes,
579
+ Buffer.from(
580
+ "\r\n--" + boundary + "\r\n" +
581
+ 'Content-Type: application/pgp-signature; name="signature.asc"\r\n' +
582
+ "Content-Description: OpenPGP digital signature\r\n" +
583
+ 'Content-Disposition: attachment; filename="signature.asc"\r\n' +
584
+ "\r\n" +
585
+ armored +
586
+ "--" + boundary + "--\r\n", "utf8"),
587
+ ]);
580
588
 
581
589
  // Audit (drop-silent — never crash the request that triggered us).
582
590
  _audit(opts.audit, "mail.crypto.pgp.sign", "success", {
@@ -3066,9 +3066,13 @@ function checkServerIdentity9525(host, cert) {
3066
3066
  }
3067
3067
  var rawSan = cert.subjectaltname;
3068
3068
  if (typeof rawSan !== "string" || rawSan.length === 0) {
3069
- // RFC 9525 §6.4.4 forbids CN fallback. If there's no SAN we refuse,
3070
- // never inspect cert.subject.CN a CN-only cert violates the
3071
- // modern PKIX baseline and the operator chose the strict checker.
3069
+ // RFC 9525 §6.4.4 forbids CN fallback. A CN-only legacy cert (CN
3070
+ // present, no SAN) surfaces the distinct `tls/pkix-cn-fallback-refused`
3071
+ // code so audit logs can tell it apart from a cert carrying neither;
3072
+ // a cert with no SAN and no CN falls through to `tls/pkix-san-required`.
3073
+ // Both refuse — we never fall back to matching on the Common Name.
3074
+ var cnRefusal = _refuseCnFallback(host, cert);
3075
+ if (cnRefusal) return cnRefusal;
3072
3076
  return new NetworkTlsError("tls/pkix-san-required",
3073
3077
  "checkServerIdentity9525: certificate has no subjectAltName " +
3074
3078
  "extension (RFC 9525 §6.4.4 forbids Common Name fallback)");
@@ -3117,10 +3121,11 @@ function _refuseCnFallback(host, cert) {
3117
3121
  return null;
3118
3122
  }
3119
3123
 
3120
- // Public combined verifier applies both the SAN-required check and
3121
- // the CN-fallback explicit refusal so operators get the more specific
3122
- // of the two error codes when applicable. checkServerIdentity9525 is
3123
- // the drop-in name; this internal helper is what `connect` wires in.
3124
+ // Explicit combined verifier kept for tests + callers that want the
3125
+ // CN-fallback / SAN-required split spelled out. The exported drop-in
3126
+ // `checkServerIdentity9525` already performs the CN-fallback refusal in
3127
+ // its no-SAN branch, so the `_refuseCnFallback` call here is a redundant
3128
+ // (idempotent) belt-and-suspenders; the more specific code wins either way.
3124
3129
  function _checkServerIdentityStrict(host, cert) {
3125
3130
  var cnRefusal = _refuseCnFallback(host, cert);
3126
3131
  if (cnRefusal) return cnRefusal;
@@ -82,6 +82,14 @@ function _writeVarint(value) {
82
82
  }
83
83
 
84
84
  function _tag(fieldNumber, wireType) {
85
+ // `fieldNumber << 3` uses JS's 32-bit signed shift, which overflows and
86
+ // emits a wrong tag once fieldNumber reaches 2^28. Reject anything outside
87
+ // the safe single-shift range rather than encode silently wrong — the OTLP
88
+ // schema this serves uses small field numbers well within it.
89
+ if (fieldNumber < 1 || fieldNumber > 268435455) { // 2^28 - 1
90
+ throw new RangeError("protobuf: field number " + fieldNumber +
91
+ " out of range (1..2^28-1)");
92
+ }
85
93
  return _writeVarint((fieldNumber << 3) | wireType);
86
94
  }
87
95
 
package/lib/queue.js CHANGED
@@ -417,8 +417,10 @@ function consume(queueName, handler, opts) {
417
417
  }
418
418
  var jobs;
419
419
  try { jobs = await b.lease(queueName, leaseDurationMs, slots); }
420
- catch {
421
- // Backend down (breaker open, etc.) — back off
420
+ catch (e) {
421
+ // Backend down (breaker open, etc.) — log + back off so a flapping
422
+ // backend that hasn't yet tripped the breaker is still visible.
423
+ log.debug("lease-failed", { op: "b.lease", queue: queueName, error: e.message });
422
424
  await _pollSleep(pollIntervalMs);
423
425
  continue;
424
426
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
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:e6a01e68-c8b4-4c27-b3b5-f1ddf1aad212",
5
+ "serialNumber": "urn:uuid:5ae2d0f7-e30f-4d02-90d0-975129430c5f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-30T04:02:38.743Z",
8
+ "timestamp": "2026-05-30T05:49:41.774Z",
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.14.0",
22
+ "bom-ref": "@blamejs/core@0.14.1",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.14.0",
25
+ "version": "0.14.1",
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.14.0",
29
+ "purl": "pkg:npm/%40blamejs/core@0.14.1",
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.14.0",
57
+ "ref": "@blamejs/core@0.14.1",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]