@blamejs/pki 0.8.55 → 0.8.56
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 +66 -0
- package/lib/acme.js +2 -2
- package/lib/asn1-der.js +17 -5
- package/lib/attrcert-verify.js +1 -1
- package/lib/cmc-verify.js +1 -1
- package/lib/cmp-session.js +2 -2
- package/lib/cmp-verify.js +2 -2
- package/lib/cms-decrypt.js +4 -1
- package/lib/cms-encrypt.js +4 -1
- package/lib/cms-sign.js +26 -12
- package/lib/cms-verify.js +1 -1
- package/lib/composite-kem.js +30 -12
- package/lib/composite-sig.js +42 -23
- package/lib/constants.js +7 -2
- package/lib/crl-verify.js +1 -1
- package/lib/crmf-sign.js +72 -43
- package/lib/crmf-verify.js +1 -1
- package/lib/csr-verify.js +1 -1
- package/lib/ct.js +2 -2
- package/lib/edwards-point.js +1 -1
- package/lib/est.js +3 -3
- package/lib/guard-async.js +22 -1
- package/lib/guard-bytes.js +6 -3
- package/lib/guard-identifier.js +1 -1
- package/lib/guard-intrinsic.js +474 -0
- package/lib/http-digest.js +146 -62
- package/lib/http-transport.js +82 -17
- package/lib/identity-match.js +2 -2
- package/lib/merkle.js +2 -2
- package/lib/ocsp-request-verify.js +1 -1
- package/lib/ocsp.js +46 -22
- package/lib/path-validate.js +169 -76
- package/lib/pbes2.js +14 -7
- package/lib/schema-attrcert.js +2 -2
- package/lib/schema-cmc.js +2 -2
- package/lib/schema-cmp.js +2 -2
- package/lib/schema-cms.js +2 -2
- package/lib/schema-crmf.js +2 -2
- package/lib/schema-csr.js +2 -2
- package/lib/schema-pkix.js +3 -3
- package/lib/schema-x509.js +2 -2
- package/lib/sigstore.js +1 -1
- package/lib/tlog.js +7 -4
- package/lib/trust.js +187 -104
- package/lib/tsp-sign.js +1 -1
- package/lib/webauthn-mds.js +257 -121
- package/lib/webauthn.js +327 -164
- package/lib/webcrypto.js +10 -3
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/webauthn.js
CHANGED
|
@@ -38,6 +38,10 @@ var intrinsic = require("./guard-intrinsic");
|
|
|
38
38
|
var _hasOwn = intrinsic.hasOwn;
|
|
39
39
|
var _reverse = intrinsic.reverse;
|
|
40
40
|
var _arrSlice = intrinsic.arraySlice;
|
|
41
|
+
/** @internal The split that cuts an android-safetynet JWS into its three segments decides which bytes
|
|
42
|
+
* are the signed input and which are the signature, so a replacement returning segments of its own
|
|
43
|
+
* choosing has the attestation verified over bytes the signer never covered. */
|
|
44
|
+
var _strSplit = intrinsic.uncurry(String.prototype.split);
|
|
41
45
|
/** @internal `resolve` and `reject` build their promise from the RECEIVER, so reading the global
|
|
42
46
|
* binding at the call hands the construction to whatever that binding holds. Two shapes here decide a
|
|
43
47
|
* verdict: the seeds the attestation trust-path reductions fold over, where a replacement settling
|
|
@@ -67,32 +71,57 @@ var subtle = webcrypto.webcrypto.subtle;
|
|
|
67
71
|
/** @internal The verify operation, captured at load. One answering true accepts an assertion or an
|
|
68
72
|
* attestation statement that the credential's key never signed, which is the whole verdict. */
|
|
69
73
|
var _subtleVerify = intrinsic.uncurry(subtle.verify);
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
/** @internal The import is captured with it. It produces the key the verify above runs under, so a
|
|
75
|
+
* replacement answering with a key of its own accepts a signature made by whoever holds that key
|
|
76
|
+
* while the bytes handed in were never the credential's. */
|
|
77
|
+
var _subtleImportKey = intrinsic.uncurry(subtle.importKey);
|
|
78
|
+
|
|
79
|
+
/** @internal The hash constructor is captured, and so are the methods of the object it returns. Every
|
|
80
|
+
* attestation comparison in this module rests on a digest, so a replaced `createHash`, or a replaced
|
|
81
|
+
* `update` or `digest` on the object it hands back, chooses what the comparison sees. */
|
|
82
|
+
var _createHash = nodeCrypto.createHash;
|
|
83
|
+
function _sha(name, buf) {
|
|
84
|
+
var h = _createHash(name);
|
|
85
|
+
return _hashDigest(_hashUpdate(h, buf));
|
|
86
|
+
}
|
|
87
|
+
var _hashUpdate = intrinsic.uncurry(nodeCrypto.createHash("sha256").update);
|
|
88
|
+
var _hashDigest = intrinsic.uncurry(nodeCrypto.createHash("sha256").digest);
|
|
72
89
|
/** @internal The comparison goes through the captured operation. This is what decides that an
|
|
73
90
|
* attestation statement's key is the credential's key, so a replacement answering zero makes a
|
|
74
91
|
* different key match. */
|
|
75
92
|
function _ucmp(a, b) {
|
|
76
|
-
function strip(x) { var i = 0; while (i < x.length - 1 && x[i] === 0) i++; return
|
|
93
|
+
function strip(x) { var i = 0; while (i < x.length - 1 && x[i] === 0) i++; return intrinsic.byteSlice(x, i); }
|
|
77
94
|
return intrinsic.compare(strip(a), strip(b)) === 0;
|
|
78
95
|
}
|
|
79
96
|
function _isInteger(node) { return !!node && !node.constructed && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.INTEGER; }
|
|
80
97
|
var COSE_ALG_HASH = intrinsic.assign(intrinsic.create(null), { "-7": "sha256", "-9": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-51": "sha384", "-258": "sha384", "-38": "sha384", "-36": "sha512", "-52": "sha512", "-259": "sha512", "-39": "sha512", "-65535": "sha1" });
|
|
98
|
+
/** @internal The conversion that builds the registry KEY is captured. It selects which hash a
|
|
99
|
+
* signature is checked under, so a replacement answering with another algorithm's key picks another
|
|
100
|
+
* hash for the same declared algorithm. */
|
|
81
101
|
function _coseAlgHash(alg, E) {
|
|
82
|
-
var h = COSE_ALG_HASH[String(alg)];
|
|
102
|
+
var h = COSE_ALG_HASH[intrinsic.String(alg)];
|
|
83
103
|
if (!h) throw E("webauthn/unsupported-algorithm", "no hash mapping for COSE algorithm " + alg);
|
|
84
104
|
return h;
|
|
85
105
|
}
|
|
86
106
|
|
|
87
107
|
|
|
88
108
|
var AAGUID_LEN = 16;
|
|
109
|
+
/** @internal Every slice this takes of authenticatorData goes through the capture that consults no
|
|
110
|
+
* construction protocol, along with the classification that admits the buffer in the first place.
|
|
111
|
+
* The slices ARE the fields the ceremony is checked against: the RP ID hash, the AAGUID, the
|
|
112
|
+
* credential id and the bytes the COSE key is read from, so a replaced slice answers the checks
|
|
113
|
+
* below about bytes the authenticator never sent. Capturing `subarray` is not enough on its own,
|
|
114
|
+
* because it runs TypedArraySpeciesCreate and so builds its result through a constructor read off
|
|
115
|
+
* the receiver: one answering with the expected RP ID hash for a 32-byte slice made an assertion
|
|
116
|
+
* produced for another relying party satisfy `expectedRpId`, while the signature was still verified
|
|
117
|
+
* over the original bytes. */
|
|
89
118
|
function _parseAuthData(buf, E) {
|
|
90
|
-
if (!
|
|
119
|
+
if (!intrinsic.isBuffer(buf) || buf.length < 37) throw E("webauthn/bad-auth-data", "authenticatorData is shorter than the 37-byte minimum (RFC WebAuthn sec. 6.1)");
|
|
91
120
|
var flags = buf[32];
|
|
92
121
|
var out = {
|
|
93
|
-
rpIdHash:
|
|
122
|
+
rpIdHash: intrinsic.byteSlice(buf, 0, 32),
|
|
94
123
|
flags: { up: !!(flags & 0x01), uv: !!(flags & 0x04), be: !!(flags & 0x08), bs: !!(flags & 0x10), at: !!(flags & 0x40), ed: !!(flags & 0x80) },
|
|
95
|
-
signCount:
|
|
124
|
+
signCount: intrinsic.readUInt32BE(buf, 33),
|
|
96
125
|
aaguid: null, credentialId: null, credentialPublicKey: null, credentialPublicKeyBytes: null, extensions: null,
|
|
97
126
|
};
|
|
98
127
|
if (out.flags.bs && !out.flags.be) throw E("webauthn/bad-auth-data", "authenticatorData sets Backup State (BS) without Backup Eligibility (BE) (WebAuthn sec. 6.1)");
|
|
@@ -100,24 +129,24 @@ function _parseAuthData(buf, E) {
|
|
|
100
129
|
var off = 37;
|
|
101
130
|
if (out.flags.at) {
|
|
102
131
|
if (buf.length < off + AAGUID_LEN + 2) throw E("webauthn/bad-auth-data", "attestedCredentialData is truncated before the credentialId length");
|
|
103
|
-
out.aaguid =
|
|
104
|
-
var credLen =
|
|
132
|
+
out.aaguid = intrinsic.byteSlice(buf, off, off + AAGUID_LEN); off += AAGUID_LEN;
|
|
133
|
+
var credLen = intrinsic.readUInt16BE(buf, off); off += 2;
|
|
105
134
|
if (credLen < 1 || credLen > 1023) throw E("webauthn/bad-credential-id", "credentialIdLength " + credLen + " is outside 1..1023 (RFC WebAuthn sec. 6.1)");
|
|
106
135
|
if (buf.length < off + credLen) throw E("webauthn/bad-auth-data", "credentialId overruns authenticatorData");
|
|
107
|
-
out.credentialId =
|
|
136
|
+
out.credentialId = intrinsic.byteSlice(buf, off, off + credLen); off += credLen;
|
|
108
137
|
var keyNode;
|
|
109
|
-
try { keyNode = cbor.decode(
|
|
138
|
+
try { keyNode = cbor.decode(intrinsic.byteSlice(buf, off), { allowTrailing: true }); }
|
|
110
139
|
catch (e) { throw E("webauthn/bad-cose-key", "the credential public key is not well-formed CBOR", e); }
|
|
111
|
-
out.credentialPublicKeyBytes =
|
|
140
|
+
out.credentialPublicKeyBytes = intrinsic.byteSlice(buf, off, off + keyNode.bytes.length);
|
|
112
141
|
out.credentialPublicKey = _decodeCoseKey(keyNode);
|
|
113
142
|
off += keyNode.bytes.length;
|
|
114
143
|
}
|
|
115
144
|
if (out.flags.ed) {
|
|
116
145
|
var extNode;
|
|
117
|
-
try { extNode = cbor.decode(
|
|
146
|
+
try { extNode = cbor.decode(intrinsic.byteSlice(buf, off)); }
|
|
118
147
|
catch (e) { throw E("webauthn/bad-auth-data", "the authenticatorData extensions are not a single well-formed CBOR map", e); }
|
|
119
148
|
if (extNode.majorType !== 5) throw E("webauthn/bad-auth-data", "the authenticatorData extensions must be a CBOR map");
|
|
120
|
-
out.extensions =
|
|
149
|
+
out.extensions = intrinsic.byteSlice(buf, off);
|
|
121
150
|
} else if (off < buf.length) {
|
|
122
151
|
throw E("webauthn/bad-auth-data", "authenticatorData has trailing bytes after attestedCredentialData with the ED flag clear");
|
|
123
152
|
}
|
|
@@ -193,16 +222,25 @@ var COSE_ALG = intrinsic.assign(intrinsic.create(null), {
|
|
|
193
222
|
|
|
194
223
|
function _derEcdsaToRaw(der, curve) { return validator.sig.ecdsaDerToP1363(der, curve, WebauthnError, "webauthn/bad-signature"); }
|
|
195
224
|
|
|
225
|
+
/** @internal Same registry-key conversion, and this one selects the import and the verify routine a
|
|
226
|
+
* signature is checked with, which is the binding between a declared algorithm and the operation
|
|
227
|
+
* applied to it. */
|
|
196
228
|
function _verifySig(alg, sig, spkiBytes, message, E) {
|
|
197
|
-
var d = COSE_ALG[String(alg)];
|
|
229
|
+
var d = COSE_ALG[intrinsic.String(alg)];
|
|
198
230
|
if (!d) throw _err("webauthn/unsupported-algorithm", "unsupported COSE algorithm " + alg);
|
|
199
231
|
var imp = d.imp, ver = d.verify;
|
|
200
232
|
if (alg === -8) { var nm = _edName(spkiBytes, E); imp = { name: nm }; ver = { name: nm }; }
|
|
201
233
|
if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name);
|
|
202
234
|
var s = d.ecdsa ? _derEcdsaToRaw(sig, d.imp.namedCurve) : sig;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
235
|
+
/** @internal Every continuation on this route is taken through the captured `then`. `then` is read
|
|
236
|
+
* off the promise at the call, so a replacement settles the chain without running the callback:
|
|
237
|
+
* settling here hands back a value in place of the signature check, and each caller below reads
|
|
238
|
+
* that value as the verdict. The whole attestation route is continued this way for that reason. */
|
|
239
|
+
var imported = _subtleImportKey(subtle, "spki", spkiBytes, imp, false, ["verify"]);
|
|
240
|
+
var checked = intrinsic.chain(imported, function (key) { return _subtleVerify(subtle, ver, key, s, message); });
|
|
241
|
+
return intrinsic.chain(checked, undefined, function (e) {
|
|
242
|
+
throw _err("webauthn/verify-error", "the attestation signature could not be evaluated", e);
|
|
243
|
+
});
|
|
206
244
|
}
|
|
207
245
|
var ED_OID_NAME = intrinsic.create(null);
|
|
208
246
|
ED_OID_NAME[oid.byName("Ed25519")] = "Ed25519";
|
|
@@ -223,7 +261,7 @@ function _coseKeyToSpki(key) { return validator.cose.toSpki(key, WebauthnError,
|
|
|
223
261
|
|
|
224
262
|
function _certEcCurveOid(cert, E) {
|
|
225
263
|
var params = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.algorithm && cert.subjectPublicKeyInfo.algorithm.parameters;
|
|
226
|
-
if (!
|
|
264
|
+
if (!intrinsic.isBuffer(params)) throw E("webauthn/key-mismatch", "the attestation certificate EC key carries no named-curve parameters");
|
|
227
265
|
try { return asn1.read.oid(asn1.decode(params)); }
|
|
228
266
|
catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate EC curve is not a valid OBJECT IDENTIFIER", e); }
|
|
229
267
|
}
|
|
@@ -239,8 +277,8 @@ function _certKeyAlgNames(cose) {
|
|
|
239
277
|
function _assertCertKeyAlgorithm(cert, cose, E) {
|
|
240
278
|
var alg = (cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.algorithm) || {};
|
|
241
279
|
var want = _certKeyAlgNames(cose);
|
|
242
|
-
if (!want.length ||
|
|
243
|
-
throw E("webauthn/key-mismatch", "the attestation certificate carries a " +
|
|
280
|
+
if (!want.length || intrinsic.indexOf(want, alg.name) < 0) {
|
|
281
|
+
throw E("webauthn/key-mismatch", "the attestation certificate carries a " + intrinsic.stringify(alg.name) +
|
|
244
282
|
" key, which is not the kind of key the credential public key declares");
|
|
245
283
|
}
|
|
246
284
|
}
|
|
@@ -254,7 +292,7 @@ function _certPubKeyEqualsCose(cert, cose, E) {
|
|
|
254
292
|
if (_certEcCurveOid(cert, E) !== oid.byName(wantCurve)) throw E("webauthn/key-mismatch", "the attestation certificate EC curve does not equal the credential key curve");
|
|
255
293
|
if (raw.length < 1 || raw[0] !== 0x04) throw E("webauthn/key-mismatch", "the attestation certificate key is not an uncompressed EC point");
|
|
256
294
|
var coordLen = (raw.length - 1) >> 1;
|
|
257
|
-
var cx =
|
|
295
|
+
var cx = intrinsic.byteSlice(raw, 1, 1 + coordLen), cy = intrinsic.byteSlice(raw, 1 + coordLen);
|
|
258
296
|
if (!cose.x || !cose.y || !_ucmp(cx, cose.x) || !_ucmp(cy, cose.y)) throw E("webauthn/key-mismatch", "the attestation certificate EC key does not equal the credential public key");
|
|
259
297
|
return;
|
|
260
298
|
}
|
|
@@ -269,7 +307,7 @@ function _certPubKeyEqualsCose(cert, cose, E) {
|
|
|
269
307
|
return;
|
|
270
308
|
}
|
|
271
309
|
if (cose.kty === 1) {
|
|
272
|
-
if (!cose.x || !
|
|
310
|
+
if (!cose.x || !intrinsic.bufferEquals(intrinsic.bufferFrom(raw), cose.x)) throw E("webauthn/key-mismatch", "the attestation certificate OKP key does not equal the credential public key");
|
|
273
311
|
return;
|
|
274
312
|
}
|
|
275
313
|
throw E("webauthn/key-mismatch", "unsupported credential key type for the certificate comparison");
|
|
@@ -279,9 +317,24 @@ function _parseTpmPubArea(buf) { return validator.tpm.parsePubArea(buf, Webauthn
|
|
|
279
317
|
function _parseTpmCertInfo(buf) { return validator.tpm.parseCertInfo(buf, WebauthnError, "webauthn/bad-tpm"); }
|
|
280
318
|
function _tpmPubKeyEqualsCose(pub, cose) { validator.tpm.pubKeyEqualsCose(pub, cose, WebauthnError, "webauthn/key-mismatch", "webauthn/bad-tpm"); }
|
|
281
319
|
|
|
320
|
+
/** @internal The selection that FINDS an extension is captured. Every check that reads an extension
|
|
321
|
+
* value reads whatever this returns, the android-safetynet hostname requirement among them, so a
|
|
322
|
+
* replacement answering with another entry hands the check the wrong value to compare. */
|
|
323
|
+
/** @internal An index loop rather than a selection, because a captured `filter` still runs
|
|
324
|
+
* ArraySpeciesCreate and builds its result through a constructor read off the receiver: one
|
|
325
|
+
* returning `{ length: 0 }` takes the match as property `0` while the length stays zero, so the
|
|
326
|
+
* extension reads as ABSENT. Absent is not inert here, since the hostname check falls back from the
|
|
327
|
+
* subject alternative name to the common name, and a leaf whose authoritative name is another host
|
|
328
|
+
* passes on a matching CN. The loop also avoids indexing an empty result, where index `0` reads
|
|
329
|
+
* through to `Array.prototype[0]` and a value installed there answers for an extension the
|
|
330
|
+
* certificate does not carry. */
|
|
282
331
|
function _findExt(cert, oidName) {
|
|
283
332
|
var target = oid.byName(oidName);
|
|
284
|
-
|
|
333
|
+
var exts = cert.extensions || [];
|
|
334
|
+
for (var i = 0; i < exts.length; i++) {
|
|
335
|
+
if (exts[i] && exts[i].oid === target) return exts[i];
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
285
338
|
}
|
|
286
339
|
|
|
287
340
|
|
|
@@ -317,7 +370,7 @@ function parseAttestationObject(bytes) {
|
|
|
317
370
|
var wantMajor = ATT_STMT_MAJOR[cbor.read.textString(fmtN)];
|
|
318
371
|
if (wantMajor === undefined) wantMajor = 5;
|
|
319
372
|
if (attStmtN.majorType !== wantMajor) {
|
|
320
|
-
throw _err("webauthn/bad-attestation-object", "attestation object 'attStmt' must be a CBOR " + (wantMajor === 4 ? "array" : "map") + " for format " +
|
|
373
|
+
throw _err("webauthn/bad-attestation-object", "attestation object 'attStmt' must be a CBOR " + (wantMajor === 4 ? "array" : "map") + " for format " + intrinsic.stringify(cbor.read.textString(fmtN)));
|
|
321
374
|
}
|
|
322
375
|
if (authDataN.majorType !== 2) throw _err("webauthn/bad-attestation-object", "attestation object 'authData' must be a byte string");
|
|
323
376
|
var authDataBytes = cbor.read.byteString(authDataN);
|
|
@@ -340,23 +393,31 @@ function _attRead(map, key, reader, what) {
|
|
|
340
393
|
try { return reader(n); }
|
|
341
394
|
catch (e) { throw _err("webauthn/bad-att-stmt", "the attestation statement '" + key + "' must be " + what, e); }
|
|
342
395
|
}
|
|
343
|
-
|
|
396
|
+
/** @internal The numeric conversion comes from the capture. This value is the algorithm the
|
|
397
|
+
* attestation DECLARES, and it is compared against the credential key's own algorithm, so a
|
|
398
|
+
* conversion that answered with a different identifier would let one algorithm's signature be
|
|
399
|
+
* checked as another's. */
|
|
400
|
+
function _algOf(attStmt) { return intrinsic.Number(_attRead(attStmt, "alg", cbor.read.int, "an integer")); }
|
|
344
401
|
function _sigOf(attStmt) { return _attRead(attStmt, "sig", cbor.read.byteString, "a byte string"); }
|
|
345
402
|
function _requireAttShape(attStmt, allowed, required) {
|
|
346
403
|
var have = intrinsic.create(null);
|
|
347
|
-
|
|
404
|
+
/** @internal The walks and the membership test are captured. These are REFUSALS: the first rejects
|
|
405
|
+
* a field the format does not allow and the second a required field that is absent, so a walk that
|
|
406
|
+
* skips an entry or a membership test answering differently admits a statement neither rule would
|
|
407
|
+
* have passed. */
|
|
408
|
+
intrinsic.forEach(cbor.read.map(attStmt), function (kv) {
|
|
348
409
|
if (kv[0].majorType !== 3) throw _err("webauthn/bad-att-stmt", "the attestation statement has a non-text-string field key");
|
|
349
410
|
have[cbor.read.textString(kv[0])] = true;
|
|
350
411
|
});
|
|
351
|
-
|
|
352
|
-
|
|
412
|
+
intrinsic.forEach(intrinsic.keys(have), function (k) { if (intrinsic.indexOf(allowed, k) === -1) throw _err("webauthn/bad-att-stmt", "the attestation statement carries an unexpected field '" + k + "'"); });
|
|
413
|
+
intrinsic.forEach(required, function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
|
|
353
414
|
}
|
|
354
415
|
var ATT_STMT_MAJOR = intrinsic.assign(intrinsic.create(null), { compound: 4 });
|
|
355
416
|
|
|
356
|
-
var _AAGUID_SIGNED_BY_FMT =
|
|
417
|
+
var _AAGUID_SIGNED_BY_FMT = intrinsic.assign(intrinsic.create(null), { "fido-u2f": false });
|
|
357
418
|
function _aaguidIsSigned(fmt) { return _AAGUID_SIGNED_BY_FMT[fmt] !== false; }
|
|
358
419
|
|
|
359
|
-
var _VERIFY_OPTS =
|
|
420
|
+
var _VERIFY_OPTS = intrinsic.assign(intrinsic.create(null), {
|
|
360
421
|
time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
|
|
361
422
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
362
423
|
rootCertificates: 1,
|
|
@@ -368,11 +429,14 @@ var _FORMAT_SCOPED_BOOLEAN_OPTS = ["verifySafetyNetJws", "requireCtsProfileMatch
|
|
|
368
429
|
function _cloneParsed(v, depth) {
|
|
369
430
|
if (depth > 64) throw _err("webauthn/bad-input", "opts.rootCertificates[] is nested too deeply to be a parsed certificate");
|
|
370
431
|
if (guard.bytes.isByteSource(v)) return guard.bytes.snapshotSource(v, _err, "webauthn/bad-input", "a byte field of opts.rootCertificates[]");
|
|
371
|
-
if (
|
|
372
|
-
if (guard.time.isDate(v)) return new Date(guard.time.instantOf(v));
|
|
432
|
+
if (intrinsic.isArray(v)) return guard.list.copyMap(v, function (x) { return _cloneParsed(x, depth + 1); });
|
|
433
|
+
if (guard.time.isDate(v)) return new intrinsic.Date(guard.time.instantOf(v));
|
|
373
434
|
if (v && typeof v === "object") {
|
|
374
435
|
var out = intrinsic.create(null);
|
|
375
|
-
|
|
436
|
+
/** @internal The key enumeration and the walk over it are captured: this is the copy that stops a
|
|
437
|
+
* caller's root certificate changing after it was validated, and an enumeration that omits a key
|
|
438
|
+
* leaves that field reading through to the caller's own object. */
|
|
439
|
+
intrinsic.forEach(intrinsic.keys(v), function (k) { out[k] = _cloneParsed(v[k], depth + 1); });
|
|
376
440
|
return out;
|
|
377
441
|
}
|
|
378
442
|
return v;
|
|
@@ -386,9 +450,13 @@ function _bytesArg(v, label) {
|
|
|
386
450
|
throw _err("webauthn/bad-input", label + " must be a BufferSource (a Buffer, a typed-array view, or an ArrayBuffer)");
|
|
387
451
|
}
|
|
388
452
|
|
|
453
|
+
/** @internal The classification and the walk are captured. These produce the root set an attestation
|
|
454
|
+
* path is anchored to, so a walk answering with entries of its own supplies the anchors rather than
|
|
455
|
+
* copying the caller's, and a classification answering `false` hands the caller's live array through
|
|
456
|
+
* with no snapshot taken. */
|
|
389
457
|
function _snapshotRoots(supplied) {
|
|
390
|
-
if (!
|
|
391
|
-
return
|
|
458
|
+
if (!intrinsic.isArray(supplied)) return supplied;
|
|
459
|
+
return intrinsic.mapList(supplied, function (root) {
|
|
392
460
|
if (_isBufferSource(root)) {
|
|
393
461
|
return guard.bytes.snapshotSource(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[]");
|
|
394
462
|
}
|
|
@@ -397,10 +465,10 @@ function _snapshotRoots(supplied) {
|
|
|
397
465
|
}
|
|
398
466
|
|
|
399
467
|
function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
400
|
-
if (!
|
|
468
|
+
if (!intrinsic.isArray(supplied) || !supplied.length) {
|
|
401
469
|
throw _err("webauthn/bad-input", "opts.rootCertificates must be a non-empty array of root certificates (DER, PEM, or parsed)");
|
|
402
470
|
}
|
|
403
|
-
var roots =
|
|
471
|
+
var roots = intrinsic.mapList(supplied, function (root, i) {
|
|
404
472
|
var label = "opts.rootCertificates[" + i + "]";
|
|
405
473
|
return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
|
|
406
474
|
try {
|
|
@@ -408,42 +476,46 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
|
408
476
|
} catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
|
|
409
477
|
}, _err, "webauthn/bad-input", label);
|
|
410
478
|
});
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
479
|
+
/** @internal The selection of trust paths to anchor is captured. A selection that drops an element
|
|
480
|
+
* leaves that element's path unanchored while the refusal below sees a non-empty list, so the
|
|
481
|
+
* verdict reports the whole attestation anchored on the strength of the paths that survived. */
|
|
482
|
+
var paths = onlyPaths || ((res.fmt === "compound" && intrinsic.isArray(res.compound))
|
|
483
|
+
? intrinsic.mapList(intrinsic.selectList(res.compound, function (el) { return el.trustPath && el.trustPath.length; }),
|
|
484
|
+
function (el) { return { tp: el.trustPath, at: el.chainValidatedAt }; })
|
|
414
485
|
: (res.trustPath && res.trustPath.length ? [{ tp: res.trustPath, at: res.chainValidatedAt }] : []));
|
|
415
486
|
if (!onlyPaths) {
|
|
416
|
-
res = guard.verdict.of(res, { anchoredElements: { total: (res.fmt === "compound" &&
|
|
487
|
+
res = guard.verdict.of(res, { anchoredElements: { total: (res.fmt === "compound" && intrinsic.isArray(res.compound)) ? res.compound.length : 1,
|
|
417
488
|
anchored: paths.length } });
|
|
418
489
|
}
|
|
419
490
|
if (!paths.length) {
|
|
420
491
|
throw _err("webauthn/anchor-not-applicable",
|
|
421
492
|
"opts.rootCertificates was supplied, but this attestation carries no trust path to anchor (format '" + res.fmt + "')");
|
|
422
493
|
}
|
|
423
|
-
var at = vopts.time !== undefined ? vopts.time : new Date();
|
|
424
|
-
|
|
425
|
-
return
|
|
494
|
+
var at = vopts.time !== undefined ? vopts.time : new intrinsic.Date();
|
|
495
|
+
var anchoredAll = intrinsic.reduce(paths, function (p, info) {
|
|
496
|
+
return intrinsic.chain(p, function () {
|
|
426
497
|
var pathAt = vopts.time !== undefined ? vopts.time : (info.at || at);
|
|
427
|
-
return mds.chainToAnchor(_reverse(
|
|
498
|
+
return mds.chainToAnchor(_reverse(intrinsic.copyList(info.tp)), roots, pathAt,
|
|
428
499
|
"attestation trust path (against the roots supplied as opts.rootCertificates)");
|
|
429
500
|
});
|
|
430
|
-
}, _promiseResolve(_Promise, undefined))
|
|
501
|
+
}, _promiseResolve(_Promise, undefined));
|
|
502
|
+
return intrinsic.chain(anchoredAll, function () {
|
|
431
503
|
return guard.verdict.of(res, { anchoredTo: _anchoredRoutes(res, "rootCertificates") });
|
|
432
504
|
});
|
|
433
505
|
}
|
|
434
506
|
|
|
435
507
|
function _safetyNetAnchored(res) {
|
|
436
508
|
if (res.fmt === "android-safetynet") return 1;
|
|
437
|
-
if (res.fmt === "compound" &&
|
|
438
|
-
return res.compound
|
|
509
|
+
if (res.fmt === "compound" && intrinsic.isArray(res.compound)) {
|
|
510
|
+
return intrinsic.selectList(res.compound, function (el) { return el && el.fmt === "android-safetynet"; }).length;
|
|
439
511
|
}
|
|
440
512
|
return 0;
|
|
441
513
|
}
|
|
442
514
|
|
|
443
515
|
function _anchoredRoutes(res, base) {
|
|
444
|
-
var routes = base ?
|
|
445
|
-
if (_safetyNetAnchored(res) &&
|
|
446
|
-
return routes.length ?
|
|
516
|
+
var routes = base ? intrinsic.splitChar(base, 43) : [];
|
|
517
|
+
if (_safetyNetAnchored(res) && intrinsic.indexOf(routes, "safetyNetRoots") < 0) guard.list.append(routes, "safetyNetRoots");
|
|
518
|
+
return routes.length ? intrinsic.join(routes, "+") : null;
|
|
447
519
|
}
|
|
448
520
|
|
|
449
521
|
function _assertBool(v, name) {
|
|
@@ -456,8 +528,11 @@ function _applyBindings(authData, coseKey, opts) {
|
|
|
456
528
|
if (typeof opts.expectedRpId !== "string" || !opts.expectedRpId.length) {
|
|
457
529
|
throw _err("webauthn/bad-input", "opts.expectedRpId must be a non-empty RP ID string");
|
|
458
530
|
}
|
|
459
|
-
|
|
460
|
-
|
|
531
|
+
/** @internal Both operands of the RP ID comparison are built with captured operations. This is
|
|
532
|
+
* what decides the response was produced for the relying party the caller named, so an operation
|
|
533
|
+
* answering with bytes of its own matches a response made for another party. */
|
|
534
|
+
if (!guard.crypto.constantTimeEqual(_sha("sha256", intrinsic.bufferFrom(opts.expectedRpId, "utf8")),
|
|
535
|
+
intrinsic.bufferFrom(authData.rpIdHash))) {
|
|
461
536
|
throw _err("webauthn/rp-id-mismatch",
|
|
462
537
|
"the authenticatorData rpIdHash is not SHA-256 of opts.expectedRpId, so this response was " +
|
|
463
538
|
"produced for a different relying party (WebAuthn sec. 7.1 step 13 / sec. 7.2 step 15)");
|
|
@@ -482,7 +557,9 @@ function _applyBindings(authData, coseKey, opts) {
|
|
|
482
557
|
}
|
|
483
558
|
checked.userVerification = opts.requireUserVerification;
|
|
484
559
|
}
|
|
485
|
-
|
|
560
|
+
/** @internal The SHA-1 opt-in is read through captures. This membership test ADMITS COSE -65535, so
|
|
561
|
+
* a test answering that -65535 is present waives the refusal for a caller who never named it. */
|
|
562
|
+
var alg65535Allowed = intrinsic.isArray(opts.allowedAlgorithms) && intrinsic.ownIndexOf(opts.allowedAlgorithms, -65535) !== -1;
|
|
486
563
|
if (coseKey && coseKey.alg === -65535 && !alg65535Allowed) {
|
|
487
564
|
throw _err("webauthn/algorithm-not-allowed",
|
|
488
565
|
"the credential public key declares COSE algorithm -65535 (RSASSA-PKCS1-v1_5 with SHA-1); every " +
|
|
@@ -490,12 +567,17 @@ function _applyBindings(authData, coseKey, opts) {
|
|
|
490
567
|
"names -65535 explicitly");
|
|
491
568
|
}
|
|
492
569
|
if (opts.allowedAlgorithms !== undefined) {
|
|
493
|
-
|
|
494
|
-
|
|
570
|
+
/** @internal The entries are required to be the list's OWN. A hole answers from the prototype at
|
|
571
|
+
* that index, and the universal test skips it, so a sparse list with a number installed on
|
|
572
|
+
* `Array.prototype` passes this door and then admits that algorithm below. */
|
|
573
|
+
if (!intrinsic.isArray(opts.allowedAlgorithms) || !opts.allowedAlgorithms.length ||
|
|
574
|
+
!intrinsic.everyOwn(opts.allowedAlgorithms, function (a) { return typeof a === "number" && intrinsic.isSafeInteger(a); })) {
|
|
495
575
|
throw _err("webauthn/bad-input", "opts.allowedAlgorithms must be a non-empty array of COSE algorithm integers");
|
|
496
576
|
}
|
|
497
577
|
var alg = coseKey && coseKey.alg;
|
|
498
|
-
|
|
578
|
+
/** @internal The membership test comes from the capture. It REFUSES an algorithm the caller did
|
|
579
|
+
* not name, so one answering that any algorithm is present admits every algorithm. */
|
|
580
|
+
if (intrinsic.ownIndexOf(opts.allowedAlgorithms, alg) === -1) {
|
|
499
581
|
throw _err("webauthn/algorithm-not-allowed",
|
|
500
582
|
"the credential public key declares COSE algorithm " + alg + ", which is not in opts.allowedAlgorithms");
|
|
501
583
|
}
|
|
@@ -513,7 +595,10 @@ function _readX5c(attStmt) {
|
|
|
513
595
|
var x5cN = cbor.read.mapGet(attStmt, "x5c");
|
|
514
596
|
if (!x5cN || x5cN.majorType !== 4 || !x5cN.children || !x5cN.children.length) throw _err("webauthn/bad-att-stmt", "x5c must be a non-empty array of certificates");
|
|
515
597
|
_requireX5cCount(x5cN.children.length);
|
|
516
|
-
|
|
598
|
+
/** @internal The walk over the x5c entries is captured. It produces the chain the attestation is
|
|
599
|
+
* verified against, so a walk answering with certificates of its own replaces the chain whose
|
|
600
|
+
* signatures are then checked. */
|
|
601
|
+
return intrinsic.mapList(x5cN.children, function (c) {
|
|
517
602
|
var der;
|
|
518
603
|
try { der = cbor.read.byteString(c); } catch (e) { throw _err("webauthn/bad-att-stmt", "an x5c entry must be a byte string", e); }
|
|
519
604
|
try { return x509.parse(der); } catch (e) { throw _err("webauthn/bad-att-stmt", "an x5c certificate is not a well-formed X.509 certificate", e); }
|
|
@@ -530,11 +615,15 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
530
615
|
var isX5c = !!cbor.read.mapGet(att.attStmt, "x5c");
|
|
531
616
|
_requireAttShape(att.attStmt, isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"], isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"]);
|
|
532
617
|
var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
|
|
533
|
-
|
|
618
|
+
/** @internal The signed input is assembled with the captured operation. Every verifier below does
|
|
619
|
+
* the same, because the assembly decides WHICH bytes the signature is checked over: an operation
|
|
620
|
+
* answering with bytes the attestation statement does carry a signature for verifies a statement
|
|
621
|
+
* that was never made about this ceremony's authenticatorData and client data. */
|
|
622
|
+
var message = intrinsic.bufferConcat([att.authDataBytes, clientDataHash]);
|
|
534
623
|
if (isX5c) {
|
|
535
624
|
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
536
625
|
_checkPackedCert(leaf);
|
|
537
|
-
return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err)
|
|
626
|
+
return intrinsic.chain(_verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err), function (ok) {
|
|
538
627
|
if (!ok) throw _err("webauthn/verify-failed", "the packed attestation signature does not verify under the x5c leaf key");
|
|
539
628
|
_checkAaguidExt(leaf, att.authData.aaguid);
|
|
540
629
|
return _result("packed", "Basic", chain, att);
|
|
@@ -542,7 +631,7 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
542
631
|
}
|
|
543
632
|
if (alg !== att.authData.credentialPublicKey.alg) throw _err("webauthn/bad-att-stmt", "the packed self-attestation alg does not match the credential public key algorithm (WebAuthn 8.2)");
|
|
544
633
|
var spki = _coseKeyToSpki(att.authData.credentialPublicKey);
|
|
545
|
-
return _verifySig(alg, sig, spki, message, _err)
|
|
634
|
+
return intrinsic.chain(_verifySig(alg, sig, spki, message, _err), function (ok) {
|
|
546
635
|
if (!ok) throw _err("webauthn/verify-failed", "the packed self-attestation signature does not verify under the credential key");
|
|
547
636
|
return _result("packed", "Self", [], att);
|
|
548
637
|
});
|
|
@@ -558,9 +647,9 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
558
647
|
if (key.alg !== -7 || key.kty !== 2 || key.crv !== 1 || !key.x || !key.y || key.x.length !== 32 || key.y.length !== 32) {
|
|
559
648
|
throw _err("webauthn/bad-att-stmt", "fido-u2f requires an ES256 (-7) EC2 P-256 credential public key (WebAuthn 8.6)");
|
|
560
649
|
}
|
|
561
|
-
var publicKeyU2F =
|
|
562
|
-
var verificationData =
|
|
563
|
-
return _verifySig(-7, sig, leaf.subjectPublicKeyInfo.bytes, verificationData, _err)
|
|
650
|
+
var publicKeyU2F = intrinsic.bufferConcat([intrinsic.bufferFrom([0x04]), key.x, key.y]);
|
|
651
|
+
var verificationData = intrinsic.bufferConcat([intrinsic.bufferFrom([0x00]), att.authData.rpIdHash, clientDataHash, att.authData.credentialId, publicKeyU2F]);
|
|
652
|
+
return intrinsic.chain(_verifySig(-7, sig, leaf.subjectPublicKeyInfo.bytes, verificationData, _err), function (ok) {
|
|
564
653
|
if (!ok) throw _err("webauthn/verify-failed", "the fido-u2f attestation signature does not verify under the x5c leaf key");
|
|
565
654
|
return _result("fido-u2f", "Basic", chain, att);
|
|
566
655
|
});
|
|
@@ -570,11 +659,11 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
570
659
|
_requireAttShape(att.attStmt, ["alg", "x5c"], ["x5c"]);
|
|
571
660
|
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
572
661
|
_requireV3(leaf);
|
|
573
|
-
var nonce = _sha("sha256",
|
|
662
|
+
var nonce = _sha("sha256", intrinsic.bufferConcat([att.authDataBytes, clientDataHash]));
|
|
574
663
|
var ext = _findExt(leaf, "appleAnonymousAttestation");
|
|
575
664
|
if (!ext) throw _err("webauthn/bad-att-cert", "the apple attestation certificate is missing the anonymous-attestation extension (WebAuthn 8.8)");
|
|
576
665
|
var embedded = _appleNonce(ext.value);
|
|
577
|
-
if (!
|
|
666
|
+
if (!intrinsic.bufferEquals(embedded, nonce)) throw _err("webauthn/verify-failed", "the apple attestation nonce does not equal SHA-256(authData || clientDataHash)");
|
|
578
667
|
_certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err);
|
|
579
668
|
return _promiseResolve(_Promise, _result("apple", "AnonCA", chain, att));
|
|
580
669
|
},
|
|
@@ -584,8 +673,8 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
584
673
|
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
585
674
|
_requireV3(leaf);
|
|
586
675
|
var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
|
|
587
|
-
var message =
|
|
588
|
-
return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err)
|
|
676
|
+
var message = intrinsic.bufferConcat([att.authDataBytes, clientDataHash]);
|
|
677
|
+
return intrinsic.chain(_verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err), function (ok) {
|
|
589
678
|
if (!ok) throw _err("webauthn/verify-failed", "the android-key attestation signature does not verify under the x5c leaf key");
|
|
590
679
|
_certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err);
|
|
591
680
|
_checkAndroidKeyDescription(leaf, clientDataHash);
|
|
@@ -594,7 +683,7 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
594
683
|
},
|
|
595
684
|
|
|
596
685
|
tpm: function (att, clientDataHash, opts) {
|
|
597
|
-
var tpmPolicy = validator.tpm.normalizeObjectAttributePolicy((opts ||
|
|
686
|
+
var tpmPolicy = validator.tpm.normalizeObjectAttributePolicy((opts || intrinsic.create(null)).tpmPolicy, WebauthnError, "webauthn/bad-input");
|
|
598
687
|
_requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
|
|
599
688
|
var verN = cbor.read.mapGet(att.attStmt, "ver");
|
|
600
689
|
if (!verN || verN.majorType !== 3 || cbor.read.textString(verN) !== "2.0") throw _err("webauthn/bad-att-stmt", "tpm attestation 'ver' MUST be \"2.0\" (WebAuthn 8.3)");
|
|
@@ -608,15 +697,18 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
608
697
|
|
|
609
698
|
var certInfo = _parseTpmCertInfo(certInfoBytes);
|
|
610
699
|
|
|
611
|
-
var attToBeSigned =
|
|
612
|
-
|
|
700
|
+
var attToBeSigned = intrinsic.bufferConcat([att.authDataBytes, clientDataHash]);
|
|
701
|
+
/** @internal Both TPM comparisons go through the capture. `extraData` is what binds the quote to
|
|
702
|
+
* this ceremony and the attested Name is what binds it to the pubArea key, so one answering true
|
|
703
|
+
* accepts a genuine TPM quote taken over another ceremony or over a different key. */
|
|
704
|
+
if (!intrinsic.bufferEquals(certInfo.extraData, _sha(_coseAlgHash(alg, _err), attToBeSigned))) throw _err("webauthn/verify-failed", "certInfo extraData does not equal the hash of authData || clientDataHash");
|
|
613
705
|
|
|
614
706
|
var nameHash = validator.tpm.TPM_ALG_HASH[pub.nameAlg];
|
|
615
|
-
if (!nameHash) throw _err("webauthn/bad-tpm", "unsupported TPM nameAlg 0x" + pub.nameAlg
|
|
616
|
-
var computedName =
|
|
617
|
-
if (!certInfo.attestedName
|
|
707
|
+
if (!nameHash) throw _err("webauthn/bad-tpm", "unsupported TPM nameAlg 0x" + intrinsic.numberToString(pub.nameAlg, 16));
|
|
708
|
+
var computedName = intrinsic.bufferConcat([pub.nameAlgBytes, _sha(nameHash, pubAreaBytes)]);
|
|
709
|
+
if (!intrinsic.bufferEquals(certInfo.attestedName, computedName)) throw _err("webauthn/verify-failed", "certInfo attested Name does not match the pubArea TPM Name");
|
|
618
710
|
|
|
619
|
-
return _verifySig(alg, sig, aik.subjectPublicKeyInfo.bytes, certInfoBytes, _err)
|
|
711
|
+
return intrinsic.chain(_verifySig(alg, sig, aik.subjectPublicKeyInfo.bytes, certInfoBytes, _err), function (ok) {
|
|
620
712
|
if (!ok) throw _err("webauthn/verify-failed", "the tpm attestation signature does not verify over certInfo under the AIK");
|
|
621
713
|
_checkAikCert(aik);
|
|
622
714
|
_checkAaguidExt(aik, att.authData.aaguid);
|
|
@@ -627,12 +719,12 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
627
719
|
},
|
|
628
720
|
|
|
629
721
|
"android-safetynet": function (att, clientDataHash, opts) {
|
|
630
|
-
opts = opts ||
|
|
722
|
+
opts = opts || intrinsic.create(null);
|
|
631
723
|
if (opts.verifySafetyNetJws !== true) {
|
|
632
724
|
throw _err("webauthn/unsupported-format", "attestation statement format 'android-safetynet' is not supported");
|
|
633
725
|
}
|
|
634
726
|
var roots = opts.safetyNetRoots;
|
|
635
|
-
if (!
|
|
727
|
+
if (!intrinsic.isArray(roots) || roots.length === 0) {
|
|
636
728
|
throw _err("webauthn/safetynet-no-root", "verifying an android-safetynet attestation requires opts.safetyNetRoots -- the Google root(s) to anchor the x5c chain to; this library bundles none (WebAuthn 8.5)");
|
|
637
729
|
}
|
|
638
730
|
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
@@ -642,24 +734,26 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
642
734
|
var responseBytes = _attRead(att.attStmt, "response", cbor.read.byteString, "a byte string");
|
|
643
735
|
if (!responseBytes.length) throw _err("webauthn/bad-att-stmt", "the android-safetynet response is empty");
|
|
644
736
|
|
|
645
|
-
|
|
737
|
+
/** @internal The split that divides the JWS is captured: it decides which bytes are the signed
|
|
738
|
+
* input and which are the signature, so a replacement chooses what the verification covers. */
|
|
739
|
+
var segs = intrinsic.splitChar(guard.text.decode(responseBytes, constants.LIMITS.SAFETYNET_JWS_MAX_BYTES, WebauthnError, "webauthn/bad-att-stmt", "the android-safetynet response"), 46);
|
|
646
740
|
if (segs.length !== 3) throw _err("webauthn/bad-att-stmt", "the android-safetynet response is not a three-part JWS compact serialization (RFC 7515 sec. 3.1)");
|
|
647
741
|
var header, payload, sigBytes;
|
|
648
742
|
try {
|
|
649
743
|
header = jose.parseJson(jose.base64url.decode(segs[0]));
|
|
650
744
|
payload = jose.parseJson(jose.base64url.decode(segs[1]));
|
|
651
|
-
sigBytes =
|
|
745
|
+
sigBytes = intrinsic.bufferFrom(jose.base64url.decode(segs[2]));
|
|
652
746
|
} catch (e) { throw _err("webauthn/bad-att-stmt", "the android-safetynet response is not a decodable JWS", e); }
|
|
653
747
|
if (!_isPlainObject(header) || !_isPlainObject(payload)) {
|
|
654
748
|
throw _err("webauthn/bad-att-stmt", "the android-safetynet JWS header and payload must each be a JSON object");
|
|
655
749
|
}
|
|
656
750
|
|
|
657
|
-
if (header.alg !== "RS256") throw _err("webauthn/unsupported-algorithm", "the android-safetynet JWS alg must be RS256, got " +
|
|
658
|
-
if (!
|
|
751
|
+
if (header.alg !== "RS256") throw _err("webauthn/unsupported-algorithm", "the android-safetynet JWS alg must be RS256, got " + intrinsic.stringify(header.alg));
|
|
752
|
+
if (!intrinsic.isArray(header.x5c) || header.x5c.length === 0) {
|
|
659
753
|
throw _err("webauthn/bad-att-stmt", "the android-safetynet JWS header carries no x5c certificate chain (RFC 7515 sec. 4.1.6)");
|
|
660
754
|
}
|
|
661
755
|
_requireX5cCount(header.x5c.length);
|
|
662
|
-
var chain = header.x5c
|
|
756
|
+
var chain = intrinsic.mapList(header.x5c, function (entry, i) {
|
|
663
757
|
if (typeof entry !== "string") throw _err("webauthn/bad-att-stmt", "the android-safetynet x5c entry " + i + " is not a string");
|
|
664
758
|
var der;
|
|
665
759
|
try { der = guard.encoding.base64(entry, constants.LIMITS.SAFETYNET_CERT_MAX_BYTES, _err, "webauthn/bad-att-stmt", "an android-safetynet x5c entry"); }
|
|
@@ -669,8 +763,13 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
669
763
|
});
|
|
670
764
|
var leaf = chain[0];
|
|
671
765
|
|
|
672
|
-
|
|
673
|
-
|
|
766
|
+
/** @internal The digest is taken with the captured hash, and the encoding that turns it back into
|
|
767
|
+
* text is captured too, along with the construction of both comparison operands. The nonce is what
|
|
768
|
+
* binds this response to this ceremony's authenticatorData and clientDataHash, so an operation that
|
|
769
|
+
* chooses either operand makes a correctly signed response replay against different data. */
|
|
770
|
+
var wantNonce = intrinsic.bufToString(_sha("sha256", intrinsic.bufferConcat([att.authDataBytes, clientDataHash])), "base64");
|
|
771
|
+
if (typeof payload.nonce !== "string" || !guard.crypto.constantTimeEqual(
|
|
772
|
+
intrinsic.bufferFrom(payload.nonce, "utf8"), intrinsic.bufferFrom(wantNonce, "utf8"))) {
|
|
674
773
|
throw _err("webauthn/safetynet-nonce-mismatch", "the android-safetynet nonce does not bind this authenticatorData and clientDataHash (WebAuthn 8.5)");
|
|
675
774
|
}
|
|
676
775
|
|
|
@@ -685,17 +784,18 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
685
784
|
apkCertificateDigestSha256: payload.apkCertificateDigestSha256, advice: payload.advice,
|
|
686
785
|
};
|
|
687
786
|
if (opts.requireCtsProfileMatch === true && signals.ctsProfileMatch !== true) {
|
|
688
|
-
throw _err("webauthn/safetynet-cts-profile", "the android-safetynet response reports ctsProfileMatch " +
|
|
787
|
+
throw _err("webauthn/safetynet-cts-profile", "the android-safetynet response reports ctsProfileMatch " + intrinsic.stringify(signals.ctsProfileMatch) + ", and opts.requireCtsProfileMatch demands true");
|
|
689
788
|
}
|
|
690
789
|
|
|
691
|
-
|
|
692
|
-
|
|
790
|
+
var jwsTrusted = intrinsic.chain(_verifySig(-257, sigBytes, leaf.subjectPublicKeyInfo.bytes,
|
|
791
|
+
intrinsic.bufferFrom(segs[0] + "." + segs[1], "ascii"), _err), function (ok) {
|
|
693
792
|
if (!ok) throw _err("webauthn/verify-failed", "the android-safetynet JWS signature does not verify under the x5c leaf key");
|
|
694
793
|
chainAt = opts.time !== undefined ? opts.time
|
|
695
|
-
: (typeof payload.timestampMs === "number" && isFinite(payload.timestampMs) && payload.timestampMs > 0
|
|
696
|
-
? new Date(payload.timestampMs) : undefined);
|
|
794
|
+
: (typeof payload.timestampMs === "number" && intrinsic.isFinite(payload.timestampMs) && payload.timestampMs > 0
|
|
795
|
+
? new intrinsic.Date(payload.timestampMs) : undefined);
|
|
697
796
|
return _safetyNetChainTrusted(chain, roots, chainAt);
|
|
698
|
-
})
|
|
797
|
+
});
|
|
798
|
+
return intrinsic.chain(jwsTrusted, function () {
|
|
699
799
|
var res = guard.verdict.of(_result("android-safetynet", "Basic", chain, att), { safetyNet: signals });
|
|
700
800
|
if (chainAt !== undefined) res = guard.verdict.of(res, { chainValidatedAt: chainAt });
|
|
701
801
|
return res;
|
|
@@ -708,7 +808,11 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
708
808
|
if (kids.length > constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS) {
|
|
709
809
|
throw _err("webauthn/bad-att-stmt", "a compound attestation statement carries " + kids.length + " nested statements, above the " + constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS + " this toolkit will verify");
|
|
710
810
|
}
|
|
711
|
-
|
|
811
|
+
/** @internal The walk that builds the element list and the fold that verifies them are both
|
|
812
|
+
* captured. A walk that drops an element leaves it unverified while the compound verdict still
|
|
813
|
+
* reports, and a fold that returns its seed without visiting an element answers for a statement
|
|
814
|
+
* nothing checked. */
|
|
815
|
+
var elements = intrinsic.mapList(kids, function (el, i) {
|
|
712
816
|
if (!el || el.majorType !== 5) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be a CBOR map { fmt, attStmt } (WebAuthn 8.9)");
|
|
713
817
|
var fN = cbor.read.mapGet(el, "fmt"), sN = cbor.read.mapGet(el, "attStmt");
|
|
714
818
|
if (el.children.length !== 2 || !fN || !sN) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be exactly { fmt, attStmt } (WebAuthn 8.9)");
|
|
@@ -722,14 +826,16 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
722
826
|
return { fmt: f, index: i, att: { fmt: f, attStmt: sN, authData: att.authData, authDataBytes: att.authDataBytes } };
|
|
723
827
|
});
|
|
724
828
|
var out = [];
|
|
725
|
-
|
|
726
|
-
return
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
829
|
+
var allVerified = intrinsic.reduce(elements, function (p, e) {
|
|
830
|
+
return intrinsic.chain(p, function () {
|
|
831
|
+
var ran = intrinsic.chain(_promiseResolve(_Promise, undefined),
|
|
832
|
+
function () { return VERIFIERS[e.fmt](e.att, clientDataHash, opts); });
|
|
833
|
+
return intrinsic.chain(ran, function (r) { guard.list.append(out, r); }, function (err) {
|
|
834
|
+
throw _err("webauthn/compound-element-failed", "compound element " + e.index + " (format '" + e.fmt + "') did not verify", err);
|
|
835
|
+
});
|
|
731
836
|
});
|
|
732
|
-
}, _promiseResolve(_Promise, undefined))
|
|
837
|
+
}, _promiseResolve(_Promise, undefined));
|
|
838
|
+
return intrinsic.chain(allVerified, function () {
|
|
733
839
|
return guard.verdict.of(_result("compound", "Compound", [], att), { compound: out });
|
|
734
840
|
});
|
|
735
841
|
},
|
|
@@ -742,17 +848,40 @@ var VERIFIERS = intrinsic.assign(intrinsic.create(null), {
|
|
|
742
848
|
},
|
|
743
849
|
});
|
|
744
850
|
|
|
745
|
-
function _isPlainObject(v) { return !!v && typeof v === "object" && !
|
|
851
|
+
function _isPlainObject(v) { return !!v && typeof v === "object" && !intrinsic.isArray(v); }
|
|
746
852
|
|
|
853
|
+
/** @internal The case fold comes from the load-time capture, not the live prototype. This is an
|
|
854
|
+
* ADMISSION gate: it answers `true` when the leaf is issued to the one name WebAuthn 8.5 requires, so
|
|
855
|
+
* a replacement that answers with that name admits ANY leaf. Measured, with the fold replaced for the
|
|
856
|
+
* leaf's own name alone, `pki.webauthn.verify` ACCEPTED an android-safetynet attestation whose x5c
|
|
857
|
+
* leaf names `other.example`. Replacing the fold for every string instead breaks an earlier step and
|
|
858
|
+
* hides it, which is why the vector narrows the replacement to one value. */
|
|
747
859
|
function _safetyNetHostnameOk(leaf) {
|
|
748
860
|
var want = "attest.android.com";
|
|
749
861
|
var san = _decodeExt(leaf, "subjectAltName");
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
862
|
+
/** @internal The entries come from the decoded record's `names`, and a dNSName is GeneralName
|
|
863
|
+
* `[2]`, which is how `path-validate` and `identity-match` read the same structure. Read as a bare
|
|
864
|
+
* array under a `type` field, this branch selected nothing at all: the decoder returns
|
|
865
|
+
* `{ names, bytes }` and each entry carries `tagNumber`, so every leaf fell through to the
|
|
866
|
+
* commonName fallback. That accepted a leaf whose authoritative SAN named another host while its
|
|
867
|
+
* commonName named the wanted one, and refused a SAN-only leaf whose commonName differed. */
|
|
868
|
+
var entries = (san && san.value && intrinsic.isArray(san.value.names)) ? san.value.names : [];
|
|
869
|
+
/** @internal The WALKS are captured as well as the fold. The fold alone is not the gate: a replaced
|
|
870
|
+
* `some` answering `true` passes the requirement whatever the names compare as, and a selection
|
|
871
|
+
* that drops every dNSName entry sends this down the commonName fallback instead. Both reach the
|
|
872
|
+
* same `true` this function exists to withhold. */
|
|
873
|
+
var dns = intrinsic.selectList(entries, function (gn) { return gn && gn.tagNumber === 2 && typeof gn.value === "string"; });
|
|
874
|
+
/** @internal A subjectAltName that is PRESENT is authoritative, whichever name forms it carries,
|
|
875
|
+
* so the commonName is consulted only for a leaf that carries no such extension at all. Gating the
|
|
876
|
+
* fallback on "no dNSName was selected" instead let a leaf whose SAN holds only a URI or an
|
|
877
|
+
* address fall through to its commonName, and a rooted leaf naming another host in the one place
|
|
878
|
+
* that binds it was accepted on a commonName that read `attest.android.com`. A leaf with no SAN is
|
|
879
|
+
* still read by commonName, which is how the service's own certificates were issued. */
|
|
880
|
+
if (san) return intrinsic.some(dns, function (gn) { return intrinsic.toLowerCase(gn.value) === want; });
|
|
881
|
+
return intrinsic.some(leaf.subject.rdns, function (rdn) {
|
|
882
|
+
return intrinsic.some(rdn, function (atv) {
|
|
883
|
+
return atv.name === "commonName" && typeof atv.value === "string" &&
|
|
884
|
+
intrinsic.toLowerCase(atv.value) === want;
|
|
756
885
|
});
|
|
757
886
|
});
|
|
758
887
|
}
|
|
@@ -760,7 +889,7 @@ function _safetyNetHostnameOk(leaf) {
|
|
|
760
889
|
function _safetyNetChainTrusted(chain, roots, time) {
|
|
761
890
|
var anchors;
|
|
762
891
|
try {
|
|
763
|
-
anchors =
|
|
892
|
+
anchors = intrinsic.mapList(roots, function (root, i) {
|
|
764
893
|
var label = "opts.safetyNetRoots[" + i + "]";
|
|
765
894
|
return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
|
|
766
895
|
try {
|
|
@@ -769,14 +898,14 @@ function _safetyNetChainTrusted(chain, roots, time) {
|
|
|
769
898
|
}, _err, "webauthn/bad-input", label);
|
|
770
899
|
});
|
|
771
900
|
} catch (e) { return _promiseReject(_Promise, e); }
|
|
772
|
-
return mds.chainToAnchor(chain, anchors, time === undefined ? new Date() : time,
|
|
901
|
+
return mds.chainToAnchor(chain, anchors, time === undefined ? new intrinsic.Date() : time,
|
|
773
902
|
"android-safetynet x5c certificate chain", "webauthn/safetynet-cert-untrusted");
|
|
774
903
|
}
|
|
775
904
|
|
|
776
905
|
function _result(fmt, attestationType, chain, att) {
|
|
777
906
|
return guard.verdict.of({
|
|
778
907
|
valid: true, attestationVerified: true, fmt: fmt, attestationType: attestationType,
|
|
779
|
-
trustPath: _reverse(
|
|
908
|
+
trustPath: _reverse(intrinsic.copyList(chain || [])),
|
|
780
909
|
aaguid: att.authData.aaguid,
|
|
781
910
|
credentialId: att.authData.credentialId,
|
|
782
911
|
credentialPublicKey: att.authData.credentialPublicKey,
|
|
@@ -920,13 +1049,18 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
920
1049
|
!_isBufferSource(clientDataHash) && _isPlainObject(clientDataHash)) {
|
|
921
1050
|
opts = clientDataHash; clientDataHash = undefined;
|
|
922
1051
|
}
|
|
923
|
-
|
|
1052
|
+
/** @internal A caller who passes no options gets a record with NO prototype. Defaulted to `{}` it
|
|
1053
|
+
* inherits, so a property installed on `Object.prototype` reads as an option this call never set. */
|
|
1054
|
+
opts = opts || intrinsic.create(null);
|
|
924
1055
|
try {
|
|
925
1056
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
926
1057
|
guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
927
1058
|
opts = guard.identifier.ownOptions(opts);
|
|
928
1059
|
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
929
|
-
|
|
1060
|
+
/** @internal The walk comes from the capture. Each pass of it is a REFUSAL of a mistyped option,
|
|
1061
|
+
* and a mistyped one that survives is read for truthiness later where the rule wants exactly
|
|
1062
|
+
* `true`, so a walk that visits nothing admits `"true"` as the opt-in. */
|
|
1063
|
+
intrinsic.forEach(_FORMAT_SCOPED_BOOLEAN_OPTS, function (k) {
|
|
930
1064
|
if (opts[k] !== undefined && typeof opts[k] !== "boolean") throw _err("webauthn/bad-input", "opts." + k + " must be a boolean");
|
|
931
1065
|
});
|
|
932
1066
|
} catch (e) { return _promiseReject(_Promise, e); }
|
|
@@ -979,27 +1113,30 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
979
1113
|
vopts.rootCertificates = _snapshotRoots(opts.rootCertificates);
|
|
980
1114
|
vopts.safetyNetRoots = _snapshotRoots(opts.safetyNetRoots);
|
|
981
1115
|
} catch (e) { return _promiseReject(_Promise, e); }
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
})
|
|
986
|
-
|
|
1116
|
+
var ranVerifier = intrinsic.chain(_promiseResolve(_Promise, undefined),
|
|
1117
|
+
function () { return verifier(att, clientDataHash, vopts); });
|
|
1118
|
+
var withClientData = intrinsic.chain(ranVerifier, function (res) {
|
|
1119
|
+
return guard.verdict.of(res, { clientData: clientData });
|
|
1120
|
+
});
|
|
1121
|
+
var anchoredRes = intrinsic.chain(withClientData, function (res) {
|
|
987
1122
|
if (vopts.metadata === undefined) {
|
|
988
1123
|
if (vopts.rootCertificates !== undefined) return _applyCallerRoots(res, vopts.rootCertificates, vopts);
|
|
989
1124
|
var anchored = guard.verdict.of(res, { anchoredTo: _anchoredRoutes(res, null) });
|
|
990
1125
|
var sn = _safetyNetAnchored(anchored);
|
|
991
1126
|
if (sn) {
|
|
992
1127
|
anchored = guard.verdict.of(anchored, { anchoredElements: {
|
|
993
|
-
total: (anchored.fmt === "compound" &&
|
|
1128
|
+
total: (anchored.fmt === "compound" && intrinsic.isArray(anchored.compound)) ? anchored.compound.length : 1,
|
|
994
1129
|
anchored: sn } });
|
|
995
1130
|
}
|
|
996
1131
|
return anchored;
|
|
997
1132
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1133
|
+
var applyMd = intrinsic.chain(_promiseResolve(_Promise, undefined),
|
|
1134
|
+
function () { return _applyMetadata(res, att, vopts); });
|
|
1135
|
+
return intrinsic.chain(applyMd,
|
|
1136
|
+
function (out) { return guard.verdict.of(out, { anchoredTo: _anchoredRoutes(out, "metadata") }); }, function (e) {
|
|
1000
1137
|
if (!e || e.code !== "webauthn/metadata-not-found" || vopts.rootCertificates === undefined) throw e;
|
|
1001
|
-
return _promiseResolve(_Promise, _applyCallerRoots(res, vopts.rootCertificates, vopts, e.missedPaths))
|
|
1002
|
-
|
|
1138
|
+
return intrinsic.chain(_promiseResolve(_Promise, _applyCallerRoots(res, vopts.rootCertificates, vopts, e.missedPaths)),
|
|
1139
|
+
function (withRoots) {
|
|
1003
1140
|
/** @internal The element coverage the metadata attempt measured is the whole
|
|
1004
1141
|
* statement's, so it travels on the error rather than being recomputed from the
|
|
1005
1142
|
* fallback's own paths. */
|
|
@@ -1009,13 +1146,13 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1009
1146
|
var primary = applied[0];
|
|
1010
1147
|
var withMd = guard.verdict.of(out, { metadata: { aaguid: primary.entry.aaguid,
|
|
1011
1148
|
keyIdentifiers: primary.entry.keyIdentifiers || [],
|
|
1012
|
-
entry: primary.entry, entries:
|
|
1149
|
+
entry: primary.entry, entries: intrinsic.mapList(applied, function (a) { return a.entry; }),
|
|
1013
1150
|
anchors: primary.anchors.length } });
|
|
1014
1151
|
return guard.verdict.of(withMd, { anchoredTo: _anchoredRoutes(withMd, "metadata+rootCertificates") });
|
|
1015
1152
|
});
|
|
1016
1153
|
});
|
|
1017
|
-
|
|
1018
|
-
|
|
1154
|
+
});
|
|
1155
|
+
return intrinsic.chain(anchoredRes, function (res) { return guard.verdict.of(res, { bindingChecked: bindingChecked }); });
|
|
1019
1156
|
}
|
|
1020
1157
|
|
|
1021
1158
|
function _applyMetadata(res, att, opts) {
|
|
@@ -1023,12 +1160,12 @@ function _applyMetadata(res, att, opts) {
|
|
|
1023
1160
|
if (!mds.isVerifiedResult(md)) {
|
|
1024
1161
|
throw _err("webauthn/bad-input", "opts.metadata must be a pki.webauthn.verifyMetadataBlob result, not a raw BLOB");
|
|
1025
1162
|
}
|
|
1026
|
-
var paths = (res.fmt === "compound" &&
|
|
1027
|
-
? res.compound
|
|
1028
|
-
|
|
1163
|
+
var paths = (res.fmt === "compound" && intrinsic.isArray(res.compound))
|
|
1164
|
+
? intrinsic.mapList(intrinsic.selectList(res.compound, function (el) { return el.trustPath && el.trustPath.length; }),
|
|
1165
|
+
function (el) { return { tp: el.trustPath, fmt: el.fmt, at: el.chainValidatedAt }; })
|
|
1029
1166
|
: (res.trustPath && res.trustPath.length ? [{ tp: res.trustPath, fmt: res.fmt, at: res.chainValidatedAt }] : []);
|
|
1030
1167
|
res = guard.verdict.of(res, { anchoredElements: {
|
|
1031
|
-
total: (res.fmt === "compound" &&
|
|
1168
|
+
total: (res.fmt === "compound" && intrinsic.isArray(res.compound)) ? res.compound.length : 1,
|
|
1032
1169
|
anchored: paths.length } });
|
|
1033
1170
|
if (!paths.length) {
|
|
1034
1171
|
throw _err("webauthn/metadata-not-applicable", "opts.metadata was supplied, but this attestation carries no trust path to anchor (format '" + res.fmt + "')");
|
|
@@ -1056,7 +1193,7 @@ function _applyMetadata(res, att, opts) {
|
|
|
1056
1193
|
guard.list.append(applied, { entry: entry, anchors: anchors, identifier: identifier });
|
|
1057
1194
|
return { anchors: anchors, identifier: identifier };
|
|
1058
1195
|
}
|
|
1059
|
-
var at = opts.time !== undefined ? opts.time : new Date();
|
|
1196
|
+
var at = opts.time !== undefined ? opts.time : new intrinsic.Date();
|
|
1060
1197
|
mds.assertFresh(md, at, "the metadata supplied as opts.metadata");
|
|
1061
1198
|
var governed = [];
|
|
1062
1199
|
var missed = null;
|
|
@@ -1074,20 +1211,21 @@ function _applyMetadata(res, att, opts) {
|
|
|
1074
1211
|
guard.verdict.set(missed, "appliedEntries", applied);
|
|
1075
1212
|
guard.verdict.set(missed, "anchoredElements", res.anchoredElements);
|
|
1076
1213
|
}
|
|
1077
|
-
|
|
1078
|
-
return
|
|
1214
|
+
var governedAnchored = intrinsic.reduce(governed, function (p, item) {
|
|
1215
|
+
return intrinsic.chain(p, function () {
|
|
1079
1216
|
var g = item.g, info = item.info;
|
|
1080
1217
|
var pathAt = opts.time !== undefined ? opts.time : (info.at || at);
|
|
1081
|
-
return mds.chainToAnchor(_reverse(
|
|
1218
|
+
return mds.chainToAnchor(_reverse(intrinsic.copyList(info.tp)), g.anchors, pathAt,
|
|
1082
1219
|
"attestation trust path for " + g.identifier + " (against the roots its metadata entry registers)");
|
|
1083
1220
|
});
|
|
1084
|
-
}, _promiseResolve(_Promise, undefined))
|
|
1085
|
-
|
|
1221
|
+
}, _promiseResolve(_Promise, undefined));
|
|
1222
|
+
return intrinsic.chain(governedAnchored,
|
|
1223
|
+
function () {
|
|
1086
1224
|
if (missed) throw missed;
|
|
1087
1225
|
var primary = applied[0];
|
|
1088
1226
|
return guard.verdict.of(res, { metadata: { aaguid: primary.entry.aaguid,
|
|
1089
1227
|
keyIdentifiers: primary.entry.keyIdentifiers || [],
|
|
1090
|
-
entry: primary.entry, entries:
|
|
1228
|
+
entry: primary.entry, entries: intrinsic.mapList(applied, function (a) { return a.entry; }),
|
|
1091
1229
|
anchors: primary.anchors.length } });
|
|
1092
1230
|
});
|
|
1093
1231
|
}
|
|
@@ -1095,7 +1233,11 @@ function _applyMetadata(res, att, opts) {
|
|
|
1095
1233
|
function _formatCarries(att, fmt) {
|
|
1096
1234
|
if (att.fmt === fmt) return true;
|
|
1097
1235
|
if (att.fmt !== "compound") return false;
|
|
1098
|
-
|
|
1236
|
+
/** @internal The membership walk is captured. It decides whether a format-scoped option applies to
|
|
1237
|
+
* a compound statement, so one answering `false` for a format the statement does carry leaves that
|
|
1238
|
+
* element's option unapplied, and one answering `true` applies an option to a statement that has
|
|
1239
|
+
* no element of that format. */
|
|
1240
|
+
return intrinsic.some(att.attStmt.children || [], function (el) {
|
|
1099
1241
|
if (!el || el.majorType !== 5) return false;
|
|
1100
1242
|
var fN = cbor.read.mapGet(el, "fmt");
|
|
1101
1243
|
return !!fN && fN.majorType === 3 && cbor.read.textString(fN) === fmt;
|
|
@@ -1234,7 +1376,7 @@ void constants;
|
|
|
1234
1376
|
*/
|
|
1235
1377
|
|
|
1236
1378
|
|
|
1237
|
-
var CLIENT_DATA_TYPE =
|
|
1379
|
+
var CLIENT_DATA_TYPE = intrinsic.assign(intrinsic.create(null), { "webauthn.create": 1, "webauthn.get": 1 });
|
|
1238
1380
|
|
|
1239
1381
|
/**
|
|
1240
1382
|
* @primitive pki.webauthn.parseClientData
|
|
@@ -1289,7 +1431,7 @@ var CLIENT_DATA_TYPE = Object.assign(Object.create(null), { "webauthn.create": 1
|
|
|
1289
1431
|
* cd.crossOrigin; // false
|
|
1290
1432
|
*/
|
|
1291
1433
|
function parseClientData(bytes, opts) {
|
|
1292
|
-
opts = opts ||
|
|
1434
|
+
opts = opts || intrinsic.create(null);
|
|
1293
1435
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1294
1436
|
guard.identifier.assertKnownKeys(opts, _CLIENT_DATA_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
1295
1437
|
/** @internal Each expected* option is read to decide whether to compare and read again for the
|
|
@@ -1298,7 +1440,7 @@ function parseClientData(bytes, opts) {
|
|
|
1298
1440
|
* the caller never supplied. Take each option once. */
|
|
1299
1441
|
opts = guard.identifier.snapshotOptions(opts, _CLIENT_DATA_OPTS);
|
|
1300
1442
|
bytes = _bytesArg(bytes, "clientDataJSON");
|
|
1301
|
-
var doc = guard.json.parse(
|
|
1443
|
+
var doc = guard.json.parse(intrinsic.bufferFrom(bytes), _err, {
|
|
1302
1444
|
maxBytes: constants.LIMITS.JSON_MAX_BYTES, maxDepth: constants.LIMITS.JSON_MAX_DEPTH,
|
|
1303
1445
|
badJson: "webauthn/bad-client-data", tooDeep: "webauthn/bad-client-data",
|
|
1304
1446
|
duplicateMember: "webauthn/bad-client-data", tooLarge: "webauthn/bad-client-data",
|
|
@@ -1307,7 +1449,7 @@ function parseClientData(bytes, opts) {
|
|
|
1307
1449
|
if (!_isPlainObject(doc)) throw _err("webauthn/bad-client-data", "clientDataJSON is not a JSON object (WebAuthn sec. 5.8.1)");
|
|
1308
1450
|
if (typeof doc.type !== "string" || CLIENT_DATA_TYPE[doc.type] !== 1) {
|
|
1309
1451
|
throw _err("webauthn/bad-client-data",
|
|
1310
|
-
"clientDataJSON type must be \"webauthn.create\" or \"webauthn.get\", got " +
|
|
1452
|
+
"clientDataJSON type must be \"webauthn.create\" or \"webauthn.get\", got " + intrinsic.stringify(doc.type) +
|
|
1311
1453
|
" (WebAuthn sec. 5.8.1)");
|
|
1312
1454
|
}
|
|
1313
1455
|
if (typeof doc.origin !== "string" || !doc.origin.length) {
|
|
@@ -1351,13 +1493,23 @@ function parseClientData(bytes, opts) {
|
|
|
1351
1493
|
checked.challenge = true;
|
|
1352
1494
|
}
|
|
1353
1495
|
if (opts.expectedOrigin !== undefined) {
|
|
1354
|
-
|
|
1355
|
-
|
|
1496
|
+
/** @internal The origin allow-list is read through captures end to end. The membership test is
|
|
1497
|
+
* what REFUSES a response produced at an origin this relying party does not accept, so one
|
|
1498
|
+
* answering that any origin is present admits a ceremony completed on another site, and the
|
|
1499
|
+
* classification above it decides whether the caller's single origin or their whole list is the
|
|
1500
|
+
* set that gets tested. */
|
|
1501
|
+
var allowed = intrinsic.isArray(opts.expectedOrigin) ? opts.expectedOrigin : [opts.expectedOrigin];
|
|
1502
|
+
/** @internal The entries are required to be the list's OWN, and the membership test reads own
|
|
1503
|
+
* indexes. A hole answers from the prototype at that index while the universal test skips it, so
|
|
1504
|
+
* a one-element sparse list carrying no origin of its own passed this door and then admitted a
|
|
1505
|
+
* response whose origin had been installed on `Array.prototype`, with the origin reported as
|
|
1506
|
+
* checked although the caller listed none. */
|
|
1507
|
+
if (!allowed.length || !intrinsic.everyOwn(allowed, function (o) { return typeof o === "string" && o.length; })) {
|
|
1356
1508
|
throw _err("webauthn/bad-input", "opts.expectedOrigin must be a non-empty origin string, or an array of them");
|
|
1357
1509
|
}
|
|
1358
|
-
if (
|
|
1510
|
+
if (intrinsic.ownIndexOf(allowed, doc.origin) === -1) {
|
|
1359
1511
|
throw _err("webauthn/client-data-mismatch",
|
|
1360
|
-
"the clientDataJSON origin " +
|
|
1512
|
+
"the clientDataJSON origin " + intrinsic.stringify(doc.origin) + " is not one this relying party accepts " +
|
|
1361
1513
|
"(WebAuthn sec. 7.1 step 10 / sec. 7.2 step 13)");
|
|
1362
1514
|
}
|
|
1363
1515
|
checked.origin = true;
|
|
@@ -1368,19 +1520,19 @@ function parseClientData(bytes, opts) {
|
|
|
1368
1520
|
if (wantTop === null) {
|
|
1369
1521
|
if (framed || doc.topOrigin !== undefined) {
|
|
1370
1522
|
throw _err("webauthn/client-data-mismatch",
|
|
1371
|
-
"the clientDataJSON describes a cross-origin ceremony (crossOrigin " +
|
|
1372
|
-
", topOrigin " +
|
|
1523
|
+
"the clientDataJSON describes a cross-origin ceremony (crossOrigin " + intrinsic.stringify(doc.crossOrigin) +
|
|
1524
|
+
", topOrigin " + intrinsic.stringify(doc.topOrigin === undefined ? null : doc.topOrigin) +
|
|
1373
1525
|
"), and this relying party requires an unframed one (WebAuthn sec. 5.8.1)");
|
|
1374
1526
|
}
|
|
1375
1527
|
} else {
|
|
1376
|
-
var allowedTop =
|
|
1377
|
-
if (!allowedTop.length || !
|
|
1528
|
+
var allowedTop = intrinsic.isArray(wantTop) ? wantTop : [wantTop];
|
|
1529
|
+
if (!allowedTop.length || !intrinsic.everyOwn(allowedTop, function (o) { return typeof o === "string" && o.length; })) {
|
|
1378
1530
|
throw _err("webauthn/bad-input", "opts.expectedTopOrigin must be null, a non-empty origin string, or an array of them");
|
|
1379
1531
|
}
|
|
1380
|
-
if (!framed ||
|
|
1532
|
+
if (!framed || intrinsic.ownIndexOf(allowedTop, doc.topOrigin) === -1) {
|
|
1381
1533
|
throw _err("webauthn/client-data-mismatch",
|
|
1382
|
-
"the clientDataJSON topOrigin " +
|
|
1383
|
-
" (crossOrigin " +
|
|
1534
|
+
"the clientDataJSON topOrigin " + intrinsic.stringify(doc.topOrigin === undefined ? null : doc.topOrigin) +
|
|
1535
|
+
" (crossOrigin " + intrinsic.stringify(doc.crossOrigin) + ") is not a framing this relying party accepts (WebAuthn sec. 5.8.1)");
|
|
1384
1536
|
}
|
|
1385
1537
|
}
|
|
1386
1538
|
checked.topOrigin = true;
|
|
@@ -1392,7 +1544,7 @@ function parseClientData(bytes, opts) {
|
|
|
1392
1544
|
checked: checked,
|
|
1393
1545
|
};
|
|
1394
1546
|
}
|
|
1395
|
-
var _CLIENT_DATA_OPTS =
|
|
1547
|
+
var _CLIENT_DATA_OPTS = intrinsic.assign(intrinsic.create(null), {
|
|
1396
1548
|
expectedType: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
1397
1549
|
});
|
|
1398
1550
|
|
|
@@ -1426,7 +1578,7 @@ function parseAuthenticatorData(bytes) {
|
|
|
1426
1578
|
return _parseAuthData(_bytesArg(bytes, "authenticatorData"), _err);
|
|
1427
1579
|
}
|
|
1428
1580
|
|
|
1429
|
-
var _ASSERT_OPTS =
|
|
1581
|
+
var _ASSERT_OPTS = intrinsic.assign(intrinsic.create(null), {
|
|
1430
1582
|
authenticatorData: 1, clientDataHash: 1, clientDataJSON: 1, signature: 1,
|
|
1431
1583
|
credentialPublicKey: 1, previousSignCount: 1,
|
|
1432
1584
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
@@ -1496,7 +1648,11 @@ function _snapshotAssertion(input) {
|
|
|
1496
1648
|
if (!_isPlainObject(input)) throw _err("webauthn/bad-input", "pki.webauthn.verifyAssertion takes an options object");
|
|
1497
1649
|
guard.identifier.assertKnownKeys(input, _ASSERT_OPTS, _err, "webauthn/bad-input", "verifyAssertion input has an unknown key ");
|
|
1498
1650
|
var out = guard.identifier.ownOptions(input);
|
|
1499
|
-
|
|
1651
|
+
/** @internal The walk comes from the capture. Each pass of it SNAPSHOTS one of the byte fields the
|
|
1652
|
+
* verdict is computed over, so a walk that visits nothing leaves the caller's own buffers in place
|
|
1653
|
+
* and they can be rewritten between this call returning its promise and the verification running:
|
|
1654
|
+
* substituted authenticatorData with a signature that matches it then verifies. */
|
|
1655
|
+
intrinsic.forEach(["authenticatorData", "clientDataJSON", "clientDataHash", "signature", "expectedChallenge"], function (f) {
|
|
1500
1656
|
if (_isBufferSource(out[f])) out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
1501
1657
|
});
|
|
1502
1658
|
if (_isBufferSource(out.credentialPublicKey)) {
|
|
@@ -1514,16 +1670,19 @@ function _snapshotAssertion(input) {
|
|
|
1514
1670
|
}
|
|
1515
1671
|
out.credentialPublicKey = key;
|
|
1516
1672
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1673
|
+
/** @internal Each classification decides whether the list is snapshotted. One answering `false`
|
|
1674
|
+
* leaves the caller's own array in place, so the allow-list the checks run against can still change
|
|
1675
|
+
* after this point. */
|
|
1676
|
+
if (intrinsic.isArray(out.allowedAlgorithms)) out.allowedAlgorithms = guard.list.snapshot(out.allowedAlgorithms);
|
|
1677
|
+
if (intrinsic.isArray(out.expectedOrigin)) out.expectedOrigin = guard.list.snapshot(out.expectedOrigin);
|
|
1678
|
+
if (intrinsic.isArray(out.expectedTopOrigin)) out.expectedTopOrigin = guard.list.snapshot(out.expectedTopOrigin);
|
|
1520
1679
|
return out;
|
|
1521
1680
|
}
|
|
1522
1681
|
|
|
1523
1682
|
function verifyAssertion(input) {
|
|
1524
1683
|
var frozen;
|
|
1525
1684
|
try { frozen = _snapshotAssertion(input); } catch (e) { return _promiseReject(_Promise, e); }
|
|
1526
|
-
return _promiseResolve(_Promise, undefined)
|
|
1685
|
+
return intrinsic.chain(_promiseResolve(_Promise, undefined), function () {
|
|
1527
1686
|
input = frozen;
|
|
1528
1687
|
var authData = parseAuthenticatorData(input.authenticatorData);
|
|
1529
1688
|
input.signature = _bytesArg(input.signature, "signature");
|
|
@@ -1540,7 +1699,7 @@ function verifyAssertion(input) {
|
|
|
1540
1699
|
expectedOrigin: input.expectedOrigin,
|
|
1541
1700
|
expectedTopOrigin: input.expectedTopOrigin,
|
|
1542
1701
|
});
|
|
1543
|
-
clientDataHash = _sha("sha256",
|
|
1702
|
+
clientDataHash = _sha("sha256", intrinsic.bufferFrom(input.clientDataJSON));
|
|
1544
1703
|
} else {
|
|
1545
1704
|
if (input.expectedChallenge !== undefined || input.expectedOrigin !== undefined ||
|
|
1546
1705
|
input.expectedTopOrigin !== undefined) {
|
|
@@ -1561,12 +1720,14 @@ function verifyAssertion(input) {
|
|
|
1561
1720
|
}
|
|
1562
1721
|
var bindingChecked = _applyBindings(authData, coseKey, input);
|
|
1563
1722
|
var prev = input.previousSignCount;
|
|
1564
|
-
if (prev !== undefined && (typeof prev !== "number" || !
|
|
1723
|
+
if (prev !== undefined && (typeof prev !== "number" || !intrinsic.isSafeInteger(prev) || prev < 0 || prev > 0xFFFFFFFF)) {
|
|
1565
1724
|
throw _err("webauthn/bad-input", "previousSignCount must be an integer in 0..4294967295");
|
|
1566
1725
|
}
|
|
1567
|
-
|
|
1726
|
+
/** @internal The signed input and the signature are both assembled with captures, which is what
|
|
1727
|
+
* decides the bytes this assertion is checked over and the bytes it is checked against. */
|
|
1728
|
+
var message = intrinsic.bufferConcat([intrinsic.bufferFrom(input.authenticatorData), clientDataHash]);
|
|
1568
1729
|
var spki = _coseKeyToSpki(coseKey);
|
|
1569
|
-
return _verifySig(coseKey.alg,
|
|
1730
|
+
return intrinsic.chain(_verifySig(coseKey.alg, intrinsic.bufferFrom(input.signature), spki, message, _err), function (ok) {
|
|
1570
1731
|
if (!ok) {
|
|
1571
1732
|
throw _err("webauthn/bad-signature",
|
|
1572
1733
|
"the assertion signature does not verify under the stored credential public key (WebAuthn sec. 7.2 step 20)");
|
|
@@ -1599,7 +1760,9 @@ function verifyAssertion(input) {
|
|
|
1599
1760
|
});
|
|
1600
1761
|
}
|
|
1601
1762
|
|
|
1602
|
-
|
|
1763
|
+
/** @internal Frozen, so a consumer that reads one of these off this object at the call cannot be
|
|
1764
|
+
* handed a replacement in place of it. */
|
|
1765
|
+
module.exports = intrinsic.freeze({
|
|
1603
1766
|
parseAttestationObject: parseAttestationObject,
|
|
1604
1767
|
parseAuthenticatorData: parseAuthenticatorData,
|
|
1605
1768
|
parseClientData: parseClientData,
|
|
@@ -1609,4 +1772,4 @@ module.exports = {
|
|
|
1609
1772
|
verifyMetadataBlob: mds.verifyMetadataBlob,
|
|
1610
1773
|
metadataFor: mds.metadataFor,
|
|
1611
1774
|
metadataAnchors: mds.metadataAnchors,
|
|
1612
|
-
};
|
|
1775
|
+
});
|