@blamejs/core 0.8.52 → 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.
- package/CHANGELOG.md +5 -0
- package/index.js +8 -0
- package/lib/audit.js +4 -0
- package/lib/auth/fido-mds3.js +624 -0
- package/lib/auth/passkey.js +214 -2
- package/lib/auth-bot-challenge.js +1 -1
- package/lib/credential-hash.js +2 -2
- package/lib/framework-error.js +55 -0
- package/lib/guard-cidr.js +2 -1
- package/lib/guard-jwt.js +2 -2
- package/lib/guard-oauth.js +2 -2
- package/lib/http-client-cache.js +916 -0
- package/lib/http-client.js +242 -0
- package/lib/mail-arf.js +343 -0
- package/lib/mail-auth.js +265 -40
- package/lib/mail-bimi.js +948 -33
- package/lib/mail-bounce.js +386 -4
- package/lib/mail-mdn.js +424 -0
- package/lib/mail-unsubscribe.js +265 -25
- package/lib/mail.js +403 -21
- package/lib/middleware/bearer-auth.js +1 -1
- package/lib/middleware/clear-site-data.js +122 -0
- package/lib/middleware/dpop.js +1 -1
- package/lib/middleware/index.js +9 -0
- package/lib/middleware/nel.js +214 -0
- package/lib/middleware/security-headers.js +56 -4
- package/lib/middleware/speculation-rules.js +323 -0
- package/lib/mime-parse.js +198 -0
- package/lib/network-dns.js +890 -27
- package/lib/network-tls.js +745 -0
- package/lib/object-store/sigv4.js +54 -0
- package/lib/public-suffix.js +414 -0
- package/lib/safe-buffer.js +7 -0
- package/lib/safe-json.js +1 -1
- package/lib/static.js +120 -0
- package/lib/storage.js +11 -0
- package/lib/vendor/MANIFEST.json +33 -0
- package/lib/vendor/bimi-trust-anchors.pem +33 -0
- package/lib/vendor/public-suffix-list.dat +16376 -0
- package/package.json +1 -1
- 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
|
+
};
|