@blamejs/core 0.9.2 → 0.9.4
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 +2 -0
- package/lib/auth/ciba.js +60 -7
- package/lib/auth/jwt-external.js +13 -4
- package/lib/auth/oauth.js +63 -14
- package/lib/auth/oid4vci.js +14 -6
- package/lib/auth/oid4vp.js +19 -2
- package/lib/middleware/dpop.js +26 -5
- package/lib/network-tls.js +8 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.9.x
|
|
10
10
|
|
|
11
|
+
- v0.9.4 (2026-05-12) — **Audit hardening slice 4: kid-less JWKS lookup refusal + OCSP nonce CT compare + OAuth scope strict-split + DPoP `X-Forwarded-Proto` trust gate**. Closes the remaining MEDIUM-tier findings from the 2026-05-11 auth audit. **`b.auth.oauth.verifyIdToken` + `b.auth.jwt.verifyExternal` kid-less JWKS lookup refusal** — pre-v0.9.4 both verifiers fell back to `keys[0]` when the token carried NO `kid` and the JWKS had exactly one key. This is a latent vector during JWKS rotation: an attacker shipping a kid-less token gets the lone-key path during the window the rotated-out key is still cached at the IdP but the rotated-in key is already published. Every modern IdP includes `kid`; the framework now refuses kid-less tokens unconditionally. Operators with non-conforming IdPs that genuinely emit kid-less tokens opt out via `vopts.allowKidlessJwks: true`. **`b.network.tls` OCSP nonce constant-time compare** — `evaluateOcspResponse`'s `expectedNonce` match migrated from `Buffer.equals` to `b.crypto.timingSafeEqual` for module-wide consistency with the Merkle-root / NTS-cookie / cert-fingerprint paths that already use `timingSafeEqual`. **`b.auth.oauth` scope strict whitespace split** — RFC 6749 §3.3 says `scope` is space-separated, ONLY `U+0020`. Pre-v0.9.4 `raw.scope.split(/\s+/)` matched U+0085 NEL, U+00A0 NBSP, etc., so a hostile AS returning `scope: "admin<NEL>read"` would surface as `["admin", "read"]` and the operator's scope allowlist saw two distinct scopes. Now splits on single-space only; empty pieces filtered out. **`b.middleware.dpop` `X-Forwarded-*` trust gate** — `_reconstructHtu` previously read `X-Forwarded-Proto` / `X-Forwarded-Host` unconditionally; an attacker who can hit the origin directly while spoofing `X-Forwarded-Proto: https` could trick the middleware into building an `https` htu that the DPoP proof was signed for, when the origin is actually serving HTTP (RFC 9449 §4.3 says the htu MUST be the absolute URL the request was sent to). The default now derives proto/host from the socket; operators with a confirmed-trusted front proxy opt in via `opts.trustForwardedHeaders: true`.
|
|
12
|
+
- v0.9.3 (2026-05-11) — **Audit hardening slice 3: OAuth + OID4VCI + OID4VP + CIBA + constant-time-compare migrations**. Continues the 2026-05-11 auth audit follow-through. **`b.auth.oauth.refreshAccessToken` atomic check-and-insert** — new `ropts.checkAndInsert(token, expireAtMs)` callback contract replaces the previous `ropts.seen(token)` check-then-act race. Two concurrent refresh requests on the same event-loop tick could both see `seen === false` and both POST to the token endpoint, neither flagging the replay; the new contract requires an atomic test-and-set (Redis SETNX, DB INSERT ON CONFLICT) and is the OAuth 2.1 §6.1 / RFC 9700 §4.13 one-time-use defense surfacing the actual race window. Legacy `seen` callback continues to work for backwards-compat with operator code; the docstring documents the race + recommends migration to `checkAndInsert`. **`b.auth.oid4vci` constant-time compares** — pre-auth `tx_code` hash compare (was `!==` on sha3 hex) and proof-JWT `c_nonce` compare (was `!==` on attacker-supplied wallet payload) both route through `b.crypto.timingSafeEqual`. **`b.auth.oid4vp` per-presentation `vct` enforcement** — DCQL filters with 2+ `vct_values` entries previously bypassed vct validation entirely (the framework only set `expectedVct` when the filter pinned to a single value). Verifier now validates the presented vct against the DCQL filter list manually when length > 1; refuses with `vp_token['<id>'][<n>] vct '<presented>' is not in DCQL vct_values [...]` on over-disclosure. **`b.auth.ciba` slow_down honoring** — CIBA §11.3 requires the client to increase its polling interval by at least 5s on every `slow_down` response. Pre-v0.9.3 the framework client never bumped, leaving operators to do their own interval bookkeeping. Now `pollToken()` tracks per-`authReqId` interval state internally (Map keyed by authReqId, seeded from `startAuthentication`'s response, cleared on token issuance), bumps by `max(5s, IdP-suggested interval) <= MAX_INTERVAL_SEC` on every slow_down, and attaches the next-suggested interval to the thrown `auth-ciba/slow_down` error as `err.nextIntervalSec` so operators read a spec-correct back-off without manual bookkeeping. **`b.auth.ciba` notification-token entropy** — `clientNotificationToken` now refuses < 32 chars per CIBA §7.1.2's opaque-hard-to-guess requirement. Pre-v0.9.3 a 4-char token passed. **`b.auth.ciba.parseNotification` constant-time compare** — bearer-token hash compare migrated from `!==` to `b.crypto.timingSafeEqual` (both sides are fixed-width sha3-512 hex strings; defense-in-depth even though equal-length JS string compare is already widely understood as constant-time on V8).
|
|
11
13
|
- v0.9.2 (2026-05-11) — **Audit hardening slice 2: WebAuthn + FIDO MDS3 + NIST AAL**. Continues the 2026-05-11 auth surface audit follow-through. **`b.auth.passkey.verifyAuthentication` counter-regression bypass fix** — pre-v0.9.2 the wrapper coerced `opts.credential.counter || 0`, silently zeroing an `undefined` / `null` / `NaN` counter and defeating CTAP 2.1 clone-detection on credentials whose stored counter was > 0. An operator deserializing the credential from a column that lost the counter would unknowingly accept a cloned authenticator. The wrapper now refuses `undefined` / `null` (operators MUST persist whatever the vendor returned at registration; first-time-stored credentials carry counter:0 explicitly) and rejects any non-integer / non-finite / negative value with `auth-passkey/bad-counter`. **`b.auth.passkey` multi-origin support** — `expectedOrigin` now accepts `string` OR `string[]` on both `verifyRegistration` and `verifyAuthentication`. Pre-v0.9.2 the wrapper enforced a single string only, blocking multi-origin deployments (web + admin-subdomain) from sharing one verifier; SimpleWebAuthn natively supports arrays. **`b.auth.passkey` prototype-pollution fix** — `ALLOWED_MEDIATION` lookup changed from `{...}[opts.mediation]` to `hasOwnProperty.call(ALLOWED_MEDIATION, opts.mediation)` with a null-prototype map. Pre-v0.9.2 a caller passing `mediation: "__proto__"` / `"constructor"` truthy-matched an inherited Object.prototype property and slipped past the allowlist into `generateAuthenticationOptions`. **`b.auth.aal.fromMethods` UV requirement for AAL3** — per NIST SP 800-63-4 §5.1.7, WebAuthn / passkey satisfies AAL3 (MF-CRYPT) only when user verification was performed on the assertion. Pre-v0.9.2 `fromMethods({ webauthn: true })` returned `AAL3` unconditionally; operators using `userVerification: "preferred"` whose authenticator skipped UV landed in AAL3 despite not satisfying the spec's MF requirement. The caller now passes `uv: true` (sourced from the vendor's authData UV bit) to claim AAL3 with webauthn alone; without `uv`, webauthn alone caps at AAL2 (SF-CRYPT). Combination paths (`webauthn + password` / `webauthn + pin`) reach AAL3 regardless of UV (the memorized secret provides the second factor independently). **`b.auth.fidoMds3.verifyAuthenticator` fail-closed default + new opts** — pre-v0.9.2 unknown AAGUIDs returned `{ ok: true, reason: "aaguid-not-in-blob" }`, silently trusting any authenticator the metadata service hadn't yet listed (rogue / pre-certification / fake hardware). Now fails closed by default; operators wanting the legacy fail-open behavior (test fixtures, pre-certification pilot rollouts) pass `opts.allowUnknownAaguid: true` explicitly. **`b.auth.fidoMds3.parseBlob` stale-BLOB refusal** — refuses BLOB payloads whose `nextUpdate` is already in the past (FIDO MDS3 §3.1.7). Pre-v0.9.2 the staleness was floored to `MIN_CACHE_TTL_MS` but the BLOB was still served, letting an attacker pin operators to a revoked-authenticator-list-frozen-at-X by serving an ancient signed-but-expired BLOB. **`b.auth.fidoMds3.REFUSE_STATUS`** — added `ATTESTATION_KEY_COMPROMISE` per FIDO MDS3 §3.1.4. Pre-v0.9.2 this status was silently accepted; manufacturer batch-signing-key compromise affects every credential attested under that key.
|
|
12
14
|
- v0.9.1 (2026-05-11) — **Audit hardening, slice 1 of N: federation auth + OAuth ID-token verifier**. Audit run after v0.9.0 across OAuth / SAML / OIDC federation / SD-JWT VC / WebAuthn / FIDO MDS3 / constant-time-compare surfaces; this slice closes the highest-severity SAML + OIDC + SD-JWT + OAuth findings. **SAML SP** (`b.auth.saml.sp.create({...}).buildAuthnRequest` + `.metadata`): every operator-supplied URL / ID interpolated into the emitted XML now routes through `b.xmlC14n.escapeAttrValue` / `escapeText`. Pre-v0.9.1 a `"` or `<` in `idpSsoUrl` / `assertionConsumerServiceUrl` / `entityId` / `nameIdFormat` broke out of attribute / element context and produced unsigned-XML breakout into the IdP redirect. Newly exported `b.xmlC14n.escapeAttrValue(s)` and `b.xmlC14n.escapeText(s)` — RFC 3741 §1.3.x compliant; available for any operator emitting XML alongside the framework's own SAML / canonicalization paths. **SAML SP `verifyResponse`**: digest compare on `Reference DigestValue` and `SubjectConfirmation InResponseTo` now use `b.crypto.timingSafeEqual` instead of `Buffer.compare` / `!==`. **SAML XSW defense**: refuses Response payloads that carry duplicate `<Status>`, `<StatusCode>`, `<Assertion>`, `<Subject>`, or `<NameID>` children — XML signature wrapping attacks ferry an unsigned sibling next to a signed element and exploit first-match parsers; the verifier now asserts single-child cardinality on every security-critical element via `_findAllChildren(...).length === 1`. **OIDC federation** (`b.auth.openidFederation.verifyEntityStatement`): JWK key-type cross-check against the JWS `alg` header BEFORE `nodeCrypto.createPublicKey` — an attacker-controlled entity-config declaring `alg: "ES256"` while supplying an RSA JWK would previously load through Node's silent algorithm-vs-key coercion path. Now refuses with `auth-openid-federation/alg-kty-mismatch` for any `alg=ES*` not paired with `kty=EC`, `alg=PS*`/`RS*` not paired with `RSA`, or `alg=EdDSA` not paired with `OKP`. **`b.auth.openidFederation.buildTrustChain` error-masking**: trust-chain ascent previously swallowed every per-authority failure via `catch (_e) {}` and continued to the next `authority_hint`; signature-failure errors from one authority no longer mask, the chain now refuses on cryptographic refusal (`bad-jwk`, `alg-kty-mismatch`, `bad-signature`, `signature-failed`). Network / 404 / iss-sub-mismatch errors still continue to the next hint but are collected and surfaced in the `no-ascent` failure shape. **SD-JWT VC verify** (`b.auth.sdJwtVc.verify`): three correctness fixes. (1) `_sd_alg` default switched from `sha3-512` to `sha-256` per IETF draft §4.1.1 — prior default broke verification against spec-conformant issuers when the issuer omitted `_sd_alg`. (2) Disclosure-replay defense: every disclosure digest tracked in a Set; second occurrence of the same digest refuses with `auth-sd-jwt-vc/disclosure-replay`. (3) Claim-shadowing defense: holder-supplied disclosures whose name collides with an issuer-signed top-level claim (`iss`, `sub`, `aud`, `iat`, `nbf`, `exp`, `jti`, `vct`, `cnf`, `_sd`, `_sd_alg`, `status`) refuse with `auth-sd-jwt-vc/protected-claim-shadow` instead of silently overwriting the signed value. (4) KB-JWT `sd_hash` now uses the credential's declared `_sd_alg` (was hardcoded sha256, breaking against issuers using sha3-512); `sd_hash` compare routed through `b.crypto.timingSafeEqual`. **OAuth `verifyIdToken`** (`b.auth.oauth.verifyIdToken`): three hardening fixes that bring the verifier to parity with the framework's other JWS verifiers. (1) `nodeCrypto.verify` wrapped in try/catch — previously panicked on key/sig shape mismatch (e.g. ES256 sig against an RS256 key returned by a buggy IdP with duplicate kids), bubbling a raw `Error` to the operator's handler instead of an `OAuthError`. (2) RFC 7515 §4.1.11 `crit` header refusal — every sibling verifier (`b.auth.jwt`, `b.auth.jwt.verifyExternal`, `b.auth.dpop`) refuses; verifyIdToken previously silently ignored, letting an attacker-controlled OP ship critical extensions the verifier doesn't understand. (3) `state` and `nonce` claim compares routed through `b.crypto.timingSafeEqual` — these are CSRF / replay tokens compared against attacker-controlled callback / payload data.
|
|
13
15
|
- v0.9.0 (2026-05-11) — **Minor: 3 new RFC primitives + `b.structuredFields` shared substrate + full audit-derived hardening sweep + 5 new bug-class detectors**. **`b.structuredFields`** consolidates the quote-aware top-level splitter (`splitTopLevel(s, sep)`), the raw-value control-byte refusal scan (`refuseControlBytes` / `containsControlBytes`), and the sf-string unquote (`unquoteSfString`) used by every RFC 8941 / RFC 9110 / RFC 9111 / RFC 9213 / RFC 9421 / RFC 6266 / RFC 6265 / RFC 6455 parser in the framework — replaces the per-file open-coded copies that were drifting site-by-site. **8 audit-surfaced bug-class sites fixed in this same patch** (no deferred follow-up): (1) `b.middleware.bodyParser._contentType` + `_parseHeaderParams` — RFC 9110 Content-Type / RFC 6266 Content-Disposition parameters can carry quoted-string values (`boundary="foo;bar"` / `filename="weird;name.txt"`); bare `.split(";")` previously sliced through quoted semicolons and corrupted multipart boundaries. (2) `b.requestHelpers.parseListHeader({ strictToken: true })` — control-byte scan now runs on the RAW value before `.trim()` so a leading `\n<token>` no longer slips past `RFC_9110_TOKEN_RE` (same v0.8.90 bug class; used by webhook signature parsing and WS subprotocol negotiation). (3) `b.middleware.tusUpload._parseChecksumHeader` — same trim-before-validate fix. (4) `b.httpClient.cache._parseCacheControl` — quote-aware `,` splitter (RFC 9111 §5.2 + RFC 9110 §5.6.4 directive values may be quoted-string). (5) `b.httpClient.cookieJar._parseSetCookie` — quote-aware `;` splitter defends RFC 7230 quoted-string attribute values (`SameSite="Strict"` from interop-imperfect upstreams). (6) `b.websocket._parseExtensionHeader` — quote-aware `;` and `,` splitters defend RFC 6455 §9.1 + RFC 7230 token-or-quoted-string parameter values against forward-compat extensions shipping quoted params. (7) `b.aiPref.parseHeader` — control-byte refusal added on the RAW value before split + trim. (8) `b.auth.stepUp.parseChallenge` — same trim-before-validate fix (returns `null` per defensive-reader contract instead of throwing). Also: `b.logStream.init({ minLevel })` now validates the level vocabulary at config time so a typo'd `"infos"` (which previously produced `LEVEL_PRIORITY["infos"] === undefined` and silently dropped every log record) throws at boot. `b.crypto.httpSig`'s RFC 9421 Signature-Input parameter parser uses the quote-aware `;` splitter (RFC 8941 §3.1.2 sf-string parameter values). `b.security.assertProductionPosture({ minTlsVersion })` validates `minTlsVersion` against the canonical TLS vocabulary BEFORE the rank comparison (a typo previously silently passed because `indexOf` returned `-1` — same bug class as v0.8.88 `b.auth.fal.meets`). **3 new RFC primitives**: **`b.cdnCacheControl`** ships an RFC 9213 directive list builder + parser shared across `Cache-Control`, `CDN-Cache-Control`, `Surrogate-Control`, and the operator-specific `Cloudflare-` / `Vercel-` / `Fastly-` / `Akamai-` / `Netlify-CDN-Cache-Control` variants. `build({...})` emits the directive list string (numeric directives non-negative-integer-only; refuses Infinity / NaN / floats / negatives; full RFC 9111 boolean directive set; `extensions` for non-standard directives with RFC 7234 §5.2 token-shape enforcement); refuses `public + private` conflict per RFC 9111 §5.2.2.5/§5.2.2.6. `parse(headerValue)` decodes any targeted header into `{ public, private, noStore, maxAge, sMaxAge, ..., directives, fields }` with **qualified-form support** (`private="Authorization"` flag stays enabled, field-name list under `.fields[camel]` per RFC 9111 §5.2.2.4 / §5.2.2.6 — presence == enabled, the argument narrows scope), **bare `max-stale` parses as `Infinity`** per RFC 9111 §5.2.1.2 (instead of buggy `Number(true) === 1`), and a quote-aware top-level `,` splitter so a `, ` inside a quoted directive value doesn't fake-split. `isTargetedHeader(name)` + curated `TARGETED_HEADERS` allowlist. **`b.clientHints`** ships a Sec-CH-UA-* request-header family parser per W3C UA Client Hints + IETF draft-davidben-http-client-hint-reliability. `parse(req.headers)` returns `{ brands, mobile, platform, platformVersion, arch, bitness, model, fullVersionList, wow64, formFactors, raw }`; quote-aware splitters at brand-list and brand-member-parameter level (RFC 8941 §4.1.1.4 parameter values may be sf-string); refuses control characters in any Sec-CH-* value. `acceptList(hintNames)` builds `Accept-CH` with typo-defense (unknown hint name throws `client-hints/unknown-hint`); dedupes case-variant duplicates; canonicalizes to W3C mixed-case spelling. `KNOWN_HINTS` exports the well-known 22-name list. **`b.network.dns.classifyDnskeyAlgorithm(algorithm)` / `classifyDsDigestType(digestType)`** — RFC 9905 DNSSEC SHA-1 deprecation classifier. Covers every IANA-assigned DNSKEY algorithm (including PRIVATEDNS/PRIVATEOID/INDIRECT/Reserved entries) and RFC 9558 §3 DS digest types 5 (GOST R 34.11-2012) + 6 (SM3); operators auditing inbound DNSSEC chain-of-trust evidence refuse validations where `deprecated === true`. Inline `allow:bare-split-on-quoted-header` markers added across `mail-auth.js` (DKIM / DMARC / ARC tag-list grammar — token-only), `network-smtp-policy.js` (TLS-RPT — token-only), `middleware/scim-server.js` (RFC 7644 §3.9 SCIM attribute paths), `http-client-cache.js` (RFC 9110 §12.5.5 Vary field-names), `http-message-signature.js` (RFC 9421 component-id covered list), `middleware/body-parser.js` (RFC 9112 §6.1 Transfer-Encoding token-only), each citing the controlling RFC clause showing why quoted-string is not a legal value in that grammar. **5 new bug-class detectors** in `test/layer-0-primitives/codebase-patterns.test.js` so the same shapes can't drift back in: (a) `trim-before-validate` — control-byte refusal scans must run on the RAW header value BEFORE `.trim()` strips leading/trailing C0/DEL bytes; detector now catches BOTH the `charCodeAt` codepoint-loop shape AND the `<NAME>_RE.test(<trimmed>)` grammar-regex shape; (b) `enum-rank-without-validation` — `_rankFn(X) >= _rankFn(Y)` arithmetic comparisons must have a preceding `isValid*` / `KNOWN_*` membership check on both inputs (catches `b.auth.fal.meets` bug shape); (c) `bool-string-coerce-shape` — boolean directive parsing must NOT use `val === "" || val === "true"` coercion (catches `b.cdnCacheControl.parse` qualified-form bug shape); (d) `bare-split-on-quoted-header` — RFC structured-fields parsers in files that ALSO handle sf-string unquote regex must use the shared quote-aware `b.structuredFields.splitTopLevel`, not bare `.split(",") / .split(";")`; (e) `scoped-context-binding-unused` — scope-named factory bindings (`forwarderDomain` / `realm` / `origin` / `audience` / `issuer`) captured in the factory must be compared against the inbound value's embedded scope in the `verify` / `reverse` / `decode` path (catches the v0.8.89 SRS forwarder-domain bug shape). Operators upgrade `0.8.90` → `0.9.0`; v0.8.91 was never tagged (its surface is folded into v0.9.0 with the audit-derived hardening).
|
package/lib/auth/ciba.js
CHANGED
|
@@ -55,7 +55,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
55
55
|
var validateOpts = require("../validate-opts");
|
|
56
56
|
var safeJson = require("../safe-json");
|
|
57
57
|
var safeUrl = require("../safe-url");
|
|
58
|
-
var { generateToken, sha3Hash } = require("../crypto");
|
|
58
|
+
var { generateToken, sha3Hash, timingSafeEqual } = require("../crypto");
|
|
59
59
|
var { AuthError } = require("../framework-error");
|
|
60
60
|
|
|
61
61
|
var httpClient = lazyRequire(function () { return require("../http-client"); });
|
|
@@ -169,6 +169,16 @@ function create(opts) {
|
|
|
169
169
|
throw new AuthError("auth-ciba/no-notification-token",
|
|
170
170
|
"auth.ciba.client.create: clientNotificationToken required for ping/push delivery modes");
|
|
171
171
|
}
|
|
172
|
+
// Minimum-entropy guard on the client_notification_token (audit
|
|
173
|
+
// 2026-05-11). CIBA §7.1.2 requires the token be opaque + hard to
|
|
174
|
+
// guess; the framework's other token-shaped primitives enforce 32
|
|
175
|
+
// chars minimum. A 4-char token was previously accepted; refuse.
|
|
176
|
+
if (clientNotificationToken !== null && clientNotificationToken.length < 32) { // allow:raw-byte-literal — RFC 9700 §7.1.2 token char-length minimum, not bytes
|
|
177
|
+
throw new AuthError("auth-ciba/notification-token-too-short",
|
|
178
|
+
"auth.ciba.client.create: clientNotificationToken must be >= 32 chars " +
|
|
179
|
+
"(generate via b.crypto.generateToken(32) or stronger; CIBA §7.1.2 " +
|
|
180
|
+
"requires opaque hard-to-guess token).");
|
|
181
|
+
}
|
|
172
182
|
|
|
173
183
|
// Each backchannel-authentication request mints a fresh
|
|
174
184
|
// `client_notification_token` per the spec? No — the RP registers
|
|
@@ -368,6 +378,10 @@ function create(opts) {
|
|
|
368
378
|
? rv.interval : DEFAULT_INTERVAL_SEC;
|
|
369
379
|
var expiresIn = typeof rv.expires_in === "number" && rv.expires_in > 0
|
|
370
380
|
? rv.expires_in : DEFAULT_EXPIRES_SEC;
|
|
381
|
+
// Seed the per-authReqId interval tracker so pollToken's
|
|
382
|
+
// slow_down handler bumps from the IdP-supplied starting point
|
|
383
|
+
// (CIBA §11.3 minimum-5s bump on every slow_down response).
|
|
384
|
+
_registerInitialInterval(rv.auth_req_id, interval);
|
|
371
385
|
|
|
372
386
|
_emitAudit("start", "success", {
|
|
373
387
|
authReqIdHash: sha3Hash("auth-ciba:" + rv.auth_req_id),
|
|
@@ -399,6 +413,17 @@ function create(opts) {
|
|
|
399
413
|
* var tokens = await ciba.pollToken({ authReqId: ticket.authReqId });
|
|
400
414
|
* // → { accessToken, idToken, refreshToken, tokenType, scope, expiresIn, raw }
|
|
401
415
|
*/
|
|
416
|
+
// Per-authReqId interval tracking — CIBA §11.3 requires the client
|
|
417
|
+
// to increase its polling interval by at least 5s on every
|
|
418
|
+
// `slow_down` response. The framework client now maintains this
|
|
419
|
+
// state internally so operators reading `err.nextIntervalSec` get
|
|
420
|
+
// a spec-correct back-off without rolling their own counter.
|
|
421
|
+
var _intervalState = new Map(); // authReqId → current interval sec
|
|
422
|
+
|
|
423
|
+
function _registerInitialInterval(authReqId, intervalSec) {
|
|
424
|
+
_intervalState.set(authReqId, intervalSec);
|
|
425
|
+
}
|
|
426
|
+
|
|
402
427
|
async function pollToken(popts) {
|
|
403
428
|
popts = popts || {};
|
|
404
429
|
if (typeof popts.authReqId !== "string" || popts.authReqId.length === 0) {
|
|
@@ -421,7 +446,33 @@ function create(opts) {
|
|
|
421
446
|
body.set("client_id", opts.clientId);
|
|
422
447
|
}
|
|
423
448
|
if (clientAuth === "mtls") body.set("client_id", opts.clientId);
|
|
424
|
-
var rv
|
|
449
|
+
var rv;
|
|
450
|
+
try {
|
|
451
|
+
rv = await _postForm(endpoint, body);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
// CIBA §11.3 — on slow_down response, increase polling
|
|
454
|
+
// interval by at least 5s. Attach the next-suggested-interval
|
|
455
|
+
// to the error so the operator's poll loop reads a spec-
|
|
456
|
+
// correct back-off without manual bookkeeping. The IdP MAY
|
|
457
|
+
// optionally return its own `interval` value in the 400 body;
|
|
458
|
+
// honor that when >= current + 5, otherwise enforce the
|
|
459
|
+
// minimum 5s bump.
|
|
460
|
+
if (err && err.code === "auth-ciba/slow_down") {
|
|
461
|
+
var current = _intervalState.get(popts.authReqId) || DEFAULT_INTERVAL_SEC;
|
|
462
|
+
var idpSuggested = err.cibaError && typeof err.cibaError.interval === "number"
|
|
463
|
+
? err.cibaError.interval : null;
|
|
464
|
+
var next = current + 5; // allow:raw-time-literal — §11.3 mandates +5s minimum
|
|
465
|
+
if (idpSuggested !== null && idpSuggested > next && idpSuggested <= MAX_INTERVAL_SEC) {
|
|
466
|
+
next = idpSuggested;
|
|
467
|
+
}
|
|
468
|
+
if (next > MAX_INTERVAL_SEC) next = MAX_INTERVAL_SEC;
|
|
469
|
+
_intervalState.set(popts.authReqId, next);
|
|
470
|
+
err.nextIntervalSec = next;
|
|
471
|
+
}
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
// Token issued — clear interval tracking for this authReqId.
|
|
475
|
+
_intervalState.delete(popts.authReqId);
|
|
425
476
|
_emitAudit("token_received", "success", {
|
|
426
477
|
authReqIdHash: sha3Hash("auth-ciba:" + popts.authReqId),
|
|
427
478
|
});
|
|
@@ -479,13 +530,15 @@ function create(opts) {
|
|
|
479
530
|
throw new AuthError("auth-ciba/bad-bearer",
|
|
480
531
|
"ciba.parseNotification: empty bearer or no expected token configured");
|
|
481
532
|
}
|
|
482
|
-
// Constant-time compare
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
533
|
+
// Constant-time compare on the SHA3 hash of both tokens —
|
|
534
|
+
// matches the project-wide discipline (audit 2026-05-11). Both
|
|
535
|
+
// sides are fixed-width sha3-512 hex strings; timingSafeEqual
|
|
536
|
+
// adds explicit defense-in-depth over `!==` even though equal-
|
|
537
|
+
// length JS string compare is already broadly understood as
|
|
538
|
+
// constant-time on V8.
|
|
486
539
|
var presentedHash = sha3Hash(presented);
|
|
487
540
|
var expectedHash = sha3Hash(clientNotificationToken);
|
|
488
|
-
if (presentedHash
|
|
541
|
+
if (!timingSafeEqual(presentedHash, expectedHash)) {
|
|
489
542
|
_emitAudit("notification_token_mismatch", "failure", {});
|
|
490
543
|
throw new AuthError("auth-ciba/wrong-bearer",
|
|
491
544
|
"ciba.parseNotification: client_notification_token does not match");
|
package/lib/auth/jwt-external.js
CHANGED
|
@@ -187,7 +187,7 @@ async function _fetchJwks(uri, cacheMs) {
|
|
|
187
187
|
}, cacheMs || DEFAULT_JWKS_CACHE_MS);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
function _selectKey(keys, header) {
|
|
190
|
+
function _selectKey(keys, header, vopts) {
|
|
191
191
|
if (!Array.isArray(keys) || keys.length === 0) {
|
|
192
192
|
throw new AuthError("auth-jwt-external/no-jwks-keys",
|
|
193
193
|
"JWKS source has no keys");
|
|
@@ -199,9 +199,18 @@ function _selectKey(keys, header) {
|
|
|
199
199
|
throw new AuthError("auth-jwt-external/no-matching-kid",
|
|
200
200
|
"no JWKS key matches header.kid='" + header.kid + "'");
|
|
201
201
|
}
|
|
202
|
-
|
|
202
|
+
// Refuse kid-less tokens by default (audit 2026-05-11). JWKS
|
|
203
|
+
// rotation creates a window where the rotated-out key is still
|
|
204
|
+
// cached but the rotated-in key is already published; an
|
|
205
|
+
// attacker shipping a kid-less token gets the lone-key path
|
|
206
|
+
// during that window. Modern IdPs always emit kid. Operators
|
|
207
|
+
// with non-conforming issuers opt in via vopts.allowKidlessJwks
|
|
208
|
+
// = true (logged via the caller's audit hook).
|
|
209
|
+
if (keys.length === 1 && vopts && vopts.allowKidlessJwks === true) return keys[0];
|
|
203
210
|
throw new AuthError("auth-jwt-external/kid-required",
|
|
204
|
-
"JWKS has " + keys.length + "
|
|
211
|
+
"JWKS has " + keys.length + " key(s) but token header has no kid — " +
|
|
212
|
+
"framework refuses kid-less tokens to defend against JWKS-rotation " +
|
|
213
|
+
"key-pick attacks (pass vopts.allowKidlessJwks: true to opt out)");
|
|
205
214
|
}
|
|
206
215
|
|
|
207
216
|
// ---- public surface ----
|
|
@@ -304,7 +313,7 @@ async function verifyExternal(token, opts) {
|
|
|
304
313
|
} else {
|
|
305
314
|
var keys = opts.jwks ? opts.jwks
|
|
306
315
|
: await _fetchJwks(opts.jwksUri, opts.jwksCacheMs);
|
|
307
|
-
var jwk = _selectKey(keys, header);
|
|
316
|
+
var jwk = _selectKey(keys, header, opts);
|
|
308
317
|
key = _jwkToKey(jwk);
|
|
309
318
|
}
|
|
310
319
|
|
package/lib/auth/oauth.js
CHANGED
|
@@ -561,20 +561,45 @@ function create(opts) {
|
|
|
561
561
|
// constrained confidential clients. Operators with sender-
|
|
562
562
|
// constrained tokens (DPoP / mTLS) can opt out by NOT supplying
|
|
563
563
|
// a seen callback.
|
|
564
|
-
|
|
565
|
-
|
|
564
|
+
//
|
|
565
|
+
// Atomic check-and-insert (audit 2026-05-11) — pre-v0.9.3 the
|
|
566
|
+
// check ran via `ropts.seen(token)` which was a check-then-act
|
|
567
|
+
// race: two concurrent refresh requests landed on the same
|
|
568
|
+
// event-loop tick could both see `seen === false` and both POST
|
|
569
|
+
// to the token endpoint, neither flagging the replay. The new
|
|
570
|
+
// contract is `ropts.checkAndInsert(token, expireAtMs)` which
|
|
571
|
+
// MUST atomically test-and-set: returns true if the token was
|
|
572
|
+
// ALREADY in the store (replay) and false if it just inserted
|
|
573
|
+
// the token. The legacy `seen` callback continues to work for
|
|
574
|
+
// backward compatibility but emits a deprecation warning.
|
|
575
|
+
var alreadySeen = false;
|
|
576
|
+
if (typeof ropts.checkAndInsert === "function") {
|
|
577
|
+
var nowMs = Date.now();
|
|
578
|
+
// 24h max refresh-token TTL — operators with shorter TTLs
|
|
579
|
+
// should configure their store's own expiry policy.
|
|
580
|
+
var expireAtMs = nowMs + C.TIME.hours(24);
|
|
581
|
+
try { alreadySeen = await ropts.checkAndInsert(refreshToken, expireAtMs); }
|
|
582
|
+
catch (e) {
|
|
583
|
+
throw new OAuthError("auth-oauth/seen-callback-failed",
|
|
584
|
+
"refreshAccessToken: checkAndInsert() callback threw: " + ((e && e.message) || String(e)));
|
|
585
|
+
}
|
|
586
|
+
} else if (typeof ropts.seen === "function") {
|
|
587
|
+
// Legacy non-atomic path. Documented as a check-then-act race;
|
|
588
|
+
// operators sharing a single-writer store (Redis SETNX, DB
|
|
589
|
+
// INSERT ON CONFLICT) MUST migrate to checkAndInsert. Stays
|
|
590
|
+
// here for backwards-compat with existing operator code.
|
|
566
591
|
try { alreadySeen = await ropts.seen(refreshToken); }
|
|
567
592
|
catch (e) {
|
|
568
593
|
throw new OAuthError("auth-oauth/seen-callback-failed",
|
|
569
594
|
"refreshAccessToken: seen() callback threw: " + ((e && e.message) || String(e)));
|
|
570
595
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
596
|
+
}
|
|
597
|
+
if (alreadySeen === true) {
|
|
598
|
+
throw new OAuthError("auth-oauth/refresh-token-replay",
|
|
599
|
+
"refreshAccessToken: refresh token has been presented before — refused " +
|
|
600
|
+
"(OAuth 2.1 §6.1 / RFC 9700 §4.13 one-time-use defense). The operator MUST " +
|
|
601
|
+
"treat this as a token-theft signal: revoke the refresh-token family + force " +
|
|
602
|
+
"the user to re-authenticate.");
|
|
578
603
|
}
|
|
579
604
|
var endpoint = await _resolveEndpoint("tokenEndpoint");
|
|
580
605
|
var body = new URLSearchParams();
|
|
@@ -873,7 +898,14 @@ function create(opts) {
|
|
|
873
898
|
expiresIn: raw.expires_in || null,
|
|
874
899
|
refreshToken: raw.refresh_token || null,
|
|
875
900
|
idToken: raw.id_token || null,
|
|
876
|
-
|
|
901
|
+
// RFC 6749 §3.3 — scope is space-separated, ONLY U+0020.
|
|
902
|
+
// `\s+` previously matched U+0085 NEL, U+00A0 NBSP, etc., so a
|
|
903
|
+
// hostile AS returning `scope: "admin<NEL>read"` would
|
|
904
|
+
// surface as `["admin", "read"]` and the operator's scope
|
|
905
|
+
// allowlist saw two distinct scopes. Spec-strict split on
|
|
906
|
+
// single-space + reject scope tokens that contain non-token
|
|
907
|
+
// chars. (Audit 2026-05-11.)
|
|
908
|
+
scope: raw.scope ? raw.scope.split(" ").filter(function (s) { return s.length > 0; }) : scope.slice(),
|
|
877
909
|
raw: raw,
|
|
878
910
|
};
|
|
879
911
|
if (tokens.idToken && isOidc) {
|
|
@@ -943,12 +975,29 @@ function create(opts) {
|
|
|
943
975
|
for (var i = 0; i < keys.length; i++) {
|
|
944
976
|
if (keys[i].kid === header.kid) { match = keys[i]; break; }
|
|
945
977
|
}
|
|
946
|
-
} else if (keys.length === 1) {
|
|
947
|
-
match = keys[0];
|
|
948
978
|
}
|
|
979
|
+
// Pre-v0.9.4 fell back to keys[0] when the token carried NO kid
|
|
980
|
+
// and the JWKS had exactly one key. This is a latent vector
|
|
981
|
+
// during JWKS rotation: an attacker who can ship a kid-less
|
|
982
|
+
// token gets the lone key during the window the rotated-out
|
|
983
|
+
// key was still cached at the IdP but the rotated-in key is
|
|
984
|
+
// already published. Refuse kid-less tokens unconditionally —
|
|
985
|
+
// every modern IdP includes kid; absent kid is a spec smell.
|
|
986
|
+
// (Audit 2026-05-11.) Operators with non-conforming IdPs that
|
|
987
|
+
// genuinely emit kid-less tokens can opt out via
|
|
988
|
+
// vopts.allowKidlessJwks = true with a logged warning.
|
|
949
989
|
if (!match) {
|
|
950
|
-
|
|
951
|
-
|
|
990
|
+
if (!header.kid && keys.length === 1 && vopts.allowKidlessJwks === true) {
|
|
991
|
+
match = keys[0];
|
|
992
|
+
} else {
|
|
993
|
+
throw new OAuthError("auth-oauth/no-matching-key",
|
|
994
|
+
header.kid
|
|
995
|
+
? "no JWKS key matches header.kid='" + header.kid + "'"
|
|
996
|
+
: "ID token has no kid header; framework refuses kid-less " +
|
|
997
|
+
"tokens to defend against JWKS-rotation key-pick attacks " +
|
|
998
|
+
"(pass vopts.allowKidlessJwks: true to opt out if your IdP " +
|
|
999
|
+
"genuinely emits kid-less tokens)");
|
|
1000
|
+
}
|
|
952
1001
|
}
|
|
953
1002
|
var keyObject = _jwkToKey(match);
|
|
954
1003
|
var params = _verifyParamsForAlg(header.alg);
|
package/lib/auth/oid4vci.js
CHANGED
|
@@ -54,7 +54,7 @@ var lazyRequire = require("../lazy-require");
|
|
|
54
54
|
var validateOpts = require("../validate-opts");
|
|
55
55
|
var safeJson = require("../safe-json");
|
|
56
56
|
var nodeCrypto = require("node:crypto");
|
|
57
|
-
var { generateToken, sha3Hash } = require("../crypto");
|
|
57
|
+
var { generateToken, sha3Hash, timingSafeEqual } = require("../crypto");
|
|
58
58
|
var { AuthError } = require("../framework-error");
|
|
59
59
|
|
|
60
60
|
var cache = lazyRequire(function () { return require("../cache"); });
|
|
@@ -116,9 +116,14 @@ function _verifyProofJwt(proofJwt, expectedAud, expectedCNonce, expectedClientId
|
|
|
116
116
|
throw new AuthError("auth-oid4vci/wrong-proof-aud",
|
|
117
117
|
"credential issuance: proof JWT aud \"" + payload.aud + "\" mismatch (expected \"" + expectedAud + "\")");
|
|
118
118
|
}
|
|
119
|
-
if (expectedCNonce !== null
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
if (expectedCNonce !== null) {
|
|
120
|
+
// Constant-time c_nonce compare — secret-shaped value vs
|
|
121
|
+
// attacker-controlled wallet payload. (Audit 2026-05-11.)
|
|
122
|
+
if (typeof payload.nonce !== "string" ||
|
|
123
|
+
!timingSafeEqual(payload.nonce, expectedCNonce)) {
|
|
124
|
+
throw new AuthError("auth-oid4vci/wrong-proof-nonce",
|
|
125
|
+
"credential issuance: proof JWT nonce mismatch (replay defense — wallet must use the c_nonce from the most recent issuer response)");
|
|
126
|
+
}
|
|
122
127
|
}
|
|
123
128
|
if (typeof payload.iat !== "number") {
|
|
124
129
|
throw new AuthError("auth-oid4vci/no-proof-iat",
|
|
@@ -400,8 +405,11 @@ function create(opts) {
|
|
|
400
405
|
"exchangePreAuthorizedCode: tx_code required (offer mandates it)");
|
|
401
406
|
}
|
|
402
407
|
var txHash = sha3Hash("oid4vci-tx:" + eopts.txCode);
|
|
403
|
-
// Constant-time
|
|
404
|
-
|
|
408
|
+
// Constant-time compare on the hashed tx_code (audit 2026-05-11
|
|
409
|
+
// — was `!==` on fixed-width sha3 hex; per CLAUDE.md rule §5
|
|
410
|
+
// every framework-internal compare against attacker-controlled
|
|
411
|
+
// input routes through timingSafeEqual).
|
|
412
|
+
if (!timingSafeEqual(txHash, entry.txCodeHash)) {
|
|
405
413
|
// Don't consume on failure — wallet may be retrying. Operator
|
|
406
414
|
// attaches their own attempt counter / lockout via b.auth.lockout.
|
|
407
415
|
_emitAudit("tx_code_mismatch", "failure", {
|
package/lib/auth/oid4vp.js
CHANGED
|
@@ -452,6 +452,16 @@ function create(opts) {
|
|
|
452
452
|
continue;
|
|
453
453
|
}
|
|
454
454
|
try {
|
|
455
|
+
// Per-presentation vct enforcement (audit 2026-05-11): when
|
|
456
|
+
// DCQL's `vct_values` has 1 entry, `expectedVct` pins it.
|
|
457
|
+
// With 2+ entries the verifier's expectedVct opt can't hold
|
|
458
|
+
// a list, so we verify-without-expected and then validate
|
|
459
|
+
// the actual vct against the DCQL list manually — over-
|
|
460
|
+
// disclosure defense (a holder presenting a vct outside
|
|
461
|
+
// the DCQL filter would previously slip through).
|
|
462
|
+
var dcqlVctValues = cq.meta && Array.isArray(cq.meta.vct_values) ? cq.meta.vct_values : null;
|
|
463
|
+
var expectedVct = dcqlVctValues && dcqlVctValues.length === 1
|
|
464
|
+
? dcqlVctValues[0] : undefined;
|
|
455
465
|
var verified = await sdJwtVcCore().verify(t, {
|
|
456
466
|
issuerKeyResolver: opts.issuerKeyResolver,
|
|
457
467
|
audience: audience,
|
|
@@ -459,9 +469,16 @@ function create(opts) {
|
|
|
459
469
|
requireKeyBinding: true,
|
|
460
470
|
requireKeyAttestation: vopts.requireKeyAttestation === true,
|
|
461
471
|
keyAttestationVerifier: opts.keyAttestationVerifier || null,
|
|
462
|
-
expectedVct:
|
|
463
|
-
? cq.meta.vct_values[0] : undefined,
|
|
472
|
+
expectedVct: expectedVct,
|
|
464
473
|
});
|
|
474
|
+
if (dcqlVctValues && dcqlVctValues.length > 1) {
|
|
475
|
+
if (!verified.claims || dcqlVctValues.indexOf(verified.claims.vct) === -1) {
|
|
476
|
+
verifyErrors.push("vp_token['" + id + "'][" + ti + "] vct '" +
|
|
477
|
+
((verified.claims && verified.claims.vct) || "<missing>") +
|
|
478
|
+
"' is not in DCQL vct_values [" + dcqlVctValues.join(", ") + "]");
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
465
482
|
presentations.push({
|
|
466
483
|
id: id,
|
|
467
484
|
format: cq.format,
|
package/lib/middleware/dpop.js
CHANGED
|
@@ -102,12 +102,33 @@ function _nonceManager(rotateSec) {
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function _reconstructHtu(req) {
|
|
105
|
+
function _reconstructHtu(req, mopts) {
|
|
106
106
|
// The proof's htu is the request URI WITHOUT query/fragment. Behind
|
|
107
107
|
// a reverse proxy the operator may need to override via opts.htu /
|
|
108
|
-
// opts.getHtu
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
// opts.getHtu. X-Forwarded-* headers are ATTACKER-CONTROLLED when
|
|
109
|
+
// the origin is reachable directly; an attacker who can hit the
|
|
110
|
+
// origin while spoofing X-Forwarded-Proto: https can trick this
|
|
111
|
+
// function into building an `https` htu that the DPoP proof was
|
|
112
|
+
// signed for — when the origin is actually serving HTTP. RFC 9449
|
|
113
|
+
// §4.3 says htu MUST be the absolute URL the request was sent to.
|
|
114
|
+
//
|
|
115
|
+
// Default: ignore X-Forwarded-* and derive proto/host from the
|
|
116
|
+
// socket. Operators with a confirmed-trusted front proxy opt in
|
|
117
|
+
// via opts.trustForwardedHeaders: true. (Audit 2026-05-11.)
|
|
118
|
+
mopts = mopts || {};
|
|
119
|
+
var trustForwarded = mopts.trustForwardedHeaders === true;
|
|
120
|
+
var proto;
|
|
121
|
+
if (trustForwarded && req.headers["x-forwarded-proto"]) {
|
|
122
|
+
proto = String(req.headers["x-forwarded-proto"]).split(",")[0].trim();
|
|
123
|
+
} else {
|
|
124
|
+
proto = req.socket && req.socket.encrypted ? "https" : "http";
|
|
125
|
+
}
|
|
126
|
+
var host;
|
|
127
|
+
if (trustForwarded && req.headers["x-forwarded-host"]) {
|
|
128
|
+
host = String(req.headers["x-forwarded-host"]).split(",")[0].trim();
|
|
129
|
+
} else {
|
|
130
|
+
host = req.headers.host;
|
|
131
|
+
}
|
|
111
132
|
if (!host) return null;
|
|
112
133
|
var path = req.url || "/";
|
|
113
134
|
var qIdx = path.indexOf("?");
|
|
@@ -217,7 +238,7 @@ function create(opts) {
|
|
|
217
238
|
"multiple DPoP headers are not allowed");
|
|
218
239
|
}
|
|
219
240
|
|
|
220
|
-
var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req));
|
|
241
|
+
var htu = (typeof opts.getHtu === "function" ? opts.getHtu(req) : _reconstructHtu(req, opts));
|
|
221
242
|
if (!htu) {
|
|
222
243
|
return _writeUnauthorized(res, "invalid_dpop_proof", "could not reconstruct htu");
|
|
223
244
|
}
|
package/lib/network-tls.js
CHANGED
|
@@ -1103,7 +1103,14 @@ function evaluateOcspResponse(ocspDer, opts) {
|
|
|
1103
1103
|
return { ok: false, status: parsed.status, signatureValid: true,
|
|
1104
1104
|
errors: ["OCSP response missing nonce extension (expected for replay defense)"] };
|
|
1105
1105
|
}
|
|
1106
|
-
|
|
1106
|
+
// Constant-time compare — module-wide consistency with the
|
|
1107
|
+
// Merkle-root / NTS-cookie / cert-fingerprint paths that already
|
|
1108
|
+
// use timingSafeEqual. Buffer.equals is constant-time on equal-
|
|
1109
|
+
// length inputs but fast-paths on length mismatch; not security-
|
|
1110
|
+
// critical here (the OCSP response is CA-signed and signature
|
|
1111
|
+
// already verified) but matches the project discipline.
|
|
1112
|
+
// (Audit 2026-05-11.)
|
|
1113
|
+
if (!blamejsCrypto.timingSafeEqual(parsed.basic.nonce, opts.expectedNonce)) {
|
|
1107
1114
|
return { ok: false, status: parsed.status, signatureValid: true,
|
|
1108
1115
|
errors: ["OCSP nonce mismatch — possible replay or wrong responder"] };
|
|
1109
1116
|
}
|
package/package.json
CHANGED
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.6",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:9ba09f74-5c33-44b9-b39c-b6016b2073d8",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-
|
|
8
|
+
"timestamp": "2026-05-12T00:48:11.880Z",
|
|
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.9.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.9.4",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.9.
|
|
25
|
+
"version": "0.9.4",
|
|
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.9.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.9.4",
|
|
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.9.
|
|
57
|
+
"ref": "@blamejs/core@0.9.4",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|