@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.
@@ -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
+ };