@blamejs/core 0.8.51 → 0.8.57

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/index.js +8 -0
  3. package/lib/audit.js +4 -0
  4. package/lib/auth/fido-mds3.js +624 -0
  5. package/lib/auth/passkey.js +214 -2
  6. package/lib/auth-bot-challenge.js +1 -1
  7. package/lib/credential-hash.js +2 -2
  8. package/lib/framework-error.js +55 -0
  9. package/lib/guard-cidr.js +2 -1
  10. package/lib/guard-jwt.js +2 -2
  11. package/lib/guard-oauth.js +2 -2
  12. package/lib/http-client-cache.js +916 -0
  13. package/lib/http-client.js +242 -0
  14. package/lib/local-db-thin.js +8 -7
  15. package/lib/mail-arf.js +343 -0
  16. package/lib/mail-auth.js +265 -40
  17. package/lib/mail-bimi.js +948 -33
  18. package/lib/mail-bounce.js +386 -4
  19. package/lib/mail-mdn.js +424 -0
  20. package/lib/mail-unsubscribe.js +265 -25
  21. package/lib/mail.js +403 -21
  22. package/lib/middleware/bearer-auth.js +1 -1
  23. package/lib/middleware/clear-site-data.js +122 -0
  24. package/lib/middleware/dpop.js +1 -1
  25. package/lib/middleware/index.js +9 -0
  26. package/lib/middleware/nel.js +214 -0
  27. package/lib/middleware/security-headers.js +56 -4
  28. package/lib/middleware/speculation-rules.js +323 -0
  29. package/lib/mime-parse.js +198 -0
  30. package/lib/network-dns.js +890 -27
  31. package/lib/network-tls.js +745 -0
  32. package/lib/object-store/sigv4.js +54 -0
  33. package/lib/public-suffix.js +414 -0
  34. package/lib/safe-buffer.js +7 -0
  35. package/lib/safe-json.js +1 -1
  36. package/lib/static.js +120 -0
  37. package/lib/storage.js +11 -0
  38. package/lib/vendor/MANIFEST.json +33 -0
  39. package/lib/vendor/bimi-trust-anchors.pem +33 -0
  40. package/lib/vendor/public-suffix-list.dat +16376 -0
  41. package/package.json +1 -1
  42. package/sbom.cyclonedx.json +6 -6
