@blamejs/pki 0.4.9 → 0.4.11
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 +44 -1
- package/README.md +1 -1
- package/lib/attrcert-sign.js +7 -5
- package/lib/cmp-build.js +11 -6
- package/lib/cmp-session.js +1 -1
- package/lib/cmp-verify.js +1 -1
- package/lib/constants.js +23 -0
- package/lib/crl-sign.js +6 -2
- package/lib/crmf-sign.js +10 -4
- package/lib/csr-sign.js +3 -3
- package/lib/ct.js +1 -1
- package/lib/guard-identifier.js +31 -1
- package/lib/path-validate.js +3 -1
- package/lib/pki-build.js +4 -5
- package/lib/rfc3339.js +28 -1
- package/lib/validator-tpm.js +173 -3
- package/lib/webauthn-mds.js +753 -0
- package/lib/webauthn.js +320 -11
- package/lib/x509-sign.js +2 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
//
|
|
5
|
+
// @internal -- the pki.webauthn metadata implementation. The operator-facing @module pki.webauthn
|
|
6
|
+
// and the @primitive blocks for verifyMetadataBlob / metadataFor / metadataAnchors live in
|
|
7
|
+
// lib/webauthn.js, which re-exports these, so the namespace has one documented home.
|
|
8
|
+
//
|
|
9
|
+
// webauthn-mds -- the FIDO Metadata Service (MDS v3) reader. The BLOB is a JWS whose payload
|
|
10
|
+
// lists every registered authenticator model, keyed by AAGUID, with the certificates its
|
|
11
|
+
// attestations chain to and the status reports that say whether it is still trusted.
|
|
12
|
+
//
|
|
13
|
+
// The ordering is the load-bearing part: the signature and its certificate chain are established
|
|
14
|
+
// against a CALLER-supplied FIDO root before a single byte of the payload is read, so a hostile
|
|
15
|
+
// BLOB never reaches the JSON reader, the entry walk, or any per-entry certificate decode. No
|
|
16
|
+
// root is bundled and there is no trust-on-first-use -- an operator supplies the anchor, exactly
|
|
17
|
+
// as pki.trust does for a root store. Retrieval is out of scope: the BLOB is caller-supplied
|
|
18
|
+
// bytes, so this module never touches a socket.
|
|
19
|
+
//
|
|
20
|
+
// FIDO Metadata Service v3.0 sec. 3.1 / sec. 3.2, RFC 7515 (JWS).
|
|
21
|
+
|
|
22
|
+
var frameworkError = require("./framework-error");
|
|
23
|
+
var asn1 = require("./asn1-der");
|
|
24
|
+
var x509 = require("./schema-x509");
|
|
25
|
+
var guard = require("./guard-all");
|
|
26
|
+
var jose = require("./jose");
|
|
27
|
+
var rfc3339 = require("./rfc3339");
|
|
28
|
+
var constants = require("./constants");
|
|
29
|
+
var pathValidate = require("./path-validate");
|
|
30
|
+
var webcrypto = require("./webcrypto");
|
|
31
|
+
var nodeCrypto = require("crypto");
|
|
32
|
+
|
|
33
|
+
var WebauthnError = frameworkError.WebauthnError;
|
|
34
|
+
function _err(code, message, cause) { return new WebauthnError(code, message, cause); }
|
|
35
|
+
var C = constants.LIMITS;
|
|
36
|
+
|
|
37
|
+
// The JWS algorithms a metadata BLOB may be signed with, each with the key family it requires.
|
|
38
|
+
// Null-prototype: `alg` is attacker-supplied, and an inherited Object member would otherwise
|
|
39
|
+
// resolve to a truthy non-row and read as a recognised algorithm.
|
|
40
|
+
var BLOB_ALGS = Object.assign(Object.create(null), {
|
|
41
|
+
ES256: { family: "EC", hash: "SHA-256", imp: { name: "ECDSA", namedCurve: "P-256" }, ver: { name: "ECDSA", hash: "SHA-256" } },
|
|
42
|
+
ES384: { family: "EC", hash: "SHA-384", imp: { name: "ECDSA", namedCurve: "P-384" }, ver: { name: "ECDSA", hash: "SHA-384" } },
|
|
43
|
+
ES512: { family: "EC", hash: "SHA-512", imp: { name: "ECDSA", namedCurve: "P-521" }, ver: { name: "ECDSA", hash: "SHA-512" } },
|
|
44
|
+
RS256: { family: "RSA", hash: "SHA-256", imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, ver: { name: "RSASSA-PKCS1-v1_5" } },
|
|
45
|
+
RS384: { family: "RSA", hash: "SHA-384", imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, ver: { name: "RSASSA-PKCS1-v1_5" } },
|
|
46
|
+
RS512: { family: "RSA", hash: "SHA-512", imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, ver: { name: "RSASSA-PKCS1-v1_5" } },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// The status values that deny trust (MDS v3.0 sec. 3.1.4). An unknown status is IGNORED for the
|
|
50
|
+
// gate and surfaced raw -- the specification requires that a verifier not fail on a status it does
|
|
51
|
+
// not recognise -- unless the caller opts into refusing them.
|
|
52
|
+
var DISQUALIFYING = Object.assign(Object.create(null), {
|
|
53
|
+
REVOKED: 1, ATTESTATION_KEY_COMPROMISE: 1, USER_KEY_REMOTE_COMPROMISE: 1,
|
|
54
|
+
USER_KEY_PHYSICAL_COMPROMISE: 1, USER_VERIFICATION_BYPASS: 1,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The statuses whose optional `certificate` field NARROWS the report to the certificate it names
|
|
58
|
+
// (MDS v3.0 sec. 3.1.3). Only a compromised attestation key is about one certificate; every other
|
|
59
|
+
// disqualifying status is about the model, and a certificate attached to one of those is a
|
|
60
|
+
// nonconforming field rather than a narrower scope.
|
|
61
|
+
var CERT_SCOPED_STATUS = Object.assign(Object.create(null), { ATTESTATION_KEY_COMPROMISE: 1 });
|
|
62
|
+
|
|
63
|
+
var _BLOB_OPTS = Object.assign(Object.create(null), {
|
|
64
|
+
rootCertificates: 1, time: 1, previousNo: 1, requireRollbackCheck: 1, allowStale: 1,
|
|
65
|
+
statusPolicy: 1, rejectUnknownStatus: 1,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function _isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
|
|
69
|
+
|
|
70
|
+
// The results this module actually produced. Membership is the PROVENANCE check: a metadata object
|
|
71
|
+
// is only allowed to decide trust if verifyMetadataBlob made it. Recognising it by shape instead
|
|
72
|
+
// would accept anything carrying the right property names -- including a catalogue deserialized
|
|
73
|
+
// from a cache, where nothing establishes that the signature and chain were ever checked, and an
|
|
74
|
+
// attacker who can write that cache chooses which roots an authenticator is allowed to chain to.
|
|
75
|
+
// A WeakSet keys on object IDENTITY, which no serialization survives, so a round-tripped catalogue
|
|
76
|
+
// is refused and the caller re-verifies -- which the freshness rule wants of them anyway. It also
|
|
77
|
+
// holds no strong reference, so a discarded result is still collectable.
|
|
78
|
+
var _verifiedResults = new WeakSet();
|
|
79
|
+
function isVerifiedResult(v) { return _isPlainObject(v) && _verifiedResults.has(v); }
|
|
80
|
+
|
|
81
|
+
// Provenance alone is not enough: the verified catalogue is handed to the caller, and anything
|
|
82
|
+
// holding a reference could rewrite `allowStale`, a status report, or an entry's registered roots
|
|
83
|
+
// and the object would still pass the identity check. Freezing it means the catalogue that decides
|
|
84
|
+
// a later verification is the one the signature covered, field for field. Depth-bounded and
|
|
85
|
+
// cycle-safe via the frozen check; typed arrays are skipped because Object.freeze rejects a view
|
|
86
|
+
// with elements, and the payload is JSON-derived so it holds none anyway.
|
|
87
|
+
function _deepFreeze(v, depth) {
|
|
88
|
+
if (!v || typeof v !== "object" || Object.isFrozen(v) || ArrayBuffer.isView(v)) return v;
|
|
89
|
+
if (depth > C.JSON_MAX_DEPTH) return v;
|
|
90
|
+
Object.freeze(v);
|
|
91
|
+
Object.keys(v).forEach(function (k) { _deepFreeze(v[k], depth + 1); });
|
|
92
|
+
return v;
|
|
93
|
+
}
|
|
94
|
+
var AAGUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
95
|
+
// "This authenticator declares no model identity" -- the AAGUID a U2F authenticator carries, since
|
|
96
|
+
// U2F has no such concept. It is a sentinel, never a lookup key, and lives in one place so the
|
|
97
|
+
// lookup and the dispatch that decides WHICH key space applies cannot disagree about it.
|
|
98
|
+
var ZERO_AAGUID = "00000000-0000-0000-0000-000000000000";
|
|
99
|
+
// The JWS header parameters this reader understands (RFC 7515 sec. 4.1). Used only to tell a
|
|
100
|
+
// standard name from an extension name when checking `crit`.
|
|
101
|
+
var UNDERSTOOD_HEADER = Object.assign(Object.create(null), {
|
|
102
|
+
alg: 1, typ: 1, cty: 1, crit: 1, jku: 1, jwk: 1, kid: 1, x5u: 1, x5c: 1, x5t: 1, "x5t#S256": 1,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The BLOB's signing certificate must be allowed to sign. RFC 5280 sec. 4.2.1.3 numbers
|
|
106
|
+
// digitalSignature bit 0 of the keyUsage BIT STRING; an absent extension places no restriction.
|
|
107
|
+
function _assertLeafSigns(leaf) {
|
|
108
|
+
var exts = leaf.extensions || [];
|
|
109
|
+
for (var i = 0; i < exts.length; i++) {
|
|
110
|
+
if (exts[i].name !== "keyUsage" || exts[i].value == null) continue;
|
|
111
|
+
var ku;
|
|
112
|
+
try { ku = asn1.read.bitString(asn1.decode(exts[i].value)); }
|
|
113
|
+
catch (e) { throw _err("webauthn/bad-att-cert", "the metadata BLOB x5c leaf keyUsage extension is malformed", e); }
|
|
114
|
+
if (!ku.bytes.length || (ku.bytes[0] & 0x80) === 0) {
|
|
115
|
+
throw _err("webauthn/bad-att-cert", "the metadata BLOB x5c leaf keyUsage does not assert digitalSignature, so it may not sign the BLOB (RFC 5280 sec. 4.2.1.3)");
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Is this certificate the anchor itself? RFC 5280 sec. 6.1.1 defines a trust anchor as a name and a
|
|
122
|
+
// public key, so that pair -- not the issuer field, and not the certificate's bytes -- is what
|
|
123
|
+
// decides. The DN comparison is the canonical one, so two spellings of one name still match.
|
|
124
|
+
function _isAnchorItself(cert, anchor) {
|
|
125
|
+
return guard.name.dnEqual(cert.subject, anchor.subject) &&
|
|
126
|
+
cert.subjectPublicKeyInfo.bytes.equals(anchor.subjectPublicKeyInfo.bytes);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A parsed anchor certificate, whichever shape the caller supplied it in.
|
|
130
|
+
//
|
|
131
|
+
// An already-parsed certificate is taken as-is rather than re-encoded and re-parsed, but the
|
|
132
|
+
// recognition test names every field the anchor is later READ for -- the subject name, the public
|
|
133
|
+
// key bytes, and the signature algorithm OID. Recognising an object on a looser test lets something
|
|
134
|
+
// that is merely certificate-SHAPED through: a parsed CSR satisfies "has a subject and an SPKI", and
|
|
135
|
+
// a hand-built object literal satisfies it too and then raises a raw TypeError from deep inside the
|
|
136
|
+
// path validator, which is an untyped throw escaping a public verb.
|
|
137
|
+
function _asCert(v, label) {
|
|
138
|
+
if (v && typeof v === "object" && !Buffer.isBuffer(v) && !(v instanceof Uint8Array) &&
|
|
139
|
+
v.subject && v.subjectPublicKeyInfo && Buffer.isBuffer(v.subjectPublicKeyInfo.bytes) &&
|
|
140
|
+
v.signatureAlgorithm && typeof v.signatureAlgorithm.oid === "string" &&
|
|
141
|
+
// `validity` is what separates a certificate from the other signed structures that carry a
|
|
142
|
+
// subject, a public key and a signature algorithm: a parsed certification request has all
|
|
143
|
+
// three and would otherwise be installed as a trust anchor.
|
|
144
|
+
v.validity && v.validity.notBefore !== undefined) {
|
|
145
|
+
return v;
|
|
146
|
+
}
|
|
147
|
+
try { return x509.parse(v); }
|
|
148
|
+
catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Verify a FIDO Metadata Service BLOB and return its entries indexed for lookup. `blob` is the
|
|
152
|
+
// BLOB as caller-supplied bytes or a string -- this module never fetches it. The signature is
|
|
153
|
+
// checked under the certificate in the BLOB's own header, that chain is validated to one of
|
|
154
|
+
// `opts.rootCertificates`, and only then is the payload parsed: a BLOB that does not verify never
|
|
155
|
+
// reaches the JSON reader. `no` must exceed a supplied `previousNo` (the rollback check) and
|
|
156
|
+
// `nextUpdate` must not have passed (the freshness check), both fail-closed. No FIDO root ships
|
|
157
|
+
// with this toolkit: which metadata authority to trust is the operator's choice, and a verifier
|
|
158
|
+
// that bundled its own would be deciding trust on the caller's behalf.
|
|
159
|
+
function verifyMetadataBlob(blob, opts) {
|
|
160
|
+
return Promise.resolve().then(function () { return _verifyMetadataBlob(blob, opts); });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _verifyMetadataBlob(blob, opts) {
|
|
164
|
+
opts = opts || {};
|
|
165
|
+
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
166
|
+
guard.identifier.assertKnownKeys(opts, _BLOB_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
167
|
+
var roots = opts.rootCertificates;
|
|
168
|
+
if (!Array.isArray(roots) || roots.length === 0) {
|
|
169
|
+
throw _err("webauthn/metadata-no-root", "verifying a metadata BLOB requires opts.rootCertificates -- the FIDO root(s) to anchor it to; this library bundles none");
|
|
170
|
+
}
|
|
171
|
+
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
172
|
+
var at = opts.time === undefined ? new Date() : opts.time;
|
|
173
|
+
if (opts.previousNo !== undefined && (!Number.isSafeInteger(opts.previousNo) || opts.previousNo < 0)) {
|
|
174
|
+
throw _err("webauthn/bad-input", "opts.previousNo must be a non-negative safe integer");
|
|
175
|
+
}
|
|
176
|
+
// The boolean options are checked for TYPE, not merely compared against true. A caller who wrote
|
|
177
|
+
// `rejectUnknownStatus: "true"` from a config file is asking for the stricter behaviour; comparing
|
|
178
|
+
// against `true` silently records the policy as off and the authenticator carrying an unrecognised
|
|
179
|
+
// status is then accepted -- the fail-open the option exists to prevent.
|
|
180
|
+
["requireRollbackCheck", "allowStale", "rejectUnknownStatus"].forEach(function (k) {
|
|
181
|
+
if (opts[k] !== undefined && typeof opts[k] !== "boolean") {
|
|
182
|
+
throw _err("webauthn/bad-input", "opts." + k + " must be a boolean");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
if (opts.statusPolicy !== undefined && typeof opts.statusPolicy !== "function" &&
|
|
186
|
+
opts.statusPolicy !== "any" && opts.statusPolicy !== "latest-by-date") {
|
|
187
|
+
throw _err("webauthn/bad-input", "opts.statusPolicy must be \"any\", \"latest-by-date\", or a function");
|
|
188
|
+
}
|
|
189
|
+
if (opts.requireRollbackCheck === true && opts.previousNo === undefined) {
|
|
190
|
+
throw _err("webauthn/metadata-no-baseline", "opts.requireRollbackCheck was set without opts.previousNo, so there is no baseline to compare against");
|
|
191
|
+
}
|
|
192
|
+
var anchors = roots.map(function (r, i) { return _asCert(r, "opts.rootCertificates[" + i + "]"); });
|
|
193
|
+
|
|
194
|
+
// Cap BEFORE the copy, not after it. Measuring the CONVERTED buffer would mean an oversized input
|
|
195
|
+
// is fully materialized in order to discover it should have been refused -- the allocation the
|
|
196
|
+
// ceiling exists to prevent, performed on the way to reporting that it was too large. A string's
|
|
197
|
+
// length is its character count and a BLOB is ASCII base64url + dots, so it bounds the byte count.
|
|
198
|
+
// A byte input is re-viewed through the shared guard, which is where the detached-backing-buffer
|
|
199
|
+
// case is handled once, rather than re-derived here.
|
|
200
|
+
var raw = (Buffer.isBuffer(blob) || blob instanceof Uint8Array)
|
|
201
|
+
? guard.bytes.view(blob, WebauthnError, "webauthn/bad-input", "the metadata BLOB") : null;
|
|
202
|
+
// A string's `.length` counts UTF-16 code units, not the UTF-8 bytes the conversion produces, so
|
|
203
|
+
// measuring it would let a string of multi-byte characters sit under the ceiling and then expand
|
|
204
|
+
// several-fold past it during the copy -- the allocation the ceiling exists to prevent.
|
|
205
|
+
// Buffer.byteLength computes the encoded size WITHOUT producing the buffer, so the bound still
|
|
206
|
+
// bites before anything is materialized. (A conforming BLOB is base64url and dots, hence ASCII,
|
|
207
|
+
// where the two counts agree; a non-ASCII one is not a JWS at all and is refused on size or on
|
|
208
|
+
// shape immediately after.)
|
|
209
|
+
var declaredLength = raw ? raw.length : (typeof blob === "string" ? Buffer.byteLength(blob, "utf8") : null);
|
|
210
|
+
if (declaredLength !== null && declaredLength > C.MDS_BLOB_MAX_BYTES) {
|
|
211
|
+
throw _err("webauthn/too-large", "the metadata BLOB is " + declaredLength + " bytes, above the " + C.MDS_BLOB_MAX_BYTES + "-byte ceiling");
|
|
212
|
+
}
|
|
213
|
+
if (!raw && typeof blob !== "string") {
|
|
214
|
+
throw _err("webauthn/bad-input", "the metadata BLOB must be bytes or a string");
|
|
215
|
+
}
|
|
216
|
+
var bytes = raw || Buffer.from(blob, "utf8");
|
|
217
|
+
if (bytes.length > C.MDS_BLOB_MAX_BYTES) {
|
|
218
|
+
throw _err("webauthn/too-large", "the metadata BLOB is " + bytes.length + " bytes, above the " + C.MDS_BLOB_MAX_BYTES + "-byte ceiling");
|
|
219
|
+
}
|
|
220
|
+
var segs = bytes.toString("utf8").split(".");
|
|
221
|
+
if (segs.length !== 3) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB is not a three-part JWS compact serialization (RFC 7515 sec. 3.1)");
|
|
222
|
+
// The header's ceiling has to bite on the ENCODED segment, before it is decoded. Checking it
|
|
223
|
+
// afterwards means the decode has already allocated the very buffer the ceiling exists to bound,
|
|
224
|
+
// and does so for input nothing has authenticated yet. base64url expands 4 characters to 3 bytes,
|
|
225
|
+
// so the encoded length bounds the decoded one.
|
|
226
|
+
if (segs[0].length > Math.ceil(C.MDS_BLOB_HEADER_MAX_BYTES / 3) * 4) {
|
|
227
|
+
throw _err("webauthn/too-large", "the metadata BLOB protected header is above the " + C.MDS_BLOB_HEADER_MAX_BYTES + "-byte ceiling");
|
|
228
|
+
}
|
|
229
|
+
// The signature is bounded on the same principle and for the same reason: it is read before
|
|
230
|
+
// anything is authenticated, and every algorithm here has a tightly bounded signature size, so a
|
|
231
|
+
// segment consuming the whole envelope allowance is not a signature -- it is a request to
|
|
232
|
+
// allocate megabytes and hand them to the verifier without possessing any key.
|
|
233
|
+
if (segs[2].length > Math.ceil(C.MDS_BLOB_SIG_MAX_BYTES / 3) * 4) {
|
|
234
|
+
throw _err("webauthn/too-large", "the metadata BLOB signature is above the " + C.MDS_BLOB_SIG_MAX_BYTES + "-byte ceiling");
|
|
235
|
+
}
|
|
236
|
+
var header, sig;
|
|
237
|
+
try {
|
|
238
|
+
// The header goes through the SAME bounded reader as the payload -- a duplicate `alg` or `x5c`
|
|
239
|
+
// must not be resolvable to whichever copy a permissive parser happens to keep.
|
|
240
|
+
header = guard.json.parse(Buffer.from(jose.base64url.decode(segs[0])), _err, {
|
|
241
|
+
// The header is read BEFORE anything is authenticated, so its ceiling is the header's own,
|
|
242
|
+
// not the whole BLOB's: a JWS protected header is a few hundred bytes, and giving this reader
|
|
243
|
+
// the 32 MiB envelope cap would let unauthenticated bytes buy an unbounded multiple of that
|
|
244
|
+
// in heap ahead of the signature check. The payload's reader keeps the envelope cap, which is
|
|
245
|
+
// sound because nothing reaches it until the signature and chain hold.
|
|
246
|
+
maxBytes: C.MDS_BLOB_HEADER_MAX_BYTES, maxDepth: C.JSON_MAX_DEPTH,
|
|
247
|
+
tooLarge: "webauthn/too-large", badJson: "webauthn/bad-metadata-blob",
|
|
248
|
+
// Every code the guard can raise is named. An omitted one falls back to the framework default
|
|
249
|
+
// and the module's own defences -- duplicate-member smuggling, the depth cap -- surface under
|
|
250
|
+
// a generic code no webauthn/* consumer can switch on.
|
|
251
|
+
tooDeep: "webauthn/bad-metadata-blob", duplicateMember: "webauthn/bad-metadata-blob",
|
|
252
|
+
badInput: "webauthn/bad-metadata-blob", label: "the metadata BLOB header",
|
|
253
|
+
});
|
|
254
|
+
sig = Buffer.from(jose.base64url.decode(segs[2]));
|
|
255
|
+
} catch (e) {
|
|
256
|
+
if (e instanceof WebauthnError) throw e;
|
|
257
|
+
throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header or signature is not decodable", e);
|
|
258
|
+
}
|
|
259
|
+
if (!_isPlainObject(header)) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header must be a JSON object");
|
|
260
|
+
// An x5u names a certificate chain to be FETCHED. This verifier performs no network retrieval, so
|
|
261
|
+
// it cannot establish that chain and must not pretend the BLOB is anchored.
|
|
262
|
+
if (header.x5u !== undefined) {
|
|
263
|
+
throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header carries x5u, which names a chain to fetch; supply a BLOB with an inline x5c instead");
|
|
264
|
+
}
|
|
265
|
+
// RFC 7515 sec. 4.1.11: `crit` lists header parameters the recipient MUST understand and process,
|
|
266
|
+
// and a JWS naming one this implementation does not is invalid. Ignoring it is the same fault as
|
|
267
|
+
// ignoring x5u, one parameter over: the producer said "refuse this unless you handle it" and a
|
|
268
|
+
// reader that skips the list accepts a token on terms it never met. Nothing here processes an
|
|
269
|
+
// extension parameter, so any name is unprocessed -- including a standard name, which sec. 4.1.11
|
|
270
|
+
// forbids listing at all.
|
|
271
|
+
if (Object.prototype.hasOwnProperty.call(header, "crit")) {
|
|
272
|
+
var crit = header.crit;
|
|
273
|
+
if (!Array.isArray(crit) || crit.length === 0) {
|
|
274
|
+
throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header crit must be a non-empty array (RFC 7515 sec. 4.1.11)");
|
|
275
|
+
}
|
|
276
|
+
var critSeen = Object.create(null);
|
|
277
|
+
for (var ci = 0; ci < crit.length; ci++) {
|
|
278
|
+
var critName = crit[ci];
|
|
279
|
+
if (typeof critName !== "string") throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header crit entries must be strings");
|
|
280
|
+
if (critSeen[critName]) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header crit repeats " + JSON.stringify(critName));
|
|
281
|
+
critSeen[critName] = 1;
|
|
282
|
+
if (UNDERSTOOD_HEADER[critName]) {
|
|
283
|
+
throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header crit must not name the standard header parameter " + JSON.stringify(critName) + " (RFC 7515 sec. 4.1.11)");
|
|
284
|
+
}
|
|
285
|
+
throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header names critical header parameter " + JSON.stringify(critName) + ", which this reader does not process");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
var algRow = typeof header.alg === "string" ? BLOB_ALGS[header.alg] : undefined;
|
|
289
|
+
if (!algRow) throw _err("webauthn/unsupported-algorithm", "the metadata BLOB alg " + JSON.stringify(header.alg) + " is not a supported JWS signature algorithm");
|
|
290
|
+
if (!Array.isArray(header.x5c) || header.x5c.length === 0) {
|
|
291
|
+
throw _err("webauthn/bad-metadata-blob", "the metadata BLOB header carries no x5c certificate chain (RFC 7515 sec. 4.1.6)");
|
|
292
|
+
}
|
|
293
|
+
if (header.x5c.length > C.WEBAUTHN_X5C_MAX_CERTS) {
|
|
294
|
+
throw _err("webauthn/too-large", "the metadata BLOB x5c carries " + header.x5c.length + " certificates, above the " + C.WEBAUTHN_X5C_MAX_CERTS + " this library will parse");
|
|
295
|
+
}
|
|
296
|
+
var chain = header.x5c.map(function (entry, i) {
|
|
297
|
+
if (typeof entry !== "string") throw _err("webauthn/bad-metadata-blob", "the metadata BLOB x5c entry " + i + " is not a string");
|
|
298
|
+
var der;
|
|
299
|
+
try { der = guard.encoding.base64(entry, C.MDS_BLOB_MAX_BYTES, _err, "webauthn/bad-metadata-blob", "a metadata BLOB x5c entry"); }
|
|
300
|
+
catch (e) { throw _err("webauthn/bad-metadata-blob", "the metadata BLOB x5c entry " + i + " is not canonical base64", e); }
|
|
301
|
+
try { return x509.parse(der); }
|
|
302
|
+
catch (e) { throw _err("webauthn/bad-att-cert", "the metadata BLOB x5c entry " + i + " is not a decodable certificate", e); }
|
|
303
|
+
});
|
|
304
|
+
var leaf = chain[0];
|
|
305
|
+
// RFC 5280 sec. 4.2.1.3: a certificate that carries a keyUsage extension may only be used for the
|
|
306
|
+
// purposes it asserts. Verifying the BLOB signature with a leaf whose keyUsage omits
|
|
307
|
+
// digitalSignature grants metadata-signing authority to a certificate that explicitly excludes it
|
|
308
|
+
// -- and the path validation that follows checks the CHAIN, not the target's application-level
|
|
309
|
+
// usage, so nothing else catches it. An absent keyUsage places no restriction, per that section.
|
|
310
|
+
_assertLeafSigns(leaf);
|
|
311
|
+
// The alg names a signature scheme; the leaf key must be of the family that scheme uses. Taking
|
|
312
|
+
// the scheme from the token and the key from the chain without checking they agree is the JWS
|
|
313
|
+
// algorithm-confusion class.
|
|
314
|
+
var leafAlg = (leaf.subjectPublicKeyInfo.algorithm || {}).name;
|
|
315
|
+
var leafFamily = leafAlg === "ecPublicKey" ? "EC" : (leafAlg === "rsaEncryption" ? "RSA" : null);
|
|
316
|
+
if (leafFamily !== algRow.family) {
|
|
317
|
+
throw _err("webauthn/unsupported-algorithm", "the metadata BLOB alg " + header.alg + " does not match the x5c leaf key type " + JSON.stringify(leafAlg));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
var signingInput = Buffer.from(segs[0] + "." + segs[1], "ascii");
|
|
321
|
+
return webcrypto.webcrypto.subtle.importKey("spki", leaf.subjectPublicKeyInfo.bytes, algRow.imp, false, ["verify"])
|
|
322
|
+
.then(function (key) { return webcrypto.webcrypto.subtle.verify(algRow.ver, key, sig, signingInput); },
|
|
323
|
+
function (e) { throw _err("webauthn/unsupported-algorithm", "the metadata BLOB x5c leaf key could not be imported for " + header.alg, e); })
|
|
324
|
+
.then(function (ok) {
|
|
325
|
+
if (!ok) throw _err("webauthn/verify-failed", "the metadata BLOB signature does not verify under its x5c leaf key");
|
|
326
|
+
return _chainToAnchor(chain, anchors, at);
|
|
327
|
+
})
|
|
328
|
+
.then(function () {
|
|
329
|
+
// ONLY NOW is the payload read. Everything above establishes that these bytes came from the
|
|
330
|
+
// holder of a key the caller anchored; parsing before that would expose the JSON reader, the
|
|
331
|
+
// entry walk and every per-entry decode to bytes nobody vouched for.
|
|
332
|
+
return _parsePayload(segs[1], at, opts);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The x5c chain must validate to one of the caller's anchors. Every anchor is tried because an
|
|
337
|
+
// operator may hold several across a rotation; the last path verdict is threaded as the cause so a
|
|
338
|
+
// caller can see WHY it did not chain rather than only that it did not.
|
|
339
|
+
function _chainToAnchor(chain, anchors, at, what) {
|
|
340
|
+
var subject = what || "metadata BLOB certificate chain";
|
|
341
|
+
var ordered = chain.slice().reverse(); // path.validate takes anchor-adjacent first
|
|
342
|
+
var lastFault = null;
|
|
343
|
+
return anchors.reduce(function (p, anchor) {
|
|
344
|
+
return p.then(function (done) {
|
|
345
|
+
if (done) return true;
|
|
346
|
+
// A terminal certificate that IS the anchor is the anchor, not a path element -- validating it
|
|
347
|
+
// against itself would be a different assertion from the one being made. What identifies it is
|
|
348
|
+
// the trust-anchor identity the validator itself uses: the SUBJECT NAME and the PUBLIC KEY. A
|
|
349
|
+
// self-issued test instead of a key comparison misses the cross-signed form of the same root,
|
|
350
|
+
// which carries that identity but was signed by a cross-signing CA -- so it would be left in
|
|
351
|
+
// the path and then fail to verify under the anchor that never issued it, refusing an
|
|
352
|
+
// otherwise valid chain. Matching on name AND key cannot be looser: two certificates naming
|
|
353
|
+
// one subject with one key ARE the same entity for anchoring, whoever signed them.
|
|
354
|
+
var path = ordered.slice();
|
|
355
|
+
var strippedAnchor = false;
|
|
356
|
+
if (path.length && _isAnchorItself(path[0], anchor)) { path = path.slice(1); strippedAnchor = true; }
|
|
357
|
+
// Nothing left to validate. The two ways of getting here are NOT the same, and the difference
|
|
358
|
+
// is the whole verdict. If the anchor itself was the entire chain, then the signature was
|
|
359
|
+
// verified under the anchor's own key -- the identity was established by name AND key, so
|
|
360
|
+
// there is nothing further to chain and it is trusted. If the path was empty for any other
|
|
361
|
+
// reason, a validator has been handed nothing and nothing has been proved, so it refuses and
|
|
362
|
+
// the walk moves on to the next anchor.
|
|
363
|
+
if (path.length === 0) return strippedAnchor;
|
|
364
|
+
return pathValidate.validate(path, {
|
|
365
|
+
time: at,
|
|
366
|
+
// The anchor tuple names the anchor's own KEY -- its SubjectPublicKeyInfo algorithm and that
|
|
367
|
+
// algorithm's parameters -- not the algorithm its issuer used to sign it. The validator
|
|
368
|
+
// carries these forward as the working public key, and a certificate below the anchor may
|
|
369
|
+
// inherit its key parameters from them (the DSA-style parameter-inheritance case), so
|
|
370
|
+
// supplying the signature OID leaves such a key unreconstructable and refuses a valid chain.
|
|
371
|
+
trustAnchor: { name: anchor.subject, publicKey: anchor.subjectPublicKeyInfo.bytes,
|
|
372
|
+
algorithm: anchor.subjectPublicKeyInfo.algorithm.oid,
|
|
373
|
+
parameters: anchor.subjectPublicKeyInfo.algorithm.parameters },
|
|
374
|
+
}).then(function (r) { return !!(r && r.valid); }, function (e) { lastFault = e; return false; });
|
|
375
|
+
});
|
|
376
|
+
}, Promise.resolve(false)).then(function (trusted) {
|
|
377
|
+
if (!trusted) {
|
|
378
|
+
throw _err("webauthn/metadata-untrusted", "the " + subject + " does not validate to any of the roots it must reach", lastFault);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// The instant a catalogue stops being current. `nextUpdate` is a DATE, so the BLOB is current
|
|
384
|
+
// through the END of that UTC day. One home, because the rule is applied twice -- once when the
|
|
385
|
+
// BLOB is verified, and again whenever a verified result is USED -- and two copies would drift.
|
|
386
|
+
function _staleAfter(nextUpdate) {
|
|
387
|
+
var d = rfc3339.parseDate(nextUpdate, function (c, m) { return _err("webauthn/bad-metadata-blob", m); },
|
|
388
|
+
"webauthn/bad-metadata-blob", "the metadata BLOB nextUpdate");
|
|
389
|
+
return d.getTime() + constants.TIME.days(1);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// A verified result is a plain object the caller may hold for as long as it likes, so its freshness
|
|
393
|
+
// has to be re-established every time it DECIDES something -- not only when it was parsed. A
|
|
394
|
+
// catalogue fetched before its nextUpdate and reused a month later would otherwise keep authorizing
|
|
395
|
+
// an authenticator whose status reports have since revoked it, which is precisely what nextUpdate
|
|
396
|
+
// exists to prevent. The caller's original allowStale decision is carried on the result and honoured
|
|
397
|
+
// here, so opting out stays opted out rather than silently reappearing at the point of use.
|
|
398
|
+
function assertFresh(metadata, at, label) {
|
|
399
|
+
if (!metadata || metadata.allowStale === true || typeof metadata.nextUpdate !== "string") return;
|
|
400
|
+
var atMs = at instanceof Date ? at.getTime() : NaN;
|
|
401
|
+
var limit = _staleAfter(metadata.nextUpdate);
|
|
402
|
+
if (!isFinite(atMs) || !isFinite(limit)) return;
|
|
403
|
+
if (atMs >= limit) {
|
|
404
|
+
throw _err("webauthn/metadata-stale", (label || "the metadata") + " expired after " + metadata.nextUpdate +
|
|
405
|
+
"; re-verify a current BLOB, or pass opts.allowStale when verifying it");
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function _parsePayload(seg, at, opts) {
|
|
410
|
+
var payload;
|
|
411
|
+
try {
|
|
412
|
+
// The shared bounded JSON reader: byte cap before the UTF-8 decode, depth cap, and a duplicate
|
|
413
|
+
// member rejected at any depth -- so a payload cannot smuggle a second `no` or `entries` past
|
|
414
|
+
// whichever one JSON.parse would have kept.
|
|
415
|
+
payload = guard.json.parse(Buffer.from(jose.base64url.decode(seg)), _err, {
|
|
416
|
+
maxBytes: C.MDS_BLOB_MAX_BYTES, maxDepth: C.JSON_MAX_DEPTH,
|
|
417
|
+
tooLarge: "webauthn/too-large", badJson: "webauthn/bad-metadata-blob",
|
|
418
|
+
tooDeep: "webauthn/bad-metadata-blob", duplicateMember: "webauthn/bad-metadata-blob",
|
|
419
|
+
badInput: "webauthn/bad-metadata-blob", label: "the metadata BLOB payload",
|
|
420
|
+
});
|
|
421
|
+
} catch (e) {
|
|
422
|
+
if (e instanceof WebauthnError) throw e;
|
|
423
|
+
throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload is not decodable JSON", e);
|
|
424
|
+
}
|
|
425
|
+
if (!_isPlainObject(payload)) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload must be a JSON object");
|
|
426
|
+
if (typeof payload.legalHeader !== "string") throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload must carry a string legalHeader");
|
|
427
|
+
if (!Number.isSafeInteger(payload.no) || payload.no < 0) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload must carry a non-negative integer no");
|
|
428
|
+
// Rollback: a BLOB older than the one already held would reinstate authenticators whose trust was
|
|
429
|
+
// withdrawn since, so it is refused rather than merely reported.
|
|
430
|
+
if (opts.previousNo !== undefined && payload.no <= opts.previousNo) {
|
|
431
|
+
throw _err("webauthn/metadata-rollback", "the metadata BLOB no " + payload.no + " does not exceed the previously held " + opts.previousNo);
|
|
432
|
+
}
|
|
433
|
+
var staleAfter = _staleAfter(payload.nextUpdate);
|
|
434
|
+
var atMs = at.getTime();
|
|
435
|
+
// An unusable comparison instant must not read as "fresh": without this, a NaN date makes every
|
|
436
|
+
// comparison false and the staleness check silently passes.
|
|
437
|
+
//
|
|
438
|
+
// Unreachable as the code stands, and kept deliberately. Both operands are already gated by
|
|
439
|
+
// throwing guards -- `at` by guard.time.assertValid at the entry, `staleAfter` by
|
|
440
|
+
// rfc3339.parseDate above, which refuses a date that does not exist rather than rolling it over --
|
|
441
|
+
// so no caller input reaches here non-finite. It stays because the cost is one comparison and the
|
|
442
|
+
// failure it catches is silent: if either gate is ever relaxed or moved, this is what keeps a NaN
|
|
443
|
+
// from being read as fresh instead of stale.
|
|
444
|
+
if (!isFinite(atMs) || !isFinite(staleAfter)) {
|
|
445
|
+
throw _err("webauthn/bad-input", "the metadata freshness comparison has no usable instant");
|
|
446
|
+
}
|
|
447
|
+
var stale = atMs >= staleAfter;
|
|
448
|
+
if (stale && opts.allowStale !== true) {
|
|
449
|
+
throw _err("webauthn/metadata-stale", "the metadata BLOB expired after " + payload.nextUpdate + "; pass opts.allowStale to accept it anyway");
|
|
450
|
+
}
|
|
451
|
+
if (!Array.isArray(payload.entries)) throw _err("webauthn/bad-metadata-blob", "the metadata BLOB payload must carry an entries array");
|
|
452
|
+
if (payload.entries.length > C.MDS_MAX_ENTRIES) {
|
|
453
|
+
throw _err("webauthn/too-large", "the metadata BLOB declares " + payload.entries.length + " entries, above the " + C.MDS_MAX_ENTRIES + " ceiling");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
var byAaguid = Object.create(null);
|
|
457
|
+
var byKeyIdentifier = Object.create(null);
|
|
458
|
+
var entries = payload.entries.map(function (e, i) {
|
|
459
|
+
if (!_isPlainObject(e)) throw _err("webauthn/bad-metadata-blob", "metadata entry " + i + " is not an object");
|
|
460
|
+
if (!Array.isArray(e.statusReports) || e.statusReports.length === 0) {
|
|
461
|
+
throw _err("webauthn/bad-metadata-blob", "metadata entry " + i + " must carry a non-empty statusReports array");
|
|
462
|
+
}
|
|
463
|
+
// Bounded like every other repeated per-entry structure. The status gate walks this array on
|
|
464
|
+
// each verify, so an unbounded one turns a single entry into per-verification work with no
|
|
465
|
+
// ceiling -- the entry count and anchor count are capped for exactly that reason, and leaving
|
|
466
|
+
// one of the three uncapped is the same rule applied to part of the structure.
|
|
467
|
+
if (e.statusReports.length > C.MDS_MAX_STATUS_REPORTS_PER_ENTRY) {
|
|
468
|
+
throw _err("webauthn/too-large", "metadata entry " + i + " declares " + e.statusReports.length +
|
|
469
|
+
" status reports, above the " + C.MDS_MAX_STATUS_REPORTS_PER_ENTRY + " ceiling");
|
|
470
|
+
}
|
|
471
|
+
// sec. 3.1.3 makes `status` REQUIRED on every report. A report that is not an object, or that
|
|
472
|
+
// omits it, is refused HERE rather than skipped by the status gate: the gate reads a missing
|
|
473
|
+
// status as "nothing disqualifying", so a malformed report would be silently read as a clean
|
|
474
|
+
// bill of health for the authenticator whose status it was supposed to carry.
|
|
475
|
+
e.statusReports.forEach(function (r, ri) {
|
|
476
|
+
if (!_isPlainObject(r)) throw _err("webauthn/bad-metadata-blob", "metadata entry " + i + " status report " + ri + " is not an object");
|
|
477
|
+
if (typeof r.status !== "string" || !r.status) {
|
|
478
|
+
throw _err("webauthn/bad-metadata-blob", "metadata entry " + i + " status report " + ri + " has no status (MDS v3.0 sec. 3.1.3 requires one)");
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
var aaguid = null;
|
|
482
|
+
if (e.aaguid !== undefined) {
|
|
483
|
+
if (typeof e.aaguid !== "string" || !AAGUID_RE.test(e.aaguid.toLowerCase())) {
|
|
484
|
+
throw _err("webauthn/bad-metadata-blob", "metadata entry " + i + " has a malformed aaguid");
|
|
485
|
+
}
|
|
486
|
+
aaguid = e.aaguid.toLowerCase();
|
|
487
|
+
}
|
|
488
|
+
// A U2F authenticator carries no AAGUID, so the catalogue keys it by the key identifiers of its
|
|
489
|
+
// attestation certificates instead. Without this index such an authenticator matches nothing,
|
|
490
|
+
// and a caller who enabled metadata enforcement would have every U2F registration refused as
|
|
491
|
+
// unlisted -- fail-closed, but on an authenticator the catalogue does in fact describe.
|
|
492
|
+
// sec. 3.1.1 puts attestationCertificateKeyIdentifiers on the ENTRY, as a sibling of
|
|
493
|
+
// metadataStatement -- that is the field the lookup is keyed by, and reading it only from the
|
|
494
|
+
// statement means real U2F entries never populate the index and every catalogued U2F
|
|
495
|
+
// authenticator is refused as unlisted. sec. 3.2 defines the same field on the statement as
|
|
496
|
+
// well, and live entries populate both, so both are read and the union is indexed.
|
|
497
|
+
var st = e.metadataStatement;
|
|
498
|
+
var keyIds = [];
|
|
499
|
+
var seenKeyId = Object.create(null);
|
|
500
|
+
[[e.attestationCertificateKeyIdentifiers, "entry"], [st && st.attestationCertificateKeyIdentifiers, "metadataStatement"]]
|
|
501
|
+
.forEach(function (pair) {
|
|
502
|
+
var list = pair[0];
|
|
503
|
+
if (list === undefined) return;
|
|
504
|
+
if (!Array.isArray(list)) {
|
|
505
|
+
throw _err("webauthn/bad-metadata-blob", "metadata entry " + i + " " + pair[1] + " attestationCertificateKeyIdentifiers is not an array");
|
|
506
|
+
}
|
|
507
|
+
if (list.length > C.MDS_MAX_KEY_IDS_PER_ENTRY) {
|
|
508
|
+
throw _err("webauthn/too-large", "metadata entry " + i + " declares " + list.length +
|
|
509
|
+
" attestation certificate key identifiers, above the " + C.MDS_MAX_KEY_IDS_PER_ENTRY + " ceiling");
|
|
510
|
+
}
|
|
511
|
+
list.forEach(function (k) {
|
|
512
|
+
// A SHA-1 key identifier is 40 hex digits (RFC 5280 sec. 4.2.1.2 method 1). Anything else
|
|
513
|
+
// cannot be what a certificate hashes to, so accepting it would add a key that never
|
|
514
|
+
// matches -- an entry that silently cannot be found. sec. 3.1.1 requires lower case; the
|
|
515
|
+
// value is canonicalized rather than refused, so a catalogue that differs only in letter
|
|
516
|
+
// case still resolves instead of denying service for every authenticator it lists.
|
|
517
|
+
if (typeof k !== "string" || !/^[0-9a-fA-F]{40}$/.test(k)) {
|
|
518
|
+
throw _err("webauthn/bad-metadata-blob", "metadata entry " + i + " has a malformed attestation certificate key identifier");
|
|
519
|
+
}
|
|
520
|
+
var lower = k.toLowerCase();
|
|
521
|
+
// The entry and its statement legitimately repeat an identifier; that is one entry naming
|
|
522
|
+
// itself twice, not two entries claiming one authenticator, so it is deduplicated here
|
|
523
|
+
// rather than reaching the cross-entry duplicate check below.
|
|
524
|
+
if (!seenKeyId[lower]) { seenKeyId[lower] = 1; keyIds.push(lower); }
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
var out = { index: i, aaguid: aaguid, keyIdentifiers: keyIds, statusReports: e.statusReports,
|
|
528
|
+
metadataStatement: st || null, timeOfLastStatusChange: e.timeOfLastStatusChange || null };
|
|
529
|
+
if (aaguid) {
|
|
530
|
+
// A duplicate identifier is refused rather than resolved by position: two entries claiming
|
|
531
|
+
// one authenticator give the lookup a choice the specification does not define.
|
|
532
|
+
if (byAaguid[aaguid]) throw _err("webauthn/duplicate-metadata-entry", "two metadata entries claim aaguid " + aaguid);
|
|
533
|
+
byAaguid[aaguid] = out;
|
|
534
|
+
}
|
|
535
|
+
keyIds.forEach(function (k) {
|
|
536
|
+
if (byKeyIdentifier[k]) throw _err("webauthn/duplicate-metadata-entry", "two metadata entries claim attestation certificate key identifier " + k);
|
|
537
|
+
byKeyIdentifier[k] = out;
|
|
538
|
+
});
|
|
539
|
+
return out;
|
|
540
|
+
});
|
|
541
|
+
var result = { no: payload.no, legalHeader: payload.legalHeader, nextUpdate: payload.nextUpdate,
|
|
542
|
+
stale: stale, allowStale: opts.allowStale === true,
|
|
543
|
+
entries: entries, byAaguid: byAaguid, byKeyIdentifier: byKeyIdentifier,
|
|
544
|
+
statusPolicy: opts.statusPolicy || "any", rejectUnknownStatus: opts.rejectUnknownStatus === true };
|
|
545
|
+
// Frozen FIRST, then recorded as verified: the mark means "this exact catalogue passed the
|
|
546
|
+
// signature, chain, rollback and freshness gates", and that claim only holds if the object cannot
|
|
547
|
+
// be edited afterwards. This is the only place a catalogue can have been through those gates.
|
|
548
|
+
_deepFreeze(result, 0);
|
|
549
|
+
_verifiedResults.add(result);
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// The verified metadata entry for an AAGUID, or `null` when the BLOB lists none. `metadata` is a
|
|
554
|
+
// `verifyMetadataBlob` result -- never raw bytes, so a lookup cannot be answered from an
|
|
555
|
+
// unverified BLOB. The all-zero AAGUID means "no model identity" and never matches.
|
|
556
|
+
// It also accepts the identifier a U2F authenticator is keyed by instead -- the key identifier of
|
|
557
|
+
// its attestation certificate -- so one verb covers both of the catalogue's key spaces. The two are
|
|
558
|
+
// disjoint by shape (a dashed 36-character UUID against 40 hex digits), so the form is DISPATCHED
|
|
559
|
+
// ON, never guessed at: anything matching neither is a miss rather than a lookup in whichever table
|
|
560
|
+
// happens to answer.
|
|
561
|
+
function metadataFor(metadata, identifier) {
|
|
562
|
+
if (!isVerifiedResult(metadata)) throw _err("webauthn/bad-input", "metadataFor expects a verifyMetadataBlob result -- an object that merely resembles one, such as a catalogue restored from a cache, has not been through the signature and chain checks");
|
|
563
|
+
if (typeof identifier !== "string") return null;
|
|
564
|
+
var key = identifier.toLowerCase();
|
|
565
|
+
if (AAGUID_RE.test(key)) {
|
|
566
|
+
if (key === ZERO_AAGUID) return null;
|
|
567
|
+
return metadata.byAaguid[key] || null;
|
|
568
|
+
}
|
|
569
|
+
if (/^[0-9a-f]{40}$/.test(key)) return metadataForKeyIdentifier(metadata, key);
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// The attestation root certificates an entry's authenticator chains to, decoded on demand. Decoding
|
|
574
|
+
// is deliberately per entry rather than for the whole BLOB: a handful of the certificates in the
|
|
575
|
+
// live metadata do not parse under a strict decoder, and decoding everything up front would let one
|
|
576
|
+
// vendor's malformed root refuse the entire BLOB for every authenticator in it.
|
|
577
|
+
function metadataAnchors(entry) {
|
|
578
|
+
if (!entry || typeof entry !== "object") throw _err("webauthn/bad-input", "metadataAnchors expects a metadata entry");
|
|
579
|
+
var st = entry.metadataStatement;
|
|
580
|
+
var list = st && Array.isArray(st.attestationRootCertificates) ? st.attestationRootCertificates : [];
|
|
581
|
+
if (list.length > C.MDS_MAX_ANCHORS_PER_ENTRY) {
|
|
582
|
+
throw _err("webauthn/too-large", "metadata entry " + entry.index + " declares " + list.length + " attestation roots, above the " + C.MDS_MAX_ANCHORS_PER_ENTRY + " ceiling");
|
|
583
|
+
}
|
|
584
|
+
return list.map(function (b64, i) {
|
|
585
|
+
var der;
|
|
586
|
+
try { der = guard.encoding.base64(b64, C.MDS_BLOB_MAX_BYTES, _err, "webauthn/bad-metadata-entry", "an attestation root certificate"); }
|
|
587
|
+
catch (e) { throw _err("webauthn/bad-metadata-entry", "metadata entry " + entry.index + " attestation root " + i + " is not canonical base64", e); }
|
|
588
|
+
try { return x509.parse(der); }
|
|
589
|
+
catch (e) { throw _err("webauthn/bad-metadata-entry", "metadata entry " + entry.index + " attestation root " + i + " is not a decodable certificate", e); }
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Does this entry's status deny trust? Default: ANY disqualifying report denies, wherever it sits
|
|
594
|
+
// in the array. The array is not stated to be chronological, `effectiveDate` is optional, and in
|
|
595
|
+
// the live metadata a number of entries are not in date order -- one of them flipping its verdict
|
|
596
|
+
// between "last element" and "newest by date", in the direction that matters. A caller who wants
|
|
597
|
+
// the by-date reading asks for it.
|
|
598
|
+
// A status report may name the single certificate it concerns (MDS v3.0 sec. 3.1.3 `certificate`),
|
|
599
|
+
// and a key-compromise report that does so is about THAT attestation key -- not about every
|
|
600
|
+
// authenticator the entry covers. A whole batch is often listed under one entry, so ignoring the
|
|
601
|
+
// scoping would refuse registrations from devices whose key was never compromised. A report that
|
|
602
|
+
// names nothing applies to the entry as a whole, and a report whose named certificate cannot be
|
|
603
|
+
// read applies too: an unreadable scope is not a narrower scope.
|
|
604
|
+
// Phrased as "does this report demonstrably name a DIFFERENT certificate", so the only way to
|
|
605
|
+
// escape a disqualifying report is to PROVE it is about someone else. Every uncertain path returns
|
|
606
|
+
// false and the report keeps applying: a scope that cannot be read is not a narrower scope.
|
|
607
|
+
function _reportNamesOtherCert(report, leaf) {
|
|
608
|
+
if (typeof report.certificate !== "string" || !report.certificate) return false;
|
|
609
|
+
if (!leaf) return false;
|
|
610
|
+
var named;
|
|
611
|
+
try { named = x509.parse(guard.encoding.base64(report.certificate, C.MDS_BLOB_MAX_BYTES, _err, "webauthn/bad-metadata-entry", "a status report certificate")); }
|
|
612
|
+
catch (_e) { return false; }
|
|
613
|
+
try { return certKeyIdentifier(named) !== certKeyIdentifier(leaf); }
|
|
614
|
+
catch (_e) { return false; }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Has this report taken effect by `atMs`?
|
|
618
|
+
function _reportInForceAt(report, atMs) {
|
|
619
|
+
var d = rfc3339.parseDate(report.effectiveDate, function (c, m) { return _err("webauthn/bad-metadata-blob", m); },
|
|
620
|
+
"webauthn/bad-metadata-blob", "a status report effectiveDate");
|
|
621
|
+
// Only ever called with a report whose effectiveDate rfc3339.isValidDate already accepted, and
|
|
622
|
+
// parseDate throws rather than returning an Invalid Date, so d is finite; atMs was isFinite-
|
|
623
|
+
// checked by the caller before this runs.
|
|
624
|
+
// allow:nan-date-comparison-unguarded -- both operands are source-validated, as described above.
|
|
625
|
+
return d.getTime() <= atMs;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function statusDenied(entry, metadata, leaf, at) {
|
|
629
|
+
var policy = (metadata && metadata.statusPolicy) || "any";
|
|
630
|
+
var reports = entry.statusReports || [];
|
|
631
|
+
// A caller's own predicate receives the entry's reports AS GIVEN, which is what the documented
|
|
632
|
+
// contract promises. Handing it a pre-filtered array would silently defeat the very policies it
|
|
633
|
+
// exists for -- an entry-wide rule such as "deny whenever any attestation key is compromised"
|
|
634
|
+
// would be evaluated against an array with exactly those reports removed.
|
|
635
|
+
if (typeof policy === "function") return policy(reports) === true;
|
|
636
|
+
// For the built-in policies, reports that demonstrably concern a DIFFERENT certificate are removed
|
|
637
|
+
// first, before recency is considered. Scope has to be settled before recency, or under
|
|
638
|
+
// latest-by-date such a report could be selected as the newest, displace an older model-wide
|
|
639
|
+
// revocation, and then be discarded as inapplicable -- clearing the entry using a report that was
|
|
640
|
+
// never about this authenticator at all.
|
|
641
|
+
reports = reports.filter(function (r) {
|
|
642
|
+
return !(r && typeof r.status === "string" && CERT_SCOPED_STATUS[r.status] && _reportNamesOtherCert(r, leaf));
|
|
643
|
+
});
|
|
644
|
+
// A report dated AFTER the instant being judged has not taken effect, whatever the policy. This
|
|
645
|
+
// belongs here rather than inside one policy's branch: under the default reading, a scheduled
|
|
646
|
+
// revocation would otherwise deny every registration from the moment it is published rather than
|
|
647
|
+
// from the date it names, and a deliberately historical verification would see reports filed
|
|
648
|
+
// after the time it asks about. When every dated report is still in the future, the answer is
|
|
649
|
+
// that none of them is in force -- not that all of them are.
|
|
650
|
+
var isDated = function (r) { return r && typeof r.effectiveDate === "string" && rfc3339.isValidDate(r.effectiveDate); };
|
|
651
|
+
var atMs = (at instanceof Date && isFinite(at.getTime())) ? at.getTime() : null;
|
|
652
|
+
if (atMs !== null) {
|
|
653
|
+
reports = reports.filter(function (r) { return !isDated(r) || _reportInForceAt(r, atMs); });
|
|
654
|
+
}
|
|
655
|
+
var considered = reports;
|
|
656
|
+
if (policy === "latest-by-date") {
|
|
657
|
+
var dated = reports.filter(isDated);
|
|
658
|
+
if (dated.length) {
|
|
659
|
+
// EVERY report on the newest date, not the first one found there. Reducing to a single report
|
|
660
|
+
// makes a tie resolve by array position: a same-day clean report and a same-day REVOKED would
|
|
661
|
+
// deny or not depending purely on which the catalogue happened to list first, and reversing
|
|
662
|
+
// the array would flip the verdict. Reports that are equally recent are equally current, so a
|
|
663
|
+
// disqualifying one among them cannot be discarded.
|
|
664
|
+
var newest = dated.reduce(function (a, b) { return a.effectiveDate >= b.effectiveDate ? a : b; }).effectiveDate;
|
|
665
|
+
// effectiveDate is OPTIONAL (sec. 3.1.3), and a report without one cannot be shown to be
|
|
666
|
+
// older than anything -- so it is KEPT rather than dropped. Discarding it would let an entry
|
|
667
|
+
// clear an undated REVOKED simply by adding a dated clean report, which is the fail-open this
|
|
668
|
+
// policy is most likely to be reached for. Where the ordering cannot be established, the
|
|
669
|
+
// report still counts.
|
|
670
|
+
considered = dated.filter(function (r) { return r.effectiveDate === newest; })
|
|
671
|
+
.concat(reports.filter(function (r) { return !isDated(r); }));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
var rejectUnknown = !!(metadata && metadata.rejectUnknownStatus);
|
|
675
|
+
return considered.some(function (r) {
|
|
676
|
+
var s = r && typeof r.status === "string" ? r.status : null;
|
|
677
|
+
if (s === null) return false;
|
|
678
|
+
// Scope was settled above, so anything still here applies to this authenticator.
|
|
679
|
+
if (DISQUALIFYING[s]) return true;
|
|
680
|
+
// An unrecognised status is IGNORED for the gate unless the caller opted in: the specification
|
|
681
|
+
// requires a verifier not to fail on a status value it does not know.
|
|
682
|
+
return rejectUnknown && !_KNOWN_STATUS[s];
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Status values this library recognises as non-disqualifying, so `rejectUnknownStatus` can tell an
|
|
687
|
+
// unknown value from a known-good one.
|
|
688
|
+
var _KNOWN_STATUS = Object.assign(Object.create(null), {
|
|
689
|
+
NOT_FIDO_CERTIFIED: 1, SELF_ASSERTION_SUBMITTED: 1, FIDO_CERTIFIED: 1, FIDO_CERTIFIED_L1: 1,
|
|
690
|
+
FIDO_CERTIFIED_L1plus: 1, FIDO_CERTIFIED_L2: 1, FIDO_CERTIFIED_L2plus: 1, FIDO_CERTIFIED_L3: 1,
|
|
691
|
+
FIDO_CERTIFIED_L3plus: 1, UPDATE_AVAILABLE: 1,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// A 16-byte AAGUID from authenticatorData -> the dashed lower-case form the metadata is keyed by.
|
|
695
|
+
function aaguidToString(buf) {
|
|
696
|
+
if (!Buffer.isBuffer(buf) || buf.length !== 16) return null;
|
|
697
|
+
var h = buf.toString("hex");
|
|
698
|
+
return h.slice(0, 8) + "-" + h.slice(8, 12) + "-" + h.slice(12, 16) + "-" + h.slice(16, 20) + "-" + h.slice(20);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The key identifier of a certificate's subject public key, which is how the metadata keys a U2F
|
|
702
|
+
// authenticator that carries no AAGUID (`attestationCertificateKeyIdentifiers`).
|
|
703
|
+
//
|
|
704
|
+
// RFC 5280 sec. 4.2.1.2 method 1 exactly: the SHA-1 of the BIT STRING **contents** of the
|
|
705
|
+
// subjectPublicKey -- not of the whole SubjectPublicKeyInfo, which is a different digest that would
|
|
706
|
+
// match nothing in the catalogue and silently turn every U2F lookup into a miss.
|
|
707
|
+
//
|
|
708
|
+
// This is the same value pki-build's spkiKeyId derives for the subjectKeyIdentifier extension, from
|
|
709
|
+
// the DER rather than from a parsed certificate. Two derivations of one definition can drift, and a
|
|
710
|
+
// drift here is silent -- every U2F lookup simply stops matching -- so a vector pins the value
|
|
711
|
+
// against a real certificate's OWN subjectKeyIdentifier extension, which is the independent oracle
|
|
712
|
+
// for method 1 and fails the moment either derivation changes.
|
|
713
|
+
//
|
|
714
|
+
// The algorithm is not a choice: SHA-1 is what the standard names, this is an IDENTIFIER rather than
|
|
715
|
+
// a signature or an integrity check, and the identity it labels is re-established by the certificate
|
|
716
|
+
// chain validation that follows. Choosing a stronger hash would produce a value the catalogue does
|
|
717
|
+
// not contain.
|
|
718
|
+
function certKeyIdentifier(cert) {
|
|
719
|
+
var pk = cert && cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.publicKey;
|
|
720
|
+
if (!pk || !Buffer.isBuffer(pk.bytes)) {
|
|
721
|
+
throw _err("webauthn/bad-input", "certKeyIdentifier expects a parsed certificate carrying a subject public key");
|
|
722
|
+
}
|
|
723
|
+
// Collision resistance is not the property relied on here: a second key hashing to the same
|
|
724
|
+
// identifier would resolve to the same catalogue entry, and its certificate would then still have
|
|
725
|
+
// to validate to the roots THAT entry registers -- which is the check that actually grants trust.
|
|
726
|
+
// nosemgrep: pki-weak-hash-md5-sha1
|
|
727
|
+
return nodeCrypto.createHash("sha1").update(pk.bytes).digest("hex");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// The verified metadata entry registering an attestation-certificate key identifier, or null. This
|
|
731
|
+
// is the lookup for an authenticator with no AAGUID -- the U2F case -- and it takes a
|
|
732
|
+
// verifyMetadataBlob result for the same reason metadataFor does: an unverified catalogue must not
|
|
733
|
+
// be able to answer which roots an authenticator is allowed to chain to.
|
|
734
|
+
function metadataForKeyIdentifier(metadata, keyId) {
|
|
735
|
+
if (!isVerifiedResult(metadata)) throw _err("webauthn/bad-input", "metadataForKeyIdentifier expects a verifyMetadataBlob result -- an object that merely resembles one has not been through the signature and chain checks");
|
|
736
|
+
if (typeof keyId !== "string") return null;
|
|
737
|
+
return metadata.byKeyIdentifier[keyId.toLowerCase()] || null;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
module.exports = {
|
|
741
|
+
verifyMetadataBlob: verifyMetadataBlob,
|
|
742
|
+
metadataFor: metadataFor,
|
|
743
|
+
metadataForKeyIdentifier: metadataForKeyIdentifier,
|
|
744
|
+
metadataAnchors: metadataAnchors,
|
|
745
|
+
chainToAnchor: _chainToAnchor,
|
|
746
|
+
assertFresh: assertFresh,
|
|
747
|
+
isVerifiedResult: isVerifiedResult,
|
|
748
|
+
statusDenied: statusDenied,
|
|
749
|
+
aaguidToString: aaguidToString,
|
|
750
|
+
ZERO_AAGUID: ZERO_AAGUID,
|
|
751
|
+
certKeyIdentifier: certKeyIdentifier,
|
|
752
|
+
DISQUALIFYING: DISQUALIFYING,
|
|
753
|
+
};
|