@blamejs/core 0.9.1 → 0.9.2

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,7 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.9.x
10
10
 
11
+ - 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.
11
12
  - 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.
12
13
  - 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).
13
14
  - v0.8.90 (2026-05-11) — **RFC 8689 REQUIRETLS support** (`b.mail.requireTls`). Per-message TLS-requirement signaling between sender and receiver MTAs. Complements MTA-STS / DANE (policy-side, domain-scoped) with a per-message knob that overrides policy when the operator wants stricter-than-policy delivery — message bounces instead of falling back to cleartext if no downstream MTA can deliver under TLS. **`peerSupports(ehloLines)`**: walks a parsed EHLO response and returns `true` when the peer advertised the `REQUIRETLS` keyword; case-insensitive per RFC 5321 §2.4; refuses substring matches (`FOO-REQUIRETLS-BAR` does NOT match); empty / non-array input returns `false`. **`mailFromExtension({ requireTls })`**: builds the trailing `" REQUIRETLS"` token to append to a MAIL FROM line; refuses non-boolean flag value (a truthy-but-wrong-shape value like `"yes"` throws instead of silently succeeding). **`parseTlsRequiredHeader(headerValue)`**: parses the RFC 8689 §5 `TLS-Required` header — returns `"no"` only when the value is the literal token `no` (case-insensitive, ignoring whitespace) per spec; any other non-empty value returns `"yes"` (RFC 8689 §5: "any value other than 'No' MUST be treated as if the field had been absent" — conservative strict path); returns `null` for absent / empty / non-string input; refuses control characters on the **raw** header value before `trim()` runs so a leading `\n` / trailing `\r` / NUL / DEL byte can no longer slip past as the literal token `no` (ASCII HT remains permitted as structural folding whitespace).