@@ -0,0 +1,624 @@
1
+ "use strict";
2
+ /**
3
+ * FIDO Metadata Service v3 (MDS3) — authenticator metadata BLOB
4
+ * verifier + AAGUID lookup.
5
+ *
6
+ * Spec: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.0-rd-20210518.html
7
+ *
8
+ * The MDS3 BLOB is a JWS-signed JSON document hosted at
9
+ * https://mds3.fidoalliance.org/. Operators use it to:
10
+ *
11
+ * 1. Pin the AAGUIDs of authenticators they accept (allowlist by
12
+ * vendor / FIDO certification level).
13
+ * 2. Refuse credentials registered against authenticators with a
14
+ * REVOKED / USER_KEY_PHYSICAL_COMPROMISE /
15
+ * USER_KEY_REMOTE_COMPROMISE status report.
16
+ * 3. Surface the FIDO Certified level (L1 / L1+ / L2 / L3 / L3+) so
17
+ * step-up / risk policies can require a minimum bar.
18
+ *
19
+ * Surface (b.auth.fidoMds3.*):
20
+ *
21
+ * await fidoMds3.fetch({ url?, caCertificate?, force? })
22
+ * -> { entries, no, nextUpdate }
23
+ * fidoMds3.lookupAaguid(blob, aaguid)
24
+ * -> entry | null
25
+ * fidoMds3.verifyAuthenticator(blob, registrationInfo)
26
+ * -> { ok, statement, statusReports, certifiedLevel, reason? }
27
+ *
28
+ * Trust root: pinned to the FIDO Alliance MDS3 root certificate
29
+ * (GlobalSign Root CA - R3, vendored via the simplewebauthn-server
30
+ * SettingsService). Operators with an air-gapped or proxied deployment
31
+ * can override via caCertificate (PEM or array of PEMs).
32
+ *
33
+ * Cache: in-memory, keyed by URL, TTL = nextUpdate - now from the BLOB
34
+ * itself. The MDS3 spec mandates clients refresh by nextUpdate; the
35
+ * cache enforces it. force: true bypasses the cache for an immediate
36
+ * refresh.
37
+ */
38
+
39
+ var nodeCrypto = require("node:crypto");
40
+
41
+ var C = require("../constants");
42
+ var safeJson = require("../safe-json");
43
+ var safeBuffer = require("../safe-buffer");
44
+ var lazyRequire = require("../lazy-require");
45
+ var validateOpts = require("../validate-opts");
46
+ var _wa = require("../vendor/simplewebauthn-server.cjs");
47
+ var { FidoMds3Error } = require("../framework-error");
48
+
49
+ var httpClient = lazyRequire(function () { return require("../http-client"); });
50
+ var cacheFwk = lazyRequire(function () { return require("../cache"); });
51
+ var auditFwk = lazyRequire(function () { return require("../audit"); });
52
+
53
+ var DEFAULT_URL = "https://mds3.fidoalliance.org/";
54
+ var DEFAULT_TIMEOUT_MS = C.TIME.seconds(30);
55
+ // MDS3 BLOB is ~4-8 MB depending on vendor count; cap at 32 MiB so
56
+ // transient size growth doesn't break operators while a runaway
57
+ // response body can't OOM the host.
58
+ var MAX_BLOB_BYTES = C.BYTES.mib(32);
59
+ // Floor + ceiling on cache TTL. The BLOB itself dictates nextUpdate,
60
+ // but a malformed payload that yields nextUpdate-in-the-past would
61
+ // otherwise force every call to refetch. Floor protects upstream;
62
+ // ceiling caps stale-trust risk if nextUpdate is set absurdly far out.
63
+ var MIN_CACHE_TTL_MS = C.TIME.minutes(5);
64
+ var MAX_CACHE_TTL_MS = C.TIME.days(30);
65
+
66
+ // FIDO MDS3 status reports that mark an authenticator as compromised /
67
+ // revoked per spec section 3.1.4. ANY of these in an authenticator's
68
+ // status report list refuses the credential.
69
+ var REFUSE_STATUS = {
70
+ REVOKED: 1,
71
+ USER_KEY_PHYSICAL_COMPROMISE: 1,
72
+ USER_KEY_REMOTE_COMPROMISE: 1,
73
+ };
74
+
75
+ // FIDO Certified levels that surface as certifiedLevel. The spec uses
76
+ // FIDO_CERTIFIED_L{N} / FIDO_CERTIFIED_L{N}_PLUS tokens; we collapse
77
+ // them to { level: 1|2|3, plus: bool } so policy code doesn't have to
78
+ // grep for the textual variants.
79
+ var CERT_LEVEL_RE = /^FIDO_CERTIFIED_L([1-3])(_PLUS)?$/;
80
+
81
+ // ---- helpers ----
82
+
83
+ function _b64urlDecode(s) {
84
+ if (typeof s !== "string" || s.length === 0 || !safeBuffer.BASE64URL_RE.test(s)) {
85
+ throw new FidoMds3Error("fido-mds3/bad-jws-segment",
86
+ "JWS segment is not base64url");
87
+ }
88
+ return Buffer.from(s, "base64url");
89
+ }
90
+
91
+ // Wrap a base64-encoded DER cert (no PEM markers) into PEM form so
92
+ // node:crypto.X509Certificate accepts it.
93
+ function _derToPem(b64) {
94
+ var lines = [];
95
+ for (var i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64)); // allow:raw-byte-literal — RFC 7468 PEM line width
96
+ return "-----BEGIN CERTIFICATE-----\n" + lines.join("\n") +
97
+ "\n-----END CERTIFICATE-----\n";
98
+ }
99
+
100
+ // Parse the JWS compact serialization. Returns { header, payload, sig,
101
+ // signingInput }.
102
+ function _parseJws(token) {
103
+ if (typeof token !== "string" || token.length === 0) {
104
+ throw new FidoMds3Error("fido-mds3/bad-jws", "BLOB token must be a non-empty string");
105
+ }
106
+ var parts = token.split(".");
107
+ if (parts.length !== 3) {
108
+ throw new FidoMds3Error("fido-mds3/bad-jws", "BLOB does not have 3 JWS segments");
109
+ }
110
+ var header, payload;
111
+ try {
112
+ header = safeJson.parse(_b64urlDecode(parts[0]).toString("utf8"),
113
+ { maxBytes: C.BYTES.kib(64) });
114
+ payload = safeJson.parse(_b64urlDecode(parts[1]).toString("utf8"),
115
+ { maxBytes: MAX_BLOB_BYTES });
116
+ } catch (e) {
117
+ throw new FidoMds3Error("fido-mds3/bad-jws-json",
118
+ "BLOB header / payload JSON parse failed: " + ((e && e.message) || String(e)));
119
+ }
120
+ if (!header || typeof header.alg !== "string") {
121
+ throw new FidoMds3Error("fido-mds3/bad-jws-header", "BLOB header missing 'alg'");
122
+ }
123
+ if (!Array.isArray(header.x5c) || header.x5c.length === 0) {
124
+ throw new FidoMds3Error("fido-mds3/bad-jws-header",
125
+ "BLOB header missing 'x5c' certificate chain");
126
+ }
127
+ return {
128
+ header: header,
129
+ payload: payload,
130
+ sig: _b64urlDecode(parts[2]),
131
+ signingInput: parts[0] + "." + parts[1],
132
+ };
133
+ }
134
+
135
+ // Map JWS alg to nodeCrypto verify parameters. MDS3 uses RS256 / ES256
136
+ // in practice; PS* and EdDSA are listed for completeness so future
137
+ // BLOBs over the same surface validate without a code edit.
138
+ function _verifyParamsForAlg(alg) {
139
+ switch (alg) {
140
+ case "RS256": return { hash: "sha256", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
141
+ case "RS384": return { hash: "sha384", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
142
+ case "RS512": return { hash: "sha512", padding: nodeCrypto.constants.RSA_PKCS1_PADDING };
143
+ case "PS256": return { hash: "sha256", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }; // allow:raw-byte-literal — SHA-256 hash length
144
+ case "PS384": return { hash: "sha384", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 48 }; // allow:raw-byte-literal — SHA-384 hash length
145
+ case "PS512": return { hash: "sha512", padding: nodeCrypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: 64 }; // allow:raw-byte-literal — SHA-512 hash length
146
+ case "ES256": return { hash: "sha256", dsaEncoding: "ieee-p1363" };
147
+ case "ES384": return { hash: "sha384", dsaEncoding: "ieee-p1363" };
148
+ case "ES512": return { hash: "sha512", dsaEncoding: "ieee-p1363" };
149
+ case "EdDSA": return { hash: null };
150
+ default:
151
+ throw new FidoMds3Error("fido-mds3/unsupported-alg",
152
+ "JWS alg '" + alg + "' is not supported");
153
+ }
154
+ }
155
+
156
+ // Resolve the trust roots. Operator override via caCertificate lets
157
+ // the framework run against a private MDS3 mirror or a regenerated
158
+ // root without a vendor refresh; the default is the GlobalSign Root
159
+ // CA - R3 PEM that the FIDO Alliance pins MDS3 BLOB chains to,
160
+ // vendored through simplewebauthn-server SettingsService so the PEM
161
+ // stays in lock-step with the vendor refresh.
162
+ function _resolveRoots(caCertificate) {
163
+ if (caCertificate === undefined || caCertificate === null) {
164
+ var pems = [];
165
+ try { pems = _wa.SettingsService.getRootCertificates({ identifier: "mds" }) || []; }
166
+ catch (_e) { pems = []; }
167
+ if (!pems || pems.length === 0) {
168
+ throw new FidoMds3Error("fido-mds3/no-trust-root",
169
+ "no FIDO MDS3 root certificate available — vendored bundle missing 'mds' trust anchor");
170
+ }
171
+ return pems.slice();
172
+ }
173
+ if (typeof caCertificate === "string") return [caCertificate];
174
+ if (Array.isArray(caCertificate)) {
175
+ if (caCertificate.length === 0) {
176
+ throw new FidoMds3Error("fido-mds3/bad-ca",
177
+ "caCertificate array must not be empty");
178
+ }
179
+ for (var i = 0; i < caCertificate.length; i++) {
180
+ if (typeof caCertificate[i] !== "string") {
181
+ throw new FidoMds3Error("fido-mds3/bad-ca",
182
+ "caCertificate[" + i + "] must be a PEM string");
183
+ }
184
+ }
185
+ return caCertificate.slice();
186
+ }
187
+ throw new FidoMds3Error("fido-mds3/bad-ca",
188
+ "caCertificate must be a PEM string or array of PEM strings");
189
+ }
190
+
191
+ // Validate the x5c cert chain against the trust roots. Each cert in
192
+ // the chain must be issued by the next; the last cert must verify
193
+ // against one of the trust roots. Uses node:crypto's
194
+ // X509Certificate.verify(publicKey) for issuer-signature checks and
195
+ // .checkIssued(other) for subject/issuer match.
196
+ function _validateChain(x5c, rootPems) {
197
+ if (!Array.isArray(x5c) || x5c.length === 0) {
198
+ throw new FidoMds3Error("fido-mds3/bad-x5c", "JWS x5c chain is empty");
199
+ }
200
+ var chain = [];
201
+ for (var i = 0; i < x5c.length; i++) {
202
+ if (typeof x5c[i] !== "string" || x5c[i].length === 0) {
203
+ throw new FidoMds3Error("fido-mds3/bad-x5c",
204
+ "x5c[" + i + "] must be a base64-encoded DER cert");
205
+ }
206
+ try {
207
+ chain.push(new nodeCrypto.X509Certificate(_derToPem(x5c[i])));
208
+ } catch (e) {
209
+ throw new FidoMds3Error("fido-mds3/bad-x5c",
210
+ "x5c[" + i + "] failed to parse: " + ((e && e.message) || String(e)));
211
+ }
212
+ }
213
+ var now = Date.now();
214
+ for (var v = 0; v < chain.length; v++) {
215
+ var notBefore = Date.parse(chain[v].validFrom);
216
+ var notAfter = Date.parse(chain[v].validTo);
217
+ if (isFinite(notBefore) && now < notBefore) {
218
+ throw new FidoMds3Error("fido-mds3/cert-not-yet-valid",
219
+ "x5c[" + v + "] is not yet valid (notBefore=" + chain[v].validFrom + ")");
220
+ }
221
+ if (isFinite(notAfter) && now > notAfter) {
222
+ throw new FidoMds3Error("fido-mds3/cert-expired",
223
+ "x5c[" + v + "] expired at " + chain[v].validTo);
224
+ }
225
+ }
226
+ for (var c = 0; c < chain.length - 1; c++) {
227
+ if (!chain[c].checkIssued(chain[c + 1])) {
228
+ throw new FidoMds3Error("fido-mds3/chain-broken",
229
+ "x5c[" + c + "] not issued by x5c[" + (c + 1) + "]");
230
+ }
231
+ var issuerKey = chain[c + 1].publicKey;
232
+ if (!chain[c].verify(issuerKey)) {
233
+ throw new FidoMds3Error("fido-mds3/chain-bad-signature",
234
+ "x5c[" + c + "] signature does not verify against x5c[" + (c + 1) + "] public key");
235
+ }
236
+ }
237
+ var tail = chain[chain.length - 1];
238
+ var anchored = false;
239
+ for (var r = 0; r < rootPems.length; r++) {
240
+ var root;
241
+ try { root = new nodeCrypto.X509Certificate(rootPems[r]); }
242
+ catch (_e) { continue; }
243
+ if (tail.checkIssued(root) && tail.verify(root.publicKey)) {
244
+ anchored = true;
245
+ break;
246
+ }
247
+ // The root may itself be self-signed and identical to tail (some
248
+ // CAs ship the root in x5c). Treat exact-match as anchored.
249
+ if (tail.fingerprint256 === root.fingerprint256) {
250
+ anchored = true;
251
+ break;
252
+ }
253
+ }
254
+ if (!anchored) {
255
+ throw new FidoMds3Error("fido-mds3/chain-not-anchored",
256
+ "x5c chain does not anchor to any provided trust root");
257
+ }
258
+ return chain;
259
+ }
260
+
261
+ // Verify the JWS signature using the leaf certificate's public key.
262
+ function _verifyJws(jws, leafCert) {
263
+ var params = _verifyParamsForAlg(jws.header.alg);
264
+ var verifyOpts = { key: leafCert.publicKey };
265
+ if (params.padding !== undefined) verifyOpts.padding = params.padding;
266
+ if (params.saltLength !== undefined) verifyOpts.saltLength = params.saltLength;
267
+ if (params.dsaEncoding !== undefined) verifyOpts.dsaEncoding = params.dsaEncoding;
268
+ var verified;
269
+ try {
270
+ verified = nodeCrypto.verify(params.hash, Buffer.from(jws.signingInput, "ascii"),
271
+ verifyOpts, jws.sig);
272
+ } catch (e) {
273
+ throw new FidoMds3Error("fido-mds3/bad-signature",
274
+ "BLOB signature verify threw: " + ((e && e.message) || String(e)));
275
+ }
276
+ if (!verified) {
277
+ throw new FidoMds3Error("fido-mds3/bad-signature",
278
+ "BLOB signature did not verify against the leaf cert");
279
+ }
280
+ }
281
+
282
+ // ---- cache ----
283
+
284
+ var _sharedCache = null;
285
+ function _getCache() {
286
+ if (_sharedCache) return _sharedCache;
287
+ _sharedCache = cacheFwk().create({
288
+ namespace: "auth-fido-mds3.blob",
289
+ ttlMs: MAX_CACHE_TTL_MS,
290
+ maxEntries: 8, // allow:raw-byte-literal — operator-pinned URL set
291
+ });
292
+ return _sharedCache;
293
+ }
294
+
295
+ function _ttlFromNextUpdate(nextUpdateDate) {
296
+ if (!(nextUpdateDate instanceof Date) || !isFinite(nextUpdateDate.getTime())) {
297
+ return MIN_CACHE_TTL_MS;
298
+ }
299
+ var ms = nextUpdateDate.getTime() - Date.now();
300
+ if (ms < MIN_CACHE_TTL_MS) return MIN_CACHE_TTL_MS;
301
+ if (ms > MAX_CACHE_TTL_MS) return MAX_CACHE_TTL_MS;
302
+ return ms;
303
+ }
304
+
305
+ // MDS3 nextUpdate per spec section 3.1.7 is "YYYY-MM-DD" (UTC midnight).
306
+ function _parseNextUpdate(s) {
307
+ if (typeof s !== "string") return null;
308
+ var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); // allow:raw-byte-literal — ISO-8601 date components
309
+ if (!m) return null;
310
+ var d = new Date(Date.UTC(parseInt(m[1], 10), parseInt(m[2], 10) - 1, parseInt(m[3], 10)));
311
+ return isFinite(d.getTime()) ? d : null;
312
+ }
313
+
314
+ // Internal verify-blob helper used by both fetch (live HTTP) and the
315
+ // fetch-with-injected-body test path. Operator-facing surface goes
316
+ // through fetch().
317
+ function _verifyAndParseBlob(token) {
318
+ var jws = _parseJws(token);
319
+ var rootPems = _resolveRoots(undefined);
320
+ var chain = _validateChain(jws.header.x5c, rootPems);
321
+ _verifyJws(jws, chain[0]);
322
+ var payload = jws.payload;
323
+ if (!payload || !Array.isArray(payload.entries)) {
324
+ throw new FidoMds3Error("fido-mds3/bad-payload",
325
+ "BLOB payload missing 'entries' array");
326
+ }
327
+ if (typeof payload.no !== "number" || !isFinite(payload.no)) {
328
+ throw new FidoMds3Error("fido-mds3/bad-payload",
329
+ "BLOB payload missing or non-numeric 'no'");
330
+ }
331
+ var nextUpdate = _parseNextUpdate(payload.nextUpdate);
332
+ if (!nextUpdate) {
333
+ throw new FidoMds3Error("fido-mds3/bad-payload",
334
+ "BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
335
+ }
336
+ return {
337
+ entries: payload.entries,
338
+ no: payload.no,
339
+ nextUpdate: nextUpdate,
340
+ legalHeader: payload.legalHeader,
341
+ };
342
+ }
343
+
344
+ // ---- public surface ----
345
+
346
+ /**
347
+ * @primitive b.auth.fidoMds3.fetch
348
+ * @signature b.auth.fidoMds3.fetch(opts)
349
+ * @since 0.8.53
350
+ * @status stable
351
+ * @related b.auth.fidoMds3.lookupAaguid, b.auth.fidoMds3.verifyAuthenticator
352
+ *
353
+ * Fetches the FIDO Alliance MDS3 metadata BLOB, verifies the JWS
354
+ * signature against the FIDO Alliance MDS3 root CA, parses the payload,
355
+ * and returns a structured handle. Subsequent calls within the BLOB's
356
+ * nextUpdate window return the cached result. force: true bypasses
357
+ * the cache for an immediate refresh.
358
+ *
359
+ * Verification steps (each fails closed with FidoMds3Error):
360
+ * 1. HTTPS GET via b.httpClient (SSRF gate, response-size cap).
361
+ * 2. Parse compact JWS (header / payload / signature).
362
+ * 3. Decode x5c certificate chain; validate validity windows; chain
363
+ * each link with X509Certificate.checkIssued and
364
+ * X509Certificate.verify(issuerKey); anchor the tail to the MDS3
365
+ * root trust set.
366
+ * 4. Verify the JWS signature against the leaf cert's public key.
367
+ * 5. Parse nextUpdate; reject if missing or malformed.
368
+ *
369
+ * @opts
370
+ * url: string, // default: https://mds3.fidoalliance.org/
371
+ * caCertificate: string|string[],// PEM(s) overriding the default MDS3 root
372
+ * force: boolean, // default: false; bypass the cache
373
+ * timeoutMs: number, // default: 30s
374
+ *
375
+ * @example
376
+ * var blob = await b.auth.fidoMds3.fetch({ force: false });
377
+ * typeof blob.entries.length === "number";
378
+ * // → true
379
+ */
380
+ async function fetch(opts) { // allow:raw-outbound-http — function name is fetch, internal call routes through b.httpClient
381
+ opts = opts || {};
382
+ validateOpts(opts, ["url", "caCertificate", "force", "timeoutMs"], "auth.fido_mds3.fetch");
383
+
384
+ var url = opts.url || DEFAULT_URL;
385
+ if (typeof url !== "string" || url.length === 0) {
386
+ throw new FidoMds3Error("fido-mds3/bad-url", "url must be a non-empty string");
387
+ }
388
+ if (!/^https:/i.test(url)) {
389
+ throw new FidoMds3Error("fido-mds3/bad-url",
390
+ "url must be https:// (FIDO MDS3 trust root requires TLS)");
391
+ }
392
+ var timeoutMs = typeof opts.timeoutMs === "number" ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
393
+ if (typeof timeoutMs !== "number" || !isFinite(timeoutMs) || timeoutMs <= 0) {
394
+ throw new FidoMds3Error("fido-mds3/bad-timeout",
395
+ "timeoutMs must be a positive finite number");
396
+ }
397
+ var rootPems = _resolveRoots(opts.caCertificate);
398
+
399
+ var cacheKey = "blob:" + url;
400
+ var c = _getCache();
401
+ if (opts.force) {
402
+ try { await c.del(cacheKey); } catch (_e) { /* best-effort */ }
403
+ }
404
+
405
+ // cache.wrap takes an upfront ttlMs; for the loader we use the safe
406
+ // minimum, then re-assert a precise nextUpdate-driven TTL with c.set
407
+ // once the BLOB is parsed. This pattern lets the cache carry the
408
+ // computed-from-payload TTL without blocking on a pre-knowledge of it.
409
+ return await c.wrap(cacheKey, async function () {
410
+ var rsp;
411
+ try {
412
+ rsp = await httpClient().request({
413
+ method: "GET",
414
+ url: url,
415
+ maxResponseBytes: MAX_BLOB_BYTES,
416
+ timeoutMs: timeoutMs,
417
+ headers: {
418
+ "User-Agent": "blamejs-fido-mds3/1",
419
+ "Accept": "application/jwt, application/octet-stream, */*",
420
+ },
421
+ });
422
+ } catch (e) {
423
+ try { auditFwk().safeEmit({
424
+ action: "auth.fido_mds3.fetch.network",
425
+ outcome: "failure",
426
+ metadata: { url: url, reason: (e && e.message) || String(e) },
427
+ }); } catch (_e) { /* audit best-effort */ }
428
+ throw new FidoMds3Error("fido-mds3/network",
429
+ "BLOB GET " + url + " failed: " + ((e && e.message) || String(e)));
430
+ }
431
+ if (rsp.statusCode < 200 || rsp.statusCode >= 300) { // allow:raw-byte-literal — HTTP 2xx range
432
+ throw new FidoMds3Error("fido-mds3/bad-status",
433
+ "BLOB GET " + url + " returned " + rsp.statusCode);
434
+ }
435
+ var token = rsp.body.toString("ascii").trim();
436
+
437
+ var jws = _parseJws(token);
438
+ var chain = _validateChain(jws.header.x5c, rootPems);
439
+ _verifyJws(jws, chain[0]);
440
+ var payload = jws.payload;
441
+ if (!payload || !Array.isArray(payload.entries)) {
442
+ throw new FidoMds3Error("fido-mds3/bad-payload",
443
+ "BLOB payload missing 'entries' array");
444
+ }
445
+ if (typeof payload.no !== "number" || !isFinite(payload.no)) {
446
+ throw new FidoMds3Error("fido-mds3/bad-payload",
447
+ "BLOB payload missing or non-numeric 'no'");
448
+ }
449
+ var nextUpdate = _parseNextUpdate(payload.nextUpdate);
450
+ if (!nextUpdate) {
451
+ throw new FidoMds3Error("fido-mds3/bad-payload",
452
+ "BLOB payload 'nextUpdate' missing or not YYYY-MM-DD: " + payload.nextUpdate);
453
+ }
454
+ var record = {
455
+ entries: payload.entries,
456
+ no: payload.no,
457
+ nextUpdate: nextUpdate,
458
+ url: url,
459
+ legalHeader: payload.legalHeader,
460
+ };
461
+ // Re-assert TTL based on the BLOB's nextUpdate (overrides the
462
+ // wrap-call's safe-minimum seed).
463
+ try { await c.set(cacheKey, record, _ttlFromNextUpdate(nextUpdate)); }
464
+ catch (_e) { /* cache.set best-effort */ }
465
+ try { auditFwk().safeEmit({
466
+ action: "auth.fido_mds3.fetch",
467
+ outcome: "success",
468
+ metadata: { url: url, no: payload.no, entries: payload.entries.length,
469
+ nextUpdate: payload.nextUpdate },
470
+ }); } catch (_e) { /* audit best-effort */ }
471
+ return record;
472
+ }, MIN_CACHE_TTL_MS);
473
+ }
474
+
475
+ /**
476
+ * @primitive b.auth.fidoMds3.lookupAaguid
477
+ * @signature b.auth.fidoMds3.lookupAaguid(blob, aaguid)
478
+ * @since 0.8.53
479
+ * @status stable
480
+ * @related b.auth.fidoMds3.fetch, b.auth.fidoMds3.verifyAuthenticator
481
+ *
482
+ * Finds the metadata entry for an AAGUID. Returns the entry shape
483
+ * `{ aaguid, metadataStatement, statusReports, timeOfLastStatusChange }`
484
+ * or null if the AAGUID isn't in the BLOB. AAGUID matching is
485
+ * case-insensitive UUID compare with both dashed and undashed forms
486
+ * accepted (registrationInfo.aaguid is a 16-byte hex with dashes;
487
+ * statusReport AAGUIDs in some BLOBs drop the dashes).
488
+ *
489
+ * @example
490
+ * var blob = { entries: [{ aaguid: "00000000-0000-0000-0000-000000000000",
491
+ * metadataStatement: { description: "Test" },
492
+ * statusReports: [] }] };
493
+ * var entry = b.auth.fidoMds3.lookupAaguid(blob, "00000000-0000-0000-0000-000000000000");
494
+ * entry && entry.metadataStatement.description === "Test";
495
+ * // → true
496
+ */
497
+ function lookupAaguid(blob, aaguid) {
498
+ if (!blob || !Array.isArray(blob.entries)) {
499
+ throw new FidoMds3Error("fido-mds3/bad-blob",
500
+ "blob.entries must be an array (call fetch first)");
501
+ }
502
+ if (typeof aaguid !== "string" || aaguid.length === 0) {
503
+ throw new FidoMds3Error("fido-mds3/bad-aaguid", "aaguid must be a non-empty string");
504
+ }
505
+ var canon = aaguid.replace(/-/g, "").toLowerCase();
506
+ if (!safeBuffer.isHex(canon, 32)) { // allow:raw-byte-literal — 32 = AAGUID hex-char count, not bytes
507
+ throw new FidoMds3Error("fido-mds3/bad-aaguid",
508
+ "aaguid must be a UUID (with or without dashes)");
509
+ }
510
+ for (var i = 0; i < blob.entries.length; i++) {
511
+ var e = blob.entries[i];
512
+ if (!e) continue;
513
+ var entryAaguid = e.aaguid;
514
+ if (typeof entryAaguid !== "string") continue;
515
+ if (entryAaguid.replace(/-/g, "").toLowerCase() === canon) return e;
516
+ }
517
+ return null;
518
+ }
519
+
520
+ // Pull the certified-level token out of a list of status reports. The
521
+ // most recent FIDO_CERTIFIED_L{N}[_PLUS] report wins; if none exist,
522
+ // the authenticator is uncertified (level 0).
523
+ function _certifiedLevel(statusReports) {
524
+ if (!Array.isArray(statusReports)) return { level: 0, plus: false };
525
+ var best = { level: 0, plus: false };
526
+ for (var i = 0; i < statusReports.length; i++) {
527
+ var sr = statusReports[i];
528
+ if (!sr || typeof sr.status !== "string") continue;
529
+ var m = CERT_LEVEL_RE.exec(sr.status);
530
+ if (!m) continue;
531
+ var level = parseInt(m[1], 10);
532
+ var plus = !!m[2];
533
+ if (level > best.level || (level === best.level && plus && !best.plus)) {
534
+ best = { level: level, plus: plus };
535
+ }
536
+ }
537
+ return best;
538
+ }
539
+
540
+ /**
541
+ * @primitive b.auth.fidoMds3.verifyAuthenticator
542
+ * @signature b.auth.fidoMds3.verifyAuthenticator(blob, registrationInfo)
543
+ * @since 0.8.53
544
+ * @status stable
545
+ * @related b.auth.fidoMds3.fetch, b.auth.fidoMds3.lookupAaguid
546
+ *
547
+ * Given a BLOB handle and the registrationInfo returned by
548
+ * b.auth.passkey.verifyRegistration, returns
549
+ * `{ ok, statement, statusReports, certifiedLevel, reason? }`. Refuses
550
+ * (ok: false) when the authenticator's status reports include any of
551
+ * 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.
556
+ *
557
+ * Audits auth.fido_mds3.verify.refused (drop-silent) on compromise.
558
+ *
559
+ * @example
560
+ * var blob = { entries: [] };
561
+ * var reg = { aaguid: "00000000-0000-0000-0000-000000000000" };
562
+ * var rv = b.auth.fidoMds3.verifyAuthenticator(blob, reg);
563
+ * rv.ok === true && rv.statement === null;
564
+ * // → true
565
+ */
566
+ function verifyAuthenticator(blob, registrationInfo) {
567
+ if (!blob) {
568
+ throw new FidoMds3Error("fido-mds3/bad-blob", "blob is required");
569
+ }
570
+ if (!registrationInfo || typeof registrationInfo.aaguid !== "string") {
571
+ throw new FidoMds3Error("fido-mds3/bad-registrationinfo",
572
+ "registrationInfo with .aaguid is required");
573
+ }
574
+ var entry = lookupAaguid(blob, registrationInfo.aaguid);
575
+ if (!entry) {
576
+ return {
577
+ ok: true,
578
+ statement: null,
579
+ statusReports: [],
580
+ certifiedLevel: { level: 0, plus: false },
581
+ reason: "aaguid-not-in-blob",
582
+ };
583
+ }
584
+ var statusReports = Array.isArray(entry.statusReports) ? entry.statusReports : [];
585
+ var refusedStatus = null;
586
+ for (var i = 0; i < statusReports.length; i++) {
587
+ var sr = statusReports[i];
588
+ if (sr && typeof sr.status === "string" && REFUSE_STATUS[sr.status]) {
589
+ refusedStatus = sr.status;
590
+ break;
591
+ }
592
+ }
593
+ var certifiedLevel = _certifiedLevel(statusReports);
594
+ if (refusedStatus) {
595
+ try { auditFwk().safeEmit({
596
+ action: "auth.fido_mds3.verify.refused",
597
+ outcome: "denied",
598
+ metadata: { aaguid: registrationInfo.aaguid, status: refusedStatus },
599
+ }); } catch (_e) { /* audit best-effort */ }
600
+ return {
601
+ ok: false,
602
+ statement: entry.metadataStatement || null,
603
+ statusReports: statusReports,
604
+ certifiedLevel: certifiedLevel,
605
+ reason: "compromised: " + refusedStatus,
606
+ };
607
+ }
608
+ return {
609
+ ok: true,
610
+ statement: entry.metadataStatement || null,
611
+ statusReports: statusReports,
612
+ certifiedLevel: certifiedLevel,
613
+ };
614
+ }
615
+
616
+ module.exports = {
617
+ fetch: fetch,
618
+ lookupAaguid: lookupAaguid,
619
+ verifyAuthenticator: verifyAuthenticator,
620
+ DEFAULT_URL: DEFAULT_URL,
621
+ // Internal — exposed so tests can exercise the verifier without
622
+ // standing up a real HTTPS endpoint. Operators should call fetch().
623
+ _verifyAndParseBlob: _verifyAndParseBlob,
624
+ };