@blamejs/pki 0.6.13 → 0.6.15
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 +22 -1
- package/README.md +1 -1
- package/index.js +1 -1
- package/lib/acme.js +59 -815
- package/lib/asn1-der.js +7 -245
- package/lib/attrcert-sign.js +12 -260
- package/lib/attrcert-verify.js +1 -7
- package/lib/byte-reader.js +0 -8
- package/lib/byte-writer.js +0 -11
- package/lib/cbor-det.js +0 -54
- package/lib/cmc-build.js +15 -357
- package/lib/cmc-verify.js +6 -239
- package/lib/cmp-build.js +41 -317
- package/lib/cmp-session.js +73 -760
- package/lib/cmp-verify.js +32 -488
- package/lib/cms-compress.js +3 -32
- package/lib/cms-decrypt.js +29 -294
- package/lib/cms-encrypt.js +14 -234
- package/lib/cms-sign.js +29 -324
- package/lib/cms-verify.js +9 -500
- package/lib/composite-kem.js +2 -102
- package/lib/composite-sig.js +1 -77
- package/lib/constants.js +0 -249
- package/lib/crl-sign.js +9 -237
- package/lib/crl-verify.js +1 -10
- package/lib/crmf-sign.js +19 -198
- package/lib/crmf-verify.js +1 -12
- package/lib/csr-sign.js +3 -34
- package/lib/csr-verify.js +1 -12
- package/lib/ct.js +28 -253
- package/lib/edwards-point.js +10 -44
- package/lib/est.js +28 -764
- package/lib/framework-error.js +4 -182
- package/lib/guard-all.js +1 -58
- package/lib/guard-async.js +1 -36
- package/lib/guard-bytes.js +6 -650
- package/lib/guard-compress.js +5 -102
- package/lib/guard-crypto.js +1 -40
- package/lib/guard-der.js +2 -93
- package/lib/guard-encoding.js +2 -60
- package/lib/guard-header.js +1 -36
- package/lib/guard-identifier.js +7 -531
- package/lib/guard-intrinsic.js +1 -150
- package/lib/guard-json.js +10 -73
- package/lib/guard-limits.js +1 -77
- package/lib/guard-list.js +1 -49
- package/lib/guard-name.js +3 -209
- package/lib/guard-parsed.js +7 -335
- package/lib/guard-range.js +1 -71
- package/lib/guard-secret.js +1 -102
- package/lib/guard-text.js +1 -64
- package/lib/guard-time.js +1 -97
- package/lib/hpke.js +3 -105
- package/lib/http-digest.js +14 -142
- package/lib/http-retry-after.js +1 -47
- package/lib/http-transport.js +21 -131
- package/lib/inspect.js +30 -284
- package/lib/ip-utils.js +6 -47
- package/lib/jose.js +7 -142
- package/lib/key.js +2 -67
- package/lib/lint.js +9 -194
- package/lib/merkle.js +1 -27
- package/lib/mime.js +9 -82
- package/lib/ocsp-request-verify.js +1 -14
- package/lib/ocsp-verify.js +1 -82
- package/lib/ocsp.js +11 -169
- package/lib/oid.js +5 -337
- package/lib/path-validate.js +103 -1492
- package/lib/pbes2.js +2 -89
- package/lib/pkcs12-build.js +31 -379
- package/lib/pki-build.js +64 -290
- package/lib/rc2.js +2 -18
- package/lib/rfc3339.js +4 -27
- package/lib/scep.js +59 -233
- package/lib/schema-all.js +0 -122
- package/lib/schema-attrcert.js +4 -146
- package/lib/schema-c509.js +153 -802
- package/lib/schema-cmc.js +5 -220
- package/lib/schema-cmp.js +2 -223
- package/lib/schema-cms.js +13 -427
- package/lib/schema-crl.js +5 -68
- package/lib/schema-crmf.js +6 -167
- package/lib/schema-csr.js +1 -54
- package/lib/schema-csrattrs.js +2 -87
- package/lib/schema-engine.js +0 -152
- package/lib/schema-ocsp.js +0 -117
- package/lib/schema-pkcs12.js +2 -93
- package/lib/schema-pkcs8.js +3 -35
- package/lib/schema-pkix.js +24 -655
- package/lib/schema-smime.js +0 -54
- package/lib/schema-tsp.js +3 -99
- package/lib/schema-x509.js +4 -63
- package/lib/shbs.js +11 -95
- package/lib/sign-scheme.js +2 -97
- package/lib/sigstore.js +9 -169
- package/lib/sleep.js +2 -8
- package/lib/smime.js +15 -408
- package/lib/tls-cert-compress.js +0 -62
- package/lib/trust.js +19 -185
- package/lib/tsp-sign.js +16 -270
- package/lib/validator-all.js +1 -30
- package/lib/validator-attcert.js +2 -46
- package/lib/validator-cose.js +2 -153
- package/lib/validator-keydesc.js +1 -37
- package/lib/validator-sig.js +1 -46
- package/lib/validator-tls.js +1 -88
- package/lib/validator-tpm.js +19 -122
- package/lib/verify-seam.js +1 -24
- package/lib/webauthn-mds.js +3 -400
- package/lib/webauthn.js +12 -833
- package/lib/webcrypto.js +5 -485
- package/lib/x509-sign.js +4 -108
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/webauthn.js
CHANGED
|
@@ -35,8 +35,6 @@ var validator = require("./validator-all");
|
|
|
35
35
|
var edwardsPoint = require("./edwards-point");
|
|
36
36
|
var guard = require("./guard-all");
|
|
37
37
|
var intrinsic = require("./guard-intrinsic");
|
|
38
|
-
// Own-key membership through an operation taken at load: written out it reads two replaceable
|
|
39
|
-
// properties to answer one question, and either answering true admits a name the table never had.
|
|
40
38
|
var _hasOwn = intrinsic.hasOwn;
|
|
41
39
|
var jose = require("./jose");
|
|
42
40
|
var mds = require("./webauthn-mds");
|
|
@@ -44,9 +42,6 @@ var nodeCrypto = require("crypto");
|
|
|
44
42
|
|
|
45
43
|
var WebauthnError = frameworkError.WebauthnError;
|
|
46
44
|
function _err(code, message, cause) { return new WebauthnError(code, message, cause); }
|
|
47
|
-
// The shared certificate-extension decoder registry (keyed by OID), so an
|
|
48
|
-
// attestation-certificate extension (extKeyUsage, subjectAltName) is decoded by the
|
|
49
|
-
// same fail-closed pkix decoder every other format uses, not a local hand-roll.
|
|
50
45
|
var NS = pkix.makeNS("webauthn", WebauthnError, oid);
|
|
51
46
|
var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
|
|
52
47
|
function _decodeExt(cert, oidName) {
|
|
@@ -58,25 +53,12 @@ function _decodeExt(cert, oidName) {
|
|
|
58
53
|
}
|
|
59
54
|
var subtle = webcrypto.webcrypto.subtle;
|
|
60
55
|
|
|
61
|
-
// A one-shot digest over a bounded buffer (the attestation nonce / TPM Name / TPM
|
|
62
|
-
// extraData preimages), by the node hash name the caller has already resolved.
|
|
63
56
|
function _sha(name, buf) { return nodeCrypto.createHash(name).update(buf).digest(); }
|
|
64
|
-
// Unsigned big-endian magnitude compare: strip leading zero octets, then byte-equal.
|
|
65
|
-
// TPM2B buffers and COSE fixed-length coordinates can differ by a leading 0x00, so a
|
|
66
|
-
// raw memcmp would spuriously reject an equal key (WebAuthn 8.3, item 22).
|
|
67
57
|
function _ucmp(a, b) {
|
|
68
58
|
function strip(x) { var i = 0; while (i < x.length - 1 && x[i] === 0) i++; return x.subarray(i); }
|
|
69
59
|
return Buffer.compare(strip(a), strip(b)) === 0;
|
|
70
60
|
}
|
|
71
|
-
// A decoded node is a primitive universal INTEGER (so `.content` is a real buffer,
|
|
72
|
-
// not null as it is for a constructed node).
|
|
73
61
|
function _isInteger(node) { return !!node && !node.constructed && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.INTEGER; }
|
|
74
|
-
// alg -> the digest a TPM attestation's certInfo.extraData is taken under (its only
|
|
75
|
-
// consumer). Every ECDSA/RSA algorithm a TPM AIK may sign with, including the RFC 9864
|
|
76
|
-
// fully-specified ECDSA ids (ESP256/384/512) -- MUST appear here, or the TPM extraData
|
|
77
|
-
// step rejects the attestation before the signature is evaluated. EdDSA (-8/-19/-53) is
|
|
78
|
-
// absent by design: a TPM 2.0 AIK never signs with EdDSA, so such an attestation is
|
|
79
|
-
// correctly refused.
|
|
80
62
|
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" };
|
|
81
63
|
function _coseAlgHash(alg, E) {
|
|
82
64
|
var h = COSE_ALG_HASH[String(alg)];
|
|
@@ -84,11 +66,7 @@ function _coseAlgHash(alg, E) {
|
|
|
84
66
|
return h;
|
|
85
67
|
}
|
|
86
68
|
|
|
87
|
-
// ---- authenticatorData bounded reader (WebAuthn sec. 6.1) --------------------
|
|
88
69
|
|
|
89
|
-
// authData = rpIdHash[32] || flags[1] || signCount[4 BE] || (AT? attestedCredentialData) || (ED? extensions CBOR).
|
|
90
|
-
// A bounded big-endian read: every slice is length-checked before it is taken, so a
|
|
91
|
-
// truncated / oversize field fails closed and never reads past the buffer.
|
|
92
70
|
var AAGUID_LEN = 16;
|
|
93
71
|
function _parseAuthData(buf, E) {
|
|
94
72
|
if (!Buffer.isBuffer(buf) || buf.length < 37) throw E("webauthn/bad-auth-data", "authenticatorData is shorter than the 37-byte minimum (RFC WebAuthn sec. 6.1)");
|
|
@@ -99,12 +77,7 @@ function _parseAuthData(buf, E) {
|
|
|
99
77
|
signCount: buf.readUInt32BE(33),
|
|
100
78
|
aaguid: null, credentialId: null, credentialPublicKey: null, credentialPublicKeyBytes: null, extensions: null,
|
|
101
79
|
};
|
|
102
|
-
// Backup State is only valid when Backup Eligibility is set: a credential cannot be
|
|
103
|
-
// "backed up" if it is not "backup eligible" (WebAuthn sec. 6.1).
|
|
104
80
|
if (out.flags.bs && !out.flags.be) throw E("webauthn/bad-auth-data", "authenticatorData sets Backup State (BS) without Backup Eligibility (BE) (WebAuthn sec. 6.1)");
|
|
105
|
-
// The reserved flag bits (bit 1 = 0x02, bit 5 = 0x20) are undefined; a conforming
|
|
106
|
-
// authenticator leaves them 0. Reject a set reserved bit; never ignore an
|
|
107
|
-
// unknown flag (WebAuthn sec. 6.1, fail-closed on undefined structure).
|
|
108
81
|
if (flags & 0x22) throw E("webauthn/bad-auth-data", "authenticatorData sets a reserved (RFU) flag bit (WebAuthn sec. 6.1)");
|
|
109
82
|
var off = 37;
|
|
110
83
|
if (out.flags.at) {
|
|
@@ -114,7 +87,6 @@ function _parseAuthData(buf, E) {
|
|
|
114
87
|
if (credLen < 1 || credLen > 1023) throw E("webauthn/bad-credential-id", "credentialIdLength " + credLen + " is outside 1..1023 (RFC WebAuthn sec. 6.1)");
|
|
115
88
|
if (buf.length < off + credLen) throw E("webauthn/bad-auth-data", "credentialId overruns authenticatorData");
|
|
116
89
|
out.credentialId = buf.subarray(off, off + credLen); off += credLen;
|
|
117
|
-
// credentialPublicKey is a COSE_Key: a single CBOR map occupying [off, its end).
|
|
118
90
|
var keyNode;
|
|
119
91
|
try { keyNode = cbor.decode(buf.subarray(off), { allowTrailing: true }); }
|
|
120
92
|
catch (e) { throw E("webauthn/bad-cose-key", "the credential public key is not well-formed CBOR", e); }
|
|
@@ -123,28 +95,18 @@ function _parseAuthData(buf, E) {
|
|
|
123
95
|
off += keyNode.bytes.length;
|
|
124
96
|
}
|
|
125
97
|
if (out.flags.ed) {
|
|
126
|
-
// With the ED flag set, the remainder MUST be exactly one well-formed CBOR map
|
|
127
|
-
// (the extensions); the strict decoder rejects a non-map, malformed bytes, an
|
|
128
|
-
// empty remainder, or trailing bytes (RFC WebAuthn sec. 6.1).
|
|
129
98
|
var extNode;
|
|
130
99
|
try { extNode = cbor.decode(buf.subarray(off)); }
|
|
131
100
|
catch (e) { throw E("webauthn/bad-auth-data", "the authenticatorData extensions are not a single well-formed CBOR map", e); }
|
|
132
101
|
if (extNode.majorType !== 5) throw E("webauthn/bad-auth-data", "the authenticatorData extensions must be a CBOR map");
|
|
133
102
|
out.extensions = buf.subarray(off);
|
|
134
103
|
} else if (off < buf.length) {
|
|
135
|
-
// authenticatorData is fixed-layout: with the ED flag clear there MUST be no
|
|
136
|
-
// bytes after the attestedCredentialData (RFC WebAuthn sec. 6.1). Trailing
|
|
137
|
-
// bytes are a malformed structure, so fail closed and never ignore them.
|
|
138
104
|
throw E("webauthn/bad-auth-data", "authenticatorData has trailing bytes after attestedCredentialData with the ED flag clear");
|
|
139
105
|
}
|
|
140
106
|
return out;
|
|
141
107
|
}
|
|
142
108
|
|
|
143
|
-
// ---- COSE_Key decode (WebAuthn sec. 6.5.1, RFC 9052 sec. 7) ------------------
|
|
144
109
|
|
|
145
|
-
// The complete COSE credential-key conformance rule set (kty/alg/crv/length/canonical/
|
|
146
|
-
// profile/on-curve) lives in validator-cose, composed here so every credential key
|
|
147
|
-
// routes through the one home -- never a per-format re-derivation of a partial subset.
|
|
148
110
|
function _decodeCoseKey(node) {
|
|
149
111
|
return validator.cose.credentialKey(node, WebauthnError, "webauthn/bad-cose-key", "webauthn/unsupported-algorithm");
|
|
150
112
|
}
|
|
@@ -191,20 +153,12 @@ function parseCoseKey(bytes) {
|
|
|
191
153
|
return _decodeCoseKey(node);
|
|
192
154
|
}
|
|
193
155
|
|
|
194
|
-
// ---- signature verification bridge ------------------------------------------
|
|
195
156
|
|
|
196
|
-
// COSE algorithm id -> the WebCrypto import + verify descriptor. WebAuthn packed /
|
|
197
|
-
// android-key / tpm ECDSA signatures are DER ECDSA-Sig-Value, so they are converted
|
|
198
|
-
// to the raw r||s form pki.webcrypto (ieee-p1363) expects.
|
|
199
157
|
var COSE_ALG = {
|
|
200
158
|
"-7": { imp: { name: "ECDSA", namedCurve: "P-256" }, verify: { name: "ECDSA", hash: "SHA-256" }, ecdsa: 32 },
|
|
201
159
|
"-35": { imp: { name: "ECDSA", namedCurve: "P-384" }, verify: { name: "ECDSA", hash: "SHA-384" }, ecdsa: 48 },
|
|
202
160
|
"-36": { imp: { name: "ECDSA", namedCurve: "P-521" }, verify: { name: "ECDSA", hash: "SHA-512" }, ecdsa: 66 },
|
|
203
161
|
"-8": { imp: { name: "Ed25519" }, verify: { name: "Ed25519" }, ecdsa: 0 },
|
|
204
|
-
// RFC 9864 fully-specified ids. WebAuthn recommends against them for credential creation,
|
|
205
|
-
// but a verifier MUST evaluate an assertion signed under one. ESP256/384/512 are the
|
|
206
|
-
// curve-pinned ECDSA twins of ES256/384/512; Ed25519(-19)/Ed448(-53) are fully-specified
|
|
207
|
-
// EdDSA (Ed448 is the ONLY WebAuthn path to Ed448 -- -8 is Ed25519 only).
|
|
208
162
|
"-9": { imp: { name: "ECDSA", namedCurve: "P-256" }, verify: { name: "ECDSA", hash: "SHA-256" }, ecdsa: 32 },
|
|
209
163
|
"-51": { imp: { name: "ECDSA", namedCurve: "P-384" }, verify: { name: "ECDSA", hash: "SHA-384" }, ecdsa: 48 },
|
|
210
164
|
"-52": { imp: { name: "ECDSA", namedCurve: "P-521" }, verify: { name: "ECDSA", hash: "SHA-512" }, ecdsa: 66 },
|
|
@@ -213,45 +167,25 @@ var COSE_ALG = {
|
|
|
213
167
|
"-257": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
214
168
|
"-258": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
215
169
|
"-259": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
216
|
-
// RSASSA-PSS. The salt length is the hash length, the profile WebCrypto verifies and the one
|
|
217
|
-
// RFC 8230 sec. 2 fixes for the COSE PS* identifiers -- 32 / 48 / 64 bytes for SHA-256/384/512.
|
|
218
170
|
"-37": { imp: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 }, ecdsa: 0 },
|
|
219
171
|
"-38": { imp: { name: "RSA-PSS", hash: "SHA-384" }, verify: { name: "RSA-PSS", saltLength: 48 }, ecdsa: 0 },
|
|
220
172
|
"-39": { imp: { name: "RSA-PSS", hash: "SHA-512" }, verify: { name: "RSA-PSS", saltLength: 64 }, ecdsa: 0 },
|
|
221
|
-
// RS1 (RSASSA-PKCS1-v1_5 / SHA-1): a legacy COSE algorithm real Windows Hello TPM
|
|
222
|
-
// authenticators emit in their attestation statement. VERIFY-only support -- the
|
|
223
|
-
// toolkit never signs with SHA-1; it must still evaluate the attestations that
|
|
224
|
-
// ship using it, or a large class of TPM authenticators cannot be verified.
|
|
225
173
|
"-65535": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-1" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
226
174
|
};
|
|
227
175
|
|
|
228
|
-
// DER ECDSA-Sig-Value { r, s } -> raw r||s (the ieee-p1363 form WebCrypto verify expects).
|
|
229
|
-
// validator-sig owns the complete strict-DER conformance PLUS the FIPS 186-5 sec. 6.4.2 order bound
|
|
230
|
-
// (r,s in [1, n-1] -- the order-aware gate a curve-aware verifier MUST use), composed here.
|
|
231
176
|
function _derEcdsaToRaw(der, curve) { return validator.sig.ecdsaDerToP1363(der, curve, WebauthnError, "webauthn/bad-signature"); }
|
|
232
177
|
|
|
233
|
-
// Verify `sig` over `message` with the SPKI public key `spkiBytes` under COSE `alg`.
|
|
234
|
-
// A wrong signature resolves `false` from subtle.verify without throwing (a false
|
|
235
|
-
// verdict is a verdict); a structural failure -- an unimportable key, a bad
|
|
236
|
-
// descriptor -- is re-thrown as a typed fail-closed error, never masked as false.
|
|
237
178
|
function _verifySig(alg, sig, spkiBytes, message, E) {
|
|
238
179
|
var d = COSE_ALG[String(alg)];
|
|
239
180
|
if (!d) throw _err("webauthn/unsupported-algorithm", "unsupported COSE algorithm " + alg);
|
|
240
181
|
var imp = d.imp, ver = d.verify;
|
|
241
|
-
// COSE alg -8 (EdDSA) covers Ed25519 and Ed448; the WebCrypto name follows the
|
|
242
|
-
// signing key's own SPKI algorithm OID, not the (curve-agnostic) alg id.
|
|
243
182
|
if (alg === -8) { var nm = _edName(spkiBytes, E); imp = { name: nm }; ver = { name: nm }; }
|
|
244
|
-
// node/OpenSSL imports any Ed25519/Ed448 SPKI without validating the point, and a
|
|
245
|
-
// low-order (e.g. all-zeroes) key verifies a trivial signature -- so validate the OKP
|
|
246
|
-
// point before verify. This covers EVERY key that signs a WebAuthn statement: the x5c
|
|
247
|
-
// attestation-certificate key (packed/tpm/apple) AND the self-attestation credential key.
|
|
248
183
|
if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name);
|
|
249
184
|
var s = d.ecdsa ? _derEcdsaToRaw(sig, d.imp.namedCurve) : sig;
|
|
250
185
|
return subtle.importKey("spki", spkiBytes, imp, false, ["verify"])
|
|
251
186
|
.then(function (key) { return subtle.verify(ver, key, s, message); })
|
|
252
187
|
.catch(function (e) { throw _err("webauthn/verify-error", "the attestation signature could not be evaluated", e); });
|
|
253
188
|
}
|
|
254
|
-
// The WebCrypto EdDSA name (Ed25519 / Ed448) an SPKI declares via its algorithm OID.
|
|
255
189
|
var ED_OID_NAME = {};
|
|
256
190
|
ED_OID_NAME[oid.byName("Ed25519")] = "Ed25519";
|
|
257
191
|
ED_OID_NAME[oid.byName("Ed448")] = "Ed448";
|
|
@@ -263,48 +197,20 @@ function _edName(spkiBytes, E) {
|
|
|
263
197
|
if (!nm) throw E("webauthn/unsupported-algorithm", "unsupported EdDSA curve OID " + algOid);
|
|
264
198
|
return nm;
|
|
265
199
|
}
|
|
266
|
-
// The raw Edwards point an OKP SPKI carries MUST be a valid, full-order point -- reject an
|
|
267
|
-
// off-curve or low-order key before it verifies a signature, which WebCrypto import will not do.
|
|
268
|
-
// Routed through edwards-point's own SPKI entry rather than extracting the point here: that module
|
|
269
|
-
// documents itself as the one home every EdDSA verify path takes an SPKI through, and this was the
|
|
270
|
-
// path that had its own copy. Keeping a second extraction is how one route comes to be guarded and
|
|
271
|
-
// the next one written is not.
|
|
272
200
|
function _requireValidEdPoint(spkiBytes, name) {
|
|
273
201
|
edwardsPoint.validateSpki(spkiBytes, name === "Ed25519" ? 6 : 7, WebauthnError, "webauthn/bad-signature");
|
|
274
202
|
}
|
|
275
203
|
|
|
276
|
-
// A validated COSE credential key -> a self-contained SPKI the WebCrypto import
|
|
277
|
-
// consumes, so a credential public key and a certificate key verify by one path.
|
|
278
|
-
// validator-cose owns the encoding (EC2 / RSA / OKP), composed here.
|
|
279
204
|
function _coseKeyToSpki(key) { return validator.cose.toSpki(key, WebauthnError, "webauthn/bad-cose-key"); }
|
|
280
205
|
|
|
281
|
-
// The attestation-certificate subject public key MUST equal the credential public
|
|
282
|
-
// key that authenticatorData carries (WebAuthn 8.4/8.8 item 30). Compare the raw
|
|
283
|
-
// key material unsigned: an EC2 uncompressed point's X/Y vs the COSE x/y; an RSA
|
|
284
|
-
// modulus/exponent vs the COSE n/e. `cert.subjectPublicKeyInfo.publicKey.bytes` is
|
|
285
|
-
// the BIT STRING key material the SPKI carries.
|
|
286
|
-
// The named-curve OID a certificate's EC SubjectPublicKeyInfo declares (the DER
|
|
287
|
-
// OBJECT IDENTIFIER in the algorithm parameters). Fail-closed: absent or malformed
|
|
288
|
-
// curve parameters throw a typed key-mismatch -- never swallowed to a null the
|
|
289
|
-
// caller would compare as "no match by default".
|
|
290
206
|
function _certEcCurveOid(cert, E) {
|
|
291
207
|
var params = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.algorithm && cert.subjectPublicKeyInfo.algorithm.parameters;
|
|
292
208
|
if (!Buffer.isBuffer(params)) throw E("webauthn/key-mismatch", "the attestation certificate EC key carries no named-curve parameters");
|
|
293
209
|
try { return asn1.read.oid(asn1.decode(params)); }
|
|
294
210
|
catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate EC curve is not a valid OBJECT IDENTIFIER", e); }
|
|
295
211
|
}
|
|
296
|
-
// Which certificate key ALGORITHMS a credential key of each COSE type may be carried by. Two keys
|
|
297
|
-
// are the same key only if they are the same KIND of key: the bytes alone do not say what a key is,
|
|
298
|
-
// and for the Edwards curves they cannot -- an X25519 key-agreement key and an Ed25519 signing key
|
|
299
|
-
// are both 32 raw bytes, so a certificate declaring the former would otherwise compare equal to a
|
|
300
|
-
// credential key declaring the latter and be accepted as its attestation certificate. The EC2
|
|
301
|
-
// branch already asked this question as a curve check; asking it only there left the two key types
|
|
302
|
-
// whose confusion is undetectable from the material to be decided on the material alone.
|
|
303
|
-
// A row per COSE key type, keyed the way every other algorithm decision in this toolkit is.
|
|
304
212
|
function _certKeyAlgNames(cose) {
|
|
305
213
|
if (cose.kty === 2) return ["ecPublicKey"];
|
|
306
|
-
// An RSA key may be carried under the general OID or under id-RSASSA-PSS, which restricts it to
|
|
307
|
-
// PSS but is the same key (RFC 4055 sec. 1.2).
|
|
308
214
|
if (cose.kty === 3) return ["rsaEncryption", "rsassaPss"];
|
|
309
215
|
if (cose.kty === 1) {
|
|
310
216
|
var okp = validator.cose.OKP_CRV[cose.crv];
|
|
@@ -320,15 +226,11 @@ function _assertCertKeyAlgorithm(cert, cose, E) {
|
|
|
320
226
|
" key, which is not the kind of key the credential public key declares");
|
|
321
227
|
}
|
|
322
228
|
}
|
|
323
|
-
// A void assert: throws webauthn/key-mismatch on any inequality, returns nothing on
|
|
324
|
-
// success (called for its throw side-effect, like the other _check* asserts).
|
|
325
229
|
function _certPubKeyEqualsCose(cert, cose, E) {
|
|
326
230
|
var raw = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.publicKey && cert.subjectPublicKeyInfo.publicKey.bytes;
|
|
327
231
|
if (!raw) throw E("webauthn/key-mismatch", "the attestation certificate exposes no public key");
|
|
328
232
|
_assertCertKeyAlgorithm(cert, cose, E);
|
|
329
233
|
if (cose.kty === 2) {
|
|
330
|
-
// The certificate's declared EC curve MUST equal the credential key's curve --
|
|
331
|
-
// a curve substitution is a different key even if the coordinate bytes line up.
|
|
332
234
|
var wantCurve = validator.cose.EC2_CRV_OID[cose.crv];
|
|
333
235
|
if (!wantCurve) throw E("webauthn/key-mismatch", "unsupported credential EC curve " + cose.crv);
|
|
334
236
|
if (_certEcCurveOid(cert, E) !== oid.byName(wantCurve)) throw E("webauthn/key-mismatch", "the attestation certificate EC curve does not equal the credential key curve");
|
|
@@ -341,8 +243,6 @@ function _certPubKeyEqualsCose(cert, cose, E) {
|
|
|
341
243
|
if (cose.kty === 3) {
|
|
342
244
|
var seq;
|
|
343
245
|
try { seq = asn1.decode(raw); } catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate RSA key is not decodable", e); }
|
|
344
|
-
// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }; both
|
|
345
|
-
// MUST be primitive INTEGERs (a constructed child has a null content buffer).
|
|
346
246
|
if (!seq.children || seq.children.length !== 2 || !_isInteger(seq.children[0]) || !_isInteger(seq.children[1])) {
|
|
347
247
|
throw E("webauthn/key-mismatch", "the attestation certificate RSA key is malformed");
|
|
348
248
|
}
|
|
@@ -351,31 +251,21 @@ function _certPubKeyEqualsCose(cert, cose, E) {
|
|
|
351
251
|
return;
|
|
352
252
|
}
|
|
353
253
|
if (cose.kty === 1) {
|
|
354
|
-
// OKP Ed25519 / Ed448: the SPKI BIT STRING body IS the raw public key (fixed
|
|
355
|
-
// width, so a byte-exact compare, not a leading-zero-stripping unsigned compare).
|
|
356
254
|
if (!cose.x || !Buffer.from(raw).equals(cose.x)) throw E("webauthn/key-mismatch", "the attestation certificate OKP key does not equal the credential public key");
|
|
357
255
|
return;
|
|
358
256
|
}
|
|
359
257
|
throw E("webauthn/key-mismatch", "unsupported credential key type for the certificate comparison");
|
|
360
258
|
}
|
|
361
259
|
|
|
362
|
-
// ---- TPM structure conformance (WebAuthn 8.3; TCG TPM 2.0 Part 2) -------------
|
|
363
|
-
// The TPMT_PUBLIC (pubArea) + TPMS_ATTEST (certInfo) decode + the pubArea-key binding
|
|
364
|
-
// live in validator-tpm, composed here. A malformed structure is bad-tpm; a key that
|
|
365
|
-
// does not equal the credential key is key-mismatch.
|
|
366
260
|
function _parseTpmPubArea(buf) { return validator.tpm.parsePubArea(buf, WebauthnError, "webauthn/bad-tpm"); }
|
|
367
261
|
function _parseTpmCertInfo(buf) { return validator.tpm.parseCertInfo(buf, WebauthnError, "webauthn/bad-tpm"); }
|
|
368
262
|
function _tpmPubKeyEqualsCose(pub, cose) { validator.tpm.pubKeyEqualsCose(pub, cose, WebauthnError, "webauthn/key-mismatch", "webauthn/bad-tpm"); }
|
|
369
263
|
|
|
370
|
-
// ---- extension helpers -------------------------------------------------------
|
|
371
|
-
// `oidName` is a registered name (byName resolves it at call time); an unregistered
|
|
372
|
-
// name is a programming error that surfaces as an undefined target, never swallowed.
|
|
373
264
|
function _findExt(cert, oidName) {
|
|
374
265
|
var target = oid.byName(oidName);
|
|
375
266
|
return (cert.extensions || []).filter(function (e) { return e.oid === target; })[0] || null;
|
|
376
267
|
}
|
|
377
268
|
|
|
378
|
-
// ---- public: parseAttestationObject -----------------------------------------
|
|
379
269
|
|
|
380
270
|
/**
|
|
381
271
|
* @primitive pki.webauthn.parseAttestationObject
|
|
@@ -404,15 +294,8 @@ function parseAttestationObject(bytes) {
|
|
|
404
294
|
try { root = cbor.decode(bytes); } catch (e) { throw _err("webauthn/bad-attestation-object", "the attestation object is not well-formed CBOR", e); }
|
|
405
295
|
if (root.majorType !== 5) throw _err("webauthn/bad-attestation-object", "the attestation object must be a CBOR map { fmt, attStmt, authData }");
|
|
406
296
|
var fmtN = cbor.read.mapGet(root, "fmt"), attStmtN = cbor.read.mapGet(root, "attStmt"), authDataN = cbor.read.mapGet(root, "authData");
|
|
407
|
-
// The attestation object is EXACTLY { fmt, attStmt, authData } -- no more, no fewer
|
|
408
|
-
// (WebAuthn 6.5.4); an extra top-level key is a non-canonical envelope, rejected.
|
|
409
297
|
if (root.children.length !== 3 || !fmtN || !attStmtN || !authDataN) throw _err("webauthn/bad-attestation-object", "the attestation object must be exactly { fmt, attStmt, authData }");
|
|
410
298
|
if (fmtN.majorType !== 3) throw _err("webauthn/bad-attestation-object", "attestation object 'fmt' must be a text string");
|
|
411
|
-
// attStmt is the attestation statement, a CBOR map keyed by field name (WebAuthn 6.5.4) for every
|
|
412
|
-
// format but one: sec. 8.9 gives compound an ARRAY of nested statements. Which shape a format
|
|
413
|
-
// takes is a registry row, not a branch, so adding a format cannot silently widen the envelope
|
|
414
|
-
// for the others -- a non-map value (whose children are single nodes, not { key, value } pairs)
|
|
415
|
-
// must never reach the per-field statement walk of a format that expects a map.
|
|
416
299
|
var wantMajor = ATT_STMT_MAJOR[cbor.read.textString(fmtN)];
|
|
417
300
|
if (wantMajor === undefined) wantMajor = 5;
|
|
418
301
|
if (attStmtN.majorType !== wantMajor) {
|
|
@@ -428,16 +311,12 @@ function parseAttestationObject(bytes) {
|
|
|
428
311
|
};
|
|
429
312
|
}
|
|
430
313
|
|
|
431
|
-
// ---- attestation-format verifiers -------------------------------------------
|
|
432
314
|
|
|
433
315
|
function _reqAttr(map, key) {
|
|
434
316
|
var n = cbor.read.mapGet(map, key);
|
|
435
317
|
if (!n) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + key + "' field");
|
|
436
318
|
return n;
|
|
437
319
|
}
|
|
438
|
-
// Read a required attStmt field of an expected CBOR type, mapping a wrong-type
|
|
439
|
-
// cbor/* fault to the webauthn domain (an attStmt field is not-well-formed input to
|
|
440
|
-
// this layer, so it is a webauthn/bad-att-stmt verdict, not a leaked codec error).
|
|
441
320
|
function _attRead(map, key, reader, what) {
|
|
442
321
|
var n = _reqAttr(map, key);
|
|
443
322
|
try { return reader(n); }
|
|
@@ -445,42 +324,20 @@ function _attRead(map, key, reader, what) {
|
|
|
445
324
|
}
|
|
446
325
|
function _algOf(attStmt) { return Number(_attRead(attStmt, "alg", cbor.read.int, "an integer")); }
|
|
447
326
|
function _sigOf(attStmt) { return _attRead(attStmt, "sig", cbor.read.byteString, "a byte string"); }
|
|
448
|
-
// A format's attStmt is its CANONICAL field set: every present key must be in
|
|
449
|
-
// `allowed`, and every key in `required` must be present. An unexpected field is a
|
|
450
|
-
// non-canonical statement, rejected before any field is trusted (WebAuthn sec. 8.*).
|
|
451
327
|
function _requireAttShape(attStmt, allowed, required) {
|
|
452
328
|
var have = {};
|
|
453
|
-
// attStmt is the attestation-statement map (parseAttestationObject established it is a CBOR
|
|
454
|
-
// map). cbor.read.map asserts the major type and yields the { key, value } pairs -- never a
|
|
455
|
-
// raw .children walk, whose entries are single nodes (not pairs) for a non-map, so a pair
|
|
456
|
-
// index would read undefined and throw a raw TypeError instead of failing closed.
|
|
457
329
|
cbor.read.map(attStmt).forEach(function (kv) {
|
|
458
|
-
// attStmt keys are text strings; a non-text key is a malformed statement,
|
|
459
|
-
// rejected (not silently skipped, which would evade the unexpected-field check).
|
|
460
330
|
if (kv[0].majorType !== 3) throw _err("webauthn/bad-att-stmt", "the attestation statement has a non-text-string field key");
|
|
461
331
|
have[cbor.read.textString(kv[0])] = true;
|
|
462
332
|
});
|
|
463
333
|
Object.keys(have).forEach(function (k) { if (allowed.indexOf(k) === -1) throw _err("webauthn/bad-att-stmt", "the attestation statement carries an unexpected field '" + k + "'"); });
|
|
464
334
|
required.forEach(function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
|
|
465
335
|
}
|
|
466
|
-
// The CBOR major type each format's attStmt takes. Every format uses a map (5) except compound,
|
|
467
|
-
// whose sec. 8.9 syntax is an array of nested statements. A data row rather than a branch, so a
|
|
468
|
-
// future format declares its shape here instead of loosening the shared envelope check.
|
|
469
336
|
var ATT_STMT_MAJOR = { compound: 4 };
|
|
470
337
|
|
|
471
|
-
// Which attestation formats put the AAGUID under their signature. A registry row rather than a
|
|
472
|
-
// branch, so adding a format forces the question to be answered for it rather than inheriting the
|
|
473
|
-
// permissive default. Every format signs over authenticatorData (which contains the AAGUID) except
|
|
474
|
-
// fido-u2f, whose sec. 8.6 verificationData is assembled from named fields and omits it.
|
|
475
|
-
// The question is asked PER FORMAT, and answered for the element being judged -- never once for a
|
|
476
|
-
// whole statement. A compound mixes formats, and a single answer would be wrong in both directions:
|
|
477
|
-
// it would either trust a u2f element's unsigned AAGUID, or refuse to look a packed element up by
|
|
478
|
-
// the very AAGUID its own signature covers.
|
|
479
338
|
var _AAGUID_SIGNED_BY_FMT = Object.assign(Object.create(null), { "fido-u2f": false });
|
|
480
339
|
function _aaguidIsSigned(fmt) { return _AAGUID_SIGNED_BY_FMT[fmt] !== false; }
|
|
481
340
|
|
|
482
|
-
// The options pki.webauthn.verify recognizes. Null-prototype, so a caller-supplied key cannot
|
|
483
|
-
// resolve to an inherited Object member and read as recognized.
|
|
484
341
|
var _VERIFY_OPTS = Object.assign(Object.create(null), {
|
|
485
342
|
time: 1, metadata: 1, tpmPolicy: 1, safetyNetRoots: 1, verifySafetyNetJws: 1, requireCtsProfileMatch: 1,
|
|
486
343
|
expectedRpId: 1, requireUserPresence: 1, requireUserVerification: 1, allowedAlgorithms: 1,
|
|
@@ -488,52 +345,8 @@ var _VERIFY_OPTS = Object.assign(Object.create(null), {
|
|
|
488
345
|
clientDataJSON: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
489
346
|
});
|
|
490
347
|
|
|
491
|
-
// The boolean switches whose only reader sits inside ONE format's arm, so nothing else would ever
|
|
492
|
-
// examine them. The binding switches are absent because _applyBindings owns their type and runs on
|
|
493
|
-
// every format and on the assertion path too -- a second copy of that rule here would be a second
|
|
494
|
-
// place for it to drift.
|
|
495
348
|
var _FORMAT_SCOPED_BOOLEAN_OPTS = ["verifySafetyNetJws", "requireCtsProfileMatch"];
|
|
496
349
|
|
|
497
|
-
// Anchor an attestation's trust path to roots the CALLER pins. The metadata route
|
|
498
|
-
// resolves an authenticator's roots from the catalogue that registered it, which is
|
|
499
|
-
// the stronger source -- but it only reaches models the catalogue lists, and some
|
|
500
|
-
// are not there: Apple does not publish its authenticators to the FIDO Metadata
|
|
501
|
-
// Service, and the Google hardware-attestation roots are distributed by Google. For
|
|
502
|
-
// those formats the catalogue route resolves nothing, so without this there is no
|
|
503
|
-
// way to anchor them at all and a trust path comes back unchecked. (android-safetynet
|
|
504
|
-
// already had its own `safetyNetRoots` for the same reason; this is that carve-out
|
|
505
|
-
// made general.)
|
|
506
|
-
//
|
|
507
|
-
// PRECEDENCE, stated rather than implied: `metadata` governs when it is supplied,
|
|
508
|
-
// because a model's own registered roots are a stronger claim than a static pin, and
|
|
509
|
-
// its status reports can disqualify a model these roots would still accept.
|
|
510
|
-
// `rootCertificates` is the fallback for the models the catalogue does not cover.
|
|
511
|
-
// Supplying both is not an error -- it is the ordinary configuration for a relying
|
|
512
|
-
// party that accepts MDS-listed authenticators AND Apple.
|
|
513
|
-
// The caller's pinned roots are copied SYNCHRONOUSLY, at the entry point, because
|
|
514
|
-
// they are not read until the attestation verifier has resolved -- a later promise
|
|
515
|
-
// turn. Both the ARRAY and each DER buffer in it stay caller-owned across that gap,
|
|
516
|
-
// so a caller that recycles the array or overwrites a certificate's bytes in between
|
|
517
|
-
// would have the attestation anchored against the replacement roots while the verdict
|
|
518
|
-
// still reports `anchoredTo: "rootCertificates"` (CWE-367 reaching a wrong trust
|
|
519
|
-
// decision). Same defense, and the same reason, as the assertion input's snapshot.
|
|
520
|
-
// Shape faults are NOT raised here: the array is validated where it is consumed, so
|
|
521
|
-
// supplying a malformed pin alongside a metadata catalogue that answers keeps failing
|
|
522
|
-
// exactly where it did before.
|
|
523
|
-
// A parsed certificate is a tree of plain objects, arrays and Buffers, and the anchor
|
|
524
|
-
// comparison downstream reads its `subject` and `subjectPublicKeyInfo` -- both nested
|
|
525
|
-
// Buffers the caller still owns. Copying only the top-level array would leave those
|
|
526
|
-
// aliased, so the parsed form is deep-copied: every Buffer is duplicated and every
|
|
527
|
-
// container rebuilt, while the value types a parsed certificate carries (Date for a
|
|
528
|
-
// validity bound, BigInt for a serial) are preserved rather than flattened.
|
|
529
|
-
//
|
|
530
|
-
// The depth bound is a RECURSION BACKSTOP set far above any real certificate, never the
|
|
531
|
-
// precision mechanism -- a bound tuned to today's shapes silently rejects a legitimate root
|
|
532
|
-
// the moment a deeper one appears. A measured certificate reaches depth 5, and the deepest
|
|
533
|
-
// structure the profile allows -- a certificate policy whose qualifier carries a UserNotice
|
|
534
|
-
// with a noticeRef and its noticeNumbers -- lands near 12, so a cap of 12 would sit exactly
|
|
535
|
-
// on a conforming root. This is set well clear of that; it exists only so a cyclic or hostile
|
|
536
|
-
// object cannot recurse without end.
|
|
537
350
|
function _cloneParsed(v, depth) {
|
|
538
351
|
if (depth > 64) throw _err("webauthn/bad-input", "opts.rootCertificates[] is nested too deeply to be a parsed certificate");
|
|
539
352
|
if (guard.bytes.isByteSource(v)) return guard.bytes.snapshotSource(v, _err, "webauthn/bad-input", "a byte field of opts.rootCertificates[]");
|
|
@@ -544,21 +357,9 @@ function _cloneParsed(v, depth) {
|
|
|
544
357
|
Object.keys(v).forEach(function (k) { out[k] = _cloneParsed(v[k], depth + 1); });
|
|
545
358
|
return out;
|
|
546
359
|
}
|
|
547
|
-
return v;
|
|
360
|
+
return v;
|
|
548
361
|
}
|
|
549
362
|
|
|
550
|
-
// A caller-owned byte argument, copied so nothing downstream reads bytes the caller can still
|
|
551
|
-
// rewrite, and accepted in every form the W3C BufferSource contract defines.
|
|
552
|
-
//
|
|
553
|
-
// ONE accepted set, for every byte argument on this namespace. The forms differed per argument
|
|
554
|
-
// before -- an attestation object took an ArrayBuffer while the clientDataHash beside it took only
|
|
555
|
-
// a Buffer -- and the caller producing both is the same one: `crypto.subtle.digest` returns an
|
|
556
|
-
// ArrayBuffer, so the natural way to compute that hash produced a value this verb refused. Which
|
|
557
|
-
// forms an argument accepts is not a per-argument decision; it is the namespace's contract, and it
|
|
558
|
-
// is made once here.
|
|
559
|
-
//
|
|
560
|
-
// Anything that is not a BufferSource is refused BY NAME, rather than passed down to be described
|
|
561
|
-
// by whichever parser reaches it first.
|
|
562
363
|
function _isBufferSource(v) { return guard.bytes.isByteSource(v); }
|
|
563
364
|
function _bytesArg(v, label) {
|
|
564
365
|
if (_isBufferSource(v)) {
|
|
@@ -570,87 +371,38 @@ function _bytesArg(v, label) {
|
|
|
570
371
|
function _snapshotRoots(supplied) {
|
|
571
372
|
if (!Array.isArray(supplied)) return supplied;
|
|
572
373
|
return supplied.map(function (root) {
|
|
573
|
-
// Every byte form, tested BEFORE the parsed-object branch: a DataView is an object too, so a
|
|
574
|
-
// narrower byte test does not merely miss the copy -- it sends those bytes down the branch for
|
|
575
|
-
// parsed certificates, which deep-copies a typed array field by field into something that is no
|
|
576
|
-
// longer a certificate at all.
|
|
577
374
|
if (_isBufferSource(root)) {
|
|
578
375
|
return guard.bytes.snapshotSource(root, WebauthnError, "webauthn/bad-input", "opts.rootCertificates[]");
|
|
579
376
|
}
|
|
580
|
-
|
|
581
|
-
// stopping a caller mutating a root after it was accepted, and it solved that by making a
|
|
582
|
-
// detached twin -- which loses the parser's record, so the door below could no longer re-derive
|
|
583
|
-
// the anchor from the bytes it was read from. The record is the stronger form of the same
|
|
584
|
-
// protection: the anchor is re-parsed from those bytes, so an edit made afterwards, at any depth,
|
|
585
|
-
// is discarded rather than copied. Cloning would trade that for a snapshot of a mutable object.
|
|
586
|
-
return root; // a PEM string is immutable; a parsed certificate carries its own provenance
|
|
377
|
+
return root;
|
|
587
378
|
});
|
|
588
379
|
}
|
|
589
380
|
|
|
590
|
-
// `supplied` is the SNAPSHOT taken at the entry point, never `opts.rootCertificates`
|
|
591
|
-
// read afresh here -- that re-read is the window this guard closes. `opts` is still
|
|
592
|
-
// threaded for the non-aliasing options (the validation time).
|
|
593
|
-
// `vopts` is `verify`'s NORMALIZED options object, never the caller's -- the parameter is
|
|
594
|
-
// named for that so a future caller cannot hand it the live one without noticing. Everything
|
|
595
|
-
// it reads was captured synchronously at the entry.
|
|
596
381
|
function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
597
382
|
if (!Array.isArray(supplied) || !supplied.length) {
|
|
598
383
|
throw _err("webauthn/bad-input", "opts.rootCertificates must be a non-empty array of root certificates (DER, PEM, or parsed)");
|
|
599
384
|
}
|
|
600
|
-
// DER, PEM or an already-parsed certificate, decoded HERE -- the anchor comparison
|
|
601
|
-
// downstream reads a certificate's subject and key, so a raw buffer reaching it
|
|
602
|
-
// would fault on a field it does not have rather than naming the caller's mistake.
|
|
603
|
-
// The same three forms opts.safetyNetRoots takes, since it is the same question.
|
|
604
385
|
var roots = supplied.map(function (root, i) {
|
|
605
386
|
var label = "opts.rootCertificates[" + i + "]";
|
|
606
|
-
// These BECOME trust anchors, which is the sharpest form of the rule: the anchor's key is what
|
|
607
|
-
// the whole attestation chain is judged against, so a caller-assembled certificate carrying a
|
|
608
|
-
// real root's name beside a substituted key would anchor an attacker's chain. Re-derived from
|
|
609
|
-
// the bytes its parser read, exactly as at every other certificate door.
|
|
610
387
|
return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
|
|
611
388
|
try {
|
|
612
389
|
return x509.parse(_isBufferSource(bytes) ? guard.bytes.source(bytes, WebauthnError, "webauthn/bad-input", label) : bytes);
|
|
613
390
|
} catch (e) { throw _err("webauthn/bad-input", label + " is not a decodable certificate", e); }
|
|
614
391
|
}, _err, "webauthn/bad-input", label);
|
|
615
392
|
});
|
|
616
|
-
// Same rule the metadata route applies: a compound element carrying no
|
|
617
|
-
// certificates makes no claim there is anything to anchor, so it is not a reason to
|
|
618
|
-
// refuse the statement -- but `anchoredTo` alone would then say "anchored" over a
|
|
619
|
-
// statement it did not wholly cover. The coverage is reported instead.
|
|
620
|
-
// `onlyPaths`, when given, is the set of paths the CATALOGUE did not cover -- the
|
|
621
|
-
// fallback is for exactly those. Applying it to the whole statement instead would
|
|
622
|
-
// re-judge an element the catalogue already governed against roots that have nothing
|
|
623
|
-
// to do with it: a compound holding one listed element (chaining to the root its own
|
|
624
|
-
// metadata entry registers) beside one unlisted element (chaining to a caller pin) would
|
|
625
|
-
// be refused, because the listed element does not chain to the pin. That is the
|
|
626
|
-
// documented "MDS-listed authenticators AND Apple" configuration, and each element is
|
|
627
|
-
// still anchored to something the caller trusts -- just not all to the same thing.
|
|
628
393
|
var paths = onlyPaths || ((res.fmt === "compound" && Array.isArray(res.compound))
|
|
629
394
|
? res.compound.filter(function (el) { return el.trustPath && el.trustPath.length; })
|
|
630
395
|
.map(function (el) { return { tp: el.trustPath, at: el.chainValidatedAt }; })
|
|
631
396
|
: (res.trustPath && res.trustPath.length ? [{ tp: res.trustPath, at: res.chainValidatedAt }] : []));
|
|
632
|
-
// Coverage is ACCUMULATED across routes, not overwritten by whichever ran last. When
|
|
633
|
-
// `onlyPaths` is given the metadata route already anchored the rest and recorded that here,
|
|
634
|
-
// so reporting the fallback's own count would say 1 of 2 for a statement whose two elements
|
|
635
|
-
// were both anchored -- one by the catalogue, one by the pin. A caller enforcing
|
|
636
|
-
// `anchored === total` would then reject the mixed configuration this fallback exists to
|
|
637
|
-
// support, and the verdict would understate the trust evaluation it actually performed.
|
|
638
397
|
if (!onlyPaths) {
|
|
639
398
|
res.anchoredElements = { total: (res.fmt === "compound" && Array.isArray(res.compound)) ? res.compound.length : 1,
|
|
640
399
|
anchored: paths.length };
|
|
641
400
|
}
|
|
642
|
-
// `none` and a self-attestation carry no certificates, so there is nothing to
|
|
643
|
-
// anchor -- and a caller who asked for anchoring is told it could not be applied
|
|
644
|
-
// rather than handed a pass that looks like it was. Same rule the metadata route
|
|
645
|
-
// states, for the same reason.
|
|
646
401
|
if (!paths.length) {
|
|
647
402
|
throw _err("webauthn/anchor-not-applicable",
|
|
648
403
|
"opts.rootCertificates was supplied, but this attestation carries no trust path to anchor (format '" + res.fmt + "')");
|
|
649
404
|
}
|
|
650
405
|
var at = vopts.time !== undefined ? vopts.time : new Date();
|
|
651
|
-
// EVERY path, not merely one: a compound's elements are independent claims, and
|
|
652
|
-
// accepting the whole because one element anchored would let an unanchored element
|
|
653
|
-
// ride along on its neighbor's trust.
|
|
654
406
|
return paths.reduce(function (p, info) {
|
|
655
407
|
return p.then(function () {
|
|
656
408
|
var pathAt = vopts.time !== undefined ? vopts.time : (info.at || at);
|
|
@@ -663,13 +415,6 @@ function _applyCallerRoots(res, supplied, vopts, onlyPaths) {
|
|
|
663
415
|
});
|
|
664
416
|
}
|
|
665
417
|
|
|
666
|
-
// android-safetynet anchors through a route of its own. The verifier REQUIRES
|
|
667
|
-
// `opts.safetyNetRoots` (8.5) and refuses the statement unless the x5c chain validates to
|
|
668
|
-
// one of them, so by the time a verdict is composed that element's path HAS been anchored --
|
|
669
|
-
// without either `metadata` or `rootCertificates` being involved. Counting only those two
|
|
670
|
-
// routes reports `anchoredTo: null` over it, whose documented meaning is that nobody checked
|
|
671
|
-
// the path, so a caller enforcing `anchoredTo !== null` would refuse an attestation this
|
|
672
|
-
// library did in fact anchor. Every route that contributed is named.
|
|
673
418
|
function _safetyNetAnchored(res) {
|
|
674
419
|
if (res.fmt === "android-safetynet") return 1;
|
|
675
420
|
if (res.fmt === "compound" && Array.isArray(res.compound)) {
|
|
@@ -678,46 +423,22 @@ function _safetyNetAnchored(res) {
|
|
|
678
423
|
return 0;
|
|
679
424
|
}
|
|
680
425
|
|
|
681
|
-
// `base` is whichever of the two caller-selected routes ran, or null when neither did.
|
|
682
|
-
// Routes are named in a stable order so a caller may compare the string.
|
|
683
426
|
function _anchoredRoutes(res, base) {
|
|
684
427
|
var routes = base ? base.split("+") : [];
|
|
685
428
|
if (_safetyNetAnchored(res) && routes.indexOf("safetyNetRoots") < 0) routes.push("safetyNetRoots");
|
|
686
429
|
return routes.length ? routes.join("+") : null;
|
|
687
430
|
}
|
|
688
431
|
|
|
689
|
-
// ---- ceremony binding (WebAuthn sec. 7.1 / 7.2) ------------------------------
|
|
690
|
-
//
|
|
691
|
-
// The attestation and assertion procedures verify a SIGNATURE. What makes a
|
|
692
|
-
// response acceptable is a separate set of checks the relying party owns, because
|
|
693
|
-
// only it knows what it asked for: the challenge it issued, the origin the browser
|
|
694
|
-
// reported, the RP ID it operates under, and the user-presence / user-verification
|
|
695
|
-
// policy it requires. A verifier that answers only the first question, under a name
|
|
696
|
-
// that sounds like the second, is the shape of every phishing-resistant login that
|
|
697
|
-
// turns out not to be -- an attestation naming ANOTHER RP, with user presence
|
|
698
|
-
// clear, is a perfectly sound statement about a credential the caller must not
|
|
699
|
-
// accept.
|
|
700
|
-
//
|
|
701
|
-
// So the checks this layer CAN make are offered here, opt-in, and every verdict
|
|
702
|
-
// reports which of them actually ran. The challenge and the origin stay with the
|
|
703
|
-
// caller: they live in clientDataJSON, which the caller already holds and compares
|
|
704
|
-
// against state only it has.
|
|
705
432
|
function _assertBool(v, name) {
|
|
706
433
|
if (typeof v !== "boolean") throw _err("webauthn/bad-input", "opts." + name + " must be a boolean");
|
|
707
434
|
}
|
|
708
435
|
|
|
709
|
-
// Runs the bindings the caller asked for and reports EACH one's status: `true` it
|
|
710
|
-
// was checked and holds, `false` it was not asked for. A caller reading the verdict
|
|
711
|
-
// can therefore tell a check that passed from one that never ran -- the distinction
|
|
712
|
-
// the single `verified` boolean could not express.
|
|
713
436
|
function _applyBindings(authData, coseKey, opts) {
|
|
714
437
|
var checked = { rpId: false, userPresence: false, userVerification: false, algorithm: false };
|
|
715
438
|
if (opts.expectedRpId !== undefined) {
|
|
716
439
|
if (typeof opts.expectedRpId !== "string" || !opts.expectedRpId.length) {
|
|
717
440
|
throw _err("webauthn/bad-input", "opts.expectedRpId must be a non-empty RP ID string");
|
|
718
441
|
}
|
|
719
|
-
// Compared in constant time and by full value, like every other identity
|
|
720
|
-
// comparison in this toolkit -- a prefix must not read as a match.
|
|
721
442
|
if (!guard.crypto.constantTimeEqual(_sha("sha256", Buffer.from(opts.expectedRpId, "utf8")),
|
|
722
443
|
Buffer.from(authData.rpIdHash))) {
|
|
723
444
|
throw _err("webauthn/rp-id-mismatch",
|
|
@@ -744,13 +465,6 @@ function _applyBindings(authData, coseKey, opts) {
|
|
|
744
465
|
}
|
|
745
466
|
checked.userVerification = opts.requireUserVerification;
|
|
746
467
|
}
|
|
747
|
-
// COSE alg -65535 is RSASSA-PKCS1-v1_5 with SHA-1, and a credential key may name
|
|
748
|
-
// it -- which would make SHA-1 the signature algorithm of every login that
|
|
749
|
-
// credential ever performs. Refused unless the caller lists it, so supporting a
|
|
750
|
-
// legacy authenticator is a decision someone wrote down rather than the default.
|
|
751
|
-
// (This is the CREDENTIAL key's algorithm. A legacy TPM attestation STATEMENT
|
|
752
|
-
// signed with RS1 is a different field and is still verified, since refusing it
|
|
753
|
-
// would reject the authenticator's own evidence about itself.)
|
|
754
468
|
var alg65535Allowed = Array.isArray(opts.allowedAlgorithms) && opts.allowedAlgorithms.indexOf(-65535) !== -1;
|
|
755
469
|
if (coseKey && coseKey.alg === -65535 && !alg65535Allowed) {
|
|
756
470
|
throw _err("webauthn/algorithm-not-allowed",
|
|
@@ -758,14 +472,8 @@ function _applyBindings(authData, coseKey, opts) {
|
|
|
758
472
|
"signature made by this credential would use SHA-1, so it is refused unless opts.allowedAlgorithms " +
|
|
759
473
|
"names -65535 explicitly");
|
|
760
474
|
}
|
|
761
|
-
// The rest of algorithm policy is the RP's, declared to the browser as
|
|
762
|
-
// pubKeyCredParams, and nothing in the response proves what was offered -- so it
|
|
763
|
-
// is checked only when the caller states the list.
|
|
764
475
|
if (opts.allowedAlgorithms !== undefined) {
|
|
765
476
|
if (!Array.isArray(opts.allowedAlgorithms) || !opts.allowedAlgorithms.length ||
|
|
766
|
-
// isSafeInteger, not isInteger: a value above 2^53 is not held exactly as a
|
|
767
|
-
// Number, so one written that way is not the identifier the caller meant --
|
|
768
|
-
// and no COSE algorithm identifier lives out there anyway.
|
|
769
477
|
!opts.allowedAlgorithms.every(function (a) { return typeof a === "number" && Number.isSafeInteger(a); })) {
|
|
770
478
|
throw _err("webauthn/bad-input", "opts.allowedAlgorithms must be a non-empty array of COSE algorithm integers");
|
|
771
479
|
}
|
|
@@ -779,10 +487,6 @@ function _applyBindings(authData, coseKey, opts) {
|
|
|
779
487
|
return checked;
|
|
780
488
|
}
|
|
781
489
|
|
|
782
|
-
// One bound for every attestation certificate chain, wherever it arrives from -- an attStmt x5c
|
|
783
|
-
// array or a JWS x5c header. Capping the bytes of a single entry does not bound the COUNT, and the
|
|
784
|
-
// cost of an entry is a DER parse plus, downstream, a signature check or a path validation. Kept in
|
|
785
|
-
// one place so a new chain-bearing format cannot reintroduce the unbounded fanout.
|
|
786
490
|
function _requireX5cCount(n) {
|
|
787
491
|
if (n > constants.LIMITS.WEBAUTHN_X5C_MAX_CERTS) {
|
|
788
492
|
throw _err("webauthn/bad-att-stmt", "an attestation certificate chain carries " + n + " certificates, above the " + constants.LIMITS.WEBAUTHN_X5C_MAX_CERTS + " this toolkit will parse");
|
|
@@ -798,10 +502,6 @@ function _readX5c(attStmt) {
|
|
|
798
502
|
try { return x509.parse(der); } catch (e) { throw _err("webauthn/bad-att-stmt", "an x5c certificate is not a well-formed X.509 certificate", e); }
|
|
799
503
|
});
|
|
800
504
|
}
|
|
801
|
-
// The WebAuthn attestation-certificate profile checks (WebAuthn 8.2.1 packed / 8.3.1 TPM
|
|
802
|
-
// AIK / the id-fido-gen-ce-aaguid extension) live in validator-attcert, composed here. The
|
|
803
|
-
// extension-accessor object hands the validator this format's fail-closed extension
|
|
804
|
-
// decoders so it stays decoupled from the webauthn error namespace.
|
|
805
505
|
var _exts = { find: _findExt, decode: _decodeExt };
|
|
806
506
|
function _requireV3(cert) { validator.attcert.requireV3(cert, WebauthnError, "webauthn/bad-att-cert"); }
|
|
807
507
|
function _checkPackedCert(cert) { validator.attcert.packedCert(cert, _exts, WebauthnError, "webauthn/bad-att-cert"); }
|
|
@@ -809,7 +509,6 @@ function _checkAikCert(cert) { validator.attcert.aikCert(cert, _exts, WebauthnEr
|
|
|
809
509
|
function _checkAaguidExt(cert, aaguid) { validator.attcert.aaguidExt(cert, aaguid, _exts, WebauthnError, "webauthn/bad-att-cert", "webauthn/aaguid-mismatch"); }
|
|
810
510
|
|
|
811
511
|
var VERIFIERS = {
|
|
812
|
-
// packed (WebAuthn 8.2): the x5c arm (Basic/AttCA) or self-attestation.
|
|
813
512
|
packed: function (att, clientDataHash) {
|
|
814
513
|
var isX5c = !!cbor.read.mapGet(att.attStmt, "x5c");
|
|
815
514
|
_requireAttShape(att.attStmt, isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"], isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"]);
|
|
@@ -817,15 +516,13 @@ var VERIFIERS = {
|
|
|
817
516
|
var message = Buffer.concat([att.authDataBytes, clientDataHash]);
|
|
818
517
|
if (isX5c) {
|
|
819
518
|
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
820
|
-
_checkPackedCert(leaf);
|
|
519
|
+
_checkPackedCert(leaf);
|
|
821
520
|
return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err).then(function (ok) {
|
|
822
521
|
if (!ok) throw _err("webauthn/verify-failed", "the packed attestation signature does not verify under the x5c leaf key");
|
|
823
522
|
_checkAaguidExt(leaf, att.authData.aaguid);
|
|
824
523
|
return _result("packed", "Basic", chain, att);
|
|
825
524
|
});
|
|
826
525
|
}
|
|
827
|
-
// Self-attestation: the statement alg MUST equal the credential key's own alg
|
|
828
|
-
// (WebAuthn 8.2), then sig verifies under the credential key itself.
|
|
829
526
|
if (alg !== att.authData.credentialPublicKey.alg) throw _err("webauthn/bad-att-stmt", "the packed self-attestation alg does not match the credential public key algorithm (WebAuthn 8.2)");
|
|
830
527
|
var spki = _coseKeyToSpki(att.authData.credentialPublicKey);
|
|
831
528
|
return _verifySig(alg, sig, spki, message, _err).then(function (ok) {
|
|
@@ -834,19 +531,13 @@ var VERIFIERS = {
|
|
|
834
531
|
});
|
|
835
532
|
},
|
|
836
533
|
|
|
837
|
-
// fido-u2f (WebAuthn 8.6): reconstruct the U2F verificationData and verify with
|
|
838
|
-
// the single x5c cert. The credential key MUST be EC2/P-256.
|
|
839
534
|
"fido-u2f": function (att, clientDataHash) {
|
|
840
535
|
_requireAttShape(att.attStmt, ["sig", "x5c"], ["sig", "x5c"]);
|
|
841
536
|
var chain = _readX5c(att.attStmt);
|
|
842
537
|
if (chain.length !== 1) throw _err("webauthn/bad-att-stmt", "fido-u2f x5c MUST contain exactly one certificate (WebAuthn 8.6)");
|
|
843
|
-
// WebAuthn 8.6 does not require a version-3 certificate for fido-u2f (unlike the
|
|
844
|
-
// packed 8.2.1 / tpm 8.3.1 leaves), so a legacy v1 U2F attestation cert is valid.
|
|
845
538
|
var leaf = chain[0];
|
|
846
539
|
var sig = _sigOf(att.attStmt);
|
|
847
540
|
var key = att.authData.credentialPublicKey;
|
|
848
|
-
// WebAuthn 8.6: the fido-u2f credential public key MUST be alg -7 (ES256) on EC2 P-256 --
|
|
849
|
-
// the newer ESP256 (-9) id, though the same curve, is not a valid fido-u2f credential.
|
|
850
541
|
if (key.alg !== -7 || key.kty !== 2 || key.crv !== 1 || !key.x || !key.y || key.x.length !== 32 || key.y.length !== 32) {
|
|
851
542
|
throw _err("webauthn/bad-att-stmt", "fido-u2f requires an ES256 (-7) EC2 P-256 credential public key (WebAuthn 8.6)");
|
|
852
543
|
}
|
|
@@ -858,10 +549,8 @@ var VERIFIERS = {
|
|
|
858
549
|
});
|
|
859
550
|
},
|
|
860
551
|
|
|
861
|
-
// apple (WebAuthn 8.8): the binding is the SHA-256 nonce over authData ||
|
|
862
|
-
// clientDataHash embedded in the leaf cert; there is no signature field.
|
|
863
552
|
apple: function (att, clientDataHash) {
|
|
864
|
-
_requireAttShape(att.attStmt, ["alg", "x5c"], ["x5c"]);
|
|
553
|
+
_requireAttShape(att.attStmt, ["alg", "x5c"], ["x5c"]);
|
|
865
554
|
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
866
555
|
_requireV3(leaf);
|
|
867
556
|
var nonce = _sha("sha256", Buffer.concat([att.authDataBytes, clientDataHash]));
|
|
@@ -869,12 +558,10 @@ var VERIFIERS = {
|
|
|
869
558
|
if (!ext) throw _err("webauthn/bad-att-cert", "the apple attestation certificate is missing the anonymous-attestation extension (WebAuthn 8.8)");
|
|
870
559
|
var embedded = _appleNonce(ext.value);
|
|
871
560
|
if (!embedded.equals(nonce)) throw _err("webauthn/verify-failed", "the apple attestation nonce does not equal SHA-256(authData || clientDataHash)");
|
|
872
|
-
_certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err);
|
|
561
|
+
_certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err);
|
|
873
562
|
return Promise.resolve(_result("apple", "AnonCA", chain, att));
|
|
874
563
|
},
|
|
875
564
|
|
|
876
|
-
// android-key (WebAuthn 8.4): verify sig with the leaf key, bind the leaf key to
|
|
877
|
-
// the credential key, and enforce the four KeyDescription checks (8.4.1).
|
|
878
565
|
"android-key": function (att, clientDataHash) {
|
|
879
566
|
_requireAttShape(att.attStmt, ["alg", "sig", "x5c"], ["alg", "sig", "x5c"]);
|
|
880
567
|
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
@@ -884,16 +571,12 @@ var VERIFIERS = {
|
|
|
884
571
|
return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err).then(function (ok) {
|
|
885
572
|
if (!ok) throw _err("webauthn/verify-failed", "the android-key attestation signature does not verify under the x5c leaf key");
|
|
886
573
|
_certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err);
|
|
887
|
-
_checkAndroidKeyDescription(leaf, clientDataHash);
|
|
574
|
+
_checkAndroidKeyDescription(leaf, clientDataHash);
|
|
888
575
|
return _result("android-key", "Basic", chain, att);
|
|
889
576
|
});
|
|
890
577
|
},
|
|
891
578
|
|
|
892
|
-
// tpm (WebAuthn 8.3): decode certInfo/pubArea, enforce magic/type/extraData/Name,
|
|
893
|
-
// bind pubArea to the credential key, and verify sig over certInfo with the AIK.
|
|
894
579
|
tpm: function (att, clientDataHash, opts) {
|
|
895
|
-
// Config-time: a malformed or mistyped tpmPolicy is a caller error, caught before any parsing so
|
|
896
|
-
// a typo cannot silently disable the check the caller believes they enabled.
|
|
897
580
|
var tpmPolicy = validator.tpm.normalizeObjectAttributePolicy((opts || {}).tpmPolicy, WebauthnError, "webauthn/bad-input");
|
|
898
581
|
_requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
|
|
899
582
|
var verN = cbor.read.mapGet(att.attStmt, "ver");
|
|
@@ -904,38 +587,22 @@ var VERIFIERS = {
|
|
|
904
587
|
var chain = _readX5c(att.attStmt), aik = chain[0];
|
|
905
588
|
|
|
906
589
|
var pub = _parseTpmPubArea(pubAreaBytes);
|
|
907
|
-
_tpmPubKeyEqualsCose(pub, att.authData.credentialPublicKey);
|
|
590
|
+
_tpmPubKeyEqualsCose(pub, att.authData.credentialPublicKey);
|
|
908
591
|
|
|
909
|
-
// validator-tpm decodes certInfo AND validates magic == TPM_GENERATED_VALUE / type ==
|
|
910
|
-
// TPM_ST_ATTEST_CERTIFY, so the returned structure is already self-consistent.
|
|
911
592
|
var certInfo = _parseTpmCertInfo(certInfoBytes);
|
|
912
593
|
|
|
913
|
-
// extraData == hash_alg(authData || clientDataHash) (bare digest, no method id).
|
|
914
594
|
var attToBeSigned = Buffer.concat([att.authDataBytes, clientDataHash]);
|
|
915
595
|
if (!certInfo.extraData.equals(_sha(_coseAlgHash(alg, _err), attToBeSigned))) throw _err("webauthn/verify-failed", "certInfo extraData does not equal the hash of authData || clientDataHash");
|
|
916
596
|
|
|
917
|
-
// attested.name == nameAlg || H_nameAlg(pubArea).
|
|
918
597
|
var nameHash = validator.tpm.TPM_ALG_HASH[pub.nameAlg];
|
|
919
598
|
if (!nameHash) throw _err("webauthn/bad-tpm", "unsupported TPM nameAlg 0x" + pub.nameAlg.toString(16));
|
|
920
599
|
var computedName = Buffer.concat([pub.nameAlgBytes, _sha(nameHash, pubAreaBytes)]);
|
|
921
600
|
if (!certInfo.attestedName.equals(computedName)) throw _err("webauthn/verify-failed", "certInfo attested Name does not match the pubArea TPM Name");
|
|
922
601
|
|
|
923
|
-
// WebAuthn 8.3 verification step: "Verify the sig is a valid signature over
|
|
924
|
-
// certInfo ... with the algorithm specified in alg" -- sig is verified DIRECTLY
|
|
925
|
-
// with alg, not parsed as a TPMT_SIGNATURE and unwrapped. The attStmt-syntax
|
|
926
|
-
// "in the form of a TPMT_SIGNATURE" is a description of the byte string; real
|
|
927
|
-
// authenticators put the raw signature here (the interop KAT's tpm sig is a bare
|
|
928
|
-
// 256-byte RSASSA signature that verifies as-is), and the reference verifier
|
|
929
|
-
// (py_webauthn) verifies it directly with alg the same way.
|
|
930
602
|
return _verifySig(alg, sig, aik.subjectPublicKeyInfo.bytes, certInfoBytes, _err).then(function (ok) {
|
|
931
603
|
if (!ok) throw _err("webauthn/verify-failed", "the tpm attestation signature does not verify over certInfo under the AIK");
|
|
932
|
-
_checkAikCert(aik);
|
|
933
|
-
_checkAaguidExt(aik, att.authData.aaguid);
|
|
934
|
-
// The TPMT_PUBLIC object attributes and authPolicy are properties of the credential key that
|
|
935
|
-
// sec. 8.3 does not constrain -- it bounds only pubArea's `parameters` and `unique`. They are
|
|
936
|
-
// surfaced for relying-party policy, and gated only when the caller supplies opts.tpmPolicy.
|
|
937
|
-
// Applied AFTER the signature and Name checks, so the bytes being judged are ones the AIK
|
|
938
|
-
// signature already covers rather than attacker-chosen input.
|
|
604
|
+
_checkAikCert(aik);
|
|
605
|
+
_checkAaguidExt(aik, att.authData.aaguid);
|
|
939
606
|
validator.tpm.assertObjectAttributePolicy(pub, tpmPolicy, WebauthnError, "webauthn/tpm-policy", "webauthn/bad-tpm");
|
|
940
607
|
var tpmRes = _result("tpm", "AttCA", chain, att);
|
|
941
608
|
tpmRes.tpm = { objectAttributes: pub.objectAttributes, attributes: pub.attributes, authPolicy: pub.authPolicy };
|
|
@@ -943,18 +610,8 @@ var VERIFIERS = {
|
|
|
943
610
|
});
|
|
944
611
|
},
|
|
945
612
|
|
|
946
|
-
// android-safetynet (WebAuthn 8.5): the attStmt carries a SafetyNet JWS ("response") whose payload
|
|
947
|
-
// binds a nonce to this registration and whose x5c header chains to a Google root.
|
|
948
|
-
//
|
|
949
|
-
// OFF BY DEFAULT, and anchored only by the caller. Google retired the SafetyNet Attestation API, so
|
|
950
|
-
// nothing mints these any more -- the surviving use is a relying party re-checking attestations it
|
|
951
|
-
// stored years ago. Enabling a format whose producer is gone, against a root this library chose,
|
|
952
|
-
// would widen what every caller trusts for no live benefit; so the caller opts in AND supplies the
|
|
953
|
-
// root. With the opt off the verdict is byte-identical to the one this format had before the arm
|
|
954
|
-
// existed. There is no bundled root and no trust-on-first-use.
|
|
955
613
|
"android-safetynet": function (att, clientDataHash, opts) {
|
|
956
614
|
opts = opts || {};
|
|
957
|
-
// The type is settled at the entry point, so what remains here is the opt-in itself.
|
|
958
615
|
if (opts.verifySafetyNetJws !== true) {
|
|
959
616
|
throw _err("webauthn/unsupported-format", "attestation statement format 'android-safetynet' is not supported");
|
|
960
617
|
}
|
|
@@ -962,19 +619,13 @@ var VERIFIERS = {
|
|
|
962
619
|
if (!Array.isArray(roots) || roots.length === 0) {
|
|
963
620
|
throw _err("webauthn/safetynet-no-root", "verifying an android-safetynet attestation requires opts.safetyNetRoots -- the Google root(s) to anchor the x5c chain to; this library bundles none (WebAuthn 8.5)");
|
|
964
621
|
}
|
|
965
|
-
// The guard rejects through a (code, message) FACTORY. Handing it the error CLASS makes the
|
|
966
|
-
// reject path raise "class constructor cannot be invoked without new" -- a raw, untyped throw
|
|
967
|
-
// escaping a public verb, on the branch a valid-input test never takes.
|
|
968
622
|
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
969
623
|
|
|
970
|
-
// 8.5 attStmt syntax: safetynetStmtFormat = { ver: text, response: bytes }. `ver` is READ but
|
|
971
|
-
// never gated on -- 8.5 states it is reserved for future use.
|
|
972
624
|
_requireAttShape(att.attStmt, ["ver", "response"], ["ver", "response"]);
|
|
973
625
|
_attRead(att.attStmt, "ver", cbor.read.textString, "a text string");
|
|
974
626
|
var responseBytes = _attRead(att.attStmt, "response", cbor.read.byteString, "a byte string");
|
|
975
627
|
if (!responseBytes.length) throw _err("webauthn/bad-att-stmt", "the android-safetynet response is empty");
|
|
976
628
|
|
|
977
|
-
// RFC 7515 sec. 3.1 Compact Serialization: exactly three base64url segments.
|
|
978
629
|
var segs = guard.text.decode(responseBytes, constants.LIMITS.SAFETYNET_JWS_MAX_BYTES, WebauthnError, "webauthn/bad-att-stmt", "the android-safetynet response").split(".");
|
|
979
630
|
if (segs.length !== 3) throw _err("webauthn/bad-att-stmt", "the android-safetynet response is not a three-part JWS compact serialization (RFC 7515 sec. 3.1)");
|
|
980
631
|
var header, payload, sigBytes;
|
|
@@ -983,53 +634,34 @@ var VERIFIERS = {
|
|
|
983
634
|
payload = jose.parseJson(jose.base64url.decode(segs[1]));
|
|
984
635
|
sigBytes = Buffer.from(jose.base64url.decode(segs[2]));
|
|
985
636
|
} catch (e) { throw _err("webauthn/bad-att-stmt", "the android-safetynet response is not a decodable JWS", e); }
|
|
986
|
-
// A JWS segment must decode to a JSON OBJECT. `null`, a number, a string and an array are all
|
|
987
|
-
// valid JSON, so the parse succeeds and every later field read would be a raw TypeError escaping
|
|
988
|
-
// this module's typed contract -- the caller's error handling would never see a webauthn/* code.
|
|
989
637
|
if (!_isPlainObject(header) || !_isPlainObject(payload)) {
|
|
990
638
|
throw _err("webauthn/bad-att-stmt", "the android-safetynet JWS header and payload must each be a JSON object");
|
|
991
639
|
}
|
|
992
640
|
|
|
993
|
-
// Pin the algorithm rather than reading it from the token: an attacker-chosen alg is the JWS
|
|
994
|
-
// algorithm-confusion class, and SafetyNet only ever signed RS256.
|
|
995
641
|
if (header.alg !== "RS256") throw _err("webauthn/unsupported-algorithm", "the android-safetynet JWS alg must be RS256, got " + JSON.stringify(header.alg));
|
|
996
642
|
if (!Array.isArray(header.x5c) || header.x5c.length === 0) {
|
|
997
643
|
throw _err("webauthn/bad-att-stmt", "the android-safetynet JWS header carries no x5c certificate chain (RFC 7515 sec. 4.1.6)");
|
|
998
644
|
}
|
|
999
645
|
_requireX5cCount(header.x5c.length);
|
|
1000
|
-
// x5c entries are STANDARD base64 (RFC 7515 sec. 4.1.6), not base64url like the segments.
|
|
1001
646
|
var chain = header.x5c.map(function (entry, i) {
|
|
1002
647
|
if (typeof entry !== "string") throw _err("webauthn/bad-att-stmt", "the android-safetynet x5c entry " + i + " is not a string");
|
|
1003
648
|
var der;
|
|
1004
|
-
try { der = guard.encoding.base64(entry, constants.LIMITS.SAFETYNET_CERT_MAX_BYTES,
|
|
649
|
+
try { der = guard.encoding.base64(entry, constants.LIMITS.SAFETYNET_CERT_MAX_BYTES, _err, "webauthn/bad-att-stmt", "an android-safetynet x5c entry"); }
|
|
1005
650
|
catch (e) { throw _err("webauthn/bad-att-stmt", "the android-safetynet x5c entry " + i + " is not canonical base64", e); }
|
|
1006
651
|
try { return x509.parse(der); }
|
|
1007
652
|
catch (e) { throw _err("webauthn/bad-att-cert", "the android-safetynet x5c entry " + i + " is not a decodable certificate", e); }
|
|
1008
653
|
});
|
|
1009
654
|
var leaf = chain[0];
|
|
1010
655
|
|
|
1011
|
-
// 8.5 bullet 3: nonce == STANDARD Base64 of SHA-256(authenticatorData || clientDataHash). Note
|
|
1012
|
-
// standard base64 (+/=), NOT base64url -- and the digest is over the raw concatenation.
|
|
1013
656
|
var wantNonce = _sha("sha256", Buffer.concat([att.authDataBytes, clientDataHash])).toString("base64");
|
|
1014
657
|
if (typeof payload.nonce !== "string" || !guard.crypto.constantTimeEqual(Buffer.from(payload.nonce, "utf8"), Buffer.from(wantNonce, "utf8"))) {
|
|
1015
658
|
throw _err("webauthn/safetynet-nonce-mismatch", "the android-safetynet nonce does not bind this authenticatorData and clientDataHash (WebAuthn 8.5)");
|
|
1016
659
|
}
|
|
1017
660
|
|
|
1018
|
-
// 8.5 bullet 4 (via the SafetyNet documentation): the response must come from the SafetyNet
|
|
1019
|
-
// service, which is established by the leaf being issued to attest.android.com AND the chain
|
|
1020
|
-
// validating to a Google root. The hostname alone proves nothing until the chain is anchored.
|
|
1021
661
|
if (!_safetyNetHostnameOk(leaf)) {
|
|
1022
662
|
throw _err("webauthn/safetynet-bad-hostname", "the android-safetynet x5c leaf is not issued to attest.android.com (WebAuthn 8.5)");
|
|
1023
663
|
}
|
|
1024
664
|
|
|
1025
|
-
// The device-integrity signals are NOT part of the 8.5 verification procedure -- its five bullets
|
|
1026
|
-
// never mention them -- so gating the attestation verdict on them would invent a requirement the
|
|
1027
|
-
// specification does not state. They are relying-party policy, so they are surfaced on the result
|
|
1028
|
-
// for a caller to act on, and enforced here only when the caller explicitly asks. A caller that
|
|
1029
|
-
// asks and finds them missing or false gets a refusal, never a silent pass.
|
|
1030
|
-
// The instant the service chain is judged at, resolved below once the signature has authenticated
|
|
1031
|
-
// the timestamp it comes from, and carried out on the result so a later check of the same path
|
|
1032
|
-
// uses the same one rather than resetting to the current clock.
|
|
1033
665
|
var chainAt;
|
|
1034
666
|
var signals = {
|
|
1035
667
|
ctsProfileMatch: payload.ctsProfileMatch, basicIntegrity: payload.basicIntegrity,
|
|
@@ -1043,100 +675,53 @@ var VERIFIERS = {
|
|
|
1043
675
|
return _verifySig(-257, sigBytes, leaf.subjectPublicKeyInfo.bytes,
|
|
1044
676
|
Buffer.from(segs[0] + "." + segs[1], "ascii"), _err).then(function (ok) {
|
|
1045
677
|
if (!ok) throw _err("webauthn/verify-failed", "the android-safetynet JWS signature does not verify under the x5c leaf key");
|
|
1046
|
-
// WHEN to judge the chain. These attestations are historical by construction -- the service is
|
|
1047
|
-
// retired -- so a leaf that was valid when the response was signed is routinely expired now,
|
|
1048
|
-
// and judging it against the current clock would refuse every genuine stored registration.
|
|
1049
|
-
// The response carries its own signing time, and by this point that value is covered by the
|
|
1050
|
-
// signature just verified under the leaf, so it is authenticated rather than caller-asserted.
|
|
1051
|
-
// Precedence: an explicit opts.time (the caller knows when the registration happened) beats
|
|
1052
|
-
// the signed timestamp, which beats now (a response that carries no usable timestamp).
|
|
1053
678
|
chainAt = opts.time !== undefined ? opts.time
|
|
1054
679
|
: (typeof payload.timestampMs === "number" && isFinite(payload.timestampMs) && payload.timestampMs > 0
|
|
1055
680
|
? new Date(payload.timestampMs) : undefined);
|
|
1056
681
|
return _safetyNetChainTrusted(chain, roots, chainAt);
|
|
1057
682
|
}).then(function () {
|
|
1058
|
-
// 8.5 bullet 5: attestation type Basic, trust path x5c.
|
|
1059
683
|
var res = _result("android-safetynet", "Basic", chain, att);
|
|
1060
684
|
res.safetyNet = signals;
|
|
1061
|
-
// The instant this chain was judged at, surfaced so any later check of the SAME path uses the
|
|
1062
|
-
// same one. A stored response's service chain has usually expired by now, and re-validating
|
|
1063
|
-
// it against the current clock would refuse a registration that was valid when it was made.
|
|
1064
685
|
if (chainAt !== undefined) res.chainValidatedAt = chainAt;
|
|
1065
686
|
return res;
|
|
1066
687
|
});
|
|
1067
688
|
},
|
|
1068
689
|
|
|
1069
|
-
// compound (WebAuthn 8.9): the attStmt is an ARRAY of nested attestation statements, each
|
|
1070
|
-
// verified over the SAME authenticatorData and clientDataHash as the outer object. sec. 8.9
|
|
1071
|
-
// leaves the acceptance threshold to relying-party policy ("if validation fails for one or more
|
|
1072
|
-
// subStmt, decide the appropriate result based on RP policy"); this toolkit's policy is
|
|
1073
|
-
// fail-closed -- EVERY element must verify, because accepting a compound whose strong element
|
|
1074
|
-
// failed and whose `none` element passed would let a wrapper launder a failed attestation.
|
|
1075
690
|
compound: function (att, clientDataHash, opts) {
|
|
1076
691
|
var kids = att.attStmt.children || [];
|
|
1077
|
-
// sec. 8.9 syntax `2*`: at least two nested statements, or it is not a compound.
|
|
1078
692
|
if (kids.length < 2) throw _err("webauthn/bad-att-stmt", "a compound attestation statement must carry at least two nested statements (WebAuthn 8.9)");
|
|
1079
|
-
// Not a spec rule: a resource bound this toolkit chooses. The CBOR caps bound the PARSE; they
|
|
1080
|
-
// do not bound the crypto, and each element costs a signature verify plus, for some formats, a
|
|
1081
|
-
// certificate-chain path validation.
|
|
1082
693
|
if (kids.length > constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS) {
|
|
1083
694
|
throw _err("webauthn/bad-att-stmt", "a compound attestation statement carries " + kids.length + " nested statements, above the " + constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS + " this toolkit will verify");
|
|
1084
695
|
}
|
|
1085
696
|
var elements = kids.map(function (el, i) {
|
|
1086
|
-
// sec. 8.9: nonCompoundAttStmt = { $$attStmtType } -- each element is exactly { fmt, attStmt }.
|
|
1087
697
|
if (!el || el.majorType !== 5) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be a CBOR map { fmt, attStmt } (WebAuthn 8.9)");
|
|
1088
698
|
var fN = cbor.read.mapGet(el, "fmt"), sN = cbor.read.mapGet(el, "attStmt");
|
|
1089
699
|
if (el.children.length !== 2 || !fN || !sN) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be exactly { fmt, attStmt } (WebAuthn 8.9)");
|
|
1090
700
|
if (fN.majorType !== 3) throw _err("webauthn/bad-att-stmt", "compound element " + i + " 'fmt' must be a text string");
|
|
1091
701
|
var f = cbor.read.textString(fN);
|
|
1092
|
-
// sec. 8.9 spells the element type `.ne "compound"`: nesting is forbidden by the syntax
|
|
1093
|
-
// itself, which is what fixes the evaluation depth at one. No depth parameter is needed --
|
|
1094
|
-
// and adding one would imply a nesting this format does not have.
|
|
1095
702
|
if (f === "compound") throw _err("webauthn/bad-att-stmt", "a compound attestation statement must not nest another compound (WebAuthn 8.9)");
|
|
1096
|
-
// sec. 8.1: identifiers match case-sensitively, which the registry lookup already is.
|
|
1097
703
|
var v = VERIFIERS[f];
|
|
1098
704
|
if (!v) throw _err("webauthn/unsupported-format", "compound element " + i + " uses unsupported attestation statement format '" + f + "'");
|
|
1099
705
|
var wantMajor = ATT_STMT_MAJOR[f] === undefined ? 5 : ATT_STMT_MAJOR[f];
|
|
1100
706
|
if (sN.majorType !== wantMajor) throw _err("webauthn/bad-att-stmt", "compound element " + i + " 'attStmt' has the wrong CBOR shape for format '" + f + "'");
|
|
1101
|
-
// Each element verifies against the OUTER authenticatorData: sec. 8.9 passes the same
|
|
1102
|
-
// verification-procedure inputs down, so an element cannot bind a different credential.
|
|
1103
707
|
return { fmt: f, index: i, att: { fmt: f, attStmt: sN, authData: att.authData, authDataBytes: att.authDataBytes } };
|
|
1104
708
|
});
|
|
1105
|
-
// Sequential, not Promise.all: a compound may hold many elements, and each can cost a
|
|
1106
|
-
// signature verify plus a full path validation. Fanning them out concurrently would turn one
|
|
1107
|
-
// attestation into a burst of crypto work.
|
|
1108
709
|
var out = [];
|
|
1109
710
|
return elements.reduce(function (p, e) {
|
|
1110
711
|
return p.then(function () {
|
|
1111
|
-
// The verifier is CALLED inside the promise chain, not evaluated as an argument to
|
|
1112
|
-
// Promise.resolve: most arms do their structural checks synchronously, so an argument-
|
|
1113
|
-
// position call would let those throws escape the handler below and reach the caller
|
|
1114
|
-
// bare -- the same failure reported with the element's context or without it, depending
|
|
1115
|
-
// only on whether it happened before or after the first await.
|
|
1116
712
|
return Promise.resolve().then(function () { return VERIFIERS[e.fmt](e.att, clientDataHash, opts); })
|
|
1117
713
|
.then(function (r) { out.push(r); }, function (err) {
|
|
1118
714
|
throw _err("webauthn/compound-element-failed", "compound element " + e.index + " (format '" + e.fmt + "') did not verify", err);
|
|
1119
715
|
});
|
|
1120
716
|
});
|
|
1121
717
|
}, Promise.resolve()).then(function () {
|
|
1122
|
-
// sec. 8.9 lists the supported attestation type as "Any" and authorizes returning
|
|
1123
|
-
// "implementation-specific values representing any combination of outputs". A distinct type
|
|
1124
|
-
// rather than a merge: collapsing to the strongest element would let a wrapper upgrade a
|
|
1125
|
-
// caller's attestationType check, and collapsing to the weakest would spuriously fail one.
|
|
1126
|
-
// The trust path is empty because two elements yield two independent chains and there is no
|
|
1127
|
-
// single ordered path -- each element's own path is on its entry in `compound`.
|
|
1128
718
|
var res = _result("compound", "Compound", [], att);
|
|
1129
719
|
res.compound = out;
|
|
1130
720
|
return res;
|
|
1131
721
|
});
|
|
1132
722
|
},
|
|
1133
723
|
|
|
1134
|
-
// none (WebAuthn 8.7): the authenticator provides no attestation. attStmt MUST be
|
|
1135
|
-
// an empty map; there is no statement to verify, so the result carries no trust
|
|
1136
|
-
// path. The credential public key still binds via authenticatorData (AT flag).
|
|
1137
724
|
none: function (att) {
|
|
1138
|
-
// parseAttestationObject has already established attStmt is a CBOR map; the none format
|
|
1139
|
-
// additionally requires it be EMPTY -- there is no statement to verify (WebAuthn 8.7).
|
|
1140
725
|
if (att.attStmt.children.length !== 0) {
|
|
1141
726
|
throw _err("webauthn/bad-att-stmt", "the none attestation statement MUST be an empty map (WebAuthn 8.7)");
|
|
1142
727
|
}
|
|
@@ -1144,27 +729,14 @@ var VERIFIERS = {
|
|
|
1144
729
|
},
|
|
1145
730
|
};
|
|
1146
731
|
|
|
1147
|
-
// A decoded JSON value that is safe to read named members off. JSON.parse yields null, numbers,
|
|
1148
|
-
// strings and arrays too, and a member read on any of those would leave this module's typed error
|
|
1149
|
-
// contract as a raw TypeError.
|
|
1150
732
|
function _isPlainObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
|
|
1151
733
|
|
|
1152
|
-
// WebAuthn 8.5 (via the SafetyNet documentation): the JWS leaf is issued to attest.android.com.
|
|
1153
|
-
// Checked on the SAN dNSName entries first -- the name a TLS-style certificate is actually issued
|
|
1154
|
-
// to -- falling back to the commonName only when the certificate carries no SAN at all, the way a
|
|
1155
|
-
// hostname match has been specified since RFC 6125. Compared case-insensitively (a DNS name is
|
|
1156
|
-
// case-insensitive) and exactly: no wildcard, no suffix match, so attest.android.com.evil.test
|
|
1157
|
-
// cannot pass.
|
|
1158
734
|
function _safetyNetHostnameOk(leaf) {
|
|
1159
735
|
var want = "attest.android.com";
|
|
1160
|
-
// Decoded through the SAME shared pkix extension decoder every other format here uses, so the
|
|
1161
|
-
// general-name parse cannot drift from the rest of the toolkit.
|
|
1162
736
|
var san = _decodeExt(leaf, "subjectAltName");
|
|
1163
737
|
var entries = san && Array.isArray(san.value) ? san.value : [];
|
|
1164
738
|
var dns = entries.filter(function (gn) { return gn && gn.type === "dNSName" && typeof gn.value === "string"; });
|
|
1165
|
-
// A SAN carrying dNSName entries is authoritative: the commonName is not consulted at all.
|
|
1166
739
|
if (dns.length) return dns.some(function (gn) { return gn.value.toLowerCase() === want; });
|
|
1167
|
-
// rdns is a sequence of RDNs, each a set of attribute/value pairs -- hence the nested walk.
|
|
1168
740
|
return leaf.subject.rdns.some(function (rdn) {
|
|
1169
741
|
return rdn.some(function (atv) {
|
|
1170
742
|
return atv.name === "commonName" && typeof atv.value === "string" && atv.value.toLowerCase() === want;
|
|
@@ -1172,23 +744,9 @@ function _safetyNetHostnameOk(leaf) {
|
|
|
1172
744
|
});
|
|
1173
745
|
}
|
|
1174
746
|
|
|
1175
|
-
// WebAuthn 8.5 (via the SafetyNet documentation): the x5c chain must validate to a Google root the
|
|
1176
|
-
// CALLER supplied. Every anchor is tried because a caller may hold several Google roots across a
|
|
1177
|
-
// rotation; the first that validates wins, and if none does the attestation is refused. The chain
|
|
1178
|
-
// goes through the full path validator rather than a signature-only walk, so an expired, revoked-by-
|
|
1179
|
-
// policy, or otherwise non-conforming intermediate cannot slip past on a signature alone.
|
|
1180
|
-
// The caller's roots are the only thing this function owns: their SHAPE is its own config-time
|
|
1181
|
-
// contract, so a root that is not a decodable certificate is named by its index here. Anchoring
|
|
1182
|
-
// itself is the namespace's one walk (webauthn-mds), which every other chain in this module already
|
|
1183
|
-
// reaches its anchors through -- including the anchor-stripping rule, which this had a slightly
|
|
1184
|
-
// different and weaker copy of. Each root is resolved BEFORE the walk starts, so a malformed entry
|
|
1185
|
-
// is a config fault whatever position it sits in, rather than one that only surfaces when the roots
|
|
1186
|
-
// before it happen to fail.
|
|
1187
747
|
function _safetyNetChainTrusted(chain, roots, time) {
|
|
1188
748
|
var anchors;
|
|
1189
749
|
try {
|
|
1190
|
-
// The same door opts.rootCertificates goes through, for the same reason: these become trust
|
|
1191
|
-
// anchors, and an anchor's key is what the chain is judged against.
|
|
1192
750
|
anchors = roots.map(function (root, i) {
|
|
1193
751
|
var label = "opts.safetyNetRoots[" + i + "]";
|
|
1194
752
|
return guard.parsed.acceptDerived(root, "certificate", function (bytes) {
|
|
@@ -1202,60 +760,24 @@ function _safetyNetChainTrusted(chain, roots, time) {
|
|
|
1202
760
|
"android-safetynet x5c certificate chain", "webauthn/safetynet-cert-untrusted");
|
|
1203
761
|
}
|
|
1204
762
|
|
|
1205
|
-
// `chain` is the x5c order (leaf-first); trustPath is surfaced in pki.path.validate
|
|
1206
|
-
// order (anchor-adjacent first, target/leaf last) so the caller passes it straight
|
|
1207
|
-
// to the path validator without re-ordering. The input array is not mutated.
|
|
1208
|
-
// `attestationVerified`, not `verified`: this says the attestation STATEMENT is
|
|
1209
|
-
// sound, which is not the same claim as "this registration is acceptable". The
|
|
1210
|
-
// ceremony bindings the caller may also have asked for are reported separately, in
|
|
1211
|
-
// `bindingChecked`, so a field that passed can be told from one that never ran.
|
|
1212
|
-
// It also carries everything a relying party must STORE to finish registration and
|
|
1213
|
-
// run a later login: the credentialId it will look the credential up by, the
|
|
1214
|
-
// credential public key that will verify assertions, and the signCount that is the
|
|
1215
|
-
// starting point for the sec. 7.2 step 21 counter rule. Leaving those out sent the
|
|
1216
|
-
// caller back to parse the attestation object a second time for values this call
|
|
1217
|
-
// had already decoded -- and a login cannot be verified without them.
|
|
1218
763
|
function _result(fmt, attestationType, chain, att) {
|
|
1219
764
|
return {
|
|
1220
|
-
// `valid` / `attestationVerified` say the attestation format was validly processed, which the `none`
|
|
1221
|
-
// format (WebAuthn 8.7, no attestation) satisfies too. The ASSURANCE level is `attestationType`
|
|
1222
|
-
// (`None` for none): a relying party that requires cryptographic attestation gates on it, not on `valid`.
|
|
1223
765
|
valid: true, attestationVerified: true, fmt: fmt, attestationType: attestationType,
|
|
1224
766
|
trustPath: (chain || []).slice().reverse(),
|
|
1225
767
|
aaguid: att.authData.aaguid,
|
|
1226
768
|
credentialId: att.authData.credentialId,
|
|
1227
769
|
credentialPublicKey: att.authData.credentialPublicKey,
|
|
1228
|
-
// The same key in the form that SURVIVES STORAGE. The decoded object carries Buffers, so a JSON
|
|
1229
|
-
// round trip through a datastore returns {"type":"Buffer","data":[...]} rather than what went
|
|
1230
|
-
// in; the COSE bytes are what a credential row actually holds. Returning only the object left
|
|
1231
|
-
// the caller re-parsing the attestation object to recover bytes this call had already isolated.
|
|
1232
770
|
credentialPublicKeyBytes: att.authData.credentialPublicKeyBytes,
|
|
1233
771
|
signCount: att.authData.signCount,
|
|
1234
772
|
flags: att.authData.flags,
|
|
1235
|
-
// The RP ID hash and the authenticator extension outputs, for the same reason the key bytes
|
|
1236
|
-
// above are here: this call decoded them and the caller needs them. Extension outputs arrive
|
|
1237
|
-
// at REGISTRATION and nowhere else -- credProtect (whether this credential is
|
|
1238
|
-
// user-verification-required for the rest of its life), credProps.rk (whether it is
|
|
1239
|
-
// discoverable), credBlob, minPinLength, hmac-secret/prf -- so a relying party that must
|
|
1240
|
-
// persist them had to re-parse the attestation object to read values already in hand.
|
|
1241
|
-
// `verifyAssertion` returns both; the registration verdict returning less made the two halves
|
|
1242
|
-
// of one lifecycle disagree about what they hand back.
|
|
1243
773
|
rpIdHash: att.authData.rpIdHash,
|
|
1244
774
|
extensions: att.authData.extensions,
|
|
1245
775
|
};
|
|
1246
776
|
}
|
|
1247
777
|
|
|
1248
|
-
// Decode the Apple extension AppleAnonymousAttestation ::= SEQUENCE { nonce [1]
|
|
1249
|
-
// EXPLICIT OCTET STRING } (WebAuthn 8.8 item 29) and return the 32-byte nonce.
|
|
1250
778
|
function _appleNonce(extValue) {
|
|
1251
779
|
var seq;
|
|
1252
780
|
try { seq = asn1.decode(extValue); } catch (e) { throw _err("webauthn/bad-att-cert", "the apple attestation extension is not decodable", e); }
|
|
1253
|
-
// ARITY IS PART OF THE DECLARED SHAPE. Reading the first child and ignoring the rest
|
|
1254
|
-
// accepts a certificate carrying a second, unchecked value beside the nonce -- and this
|
|
1255
|
-
// extension exists precisely to carry the value the attestation binds to, so an
|
|
1256
|
-
// ambiguous encoding of it is not a shape this verifier gets to pick a reading from.
|
|
1257
|
-
// The type is one SEQUENCE of exactly one field, and the EXPLICIT wrapper holds exactly
|
|
1258
|
-
// one value; anything else is refused rather than partially read.
|
|
1259
781
|
var isSeq = seq.tagClass === "universal" && seq.tagNumber === asn1.TAGS.SEQUENCE;
|
|
1260
782
|
if (!isSeq || !seq.children || seq.children.length !== 1) {
|
|
1261
783
|
throw _err("webauthn/bad-att-cert", "the apple attestation extension is not SEQUENCE { [1] OCTET STRING } (expected exactly one field)");
|
|
@@ -1267,9 +789,6 @@ function _appleNonce(extValue) {
|
|
|
1267
789
|
try { return asn1.read.octetString(tagged.children[0]); } catch (e) { throw _err("webauthn/bad-att-cert", "the apple attestation nonce is not an OCTET STRING", e); }
|
|
1268
790
|
}
|
|
1269
791
|
|
|
1270
|
-
// The complete Android Key Attestation KeyDescription conformance (AOSP schema + WebAuthn
|
|
1271
|
-
// 8.4.1) lives in validator-keydesc, composed here. A structural fault is bad-att-cert; a
|
|
1272
|
-
// well-formed description that fails an 8.4.1 MUST is verify-failed.
|
|
1273
792
|
function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
1274
793
|
validator.keydesc.androidKeyDescription(cert, clientDataHash, _exts, WebauthnError, "webauthn/bad-att-cert", "webauthn/verify-failed");
|
|
1275
794
|
}
|
|
@@ -1384,74 +903,30 @@ function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
|
1384
903
|
* // and anchor res.trustPath to your pinned roots with pki.path.validate
|
|
1385
904
|
*/
|
|
1386
905
|
function verify(attestationObject, clientDataHash, opts) {
|
|
1387
|
-
// Two call shapes, disjoint by shape so neither is guessed at: the digest positionally, or an
|
|
1388
|
-
// options object carrying the clientDataJSON it is computed from. A Buffer IS an object, so the
|
|
1389
|
-
// question has to be asked as "is this not bytes?" -- the disjointness is between a BufferSource
|
|
1390
|
-
// and everything else, and testing only the second half swallows the ordinary two-argument call.
|
|
1391
906
|
if (opts === undefined && clientDataHash !== undefined &&
|
|
1392
907
|
!_isBufferSource(clientDataHash) && _isPlainObject(clientDataHash)) {
|
|
1393
908
|
opts = clientDataHash; clientDataHash = undefined;
|
|
1394
909
|
}
|
|
1395
910
|
opts = opts || {};
|
|
1396
|
-
// Every option here either GATES the verdict or supplies the trust material a gate needs, so a
|
|
1397
|
-
// misspelled key is not a harmless no-op: `metdata` leaves the metadata gate switched off and the
|
|
1398
|
-
// call returns a pass the caller believes was checked against the catalogue. Reject an
|
|
1399
|
-
// unrecognized key at the boundary, and validate the one shared option every arm reads, so a bad
|
|
1400
|
-
// instant is a config fault here rather than a trust failure reported from deep inside a chain.
|
|
1401
911
|
try {
|
|
1402
912
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1403
913
|
guard.identifier.assertKnownKeys(opts, _VERIFY_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
1404
|
-
// ONE read of the caller's object, HERE, and nothing below ever touches it again. Not a
|
|
1405
|
-
// precaution about mutation across the deferred verification -- that was already the reason for
|
|
1406
|
-
// the copy this replaces -- but about the reads themselves: a gate that reads `opts.x` to decide
|
|
1407
|
-
// whether a demand was made, and a later step that reads `opts.x` again to act on it, are two
|
|
1408
|
-
// reads of a value the caller still owns, and an accessor makes them disagree. The value that
|
|
1409
|
-
// was CHECKED must be the value that is USED, which means checking and using the same copy.
|
|
1410
|
-
// `Object.assign` takes every own enumerable field by value at this instant, which resolves any
|
|
1411
|
-
// accessor exactly once.
|
|
1412
914
|
opts = Object.assign({}, opts);
|
|
1413
915
|
if (opts.time !== undefined) guard.time.assertValid(opts.time, _err, "webauthn/bad-input", "opts.time");
|
|
1414
|
-
// The boolean switches are typed HERE rather than in the arm that reads them. Validated only
|
|
1415
|
-
// inside the android-safetynet arm, `requireCtsProfileMatch: "true"` reaching any other format
|
|
1416
|
-
// is never examined at all -- a truthy string that demands nothing, silently accepted. A
|
|
1417
|
-
// recognized key with an unusable value is the same class of caller mistake as an unrecognized
|
|
1418
|
-
// key, and belongs at the same boundary.
|
|
1419
916
|
_FORMAT_SCOPED_BOOLEAN_OPTS.forEach(function (k) {
|
|
1420
917
|
if (opts[k] !== undefined && typeof opts[k] !== "boolean") throw _err("webauthn/bad-input", "opts." + k + " must be a boolean");
|
|
1421
918
|
});
|
|
1422
919
|
} catch (e) { return Promise.reject(e); }
|
|
1423
920
|
var att;
|
|
1424
|
-
// Both byte inputs are snapshotted BEFORE anything reads them, for the same reason the trust
|
|
1425
|
-
// anchors are: the attestation statement is not evaluated until a later promise turn, and both
|
|
1426
|
-
// stay caller-owned across that gap. The attestation object is copied before it is parsed, not
|
|
1427
|
-
// after, because the parse surfaces its byte fields as VIEWS -- the authenticator data the
|
|
1428
|
-
// signature covers, the statement, the credential key -- so copying afterwards would leave every
|
|
1429
|
-
// one of them pointing at bytes the caller can still rewrite. The clientDataHash is what binds
|
|
1430
|
-
// the statement to this ceremony; a caller who overwrote it in that gap would have the signature
|
|
1431
|
-
// checked against a challenge and origin nobody agreed to, and the verdict would still report a
|
|
1432
|
-
// sound attestation. A DataView or ArrayBuffer comes in by the same door and is copied too.
|
|
1433
921
|
var attBytes, cdh, clientData = null;
|
|
1434
922
|
try {
|
|
1435
923
|
attBytes = _bytesArg(attestationObject, "attestationObject");
|
|
1436
|
-
// The two forms of the same input, and NEITHER is inferred from the other's absence -- the
|
|
1437
|
-
// same rule verifyAssertion applies, because it is the same question. Supplying both invites
|
|
1438
|
-
// them to disagree, and picking one would make the attestation cover something the caller did
|
|
1439
|
-
// not mean.
|
|
1440
924
|
var haveJson = opts.clientDataJSON !== undefined, haveHash = clientDataHash !== undefined;
|
|
1441
925
|
if (haveJson === haveHash) {
|
|
1442
926
|
throw _err("webauthn/bad-input", "verify takes exactly one of clientDataJSON or the clientDataHash argument");
|
|
1443
927
|
}
|
|
1444
928
|
if (haveJson) {
|
|
1445
|
-
// The bytes are taken ONCE and both the reading and the hashing use that copy. Checking one
|
|
1446
|
-
// value and hashing another would bind the attestation to client data the ceremony checks
|
|
1447
|
-
// never saw -- the check and the use have to be of the same bytes.
|
|
1448
929
|
var cdjBytes = _bytesArg(opts.clientDataJSON, "opts.clientDataJSON");
|
|
1449
|
-
// Given the JSON, the ceremony TYPE is checked unconditionally. Which ceremony a response
|
|
1450
|
-
// belongs to is fixed by the specification rather than chosen by the caller, and a login
|
|
1451
|
-
// response replayed into a registration is exactly what that check stops -- so registration
|
|
1452
|
-
// gets the same non-negotiable rule the login path already had. Without this door there was
|
|
1453
|
-
// no way to apply it at registration at all: the hash is opaque, and the caller was left to
|
|
1454
|
-
// compare a value the attestation never bound.
|
|
1455
930
|
clientData = parseClientData(cdjBytes, {
|
|
1456
931
|
expectedType: "webauthn.create",
|
|
1457
932
|
expectedChallenge: opts.expectedChallenge,
|
|
@@ -1460,8 +935,6 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1460
935
|
});
|
|
1461
936
|
cdh = _sha("sha256", cdjBytes);
|
|
1462
937
|
} else {
|
|
1463
|
-
// Every expectation the clientData reader would have answered is unanswerable from a bare
|
|
1464
|
-
// digest, so each is refused rather than left silently uncompared.
|
|
1465
938
|
if (opts.expectedChallenge !== undefined || opts.expectedOrigin !== undefined ||
|
|
1466
939
|
opts.expectedTopOrigin !== undefined) {
|
|
1467
940
|
throw _err("webauthn/bad-input",
|
|
@@ -1469,62 +942,23 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1469
942
|
"did not supply -- pass opts.clientDataJSON instead of the clientDataHash argument, or check them yourself");
|
|
1470
943
|
}
|
|
1471
944
|
cdh = _bytesArg(clientDataHash, "clientDataHash");
|
|
1472
|
-
// The LENGTH is checked on the normalized copy, so the digest is 32 bytes whichever form it
|
|
1473
|
-
// arrived in -- not only when it arrived as a Buffer.
|
|
1474
945
|
if (cdh.length !== 32) throw _err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest");
|
|
1475
946
|
}
|
|
1476
947
|
} catch (e) { return Promise.reject(e); }
|
|
1477
948
|
attestationObject = attBytes;
|
|
1478
949
|
clientDataHash = cdh;
|
|
1479
950
|
try { att = parseAttestationObject(attestationObject); } catch (e) { return Promise.reject(e); }
|
|
1480
|
-
// A registration attestation MUST carry attestedCredentialData (the AT flag): the
|
|
1481
|
-
// whole point is to bind the attestation to a credential public key. Reject an
|
|
1482
|
-
// AT-clear authenticatorData up front -- else the packed x5c arm could resolve a
|
|
1483
|
-
// positive verdict bound to NO credential, and every arm would dereference the
|
|
1484
|
-
// null credential key with a raw (untyped) throw (WebAuthn 6.1 / 7.1).
|
|
1485
951
|
if (!att.authData.flags.at || !att.authData.credentialPublicKey) {
|
|
1486
952
|
return Promise.reject(_err("webauthn/bad-auth-data", "attestation requires attestedCredentialData (the AT flag must be set)"));
|
|
1487
953
|
}
|
|
1488
954
|
var verifier = VERIFIERS[att.fmt];
|
|
1489
955
|
if (!verifier) return Promise.reject(_err("webauthn/unsupported-format", "attestation statement format '" + att.fmt + "' is not supported"));
|
|
1490
|
-
// A policy about TPM key properties is checked INSIDE the tpm arm, so an attestation in any other
|
|
1491
|
-
// format would never reach it and the policy would silently apply to nothing -- a caller who
|
|
1492
|
-
// demanded a TPM-bound key would accept a `none` attestation instead. The requirement therefore
|
|
1493
|
-
// belongs at the dispatch, where it can refuse a format that cannot satisfy it, not in the arm
|
|
1494
|
-
// that only runs once that format was already chosen.
|
|
1495
956
|
if (opts.tpmPolicy !== undefined && !_formatCarries(att, "tpm")) {
|
|
1496
957
|
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"));
|
|
1497
958
|
}
|
|
1498
|
-
// The same rule, and the same reason, for the one other option that DEMANDS rather than supplies:
|
|
1499
|
-
// ctsProfileMatch is an android-safetynet device-integrity signal, so no other format's statement
|
|
1500
|
-
// carries one to test. Checked only inside that arm, a caller who demanded a CTS-matching device
|
|
1501
|
-
// would get a pass from a `packed` or `none` attestation that was never asked the question.
|
|
1502
|
-
// A caller who passes `false` demands nothing and is not refused.
|
|
1503
959
|
if (opts.requireCtsProfileMatch === true && !_formatCarries(att, "android-safetynet")) {
|
|
1504
960
|
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"));
|
|
1505
961
|
}
|
|
1506
|
-
// The ceremony bindings run BEFORE the statement is evaluated: a response
|
|
1507
|
-
// produced for another relying party, or without the user presence the caller
|
|
1508
|
-
// requires, is not a message this call should spend a signature verification on
|
|
1509
|
-
// -- and refusing it early keeps the "acceptable" question ahead of the "sound"
|
|
1510
|
-
// one, which is the order sec. 7.1 puts them in.
|
|
1511
|
-
// ONE normalized options object, built synchronously here. Nothing past this point reads
|
|
1512
|
-
// the caller's `opts` again -- not the values, and not the presence tests that pick which
|
|
1513
|
-
// trust policy runs.
|
|
1514
|
-
//
|
|
1515
|
-
// Fixing this per-field does not converge. The verifier resolves in a later turn, so every
|
|
1516
|
-
// field the deferred path touches stays caller-owned across that gap, and each repair that
|
|
1517
|
-
// snapshotted one more field left the next one aliased: the roots, then the safetynet
|
|
1518
|
-
// roots, then the routing test beside them, then the metadata routing test, then the
|
|
1519
|
-
// validation instant. They are not separate bugs, they are one window, and the way to
|
|
1520
|
-
// close a window is to stop reading through it rather than to copy one more thing across.
|
|
1521
|
-
//
|
|
1522
|
-
// `Object.assign` captures every own field by value at this instant, which fixes both the
|
|
1523
|
-
// values and the `!== undefined` presence tests derived from them. The two root arrays get
|
|
1524
|
-
// a deeper copy because their CONTENTS are caller-owned too. `metadata` is captured by
|
|
1525
|
-
// reference: a verified catalogue can hold thousands of entries and copying it per
|
|
1526
|
-
// registration is a real cost, so reassignment can no longer swap it, while mutation of
|
|
1527
|
-
// the object it points at remains open and is tracked separately.
|
|
1528
962
|
var bindingChecked, vopts;
|
|
1529
963
|
try {
|
|
1530
964
|
bindingChecked = _applyBindings(att.authData, att.authData.credentialPublicKey, opts);
|
|
@@ -1535,23 +969,13 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1535
969
|
} catch (e) { return Promise.reject(e); }
|
|
1536
970
|
return Promise.resolve().then(function () { return verifier(att, clientDataHash, vopts); })
|
|
1537
971
|
.then(function (res) {
|
|
1538
|
-
// What the clientData reader found, on the verdict rather than left for the caller to
|
|
1539
|
-
// recompute -- the same field the login verdict carries, for the same reason: `checked` is
|
|
1540
|
-
// where a comparison that ran is told from one that never did. Null when the caller supplied
|
|
1541
|
-
// only the digest, which is the honest answer: nothing read it.
|
|
1542
972
|
res.clientData = clientData;
|
|
1543
973
|
return res;
|
|
1544
974
|
})
|
|
1545
975
|
.then(function (res) {
|
|
1546
|
-
// Metadata governs when supplied; caller roots are the fallback for the models
|
|
1547
|
-
// the catalogue does not cover. Whichever ran, the verdict SAYS which -- so a
|
|
1548
|
-
// caller can tell an anchored trust path from one nobody checked, rather than
|
|
1549
|
-
// inferring it from which option they happened to pass.
|
|
1550
976
|
if (vopts.metadata === undefined) {
|
|
1551
977
|
if (vopts.rootCertificates !== undefined) return _applyCallerRoots(res, vopts.rootCertificates, vopts);
|
|
1552
978
|
res.anchoredTo = _anchoredRoutes(res, null);
|
|
1553
|
-
// Coverage is reported whenever anything WAS anchored, for the same reason the other
|
|
1554
|
-
// routes report it: `anchoredTo` alone cannot say how much of a compound it covered.
|
|
1555
979
|
var sn = _safetyNetAnchored(res);
|
|
1556
980
|
if (sn) {
|
|
1557
981
|
res.anchoredElements = { total: (res.fmt === "compound" && Array.isArray(res.compound)) ? res.compound.length : 1,
|
|
@@ -1561,29 +985,9 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1561
985
|
}
|
|
1562
986
|
return Promise.resolve().then(function () { return _applyMetadata(res, att, vopts); })
|
|
1563
987
|
.then(function (out) { out.anchoredTo = _anchoredRoutes(out, "metadata"); return out; }, function (e) {
|
|
1564
|
-
// A catalogue MISS is the case pinned roots exist for: the combined
|
|
1565
|
-
// configuration is exactly "MDS-listed authenticators AND Apple", and Apple
|
|
1566
|
-
// is in no catalogue, so failing here would make the documented pairing
|
|
1567
|
-
// unusable for the very models it was added to reach.
|
|
1568
|
-
//
|
|
1569
|
-
// A catalogue DENIAL is not a miss and never falls through. If the model IS
|
|
1570
|
-
// listed and its entry disqualifies it -- a revoked status report, a trust
|
|
1571
|
-
// path that does not reach the roots that model registered -- then the
|
|
1572
|
-
// catalogue has spoken about this authenticator, and letting a static pin
|
|
1573
|
-
// overrule it would turn the stronger source into the weaker one.
|
|
1574
988
|
if (!e || e.code !== "webauthn/metadata-not-found" || vopts.rootCertificates === undefined) throw e;
|
|
1575
|
-
// Only the elements the catalogue did not cover fall back. The listed ones were
|
|
1576
|
-
// already governed AND chain-validated to the roots their own entries register, so
|
|
1577
|
-
// re-judging them against an unrelated pin would refuse the combined configuration
|
|
1578
|
-
// this fallback exists to support.
|
|
1579
989
|
return Promise.resolve(_applyCallerRoots(res, vopts.rootCertificates, vopts, e.missedPaths))
|
|
1580
990
|
.then(function (out) {
|
|
1581
|
-
// Report BOTH routes. The pinned roots covered only the elements the catalogue
|
|
1582
|
-
// missed; the rest were governed by their metadata entries and chain-validated
|
|
1583
|
-
// against the roots those entries register. Saying "rootCertificates" alone
|
|
1584
|
-
// would attribute the whole evaluation to the weaker half and lose the entries
|
|
1585
|
-
// an auditor needs to see. `anchoredTo` names both, and the governed entries
|
|
1586
|
-
// are surfaced exactly as the pure-metadata route surfaces them.
|
|
1587
991
|
var applied = e.appliedEntries || [];
|
|
1588
992
|
if (!applied.length) return out;
|
|
1589
993
|
var primary = applied[0];
|
|
@@ -1598,68 +1002,21 @@ function verify(attestationObject, clientDataHash, opts) {
|
|
|
1598
1002
|
.then(function (res) { res.bindingChecked = bindingChecked; return res; });
|
|
1599
1003
|
}
|
|
1600
1004
|
|
|
1601
|
-
// WebAuthn sec. 7.1 step 22-23: having verified the attestation, look the authenticator model up in
|
|
1602
|
-
// the metadata and require its trust path to chain to one of the roots that model is registered
|
|
1603
|
-
// with. The metadata must be a verifyMetadataBlob RESULT, never raw bytes -- accepting bytes here
|
|
1604
|
-
// would let an unverified BLOB decide which roots are acceptable, which is the whole question.
|
|
1605
1005
|
function _applyMetadata(res, att, opts) {
|
|
1606
1006
|
var md = opts.metadata;
|
|
1607
|
-
// Provenance, not shape: only a catalogue this toolkit actually verified may decide which roots
|
|
1608
|
-
// an authenticator is allowed to chain to. Recognizing it by its property names would accept a
|
|
1609
|
-
// hand-built object, or one restored from a cache an attacker can write -- neither of which has
|
|
1610
|
-
// been through the signature and chain checks that give the catalogue its authority.
|
|
1611
1007
|
if (!mds.isVerifiedResult(md)) {
|
|
1612
1008
|
throw _err("webauthn/bad-input", "opts.metadata must be a pki.webauthn.verifyMetadataBlob result, not a raw BLOB");
|
|
1613
1009
|
}
|
|
1614
|
-
// A compound attestation (sec. 8.9) deliberately carries an EMPTY top-level trust path, because
|
|
1615
|
-
// its elements yield independent chains with no single ordered path -- each element's own path is
|
|
1616
|
-
// on its entry in `compound`. Treating that empty path as "nothing to anchor" would refuse every
|
|
1617
|
-
// compound attestation outright, including ones whose certificate-bearing elements all chain to
|
|
1618
|
-
// the model's registered roots. So the paths to check are the elements'.
|
|
1619
|
-
// Each path travels WITH the format that produced it, because the format is what decides which
|
|
1620
|
-
// identifier may name its entry -- and a compound mixes formats. A compound's elements are
|
|
1621
|
-
// independent claims: a packed element's AAGUID is signed while a fido-u2f element's is not, so a
|
|
1622
|
-
// single choice for the whole statement is wrong in both directions. It would push the packed
|
|
1623
|
-
// element down the certificate-identifier path (where a conforming entry indexed only by its
|
|
1624
|
-
// AAGUID is not found), or trust the u2f element's unsigned AAGUID.
|
|
1625
|
-
// A compound's elements are independent claims (sec. 8.9), and one carrying no
|
|
1626
|
-
// certificates -- a `none` or self element -- makes no attestation claim there is
|
|
1627
|
-
// anything to anchor. Refusing the whole statement over it would reject a
|
|
1628
|
-
// conforming compound whose certificate-bearing elements do chain; enforcing over
|
|
1629
|
-
// the rest and saying nothing would report catalogue enforcement over a statement
|
|
1630
|
-
// it did not wholly reach. So the elements that CAN be governed are, and the
|
|
1631
|
-
// coverage is reported (`anchoredElements`) rather than left to be assumed.
|
|
1632
1010
|
var paths = (res.fmt === "compound" && Array.isArray(res.compound))
|
|
1633
1011
|
? res.compound.filter(function (el) { return el.trustPath && el.trustPath.length; })
|
|
1634
1012
|
.map(function (el) { return { tp: el.trustPath, fmt: el.fmt, at: el.chainValidatedAt }; })
|
|
1635
1013
|
: (res.trustPath && res.trustPath.length ? [{ tp: res.trustPath, fmt: res.fmt, at: res.chainValidatedAt }] : []);
|
|
1636
1014
|
res.anchoredElements = { total: (res.fmt === "compound" && Array.isArray(res.compound)) ? res.compound.length : 1,
|
|
1637
1015
|
anchored: paths.length };
|
|
1638
|
-
// An attestation with no trust path at all (`none`, or a self-attestation) has nothing to anchor,
|
|
1639
|
-
// so a caller who asked for metadata enforcement must be told it could not be applied rather than
|
|
1640
|
-
// receiving a pass that looks like it was.
|
|
1641
1016
|
if (!paths.length) {
|
|
1642
1017
|
throw _err("webauthn/metadata-not-applicable", "opts.metadata was supplied, but this attestation carries no trust path to anchor (format '" + res.fmt + "')");
|
|
1643
1018
|
}
|
|
1644
|
-
// The catalogue's two key spaces are DISJOINT, and which one applies is decided by what the
|
|
1645
|
-
// authenticator declares -- never by trying one and falling back to the other. An authenticator
|
|
1646
|
-
// that declares a signed model identity is looked up by it and by nothing else, so a model the
|
|
1647
|
-
// catalogue does not list is refused instead of being resolved out of the U2F key space under
|
|
1648
|
-
// some other authenticator's entry.
|
|
1649
1019
|
var aaguid = mds.aaguidToString(att.authData.aaguid);
|
|
1650
|
-
// The aaguid may only select the entry when the attestation SIGNATURE covers it. For fido-u2f it
|
|
1651
|
-
// does not -- sec. 8.6 signs 0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F,
|
|
1652
|
-
// which excludes the field entirely -- so those 16 bytes are attacker-editable. Trusting them
|
|
1653
|
-
// there does not merely misreport a model: setting them to a LISTED model that shares the
|
|
1654
|
-
// vendor's registered root makes the forged statement resolve to that model's entry, skipping the
|
|
1655
|
-
// real U2F entry's own key-identifier lookup and, with it, its status reports. A revoked
|
|
1656
|
-
// authenticator would present itself as its healthy sibling. So for that format the certificate
|
|
1657
|
-
// key identifier decides, whatever the field says.
|
|
1658
|
-
// Every path is resolved and enforced against ITS OWN entry, by the identifier ITS OWN format
|
|
1659
|
-
// allows. A compound carries independent claims and its element order is not signed, so choosing
|
|
1660
|
-
// one entry for the whole statement would let that order decide whose status report is consulted:
|
|
1661
|
-
// a healthy element listed first would suppress a revoked sibling's report while both still
|
|
1662
|
-
// anchor successfully. Each path answers for itself, and all of them must pass.
|
|
1663
1020
|
var applied = [];
|
|
1664
1021
|
function govern(info) {
|
|
1665
1022
|
var tp = info.tp;
|
|
@@ -1669,59 +1026,21 @@ function _applyMetadata(res, att, opts) {
|
|
|
1669
1026
|
entry = mds.metadataFor(md, aaguid);
|
|
1670
1027
|
identifier = "aaguid " + aaguid;
|
|
1671
1028
|
} else {
|
|
1672
|
-
// No signed model identity: keyed by the attestation certificate instead. A path is
|
|
1673
|
-
// anchor-adjacent first, so that certificate is its LAST element -- the one whose key the
|
|
1674
|
-
// catalogue identifies, not the root it chains to.
|
|
1675
1029
|
var keyId = mds.certKeyIdentifier(tp[tp.length - 1]);
|
|
1676
1030
|
entry = mds.metadataForKeyIdentifier(md, keyId);
|
|
1677
1031
|
identifier = "attestation certificate key identifier " + keyId;
|
|
1678
1032
|
}
|
|
1679
1033
|
if (!entry) throw _err("webauthn/metadata-not-found", "no metadata entry matches this authenticator (" + identifier + ")");
|
|
1680
|
-
// The attestation certificate is passed in so a report that names a single certificate is
|
|
1681
|
-
// judged against the one actually presented, rather than denying every device the entry covers.
|
|
1682
1034
|
if (mds.statusDenied(entry, md, tp[tp.length - 1], at)) {
|
|
1683
1035
|
throw _err("webauthn/metadata-status", "the metadata entry for " + identifier + " carries a disqualifying status report");
|
|
1684
1036
|
}
|
|
1685
|
-
// The same three inputs the status check above used. metadataAnchors makes the same decision
|
|
1686
|
-
// for the callers who reach it directly, so handing it different inputs is how the two readings
|
|
1687
|
-
// would come to disagree about the same entry.
|
|
1688
1037
|
var anchors = mds.metadataAnchors(entry, { metadata: md, time: at, certificate: tp[tp.length - 1] });
|
|
1689
1038
|
if (!anchors.length) throw _err("webauthn/metadata-no-anchor", "the metadata entry for " + identifier + " supplies no attestation root certificate");
|
|
1690
1039
|
applied.push({ entry: entry, anchors: anchors, identifier: identifier });
|
|
1691
1040
|
return { anchors: anchors, identifier: identifier };
|
|
1692
1041
|
}
|
|
1693
|
-
// A path must VALIDATE to one of the roots its own model registered -- signature chaining,
|
|
1694
|
-
// validity, constraints -- not merely resemble one. A name comparison against the top of the path
|
|
1695
|
-
// would accept any certificate asserting the registered issuer's name, which is the assertion an
|
|
1696
|
-
// attacker controls; and certificate equality is not the question either, since a registered root
|
|
1697
|
-
// normally ISSUES the attestation certificate rather than being it. So this composes the same
|
|
1698
|
-
// path validation the BLOB's own chain goes through. chainToAnchor takes leaf-first and a trust
|
|
1699
|
-
// path is anchor-adjacent first, so the order is reversed on the way in.
|
|
1700
|
-
//
|
|
1701
|
-
// opts.time was validated at the entry, so this instant is usable: an unusable one reaching the
|
|
1702
|
-
// path validator would be absorbed by the chain walk and reported as an authenticator trust
|
|
1703
|
-
// failure, which is the wrong verdict for a caller's configuration mistake.
|
|
1704
1042
|
var at = opts.time !== undefined ? opts.time : new Date();
|
|
1705
|
-
// The catalogue's own freshness is re-established HERE, at the instant it is being used to decide
|
|
1706
|
-
// trust -- not only at the instant it was parsed. A verified result is a plain object a caller may
|
|
1707
|
-
// hold and reuse indefinitely, so a catalogue fetched before its nextUpdate and then reused a
|
|
1708
|
-
// month afterwards would otherwise keep authorizing an authenticator whose status reports have
|
|
1709
|
-
// since revoked it, which is exactly what nextUpdate exists to prevent. The caller's original
|
|
1710
|
-
// allowStale decision rides on the result, so opting out stays opted out.
|
|
1711
1043
|
mds.assertFresh(md, at, "the metadata supplied as opts.metadata");
|
|
1712
|
-
// EVERY path must pass, not merely one of them: for a compound attestation each element is an
|
|
1713
|
-
// independent claim, and accepting the whole because one element anchored would let an
|
|
1714
|
-
// unanchored -- or revoked -- element ride along on its neighbor's trust.
|
|
1715
|
-
// Govern EVERY path BEFORE any is chain-validated, and let the SEVEREST outcome decide
|
|
1716
|
-
// rather than the first one reached. A compound's elements are independent claims and
|
|
1717
|
-
// its element order is NOT signed, so aborting on the first failure lets that order pick
|
|
1718
|
-
// which element the catalogue gets to speak about. The asymmetry that makes it exploitable
|
|
1719
|
-
// is that the errors are not equal: `metadata-not-found` is the one outcome the caller may
|
|
1720
|
-
// fall back to pinned roots on, and that fallback covers the WHOLE statement. So an element
|
|
1721
|
-
// the catalogue does not list, placed first, would raise the fallback error and a listed
|
|
1722
|
-
// sibling carrying a disqualifying status report would never be consulted -- the unlisted
|
|
1723
|
-
// element laundering the revoked one. A denial therefore outranks a miss wherever each sits:
|
|
1724
|
-
// a miss is only reported once every other path has been governed and none of them objected.
|
|
1725
1044
|
var governed = [];
|
|
1726
1045
|
var missed = null;
|
|
1727
1046
|
var missedPaths = [];
|
|
@@ -1729,51 +1048,21 @@ function _applyMetadata(res, att, opts) {
|
|
|
1729
1048
|
try {
|
|
1730
1049
|
governed.push({ info: paths[gi], g: govern(paths[gi]) });
|
|
1731
1050
|
} catch (ge) {
|
|
1732
|
-
// WHICH paths missed is carried with the error, not just THAT one did. The caller's
|
|
1733
|
-
// pinned-root fallback is for the elements the catalogue did not cover, and it cannot
|
|
1734
|
-
// tell which those were from the verdict alone -- every element looks the same there.
|
|
1735
1051
|
if (ge && ge.code === "webauthn/metadata-not-found") { missed = missed || ge; missedPaths.push(paths[gi]); continue; }
|
|
1736
|
-
throw ge;
|
|
1052
|
+
throw ge;
|
|
1737
1053
|
}
|
|
1738
1054
|
}
|
|
1739
|
-
// The miss carries BOTH halves: which paths the catalogue did not cover, and which entries
|
|
1740
|
-
// it did govern. Without the second the fallback can only describe its own half, so a
|
|
1741
|
-
// mixed-route verdict would attribute the whole evaluation to the pinned roots and drop the
|
|
1742
|
-
// metadata-backed part entirely -- the stronger half, and the one an auditor most needs.
|
|
1743
1055
|
if (missed) { missed.missedPaths = missedPaths; missed.appliedEntries = applied; }
|
|
1744
|
-
// The miss is NOT reported yet. Resolving an entry is only half of governance: the other
|
|
1745
|
-
// half is that the path must actually VALIDATE to the roots that entry registers, and that
|
|
1746
|
-
// runs below. Reporting the miss here would skip it for every listed element -- so a listed
|
|
1747
|
-
// element whose path reaches the caller's pinned roots but NOT its own registered roots
|
|
1748
|
-
// would ride out on the unlisted sibling's fallback, which is that same bypass moved one
|
|
1749
|
-
// phase down. Every listed element is chain-validated first, and only then may a miss be
|
|
1750
|
-
// raised.
|
|
1751
1056
|
return governed.reduce(function (p, item) {
|
|
1752
1057
|
return p.then(function () {
|
|
1753
1058
|
var g = item.g, info = item.info;
|
|
1754
|
-
// A path is re-validated at the instant its own format already judged it at, when the format
|
|
1755
|
-
// established one from signed data. An android-safetynet response carries its signing time and
|
|
1756
|
-
// its service chain has usually expired since; resetting to the current clock here would
|
|
1757
|
-
// refuse the very registration the format verifier just accepted. An explicit opts.time still
|
|
1758
|
-
// wins, since the caller knows when the registration happened. The catalogue's own expiry is a
|
|
1759
|
-
// separate question and is judged above, against the caller's instant rather than this one.
|
|
1760
1059
|
var pathAt = opts.time !== undefined ? opts.time : (info.at || at);
|
|
1761
1060
|
return mds.chainToAnchor(info.tp.slice().reverse(), g.anchors, pathAt,
|
|
1762
1061
|
"attestation trust path for " + g.identifier + " (against the roots its metadata entry registers)");
|
|
1763
1062
|
});
|
|
1764
1063
|
}, Promise.resolve())
|
|
1765
1064
|
.then(function () {
|
|
1766
|
-
// Every LISTED element has now been fully governed -- entry resolved, status checked, and its
|
|
1767
|
-
// path validated to that entry's own registered roots. A miss recorded above may finally be
|
|
1768
|
-
// reported, and the caller's pinned-roots fallback is reachable only from here: a statement
|
|
1769
|
-
// whose listed elements all satisfied their catalogue entries, with a sibling the catalogue
|
|
1770
|
-
// simply does not cover.
|
|
1771
1065
|
if (missed) throw missed;
|
|
1772
|
-
// The ENTRY's aaguid, not the authenticator's raw field: for a U2F authenticator that field is
|
|
1773
|
-
// all zeroes, which means "no model identity" -- reporting it back as though it were one would
|
|
1774
|
-
// hand the caller a value that matches nothing and reads like an identifier. `entries` lists
|
|
1775
|
-
// every entry that governed a path, which for a compound is one per certificate-bearing
|
|
1776
|
-
// element; `entry` is the first, and equals the only one for every other format.
|
|
1777
1066
|
var primary = applied[0];
|
|
1778
1067
|
res.metadata = { aaguid: primary.entry.aaguid, keyIdentifiers: primary.entry.keyIdentifiers || [],
|
|
1779
1068
|
entry: primary.entry, entries: applied.map(function (a) { return a.entry; }),
|
|
@@ -1782,13 +1071,6 @@ function _applyMetadata(res, att, opts) {
|
|
|
1782
1071
|
});
|
|
1783
1072
|
}
|
|
1784
1073
|
|
|
1785
|
-
// Does this attestation carry a statement of the named format -- as the whole statement, or as one
|
|
1786
|
-
// element of a compound (sec. 8.9), whose own arm would apply a policy about that format?
|
|
1787
|
-
//
|
|
1788
|
-
// Every option that DEMANDS something only one format can produce asks this same question, so it is
|
|
1789
|
-
// asked in one place: a demand about a TPM public area and a demand about an android-safetynet
|
|
1790
|
-
// device-integrity signal differ only in the format they name. Answering it per option is how the
|
|
1791
|
-
// second one comes to be answered only for the simple case and not for a compound.
|
|
1792
1074
|
function _formatCarries(att, fmt) {
|
|
1793
1075
|
if (att.fmt === fmt) return true;
|
|
1794
1076
|
if (att.fmt !== "compound") return false;
|
|
@@ -1930,12 +1212,7 @@ void constants;
|
|
|
1930
1212
|
* // await pki.path.validate(res.trustPath, { trustAnchors: anchors, time: mdsTime });
|
|
1931
1213
|
*/
|
|
1932
1214
|
|
|
1933
|
-
// ---- public: parseClientData -------------------------------------------------
|
|
1934
1215
|
|
|
1935
|
-
// The two ceremony types, fixed by the spec -- `webauthn.create` for registration
|
|
1936
|
-
// and `webauthn.get` for authentication. This is NOT relying-party policy: a
|
|
1937
|
-
// registration response replayed into a login (or the reverse) is a real attack,
|
|
1938
|
-
// and which one a caller is finishing is known at the call site, never negotiated.
|
|
1939
1216
|
var CLIENT_DATA_TYPE = Object.assign(Object.create(null), { "webauthn.create": 1, "webauthn.get": 1 });
|
|
1940
1217
|
|
|
1941
1218
|
/**
|
|
@@ -1994,8 +1271,6 @@ function parseClientData(bytes, opts) {
|
|
|
1994
1271
|
opts = opts || {};
|
|
1995
1272
|
if (!_isPlainObject(opts)) throw _err("webauthn/bad-input", "opts must be an object");
|
|
1996
1273
|
guard.identifier.assertKnownKeys(opts, _CLIENT_DATA_OPTS, _err, "webauthn/bad-input", "opts has an unknown key ");
|
|
1997
|
-
// The RAW bytes, not a parsed object: this reader decides on the exact octets the authenticator
|
|
1998
|
-
// signed over, and re-serializing a parsed object would decide on different ones.
|
|
1999
1274
|
bytes = _bytesArg(bytes, "clientDataJSON");
|
|
2000
1275
|
var doc = guard.json.parse(Buffer.from(bytes), _err, {
|
|
2001
1276
|
maxBytes: constants.LIMITS.JSON_MAX_BYTES, maxDepth: constants.LIMITS.JSON_MAX_DEPTH,
|
|
@@ -2015,8 +1290,6 @@ function parseClientData(bytes, opts) {
|
|
|
2015
1290
|
if (typeof doc.challenge !== "string" || !doc.challenge.length) {
|
|
2016
1291
|
throw _err("webauthn/bad-client-data", "clientDataJSON carries no challenge (WebAuthn sec. 5.8.1)");
|
|
2017
1292
|
}
|
|
2018
|
-
// base64url, and STRICTLY: the challenge is a comparison key, so an encoding the
|
|
2019
|
-
// decoder had to guess at would let two spellings of one value both "match".
|
|
2020
1293
|
var challenge;
|
|
2021
1294
|
try {
|
|
2022
1295
|
challenge = guard.encoding.base64url(doc.challenge, constants.LIMITS.JSON_MAX_BYTES, _err,
|
|
@@ -2028,10 +1301,6 @@ function parseClientData(bytes, opts) {
|
|
|
2028
1301
|
if (doc.crossOrigin !== undefined && typeof doc.crossOrigin !== "boolean") {
|
|
2029
1302
|
throw _err("webauthn/bad-client-data", "clientDataJSON crossOrigin must be a boolean when present (WebAuthn sec. 5.8.1)");
|
|
2030
1303
|
}
|
|
2031
|
-
// A present-but-malformed topOrigin is refused rather than reported as absent. It
|
|
2032
|
-
// is the top-level origin of a cross-origin ceremony -- something a caller makes a
|
|
2033
|
-
// policy decision on -- so "the sender wrote something that is not an origin" and
|
|
2034
|
-
// "the sender said nothing" must not arrive as the same `null`.
|
|
2035
1304
|
if (doc.topOrigin !== undefined && (typeof doc.topOrigin !== "string" || !doc.topOrigin.length)) {
|
|
2036
1305
|
throw _err("webauthn/bad-client-data", "clientDataJSON topOrigin must be a non-empty string when present (WebAuthn sec. 5.8.1)");
|
|
2037
1306
|
}
|
|
@@ -2060,9 +1329,6 @@ function parseClientData(bytes, opts) {
|
|
|
2060
1329
|
if (!allowed.length || !allowed.every(function (o) { return typeof o === "string" && o.length; })) {
|
|
2061
1330
|
throw _err("webauthn/bad-input", "opts.expectedOrigin must be a non-empty origin string, or an array of them");
|
|
2062
1331
|
}
|
|
2063
|
-
// Compared WHOLE and case-sensitively as the serialization it is. A prefix or
|
|
2064
|
-
// suffix test is how "https://example.com.attacker.tld" passes for
|
|
2065
|
-
// "https://example.com".
|
|
2066
1332
|
if (allowed.indexOf(doc.origin) === -1) {
|
|
2067
1333
|
throw _err("webauthn/client-data-mismatch",
|
|
2068
1334
|
"the clientDataJSON origin " + JSON.stringify(doc.origin) + " is not one this relying party accepts " +
|
|
@@ -2070,23 +1336,8 @@ function parseClientData(bytes, opts) {
|
|
|
2070
1336
|
}
|
|
2071
1337
|
checked.origin = true;
|
|
2072
1338
|
}
|
|
2073
|
-
// The top-level origin of a cross-origin ceremony. It is surfaced either way, but a value a
|
|
2074
|
-
// relying party only READS is a value nobody compares: `origin` in a cross-origin ceremony is the
|
|
2075
|
-
// iframe's, and the top-level page that framed it is the one the security decision is actually
|
|
2076
|
-
// about. Given what it accepts, this compares it the same way as `origin` -- whole, case
|
|
2077
|
-
// sensitive, against a list -- so the two cannot drift into different matching rules.
|
|
2078
|
-
//
|
|
2079
|
-
// The three cases are kept apart rather than collapsed. An expectation of `null` means "this
|
|
2080
|
-
// ceremony must NOT be framed", which is a real policy and cannot be expressed by an origin list;
|
|
2081
|
-
// a response carrying no topOrigin against a list of acceptable ones is not framed at all, so the
|
|
2082
|
-
// list has nothing to accept and the ceremony is refused rather than passed.
|
|
2083
1339
|
if (opts.expectedTopOrigin !== undefined) {
|
|
2084
1340
|
var wantTop = opts.expectedTopOrigin;
|
|
2085
|
-
// Whether the ceremony was framed is stated by TWO fields, and the answer is only usable when
|
|
2086
|
-
// they agree. `crossOrigin` says a framing happened; `topOrigin` names the page that did it.
|
|
2087
|
-
// Reading one and not the other lets a response that declares itself cross-origin, but omits
|
|
2088
|
-
// the origin, satisfy a policy of "must not be framed" -- it answers the caller's question with
|
|
2089
|
-
// the field it left out rather than the one it filled in.
|
|
2090
1341
|
var framed = doc.crossOrigin === true;
|
|
2091
1342
|
if (wantTop === null) {
|
|
2092
1343
|
if (framed || doc.topOrigin !== undefined) {
|
|
@@ -2100,10 +1351,6 @@ function parseClientData(bytes, opts) {
|
|
|
2100
1351
|
if (!allowedTop.length || !allowedTop.every(function (o) { return typeof o === "string" && o.length; })) {
|
|
2101
1352
|
throw _err("webauthn/bad-input", "opts.expectedTopOrigin must be null, a non-empty origin string, or an array of them");
|
|
2102
1353
|
}
|
|
2103
|
-
// Allow-listing the pages that may frame this ceremony is a statement about a framed one. A
|
|
2104
|
-
// response that does not say it was framed makes no such claim to match against, so the list
|
|
2105
|
-
// has nothing to accept -- and treating its absent topOrigin as a pass would report a check
|
|
2106
|
-
// that decided nothing.
|
|
2107
1354
|
if (!framed || allowedTop.indexOf(doc.topOrigin) === -1) {
|
|
2108
1355
|
throw _err("webauthn/client-data-mismatch",
|
|
2109
1356
|
"the clientDataJSON topOrigin " + JSON.stringify(doc.topOrigin === undefined ? null : doc.topOrigin) +
|
|
@@ -2123,7 +1370,6 @@ var _CLIENT_DATA_OPTS = Object.assign(Object.create(null), {
|
|
|
2123
1370
|
expectedType: 1, expectedChallenge: 1, expectedOrigin: 1, expectedTopOrigin: 1,
|
|
2124
1371
|
});
|
|
2125
1372
|
|
|
2126
|
-
// ---- public: parseAuthenticatorData / verifyAssertion ------------------------
|
|
2127
1373
|
|
|
2128
1374
|
/**
|
|
2129
1375
|
* @primitive pki.webauthn.parseAuthenticatorData
|
|
@@ -2154,8 +1400,6 @@ function parseAuthenticatorData(bytes) {
|
|
|
2154
1400
|
return _parseAuthData(_bytesArg(bytes, "authenticatorData"), _err);
|
|
2155
1401
|
}
|
|
2156
1402
|
|
|
2157
|
-
// The options pki.webauthn.verifyAssertion recognizes, null-prototype for the same
|
|
2158
|
-
// reason _VERIFY_OPTS is.
|
|
2159
1403
|
var _ASSERT_OPTS = Object.assign(Object.create(null), {
|
|
2160
1404
|
authenticatorData: 1, clientDataHash: 1, clientDataJSON: 1, signature: 1,
|
|
2161
1405
|
credentialPublicKey: 1, previousSignCount: 1,
|
|
@@ -2218,26 +1462,14 @@ var _ASSERT_OPTS = Object.assign(Object.create(null), {
|
|
|
2218
1462
|
* res.bindingChecked.rpId; // true -- the rpIdHash matched example.com
|
|
2219
1463
|
* // the challenge and origin in clientDataJSON are still yours to compare
|
|
2220
1464
|
*/
|
|
2221
|
-
// A private copy of the assertion inputs. Only what the caller can still write to
|
|
2222
|
-
// is copied: the byte fields, the descriptor itself, and the option arrays. The
|
|
2223
|
-
// COSE key is a decoded object the caller supplies from its own storage; it is
|
|
2224
|
-
// shallow-copied so a swapped field cannot reach the verification, which is the
|
|
2225
|
-
// mutation this can defend against without deep-copying an arbitrary object.
|
|
2226
1465
|
function _snapshotAssertion(input) {
|
|
2227
1466
|
if (!_isPlainObject(input)) throw _err("webauthn/bad-input", "pki.webauthn.verifyAssertion takes an options object");
|
|
2228
1467
|
guard.identifier.assertKnownKeys(input, _ASSERT_OPTS, _err, "webauthn/bad-input", "verifyAssertion input has an unknown key ");
|
|
2229
1468
|
var out = {}, k;
|
|
2230
1469
|
for (k in input) { if (_hasOwn(input, k)) out[k] = input[k]; }
|
|
2231
|
-
// The SAME set of byte forms the arguments accept. A snapshot list narrower than the accept list
|
|
2232
|
-
// leaves whatever falls in the gap aliased across the yield -- which is the one input a widened
|
|
2233
|
-
// accept list newly admits, so the two must be written from one predicate.
|
|
2234
1470
|
["authenticatorData", "clientDataJSON", "clientDataHash", "signature", "expectedChallenge"].forEach(function (f) {
|
|
2235
1471
|
if (_isBufferSource(out[f])) out[f] = guard.bytes.snapshotSource(out[f], WebauthnError, "webauthn/bad-input", f);
|
|
2236
1472
|
});
|
|
2237
|
-
// The BYTES form first. A Buffer satisfies the plain-object test below, so leaving it to that
|
|
2238
|
-
// branch copies its numeric indices into a `{0:.., 1:..}` object that is no longer a key at all
|
|
2239
|
-
// -- the stored credential silently becoming something the SPKI builder cannot read. It is
|
|
2240
|
-
// snapshotted here for the same reason the other byte inputs are: it is read after a yield.
|
|
2241
1473
|
if (_isBufferSource(out.credentialPublicKey)) {
|
|
2242
1474
|
out.credentialPublicKey = guard.bytes.snapshotSource(out.credentialPublicKey, WebauthnError,
|
|
2243
1475
|
"webauthn/bad-input", "credentialPublicKey");
|
|
@@ -2245,10 +1477,6 @@ function _snapshotAssertion(input) {
|
|
|
2245
1477
|
var key = {}, kk;
|
|
2246
1478
|
for (kk in out.credentialPublicKey) {
|
|
2247
1479
|
if (!_hasOwn(out.credentialPublicKey, kk)) continue;
|
|
2248
|
-
// The read itself can fault: a stored key is a caller-supplied object, so a field may be an
|
|
2249
|
-
// accessor, and one that THROWS would escape as a raw error from the very copy whose job is
|
|
2250
|
-
// to make the descriptor stop being the caller's. The copy is the boundary, so the fault is
|
|
2251
|
-
// named here rather than surfacing from wherever the field was later used.
|
|
2252
1480
|
var v;
|
|
2253
1481
|
try { v = out.credentialPublicKey[kk]; }
|
|
2254
1482
|
catch (e) { throw _err("webauthn/bad-cose-key", "credentialPublicKey." + kk + " could not be read", e); }
|
|
@@ -2263,21 +1491,12 @@ function _snapshotAssertion(input) {
|
|
|
2263
1491
|
}
|
|
2264
1492
|
|
|
2265
1493
|
function verifyAssertion(input) {
|
|
2266
|
-
// Every byte this verdict depends on is COPIED here, synchronously, before
|
|
2267
|
-
// anything defers. The descriptor is the caller's object and the buffers in it
|
|
2268
|
-
// are the caller's memory: a caller that reuses or zeroizes them on the line
|
|
2269
|
-
// after this call has already changed them before the deferred body would run,
|
|
2270
|
-
// and an assertion that did not verify could be replaced by one that does. The
|
|
2271
|
-
// same rule pki.cmc.verify applies to a response, for the same reason.
|
|
2272
1494
|
var frozen;
|
|
2273
1495
|
try { frozen = _snapshotAssertion(input); } catch (e) { return Promise.reject(e); }
|
|
2274
1496
|
return Promise.resolve().then(function () {
|
|
2275
1497
|
input = frozen;
|
|
2276
1498
|
var authData = parseAuthenticatorData(input.authenticatorData);
|
|
2277
1499
|
input.signature = _bytesArg(input.signature, "signature");
|
|
2278
|
-
// The two forms of the same input, and NEITHER is inferred from the other's
|
|
2279
|
-
// absence: supplying both invites them to disagree, and picking one would make
|
|
2280
|
-
// the signature cover something the caller did not mean.
|
|
2281
1500
|
var haveJson = input.clientDataJSON !== undefined, haveHash = input.clientDataHash !== undefined;
|
|
2282
1501
|
if (haveJson === haveHash) {
|
|
2283
1502
|
throw _err("webauthn/bad-input", "verifyAssertion takes exactly one of clientDataJSON or clientDataHash");
|
|
@@ -2285,11 +1504,6 @@ function verifyAssertion(input) {
|
|
|
2285
1504
|
var clientDataHash, clientData = null;
|
|
2286
1505
|
if (haveJson) {
|
|
2287
1506
|
input.clientDataJSON = _bytesArg(input.clientDataJSON, "clientDataJSON");
|
|
2288
|
-
// Given the JSON, this verb reads it -- the ceremony TYPE is checked
|
|
2289
|
-
// unconditionally, because which ceremony a response belongs to is fixed by
|
|
2290
|
-
// the spec rather than chosen by the caller, and a registration response
|
|
2291
|
-
// replayed as a login is exactly what that check stops. The challenge and
|
|
2292
|
-
// origin are checked when the caller supplies what it issued.
|
|
2293
1507
|
clientData = parseClientData(input.clientDataJSON, {
|
|
2294
1508
|
expectedType: "webauthn.get",
|
|
2295
1509
|
expectedChallenge: input.expectedChallenge,
|
|
@@ -2298,9 +1512,6 @@ function verifyAssertion(input) {
|
|
|
2298
1512
|
});
|
|
2299
1513
|
clientDataHash = _sha("sha256", Buffer.from(input.clientDataJSON));
|
|
2300
1514
|
} else {
|
|
2301
|
-
// Every expectation this verb forwards to the clientData reader is unanswerable without the
|
|
2302
|
-
// JSON, so each is refused here rather than silently going uncompared. The list is the
|
|
2303
|
-
// forwarded set above; a new expectation added there is added here too.
|
|
2304
1515
|
if (input.expectedChallenge !== undefined || input.expectedOrigin !== undefined ||
|
|
2305
1516
|
input.expectedTopOrigin !== undefined) {
|
|
2306
1517
|
throw _err("webauthn/bad-input",
|
|
@@ -2310,34 +1521,19 @@ function verifyAssertion(input) {
|
|
|
2310
1521
|
clientDataHash = _bytesArg(input.clientDataHash, "clientDataHash");
|
|
2311
1522
|
if (clientDataHash.length !== 32) throw _err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest");
|
|
2312
1523
|
}
|
|
2313
|
-
// Either form a relying party can be holding. `verify` hands back the parsed object, but the
|
|
2314
|
-
// durable form is BYTES: the object carries Buffers, so a JSON round trip through a datastore
|
|
2315
|
-
// returns {"type":"Buffer","data":[...]} rather than what went in, and every existing credential
|
|
2316
|
-
// store already holds the COSE bytes. Accepting only the object made a caller fabricate an
|
|
2317
|
-
// authenticatorData that never existed just to reach their own key.
|
|
2318
1524
|
var coseKey = input.credentialPublicKey;
|
|
2319
1525
|
if (guard.bytes.isByteSource(coseKey)) {
|
|
2320
1526
|
coseKey = parseCoseKey(coseKey);
|
|
2321
1527
|
} else if (_isPlainObject(coseKey)) {
|
|
2322
|
-
// The OBJECT form is held to the same rules about the KEY as the bytes form. The bytes went
|
|
2323
|
-
// through the curve/length, RSA modulus-floor and exponent checks; the object went through
|
|
2324
|
-
// none, so one stored credential was refused in one form and imported for signature
|
|
2325
|
-
// verification in the other. Which form a relying party stores is a question about what their
|
|
2326
|
-
// datastore round-trips, not about how carefully their credential is checked.
|
|
2327
1528
|
coseKey = validator.cose.assertKeyMaterial(coseKey, WebauthnError, "webauthn/bad-cose-key", "webauthn/unsupported-algorithm");
|
|
2328
1529
|
} else {
|
|
2329
1530
|
throw _err("webauthn/bad-input", "credentialPublicKey must be the stored COSE key -- the object pki.webauthn.verify returned, or its COSE bytes");
|
|
2330
1531
|
}
|
|
2331
1532
|
var bindingChecked = _applyBindings(authData, coseKey, input);
|
|
2332
|
-
// The counter's SHAPE is a config-time question and is answered here; whether it
|
|
2333
|
-
// advanced is a question about the message, and that is asked only after the
|
|
2334
|
-
// signature holds -- see below.
|
|
2335
1533
|
var prev = input.previousSignCount;
|
|
2336
1534
|
if (prev !== undefined && (typeof prev !== "number" || !Number.isSafeInteger(prev) || prev < 0 || prev > 0xFFFFFFFF)) {
|
|
2337
1535
|
throw _err("webauthn/bad-input", "previousSignCount must be an integer in 0..4294967295");
|
|
2338
1536
|
}
|
|
2339
|
-
// The signed message is the CONCATENATION, in that order, of the
|
|
2340
|
-
// authenticatorData exactly as it arrived and the clientDataJSON digest.
|
|
2341
1537
|
var message = Buffer.concat([Buffer.from(input.authenticatorData), clientDataHash]);
|
|
2342
1538
|
var spki = _coseKeyToSpki(coseKey);
|
|
2343
1539
|
return _verifySig(coseKey.alg, Buffer.from(input.signature), spki, message, _err).then(function (ok) {
|
|
@@ -2345,20 +1541,6 @@ function verifyAssertion(input) {
|
|
|
2345
1541
|
throw _err("webauthn/bad-signature",
|
|
2346
1542
|
"the assertion signature does not verify under the stored credential public key (WebAuthn sec. 7.2 step 20)");
|
|
2347
1543
|
}
|
|
2348
|
-
// sec. 7.2 step 21, and only NOW. A counter that fails to advance means two
|
|
2349
|
-
// authenticators hold one credential -- an alarm a relying party acts on, by
|
|
2350
|
-
// locking the account or revoking the credential. Judging it before the
|
|
2351
|
-
// signature would let anyone raise that alarm with arbitrary bytes, so the
|
|
2352
|
-
// counter is read only out of an assertion that proved to be authentic. The
|
|
2353
|
-
// 0/0 case is an authenticator that implements no counter, which is permitted.
|
|
2354
|
-
// THREE outcomes, kept apart, because this field's only purpose is to tell a relying party
|
|
2355
|
-
// whether it has cloned-authenticator detection on this credential:
|
|
2356
|
-
// false -- not requested; no previousSignCount was supplied
|
|
2357
|
-
// "not-supported" -- requested, but the authenticator implements no counter (the 0/0 case
|
|
2358
|
-
// sec. 7.2 permits), so the comparison was deliberately skipped
|
|
2359
|
-
// true -- requested and performed
|
|
2360
|
-
// Reporting `true` for the waived case claimed a detection that cannot happen, and a bare
|
|
2361
|
-
// `false` there would be indistinguishable from never having asked.
|
|
2362
1544
|
var signCountChecked = false;
|
|
2363
1545
|
if (prev !== undefined) {
|
|
2364
1546
|
if (prev === 0 && authData.signCount === 0) {
|
|
@@ -2373,16 +1555,13 @@ function verifyAssertion(input) {
|
|
|
2373
1555
|
}
|
|
2374
1556
|
}
|
|
2375
1557
|
return {
|
|
2376
|
-
valid: true,
|
|
1558
|
+
valid: true,
|
|
2377
1559
|
signatureVerified: true,
|
|
2378
1560
|
signCount: authData.signCount,
|
|
2379
1561
|
signCountChecked: signCountChecked,
|
|
2380
1562
|
flags: authData.flags,
|
|
2381
1563
|
rpIdHash: authData.rpIdHash,
|
|
2382
1564
|
extensions: authData.extensions,
|
|
2383
|
-
// The decoded clientData when this call was given the JSON, so a caller that
|
|
2384
|
-
// still owes itself a comparison has the values without re-parsing; null
|
|
2385
|
-
// when only the digest was supplied and there was nothing to read.
|
|
2386
1565
|
clientData: clientData,
|
|
2387
1566
|
bindingChecked: bindingChecked,
|
|
2388
1567
|
};
|