@blamejs/pki 0.5.2 → 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 +31 -1
- package/README.md +1 -1
- 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/sign-scheme.js +45 -6
- package/lib/validator-cose.js +39 -0
- package/lib/webauthn-mds.js +207 -23
- package/lib/webauthn.js +370 -140
- package/package.json +1 -1
- 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
|
|
|
@@ -180,10 +179,7 @@ function _decodeCoseKey(node) {
|
|
|
180
179
|
* // pass `stored` straight as its credentialPublicKey.
|
|
181
180
|
*/
|
|
182
181
|
function parseCoseKey(bytes) {
|
|
183
|
-
var buf =
|
|
184
|
-
if (!Buffer.isBuffer(buf)) {
|
|
185
|
-
throw _err("webauthn/bad-input", "parseCoseKey takes the stored COSE key bytes (a Buffer, TypedArray or ArrayBuffer)");
|
|
186
|
-
}
|
|
182
|
+
var buf = _bytesArg(bytes, "the COSE key");
|
|
187
183
|
var node;
|
|
188
184
|
try { node = cbor.decode(buf); }
|
|
189
185
|
catch (e) { throw _err("webauthn/bad-cose-key", "the stored credential key is not decodable CBOR", e); }
|
|
@@ -244,7 +240,7 @@ function _verifySig(alg, sig, spkiBytes, message, E) {
|
|
|
244
240
|
// low-order (e.g. all-zeroes) key verifies a trivial signature -- so validate the OKP
|
|
245
241
|
// point before verify. This covers EVERY key that signs a WebAuthn statement: the x5c
|
|
246
242
|
// attestation-certificate key (packed/tpm/apple) AND the self-attestation credential key.
|
|
247
|
-
if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name
|
|
243
|
+
if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name);
|
|
248
244
|
var s = d.ecdsa ? _derEcdsaToRaw(sig, d.imp.namedCurve) : sig;
|
|
249
245
|
return subtle.importKey("spki", spkiBytes, imp, false, ["verify"])
|
|
250
246
|
.then(function (key) { return subtle.verify(ver, key, s, message); })
|
|
@@ -262,17 +258,14 @@ function _edName(spkiBytes, E) {
|
|
|
262
258
|
if (!nm) throw E("webauthn/unsupported-algorithm", "unsupported EdDSA curve OID " + algOid);
|
|
263
259
|
return nm;
|
|
264
260
|
}
|
|
265
|
-
// The raw Edwards point an OKP SPKI carries
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (!edwardsPoint.validate(point, name === "Ed25519" ? 6 : 7)) {
|
|
274
|
-
throw E("webauthn/bad-signature", "the EdDSA public key is not a valid, full-order Edwards point");
|
|
275
|
-
}
|
|
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");
|
|
276
269
|
}
|
|
277
270
|
|
|
278
271
|
// A validated COSE credential key -> a self-contained SPKI the WebCrypto import
|
|
@@ -295,11 +288,39 @@ function _certEcCurveOid(cert, E) {
|
|
|
295
288
|
try { return asn1.read.oid(asn1.decode(params)); }
|
|
296
289
|
catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate EC curve is not a valid OBJECT IDENTIFIER", e); }
|
|
297
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
|
+
}
|
|
298
318
|
// A void assert: throws webauthn/key-mismatch on any inequality, returns nothing on
|
|
299
319
|
// success (called for its throw side-effect, like the other _check* asserts).
|
|
300
320
|
function _certPubKeyEqualsCose(cert, cose, E) {
|
|
301
321
|
var raw = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.publicKey && cert.subjectPublicKeyInfo.publicKey.bytes;
|
|
302
322
|
if (!raw) throw E("webauthn/key-mismatch", "the attestation certificate exposes no public key");
|
|
323
|
+
_assertCertKeyAlgorithm(cert, cose, E);
|
|
303
324
|
if (cose.kty === 2) {
|
|
304
325
|
// The certificate's declared EC curve MUST equal the credential key's curve --
|
|
305
326
|
// a curve substitution is a different key even if the coordinate bytes line up.
|
|
@@ -459,8 +480,15 @@ var _VERIFY_OPTS = Object.assign(Object.create(null), {
|
|
|
459
480
|
time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
|
|
460
481
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
461
482
|
rootCertificates: 1,
|
|
483
|
+
clientDataJSON: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
462
484
|
});
|
|
463
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
|
+
|
|
464
492
|
// Anchor an attestation's trust path to roots the CALLER pins. The metadata route
|
|
465
493
|
// resolves an authenticator's roots from the catalogue that registered it, which is
|
|
466
494
|
// the stronger source -- but it only reaches models the catalogue lists, and some
|
|
@@ -515,22 +543,34 @@ function _cloneParsed(v, depth) {
|
|
|
515
543
|
}
|
|
516
544
|
|
|
517
545
|
// A caller-owned byte argument, copied so nothing downstream reads bytes the caller can still
|
|
518
|
-
// rewrite
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
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)) {
|
|
524
560
|
return guard.bytes.snapshotSource(v, WebauthnError, "webauthn/bad-input", label);
|
|
525
561
|
}
|
|
526
|
-
|
|
562
|
+
throw _err("webauthn/bad-input", label + " must be a BufferSource (a Buffer, a typed-array view, or an ArrayBuffer)");
|
|
527
563
|
}
|
|
528
564
|
|
|
529
565
|
function _snapshotRoots(supplied) {
|
|
530
566
|
if (!Array.isArray(supplied)) return supplied;
|
|
531
567
|
return supplied.map(function (root) {
|
|
532
|
-
|
|
533
|
-
|
|
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[]");
|
|
534
574
|
}
|
|
535
575
|
if (root && typeof root === "object") return _cloneParsed(root, 0);
|
|
536
576
|
return root; // a PEM string is immutable
|
|
@@ -553,7 +593,7 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
|
553
593
|
// The same three forms opts.safetyNetRoots takes, since it is the same question.
|
|
554
594
|
var roots = supplied.map(function (root, i) {
|
|
555
595
|
var cert;
|
|
556
|
-
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); }
|
|
557
597
|
catch (e) { throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a decodable certificate", e); }
|
|
558
598
|
if (!cert || !cert.subject || !cert.subjectPublicKeyInfo) {
|
|
559
599
|
throw _err("webauthn/bad-input", "opts.rootCertificates[" + i + "] is not a certificate");
|
|
@@ -649,10 +689,6 @@ function _anchoredRoutes(res, base) {
|
|
|
649
689
|
// reports which of them actually ran. The challenge and the origin stay with the
|
|
650
690
|
// caller: they live in clientDataJSON, which the caller already holds and compares
|
|
651
691
|
// against state only it has.
|
|
652
|
-
var _BINDING_OPTS = Object.assign(Object.create(null), {
|
|
653
|
-
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
654
|
-
});
|
|
655
|
-
|
|
656
692
|
function _assertBool(v, name) {
|
|
657
693
|
if (typeof v !== "boolean") throw _err("webauthn/bad-input", "opts." + name + " must be a boolean");
|
|
658
694
|
}
|
|
@@ -905,10 +941,8 @@ var VERIFIERS = {
|
|
|
905
941
|
// existed. There is no bundled root and no trust-on-first-use.
|
|
906
942
|
"android-safetynet": function (att, clientDataHash, opts) {
|
|
907
943
|
opts = opts || {};
|
|
944
|
+
// The type is settled at the entry point, so what remains here is the opt-in itself.
|
|
908
945
|
if (opts.verifySafetyNetJws !== true) {
|
|
909
|
-
if (opts.verifySafetyNetJws !== undefined && typeof opts.verifySafetyNetJws !== "boolean") {
|
|
910
|
-
throw _err("webauthn/bad-input", "opts.verifySafetyNetJws must be a boolean");
|
|
911
|
-
}
|
|
912
946
|
throw _err("webauthn/unsupported-format", "attestation statement format 'android-safetynet' is not supported");
|
|
913
947
|
}
|
|
914
948
|
var roots = opts.safetyNetRoots;
|
|
@@ -992,9 +1026,6 @@ var VERIFIERS = {
|
|
|
992
1026
|
if (opts.requireCtsProfileMatch === true && signals.ctsProfileMatch !== true) {
|
|
993
1027
|
throw _err("webauthn/safetynet-cts-profile", "the android-safetynet response reports ctsProfileMatch " + JSON.stringify(signals.ctsProfileMatch) + ", and opts.requireCtsProfileMatch demands true");
|
|
994
1028
|
}
|
|
995
|
-
if (opts.requireCtsProfileMatch !== undefined && typeof opts.requireCtsProfileMatch !== "boolean") {
|
|
996
|
-
throw _err("webauthn/bad-input", "opts.requireCtsProfileMatch must be a boolean");
|
|
997
|
-
}
|
|
998
1029
|
|
|
999
1030
|
return _verifySig(-257, sigBytes, leaf.subjectPublicKeyInfo.bytes,
|
|
1000
1031
|
Buffer.from(segs[0] + "." + segs[1], "ascii"), _err).then(function (ok) {
|
|
@@ -1133,41 +1164,28 @@ function _safetyNetHostnameOk(leaf) {
|
|
|
1133
1164
|
// rotation; the first that validates wins, and if none does the attestation is refused. The chain
|
|
1134
1165
|
// goes through the full path validator rather than a signature-only walk, so an expired, revoked-by-
|
|
1135
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.
|
|
1136
1174
|
function _safetyNetChainTrusted(chain, roots, time) {
|
|
1137
|
-
var
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
return function () {
|
|
1175
|
+
var anchors;
|
|
1176
|
+
try {
|
|
1177
|
+
anchors = roots.map(function (root, i) {
|
|
1141
1178
|
var anchorCert;
|
|
1142
|
-
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); }
|
|
1143
1180
|
catch (e) { throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a decodable certificate", e); }
|
|
1144
1181
|
if (!anchorCert || !anchorCert.subject || !anchorCert.subjectPublicKeyInfo) {
|
|
1145
1182
|
throw _err("webauthn/bad-input", "opts.safetyNetRoots[" + i + "] is not a certificate");
|
|
1146
1183
|
}
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
guard.name.dnEqual(path[0].issuer, path[0].subject)) {
|
|
1153
|
-
path = path.slice(1);
|
|
1154
|
-
}
|
|
1155
|
-
return pathValidate.validate(path, {
|
|
1156
|
-
time: when,
|
|
1157
|
-
// The anchor's own KEY algorithm and its parameters, not the algorithm its issuer signed it
|
|
1158
|
-
// with: the validator carries these forward as the working public key, and a certificate
|
|
1159
|
-
// below the anchor may inherit its key parameters from them.
|
|
1160
|
-
trustAnchor: { name: anchorCert.subject, publicKey: anchorCert.subjectPublicKeyInfo.bytes,
|
|
1161
|
-
algorithm: anchorCert.subjectPublicKeyInfo.algorithm.oid,
|
|
1162
|
-
parameters: anchorCert.subjectPublicKeyInfo.algorithm.parameters },
|
|
1163
|
-
}).then(function (r) { return !!(r && r.valid); }, function () { return false; });
|
|
1164
|
-
};
|
|
1165
|
-
});
|
|
1166
|
-
return attempts.reduce(function (p, next) {
|
|
1167
|
-
return p.then(function (done) { return done ? true : next(); });
|
|
1168
|
-
}, Promise.resolve(false)).then(function (trusted) {
|
|
1169
|
-
if (!trusted) throw _err("webauthn/safetynet-cert-untrusted", "the android-safetynet x5c chain does not validate to any supplied root (opts.safetyNetRoots)");
|
|
1170
|
-
});
|
|
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");
|
|
1171
1189
|
}
|
|
1172
1190
|
|
|
1173
1191
|
// `chain` is the x5c order (leaf-first); trustPath is surfaced in pki.path.validate
|
|
@@ -1197,6 +1215,16 @@ function _result(fmt, attestationType, chain, att) {
|
|
|
1197
1215
|
credentialPublicKeyBytes: att.authData.credentialPublicKeyBytes,
|
|
1198
1216
|
signCount: att.authData.signCount,
|
|
1199
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,
|
|
1200
1228
|
};
|
|
1201
1229
|
}
|
|
1202
1230
|
|
|
@@ -1232,7 +1260,7 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1232
1260
|
|
|
1233
1261
|
/**
|
|
1234
1262
|
* @primitive pki.webauthn.verify
|
|
1235
|
-
* @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 }>
|
|
1236
1264
|
* @since 0.2.5
|
|
1237
1265
|
* @status stable
|
|
1238
1266
|
* @spec W3C WebAuthn Level 3 sec. 8 / sec. 7.1
|
|
@@ -1240,11 +1268,19 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1240
1268
|
*
|
|
1241
1269
|
* Verify a WebAuthn attestation statement: the attestation signature over
|
|
1242
1270
|
* `authenticatorData || clientDataHash` and (for the x5c formats) the format's
|
|
1243
|
-
* certificate requirements.
|
|
1244
|
-
* data, supplied by the relying party. Resolves the attestation type + trust path or
|
|
1271
|
+
* certificate requirements. Resolves the attestation type + trust path or
|
|
1245
1272
|
* throws a typed `webauthn/*` error; a signature that does not verify is a
|
|
1246
1273
|
* `webauthn/verify-failed` verdict, never a silent pass.
|
|
1247
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
|
+
*
|
|
1248
1284
|
* The verdict field is `attestationVerified`, and the name is the point: a sound
|
|
1249
1285
|
* attestation statement is not the same claim as an acceptable registration. The
|
|
1250
1286
|
* statement says nothing about WHICH relying party asked for it, or whether a user
|
|
@@ -1252,9 +1288,12 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1252
1288
|
* clear, is perfectly sound and must not be registered. Supply `expectedRpId`,
|
|
1253
1289
|
* `requireUserPresence`, `requireUserVerification` and `allowedAlgorithms` and those
|
|
1254
1290
|
* are checked here; `bindingChecked` reports which ran, so a check that passed can be
|
|
1255
|
-
* told from one that never happened.
|
|
1256
|
-
*
|
|
1257
|
-
*
|
|
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.
|
|
1258
1297
|
*
|
|
1259
1298
|
* The verdict also carries what a relying party must STORE to run a later login:
|
|
1260
1299
|
* `credentialId`, `credentialPublicKey` and the initial `signCount`. The credential key
|
|
@@ -1294,19 +1333,48 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1294
1333
|
* claim there is anything to anchor, so it is not a reason to refuse the statement,
|
|
1295
1334
|
* but it does mean "anchored" covered fewer elements than the statement holds.
|
|
1296
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
|
+
*
|
|
1297
1353
|
* @example
|
|
1298
|
-
* // requires: `attestationObject`
|
|
1299
|
-
* // `
|
|
1300
|
-
* 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",
|
|
1301
1360
|
* expectedRpId: "example.com", requireUserPresence: true,
|
|
1302
1361
|
* });
|
|
1303
|
-
* res.attestationVerified;
|
|
1304
|
-
* res.
|
|
1305
|
-
* res.
|
|
1306
|
-
*
|
|
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,
|
|
1307
1367
|
* // and anchor res.trustPath to your pinned roots with pki.path.validate
|
|
1308
1368
|
*/
|
|
1309
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
|
+
}
|
|
1310
1378
|
opts = opts || {};
|
|
1311
1379
|
// Every option here either GATES the verdict or supplies the trust material a gate needs, so a
|
|
1312
1380
|
// misspelled key is not a harmless no-op: `metdata` leaves the metadata gate switched off and the
|
|
@@ -1316,11 +1384,25 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1316
1384
|
try {
|
|
1317
1385
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1318
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);
|
|
1319
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
|
+
});
|
|
1320
1405
|
} catch (e) { return Promise.reject(e); }
|
|
1321
|
-
if (!Buffer.isBuffer(clientDataHash) || clientDataHash.length !== 32) {
|
|
1322
|
-
return Promise.reject(_err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest"));
|
|
1323
|
-
}
|
|
1324
1406
|
var att;
|
|
1325
1407
|
// Both byte inputs are snapshotted BEFORE anything reads them, for the same reason the trust
|
|
1326
1408
|
// anchors are: the attestation statement is not evaluated until a later promise turn, and both
|
|
@@ -1331,10 +1413,49 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1331
1413
|
// the statement to this ceremony; a caller who overwrote it in that gap would have the signature
|
|
1332
1414
|
// checked against a challenge and origin nobody agreed to, and the verdict would still report a
|
|
1333
1415
|
// sound attestation. A DataView or ArrayBuffer comes in by the same door and is copied too.
|
|
1334
|
-
var attBytes, cdh;
|
|
1416
|
+
var attBytes, cdh, clientData = null;
|
|
1335
1417
|
try {
|
|
1336
|
-
attBytes =
|
|
1337
|
-
|
|
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
|
+
}
|
|
1338
1459
|
} catch (e) { return Promise.reject(e); }
|
|
1339
1460
|
attestationObject = attBytes;
|
|
1340
1461
|
clientDataHash = cdh;
|
|
@@ -1354,9 +1475,17 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1354
1475
|
// demanded a TPM-bound key would accept a `none` attestation instead. The requirement therefore
|
|
1355
1476
|
// belongs at the dispatch, where it can refuse a format that cannot satisfy it, not in the arm
|
|
1356
1477
|
// that only runs once that format was already chosen.
|
|
1357
|
-
if (opts.tpmPolicy !== undefined && !
|
|
1478
|
+
if (opts.tpmPolicy !== undefined && !_formatCarries(att, "tpm")) {
|
|
1358
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"));
|
|
1359
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
|
+
}
|
|
1360
1489
|
// The ceremony bindings run BEFORE the statement is evaluated: a response
|
|
1361
1490
|
// produced for another relying party, or without the user presence the caller
|
|
1362
1491
|
// requires, is not a message this call should spend a signature verification on
|
|
@@ -1388,6 +1517,14 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1388
1517
|
});
|
|
1389
1518
|
} catch (e) { return Promise.reject(e); }
|
|
1390
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
|
+
})
|
|
1391
1528
|
.then(function (res) {
|
|
1392
1529
|
// Metadata governs when supplied; caller roots are the fallback for the models
|
|
1393
1530
|
// the catalogue does not cover. Whichever ran, the verdict SAYS which -- so a
|
|
@@ -1528,7 +1665,10 @@ function _applyMetadata(res, att, opts) {
|
|
|
1528
1665
|
if (mds.statusDenied(entry, md, tp[tp.length - 1], at)) {
|
|
1529
1666
|
throw _err("webauthn/metadata-status", "the metadata entry for " + identifier + " carries a disqualifying status report");
|
|
1530
1667
|
}
|
|
1531
|
-
|
|
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] });
|
|
1532
1672
|
if (!anchors.length) throw _err("webauthn/metadata-no-anchor", "the metadata entry for " + identifier + " supplies no attestation root certificate");
|
|
1533
1673
|
applied.push({ entry: entry, anchors: anchors, identifier: identifier });
|
|
1534
1674
|
return { anchors: anchors, identifier: identifier };
|
|
@@ -1625,15 +1765,20 @@ function _applyMetadata(res, att, opts) {
|
|
|
1625
1765
|
});
|
|
1626
1766
|
}
|
|
1627
1767
|
|
|
1628
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
|
|
1631
|
-
|
|
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;
|
|
1632
1777
|
if (att.fmt !== "compound") return false;
|
|
1633
1778
|
return (att.attStmt.children || []).some(function (el) {
|
|
1634
1779
|
if (!el || el.majorType !== 5) return false;
|
|
1635
1780
|
var fN = cbor.read.mapGet(el, "fmt");
|
|
1636
|
-
return !!fN && fN.majorType === 3 && cbor.read.textString(fN) ===
|
|
1781
|
+
return !!fN && fN.majorType === 3 && cbor.read.textString(fN) === fmt;
|
|
1637
1782
|
});
|
|
1638
1783
|
}
|
|
1639
1784
|
|
|
@@ -1641,7 +1786,7 @@ void constants;
|
|
|
1641
1786
|
|
|
1642
1787
|
/**
|
|
1643
1788
|
* @primitive pki.webauthn.verifyMetadataBlob
|
|
1644
|
-
* @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 }>
|
|
1645
1790
|
* @since 0.4.11
|
|
1646
1791
|
* @status stable
|
|
1647
1792
|
* @spec FIDO Metadata Service v3.0 sec. 3.1, RFC 7515
|
|
@@ -1659,6 +1804,12 @@ void constants;
|
|
|
1659
1804
|
* (freshness). Every failure is a typed `webauthn/metadata-*` throw, never a partial
|
|
1660
1805
|
* result.
|
|
1661
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
|
+
*
|
|
1662
1813
|
* @intro No FIDO root ships with this toolkit and there is no trust-on-first-use:
|
|
1663
1814
|
* which metadata authority to trust is the operator's decision, exactly as a root
|
|
1664
1815
|
* store is for `pki.path.validate`. Supply the FIDO Alliance root you pin.
|
|
@@ -1722,21 +1873,40 @@ void constants;
|
|
|
1722
1873
|
|
|
1723
1874
|
/**
|
|
1724
1875
|
* @primitive pki.webauthn.metadataAnchors
|
|
1725
|
-
* @signature pki.webauthn.metadataAnchors(entry) -> [certificate]
|
|
1876
|
+
* @signature pki.webauthn.metadataAnchors(entry, opts?) -> [certificate]
|
|
1726
1877
|
* @since 0.4.11
|
|
1727
1878
|
* @status stable
|
|
1728
1879
|
* @spec FIDO Metadata Service v3.0 sec. 3.1.1
|
|
1880
|
+
* @defends webauthn-revoked-authenticator-accepted (CWE-299)
|
|
1729
1881
|
* @related pki.webauthn.metadataFor, pki.path.validate
|
|
1730
1882
|
*
|
|
1731
1883
|
* The parsed attestation root certificates a metadata entry registers -- the anchors an
|
|
1732
|
-
* 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
|
|
1733
1897
|
* whole BLOB on purpose: a handful of certificates in the live metadata do not parse
|
|
1734
1898
|
* under a strict decoder, and decoding everything up front would let one vendor's
|
|
1735
1899
|
* malformed root refuse the entire catalogue for every other authenticator in it.
|
|
1736
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
|
+
*
|
|
1737
1906
|
* @example
|
|
1738
|
-
* // requires: `
|
|
1739
|
-
*
|
|
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 });
|
|
1740
1910
|
* anchors.length; // the attestation roots this model registered
|
|
1741
1911
|
* anchors[0].subject; // the decoded root DN
|
|
1742
1912
|
* // chain an attestation's trustPath to them:
|
|
@@ -1768,17 +1938,29 @@ var CLIENT_DATA_TYPE = Object.assign(Object.create(null), { "webauthn.create": 1
|
|
|
1768
1938
|
* Buffer, so a caller compares raw bytes and never two spellings of the same
|
|
1769
1939
|
* value; `type`, `origin`, `crossOrigin` and `topOrigin` come back as they were.
|
|
1770
1940
|
*
|
|
1771
|
-
* Supply `expectedType`, `expectedChallenge
|
|
1772
|
-
* 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`
|
|
1773
1944
|
* reports which ran, so a check that passed is distinguishable from one that never
|
|
1774
1945
|
* happened. `expectedType` is worth setting on every call: the ceremony a response
|
|
1775
1946
|
* belongs to is fixed, and accepting a `webauthn.create` where a `webauthn.get` was
|
|
1776
1947
|
* expected is a credential-registration response replayed as a login.
|
|
1777
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
|
+
*
|
|
1778
1958
|
* @opts
|
|
1779
1959
|
* expectedType -- "webauthn.create" or "webauthn.get"
|
|
1780
|
-
* expectedChallenge -- the challenge bytes this ceremony issued (
|
|
1960
|
+
* expectedChallenge -- the challenge bytes this ceremony issued (BufferSource)
|
|
1781
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
|
|
1782
1964
|
*
|
|
1783
1965
|
* @example
|
|
1784
1966
|
* // requires: `clientDataJSON` -- credential.response.clientDataJSON;
|
|
@@ -1795,9 +1977,9 @@ function parseClientData(bytes, opts) {
|
|
|
1795
1977
|
opts = opts || {};
|
|
1796
1978
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1797
1979
|
guard.identifier.assertKnownKeys(opts, _CLIENT_DATA_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
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");
|
|
1801
1983
|
var doc = guard.json.parse(Buffer.from(bytes), _err, {
|
|
1802
1984
|
maxBytes: constants.LIMITS.JSON_MAX_BYTES, maxDepth: constants.LIMITS.JSON_MAX_DEPTH,
|
|
1803
1985
|
badJson: "webauthn/bad-client-data", tooDeep: "webauthn/bad-client-data",
|
|
@@ -1837,7 +2019,7 @@ function parseClientData(bytes, opts) {
|
|
|
1837
2019
|
throw _err("webauthn/bad-client-data", "clientDataJSON topOrigin must be a non-empty string when present (WebAuthn sec. 5.8.1)");
|
|
1838
2020
|
}
|
|
1839
2021
|
|
|
1840
|
-
var checked = { type: false, challenge: false, origin: false };
|
|
2022
|
+
var checked = { type: false, challenge: false, origin: false, topOrigin: false };
|
|
1841
2023
|
if (opts.expectedType !== undefined) {
|
|
1842
2024
|
if (CLIENT_DATA_TYPE[opts.expectedType] !== 1) {
|
|
1843
2025
|
throw _err("webauthn/bad-input", "opts.expectedType must be \"webauthn.create\" or \"webauthn.get\"");
|
|
@@ -1850,10 +2032,7 @@ function parseClientData(bytes, opts) {
|
|
|
1850
2032
|
checked.type = true;
|
|
1851
2033
|
}
|
|
1852
2034
|
if (opts.expectedChallenge !== undefined) {
|
|
1853
|
-
if (!
|
|
1854
|
-
throw _err("webauthn/bad-input", "opts.expectedChallenge must be the raw challenge bytes");
|
|
1855
|
-
}
|
|
1856
|
-
if (!guard.crypto.constantTimeEqual(Buffer.from(opts.expectedChallenge), challenge)) {
|
|
2035
|
+
if (!guard.crypto.constantTimeEqual(_bytesArg(opts.expectedChallenge, "opts.expectedChallenge"), challenge)) {
|
|
1857
2036
|
throw _err("webauthn/client-data-mismatch",
|
|
1858
2037
|
"the clientDataJSON challenge is not the one this ceremony issued (WebAuthn sec. 7.1 step 9 / sec. 7.2 step 12)");
|
|
1859
2038
|
}
|
|
@@ -1874,6 +2053,48 @@ function parseClientData(bytes, opts) {
|
|
|
1874
2053
|
}
|
|
1875
2054
|
checked.origin = true;
|
|
1876
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
|
+
}
|
|
1877
2098
|
return {
|
|
1878
2099
|
type: doc.type, challenge: challenge, origin: doc.origin,
|
|
1879
2100
|
crossOrigin: doc.crossOrigin === undefined ? false : doc.crossOrigin,
|
|
@@ -1882,14 +2103,14 @@ function parseClientData(bytes, opts) {
|
|
|
1882
2103
|
};
|
|
1883
2104
|
}
|
|
1884
2105
|
var _CLIENT_DATA_OPTS = Object.assign(Object.create(null), {
|
|
1885
|
-
expectedType: 1, expectedChallenge: 1, expectedOrigin: 1,
|
|
2106
|
+
expectedType: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
1886
2107
|
});
|
|
1887
2108
|
|
|
1888
2109
|
// ---- public: parseAuthenticatorData / verifyAssertion ------------------------
|
|
1889
2110
|
|
|
1890
2111
|
/**
|
|
1891
2112
|
* @primitive pki.webauthn.parseAuthenticatorData
|
|
1892
|
-
* @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 }
|
|
1893
2114
|
* @since 0.5.0
|
|
1894
2115
|
* @status experimental
|
|
1895
2116
|
* @spec W3C WebAuthn Level 3 sec. 6.1
|
|
@@ -1913,10 +2134,7 @@ var _CLIENT_DATA_OPTS = Object.assign(Object.create(null), {
|
|
|
1913
2134
|
* ad.signCount; // the authenticator's counter for this credential
|
|
1914
2135
|
*/
|
|
1915
2136
|
function parseAuthenticatorData(bytes) {
|
|
1916
|
-
|
|
1917
|
-
throw _err("webauthn/bad-input", "authenticatorData must be a Buffer or Uint8Array");
|
|
1918
|
-
}
|
|
1919
|
-
return _parseAuthData(guard.bytes.snapshotSource(bytes, WebauthnError, "webauthn/bad-input", "authenticatorData"), _err);
|
|
2137
|
+
return _parseAuthData(_bytesArg(bytes, "authenticatorData"), _err);
|
|
1920
2138
|
}
|
|
1921
2139
|
|
|
1922
2140
|
// The options pki.webauthn.verifyAssertion recognises, null-prototype for the same
|
|
@@ -1925,12 +2143,12 @@ var _ASSERT_OPTS = Object.assign(Object.create(null), {
|
|
|
1925
2143
|
authenticatorData: 1, clientDataHash: 1, clientDataJSON: 1, signature: 1,
|
|
1926
2144
|
credentialPublicKey: 1, previousSignCount: 1,
|
|
1927
2145
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
1928
|
-
expectedChallenge: 1, expectedOrigin: 1,
|
|
2146
|
+
expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
1929
2147
|
});
|
|
1930
2148
|
|
|
1931
2149
|
/**
|
|
1932
2150
|
* @primitive pki.webauthn.verifyAssertion
|
|
1933
|
-
* @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 }>
|
|
1934
2152
|
* @since 0.5.0
|
|
1935
2153
|
* @status experimental
|
|
1936
2154
|
* @spec W3C WebAuthn Level 3 sec. 7.2
|
|
@@ -1993,17 +2211,17 @@ function _snapshotAssertion(input) {
|
|
|
1993
2211
|
guard.identifier.assertKnownKeys(input, _ASSERT_OPTS, _err, "webauthn/bad-input", "verifyAssertion input has an unknown key ");
|
|
1994
2212
|
var out = {}, k;
|
|
1995
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.
|
|
1996
2217
|
["authenticatorData", "clientDataJSON", "clientDataHash", "signature", "expectedChallenge"].forEach(function (f) {
|
|
1997
|
-
if (
|
|
1998
|
-
out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
1999
|
-
}
|
|
2218
|
+
if (_isBufferSource(out[f])) out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
2000
2219
|
});
|
|
2001
2220
|
// The BYTES form first. A Buffer satisfies the plain-object test below, so leaving it to that
|
|
2002
2221
|
// branch copies its numeric indices into a `{0:.., 1:..}` object that is no longer a key at all
|
|
2003
2222
|
// -- the stored credential silently becoming something the SPKI builder cannot read. It is
|
|
2004
2223
|
// snapshotted here for the same reason the other byte inputs are: it is read after a yield.
|
|
2005
|
-
if (
|
|
2006
|
-
out.credentialPublicKey instanceof ArrayBuffer) {
|
|
2224
|
+
if (_isBufferSource(out.credentialPublicKey)) {
|
|
2007
2225
|
out.credentialPublicKey = guard.bytes.snapshotSource(out.credentialPublicKey, WebauthnError,
|
|
2008
2226
|
"webauthn/bad-input", "credentialPublicKey");
|
|
2009
2227
|
} else if (_isPlainObject(out.credentialPublicKey)) {
|
|
@@ -2011,12 +2229,13 @@ function _snapshotAssertion(input) {
|
|
|
2011
2229
|
for (kk in out.credentialPublicKey) {
|
|
2012
2230
|
if (!Object.prototype.hasOwnProperty.call(out.credentialPublicKey, kk)) continue;
|
|
2013
2231
|
var v = out.credentialPublicKey[kk];
|
|
2014
|
-
key[kk] = (
|
|
2232
|
+
key[kk] = _isBufferSource(v) ? guard.bytes.snapshotSource(v, WebauthnError, "webauthn/bad-input", "credentialPublicKey." + kk) : v;
|
|
2015
2233
|
}
|
|
2016
2234
|
out.credentialPublicKey = key;
|
|
2017
2235
|
}
|
|
2018
2236
|
if (Array.isArray(out.allowedAlgorithms)) out.allowedAlgorithms = out.allowedAlgorithms.slice();
|
|
2019
2237
|
if (Array.isArray(out.expectedOrigin)) out.expectedOrigin = out.expectedOrigin.slice();
|
|
2238
|
+
if (Array.isArray(out.expectedTopOrigin)) out.expectedTopOrigin = out.expectedTopOrigin.slice();
|
|
2020
2239
|
return out;
|
|
2021
2240
|
}
|
|
2022
2241
|
|
|
@@ -2032,9 +2251,7 @@ function verifyAssertion(input) {
|
|
|
2032
2251
|
return Promise.resolve().then(function () {
|
|
2033
2252
|
input = frozen;
|
|
2034
2253
|
var authData = parseAuthenticatorData(input.authenticatorData);
|
|
2035
|
-
|
|
2036
|
-
throw _err("webauthn/bad-input", "signature must be a Buffer or Uint8Array");
|
|
2037
|
-
}
|
|
2254
|
+
input.signature = _bytesArg(input.signature, "signature");
|
|
2038
2255
|
// The two forms of the same input, and NEITHER is inferred from the other's
|
|
2039
2256
|
// absence: supplying both invites them to disagree, and picking one would make
|
|
2040
2257
|
// the signature cover something the caller did not mean.
|
|
@@ -2044,9 +2261,7 @@ function verifyAssertion(input) {
|
|
|
2044
2261
|
}
|
|
2045
2262
|
var clientDataHash, clientData = null;
|
|
2046
2263
|
if (haveJson) {
|
|
2047
|
-
|
|
2048
|
-
throw _err("webauthn/bad-input", "clientDataJSON must be a Buffer or Uint8Array (the RAW bytes, not a parsed object)");
|
|
2049
|
-
}
|
|
2264
|
+
input.clientDataJSON = _bytesArg(input.clientDataJSON, "clientDataJSON");
|
|
2050
2265
|
// Given the JSON, this verb reads it -- the ceremony TYPE is checked
|
|
2051
2266
|
// unconditionally, because which ceremony a response belongs to is fixed by
|
|
2052
2267
|
// the spec rather than chosen by the caller, and a registration response
|
|
@@ -2056,18 +2271,21 @@ function verifyAssertion(input) {
|
|
|
2056
2271
|
expectedType: "webauthn.get",
|
|
2057
2272
|
expectedChallenge: input.expectedChallenge,
|
|
2058
2273
|
expectedOrigin: input.expectedOrigin,
|
|
2274
|
+
expectedTopOrigin: input.expectedTopOrigin,
|
|
2059
2275
|
});
|
|
2060
2276
|
clientDataHash = _sha("sha256", Buffer.from(input.clientDataJSON));
|
|
2061
2277
|
} else {
|
|
2062
|
-
|
|
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) {
|
|
2063
2283
|
throw _err("webauthn/bad-input",
|
|
2064
|
-
"expectedChallenge / expectedOrigin are checked against clientDataJSON, which this call
|
|
2065
|
-
"supply -- pass clientDataJSON instead of clientDataHash, or check them yourself");
|
|
2066
|
-
}
|
|
2067
|
-
if (!Buffer.isBuffer(input.clientDataHash) || input.clientDataHash.length !== 32) {
|
|
2068
|
-
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");
|
|
2069
2286
|
}
|
|
2070
|
-
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");
|
|
2071
2289
|
}
|
|
2072
2290
|
// Either form a relying party can be holding. `verify` hands back the parsed object, but the
|
|
2073
2291
|
// durable form is BYTES: the object carries Buffers, so a JSON round trip through a datastore
|
|
@@ -2103,14 +2321,26 @@ function verifyAssertion(input) {
|
|
|
2103
2321
|
// signature would let anyone raise that alarm with arbitrary bytes, so the
|
|
2104
2322
|
// counter is read only out of an assertion that proved to be authentic. The
|
|
2105
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.
|
|
2106
2332
|
var signCountChecked = false;
|
|
2107
2333
|
if (prev !== undefined) {
|
|
2108
|
-
if (
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
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;
|
|
2112
2343
|
}
|
|
2113
|
-
signCountChecked = true;
|
|
2114
2344
|
}
|
|
2115
2345
|
return {
|
|
2116
2346
|
signatureVerified: true,
|