package/lib/auth/aal.js CHANGED
@@ -70,38 +70,51 @@ function _bandRank(band) {
70
70
  var KNOWN_METHODS = [
71
71
  "password", "pin", "totp", "sms", "webauthn", "passkey",
72
72
  "hardware", "mtls",
73
+ // `uv` is a webauthn-side qualifier: when true, the
74
+ // authenticator-data UV bit was set on the assertion. Required
75
+ // for AAL3 paired with `webauthn` / `passkey` per SP 800-63-4
76
+ // §5.1.7.
77
+ "uv",
73
78
  ];
74
79
 
75
80
  function fromMethods(methods) {
76
81
  if (!methods || typeof methods !== "object") {
77
82
  throw new AuthError("auth-aal/bad-methods",
78
- "fromMethods: methods must be an object like { password: true, webauthn: true }");
83
+ "fromMethods: methods must be an object like { password: true, webauthn: true, uv: true }");
79
84
  }
80
85
  var has = function (m) { return methods[m] === true; };
81
-
82
- // Phishing-resistant multi-factor AAL3.
83
- // - WebAuthn / passkey with UV=true alone satisfies AAL3 per
84
- // SP 800-63-4 §5.1.7 (cryptographic authenticator + verifier impl
85
- // binding + user verification = MF-CRYPT).
86
- // - Hardware authenticator + memorized secret also satisfies AAL3
87
- // (SF-CRYPT + memorized = MF-equivalent under §4.4.1).
88
- if (has("webauthn") || has("passkey")) return AAL3;
86
+ // SP 800-63-4 §5.1.7 — WebAuthn / passkey satisfies AAL3 only when
87
+ // user verification (UV) was actually performed on the assertion
88
+ // (MF-CRYPT requires the verifier to confirm the user authorized
89
+ // the operation). Pre-v0.9.2 this returned AAL3 unconditionally
90
+ // for any webauthn:true assertion; an operator using
91
+ // `userVerification: "preferred"` whose authenticator skipped UV
92
+ // landed in AAL3 despite not satisfying the spec's MF requirement.
93
+ //
94
+ // The operator passes `methods.uv: true` when verifyAuthentication's
95
+ // result confirmed UV on the authenticator data (vendor's
96
+ // `userVerified` flag). When `uv` is omitted or false, webauthn
97
+ // alone caps at AAL2 (SF-CRYPT — the cryptographic authenticator
98
+ // is verified, but user-verification proof is missing).
99
+ // Operators wanting the legacy optimistic path can pass
100
+ // `methods.uv: true` based on their startAuthentication
101
+ // `userVerification: "required"` setting having forced UV.
102
+ if ((has("webauthn") || has("passkey")) && has("uv")) return AAL3;
103
+ if ((has("webauthn") || has("passkey")) && !has("uv")) {
104
+ // SF-CRYPT (cryptographic but no UV-bound MF). Combine with a
105
+ // memorized secret to satisfy MF.
106
+ if (has("password") || has("pin")) return AAL3;
107
+ return AAL2;
108
+ }
89
109
  if (has("hardware") && (has("password") || has("pin"))) return AAL3;
90
110
 
91
- // Multi-factor → AAL2.
92
- // - password + totp / sms / hardware single-factor / mtls.
93
- // - SP 800-63-4 §5.1.3.3 — SMS is RESTRICTED but still satisfies
94
- // AAL2 with the operator's documented risk acceptance.
95
111
  if (has("password") || has("pin")) {
96
112
  if (has("totp") || has("sms") || has("hardware") || has("mtls")) return AAL2;
97
113
  return AAL1; // memorized secret alone
98
114
  }
99
115
 
100
- // Single-factor cryptographic — mtls / hardware on its own → AAL1
101
- // unless paired with a memorized secret (handled above).
102
116
  if (has("hardware") || has("mtls")) return AAL1;
103
117
 
104
- // No recognized method asserted.
105
118
  throw new AuthError("auth-aal/no-methods",
106
119
  "fromMethods: methods object did not assert any known authenticator " +
107
120
  "(known: " + KNOWN_METHODS.join(", ") + ")");
@@ -70,6 +70,11 @@ var REFUSE_STATUS = {
70
70
  REVOKED: 1,
71
71
  USER_KEY_PHYSICAL_COMPROMISE: 1,
72
72
  USER_KEY_REMOTE_COMPROMISE: 1,
73
+ // FIDO MDS3 §3.1.4 — attestation-key compromise means the
74
+ // manufacturer's batch-signing key is suspect; every credential
75
+ // attested under that key MUST be refused. Pre-v0.9.2 this token
76
+ // was missing from the refuse-list (audit 2026-05-11).
77
+ ATTESTATION_KEY_COMPROMISE: 1,
73
78
  };
74
79
 
75
80
  // FIDO Certified levels that surface as certifiedLevel. The spec uses
@@ -333,6 +338,21 @@ function _verifyAndParseBlob(token) {
333
338
  throw new FidoMds3Error("fido-mds3/bad-payload",
334
339
  "BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
335
340
  }
341
+ // Stale-BLOB refusal — FIDO MDS3 §3.1.7 says clients SHOULD refresh
342
+ // by nextUpdate; a BLOB whose nextUpdate is already in the past is
343
+ // not safe to trust even though its cert chain still validates.
344
+ // Pre-v0.9.2 the staleness was floored to MIN_CACHE_TTL_MS in
345
+ // _ttlFromNextUpdate but the BLOB itself was still served from
346
+ // cache; an attacker serving an ancient signed-but-expired BLOB
347
+ // could keep operators on a revoked-authenticator-list-frozen-at-X.
348
+ // Refuse at parse time so neither fetch nor cache lookup honors it.
349
+ // (Audit 2026-05-11.)
350
+ if (nextUpdate.getTime() < Date.now()) {
351
+ throw new FidoMds3Error("fido-mds3/blob-stale",
352
+ "BLOB payload nextUpdate \"" + payload.nextUpdate +
353
+ "\" is in the past — refusing to trust a stale metadata BLOB " +
354
+ "(FIDO MDS3 §3.1.7)");
355
+ }
336
356
  return {
337
357
  entries: payload.entries,
338
358
  no: payload.no,
@@ -539,7 +559,7 @@ function _certifiedLevel(statusReports) {
539
559
 
540
560
  /**
541
561
  * @primitive b.auth.fidoMds3.verifyAuthenticator
542
- * @signature b.auth.fidoMds3.verifyAuthenticator(blob, registrationInfo)
562
+ * @signature b.auth.fidoMds3.verifyAuthenticator(blob, registrationInfo, opts)
543
563
  * @since 0.8.53
544
564
  * @status stable
545
565
  * @related b.auth.fidoMds3.fetch, b.auth.fidoMds3.lookupAaguid
@@ -549,21 +569,31 @@ function _certifiedLevel(statusReports) {
549
569
  * `{ ok, statement, statusReports, certifiedLevel, reason? }`. Refuses
550
570
  * (ok: false) when the authenticator's status reports include any of
551
571
  * REVOKED / USER_KEY_PHYSICAL_COMPROMISE / USER_KEY_REMOTE_COMPROMISE
552
- * (FIDO MDS3 section 3.1.4 compromise bucket). Returns
553
- * `ok: true, statement: null` for AAGUIDs not present in the BLOB —
554
- * operators choose whether unknown AAGUIDs are permitted via an
555
- * allowlist policy on top of this primitive.
572
+ * / ATTESTATION_KEY_COMPROMISE (FIDO MDS3 section 3.1.4 compromise
573
+ * bucket).
574
+ *
575
+ * AAGUIDs not present in the BLOB **fail closed by default** in
576
+ * v0.9.2+ (pre-v0.9.2 returned `ok: true, statement: null`, silently
577
+ * trusting any authenticator not yet in the metadata service). To
578
+ * accept unknown AAGUIDs (test fixtures, pre-certification rollouts),
579
+ * pass `opts.allowUnknownAaguid: true`; the `reason` field then notes
580
+ * the operator opt-in.
556
581
  *
557
582
  * Audits auth.fido_mds3.verify.refused (drop-silent) on compromise.
558
583
  *
584
+ * @opts
585
+ * allowUnknownAaguid: boolean, // default false (fail-closed)
586
+ *
559
587
  * @example
560
588
  * var blob = { entries: [] };
561
589
  * var reg = { aaguid: "00000000-0000-0000-0000-000000000000" };
562
- * var rv = b.auth.fidoMds3.verifyAuthenticator(blob, reg);
590
+ * var rv = b.auth.fidoMds3.verifyAuthenticator(blob, reg,
591
+ * { allowUnknownAaguid: true });
563
592
  * rv.ok === true && rv.statement === null;
564
- * // → true
593
+ * // → true (with operator opt-in)
565
594
  */
566
- function verifyAuthenticator(blob, registrationInfo) {
595
+ function verifyAuthenticator(blob, registrationInfo, vopts) {
596
+ vopts = vopts || {};
567
597
  if (!blob) {
568
598
  throw new FidoMds3Error("fido-mds3/bad-blob", "blob is required");
569
599
  }
@@ -573,12 +603,23 @@ function verifyAuthenticator(blob, registrationInfo) {
573
603
  }
574
604
  var entry = lookupAaguid(blob, registrationInfo.aaguid);
575
605
  if (!entry) {
606
+ // Fail-CLOSED default for unknown AAGUIDs (audit 2026-05-11).
607
+ // Pre-v0.9.2 default was `ok: true, reason: "aaguid-not-in-blob"`
608
+ // — an attacker registering a credential with an AAGUID not in
609
+ // the BLOB (rogue authenticator, fake hardware) silently passed.
610
+ // The framework's primitive now refuses by default; operators
611
+ // who genuinely want to accept unknown authenticators (test
612
+ // fixtures, pre-certification pilot rollouts) pass
613
+ // `vopts.allowUnknownAaguid: true` explicitly.
614
+ var unknownOk = vopts.allowUnknownAaguid === true;
576
615
  return {
577
- ok: true,
616
+ ok: unknownOk,
578
617
  statement: null,
579
618
  statusReports: [],
580
619
  certifiedLevel: { level: 0, plus: false },
581
- reason: "aaguid-not-in-blob",
620
+ reason: unknownOk
621
+ ? "aaguid-not-in-blob (operator opted in via allowUnknownAaguid)"
622
+ : "aaguid-not-in-blob",
582
623
  };
583
624
  }
584
625
  var statusReports = Array.isArray(entry.statusReports) ? entry.statusReports : [];
@@ -112,13 +112,39 @@ async function startRegistration(opts) {
112
112
  return options;
113
113
  }
114
114
 
115
+ function _validateExpectedOrigin(value) {
116
+ if (typeof value === "string") {
117
+ if (value.length === 0) {
118
+ throw new AuthError("auth-passkey/missing-expectedOrigin",
119
+ "expectedOrigin must be a non-empty string or array of strings");
120
+ }
121
+ return;
122
+ }
123
+ if (Array.isArray(value)) {
124
+ if (value.length === 0) {
125
+ throw new AuthError("auth-passkey/missing-expectedOrigin",
126
+ "expectedOrigin array must contain at least one non-empty string");
127
+ }
128
+ for (var i = 0; i < value.length; i += 1) {
129
+ if (typeof value[i] !== "string" || value[i].length === 0) {
130
+ throw new AuthError("auth-passkey/missing-expectedOrigin",
131
+ "expectedOrigin[" + i + "] must be a non-empty string");
132
+ }
133
+ }
134
+ return;
135
+ }
136
+ throw new AuthError("auth-passkey/missing-expectedOrigin",
137
+ "expectedOrigin must be a non-empty string or array of strings");
138
+ }
139
+
115
140
  async function verifyRegistration(opts) {
116
141
  if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
117
142
  if (!opts.response) {
118
143
  throw new AuthError("auth-passkey/missing-response", "opts.response is required");
119
144
  }
120
145
  _requireString(opts.expectedChallenge, "expectedChallenge");
121
- _requireString(opts.expectedOrigin, "expectedOrigin");
146
+ // Multi-origin deployments (web + admin subdomain) need string[].
147
+ _validateExpectedOrigin(opts.expectedOrigin);
122
148
  _requireString(opts.expectedRPID, "expectedRPID");
123
149
 
124
150
  var rv = await _vendor().verifyRegistrationResponse({
@@ -161,12 +187,17 @@ async function verifyRegistration(opts) {
161
187
  // Credential Management spec: "silent" / "optional" / "required" /
162
188
  // "conditional". "conditional" enables passkey autofill on
163
189
  // <input autocomplete="webauthn">.
164
- var ALLOWED_MEDIATION = { silent: 1, optional: 1, required: 1, conditional: 1 };
190
+ // Null-prototype map so `opts.mediation === "__proto__"` /
191
+ // `"constructor"` can't truthy-match an inherited property and slip
192
+ // past the allowlist (audit 2026-05-11).
193
+ var ALLOWED_MEDIATION = Object.assign(Object.create(null),
194
+ { silent: 1, optional: 1, required: 1, conditional: 1 });
165
195
 
166
196
  async function startAuthentication(opts) {
167
197
  if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
168
198
  _requireString(opts.rpId, "rpId");
169
- if (opts.mediation !== undefined && !ALLOWED_MEDIATION[opts.mediation]) {
199
+ if (opts.mediation !== undefined &&
200
+ !Object.prototype.hasOwnProperty.call(ALLOWED_MEDIATION, opts.mediation)) {
170
201
  throw new AuthError("auth-passkey/bad-mediation",
171
202
  "mediation must be one of silent/optional/required/conditional");
172
203
  }
@@ -352,12 +383,40 @@ async function verifyAuthentication(opts) {
352
383
  throw new AuthError("auth-passkey/missing-response", "opts.response is required");
353
384
  }
354
385
  _requireString(opts.expectedChallenge, "expectedChallenge");
355
- _requireString(opts.expectedOrigin, "expectedOrigin");
386
+ _validateExpectedOrigin(opts.expectedOrigin);
356
387
  _requireString(opts.expectedRPID, "expectedRPID");
357
388
  if (!opts.credential || !opts.credential.id || !opts.credential.publicKey) {
358
389
  throw new AuthError("auth-passkey/missing-credential",
359
390
  "opts.credential { id, publicKey, counter? } is required");
360
391
  }
392
+ // Counter regression bypass fix (audit 2026-05-11) — pre-v0.9.2
393
+ // shape `opts.credential.counter || 0` silently zeroed an
394
+ // undefined / null / NaN counter, defeating CTAP 2.1 clone-
395
+ // detection on credentials whose stored counter is > 0. An
396
+ // operator who deserialized the credential from a column that
397
+ // dropped the counter would unknowingly accept a cloned
398
+ // authenticator. Require an explicit non-negative integer.
399
+ var counter;
400
+ if (opts.credential.counter === undefined || opts.credential.counter === null) {
401
+ // First-time-stored credentials legitimately have no counter
402
+ // yet (registration ran on a vendor returning 0). Operators
403
+ // MUST persist whatever the vendor returned; if they didn't,
404
+ // refuse rather than silently coerce.
405
+ throw new AuthError("auth-passkey/missing-counter",
406
+ "opts.credential.counter is required (set to 0 at registration; " +
407
+ "store the newCounter returned by verifyAuthentication on every " +
408
+ "successful auth). undefined / null is refused to prevent clone-" +
409
+ "detection bypass when the persisted column is missing.");
410
+ }
411
+ if (typeof opts.credential.counter !== "number" ||
412
+ !isFinite(opts.credential.counter) ||
413
+ opts.credential.counter < 0 ||
414
+ Math.floor(opts.credential.counter) !== opts.credential.counter) {
415
+ throw new AuthError("auth-passkey/bad-counter",
416
+ "opts.credential.counter must be a non-negative integer (got " +
417
+ typeof opts.credential.counter + ")");
418
+ }
419
+ counter = opts.credential.counter;
361
420
 
362
421
  var rv = await _vendor().verifyAuthenticationResponse({
363
422
  response: opts.response,
@@ -367,7 +426,7 @@ async function verifyAuthentication(opts) {
367
426
  credential: {
368
427
  id: opts.credential.id,
369
428
  publicKey: opts.credential.publicKey,
370
- counter: opts.credential.counter || 0,
429
+ counter: counter,
371
430
  transports: opts.credential.transports,
372
431
  },
373
432
  requireUserVerification: opts.requireUserVerification !== false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
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.6",
5
- "serialNumber": "urn:uuid:bffe9a17-1586-474d-aa8f-75dd58525185",
5
+ "serialNumber": "urn:uuid:1fccffd9-4415-43af-8e59-a1b496768581",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-11T23:22:03.045Z",
8
+ "timestamp": "2026-05-11T23:52:18.846Z",
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.1",
22
+ "bom-ref": "@blamejs/core@0.9.2",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.9.1",
25
+ "version": "0.9.2",
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.1",
29
+ "purl": "pkg:npm/%40blamejs/core@0.9.2",
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.1",
57
+ "ref": "@blamejs/core@0.9.2",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]