@blamejs/pki 0.2.3 → 0.2.5
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 -0
- package/README.md +5 -2
- package/index.js +15 -0
- package/lib/byte-reader.js +68 -0
- package/lib/constants.js +96 -0
- package/lib/ct.js +6 -38
- package/lib/framework-error.js +12 -0
- package/lib/guard-all.js +2 -0
- package/lib/guard-name.js +50 -1
- package/lib/inspect.js +485 -0
- package/lib/oid.js +24 -0
- package/lib/schema-attrcert.js +3 -1
- package/lib/schema-ocsp.js +2 -1
- package/lib/schema-pkix.js +10 -25
- package/lib/webauthn.js +896 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/webauthn.js
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.webauthn
|
|
6
|
+
* @nav Validation
|
|
7
|
+
* @title WebAuthn
|
|
8
|
+
* @intro Trust evaluation of a W3C WebAuthn (Level 3) / passkey attestation: parse
|
|
9
|
+
* the attestation object + authenticatorData, decode the COSE credential public
|
|
10
|
+
* key, and verify each defined attestation-statement format (packed, tpm,
|
|
11
|
+
* android-key, apple, fido-u2f, none) -- the attestation-statement signature and
|
|
12
|
+
* each format's structural bindings. The attestation CBOR is decoded by the strict,
|
|
13
|
+
* fail-closed `pki.cbor` codec (WebAuthn keys are CTAP2-canonical), the signature by
|
|
14
|
+
* `pki.webcrypto`. Chaining the returned x5c trust path to a caller-pinned root via
|
|
15
|
+
* `pki.path.validate` is the caller's step: this module verifies the statement, not
|
|
16
|
+
* the certificate chain. A verifier, not a ceremony client: the relying party
|
|
17
|
+
* supplies the clientDataHash + any trust anchors; this module never touches a
|
|
18
|
+
* socket. Fail-closed -- every malformed shape or failed check throws a typed
|
|
19
|
+
* `WebauthnError`, never a partial verdict.
|
|
20
|
+
* @spec W3C WebAuthn Level 3 sec. 6.5 / 8, RFC 9052 (COSE)
|
|
21
|
+
* @card Verify a WebAuthn / passkey attestation (packed / tpm / android-key / apple / fido-u2f / none).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
var frameworkError = require("./framework-error");
|
|
25
|
+
var cbor = require("./cbor-det");
|
|
26
|
+
var asn1 = require("./asn1-der");
|
|
27
|
+
var x509 = require("./schema-x509");
|
|
28
|
+
var pkix = require("./schema-pkix");
|
|
29
|
+
var oid = require("./oid");
|
|
30
|
+
var webcrypto = require("./webcrypto");
|
|
31
|
+
var constants = require("./constants");
|
|
32
|
+
var ByteReader = require("./byte-reader");
|
|
33
|
+
var nodeCrypto = require("crypto");
|
|
34
|
+
|
|
35
|
+
var WebauthnError = frameworkError.WebauthnError;
|
|
36
|
+
function _err(code, message, cause) { return new WebauthnError(code, message, cause); }
|
|
37
|
+
// The shared certificate-extension decoder registry (keyed by OID), so an
|
|
38
|
+
// attestation-certificate extension (extKeyUsage, subjectAltName) is decoded by the
|
|
39
|
+
// same fail-closed pkix decoder every other format uses, not a local hand-roll.
|
|
40
|
+
var NS = pkix.makeNS("webauthn", WebauthnError, oid);
|
|
41
|
+
var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
|
|
42
|
+
function _decodeExt(cert, oidName) {
|
|
43
|
+
var ext = _findExt(cert, oidName);
|
|
44
|
+
if (!ext) return null;
|
|
45
|
+
var dec = EXT_DECODERS[ext.oid];
|
|
46
|
+
if (!dec) return null;
|
|
47
|
+
return { critical: ext.critical, value: dec(ext.value) };
|
|
48
|
+
}
|
|
49
|
+
var subtle = webcrypto.webcrypto.subtle;
|
|
50
|
+
|
|
51
|
+
// A one-shot digest over a bounded buffer (the attestation nonce / TPM Name / TPM
|
|
52
|
+
// extraData preimages), by the node hash name the caller has already resolved.
|
|
53
|
+
function _sha(name, buf) { return nodeCrypto.createHash(name).update(buf).digest(); }
|
|
54
|
+
// Unsigned big-endian magnitude compare: strip leading zero octets, then byte-equal.
|
|
55
|
+
// TPM2B buffers and COSE fixed-length coordinates can differ by a leading 0x00, so a
|
|
56
|
+
// raw memcmp would spuriously reject an equal key (WebAuthn 8.3, item 22).
|
|
57
|
+
function _ucmp(a, b) {
|
|
58
|
+
function strip(x) { var i = 0; while (i < x.length - 1 && x[i] === 0) i++; return x.subarray(i); }
|
|
59
|
+
return Buffer.compare(strip(a), strip(b)) === 0;
|
|
60
|
+
}
|
|
61
|
+
// A decoded node is a primitive universal INTEGER (so `.content` is a real buffer,
|
|
62
|
+
// not null as it is for a constructed node).
|
|
63
|
+
function _isInteger(node) { return !!node && !node.constructed && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.INTEGER; }
|
|
64
|
+
// The minimal unsigned big-endian encoding of a non-negative integer (no sign octet,
|
|
65
|
+
// no leading zeros) -- for an unsigned compare against a COSE fixed-width field.
|
|
66
|
+
function _uintBytes(n) {
|
|
67
|
+
var hex = n.toString(16); if (hex.length % 2) hex = "0" + hex;
|
|
68
|
+
var b = Buffer.from(hex, "hex"); var i = 0; while (i < b.length - 1 && b[i] === 0) i++; return b.subarray(i);
|
|
69
|
+
}
|
|
70
|
+
// COSE signature algorithm -> the node hash name of its signature scheme (the digest
|
|
71
|
+
// the TPM extraData / apple nonce is taken under).
|
|
72
|
+
var COSE_ALG_HASH = { "-7": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-258": "sha384", "-36": "sha512", "-259": "sha512", "-65535": "sha1" };
|
|
73
|
+
function _coseAlgHash(alg, E) {
|
|
74
|
+
var h = COSE_ALG_HASH[String(alg)];
|
|
75
|
+
if (!h) throw E("webauthn/unsupported-algorithm", "no hash mapping for COSE algorithm " + alg);
|
|
76
|
+
return h;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---- CBOR map access over the strict pki.cbor node tree ----------------------
|
|
80
|
+
|
|
81
|
+
// A decoded CBOR map is a node whose `children` is an array of [keyNode, valNode].
|
|
82
|
+
// Look a text-keyed entry up, or null. The strict codec already rejected a
|
|
83
|
+
// duplicate map key, so at most one match exists.
|
|
84
|
+
function _mapGet(node, key) {
|
|
85
|
+
if (!node || node.majorType !== 5 || !node.children) return null;
|
|
86
|
+
for (var i = 0; i < node.children.length; i++) {
|
|
87
|
+
var k = node.children[i][0];
|
|
88
|
+
if (k.majorType === 3 && cbor.read.textString(k) === key) return node.children[i][1];
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
// An integer-keyed entry (COSE_Key labels are ints), by its signed value.
|
|
93
|
+
function _mapGetInt(node, key) {
|
|
94
|
+
if (!node || node.majorType !== 5 || !node.children) return null;
|
|
95
|
+
for (var i = 0; i < node.children.length; i++) {
|
|
96
|
+
var k = node.children[i][0];
|
|
97
|
+
if ((k.majorType === 0 || k.majorType === 1) && cbor.read.int(k) === BigInt(key)) return node.children[i][1];
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---- authenticatorData bounded reader (WebAuthn sec. 6.1) --------------------
|
|
103
|
+
|
|
104
|
+
// authData = rpIdHash[32] || flags[1] || signCount[4 BE] || (AT? attestedCredentialData) || (ED? extensions CBOR).
|
|
105
|
+
// A bounded big-endian read: every slice is length-checked before it is taken, so a
|
|
106
|
+
// truncated / oversize field fails closed rather than reading past the buffer.
|
|
107
|
+
var AAGUID_LEN = 16;
|
|
108
|
+
function _parseAuthData(buf, E) {
|
|
109
|
+
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)");
|
|
110
|
+
var flags = buf[32];
|
|
111
|
+
var out = {
|
|
112
|
+
rpIdHash: buf.subarray(0, 32),
|
|
113
|
+
flags: { up: !!(flags & 0x01), uv: !!(flags & 0x04), be: !!(flags & 0x08), bs: !!(flags & 0x10), at: !!(flags & 0x40), ed: !!(flags & 0x80) },
|
|
114
|
+
signCount: buf.readUInt32BE(33),
|
|
115
|
+
aaguid: null, credentialId: null, credentialPublicKey: null, credentialPublicKeyBytes: null, extensions: null,
|
|
116
|
+
};
|
|
117
|
+
// Backup State is only valid when Backup Eligibility is set: a credential cannot be
|
|
118
|
+
// "backed up" if it is not "backup eligible" (WebAuthn sec. 6.1).
|
|
119
|
+
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)");
|
|
120
|
+
// The reserved flag bits (bit 1 = 0x02, bit 5 = 0x20) are undefined; a conforming
|
|
121
|
+
// authenticator leaves them 0. Reject a set reserved bit rather than ignore an
|
|
122
|
+
// unknown flag (WebAuthn sec. 6.1, fail-closed on undefined structure).
|
|
123
|
+
if (flags & 0x22) throw E("webauthn/bad-auth-data", "authenticatorData sets a reserved (RFU) flag bit (WebAuthn sec. 6.1)");
|
|
124
|
+
var off = 37;
|
|
125
|
+
if (out.flags.at) {
|
|
126
|
+
if (buf.length < off + AAGUID_LEN + 2) throw E("webauthn/bad-auth-data", "attestedCredentialData is truncated before the credentialId length");
|
|
127
|
+
out.aaguid = buf.subarray(off, off + AAGUID_LEN); off += AAGUID_LEN;
|
|
128
|
+
var credLen = buf.readUInt16BE(off); off += 2;
|
|
129
|
+
if (credLen < 1 || credLen > 1023) throw E("webauthn/bad-credential-id", "credentialIdLength " + credLen + " is outside 1..1023 (RFC WebAuthn sec. 6.1)");
|
|
130
|
+
if (buf.length < off + credLen) throw E("webauthn/bad-auth-data", "credentialId overruns authenticatorData");
|
|
131
|
+
out.credentialId = buf.subarray(off, off + credLen); off += credLen;
|
|
132
|
+
// credentialPublicKey is a COSE_Key: a single CBOR map occupying [off, its end).
|
|
133
|
+
var keyNode;
|
|
134
|
+
try { keyNode = cbor.decode(buf.subarray(off), { allowTrailing: true }); }
|
|
135
|
+
catch (e) { throw E("webauthn/bad-cose-key", "the credential public key is not well-formed CBOR", e); }
|
|
136
|
+
out.credentialPublicKeyBytes = buf.subarray(off, off + keyNode.bytes.length);
|
|
137
|
+
out.credentialPublicKey = _decodeCoseKey(keyNode, E);
|
|
138
|
+
off += keyNode.bytes.length;
|
|
139
|
+
}
|
|
140
|
+
if (out.flags.ed) {
|
|
141
|
+
// With the ED flag set, the remainder MUST be exactly one well-formed CBOR map
|
|
142
|
+
// (the extensions); the strict decoder rejects a non-map, malformed bytes, an
|
|
143
|
+
// empty remainder, or trailing bytes (RFC WebAuthn sec. 6.1).
|
|
144
|
+
var extNode;
|
|
145
|
+
try { extNode = cbor.decode(buf.subarray(off)); }
|
|
146
|
+
catch (e) { throw E("webauthn/bad-auth-data", "the authenticatorData extensions are not a single well-formed CBOR map", e); }
|
|
147
|
+
if (extNode.majorType !== 5) throw E("webauthn/bad-auth-data", "the authenticatorData extensions must be a CBOR map");
|
|
148
|
+
out.extensions = buf.subarray(off);
|
|
149
|
+
} else if (off < buf.length) {
|
|
150
|
+
// authenticatorData is fixed-layout: with the ED flag clear there MUST be no
|
|
151
|
+
// bytes after the attestedCredentialData (RFC WebAuthn sec. 6.1). Trailing
|
|
152
|
+
// bytes are a malformed structure -- fail closed rather than ignore them.
|
|
153
|
+
throw E("webauthn/bad-auth-data", "authenticatorData has trailing bytes after attestedCredentialData with the ED flag clear");
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- COSE_Key decode (WebAuthn sec. 6.5.1, RFC 9052 sec. 7) ------------------
|
|
159
|
+
|
|
160
|
+
// COSE EC2 curve (label -1) -> the fixed field-element byte length its x/y carry,
|
|
161
|
+
// and the named-curve OID a certificate on that curve declares.
|
|
162
|
+
var EC2_CRV_LEN = { 1: 32, 2: 48, 3: 66 }; // P-256 / P-384 / P-521
|
|
163
|
+
var EC2_CRV_OID = { 1: "prime256v1", 2: "secp384r1", 3: "secp521r1" };
|
|
164
|
+
// COSE OKP curve (label -1) -> its RFC 8410 named-key OID + fixed public-key length.
|
|
165
|
+
// Ed25519 (crv 6, 32-byte) and Ed448 (crv 7, 57-byte) both sign under COSE alg -8.
|
|
166
|
+
var OKP_CRV = { 6: { oid: "Ed25519", len: 32 }, 7: { oid: "Ed448", len: 57 } };
|
|
167
|
+
// The WebAuthn credential-key profile: a COSE signature algorithm (label 3) pins the
|
|
168
|
+
// key type (label 1) and, for EC2, the curve -- so an EC2 key claiming EdDSA, or an
|
|
169
|
+
// OKP key claiming ES256, is a profile violation (WebAuthn sec. 6.5.1, RFC 9052).
|
|
170
|
+
var ALG_PROFILE = {
|
|
171
|
+
"-7": { kty: 2, crv: 1 }, "-35": { kty: 2, crv: 2 }, "-36": { kty: 2, crv: 3 },
|
|
172
|
+
"-8": { kty: 1 },
|
|
173
|
+
"-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 }, "-37": { kty: 3 }, "-65535": { kty: 3 },
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// kty 1 / alg 3; EC2 (kty 2): crv -1, x -2, y -3; OKP (kty 1): crv -1, x -2;
|
|
177
|
+
// RSA (kty 3): n -1, e -2. Surface the raw coordinate/modulus buffers.
|
|
178
|
+
function _decodeCoseKey(node, E) {
|
|
179
|
+
if (!node || node.majorType !== 5) throw E("webauthn/bad-cose-key", "a COSE_Key must be a CBOR map (RFC 9052 sec. 7)");
|
|
180
|
+
// Every parameter read maps a wrong-type cbor/* fault to the webauthn domain -- a
|
|
181
|
+
// wrong-typed COSE label (x as an integer, kty as a string) is bad-cose-key input,
|
|
182
|
+
// not a leaked codec error.
|
|
183
|
+
function ib(label) { var n = _mapGetInt(node, label); if (!n) return null; try { return cbor.read.byteString(n); } catch (e) { throw E("webauthn/bad-cose-key", "COSE_Key parameter " + label + " must be a byte string", e); } }
|
|
184
|
+
function ii(label) { var n = _mapGetInt(node, label); if (!n) return null; try { return cbor.read.int(n); } catch (e) { throw E("webauthn/bad-cose-key", "COSE_Key parameter " + label + " must be an integer", e); } }
|
|
185
|
+
var ktyN = _mapGetInt(node, 1), algN = _mapGetInt(node, 3);
|
|
186
|
+
if (!ktyN) throw E("webauthn/bad-cose-key", "a COSE_Key is missing the kty (label 1) parameter");
|
|
187
|
+
// The credential public key MUST declare its algorithm (label 3): a relying party
|
|
188
|
+
// needs it to verify the later assertion signature (WebAuthn sec. 6.5.1, COSE sec. 7).
|
|
189
|
+
if (!algN) throw E("webauthn/bad-cose-key", "a COSE_Key is missing the alg (label 3) parameter");
|
|
190
|
+
var kty, algv;
|
|
191
|
+
try { kty = cbor.read.int(ktyN); algv = cbor.read.int(algN); }
|
|
192
|
+
catch (e) { throw E("webauthn/bad-cose-key", "COSE_Key kty (label 1) and alg (label 3) must be integers", e); }
|
|
193
|
+
var key = { kty: Number(kty), alg: Number(algv) };
|
|
194
|
+
// A credential key MUST carry every parameter its key type requires; an incomplete
|
|
195
|
+
// key (or an unknown kty) is rejected at decode rather than surfaced with null
|
|
196
|
+
// material a later binding check would have to defend against.
|
|
197
|
+
if (kty === 2n) {
|
|
198
|
+
key.crv = ii(-1) != null ? Number(ii(-1)) : null; key.x = ib(-2); key.y = ib(-3);
|
|
199
|
+
if (key.crv == null || !key.x || !key.y) throw E("webauthn/bad-cose-key", "an EC2 COSE_Key must carry crv (-1), x (-2), and y (-3)");
|
|
200
|
+
var el = EC2_CRV_LEN[key.crv];
|
|
201
|
+
if (!el || key.x.length !== el || key.y.length !== el) throw E("webauthn/bad-cose-key", "an EC2 COSE_Key x/y length is inconsistent with its curve");
|
|
202
|
+
} else if (kty === 1n) {
|
|
203
|
+
key.crv = ii(-1) != null ? Number(ii(-1)) : null; key.x = ib(-2);
|
|
204
|
+
var okp = OKP_CRV[key.crv];
|
|
205
|
+
if (!okp || !key.x || key.x.length !== okp.len) throw E("webauthn/bad-cose-key", "an OKP COSE_Key must be Ed25519 (crv 6) or Ed448 (crv 7) with a matching-length x (-2)");
|
|
206
|
+
} else if (kty === 3n) {
|
|
207
|
+
key.n = ib(-1); key.e = ib(-2);
|
|
208
|
+
if (!key.n || !key.n.length || !key.e || !key.e.length) throw E("webauthn/bad-cose-key", "an RSA COSE_Key must carry n (-1) and e (-2)");
|
|
209
|
+
} else {
|
|
210
|
+
throw E("webauthn/bad-cose-key", "unsupported COSE_Key kty " + Number(kty));
|
|
211
|
+
}
|
|
212
|
+
// A WebAuthn credential key is the CANONICAL CTAP2 COSE_Key for its type -- exactly
|
|
213
|
+
// its defined parameters, nothing more: EC2 = { 1, 3, -1, -2, -3 } (5), OKP/RSA = 4.
|
|
214
|
+
// Extra parameters are rejected (a padded key is a canonicalization ambiguity).
|
|
215
|
+
var expectedParams = kty === 2n ? 5 : 4;
|
|
216
|
+
if (node.children.length !== expectedParams) throw E("webauthn/bad-cose-key", "the COSE_Key carries parameters beyond the canonical set for its key type (WebAuthn sec. 6.5.1)");
|
|
217
|
+
// Enforce the alg <-> kty/crv profile: the declared algorithm must match the key
|
|
218
|
+
// type (and, for EC2, the curve) it is used with.
|
|
219
|
+
var prof = ALG_PROFILE[String(key.alg)];
|
|
220
|
+
if (!prof) throw E("webauthn/bad-cose-key", "unsupported credential key algorithm " + key.alg);
|
|
221
|
+
if (prof.kty !== key.kty) throw E("webauthn/bad-cose-key", "credential key algorithm " + key.alg + " is inconsistent with key type " + key.kty);
|
|
222
|
+
if (prof.crv != null && prof.crv !== key.crv) throw E("webauthn/bad-cose-key", "credential key algorithm " + key.alg + " requires a different curve");
|
|
223
|
+
return key;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- signature verification bridge ------------------------------------------
|
|
227
|
+
|
|
228
|
+
// COSE algorithm id -> the WebCrypto import + verify descriptor. WebAuthn packed /
|
|
229
|
+
// android-key / tpm ECDSA signatures are DER ECDSA-Sig-Value, so they are converted
|
|
230
|
+
// to the raw r||s form pki.webcrypto (ieee-p1363) expects.
|
|
231
|
+
var COSE_ALG = {
|
|
232
|
+
"-7": { imp: { name: "ECDSA", namedCurve: "P-256" }, verify: { name: "ECDSA", hash: "SHA-256" }, ecdsa: 32 },
|
|
233
|
+
"-35": { imp: { name: "ECDSA", namedCurve: "P-384" }, verify: { name: "ECDSA", hash: "SHA-384" }, ecdsa: 48 },
|
|
234
|
+
"-36": { imp: { name: "ECDSA", namedCurve: "P-521" }, verify: { name: "ECDSA", hash: "SHA-512" }, ecdsa: 66 },
|
|
235
|
+
"-8": { imp: { name: "Ed25519" }, verify: { name: "Ed25519" }, ecdsa: 0 },
|
|
236
|
+
"-257": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
237
|
+
"-258": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
238
|
+
"-259": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
239
|
+
"-37": { imp: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 }, ecdsa: 0 },
|
|
240
|
+
// RS1 (RSASSA-PKCS1-v1_5 / SHA-1): a legacy COSE algorithm real Windows Hello TPM
|
|
241
|
+
// authenticators emit in their attestation statement. VERIFY-only support -- the
|
|
242
|
+
// toolkit never signs with SHA-1; it must still evaluate the attestations that
|
|
243
|
+
// ship using it, or a large class of TPM authenticators cannot be verified.
|
|
244
|
+
"-65535": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-1" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// DER ECDSA-Sig-Value SEQUENCE { r INTEGER, s INTEGER } -> raw r||s, each left-
|
|
248
|
+
// padded to `coordLen` (the ieee-p1363 form WebCrypto verify expects).
|
|
249
|
+
function _derEcdsaToRaw(der, coordLen, E) {
|
|
250
|
+
var node;
|
|
251
|
+
try { node = asn1.decode(der); } catch (e) { throw E("webauthn/bad-signature", "ECDSA signature is not a DER SEQUENCE", e); }
|
|
252
|
+
if (node.tagClass !== "universal" || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children || node.children.length !== 2) {
|
|
253
|
+
throw E("webauthn/bad-signature", "ECDSA signature must be a DER SEQUENCE { r, s }");
|
|
254
|
+
}
|
|
255
|
+
function coord(c) {
|
|
256
|
+
// r and s MUST be primitive universal INTEGERs; a constructed child has a null
|
|
257
|
+
// content buffer, so assert the shape before touching `.content` (else a crafted
|
|
258
|
+
// SEQUENCE { SEQUENCE {}, SEQUENCE {} } would raw-throw instead of failing typed).
|
|
259
|
+
if (c.constructed || c.tagClass !== "universal" || c.tagNumber !== asn1.TAGS.INTEGER) {
|
|
260
|
+
throw E("webauthn/bad-signature", "ECDSA signature r/s must be a primitive INTEGER");
|
|
261
|
+
}
|
|
262
|
+
var b = c.content;
|
|
263
|
+
// r and s MUST be positive: an empty INTEGER, or a DER-negative one (top bit of
|
|
264
|
+
// the first content octet set, with no leading 0x00 sign octet), is not a valid
|
|
265
|
+
// ECDSA signature coordinate.
|
|
266
|
+
if (!b.length || (b[0] & 0x80)) throw E("webauthn/bad-signature", "ECDSA signature r/s must be a positive integer");
|
|
267
|
+
while (b.length > 1 && b[0] === 0x00) b = b.subarray(1); // strip the DER sign octet
|
|
268
|
+
if (b.length === 1 && b[0] === 0x00) throw E("webauthn/bad-signature", "ECDSA signature r/s must be a positive integer"); // reject zero
|
|
269
|
+
if (b.length > coordLen) throw E("webauthn/bad-signature", "ECDSA signature coordinate exceeds the curve field size");
|
|
270
|
+
var out = Buffer.alloc(coordLen); b.copy(out, coordLen - b.length); return out;
|
|
271
|
+
}
|
|
272
|
+
return Buffer.concat([coord(node.children[0]), coord(node.children[1])]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Verify `sig` over `message` with the SPKI public key `spkiBytes` under COSE `alg`.
|
|
276
|
+
// A wrong signature resolves `false` from subtle.verify without throwing (a false
|
|
277
|
+
// verdict is a verdict); a structural failure -- an unimportable key, a bad
|
|
278
|
+
// descriptor -- is re-thrown as a typed fail-closed error, never masked as false.
|
|
279
|
+
function _verifySig(alg, sig, spkiBytes, message, E) {
|
|
280
|
+
var d = COSE_ALG[String(alg)];
|
|
281
|
+
if (!d) throw _err("webauthn/unsupported-algorithm", "unsupported COSE algorithm " + alg);
|
|
282
|
+
var imp = d.imp, ver = d.verify;
|
|
283
|
+
// COSE alg -8 (EdDSA) covers Ed25519 and Ed448; the WebCrypto name follows the
|
|
284
|
+
// signing key's own SPKI algorithm OID, not the (curve-agnostic) alg id.
|
|
285
|
+
if (alg === -8) { var nm = _edName(spkiBytes, E); imp = { name: nm }; ver = { name: nm }; }
|
|
286
|
+
var s = d.ecdsa ? _derEcdsaToRaw(sig, d.ecdsa, E) : sig;
|
|
287
|
+
return subtle.importKey("spki", spkiBytes, imp, false, ["verify"])
|
|
288
|
+
.then(function (key) { return subtle.verify(ver, key, s, message); })
|
|
289
|
+
.catch(function (e) { throw _err("webauthn/verify-error", "the attestation signature could not be evaluated", e); });
|
|
290
|
+
}
|
|
291
|
+
// The WebCrypto EdDSA name (Ed25519 / Ed448) an SPKI declares via its algorithm OID.
|
|
292
|
+
var ED_OID_NAME = {};
|
|
293
|
+
ED_OID_NAME[oid.byName("Ed25519")] = "Ed25519";
|
|
294
|
+
ED_OID_NAME[oid.byName("Ed448")] = "Ed448";
|
|
295
|
+
function _edName(spkiBytes, E) {
|
|
296
|
+
var algOid;
|
|
297
|
+
try { algOid = asn1.read.oid(asn1.decode(spkiBytes).children[0].children[0]); }
|
|
298
|
+
catch (e) { throw E("webauthn/bad-signature", "the EdDSA public key is not a well-formed SPKI", e); }
|
|
299
|
+
var nm = ED_OID_NAME[algOid];
|
|
300
|
+
if (!nm) throw E("webauthn/unsupported-algorithm", "unsupported EdDSA curve OID " + algOid);
|
|
301
|
+
return nm;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// COSE EC2 / RSA credential key -> a self-contained SPKI the WebCrypto import
|
|
305
|
+
// consumes, so a credential public key and a certificate key verify by one path.
|
|
306
|
+
function _coseKeyToSpki(key, E) {
|
|
307
|
+
if (key.kty === 2 && key.x && key.y) {
|
|
308
|
+
var curveOid = key.crv === 1 ? "prime256v1" : key.crv === 2 ? "secp384r1" : key.crv === 3 ? "secp521r1" : null;
|
|
309
|
+
if (!curveOid) throw E("webauthn/bad-cose-key", "unsupported EC2 curve " + key.crv);
|
|
310
|
+
var b = asn1.build;
|
|
311
|
+
return b.sequence([
|
|
312
|
+
b.sequence([b.oid(oid.byName("ecPublicKey")), b.oid(oid.byName(curveOid))]),
|
|
313
|
+
b.bitString(Buffer.concat([Buffer.from([0x04]), key.x, key.y])),
|
|
314
|
+
]);
|
|
315
|
+
}
|
|
316
|
+
if (key.kty === 3 && key.n && key.n.length && key.e && key.e.length) {
|
|
317
|
+
var bb = asn1.build;
|
|
318
|
+
return bb.sequence([
|
|
319
|
+
bb.sequence([bb.oid(oid.byName("rsaEncryption")), bb.nullValue()]),
|
|
320
|
+
bb.bitString(bb.sequence([bb.integer(BigInt("0x" + key.n.toString("hex"))), bb.integer(BigInt("0x" + key.e.toString("hex")))])),
|
|
321
|
+
]);
|
|
322
|
+
}
|
|
323
|
+
if (key.kty === 1 && OKP_CRV[key.crv] && key.x && key.x.length === OKP_CRV[key.crv].len) {
|
|
324
|
+
// OKP Ed25519 / Ed448: the SPKI is AlgorithmIdentifier { id-Ed* } (no parameters)
|
|
325
|
+
// over the raw public key -- RFC 8410.
|
|
326
|
+
var eb = asn1.build;
|
|
327
|
+
return eb.sequence([eb.sequence([eb.oid(oid.byName(OKP_CRV[key.crv].oid))]), eb.bitString(key.x)]);
|
|
328
|
+
}
|
|
329
|
+
throw E("webauthn/bad-cose-key", "cannot build an SPKI for this COSE key type");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// The attestation-certificate subject public key MUST equal the credential public
|
|
333
|
+
// key that authenticatorData carries (WebAuthn 8.4/8.8 item 30). Compare the raw
|
|
334
|
+
// key material unsigned: an EC2 uncompressed point's X/Y vs the COSE x/y; an RSA
|
|
335
|
+
// modulus/exponent vs the COSE n/e. `cert.subjectPublicKeyInfo.publicKey.bytes` is
|
|
336
|
+
// the BIT STRING key material the SPKI carries.
|
|
337
|
+
// The named-curve OID a certificate's EC SubjectPublicKeyInfo declares (the DER
|
|
338
|
+
// OBJECT IDENTIFIER in the algorithm parameters). Fail-closed: absent or malformed
|
|
339
|
+
// curve parameters throw a typed key-mismatch -- never swallowed to a null the
|
|
340
|
+
// caller would compare as "no match by default".
|
|
341
|
+
function _certEcCurveOid(cert, E) {
|
|
342
|
+
var params = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.algorithm && cert.subjectPublicKeyInfo.algorithm.parameters;
|
|
343
|
+
if (!Buffer.isBuffer(params)) throw E("webauthn/key-mismatch", "the attestation certificate EC key carries no named-curve parameters");
|
|
344
|
+
try { return asn1.read.oid(asn1.decode(params)); }
|
|
345
|
+
catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate EC curve is not a valid OBJECT IDENTIFIER", e); }
|
|
346
|
+
}
|
|
347
|
+
// A void assert: throws webauthn/key-mismatch on any inequality, returns nothing on
|
|
348
|
+
// success (called for its throw side-effect, like the other _check* asserts).
|
|
349
|
+
function _certPubKeyEqualsCose(cert, cose, E) {
|
|
350
|
+
var raw = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.publicKey && cert.subjectPublicKeyInfo.publicKey.bytes;
|
|
351
|
+
if (!raw) throw E("webauthn/key-mismatch", "the attestation certificate exposes no public key");
|
|
352
|
+
if (cose.kty === 2) {
|
|
353
|
+
// The certificate's declared EC curve MUST equal the credential key's curve --
|
|
354
|
+
// a curve substitution is a different key even if the coordinate bytes line up.
|
|
355
|
+
var wantCurve = EC2_CRV_OID[cose.crv];
|
|
356
|
+
if (!wantCurve) throw E("webauthn/key-mismatch", "unsupported credential EC curve " + cose.crv);
|
|
357
|
+
if (_certEcCurveOid(cert, E) !== oid.byName(wantCurve)) throw E("webauthn/key-mismatch", "the attestation certificate EC curve does not equal the credential key curve");
|
|
358
|
+
if (raw.length < 1 || raw[0] !== 0x04) throw E("webauthn/key-mismatch", "the attestation certificate key is not an uncompressed EC point");
|
|
359
|
+
var coordLen = (raw.length - 1) >> 1;
|
|
360
|
+
var cx = raw.subarray(1, 1 + coordLen), cy = raw.subarray(1 + coordLen);
|
|
361
|
+
if (!cose.x || !cose.y || !_ucmp(cx, cose.x) || !_ucmp(cy, cose.y)) throw E("webauthn/key-mismatch", "the attestation certificate EC key does not equal the credential public key");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (cose.kty === 3) {
|
|
365
|
+
var seq;
|
|
366
|
+
try { seq = asn1.decode(raw); } catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate RSA key is not decodable", e); }
|
|
367
|
+
// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }; both
|
|
368
|
+
// MUST be primitive INTEGERs (a constructed child has a null content buffer).
|
|
369
|
+
if (!seq.children || seq.children.length !== 2 || !_isInteger(seq.children[0]) || !_isInteger(seq.children[1])) {
|
|
370
|
+
throw E("webauthn/key-mismatch", "the attestation certificate RSA key is malformed");
|
|
371
|
+
}
|
|
372
|
+
var n = seq.children[0].content, ee = seq.children[1].content;
|
|
373
|
+
if (!cose.n || !cose.e || !_ucmp(n, cose.n) || !_ucmp(ee, cose.e)) throw E("webauthn/key-mismatch", "the attestation certificate RSA key does not equal the credential public key");
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (cose.kty === 1) {
|
|
377
|
+
// OKP Ed25519 / Ed448: the SPKI BIT STRING body IS the raw public key (fixed
|
|
378
|
+
// width, so a byte-exact compare, not a leading-zero-stripping unsigned compare).
|
|
379
|
+
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");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
throw E("webauthn/key-mismatch", "unsupported credential key type for the certificate comparison");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---- TPM structure readers (WebAuthn 8.3; TCG TPM 2.0 Part 2) ----------------
|
|
386
|
+
// Every field is unsigned big-endian, packed with no padding; every TPM2B_* is a
|
|
387
|
+
// UINT16 size followed by exactly `size` bytes. The bare certInfo (TPMS_ATTEST) and
|
|
388
|
+
// pubArea (TPMT_PUBLIC) carry NO outer TPM2B size prefix.
|
|
389
|
+
var TPM_GENERATED_VALUE = 0xff544347;
|
|
390
|
+
var TPM_ST_ATTEST_CERTIFY = 0x8017;
|
|
391
|
+
var TPM_ALG = { RSA: 0x0001, SHA1: 0x0004, SHA256: 0x000b, SHA384: 0x000c, SHA512: 0x000d, NULL: 0x0010, ECC: 0x0023 };
|
|
392
|
+
var TPM_ALG_HASH = {}; TPM_ALG_HASH[TPM_ALG.SHA1] = "sha1"; TPM_ALG_HASH[TPM_ALG.SHA256] = "sha256"; TPM_ALG_HASH[TPM_ALG.SHA384] = "sha384"; TPM_ALG_HASH[TPM_ALG.SHA512] = "sha512";
|
|
393
|
+
// TPM_ECC_CURVE -> COSE crv (the codepoints differ; this is a mapping, not equality).
|
|
394
|
+
var TPM_CURVE_TO_COSE = {}; TPM_CURVE_TO_COSE[0x0003] = 1; TPM_CURVE_TO_COSE[0x0004] = 2; TPM_CURVE_TO_COSE[0x0005] = 3;
|
|
395
|
+
|
|
396
|
+
function _tpmReader(buf) { return new ByteReader(buf, 0, buf.length, WebauthnError, "webauthn/bad-tpm"); }
|
|
397
|
+
|
|
398
|
+
// TPMT_PUBLIC (WebAuthn 8.3 item 17-20): decode type/nameAlg + the public key in
|
|
399
|
+
// `unique`, walking past `parameters` per the algorithm union.
|
|
400
|
+
function _parseTpmPubArea(buf) {
|
|
401
|
+
var r = _tpmReader(buf);
|
|
402
|
+
var type = r.u16(), nameAlg = r.u16();
|
|
403
|
+
r.u32(); // objectAttributes (full policy validation deferred)
|
|
404
|
+
r.vector(2, 0, null); // authPolicy TPM2B_DIGEST
|
|
405
|
+
var pub = { type: type, nameAlg: nameAlg, nameAlgBytes: buf.subarray(2, 4) };
|
|
406
|
+
if (type === TPM_ALG.RSA) {
|
|
407
|
+
_tpmSymDef(r); // symmetric TPMT_SYM_DEF_OBJECT
|
|
408
|
+
_tpmScheme(r); // scheme TPMT_RSA_SCHEME
|
|
409
|
+
r.u16(); // keyBits
|
|
410
|
+
var exp = r.u32(); // exponent (0 => default 65537)
|
|
411
|
+
pub.exponent = exp === 0 ? 65537 : exp;
|
|
412
|
+
pub.rsa = r.vector(2, 0, null); // unique TPM2B_PUBLIC_KEY_RSA (the modulus)
|
|
413
|
+
} else if (type === TPM_ALG.ECC) {
|
|
414
|
+
_tpmSymDef(r); // symmetric
|
|
415
|
+
_tpmScheme(r); // scheme TPMT_ECC_SCHEME
|
|
416
|
+
pub.curveId = r.u16(); // curveID TPMI_ECC_CURVE
|
|
417
|
+
_tpmScheme(r); // kdf TPMT_KDF_SCHEME
|
|
418
|
+
pub.x = r.vector(2, 0, null); // unique.ecc.x TPM2B_ECC_PARAMETER
|
|
419
|
+
pub.y = r.vector(2, 0, null); // unique.ecc.y TPM2B_ECC_PARAMETER
|
|
420
|
+
} else {
|
|
421
|
+
throw _err("webauthn/bad-tpm", "unsupported TPMT_PUBLIC type 0x" + type.toString(16));
|
|
422
|
+
}
|
|
423
|
+
// TPMT_PUBLIC ends with `unique`; trailing bytes mean a malformed pubArea (and
|
|
424
|
+
// would perturb the TPM Name hash), so fail closed rather than silently ignore them.
|
|
425
|
+
if (!r.atEnd()) throw _err("webauthn/bad-tpm", "pubArea has trailing bytes after the unique field (WebAuthn 8.3)");
|
|
426
|
+
return pub;
|
|
427
|
+
}
|
|
428
|
+
// TPMT_SYM_DEF_OBJECT: algorithm UINT16; when not NULL, keyBits+mode follow. An
|
|
429
|
+
// attestation (restricted signing) key is NULL, so the non-NULL arm is defensive.
|
|
430
|
+
function _tpmSymDef(r) { if (r.u16() !== TPM_ALG.NULL) { r.u16(); r.u16(); } }
|
|
431
|
+
// TPMT_*_SCHEME / TPMT_KDF_SCHEME: scheme UINT16; when not NULL, a TPMS_SCHEME_HASH
|
|
432
|
+
// (a single UINT16 hashAlg) follows for the signing/kdf schemes in scope.
|
|
433
|
+
function _tpmScheme(r) { if (r.u16() !== TPM_ALG.NULL) { r.u16(); } }
|
|
434
|
+
|
|
435
|
+
// TPMS_ATTEST (WebAuthn 8.3 item 13-15): magic/type + extraData + attested.name.
|
|
436
|
+
// The whole certInfo is walked -- through qualifiedName and to the end -- so a
|
|
437
|
+
// certInfo with trailing bytes past the TPMS_CERTIFY_INFO union fails closed.
|
|
438
|
+
function _parseTpmCertInfo(buf) {
|
|
439
|
+
var r = _tpmReader(buf);
|
|
440
|
+
var magic = r.u32(), type = r.u16();
|
|
441
|
+
r.vector(2, 0, null); // qualifiedSigner TPM2B_NAME
|
|
442
|
+
var extraData = r.vector(2, 0, null); // extraData TPM2B_DATA
|
|
443
|
+
r.u64(); r.u32(); r.u32(); r.u8(); // clockInfo TPMS_CLOCK_INFO (17 bytes)
|
|
444
|
+
r.u64(); // firmwareVersion
|
|
445
|
+
var name = r.vector(2, 0, null); // attested.name TPM2B_NAME (nameAlg||H)
|
|
446
|
+
r.vector(2, 0, null); // attested.qualifiedName TPM2B_NAME
|
|
447
|
+
if (!r.atEnd()) throw _err("webauthn/bad-tpm", "certInfo has trailing bytes after the attested structure (WebAuthn 8.3)");
|
|
448
|
+
return { magic: magic, type: type, extraData: extraData, attestedName: name };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---- extension helpers -------------------------------------------------------
|
|
452
|
+
// `oidName` is a registered name (byName resolves it at call time); an unregistered
|
|
453
|
+
// name is a programming error that surfaces as an undefined target, never swallowed.
|
|
454
|
+
function _findExt(cert, oidName) {
|
|
455
|
+
var target = oid.byName(oidName);
|
|
456
|
+
return (cert.extensions || []).filter(function (e) { return e.oid === target; })[0] || null;
|
|
457
|
+
}
|
|
458
|
+
// AuthorizationList (Android key attestation) is a SEQUENCE of [tag] EXPLICIT
|
|
459
|
+
// OPTIONAL fields with non-contiguous tag numbers; return the EXPLICIT-unwrapped
|
|
460
|
+
// value node for a tag, or null when absent (a tolerant, skip-unknown scan).
|
|
461
|
+
function _alGet(seqNode, tag) {
|
|
462
|
+
if (!seqNode || !seqNode.children) return null;
|
|
463
|
+
for (var i = 0; i < seqNode.children.length; i++) {
|
|
464
|
+
var c = seqNode.children[i];
|
|
465
|
+
if (c.tagClass === "context" && c.tagNumber === tag) return c.children && c.children[0] ? c.children[0] : c;
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---- public: parseAttestationObject -----------------------------------------
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* @primitive pki.webauthn.parseAttestationObject
|
|
474
|
+
* @signature pki.webauthn.parseAttestationObject(bytes) -> { fmt, attStmt, authData, authDataBytes }
|
|
475
|
+
* @since 0.2.5
|
|
476
|
+
* @status experimental
|
|
477
|
+
* @spec W3C WebAuthn Level 3 sec. 6.5.4 / 6.1
|
|
478
|
+
* @related pki.webauthn.verify
|
|
479
|
+
*
|
|
480
|
+
* Structurally decode a WebAuthn attestation object (the CBOR `{fmt, attStmt,
|
|
481
|
+
* authData}`) and its authenticatorData, fail-closed. `authData` carries the decoded
|
|
482
|
+
* rpIdHash / flags / signCount and, when the AT flag is set, the attestedCredentialData
|
|
483
|
+
* (aaguid, credentialId, and the decoded COSE `credentialPublicKey`). `authDataBytes`
|
|
484
|
+
* is the raw authenticatorData -- the exact bytes an attestation signature covers.
|
|
485
|
+
* A malformed object throws `webauthn/bad-attestation-object`.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* var att = pki.webauthn.parseAttestationObject(attestationObject);
|
|
489
|
+
* att.fmt; // "packed"
|
|
490
|
+
* att.authData.credentialPublicKey.kty; // 2 (EC2)
|
|
491
|
+
*/
|
|
492
|
+
function parseAttestationObject(bytes) {
|
|
493
|
+
var root;
|
|
494
|
+
try { root = cbor.decode(bytes); } catch (e) { throw _err("webauthn/bad-attestation-object", "the attestation object is not well-formed CBOR", e); }
|
|
495
|
+
if (root.majorType !== 5) throw _err("webauthn/bad-attestation-object", "the attestation object must be a CBOR map { fmt, attStmt, authData }");
|
|
496
|
+
var fmtN = _mapGet(root, "fmt"), attStmtN = _mapGet(root, "attStmt"), authDataN = _mapGet(root, "authData");
|
|
497
|
+
// The attestation object is EXACTLY { fmt, attStmt, authData } -- no more, no fewer
|
|
498
|
+
// (WebAuthn 6.5.4); an extra top-level key is a non-canonical envelope, rejected.
|
|
499
|
+
if (root.children.length !== 3 || !fmtN || !attStmtN || !authDataN) throw _err("webauthn/bad-attestation-object", "the attestation object must be exactly { fmt, attStmt, authData }");
|
|
500
|
+
if (fmtN.majorType !== 3) throw _err("webauthn/bad-attestation-object", "attestation object 'fmt' must be a text string");
|
|
501
|
+
if (authDataN.majorType !== 2) throw _err("webauthn/bad-attestation-object", "attestation object 'authData' must be a byte string");
|
|
502
|
+
var authDataBytes = cbor.read.byteString(authDataN);
|
|
503
|
+
return {
|
|
504
|
+
fmt: cbor.read.textString(fmtN),
|
|
505
|
+
attStmt: attStmtN,
|
|
506
|
+
authData: _parseAuthData(authDataBytes, _err),
|
|
507
|
+
authDataBytes: authDataBytes,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ---- attestation-format verifiers -------------------------------------------
|
|
512
|
+
|
|
513
|
+
function _reqAttr(map, key) {
|
|
514
|
+
var n = _mapGet(map, key);
|
|
515
|
+
if (!n) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + key + "' field");
|
|
516
|
+
return n;
|
|
517
|
+
}
|
|
518
|
+
// Read a required attStmt field of an expected CBOR type, mapping a wrong-type
|
|
519
|
+
// cbor/* fault to the webauthn domain (an attStmt field is not-well-formed input to
|
|
520
|
+
// this layer, so it is a webauthn/bad-att-stmt verdict, not a leaked codec error).
|
|
521
|
+
function _attRead(map, key, reader, what) {
|
|
522
|
+
var n = _reqAttr(map, key);
|
|
523
|
+
try { return reader(n); }
|
|
524
|
+
catch (e) { throw _err("webauthn/bad-att-stmt", "the attestation statement '" + key + "' must be " + what, e); }
|
|
525
|
+
}
|
|
526
|
+
function _algOf(attStmt) { return Number(_attRead(attStmt, "alg", cbor.read.int, "an integer")); }
|
|
527
|
+
function _sigOf(attStmt) { return _attRead(attStmt, "sig", cbor.read.byteString, "a byte string"); }
|
|
528
|
+
// A format's attStmt is its CANONICAL field set: every present key must be in
|
|
529
|
+
// `allowed`, and every key in `required` must be present. An unexpected field is a
|
|
530
|
+
// non-canonical statement, rejected before any field is trusted (WebAuthn sec. 8.*).
|
|
531
|
+
function _requireAttShape(attStmt, allowed, required) {
|
|
532
|
+
var have = {};
|
|
533
|
+
if (attStmt && attStmt.children) attStmt.children.forEach(function (kv) {
|
|
534
|
+
// attStmt keys are text strings; a non-text key is a malformed statement,
|
|
535
|
+
// rejected (not silently skipped, which would evade the unexpected-field check).
|
|
536
|
+
if (kv[0].majorType !== 3) throw _err("webauthn/bad-att-stmt", "the attestation statement has a non-text-string field key");
|
|
537
|
+
have[cbor.read.textString(kv[0])] = true;
|
|
538
|
+
});
|
|
539
|
+
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 + "'"); });
|
|
540
|
+
required.forEach(function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
|
|
541
|
+
}
|
|
542
|
+
function _readX5c(attStmt) {
|
|
543
|
+
var x5cN = _mapGet(attStmt, "x5c");
|
|
544
|
+
if (!x5cN || x5cN.majorType !== 4 || !x5cN.children || !x5cN.children.length) throw _err("webauthn/bad-att-stmt", "x5c must be a non-empty array of certificates");
|
|
545
|
+
return x5cN.children.map(function (c) {
|
|
546
|
+
var der;
|
|
547
|
+
try { der = cbor.read.byteString(c); } catch (e) { throw _err("webauthn/bad-att-stmt", "an x5c entry must be a byte string", e); }
|
|
548
|
+
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); }
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
// Every attestation leaf cert MUST be an X.509 v3 certificate (WebAuthn 8.2.1 /
|
|
552
|
+
// 8.3.1 / 8.4). The chain to a trust anchor is the caller's to run through
|
|
553
|
+
// pki.path.validate with a supplied root; a bare chain is surfaced in trustPath.
|
|
554
|
+
function _requireV3(cert) {
|
|
555
|
+
if (cert.version !== 3) throw _err("webauthn/bad-att-cert", "an attestation certificate must be X.509 version 3");
|
|
556
|
+
}
|
|
557
|
+
// A packed / tpm attestation LEAF certificate MUST carry a basicConstraints
|
|
558
|
+
// extension asserting cA=false (WebAuthn 8.2.1 / 8.3.1). Requiring the extension --
|
|
559
|
+
// not merely rejecting a present cA=true -- is the gate that stops a genuine CA
|
|
560
|
+
// certificate under the caller's pinned root, or a leaf that simply omits the
|
|
561
|
+
// constraint, from being repurposed as an attestation leaf; RFC 5280 path validation
|
|
562
|
+
// does not reject a CA-asserting or unconstrained leaf.
|
|
563
|
+
function _assertNotCa(cert) {
|
|
564
|
+
var bc = _decodeExt(cert, "basicConstraints");
|
|
565
|
+
if (!bc) throw _err("webauthn/bad-att-cert", "an attestation leaf certificate MUST carry a basicConstraints extension (WebAuthn 8.2.1 / 8.3.1)");
|
|
566
|
+
if (bc.value && bc.value.cA === true) throw _err("webauthn/bad-att-cert", "an attestation leaf certificate MUST NOT be a CA (basicConstraints cA=true)");
|
|
567
|
+
}
|
|
568
|
+
// Packed attestation certificate requirements (WebAuthn 8.2.1): v3, non-CA, and the
|
|
569
|
+
// subject set to C (country), O (organization), OU = "Authenticator Attestation",
|
|
570
|
+
// and CN (a vendor string).
|
|
571
|
+
function _checkPackedCert(cert) {
|
|
572
|
+
_requireV3(cert);
|
|
573
|
+
_assertNotCa(cert);
|
|
574
|
+
var have = {};
|
|
575
|
+
(cert.subject.rdns || []).forEach(function (rdn) { rdn.forEach(function (a) { have[a.type] = a.value; }); });
|
|
576
|
+
if (have[oid.byName("countryName")] == null || have[oid.byName("organizationName")] == null || have[oid.byName("commonName")] == null) {
|
|
577
|
+
throw _err("webauthn/bad-att-cert", "the packed attestation certificate subject MUST set C, O, OU, and CN (WebAuthn 8.2.1)");
|
|
578
|
+
}
|
|
579
|
+
if (have[oid.byName("organizationalUnitName")] !== "Authenticator Attestation") {
|
|
580
|
+
throw _err("webauthn/bad-att-cert", "the packed attestation certificate subject OU MUST be \"Authenticator Attestation\" (WebAuthn 8.2.1)");
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
var AAGUID_EXT_OID = oid.byName("idFidoGenCeAaguid"); // a registered name, resolved at load
|
|
584
|
+
// The id-fido-gen-ce-aaguid extension value (when present) MUST equal the
|
|
585
|
+
// attestedCredentialData aaguid, and it MUST NOT be critical (WebAuthn 8.2.1).
|
|
586
|
+
function _checkAaguidExt(cert, aaguid) {
|
|
587
|
+
var ext = (cert.extensions || []).filter(function (e) { return e.oid === AAGUID_EXT_OID; })[0];
|
|
588
|
+
if (!ext) return; // absence is tolerated; presence must match
|
|
589
|
+
if (ext.critical) throw _err("webauthn/bad-att-cert", "the id-fido-gen-ce-aaguid extension MUST NOT be critical (WebAuthn 8.2.1)");
|
|
590
|
+
var val;
|
|
591
|
+
try { val = asn1.read.octetString(asn1.decode(ext.value)); }
|
|
592
|
+
catch (e) { throw _err("webauthn/bad-att-cert", "the id-fido-gen-ce-aaguid extension value is not a valid OCTET STRING", e); }
|
|
593
|
+
if (!val.equals(aaguid)) throw _err("webauthn/aaguid-mismatch", "the id-fido-gen-ce-aaguid extension value does not equal the authenticatorData aaguid");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
var VERIFIERS = {
|
|
597
|
+
// packed (WebAuthn 8.2): the x5c arm (Basic/AttCA) or self-attestation.
|
|
598
|
+
packed: function (att, clientDataHash) {
|
|
599
|
+
var isX5c = !!_mapGet(att.attStmt, "x5c");
|
|
600
|
+
_requireAttShape(att.attStmt, isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"], isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"]);
|
|
601
|
+
var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
|
|
602
|
+
var message = Buffer.concat([att.authDataBytes, clientDataHash]);
|
|
603
|
+
if (isX5c) {
|
|
604
|
+
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
605
|
+
_checkPackedCert(leaf); // 8.2.1: v3, non-CA, OU=Authenticator Attestation
|
|
606
|
+
return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err).then(function (ok) {
|
|
607
|
+
if (!ok) throw _err("webauthn/verify-failed", "the packed attestation signature does not verify under the x5c leaf key");
|
|
608
|
+
_checkAaguidExt(leaf, att.authData.aaguid);
|
|
609
|
+
return _result("packed", "Basic", chain, att);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
// Self-attestation: the statement alg MUST equal the credential key's own alg
|
|
613
|
+
// (WebAuthn 8.2), then sig verifies under the credential key itself.
|
|
614
|
+
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)");
|
|
615
|
+
var spki = _coseKeyToSpki(att.authData.credentialPublicKey, _err);
|
|
616
|
+
return _verifySig(alg, sig, spki, message, _err).then(function (ok) {
|
|
617
|
+
if (!ok) throw _err("webauthn/verify-failed", "the packed self-attestation signature does not verify under the credential key");
|
|
618
|
+
return _result("packed", "Self", [], att);
|
|
619
|
+
});
|
|
620
|
+
},
|
|
621
|
+
|
|
622
|
+
// fido-u2f (WebAuthn 8.6): reconstruct the U2F verificationData and verify with
|
|
623
|
+
// the single x5c cert. The credential key MUST be EC2/P-256.
|
|
624
|
+
"fido-u2f": function (att, clientDataHash) {
|
|
625
|
+
_requireAttShape(att.attStmt, ["sig", "x5c"], ["sig", "x5c"]);
|
|
626
|
+
var chain = _readX5c(att.attStmt);
|
|
627
|
+
if (chain.length !== 1) throw _err("webauthn/bad-att-stmt", "fido-u2f x5c MUST contain exactly one certificate (WebAuthn 8.6)");
|
|
628
|
+
// WebAuthn 8.6 does not require a version-3 certificate for fido-u2f (unlike the
|
|
629
|
+
// packed 8.2.1 / tpm 8.3.1 leaves), so a legacy v1 U2F attestation cert is valid.
|
|
630
|
+
var leaf = chain[0];
|
|
631
|
+
var sig = _sigOf(att.attStmt);
|
|
632
|
+
var key = att.authData.credentialPublicKey;
|
|
633
|
+
if (key.kty !== 2 || key.crv !== 1 || !key.x || !key.y || key.x.length !== 32 || key.y.length !== 32) {
|
|
634
|
+
throw _err("webauthn/bad-att-stmt", "fido-u2f requires an EC2 P-256 credential public key (WebAuthn 8.6)");
|
|
635
|
+
}
|
|
636
|
+
var publicKeyU2F = Buffer.concat([Buffer.from([0x04]), key.x, key.y]);
|
|
637
|
+
var verificationData = Buffer.concat([Buffer.from([0x00]), att.authData.rpIdHash, clientDataHash, att.authData.credentialId, publicKeyU2F]);
|
|
638
|
+
return _verifySig(-7, sig, leaf.subjectPublicKeyInfo.bytes, verificationData, _err).then(function (ok) {
|
|
639
|
+
if (!ok) throw _err("webauthn/verify-failed", "the fido-u2f attestation signature does not verify under the x5c leaf key");
|
|
640
|
+
return _result("fido-u2f", "Basic", chain, att);
|
|
641
|
+
});
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
// apple (WebAuthn 8.8): the binding is the SHA-256 nonce over authData ||
|
|
645
|
+
// clientDataHash embedded in the leaf cert; there is no signature field.
|
|
646
|
+
apple: function (att, clientDataHash) {
|
|
647
|
+
_requireAttShape(att.attStmt, ["alg", "x5c"], ["x5c"]); // alg optional, ignored
|
|
648
|
+
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
649
|
+
_requireV3(leaf);
|
|
650
|
+
var nonce = _sha("sha256", Buffer.concat([att.authDataBytes, clientDataHash]));
|
|
651
|
+
var ext = _findExt(leaf, "appleAnonymousAttestation");
|
|
652
|
+
if (!ext) throw _err("webauthn/bad-att-cert", "the apple attestation certificate is missing the anonymous-attestation extension (WebAuthn 8.8)");
|
|
653
|
+
var embedded = _appleNonce(ext.value);
|
|
654
|
+
if (!embedded.equals(nonce)) throw _err("webauthn/verify-failed", "the apple attestation nonce does not equal SHA-256(authData || clientDataHash)");
|
|
655
|
+
_certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err); // 8.8 item 30
|
|
656
|
+
return Promise.resolve(_result("apple", "AnonCA", chain, att));
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
// android-key (WebAuthn 8.4): verify sig with the leaf key, bind the leaf key to
|
|
660
|
+
// the credential key, and enforce the four KeyDescription checks (8.4.1).
|
|
661
|
+
"android-key": function (att, clientDataHash) {
|
|
662
|
+
_requireAttShape(att.attStmt, ["alg", "sig", "x5c"], ["alg", "sig", "x5c"]);
|
|
663
|
+
var chain = _readX5c(att.attStmt), leaf = chain[0];
|
|
664
|
+
_requireV3(leaf);
|
|
665
|
+
var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
|
|
666
|
+
var message = Buffer.concat([att.authDataBytes, clientDataHash]);
|
|
667
|
+
return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err).then(function (ok) {
|
|
668
|
+
if (!ok) throw _err("webauthn/verify-failed", "the android-key attestation signature does not verify under the x5c leaf key");
|
|
669
|
+
_certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err);
|
|
670
|
+
_checkAndroidKeyDescription(leaf, clientDataHash); // 8.4.1
|
|
671
|
+
return _result("android-key", "Basic", chain, att);
|
|
672
|
+
});
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
// tpm (WebAuthn 8.3): decode certInfo/pubArea, enforce magic/type/extraData/Name,
|
|
676
|
+
// bind pubArea to the credential key, and verify sig over certInfo with the AIK.
|
|
677
|
+
tpm: function (att, clientDataHash) {
|
|
678
|
+
_requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
|
|
679
|
+
var verN = _mapGet(att.attStmt, "ver");
|
|
680
|
+
if (!verN || verN.majorType !== 3 || cbor.read.textString(verN) !== "2.0") throw _err("webauthn/bad-att-stmt", "tpm attestation 'ver' MUST be \"2.0\" (WebAuthn 8.3)");
|
|
681
|
+
var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
|
|
682
|
+
var pubAreaBytes = _attRead(att.attStmt, "pubArea", cbor.read.byteString, "a byte string");
|
|
683
|
+
var certInfoBytes = _attRead(att.attStmt, "certInfo", cbor.read.byteString, "a byte string");
|
|
684
|
+
var chain = _readX5c(att.attStmt), aik = chain[0];
|
|
685
|
+
_requireV3(aik);
|
|
686
|
+
|
|
687
|
+
var pub = _parseTpmPubArea(pubAreaBytes);
|
|
688
|
+
_tpmPubKeyEqualsCose(pub, att.authData.credentialPublicKey); // 8.3 item 22
|
|
689
|
+
|
|
690
|
+
var certInfo = _parseTpmCertInfo(certInfoBytes);
|
|
691
|
+
if (certInfo.magic !== TPM_GENERATED_VALUE) throw _err("webauthn/bad-tpm", "certInfo magic is not TPM_GENERATED_VALUE (WebAuthn 8.3)");
|
|
692
|
+
if (certInfo.type !== TPM_ST_ATTEST_CERTIFY) throw _err("webauthn/bad-tpm", "certInfo type is not TPM_ST_ATTEST_CERTIFY (WebAuthn 8.3)");
|
|
693
|
+
|
|
694
|
+
// extraData == hash_alg(authData || clientDataHash) (bare digest, no method id).
|
|
695
|
+
var attToBeSigned = Buffer.concat([att.authDataBytes, clientDataHash]);
|
|
696
|
+
if (!certInfo.extraData.equals(_sha(_coseAlgHash(alg, _err), attToBeSigned))) throw _err("webauthn/verify-failed", "certInfo extraData does not equal the hash of authData || clientDataHash");
|
|
697
|
+
|
|
698
|
+
// attested.name == nameAlg || H_nameAlg(pubArea).
|
|
699
|
+
var nameHash = TPM_ALG_HASH[pub.nameAlg];
|
|
700
|
+
if (!nameHash) throw _err("webauthn/bad-tpm", "unsupported TPM nameAlg 0x" + pub.nameAlg.toString(16));
|
|
701
|
+
var computedName = Buffer.concat([pub.nameAlgBytes, _sha(nameHash, pubAreaBytes)]);
|
|
702
|
+
if (!certInfo.attestedName.equals(computedName)) throw _err("webauthn/verify-failed", "certInfo attested Name does not match the pubArea TPM Name");
|
|
703
|
+
|
|
704
|
+
// WebAuthn 8.3 verification step: "Verify the sig is a valid signature over
|
|
705
|
+
// certInfo ... with the algorithm specified in alg" -- sig is verified DIRECTLY
|
|
706
|
+
// with alg, not parsed as a TPMT_SIGNATURE and unwrapped. The attStmt-syntax
|
|
707
|
+
// "in the form of a TPMT_SIGNATURE" is a description of the byte string; real
|
|
708
|
+
// authenticators put the raw signature here (the interop KAT's tpm sig is a bare
|
|
709
|
+
// 256-byte RSASSA signature that verifies as-is), and the reference verifier
|
|
710
|
+
// (py_webauthn) verifies it directly with alg the same way.
|
|
711
|
+
return _verifySig(alg, sig, aik.subjectPublicKeyInfo.bytes, certInfoBytes, _err).then(function (ok) {
|
|
712
|
+
if (!ok) throw _err("webauthn/verify-failed", "the tpm attestation signature does not verify over certInfo under the AIK");
|
|
713
|
+
_checkAikCert(aik); // 8.3.1
|
|
714
|
+
_checkAaguidExt(aik, att.authData.aaguid); // 8.3.1: aaguid ext, if present, MUST match
|
|
715
|
+
return _result("tpm", "AttCA", chain, att);
|
|
716
|
+
});
|
|
717
|
+
},
|
|
718
|
+
|
|
719
|
+
// none (WebAuthn 8.7): the authenticator provides no attestation. attStmt MUST be
|
|
720
|
+
// an empty map; there is no statement to verify, so the result carries no trust
|
|
721
|
+
// path. The credential public key still binds via authenticatorData (AT flag).
|
|
722
|
+
none: function (att) {
|
|
723
|
+
// attStmt MUST BE an empty CBOR map -- reject a missing, non-map, or non-empty
|
|
724
|
+
// attStmt, not only a non-empty map (WebAuthn 8.7).
|
|
725
|
+
if (!att.attStmt || att.attStmt.majorType !== 5 || (att.attStmt.children && att.attStmt.children.length !== 0)) {
|
|
726
|
+
throw _err("webauthn/bad-att-stmt", "the none attestation statement MUST be an empty map (WebAuthn 8.7)");
|
|
727
|
+
}
|
|
728
|
+
return Promise.resolve(_result("none", "None", [], att));
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
// `chain` is the x5c order (leaf-first); trustPath is surfaced in pki.path.validate
|
|
733
|
+
// order (anchor-adjacent first, target/leaf last) so the caller passes it straight
|
|
734
|
+
// to the path validator without re-ordering. The input array is not mutated.
|
|
735
|
+
function _result(fmt, attestationType, chain, att) {
|
|
736
|
+
return { verified: true, fmt: fmt, attestationType: attestationType, trustPath: (chain || []).slice().reverse(), aaguid: att.authData.aaguid, credentialPublicKey: att.authData.credentialPublicKey };
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// pubArea public key == credentialPublicKey (unsigned compare; WebAuthn 8.3 item 22).
|
|
740
|
+
function _tpmPubKeyEqualsCose(pub, cose) {
|
|
741
|
+
if (pub.type === TPM_ALG.ECC) {
|
|
742
|
+
if (cose.kty !== 2 || TPM_CURVE_TO_COSE[pub.curveId] !== cose.crv || !_ucmp(pub.x, cose.x || Buffer.alloc(0)) || !_ucmp(pub.y, cose.y || Buffer.alloc(0))) {
|
|
743
|
+
throw _err("webauthn/key-mismatch", "the TPM pubArea EC key does not equal the credential public key");
|
|
744
|
+
}
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
if (pub.type === TPM_ALG.RSA) {
|
|
748
|
+
// Compare the exponent as an unsigned integer over its FULL width (a UINT32 up to
|
|
749
|
+
// 0xFFFFFFFF); a fixed 3-byte re-encode would silently truncate an exponent
|
|
750
|
+
// > 0xFFFFFF and let a mismatched key pass (WebAuthn 8.3 item 22).
|
|
751
|
+
var e = _uintBytes(pub.exponent >>> 0);
|
|
752
|
+
if (cose.kty !== 3 || !_ucmp(pub.rsa, cose.n || Buffer.alloc(0)) || !_ucmp(e, cose.e || Buffer.alloc(0))) {
|
|
753
|
+
throw _err("webauthn/key-mismatch", "the TPM pubArea RSA key does not equal the credential public key");
|
|
754
|
+
}
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
throw _err("webauthn/bad-tpm", "unsupported TPM pubArea key type");
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Decode the Apple extension AppleAnonymousAttestation ::= SEQUENCE { nonce [1]
|
|
761
|
+
// EXPLICIT OCTET STRING } (WebAuthn 8.8 item 29) and return the 32-byte nonce.
|
|
762
|
+
function _appleNonce(extValue) {
|
|
763
|
+
var seq;
|
|
764
|
+
try { seq = asn1.decode(extValue); } catch (e) { throw _err("webauthn/bad-att-cert", "the apple attestation extension is not decodable", e); }
|
|
765
|
+
var tagged = seq.children && seq.children[0];
|
|
766
|
+
if (!tagged || tagged.tagClass !== "context" || tagged.tagNumber !== 1 || !tagged.children || !tagged.children[0]) {
|
|
767
|
+
throw _err("webauthn/bad-att-cert", "the apple attestation extension is not SEQUENCE { [1] OCTET STRING }");
|
|
768
|
+
}
|
|
769
|
+
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); }
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// The four Android KeyDescription checks (WebAuthn 8.4.1 item 28).
|
|
773
|
+
function _checkAndroidKeyDescription(cert, clientDataHash) {
|
|
774
|
+
var ext = _findExt(cert, "keyDescription");
|
|
775
|
+
if (!ext) throw _err("webauthn/bad-att-cert", "the android-key attestation certificate is missing the key-description extension (WebAuthn 8.4.1)");
|
|
776
|
+
var kd;
|
|
777
|
+
try { kd = asn1.decode(ext.value); } catch (e) { throw _err("webauthn/bad-att-cert", "the android KeyDescription is not decodable", e); }
|
|
778
|
+
if (!kd.children || kd.children.length < 8) throw _err("webauthn/bad-att-cert", "the android KeyDescription is not a positional 8-field SEQUENCE");
|
|
779
|
+
// (1) attestationChallenge (position 4) == clientDataHash.
|
|
780
|
+
var challenge;
|
|
781
|
+
try { challenge = asn1.read.octetString(kd.children[4]); } catch (e) { throw _err("webauthn/bad-att-cert", "the android attestationChallenge is not an OCTET STRING", e); }
|
|
782
|
+
if (!challenge.equals(clientDataHash)) throw _err("webauthn/verify-failed", "the android attestationChallenge does not equal clientDataHash");
|
|
783
|
+
var softwareEnforced = kd.children[6], hardwareEnforced = kd.children[7];
|
|
784
|
+
// (2) [600] allApplications MUST be ABSENT in both lists.
|
|
785
|
+
if (_alGet(softwareEnforced, 600) || _alGet(hardwareEnforced, 600)) throw _err("webauthn/verify-failed", "android allApplications MUST be absent (WebAuthn 8.4.1)");
|
|
786
|
+
// (3)+(4) origin == KM_ORIGIN_GENERATED (0) and purpose == exactly {KM_PURPOSE_SIGN}
|
|
787
|
+
// (2), evaluated over the UNION of the teeEnforced and softwareEnforced lists --
|
|
788
|
+
// the WebAuthn 8.4.1 default (a teeEnforced-only policy is the caller's opt-in).
|
|
789
|
+
// Origin: at least one list MUST declare it, and EVERY list that declares one MUST
|
|
790
|
+
// say GENERATED -- a mixed teeEnforced=IMPORTED / softwareEnforced=GENERATED key is
|
|
791
|
+
// contradictory and rejected, not accepted on the strength of one list.
|
|
792
|
+
var origins = [_alGet(softwareEnforced, 702), _alGet(hardwareEnforced, 702)].filter(Boolean);
|
|
793
|
+
if (!origins.length || !origins.every(function (o) { return _alInt(o) === 0n; })) {
|
|
794
|
+
throw _err("webauthn/verify-failed", "android key origin is not KM_ORIGIN_GENERATED in every authorization list that declares it (WebAuthn 8.4.1)");
|
|
795
|
+
}
|
|
796
|
+
var purposes = _purposeUnion(softwareEnforced, hardwareEnforced);
|
|
797
|
+
if (purposes.length !== 1 || purposes[0] !== 2n) {
|
|
798
|
+
throw _err("webauthn/verify-failed", "android key purpose is not exactly KM_PURPOSE_SIGN (WebAuthn 8.4.1)");
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
// Read an android KeyDescription authorization INTEGER, mapping a wrong-type asn1/*
|
|
802
|
+
// fault to the webauthn domain (a malformed authorization value is bad-att-cert input
|
|
803
|
+
// to this layer, not a leaked codec error).
|
|
804
|
+
function _alInt(node) {
|
|
805
|
+
try { return asn1.read.integer(node); }
|
|
806
|
+
catch (e) { throw _err("webauthn/bad-att-cert", "an android KeyDescription authorization value is not an INTEGER", e); }
|
|
807
|
+
}
|
|
808
|
+
// The union of the [1] purpose (SET OF INTEGER) values across both authorization
|
|
809
|
+
// lists -- an attestation key MUST be usable for SIGN and nothing else.
|
|
810
|
+
function _purposeUnion(a, b) {
|
|
811
|
+
var out = [];
|
|
812
|
+
[a, b].forEach(function (list) {
|
|
813
|
+
var p = _alGet(list, 1);
|
|
814
|
+
if (p && p.children) p.children.forEach(function (c) { var v = _alInt(c); if (out.indexOf(v) === -1) out.push(v); });
|
|
815
|
+
});
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// AIK-certificate requirements (WebAuthn 8.3.1): non-CA, an empty subject, the tcg
|
|
820
|
+
// AIK-certificate EKU, and the tcg tpmManufacturer/tpmModel/tpmVersion subjectAltName
|
|
821
|
+
// directory attributes.
|
|
822
|
+
function _checkAikCert(cert) {
|
|
823
|
+
_assertNotCa(cert);
|
|
824
|
+
if ((cert.subject.rdns || []).length !== 0) throw _err("webauthn/bad-att-cert", "the tpm AIK certificate subject MUST be empty (WebAuthn 8.3.1)");
|
|
825
|
+
var eku = _decodeExt(cert, "extKeyUsage");
|
|
826
|
+
if (!eku || !Array.isArray(eku.value) || eku.value.indexOf(oid.byName("tcgKpAikCertificate")) === -1) {
|
|
827
|
+
throw _err("webauthn/bad-att-cert", "the tpm AIK certificate lacks the tcg-kp-AIKCertificate extended key purpose (WebAuthn 8.3.1)");
|
|
828
|
+
}
|
|
829
|
+
var san = _decodeExt(cert, "subjectAltName");
|
|
830
|
+
var dirName = san && san.value && san.value.names && san.value.names.filter(function (n) { return n.tagNumber === 4; })[0];
|
|
831
|
+
var types = {};
|
|
832
|
+
if (dirName && dirName.value && dirName.value.rdns) {
|
|
833
|
+
dirName.value.rdns.forEach(function (rdn) { rdn.forEach(function (a) { types[a.type] = true; }); });
|
|
834
|
+
}
|
|
835
|
+
if (!types[oid.byName("tpmManufacturer")] || !types[oid.byName("tpmModel")] || !types[oid.byName("tpmVersion")]) {
|
|
836
|
+
throw _err("webauthn/bad-att-cert", "the tpm AIK certificate subjectAltName lacks the required tcg attributes (WebAuthn 8.3.1)");
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* @primitive pki.webauthn.verify
|
|
842
|
+
* @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{ verified, fmt, attestationType, trustPath, aaguid, credentialPublicKey }>
|
|
843
|
+
* @since 0.2.5
|
|
844
|
+
* @status experimental
|
|
845
|
+
* @spec W3C WebAuthn Level 3 sec. 8
|
|
846
|
+
* @related pki.webauthn.parseAttestationObject
|
|
847
|
+
*
|
|
848
|
+
* Verify a WebAuthn attestation statement: the attestation signature over
|
|
849
|
+
* `authenticatorData || clientDataHash` and (for the x5c formats) the format's
|
|
850
|
+
* certificate requirements. `clientDataHash` is the SHA-256 of the serialized client
|
|
851
|
+
* data, supplied by the relying party. Resolves the attestation type + trust path or
|
|
852
|
+
* throws a typed `webauthn/*` error; a signature that does not verify is a
|
|
853
|
+
* `webauthn/verify-failed` verdict, never a silent pass.
|
|
854
|
+
*
|
|
855
|
+
* @intro This verifies the attestation STATEMENT -- the signature and the format's
|
|
856
|
+
* structural bindings (the x5c leaf key == credential key, the apple nonce, the tpm
|
|
857
|
+
* certInfo Name/extraData, the android KeyDescription, the fido-u2f verificationData).
|
|
858
|
+
* Chaining the returned `trustPath` (the x5c certificates in `pki.path.validate`
|
|
859
|
+
* order -- anchor-adjacent first, leaf last) to a trusted root is the caller's
|
|
860
|
+
* step: anchor it with `pki.path.validate` against roots you pin. Resolving an
|
|
861
|
+
* authenticator's root from its aaguid via the FIDO Metadata Service, plus the
|
|
862
|
+
* chain-to-anchor call, are not built in yet -- re-open when the caller supplies an
|
|
863
|
+
* explicit trusted-root set or a FIDO MDS BLOB (`opts.mdsBlob`) to bind the path.
|
|
864
|
+
*
|
|
865
|
+
* @example
|
|
866
|
+
* var res = await pki.webauthn.verify(attestationObject, clientDataHash, {});
|
|
867
|
+
* res.verified; // true (statement signature + bindings hold)
|
|
868
|
+
* res.attestationType; // "Basic"
|
|
869
|
+
* // anchor res.trustPath to your pinned roots with pki.path.validate
|
|
870
|
+
*/
|
|
871
|
+
function verify(attestationObject, clientDataHash, opts) {
|
|
872
|
+
opts = opts || {};
|
|
873
|
+
if (!Buffer.isBuffer(clientDataHash) || clientDataHash.length !== 32) {
|
|
874
|
+
return Promise.reject(_err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest"));
|
|
875
|
+
}
|
|
876
|
+
var att;
|
|
877
|
+
try { att = parseAttestationObject(attestationObject); } catch (e) { return Promise.reject(e); }
|
|
878
|
+
// A registration attestation MUST carry attestedCredentialData (the AT flag): the
|
|
879
|
+
// whole point is to bind the attestation to a credential public key. Reject an
|
|
880
|
+
// AT-clear authenticatorData up front -- else the packed x5c arm could resolve a
|
|
881
|
+
// positive verdict bound to NO credential, and every arm would dereference the
|
|
882
|
+
// null credential key with a raw (untyped) throw (WebAuthn 6.1 / 7.1).
|
|
883
|
+
if (!att.authData.flags.at || !att.authData.credentialPublicKey) {
|
|
884
|
+
return Promise.reject(_err("webauthn/bad-auth-data", "attestation requires attestedCredentialData (the AT flag must be set)"));
|
|
885
|
+
}
|
|
886
|
+
var verifier = VERIFIERS[att.fmt];
|
|
887
|
+
if (!verifier) return Promise.reject(_err("webauthn/unsupported-format", "attestation statement format '" + att.fmt + "' is not supported"));
|
|
888
|
+
return Promise.resolve().then(function () { return verifier(att, clientDataHash, opts); });
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
void constants;
|
|
892
|
+
|
|
893
|
+
module.exports = {
|
|
894
|
+
parseAttestationObject: parseAttestationObject,
|
|
895
|
+
verify: verify,
|
|
896
|
+
};
|