@blamejs/pki 0.5.1 → 0.5.3
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 +50 -0
- package/README.md +3 -3
- package/index.js +5 -1
- package/lib/cms-verify.js +240 -6
- package/lib/constants.js +7 -4
- package/lib/guard-bytes.js +6 -2
- package/lib/guard-name.js +15 -0
- package/lib/jose.js +39 -0
- package/lib/path-validate.js +72 -36
- package/lib/sign-scheme.js +45 -6
- package/lib/smime.js +41 -6
- package/lib/tsp-sign.js +10 -0
- package/lib/validator-cose.js +55 -3
- package/lib/webauthn-mds.js +207 -23
- package/lib/webauthn.js +447 -140
- package/package.json +2 -2
- package/sbom.cdx.json +6 -6
package/lib/webauthn.js
CHANGED
|
@@ -34,7 +34,6 @@ var validator = require("./validator-all");
|
|
|
34
34
|
var edwardsPoint = require("./edwards-point");
|
|
35
35
|
var guard = require("./guard-all");
|
|
36
36
|
var jose = require("./jose");
|
|
37
|
-
var pathValidate = require("./path-validate");
|
|
38
37
|
var mds = require("./webauthn-mds");
|
|
39
38
|
var nodeCrypto = require("crypto");
|
|
40
39
|
|
|
@@ -73,7 +72,7 @@ function _isInteger(node) { return !!node && !node.constructed && node.tagClass
|
|
|
73
72
|
// step rejects the attestation before the signature is evaluated. EdDSA (-8/-19/-53) is
|
|
74
73
|
// absent by design: a TPM 2.0 AIK never signs with EdDSA, so such an attestation is
|
|
75
74
|
// correctly refused.
|
|
76
|
-
var COSE_ALG_HASH = { "-7": "sha256", "-9": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-51": "sha384", "-258": "sha384", "-36": "sha512", "-52": "sha512", "-259": "sha512", "-65535": "sha1" };
|
|
75
|
+
var COSE_ALG_HASH = { "-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" };
|
|
77
76
|
function _coseAlgHash(alg, E) {
|
|
78
77
|
var h = COSE_ALG_HASH[String(alg)];
|
|
79
78
|
if (!h) throw E("webauthn/unsupported-algorithm", "no hash mapping for COSE algorithm " + alg);
|
|
@@ -141,7 +140,51 @@ function _parseAuthData(buf, E) {
|
|
|
141
140
|
// The complete COSE credential-key conformance rule set (kty/alg/crv/length/canonical/
|
|
142
141
|
// profile/on-curve) lives in validator-cose, composed here so every credential key
|
|
143
142
|
// routes through the one home -- never a per-format re-derivation of a partial subset.
|
|
144
|
-
function _decodeCoseKey(node) {
|
|
143
|
+
function _decodeCoseKey(node) {
|
|
144
|
+
return validator.cose.credentialKey(node, WebauthnError, "webauthn/bad-cose-key", "webauthn/unsupported-algorithm");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @primitive pki.webauthn.parseCoseKey
|
|
149
|
+
* @signature pki.webauthn.parseCoseKey(bytes) -> object
|
|
150
|
+
* @since 0.5.2
|
|
151
|
+
* @status stable
|
|
152
|
+
* @spec RFC 9052, W3C WebAuthn Level 3 sec. 6.5.1
|
|
153
|
+
* @related pki.webauthn.verify, pki.webauthn.verifyAssertion
|
|
154
|
+
*
|
|
155
|
+
* Decode a bare COSE_Key -- the credential public key a relying party stored at
|
|
156
|
+
* registration -- back into the object `verifyAssertion` takes. `pki.webauthn.verify`
|
|
157
|
+
* returns that object, but the durable form is bytes: the object carries `Buffer`
|
|
158
|
+
* values, so a JSON round trip through a datastore yields
|
|
159
|
+
* `{"type":"Buffer","data":[...]}` rather than the object that went in, and existing
|
|
160
|
+
* credential stores already hold COSE bytes whoever wrote them. Without this the only
|
|
161
|
+
* routes into the decoder were `parseAttestationObject` and `parseAuthenticatorData`,
|
|
162
|
+
* both of which parse a CONTAINING structure -- so recovering a stored key meant
|
|
163
|
+
* fabricating an authenticatorData that never existed.
|
|
164
|
+
*
|
|
165
|
+
* The same validation the attestation path applies: the key type, the algorithm, the
|
|
166
|
+
* curve, and the coordinates are checked, and anything that is not a credential COSE
|
|
167
|
+
* key is refused with `webauthn/bad-cose-key`. `verifyAssertion` accepts either form
|
|
168
|
+
* for `credentialPublicKey`, so calling this first is a convenience rather than a step.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* // requires: `attestationObject` / `clientDataHash` -- what a browser returns from a
|
|
172
|
+
* // registration ceremony
|
|
173
|
+
* var reg = await pki.webauthn.verify(attestationObject, clientDataHash, {});
|
|
174
|
+
* var stored = reg.credentialPublicKeyBytes; // the form a credential row holds
|
|
175
|
+
* // ... at a login months later, read it back:
|
|
176
|
+
* var key = pki.webauthn.parseCoseKey(stored);
|
|
177
|
+
* key.alg; // -> -7 for ES256
|
|
178
|
+
* // verifyAssertion takes either form, so this parse is a convenience, not a step:
|
|
179
|
+
* // pass `stored` straight as its credentialPublicKey.
|
|
180
|
+
*/
|
|
181
|
+
function parseCoseKey(bytes) {
|
|
182
|
+
var buf = _bytesArg(bytes, "the COSE key");
|
|
183
|
+
var node;
|
|
184
|
+
try { node = cbor.decode(buf); }
|
|
185
|
+
catch (e) { throw _err("webauthn/bad-cose-key", "the stored credential key is not decodable CBOR", e); }
|
|
186
|
+
return _decodeCoseKey(node);
|
|
187
|
+
}
|
|
145
188
|
|
|
146
189
|
// ---- signature verification bridge ------------------------------------------
|
|
147
190
|
|
|
@@ -165,7 +208,11 @@ var COSE_ALG = {
|
|
|
165
208
|
"-257": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
166
209
|
"-258": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
167
210
|
"-259": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
211
|
+
// RSASSA-PSS. The salt length is the hash length, the profile WebCrypto verifies and the one
|
|
212
|
+
// RFC 8230 sec. 2 fixes for the COSE PS* identifiers -- 32 / 48 / 64 bytes for SHA-256/384/512.
|
|
168
213
|
"-37": { imp: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 }, ecdsa: 0 },
|
|
214
|
+
"-38": { imp: { name: "RSA-PSS", hash: "SHA-384" }, verify: { name: "RSA-PSS", saltLength: 48 }, ecdsa: 0 },
|
|
215
|
+
"-39": { imp: { name: "RSA-PSS", hash: "SHA-512" }, verify: { name: "RSA-PSS", saltLength: 64 }, ecdsa: 0 },
|
|
169
216
|
// RS1 (RSASSA-PKCS1-v1_5 / SHA-1): a legacy COSE algorithm real Windows Hello TPM
|
|
170
217
|
// authenticators emit in their attestation statement. VERIFY-only support -- the
|
|
171
218
|
// toolkit never signs with SHA-1; it must still evaluate the attestations that
|
|
@@ -193,7 +240,7 @@ function _verifySig(alg, sig, spkiBytes, message, E) {
|
|
|
193
240
|
// low-order (e.g. all-zeroes) key verifies a trivial signature -- so validate the OKP
|
|
194
241
|
// point before verify. This covers EVERY key that signs a WebAuthn statement: the x5c
|
|
195
242
|
// attestation-certificate key (packed/tpm/apple) AND the self-attestation credential key.
|
|
196
|
-
if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name
|
|
243
|
+
if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name);
|
|
197
244
|
var s = d.ecdsa ? _derEcdsaToRaw(sig, d.imp.namedCurve) : sig;
|
|
198
245
|
return subtle.importKey("spki", spkiBytes, imp, false, ["verify"])
|
|
199
246
|
.then(function (key) { return subtle.verify(ver, key, s, message); })
|
|
@@ -211,17 +258,14 @@ function _edName(spkiBytes, E) {
|
|
|
211
258
|
if (!nm) throw E("webauthn/unsupported-algorithm", "unsupported EdDSA curve OID " + algOid);
|
|
212
259
|
return nm;
|
|
213
260
|
}
|
|
214
|
-
// The raw Edwards point an OKP SPKI carries
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (!edwardsPoint.validate(point, name === "Ed25519" ? 6 : 7)) {
|
|
223
|
-
throw E("webauthn/bad-signature", "the EdDSA public key is not a valid, full-order Edwards point");
|
|
224
|
-
}
|
|
261
|
+
// The raw Edwards point an OKP SPKI carries MUST be a valid, full-order point -- reject an
|
|
262
|
+
// off-curve or low-order key before it verifies a signature, which WebCrypto import will not do.
|
|
263
|
+
// Routed through edwards-point's own SPKI entry rather than extracting the point here: that module
|
|
264
|
+
// documents itself as the one home every EdDSA verify path takes an SPKI through, and this was the
|
|
265
|
+
// path that had its own copy. Keeping a second extraction is how one route comes to be guarded and
|
|
266
|
+
// the next one written is not.
|
|
267
|
+
function _requireValidEdPoint(spkiBytes, name) {
|
|
268
|
+
edwardsPoint.validateSpki(spkiBytes, name === "Ed25519" ? 6 : 7, WebauthnError, "webauthn/bad-signature");
|
|
225
269
|
}
|
|
226
270
|
|
|
227
271
|
// A validated COSE credential key -> a self-contained SPKI the WebCrypto import
|
|
@@ -244,11 +288,39 @@ function _certEcCurveOid(cert, E) {
|
|
|
244
288
|
try { return asn1.read.oid(asn1.decode(params)); }
|
|
245
289
|
catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate EC curve is not a valid OBJECT IDENTIFIER", e); }
|
|
246
290
|
}
|
|
291
|
+
// Which certificate key ALGORITHMS a credential key of each COSE type may be carried by. Two keys
|
|
292
|
+
// are the same key only if they are the same KIND of key: the bytes alone do not say what a key is,
|
|
293
|
+
// and for the Edwards curves they cannot -- an X25519 key-agreement key and an Ed25519 signing key
|
|
294
|
+
// are both 32 raw bytes, so a certificate declaring the former would otherwise compare equal to a
|
|
295
|
+
// credential key declaring the latter and be accepted as its attestation certificate. The EC2
|
|
296
|
+
// branch already asked this question as a curve check; asking it only there left the two key types
|
|
297
|
+
// whose confusion is undetectable from the material to be decided on the material alone.
|
|
298
|
+
// A row per COSE key type, keyed the way every other algorithm decision in this toolkit is.
|
|
299
|
+
function _certKeyAlgNames(cose) {
|
|
300
|
+
if (cose.kty === 2) return ["ecPublicKey"];
|
|
301
|
+
// An RSA key may be carried under the general OID or under id-RSASSA-PSS, which restricts it to
|
|
302
|
+
// PSS but is the same key (RFC 4055 sec. 1.2).
|
|
303
|
+
if (cose.kty === 3) return ["rsaEncryption", "rsassaPss"];
|
|
304
|
+
if (cose.kty === 1) {
|
|
305
|
+
var okp = validator.cose.OKP_CRV[cose.crv];
|
|
306
|
+
return okp ? [okp.oid] : [];
|
|
307
|
+
}
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
function _assertCertKeyAlgorithm(cert, cose, E) {
|
|
311
|
+
var alg = (cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.algorithm) || {};
|
|
312
|
+
var want = _certKeyAlgNames(cose);
|
|
313
|
+
if (!want.length || want.indexOf(alg.name) < 0) {
|
|
314
|
+
throw E("webauthn/key-mismatch", "the attestation certificate carries a " + JSON.stringify(alg.name) +
|
|
315
|
+
" key, which is not the kind of key the credential public key declares");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
247
318
|
// A void assert: throws webauthn/key-mismatch on any inequality, returns nothing on
|
|
248
319
|
// success (called for its throw side-effect, like the other _check* asserts).
|
|
249
320
|
function _certPubKeyEqualsCose(cert, cose, E) {
|
|
250
321
|
var raw = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.publicKey && cert.subjectPublicKeyInfo.publicKey.bytes;
|
|
251
322
|
if (!raw) throw E("webauthn/key-mismatch", "the attestation certificate exposes no public key");
|
|
323
|
+
_assertCertKeyAlgorithm(cert, cose, E);
|
|
252
324
|
if (cose.kty === 2) {
|
|
253
325
|
// The certificate's declared EC curve MUST equal the credential key's curve --
|
|
254
326
|
// a curve substitution is a different key even if the coordinate bytes line up.
|
|
@@ -408,8 +480,15 @@ var _VERIFY_OPTS = Object.assign(Object.create(null), {
|
|
|
408
480
|
time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
|
|
409
481
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
410
482
|
rootCertificates: 1,
|
|
483
|
+
clientDataJSON: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
411
484
|
});
|
|
412
485
|
|
|
486
|
+
// The boolean switches whose only reader sits inside ONE format's arm, so nothing else would ever
|
|
487
|
+
// examine them. The binding switches are absent because _applyBindings owns their type and runs on
|
|
488
|
+
// every format and on the assertion path too -- a second copy of that rule here would be a second
|
|
489
|
+
// place for it to drift.
|
|
490
|
+
var _FORMAT_SCOPED_BOOLEAN_OPTS = ["verifySafetyNetJws", "requireCtsProfileMatch"];
|
|
491
|
+
|
|
413
492
|
// Anchor an attestation's trust path to roots the CALLER pins. The metadata route
|
|
414
493
|
// resolves an authenticator's roots from the catalogue that registered it, which is
|
|
415
494
|
// the stronger source -- but it only reaches models the catalogue lists, and some
|
|
@@ -464,22 +543,34 @@ function _cloneParsed(v, depth) {
|
|
|
464
543
|
}
|
|
465
544
|
|
|
466
545
|
// A caller-owned byte argument, copied so nothing downstream reads bytes the caller can still
|
|
467
|
-
// rewrite
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
546
|
+
// rewrite, and accepted in every form the W3C BufferSource contract defines.
|
|
547
|
+
//
|
|
548
|
+
// ONE accepted set, for every byte argument on this namespace. The forms differed per argument
|
|
549
|
+
// before -- an attestation object took an ArrayBuffer while the clientDataHash beside it took only
|
|
550
|
+
// a Buffer -- and the caller producing both is the same one: `crypto.subtle.digest` returns an
|
|
551
|
+
// ArrayBuffer, so the natural way to compute that hash produced a value this verb refused. Which
|
|
552
|
+
// forms an argument accepts is not a per-argument decision; it is the namespace's contract, and it
|
|
553
|
+
// is made once here.
|
|
554
|
+
//
|
|
555
|
+
// Anything that is not a BufferSource is refused BY NAME, rather than passed down to be described
|
|
556
|
+
// by whichever parser reaches it first.
|
|
557
|
+
function _isBufferSource(v) { return ArrayBuffer.isView(v) || v instanceof ArrayBuffer; }
|
|
558
|
+
function _bytesArg(v, label) {
|
|
559
|
+
if (_isBufferSource(v)) {
|
|
473
560
|
return guard.bytes.snapshotSource(v, WebauthnError, "webauthn/bad-input", label);
|
|
474
561
|
}
|
|
475
|
-
|
|
562
|
+
throw _err("webauthn/bad-input", label + " must be a BufferSource (a Buffer, a typed-array view, or an ArrayBuffer)");
|
|
476
563
|
}
|
|
477
564
|
|
|
478
565
|
function _snapshotRoots(supplied) {
|
|
479
566
|
if (!Array.isArray(supplied)) return supplied;
|
|
480
567
|
return supplied.map(function (root) {
|
|
481
|
-
|
|
482
|
-
|
|
568
|
+
// Every byte form, tested BEFORE the parsed-object branch: a DataView is an object too, so a
|
|
569
|
+
// narrower byte test does not merely miss the copy -- it sends those bytes down the branch for
|
|
570
|
+
// parsed certificates, which deep-copies a typed array field by field into something that is no
|
|
571
|
+
// longer a certificate at all.
|
|
572
|
+
if (_isBufferSource(root)) {
|
|
573
|
+
return guard.bytes.snapshotSource(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[]");
|
|
483
574
|
}
|
|
484
575
|
if (root && typeof root === "object") return _cloneParsed(root, 0);
|
|
485
576
|
return root; // a PEM string is immutable
|
|
@@ -502,7 +593,7 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
|
502
593
|
// The same three forms opts.safetyNetRoots takes, since it is the same question.
|
|
503
594
|
var roots = supplied.map(function (root, i) {
|
|
504
595
|
var cert;
|
|
505
|
-
try { cert = (
|
|
596
|
+
try { cert = _isBufferSource(root) ? x509.parse(guard.bytes.source(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[" + i + "]")) : (typeof root === "string" ? x509.parse(root) : root); }
|
|
506
597
|
catch (e) { throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a decodable certificate", e); }
|
|
507
598
|
if (!cert || !cert.subject || !cert.subjectPublicKeyInfo) {
|
|
508
599
|
throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a certificate");
|
|
@@ -598,10 +689,6 @@ function _anchoredRoutes(res, base) {
|
|
|
598
689
|
// reports which of them actually ran. The challenge and the origin stay with the
|
|
599
690
|
// caller: they live in clientDataJSON, which the caller already holds and compares
|
|
600
691
|
// against state only it has.
|
|
601
|
-
var _BINDING_OPTS = Object.assign(Object.create(null), {
|
|
602
|
-
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
603
|
-
});
|
|
604
|
-
|
|
605
692
|
function _assertBool(v, name) {
|
|
606
693
|
if (typeof v !== "boolean") throw _err("webauthn/bad-input", "opts." + name + " must be a boolean");
|
|
607
694
|
}
|
|
@@ -854,10 +941,8 @@ var VERIFIERS = {
|
|
|
854
941
|
// existed. There is no bundled root and no trust-on-first-use.
|
|
855
942
|
"android-safetynet": function (att, clientDataHash, opts) {
|
|
856
943
|
opts = opts || {};
|
|
944
|
+
// The type is settled at the entry point, so what remains here is the opt-in itself.
|
|
857
945
|
if (opts.verifySafetyNetJws !== true) {
|
|
858
|
-
if (opts.verifySafetyNetJws !== undefined && typeof opts.verifySafetyNetJws !== "boolean") {
|
|
859
|
-
throw _err("webauthn/bad-input", "opts.verifySafetyNetJws must be a boolean");
|
|
860
|
-
}
|
|
861
946
|
throw _err("webauthn/unsupported-format", "attestation statement format 'android-safetynet' is not supported");
|
|
862
947
|
}
|
|
863
948
|
var roots = opts.safetyNetRoots;
|
|
@@ -941,9 +1026,6 @@ var VERIFIERS = {
|
|
|
941
1026
|
if (opts.requireCtsProfileMatch === true && signals.ctsProfileMatch !== true) {
|
|
942
1027
|
throw _err("webauthn/safetynet-cts-profile", "the android-safetynet response reports ctsProfileMatch " + JSON.stringify(signals.ctsProfileMatch) + ", and opts.requireCtsProfileMatch demands true");
|
|
943
1028
|
}
|
|
944
|
-
if (opts.requireCtsProfileMatch !== undefined && typeof opts.requireCtsProfileMatch !== "boolean") {
|
|
945
|
-
throw _err("webauthn/bad-input", "opts.requireCtsProfileMatch must be a boolean");
|
|
946
|
-
}
|
|
947
1029
|
|
|
948
1030
|
return _verifySig(-257, sigBytes, leaf.subjectPublicKeyInfo.bytes,
|
|
949
1031
|
Buffer.from(segs[0] + "." + segs[1], "ascii"), _err).then(function (ok) {
|
|
@@ -1082,41 +1164,28 @@ function _safetyNetHostnameOk(leaf) {
|
|
|
1082
1164
|
// rotation; the first that validates wins, and if none does the attestation is refused. The chain
|
|
1083
1165
|
// goes through the full path validator rather than a signature-only walk, so an expired, revoked-by-
|
|
1084
1166
|
// policy, or otherwise non-conforming intermediate cannot slip past on a signature alone.
|
|
1167
|
+
// The caller's roots are the only thing this function owns: their SHAPE is its own config-time
|
|
1168
|
+
// contract, so a root that is not a decodable certificate is named by its index here. Anchoring
|
|
1169
|
+
// itself is the namespace's one walk (webauthn-mds), which every other chain in this module already
|
|
1170
|
+
// reaches its anchors through -- including the anchor-stripping rule, which this had a slightly
|
|
1171
|
+
// different and weaker copy of. Each root is resolved BEFORE the walk starts, so a malformed entry
|
|
1172
|
+
// is a config fault whatever position it sits in, rather than one that only surfaces when the roots
|
|
1173
|
+
// before it happen to fail.
|
|
1085
1174
|
function _safetyNetChainTrusted(chain, roots, time) {
|
|
1086
|
-
var
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
return function () {
|
|
1175
|
+
var anchors;
|
|
1176
|
+
try {
|
|
1177
|
+
anchors = roots.map(function (root, i) {
|
|
1090
1178
|
var anchorCert;
|
|
1091
|
-
try { anchorCert =
|
|
1179
|
+
try { anchorCert = _isBufferSource(root) ? x509.parse(guard.bytes.source(root, WebauthnError, "webauthn/bad-input", "opts.safetyNetRoots[" + i + "]")) : (typeof root === "string" ? x509.parse(root) : root); }
|
|
1092
1180
|
catch (e) { throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a decodable certificate", e); }
|
|
1093
1181
|
if (!anchorCert || !anchorCert.subject || !anchorCert.subjectPublicKeyInfo) {
|
|
1094
1182
|
throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a certificate");
|
|
1095
1183
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
guard.name.dnEqual(path[0].issuer, path[0].subject)) {
|
|
1102
|
-
path = path.slice(1);
|
|
1103
|
-
}
|
|
1104
|
-
return pathValidate.validate(path, {
|
|
1105
|
-
time: when,
|
|
1106
|
-
// The anchor's own KEY algorithm and its parameters, not the algorithm its issuer signed it
|
|
1107
|
-
// with: the validator carries these forward as the working public key, and a certificate
|
|
1108
|
-
// below the anchor may inherit its key parameters from them.
|
|
1109
|
-
trustAnchor: { name: anchorCert.subject, publicKey: anchorCert.subjectPublicKeyInfo.bytes,
|
|
1110
|
-
algorithm: anchorCert.subjectPublicKeyInfo.algorithm.oid,
|
|
1111
|
-
parameters: anchorCert.subjectPublicKeyInfo.algorithm.parameters },
|
|
1112
|
-
}).then(function (r) { return !!(r && r.valid); }, function () { return false; });
|
|
1113
|
-
};
|
|
1114
|
-
});
|
|
1115
|
-
return attempts.reduce(function (p, next) {
|
|
1116
|
-
return p.then(function (done) { return done ? true : next(); });
|
|
1117
|
-
}, Promise.resolve(false)).then(function (trusted) {
|
|
1118
|
-
if (!trusted) throw _err("webauthn/safetynet-cert-untrusted", "the android-safetynet x5c chain does not validate to any supplied root (opts.safetyNetRoots)");
|
|
1119
|
-
});
|
|
1184
|
+
return anchorCert;
|
|
1185
|
+
});
|
|
1186
|
+
} catch (e) { return Promise.reject(e); }
|
|
1187
|
+
return mds.chainToAnchor(chain, anchors, time === undefined ? new Date() : time,
|
|
1188
|
+
"android-safetynet x5c certificate chain", "webauthn/safetynet-cert-untrusted");
|
|
1120
1189
|
}
|
|
1121
1190
|
|
|
1122
1191
|
// `chain` is the x5c order (leaf-first); trustPath is surfaced in pki.path.validate
|
|
@@ -1139,8 +1208,23 @@ function _result(fmt, attestationType, chain, att) {
|
|
|
1139
1208
|
aaguid: att.authData.aaguid,
|
|
1140
1209
|
credentialId: att.authData.credentialId,
|
|
1141
1210
|
credentialPublicKey: att.authData.credentialPublicKey,
|
|
1211
|
+
// The same key in the form that SURVIVES STORAGE. The decoded object carries Buffers, so a JSON
|
|
1212
|
+
// round trip through a datastore returns {"type":"Buffer","data":[...]} rather than what went
|
|
1213
|
+
// in; the COSE bytes are what a credential row actually holds. Returning only the object left
|
|
1214
|
+
// the caller re-parsing the attestation object to recover bytes this call had already isolated.
|
|
1215
|
+
credentialPublicKeyBytes: att.authData.credentialPublicKeyBytes,
|
|
1142
1216
|
signCount: att.authData.signCount,
|
|
1143
1217
|
flags: att.authData.flags,
|
|
1218
|
+
// The RP ID hash and the authenticator extension outputs, for the same reason the key bytes
|
|
1219
|
+
// above are here: this call decoded them and the caller needs them. Extension outputs arrive
|
|
1220
|
+
// at REGISTRATION and nowhere else -- credProtect (whether this credential is
|
|
1221
|
+
// user-verification-required for the rest of its life), credProps.rk (whether it is
|
|
1222
|
+
// discoverable), credBlob, minPinLength, hmac-secret/prf -- so a relying party that must
|
|
1223
|
+
// persist them had to re-parse the attestation object to read values already in hand.
|
|
1224
|
+
// `verifyAssertion` returns both; the registration verdict returning less made the two halves
|
|
1225
|
+
// of one lifecycle disagree about what they hand back.
|
|
1226
|
+
rpIdHash: att.authData.rpIdHash,
|
|
1227
|
+
extensions: att.authData.extensions,
|
|
1144
1228
|
};
|
|
1145
1229
|
}
|
|
1146
1230
|
|
|
@@ -1176,7 +1260,7 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1176
1260
|
|
|
1177
1261
|
/**
|
|
1178
1262
|
* @primitive pki.webauthn.verify
|
|
1179
|
-
* @signature pki.webauthn.verify(attestationObject, clientDataHash
|
|
1263
|
+
* @signature pki.webauthn.verify(attestationObject, clientDataHash?, opts?) -> Promise<{ attestationVerified, fmt, attestationType, trustPath, anchoredTo, aaguid, credentialId, credentialPublicKey, credentialPublicKeyBytes, signCount, flags, rpIdHash, extensions, bindingChecked, clientData }>
|
|
1180
1264
|
* @since 0.2.5
|
|
1181
1265
|
* @status stable
|
|
1182
1266
|
* @spec W3C WebAuthn Level 3 sec. 8 / sec. 7.1
|
|
@@ -1184,11 +1268,19 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1184
1268
|
*
|
|
1185
1269
|
* Verify a WebAuthn attestation statement: the attestation signature over
|
|
1186
1270
|
* `authenticatorData || clientDataHash` and (for the x5c formats) the format's
|
|
1187
|
-
* certificate requirements.
|
|
1188
|
-
* data, supplied by the relying party. Resolves the attestation type + trust path or
|
|
1271
|
+
* certificate requirements. Resolves the attestation type + trust path or
|
|
1189
1272
|
* throws a typed `webauthn/*` error; a signature that does not verify is a
|
|
1190
1273
|
* `webauthn/verify-failed` verdict, never a silent pass.
|
|
1191
1274
|
*
|
|
1275
|
+
* Give it the client data in exactly one of the two forms, neither inferred from the
|
|
1276
|
+
* other's absence: the raw `opts.clientDataJSON`, or the SHA-256 digest of it as the
|
|
1277
|
+
* second argument. Given the JSON, this reads it -- the ceremony TYPE is checked
|
|
1278
|
+
* unconditionally, because which ceremony a response belongs to is fixed by the
|
|
1279
|
+
* specification rather than chosen by a caller, and a login response replayed into a
|
|
1280
|
+
* registration is exactly what that check stops. The challenge, origin and top-level
|
|
1281
|
+
* origin are checked when you supply what you issued, and `clientData.checked` reports
|
|
1282
|
+
* which ran. Given only the digest, nothing reads it and `clientData` is null.
|
|
1283
|
+
*
|
|
1192
1284
|
* The verdict field is `attestationVerified`, and the name is the point: a sound
|
|
1193
1285
|
* attestation statement is not the same claim as an acceptable registration. The
|
|
1194
1286
|
* statement says nothing about WHICH relying party asked for it, or whether a user
|
|
@@ -1196,12 +1288,20 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1196
1288
|
* clear, is perfectly sound and must not be registered. Supply `expectedRpId`,
|
|
1197
1289
|
* `requireUserPresence`, `requireUserVerification` and `allowedAlgorithms` and those
|
|
1198
1290
|
* are checked here; `bindingChecked` reports which ran, so a check that passed can be
|
|
1199
|
-
* told from one that never happened.
|
|
1200
|
-
*
|
|
1201
|
-
*
|
|
1291
|
+
* told from one that never happened.
|
|
1292
|
+
*
|
|
1293
|
+
* Four more fields appear only where they mean something, rather than as nulls on every
|
|
1294
|
+
* verdict: `metadata` when the catalogue governed, `anchoredElements` when a trust path
|
|
1295
|
+
* was anchored, `compound` for a sec. 8.9 statement's per-element results, and
|
|
1296
|
+
* `safetyNet` with `chainValidatedAt` for an android-safetynet response.
|
|
1202
1297
|
*
|
|
1203
1298
|
* The verdict also carries what a relying party must STORE to run a later login:
|
|
1204
|
-
* `credentialId`, `credentialPublicKey` and the initial `signCount`.
|
|
1299
|
+
* `credentialId`, `credentialPublicKey` and the initial `signCount`. The credential key
|
|
1300
|
+
* comes back in both forms: the decoded object, and `credentialPublicKeyBytes`, which is
|
|
1301
|
+
* what a credential row should hold -- the object carries `Buffer` values, so a JSON round
|
|
1302
|
+
* trip through a datastore returns `{"type":"Buffer","data":[...]}` rather than the object
|
|
1303
|
+
* that went in. `pki.webauthn.parseCoseKey` reads those bytes back, and
|
|
1304
|
+
* `verifyAssertion` accepts either form.
|
|
1205
1305
|
*
|
|
1206
1306
|
* @intro This verifies the attestation STATEMENT -- the signature and the format's
|
|
1207
1307
|
* structural bindings (the x5c leaf key == credential key, the apple nonce, the tpm
|
|
@@ -1233,19 +1333,48 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1233
1333
|
* claim there is anything to anchor, so it is not a reason to refuse the statement,
|
|
1234
1334
|
* but it does mean "anchored" covered fewer elements than the statement holds.
|
|
1235
1335
|
*
|
|
1336
|
+
* @opts
|
|
1337
|
+
* clientDataJSON -- the RAW clientDataJSON bytes; supply this OR the digest argument
|
|
1338
|
+
* expectedChallenge -- the challenge bytes this ceremony issued (needs clientDataJSON)
|
|
1339
|
+
* expectedOrigin -- the origin string, or an array of acceptable origins
|
|
1340
|
+
* expectedTopOrigin -- the acceptable top-level origin(s), or null to require an
|
|
1341
|
+
* unframed ceremony
|
|
1342
|
+
* expectedRpId -- the RP ID whose SHA-256 the authenticatorData must carry
|
|
1343
|
+
* requireUserPresence / requireUserVerification -- the flags this registration requires
|
|
1344
|
+
* allowedAlgorithms -- the COSE algorithms this relying party accepts
|
|
1345
|
+
* metadata -- a verifyMetadataBlob result; the model's own roots govern
|
|
1346
|
+
* rootCertificates -- trust anchors you pin, for the models no catalogue lists
|
|
1347
|
+
* time -- the instant certificate validity is judged at
|
|
1348
|
+
* tpmPolicy -- required TPM key properties; refuses a non-TPM attestation
|
|
1349
|
+
* safetyNetRoots / verifySafetyNetJws / requireCtsProfileMatch -- the
|
|
1350
|
+
* android-safetynet opt-in, its Google roots, and the
|
|
1351
|
+
* device-integrity demand
|
|
1352
|
+
*
|
|
1236
1353
|
* @example
|
|
1237
|
-
* // requires: `attestationObject`
|
|
1238
|
-
* // `
|
|
1239
|
-
* var res = await pki.webauthn.verify(attestationObject,
|
|
1354
|
+
* // requires: `attestationObject` and `clientDataJSON` from
|
|
1355
|
+
* // navigator.credentials.create(), and `issuedChallenge` -- the bytes you sent
|
|
1356
|
+
* var res = await pki.webauthn.verify(attestationObject, {
|
|
1357
|
+
* clientDataJSON: clientDataJSON,
|
|
1358
|
+
* expectedChallenge: issuedChallenge,
|
|
1359
|
+
* expectedOrigin: "https://example.com",
|
|
1240
1360
|
* expectedRpId: "example.com", requireUserPresence: true,
|
|
1241
1361
|
* });
|
|
1242
|
-
* res.attestationVerified;
|
|
1243
|
-
* res.
|
|
1244
|
-
* res.
|
|
1245
|
-
*
|
|
1362
|
+
* res.attestationVerified; // true (statement signature + bindings hold)
|
|
1363
|
+
* res.clientData.checked.type; // true -- this really is a registration response
|
|
1364
|
+
* res.bindingChecked.rpId; // true -- this response names example.com
|
|
1365
|
+
* res.attestationType; // "Basic"
|
|
1366
|
+
* // store res.credentialId / res.credentialPublicKeyBytes / res.signCount for logins,
|
|
1246
1367
|
* // and anchor res.trustPath to your pinned roots with pki.path.validate
|
|
1247
1368
|
*/
|
|
1248
1369
|
function verify(attestationObject, clientDataHash, opts) {
|
|
1370
|
+
// Two call shapes, disjoint by shape so neither is guessed at: the digest positionally, or an
|
|
1371
|
+
// options object carrying the clientDataJSON it is computed from. A Buffer IS an object, so the
|
|
1372
|
+
// question has to be asked as "is this not bytes?" -- the disjointness is between a BufferSource
|
|
1373
|
+
// and everything else, and testing only the second half swallows the ordinary two-argument call.
|
|
1374
|
+
if (opts === undefined && clientDataHash !== undefined &&
|
|
1375
|
+
!_isBufferSource(clientDataHash) && _isPlainObject(clientDataHash)) {
|
|
1376
|
+
opts = clientDataHash; clientDataHash = undefined;
|
|
1377
|
+
}
|
|
1249
1378
|
opts = opts || {};
|
|
1250
1379
|
// Every option here either GATES the verdict or supplies the trust material a gate needs, so a
|
|
1251
1380
|
// misspelled key is not a harmless no-op: `metdata` leaves the metadata gate switched off and the
|
|
@@ -1255,11 +1384,25 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1255
1384
|
try {
|
|
1256
1385
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1257
1386
|
guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
1387
|
+
// ONE read of the caller's object, HERE, and nothing below ever touches it again. Not a
|
|
1388
|
+
// precaution about mutation across the deferred verification -- that was already the reason for
|
|
1389
|
+
// the copy this replaces -- but about the reads themselves: a gate that reads `opts.x` to decide
|
|
1390
|
+
// whether a demand was made, and a later step that reads `opts.x` again to act on it, are two
|
|
1391
|
+
// reads of a value the caller still owns, and an accessor makes them disagree. The value that
|
|
1392
|
+
// was CHECKED must be the value that is USED, which means checking and using the same copy.
|
|
1393
|
+
// `Object.assign` takes every own enumerable field by value at this instant, which resolves any
|
|
1394
|
+
// accessor exactly once.
|
|
1395
|
+
opts = Object.assign({}, opts);
|
|
1258
1396
|
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
1397
|
+
// The boolean switches are typed HERE rather than in the arm that reads them. Validated only
|
|
1398
|
+
// inside the android-safetynet arm, `requireCtsProfileMatch: "true"` reaching any other format
|
|
1399
|
+
// is never examined at all -- a truthy string that demands nothing, silently accepted. A
|
|
1400
|
+
// recognised key with an unusable value is the same class of caller mistake as an unrecognised
|
|
1401
|
+
// key, and belongs at the same boundary.
|
|
1402
|
+
_FORMAT_SCOPED_BOOLEAN_OPTS.forEach(function (k) {
|
|
1403
|
+
if (opts[k] !== undefined && typeof opts[k] !== "boolean") throw _err("webauthn/bad-input", "opts." + k + " must be a boolean");
|
|
1404
|
+
});
|
|
1259
1405
|
} catch (e) { return Promise.reject(e); }
|
|
1260
|
-
if (!Buffer.isBuffer(clientDataHash) || clientDataHash.length !== 32) {
|
|
1261
|
-
return Promise.reject(_err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest"));
|
|
1262
|
-
}
|
|
1263
1406
|
var att;
|
|
1264
1407
|
// Both byte inputs are snapshotted BEFORE anything reads them, for the same reason the trust
|
|
1265
1408
|
// anchors are: the attestation statement is not evaluated until a later promise turn, and both
|
|
@@ -1270,10 +1413,49 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1270
1413
|
// the statement to this ceremony; a caller who overwrote it in that gap would have the signature
|
|
1271
1414
|
// checked against a challenge and origin nobody agreed to, and the verdict would still report a
|
|
1272
1415
|
// sound attestation. A DataView or ArrayBuffer comes in by the same door and is copied too.
|
|
1273
|
-
var attBytes, cdh;
|
|
1416
|
+
var attBytes, cdh, clientData = null;
|
|
1274
1417
|
try {
|
|
1275
|
-
attBytes =
|
|
1276
|
-
|
|
1418
|
+
attBytes = _bytesArg(attestationObject, "attestationObject");
|
|
1419
|
+
// The two forms of the same input, and NEITHER is inferred from the other's absence -- the
|
|
1420
|
+
// same rule verifyAssertion applies, because it is the same question. Supplying both invites
|
|
1421
|
+
// them to disagree, and picking one would make the attestation cover something the caller did
|
|
1422
|
+
// not mean.
|
|
1423
|
+
var haveJson = opts.clientDataJSON !== undefined, haveHash = clientDataHash !== undefined;
|
|
1424
|
+
if (haveJson === haveHash) {
|
|
1425
|
+
throw _err("webauthn/bad-input", "verify takes exactly one of clientDataJSON or the clientDataHash argument");
|
|
1426
|
+
}
|
|
1427
|
+
if (haveJson) {
|
|
1428
|
+
// The bytes are taken ONCE and both the reading and the hashing use that copy. Checking one
|
|
1429
|
+
// value and hashing another would bind the attestation to client data the ceremony checks
|
|
1430
|
+
// never saw -- the check and the use have to be of the same bytes.
|
|
1431
|
+
var cdjBytes = _bytesArg(opts.clientDataJSON, "opts.clientDataJSON");
|
|
1432
|
+
// Given the JSON, the ceremony TYPE is checked unconditionally. Which ceremony a response
|
|
1433
|
+
// belongs to is fixed by the specification rather than chosen by the caller, and a login
|
|
1434
|
+
// response replayed into a registration is exactly what that check stops -- so registration
|
|
1435
|
+
// gets the same non-negotiable rule the login path already had. Without this door there was
|
|
1436
|
+
// no way to apply it at registration at all: the hash is opaque, and the caller was left to
|
|
1437
|
+
// compare a value the attestation never bound.
|
|
1438
|
+
clientData = parseClientData(cdjBytes, {
|
|
1439
|
+
expectedType: "webauthn.create",
|
|
1440
|
+
expectedChallenge: opts.expectedChallenge,
|
|
1441
|
+
expectedOrigin: opts.expectedOrigin,
|
|
1442
|
+
expectedTopOrigin: opts.expectedTopOrigin,
|
|
1443
|
+
});
|
|
1444
|
+
cdh = _sha("sha256", cdjBytes);
|
|
1445
|
+
} else {
|
|
1446
|
+
// Every expectation the clientData reader would have answered is unanswerable from a bare
|
|
1447
|
+
// digest, so each is refused rather than left silently uncompared.
|
|
1448
|
+
if (opts.expectedChallenge !== undefined || opts.expectedOrigin !== undefined ||
|
|
1449
|
+
opts.expectedTopOrigin !== undefined) {
|
|
1450
|
+
throw _err("webauthn/bad-input",
|
|
1451
|
+
"expectedChallenge / expectedOrigin / expectedTopOrigin are checked against clientDataJSON, which this call " +
|
|
1452
|
+
"did not supply -- pass opts.clientDataJSON instead of the clientDataHash argument, or check them yourself");
|
|
1453
|
+
}
|
|
1454
|
+
cdh = _bytesArg(clientDataHash, "clientDataHash");
|
|
1455
|
+
// The LENGTH is checked on the normalized copy, so the digest is 32 bytes whichever form it
|
|
1456
|
+
// arrived in -- not only when it arrived as a Buffer.
|
|
1457
|
+
if (cdh.length !== 32) throw _err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest");
|
|
1458
|
+
}
|
|
1277
1459
|
} catch (e) { return Promise.reject(e); }
|
|
1278
1460
|
attestationObject = attBytes;
|
|
1279
1461
|
clientDataHash = cdh;
|
|
@@ -1293,9 +1475,17 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1293
1475
|
// demanded a TPM-bound key would accept a `none` attestation instead. The requirement therefore
|
|
1294
1476
|
// belongs at the dispatch, where it can refuse a format that cannot satisfy it, not in the arm
|
|
1295
1477
|
// that only runs once that format was already chosen.
|
|
1296
|
-
if (opts.tpmPolicy !== undefined && !
|
|
1478
|
+
if (opts.tpmPolicy !== undefined && !_formatCarries(att, "tpm")) {
|
|
1297
1479
|
return Promise.reject(_err("webauthn/tpm-policy", "opts.tpmPolicy requires a TPM attestation, but this attestation is format '" + att.fmt + "', which carries no TPM public area"));
|
|
1298
1480
|
}
|
|
1481
|
+
// The same rule, and the same reason, for the one other option that DEMANDS rather than supplies:
|
|
1482
|
+
// ctsProfileMatch is an android-safetynet device-integrity signal, so no other format's statement
|
|
1483
|
+
// carries one to test. Checked only inside that arm, a caller who demanded a CTS-matching device
|
|
1484
|
+
// would get a pass from a `packed` or `none` attestation that was never asked the question.
|
|
1485
|
+
// A caller who passes `false` demands nothing and is not refused.
|
|
1486
|
+
if (opts.requireCtsProfileMatch === true && !_formatCarries(att, "android-safetynet")) {
|
|
1487
|
+
return Promise.reject(_err("webauthn/safetynet-cts-profile", "opts.requireCtsProfileMatch requires an android-safetynet attestation, but this attestation is format '" + att.fmt + "', whose statement carries no device-integrity signals"));
|
|
1488
|
+
}
|
|
1299
1489
|
// The ceremony bindings run BEFORE the statement is evaluated: a response
|
|
1300
1490
|
// produced for another relying party, or without the user presence the caller
|
|
1301
1491
|
// requires, is not a message this call should spend a signature verification on
|
|
@@ -1327,6 +1517,14 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1327
1517
|
});
|
|
1328
1518
|
} catch (e) { return Promise.reject(e); }
|
|
1329
1519
|
return Promise.resolve().then(function () { return verifier(att, clientDataHash, vopts); })
|
|
1520
|
+
.then(function (res) {
|
|
1521
|
+
// What the clientData reader found, on the verdict rather than left for the caller to
|
|
1522
|
+
// recompute -- the same field the login verdict carries, for the same reason: `checked` is
|
|
1523
|
+
// where a comparison that ran is told from one that never did. Null when the caller supplied
|
|
1524
|
+
// only the digest, which is the honest answer: nothing read it.
|
|
1525
|
+
res.clientData = clientData;
|
|
1526
|
+
return res;
|
|
1527
|
+
})
|
|
1330
1528
|
.then(function (res) {
|
|
1331
1529
|
// Metadata governs when supplied; caller roots are the fallback for the models
|
|
1332
1530
|
// the catalogue does not cover. Whichever ran, the verdict SAYS which -- so a
|
|
@@ -1467,7 +1665,10 @@ function _applyMetadata(res, att, opts) {
|
|
|
1467
1665
|
if (mds.statusDenied(entry, md, tp[tp.length - 1], at)) {
|
|
1468
1666
|
throw _err("webauthn/metadata-status", "the metadata entry for " + identifier + " carries a disqualifying status report");
|
|
1469
1667
|
}
|
|
1470
|
-
|
|
1668
|
+
// The same three inputs the status check above used. metadataAnchors makes the same decision
|
|
1669
|
+
// for the callers who reach it directly, so handing it different inputs is how the two readings
|
|
1670
|
+
// would come to disagree about the same entry.
|
|
1671
|
+
var anchors = mds.metadataAnchors(entry, { metadata: md, time: at, certificate: tp[tp.length - 1] });
|
|
1471
1672
|
if (!anchors.length) throw _err("webauthn/metadata-no-anchor", "the metadata entry for " + identifier + " supplies no attestation root certificate");
|
|
1472
1673
|
applied.push({ entry: entry, anchors: anchors, identifier: identifier });
|
|
1473
1674
|
return { anchors: anchors, identifier: identifier };
|
|
@@ -1564,15 +1765,20 @@ function _applyMetadata(res, att, opts) {
|
|
|
1564
1765
|
});
|
|
1565
1766
|
}
|
|
1566
1767
|
|
|
1567
|
-
//
|
|
1568
|
-
//
|
|
1569
|
-
|
|
1570
|
-
|
|
1768
|
+
// Does this attestation carry a statement of the named format -- as the whole statement, or as one
|
|
1769
|
+
// element of a compound (sec. 8.9), whose own arm would apply a policy about that format?
|
|
1770
|
+
//
|
|
1771
|
+
// Every option that DEMANDS something only one format can produce asks this same question, so it is
|
|
1772
|
+
// asked in one place: a demand about a TPM public area and a demand about an android-safetynet
|
|
1773
|
+
// device-integrity signal differ only in the format they name. Answering it per option is how the
|
|
1774
|
+
// second one comes to be answered only for the simple case and not for a compound.
|
|
1775
|
+
function _formatCarries(att, fmt) {
|
|
1776
|
+
if (att.fmt === fmt) return true;
|
|
1571
1777
|
if (att.fmt !== "compound") return false;
|
|
1572
1778
|
return (att.attStmt.children || []).some(function (el) {
|
|
1573
1779
|
if (!el || el.majorType !== 5) return false;
|
|
1574
1780
|
var fN = cbor.read.mapGet(el, "fmt");
|
|
1575
|
-
return !!fN && fN.majorType === 3 && cbor.read.textString(fN) ===
|
|
1781
|
+
return !!fN && fN.majorType === 3 && cbor.read.textString(fN) === fmt;
|
|
1576
1782
|
});
|
|
1577
1783
|
}
|
|
1578
1784
|
|
|
@@ -1580,7 +1786,7 @@ void constants;
|
|
|
1580
1786
|
|
|
1581
1787
|
/**
|
|
1582
1788
|
* @primitive pki.webauthn.verifyMetadataBlob
|
|
1583
|
-
* @signature pki.webauthn.verifyMetadataBlob(blob, opts) -> Promise<{ no, nextUpdate, entries, byAaguid }>
|
|
1789
|
+
* @signature pki.webauthn.verifyMetadataBlob(blob, opts) -> Promise<{ no, legalHeader, nextUpdate, stale, allowStale, rollbackChecked, previousNo, entries, byAaguid, byKeyIdentifier, statusPolicy, rejectUnknownStatus }>
|
|
1584
1790
|
* @since 0.4.11
|
|
1585
1791
|
* @status stable
|
|
1586
1792
|
* @spec FIDO Metadata Service v3.0 sec. 3.1, RFC 7515
|
|
@@ -1598,6 +1804,12 @@ void constants;
|
|
|
1598
1804
|
* (freshness). Every failure is a typed `webauthn/metadata-*` throw, never a partial
|
|
1599
1805
|
* result.
|
|
1600
1806
|
*
|
|
1807
|
+
* The result says which of those rules actually ran, so a catalogue held for a while can
|
|
1808
|
+
* still answer for itself: `stale` and `allowStale` for freshness, `rollbackChecked` and
|
|
1809
|
+
* the `previousNo` it was compared against for rollback, `statusPolicy` and
|
|
1810
|
+
* `rejectUnknownStatus` for the status reading every later lookup will use. A rule that
|
|
1811
|
+
* did not run reads as not-run rather than as passed.
|
|
1812
|
+
*
|
|
1601
1813
|
* @intro No FIDO root ships with this toolkit and there is no trust-on-first-use:
|
|
1602
1814
|
* which metadata authority to trust is the operator's decision, exactly as a root
|
|
1603
1815
|
* store is for `pki.path.validate`. Supply the FIDO Alliance root you pin.
|
|
@@ -1661,21 +1873,40 @@ void constants;
|
|
|
1661
1873
|
|
|
1662
1874
|
/**
|
|
1663
1875
|
* @primitive pki.webauthn.metadataAnchors
|
|
1664
|
-
* @signature pki.webauthn.metadataAnchors(entry) -> [certificate]
|
|
1876
|
+
* @signature pki.webauthn.metadataAnchors(entry, opts?) -> [certificate]
|
|
1665
1877
|
* @since 0.4.11
|
|
1666
1878
|
* @status stable
|
|
1667
1879
|
* @spec FIDO Metadata Service v3.0 sec. 3.1.1
|
|
1880
|
+
* @defends webauthn-revoked-authenticator-accepted (CWE-299)
|
|
1668
1881
|
* @related pki.webauthn.metadataFor, pki.path.validate
|
|
1669
1882
|
*
|
|
1670
1883
|
* The parsed attestation root certificates a metadata entry registers -- the anchors an
|
|
1671
|
-
* attestation from that model must chain to.
|
|
1884
|
+
* attestation from that model must chain to. An entry whose status reports disqualify
|
|
1885
|
+
* the model registers none: the catalogue exists to say which authenticators are still
|
|
1886
|
+
* trusted, so handing back the roots of one it has revoked would answer a different
|
|
1887
|
+
* question than the caller asked. That refusal is `webauthn/metadata-status`.
|
|
1888
|
+
*
|
|
1889
|
+
* The judgement uses whatever the caller supplies and the strictest reading of what it
|
|
1890
|
+
* does not: pass the verified `metadata` and its own `statusPolicy` governs and its
|
|
1891
|
+
* freshness is re-checked, pass `time` and reports are judged as of that instant, pass
|
|
1892
|
+
* the `certificate` an attestation actually presented and a report naming a single
|
|
1893
|
+
* certificate is judged against that one rather than denying every device the entry
|
|
1894
|
+
* covers. With none of them: any disqualifying report denies, judged now.
|
|
1895
|
+
*
|
|
1896
|
+
* Decoding is per entry rather than for the
|
|
1672
1897
|
* whole BLOB on purpose: a handful of certificates in the live metadata do not parse
|
|
1673
1898
|
* under a strict decoder, and decoding everything up front would let one vendor's
|
|
1674
1899
|
* malformed root refuse the entire catalogue for every other authenticator in it.
|
|
1675
1900
|
*
|
|
1901
|
+
* @opts
|
|
1902
|
+
* metadata -- the verifyMetadataBlob result the entry came from
|
|
1903
|
+
* time -- the instant the status reports are judged at (default: now)
|
|
1904
|
+
* certificate -- the attestation certificate presented, for a report that names one
|
|
1905
|
+
*
|
|
1676
1906
|
* @example
|
|
1677
|
-
* // requires: `
|
|
1678
|
-
*
|
|
1907
|
+
* // requires: `mdsMetadata` -- a verifyMetadataBlob result; `mdsEntry` -- one of its
|
|
1908
|
+
* // entries, as metadataFor returns; `mdsTime` -- the instant to judge at
|
|
1909
|
+
* var anchors = pki.webauthn.metadataAnchors(mdsEntry, { metadata: mdsMetadata, time: mdsTime });
|
|
1679
1910
|
* anchors.length; // the attestation roots this model registered
|
|
1680
1911
|
* anchors[0].subject; // the decoded root DN
|
|
1681
1912
|
* // chain an attestation's trustPath to them:
|
|
@@ -1707,17 +1938,29 @@ var CLIENT_DATA_TYPE = Object.assign(Object.create(null), { "webauthn.create": 1
|
|
|
1707
1938
|
* Buffer, so a caller compares raw bytes and never two spellings of the same
|
|
1708
1939
|
* value; `type`, `origin`, `crossOrigin` and `topOrigin` come back as they were.
|
|
1709
1940
|
*
|
|
1710
|
-
* Supply `expectedType`, `expectedChallenge
|
|
1711
|
-
* checked here -- the challenge in constant time
|
|
1941
|
+
* Supply `expectedType`, `expectedChallenge`, `expectedOrigin` and
|
|
1942
|
+
* `expectedTopOrigin` and each is checked here -- the challenge in constant time
|
|
1943
|
+
* and by full value, the origins whole and case-sensitively. `checked`
|
|
1712
1944
|
* reports which ran, so a check that passed is distinguishable from one that never
|
|
1713
1945
|
* happened. `expectedType` is worth setting on every call: the ceremony a response
|
|
1714
1946
|
* belongs to is fixed, and accepting a `webauthn.create` where a `webauthn.get` was
|
|
1715
1947
|
* expected is a credential-registration response replayed as a login.
|
|
1716
1948
|
*
|
|
1949
|
+
* In a cross-origin ceremony `origin` is the framed document's and `topOrigin` is
|
|
1950
|
+
* the page that framed it, so a relying party that allows framing at all should say
|
|
1951
|
+
* which pages may do it. `expectedTopOrigin: null` requires an unframed ceremony,
|
|
1952
|
+
* which an origin list cannot express. Whether a ceremony was framed is stated by
|
|
1953
|
+
* BOTH `crossOrigin` and `topOrigin` and is only usable when they agree: a response
|
|
1954
|
+
* declaring itself cross-origin does not satisfy `null` by omitting the origin, and
|
|
1955
|
+
* one that does not declare itself cross-origin makes no framing claim for an origin
|
|
1956
|
+
* list to accept.
|
|
1957
|
+
*
|
|
1717
1958
|
* @opts
|
|
1718
1959
|
* expectedType -- "webauthn.create" or "webauthn.get"
|
|
1719
|
-
* expectedChallenge -- the challenge bytes this ceremony issued (
|
|
1960
|
+
* expectedChallenge -- the challenge bytes this ceremony issued (BufferSource)
|
|
1720
1961
|
* expectedOrigin -- the origin string, or an array of acceptable origins
|
|
1962
|
+
* expectedTopOrigin -- the acceptable top-level origin(s), or null to require an
|
|
1963
|
+
* unframed ceremony
|
|
1721
1964
|
*
|
|
1722
1965
|
* @example
|
|
1723
1966
|
* // requires: `clientDataJSON` -- credential.response.clientDataJSON;
|
|
@@ -1734,9 +1977,9 @@ function parseClientData(bytes, opts) {
|
|
|
1734
1977
|
opts = opts || {};
|
|
1735
1978
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1736
1979
|
guard.identifier.assertKnownKeys(opts, _CLIENT_DATA_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1980
|
+
// The RAW bytes, not a parsed object: this reader decides on the exact octets the authenticator
|
|
1981
|
+
// signed over, and re-serializing a parsed object would decide on different ones.
|
|
1982
|
+
bytes = _bytesArg(bytes, "clientDataJSON");
|
|
1740
1983
|
var doc = guard.json.parse(Buffer.from(bytes), _err, {
|
|
1741
1984
|
maxBytes: constants.LIMITS.JSON_MAX_BYTES, maxDepth: constants.LIMITS.JSON_MAX_DEPTH,
|
|
1742
1985
|
badJson: "webauthn/bad-client-data", tooDeep: "webauthn/bad-client-data",
|
|
@@ -1776,7 +2019,7 @@ function parseClientData(bytes, opts) {
|
|
|
1776
2019
|
throw _err("webauthn/bad-client-data", "clientDataJSON topOrigin must be a non-empty string when present (WebAuthn sec. 5.8.1)");
|
|
1777
2020
|
}
|
|
1778
2021
|
|
|
1779
|
-
var checked = { type: false, challenge: false, origin: false };
|
|
2022
|
+
var checked = { type: false, challenge: false, origin: false, topOrigin: false };
|
|
1780
2023
|
if (opts.expectedType !== undefined) {
|
|
1781
2024
|
if (CLIENT_DATA_TYPE[opts.expectedType] !== 1) {
|
|
1782
2025
|
throw _err("webauthn/bad-input", "opts.expectedType must be \"webauthn.create\" or \"webauthn.get\"");
|
|
@@ -1789,10 +2032,7 @@ function parseClientData(bytes, opts) {
|
|
|
1789
2032
|
checked.type = true;
|
|
1790
2033
|
}
|
|
1791
2034
|
if (opts.expectedChallenge !== undefined) {
|
|
1792
|
-
if (!
|
|
1793
|
-
throw _err("webauthn/bad-input", "opts.expectedChallenge must be the raw challenge bytes");
|
|
1794
|
-
}
|
|
1795
|
-
if (!guard.crypto.constantTimeEqual(Buffer.from(opts.expectedChallenge), challenge)) {
|
|
2035
|
+
if (!guard.crypto.constantTimeEqual(_bytesArg(opts.expectedChallenge, "opts.expectedChallenge"), challenge)) {
|
|
1796
2036
|
throw _err("webauthn/client-data-mismatch",
|
|
1797
2037
|
"the clientDataJSON challenge is not the one this ceremony issued (WebAuthn sec. 7.1 step 9 / sec. 7.2 step 12)");
|
|
1798
2038
|
}
|
|
@@ -1813,6 +2053,48 @@ function parseClientData(bytes, opts) {
|
|
|
1813
2053
|
}
|
|
1814
2054
|
checked.origin = true;
|
|
1815
2055
|
}
|
|
2056
|
+
// The top-level origin of a cross-origin ceremony. It is surfaced either way, but a value a
|
|
2057
|
+
// relying party only READS is a value nobody compares: `origin` in a cross-origin ceremony is the
|
|
2058
|
+
// iframe's, and the top-level page that framed it is the one the security decision is actually
|
|
2059
|
+
// about. Given what it accepts, this compares it the same way as `origin` -- whole, case
|
|
2060
|
+
// sensitive, against a list -- so the two cannot drift into different matching rules.
|
|
2061
|
+
//
|
|
2062
|
+
// The three cases are kept apart rather than collapsed. An expectation of `null` means "this
|
|
2063
|
+
// ceremony must NOT be framed", which is a real policy and cannot be expressed by an origin list;
|
|
2064
|
+
// a response carrying no topOrigin against a list of acceptable ones is not framed at all, so the
|
|
2065
|
+
// list has nothing to accept and the ceremony is refused rather than passed.
|
|
2066
|
+
if (opts.expectedTopOrigin !== undefined) {
|
|
2067
|
+
var wantTop = opts.expectedTopOrigin;
|
|
2068
|
+
// Whether the ceremony was framed is stated by TWO fields, and the answer is only usable when
|
|
2069
|
+
// they agree. `crossOrigin` says a framing happened; `topOrigin` names the page that did it.
|
|
2070
|
+
// Reading one and not the other lets a response that declares itself cross-origin, but omits
|
|
2071
|
+
// the origin, satisfy a policy of "must not be framed" -- it answers the caller's question with
|
|
2072
|
+
// the field it left out rather than the one it filled in.
|
|
2073
|
+
var framed = doc.crossOrigin === true;
|
|
2074
|
+
if (wantTop === null) {
|
|
2075
|
+
if (framed || doc.topOrigin !== undefined) {
|
|
2076
|
+
throw _err("webauthn/client-data-mismatch",
|
|
2077
|
+
"the clientDataJSON describes a cross-origin ceremony (crossOrigin " + JSON.stringify(doc.crossOrigin) +
|
|
2078
|
+
", topOrigin " + JSON.stringify(doc.topOrigin === undefined ? null : doc.topOrigin) +
|
|
2079
|
+
"), and this relying party requires an unframed one (WebAuthn sec. 5.8.1)");
|
|
2080
|
+
}
|
|
2081
|
+
} else {
|
|
2082
|
+
var allowedTop = Array.isArray(wantTop) ? wantTop : [wantTop];
|
|
2083
|
+
if (!allowedTop.length || !allowedTop.every(function (o) { return typeof o === "string" && o.length; })) {
|
|
2084
|
+
throw _err("webauthn/bad-input", "opts.expectedTopOrigin must be null, a non-empty origin string, or an array of them");
|
|
2085
|
+
}
|
|
2086
|
+
// Allow-listing the pages that may frame this ceremony is a statement about a framed one. A
|
|
2087
|
+
// response that does not say it was framed makes no such claim to match against, so the list
|
|
2088
|
+
// has nothing to accept -- and treating its absent topOrigin as a pass would report a check
|
|
2089
|
+
// that decided nothing.
|
|
2090
|
+
if (!framed || allowedTop.indexOf(doc.topOrigin) === -1) {
|
|
2091
|
+
throw _err("webauthn/client-data-mismatch",
|
|
2092
|
+
"the clientDataJSON topOrigin " + JSON.stringify(doc.topOrigin === undefined ? null : doc.topOrigin) +
|
|
2093
|
+
" (crossOrigin " + JSON.stringify(doc.crossOrigin) + ") is not a framing this relying party accepts (WebAuthn sec. 5.8.1)");
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
checked.topOrigin = true;
|
|
2097
|
+
}
|
|
1816
2098
|
return {
|
|
1817
2099
|
type: doc.type, challenge: challenge, origin: doc.origin,
|
|
1818
2100
|
crossOrigin: doc.crossOrigin === undefined ? false : doc.crossOrigin,
|
|
@@ -1821,14 +2103,14 @@ function parseClientData(bytes, opts) {
|
|
|
1821
2103
|
};
|
|
1822
2104
|
}
|
|
1823
2105
|
var _CLIENT_DATA_OPTS = Object.assign(Object.create(null), {
|
|
1824
|
-
expectedType: 1, expectedChallenge: 1, expectedOrigin: 1,
|
|
2106
|
+
expectedType: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
1825
2107
|
});
|
|
1826
2108
|
|
|
1827
2109
|
// ---- public: parseAuthenticatorData / verifyAssertion ------------------------
|
|
1828
2110
|
|
|
1829
2111
|
/**
|
|
1830
2112
|
* @primitive pki.webauthn.parseAuthenticatorData
|
|
1831
|
-
* @signature pki.webauthn.parseAuthenticatorData(bytes) -> { rpIdHash, flags, signCount, aaguid, credentialId, credentialPublicKey, extensions }
|
|
2113
|
+
* @signature pki.webauthn.parseAuthenticatorData(bytes) -> { rpIdHash, flags, signCount, aaguid, credentialId, credentialPublicKey, credentialPublicKeyBytes, extensions }
|
|
1832
2114
|
* @since 0.5.0
|
|
1833
2115
|
* @status experimental
|
|
1834
2116
|
* @spec W3C WebAuthn Level 3 sec. 6.1
|
|
@@ -1852,10 +2134,7 @@ var _CLIENT_DATA_OPTS = Object.assign(Object.create(null), {
|
|
|
1852
2134
|
* ad.signCount; // the authenticator's counter for this credential
|
|
1853
2135
|
*/
|
|
1854
2136
|
function parseAuthenticatorData(bytes) {
|
|
1855
|
-
|
|
1856
|
-
throw _err("webauthn/bad-input", "authenticatorData must be a Buffer or Uint8Array");
|
|
1857
|
-
}
|
|
1858
|
-
return _parseAuthData(guard.bytes.snapshotSource(bytes, WebauthnError, "webauthn/bad-input", "authenticatorData"), _err);
|
|
2137
|
+
return _parseAuthData(_bytesArg(bytes, "authenticatorData"), _err);
|
|
1859
2138
|
}
|
|
1860
2139
|
|
|
1861
2140
|
// The options pki.webauthn.verifyAssertion recognises, null-prototype for the same
|
|
@@ -1864,12 +2143,12 @@ var _ASSERT_OPTS = Object.assign(Object.create(null), {
|
|
|
1864
2143
|
authenticatorData: 1, clientDataHash: 1, clientDataJSON: 1, signature: 1,
|
|
1865
2144
|
credentialPublicKey: 1, previousSignCount: 1,
|
|
1866
2145
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
1867
|
-
expectedChallenge: 1, expectedOrigin: 1,
|
|
2146
|
+
expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
1868
2147
|
});
|
|
1869
2148
|
|
|
1870
2149
|
/**
|
|
1871
2150
|
* @primitive pki.webauthn.verifyAssertion
|
|
1872
|
-
* @signature pki.webauthn.verifyAssertion(input) -> Promise<{ signatureVerified, signCount, signCountChecked, flags, rpIdHash, bindingChecked }>
|
|
2151
|
+
* @signature pki.webauthn.verifyAssertion(input) -> Promise<{ signatureVerified, signCount, signCountChecked, flags, rpIdHash, extensions, bindingChecked, clientData }>
|
|
1873
2152
|
* @since 0.5.0
|
|
1874
2153
|
* @status experimental
|
|
1875
2154
|
* @spec W3C WebAuthn Level 3 sec. 7.2
|
|
@@ -1932,22 +2211,31 @@ function _snapshotAssertion(input) {
|
|
|
1932
2211
|
guard.identifier.assertKnownKeys(input, _ASSERT_OPTS, _err, "webauthn/bad-input", "verifyAssertion input has an unknown key ");
|
|
1933
2212
|
var out = {}, k;
|
|
1934
2213
|
for (k in input) { if (Object.prototype.hasOwnProperty.call(input, k)) out[k] = input[k]; }
|
|
2214
|
+
// The SAME set of byte forms the arguments accept. A snapshot list narrower than the accept list
|
|
2215
|
+
// leaves whatever falls in the gap aliased across the yield -- which is the one input a widened
|
|
2216
|
+
// accept list newly admits, so the two must be written from one predicate.
|
|
1935
2217
|
["authenticatorData", "clientDataJSON", "clientDataHash", "signature", "expectedChallenge"].forEach(function (f) {
|
|
1936
|
-
if (
|
|
1937
|
-
out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
1938
|
-
}
|
|
2218
|
+
if (_isBufferSource(out[f])) out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
1939
2219
|
});
|
|
1940
|
-
|
|
2220
|
+
// The BYTES form first. A Buffer satisfies the plain-object test below, so leaving it to that
|
|
2221
|
+
// branch copies its numeric indices into a `{0:.., 1:..}` object that is no longer a key at all
|
|
2222
|
+
// -- the stored credential silently becoming something the SPKI builder cannot read. It is
|
|
2223
|
+
// snapshotted here for the same reason the other byte inputs are: it is read after a yield.
|
|
2224
|
+
if (_isBufferSource(out.credentialPublicKey)) {
|
|
2225
|
+
out.credentialPublicKey = guard.bytes.snapshotSource(out.credentialPublicKey, WebauthnError,
|
|
2226
|
+
"webauthn/bad-input", "credentialPublicKey");
|
|
2227
|
+
} else if (_isPlainObject(out.credentialPublicKey)) {
|
|
1941
2228
|
var key = {}, kk;
|
|
1942
2229
|
for (kk in out.credentialPublicKey) {
|
|
1943
2230
|
if (!Object.prototype.hasOwnProperty.call(out.credentialPublicKey, kk)) continue;
|
|
1944
2231
|
var v = out.credentialPublicKey[kk];
|
|
1945
|
-
key[kk] = (
|
|
2232
|
+
key[kk] = _isBufferSource(v) ? guard.bytes.snapshotSource(v, WebauthnError, "webauthn/bad-input", "credentialPublicKey." + kk) : v;
|
|
1946
2233
|
}
|
|
1947
2234
|
out.credentialPublicKey = key;
|
|
1948
2235
|
}
|
|
1949
2236
|
if (Array.isArray(out.allowedAlgorithms)) out.allowedAlgorithms = out.allowedAlgorithms.slice();
|
|
1950
2237
|
if (Array.isArray(out.expectedOrigin)) out.expectedOrigin = out.expectedOrigin.slice();
|
|
2238
|
+
if (Array.isArray(out.expectedTopOrigin)) out.expectedTopOrigin = out.expectedTopOrigin.slice();
|
|
1951
2239
|
return out;
|
|
1952
2240
|
}
|
|
1953
2241
|
|
|
@@ -1963,9 +2251,7 @@ function verifyAssertion(input) {
|
|
|
1963
2251
|
return Promise.resolve().then(function () {
|
|
1964
2252
|
input = frozen;
|
|
1965
2253
|
var authData = parseAuthenticatorData(input.authenticatorData);
|
|
1966
|
-
|
|
1967
|
-
throw _err("webauthn/bad-input", "signature must be a Buffer or Uint8Array");
|
|
1968
|
-
}
|
|
2254
|
+
input.signature = _bytesArg(input.signature, "signature");
|
|
1969
2255
|
// The two forms of the same input, and NEITHER is inferred from the other's
|
|
1970
2256
|
// absence: supplying both invites them to disagree, and picking one would make
|
|
1971
2257
|
// the signature cover something the caller did not mean.
|
|
@@ -1975,9 +2261,7 @@ function verifyAssertion(input) {
|
|
|
1975
2261
|
}
|
|
1976
2262
|
var clientDataHash, clientData = null;
|
|
1977
2263
|
if (haveJson) {
|
|
1978
|
-
|
|
1979
|
-
throw _err("webauthn/bad-input", "clientDataJSON must be a Buffer or Uint8Array (the RAW bytes, not a parsed object)");
|
|
1980
|
-
}
|
|
2264
|
+
input.clientDataJSON = _bytesArg(input.clientDataJSON, "clientDataJSON");
|
|
1981
2265
|
// Given the JSON, this verb reads it -- the ceremony TYPE is checked
|
|
1982
2266
|
// unconditionally, because which ceremony a response belongs to is fixed by
|
|
1983
2267
|
// the spec rather than chosen by the caller, and a registration response
|
|
@@ -1987,22 +2271,32 @@ function verifyAssertion(input) {
|
|
|
1987
2271
|
expectedType: "webauthn.get",
|
|
1988
2272
|
expectedChallenge: input.expectedChallenge,
|
|
1989
2273
|
expectedOrigin: input.expectedOrigin,
|
|
2274
|
+
expectedTopOrigin: input.expectedTopOrigin,
|
|
1990
2275
|
});
|
|
1991
2276
|
clientDataHash = _sha("sha256", Buffer.from(input.clientDataJSON));
|
|
1992
2277
|
} else {
|
|
1993
|
-
|
|
2278
|
+
// Every expectation this verb forwards to the clientData reader is unanswerable without the
|
|
2279
|
+
// JSON, so each is refused here rather than silently going uncompared. The list is the
|
|
2280
|
+
// forwarded set above; a new expectation added there is added here too.
|
|
2281
|
+
if (input.expectedChallenge !== undefined || input.expectedOrigin !== undefined ||
|
|
2282
|
+
input.expectedTopOrigin !== undefined) {
|
|
1994
2283
|
throw _err("webauthn/bad-input",
|
|
1995
|
-
"expectedChallenge / expectedOrigin are checked against clientDataJSON, which this call
|
|
1996
|
-
"supply -- pass clientDataJSON instead of clientDataHash, or check them yourself");
|
|
1997
|
-
}
|
|
1998
|
-
if (!Buffer.isBuffer(input.clientDataHash) || input.clientDataHash.length !== 32) {
|
|
1999
|
-
throw _err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest");
|
|
2284
|
+
"expectedChallenge / expectedOrigin / expectedTopOrigin are checked against clientDataJSON, which this call " +
|
|
2285
|
+
"did not supply -- pass clientDataJSON instead of clientDataHash, or check them yourself");
|
|
2000
2286
|
}
|
|
2001
|
-
clientDataHash =
|
|
2287
|
+
clientDataHash = _bytesArg(input.clientDataHash, "clientDataHash");
|
|
2288
|
+
if (clientDataHash.length !== 32) throw _err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest");
|
|
2002
2289
|
}
|
|
2290
|
+
// Either form a relying party can be holding. `verify` hands back the parsed object, but the
|
|
2291
|
+
// durable form is BYTES: the object carries Buffers, so a JSON round trip through a datastore
|
|
2292
|
+
// returns {"type":"Buffer","data":[...]} rather than what went in, and every existing credential
|
|
2293
|
+
// store already holds the COSE bytes. Accepting only the object made a caller fabricate an
|
|
2294
|
+
// authenticatorData that never existed just to reach their own key.
|
|
2003
2295
|
var coseKey = input.credentialPublicKey;
|
|
2004
|
-
if (
|
|
2005
|
-
|
|
2296
|
+
if (Buffer.isBuffer(coseKey) || ArrayBuffer.isView(coseKey) || coseKey instanceof ArrayBuffer) {
|
|
2297
|
+
coseKey = parseCoseKey(coseKey);
|
|
2298
|
+
} else if (!_isPlainObject(coseKey)) {
|
|
2299
|
+
throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key -- the object pki.webauthn.verify returned, or its COSE bytes");
|
|
2006
2300
|
}
|
|
2007
2301
|
var bindingChecked = _applyBindings(authData, coseKey, input);
|
|
2008
2302
|
// The counter's SHAPE is a config-time question and is answered here; whether it
|
|
@@ -2027,14 +2321,26 @@ function verifyAssertion(input) {
|
|
|
2027
2321
|
// signature would let anyone raise that alarm with arbitrary bytes, so the
|
|
2028
2322
|
// counter is read only out of an assertion that proved to be authentic. The
|
|
2029
2323
|
// 0/0 case is an authenticator that implements no counter, which is permitted.
|
|
2324
|
+
// THREE outcomes, kept apart, because this field's only purpose is to tell a relying party
|
|
2325
|
+
// whether it has cloned-authenticator detection on this credential:
|
|
2326
|
+
// false -- not requested; no previousSignCount was supplied
|
|
2327
|
+
// "not-supported" -- requested, but the authenticator implements no counter (the 0/0 case
|
|
2328
|
+
// sec. 7.2 permits), so the comparison was deliberately skipped
|
|
2329
|
+
// true -- requested and performed
|
|
2330
|
+
// Reporting `true` for the waived case claimed a detection that cannot happen, and a bare
|
|
2331
|
+
// `false` there would be indistinguishable from never having asked.
|
|
2030
2332
|
var signCountChecked = false;
|
|
2031
2333
|
if (prev !== undefined) {
|
|
2032
|
-
if (
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2334
|
+
if (prev === 0 && authData.signCount === 0) {
|
|
2335
|
+
signCountChecked = "not-supported";
|
|
2336
|
+
} else {
|
|
2337
|
+
if (authData.signCount <= prev) {
|
|
2338
|
+
throw _err("webauthn/sign-count-not-advanced",
|
|
2339
|
+
"the assertion signCount " + authData.signCount + " does not advance past the stored " + prev +
|
|
2340
|
+
", which is the signal of a cloned authenticator (WebAuthn sec. 7.2 step 21)");
|
|
2341
|
+
}
|
|
2342
|
+
signCountChecked = true;
|
|
2036
2343
|
}
|
|
2037
|
-
signCountChecked = true;
|
|
2038
2344
|
}
|
|
2039
2345
|
return {
|
|
2040
2346
|
signatureVerified: true,
|
|
@@ -2057,6 +2363,7 @@ module.exports = {
|
|
|
2057
2363
|
parseAttestationObject: parseAttestationObject,
|
|
2058
2364
|
parseAuthenticatorData: parseAuthenticatorData,
|
|
2059
2365
|
parseClientData: parseClientData,
|
|
2366
|
+
parseCoseKey: parseCoseKey,
|
|
2060
2367
|
verify: verify,
|
|
2061
2368
|
verifyAssertion: verifyAssertion,
|
|
2062
2369
|
verifyMetadataBlob: mds.verifyMetadataBlob,
|