@blamejs/pki 0.2.4 → 0.2.6

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,621 @@
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 validator = require("./validator-all");
33
+ var edwardsPoint = require("./edwards-point");
34
+ var nodeCrypto = require("crypto");
35
+
36
+ var WebauthnError = frameworkError.WebauthnError;
37
+ function _err(code, message, cause) { return new WebauthnError(code, message, cause); }
38
+ // The shared certificate-extension decoder registry (keyed by OID), so an
39
+ // attestation-certificate extension (extKeyUsage, subjectAltName) is decoded by the
40
+ // same fail-closed pkix decoder every other format uses, not a local hand-roll.
41
+ var NS = pkix.makeNS("webauthn", WebauthnError, oid);
42
+ var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
43
+ function _decodeExt(cert, oidName) {
44
+ var ext = _findExt(cert, oidName);
45
+ if (!ext) return null;
46
+ var dec = EXT_DECODERS[ext.oid];
47
+ if (!dec) return null;
48
+ return { critical: ext.critical, value: dec(ext.value) };
49
+ }
50
+ var subtle = webcrypto.webcrypto.subtle;
51
+
52
+ // A one-shot digest over a bounded buffer (the attestation nonce / TPM Name / TPM
53
+ // extraData preimages), by the node hash name the caller has already resolved.
54
+ function _sha(name, buf) { return nodeCrypto.createHash(name).update(buf).digest(); }
55
+ // Unsigned big-endian magnitude compare: strip leading zero octets, then byte-equal.
56
+ // TPM2B buffers and COSE fixed-length coordinates can differ by a leading 0x00, so a
57
+ // raw memcmp would spuriously reject an equal key (WebAuthn 8.3, item 22).
58
+ function _ucmp(a, b) {
59
+ function strip(x) { var i = 0; while (i < x.length - 1 && x[i] === 0) i++; return x.subarray(i); }
60
+ return Buffer.compare(strip(a), strip(b)) === 0;
61
+ }
62
+ // A decoded node is a primitive universal INTEGER (so `.content` is a real buffer,
63
+ // not null as it is for a constructed node).
64
+ function _isInteger(node) { return !!node && !node.constructed && node.tagClass === "universal" && node.tagNumber === asn1.TAGS.INTEGER; }
65
+ // alg -> the digest a TPM attestation's certInfo.extraData is taken under (the ONLY
66
+ // consumer). Every ECDSA/RSA algorithm a TPM AIK may sign with -- including the RFC 9864
67
+ // fully-specified ECDSA ids (ESP256/384/512) -- MUST appear here, or the TPM extraData
68
+ // step rejects the attestation before the signature is evaluated. EdDSA (-8/-19/-53) is
69
+ // absent by design: a TPM 2.0 AIK never signs with EdDSA, so such an attestation is
70
+ // correctly refused.
71
+ var COSE_ALG_HASH = { "-7": "sha256", "-9": "sha256", "-257": "sha256", "-37": "sha256", "-35": "sha384", "-51": "sha384", "-258": "sha384", "-36": "sha512", "-52": "sha512", "-259": "sha512", "-65535": "sha1" };
72
+ function _coseAlgHash(alg, E) {
73
+ var h = COSE_ALG_HASH[String(alg)];
74
+ if (!h) throw E("webauthn/unsupported-algorithm", "no hash mapping for COSE algorithm " + alg);
75
+ return h;
76
+ }
77
+
78
+ // ---- CBOR map access over the strict pki.cbor node tree ----------------------
79
+
80
+ // A decoded CBOR map is a node whose `children` is an array of [keyNode, valNode].
81
+ // Look a text-keyed entry up, or null. The strict codec already rejected a
82
+ // duplicate map key, so at most one match exists.
83
+ function _mapGet(node, key) {
84
+ if (!node || node.majorType !== 5 || !node.children) return null;
85
+ for (var i = 0; i < node.children.length; i++) {
86
+ var k = node.children[i][0];
87
+ if (k.majorType === 3 && cbor.read.textString(k) === key) return node.children[i][1];
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // ---- authenticatorData bounded reader (WebAuthn sec. 6.1) --------------------
93
+
94
+ // authData = rpIdHash[32] || flags[1] || signCount[4 BE] || (AT? attestedCredentialData) || (ED? extensions CBOR).
95
+ // A bounded big-endian read: every slice is length-checked before it is taken, so a
96
+ // truncated / oversize field fails closed rather than reading past the buffer.
97
+ var AAGUID_LEN = 16;
98
+ function _parseAuthData(buf, E) {
99
+ 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)");
100
+ var flags = buf[32];
101
+ var out = {
102
+ rpIdHash: buf.subarray(0, 32),
103
+ flags: { up: !!(flags & 0x01), uv: !!(flags & 0x04), be: !!(flags & 0x08), bs: !!(flags & 0x10), at: !!(flags & 0x40), ed: !!(flags & 0x80) },
104
+ signCount: buf.readUInt32BE(33),
105
+ aaguid: null, credentialId: null, credentialPublicKey: null, credentialPublicKeyBytes: null, extensions: null,
106
+ };
107
+ // Backup State is only valid when Backup Eligibility is set: a credential cannot be
108
+ // "backed up" if it is not "backup eligible" (WebAuthn sec. 6.1).
109
+ 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)");
110
+ // The reserved flag bits (bit 1 = 0x02, bit 5 = 0x20) are undefined; a conforming
111
+ // authenticator leaves them 0. Reject a set reserved bit rather than ignore an
112
+ // unknown flag (WebAuthn sec. 6.1, fail-closed on undefined structure).
113
+ if (flags & 0x22) throw E("webauthn/bad-auth-data", "authenticatorData sets a reserved (RFU) flag bit (WebAuthn sec. 6.1)");
114
+ var off = 37;
115
+ if (out.flags.at) {
116
+ if (buf.length < off + AAGUID_LEN + 2) throw E("webauthn/bad-auth-data", "attestedCredentialData is truncated before the credentialId length");
117
+ out.aaguid = buf.subarray(off, off + AAGUID_LEN); off += AAGUID_LEN;
118
+ var credLen = buf.readUInt16BE(off); off += 2;
119
+ if (credLen < 1 || credLen > 1023) throw E("webauthn/bad-credential-id", "credentialIdLength " + credLen + " is outside 1..1023 (RFC WebAuthn sec. 6.1)");
120
+ if (buf.length < off + credLen) throw E("webauthn/bad-auth-data", "credentialId overruns authenticatorData");
121
+ out.credentialId = buf.subarray(off, off + credLen); off += credLen;
122
+ // credentialPublicKey is a COSE_Key: a single CBOR map occupying [off, its end).
123
+ var keyNode;
124
+ try { keyNode = cbor.decode(buf.subarray(off), { allowTrailing: true }); }
125
+ catch (e) { throw E("webauthn/bad-cose-key", "the credential public key is not well-formed CBOR", e); }
126
+ out.credentialPublicKeyBytes = buf.subarray(off, off + keyNode.bytes.length);
127
+ out.credentialPublicKey = _decodeCoseKey(keyNode);
128
+ off += keyNode.bytes.length;
129
+ }
130
+ if (out.flags.ed) {
131
+ // With the ED flag set, the remainder MUST be exactly one well-formed CBOR map
132
+ // (the extensions); the strict decoder rejects a non-map, malformed bytes, an
133
+ // empty remainder, or trailing bytes (RFC WebAuthn sec. 6.1).
134
+ var extNode;
135
+ try { extNode = cbor.decode(buf.subarray(off)); }
136
+ catch (e) { throw E("webauthn/bad-auth-data", "the authenticatorData extensions are not a single well-formed CBOR map", e); }
137
+ if (extNode.majorType !== 5) throw E("webauthn/bad-auth-data", "the authenticatorData extensions must be a CBOR map");
138
+ out.extensions = buf.subarray(off);
139
+ } else if (off < buf.length) {
140
+ // authenticatorData is fixed-layout: with the ED flag clear there MUST be no
141
+ // bytes after the attestedCredentialData (RFC WebAuthn sec. 6.1). Trailing
142
+ // bytes are a malformed structure -- fail closed rather than ignore them.
143
+ throw E("webauthn/bad-auth-data", "authenticatorData has trailing bytes after attestedCredentialData with the ED flag clear");
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // ---- COSE_Key decode (WebAuthn sec. 6.5.1, RFC 9052 sec. 7) ------------------
149
+
150
+ // The complete COSE credential-key conformance rule set (kty/alg/crv/length/canonical/
151
+ // profile/on-curve) lives in validator-cose, composed here so every credential key
152
+ // routes through the one home -- never a per-format re-derivation of a partial subset.
153
+ function _decodeCoseKey(node) { return validator.cose.credentialKey(node, WebauthnError, "webauthn/bad-cose-key"); }
154
+
155
+ // ---- signature verification bridge ------------------------------------------
156
+
157
+ // COSE algorithm id -> the WebCrypto import + verify descriptor. WebAuthn packed /
158
+ // android-key / tpm ECDSA signatures are DER ECDSA-Sig-Value, so they are converted
159
+ // to the raw r||s form pki.webcrypto (ieee-p1363) expects.
160
+ var COSE_ALG = {
161
+ "-7": { imp: { name: "ECDSA", namedCurve: "P-256" }, verify: { name: "ECDSA", hash: "SHA-256" }, ecdsa: 32 },
162
+ "-35": { imp: { name: "ECDSA", namedCurve: "P-384" }, verify: { name: "ECDSA", hash: "SHA-384" }, ecdsa: 48 },
163
+ "-36": { imp: { name: "ECDSA", namedCurve: "P-521" }, verify: { name: "ECDSA", hash: "SHA-512" }, ecdsa: 66 },
164
+ "-8": { imp: { name: "Ed25519" }, verify: { name: "Ed25519" }, ecdsa: 0 },
165
+ // RFC 9864 fully-specified ids. WebAuthn recommends against them for credential creation,
166
+ // but a verifier MUST evaluate an assertion signed under one. ESP256/384/512 are the
167
+ // curve-pinned ECDSA twins of ES256/384/512; Ed25519(-19)/Ed448(-53) are fully-specified
168
+ // EdDSA (Ed448 is the ONLY WebAuthn path to Ed448 -- -8 is Ed25519 only).
169
+ "-9": { imp: { name: "ECDSA", namedCurve: "P-256" }, verify: { name: "ECDSA", hash: "SHA-256" }, ecdsa: 32 },
170
+ "-51": { imp: { name: "ECDSA", namedCurve: "P-384" }, verify: { name: "ECDSA", hash: "SHA-384" }, ecdsa: 48 },
171
+ "-52": { imp: { name: "ECDSA", namedCurve: "P-521" }, verify: { name: "ECDSA", hash: "SHA-512" }, ecdsa: 66 },
172
+ "-19": { imp: { name: "Ed25519" }, verify: { name: "Ed25519" }, ecdsa: 0 },
173
+ "-53": { imp: { name: "Ed448" }, verify: { name: "Ed448" }, ecdsa: 0 },
174
+ "-257": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
175
+ "-258": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
176
+ "-259": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
177
+ "-37": { imp: { name: "RSA-PSS", hash: "SHA-256" }, verify: { name: "RSA-PSS", saltLength: 32 }, ecdsa: 0 },
178
+ // RS1 (RSASSA-PKCS1-v1_5 / SHA-1): a legacy COSE algorithm real Windows Hello TPM
179
+ // authenticators emit in their attestation statement. VERIFY-only support -- the
180
+ // toolkit never signs with SHA-1; it must still evaluate the attestations that
181
+ // ship using it, or a large class of TPM authenticators cannot be verified.
182
+ "-65535": { imp: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-1" }, verify: { name: "RSASSA-PKCS1-v1_5" }, ecdsa: 0 },
183
+ };
184
+
185
+ // DER ECDSA-Sig-Value { r, s } -> raw r||s (the ieee-p1363 form WebCrypto verify expects).
186
+ // validator-sig owns the complete strict-DER conformance (primitive, minimal, positive,
187
+ // bounded r/s), composed here.
188
+ function _derEcdsaToRaw(der, coordLen) { return validator.sig.ecdsaSigToRaw(der, coordLen, WebauthnError, "webauthn/bad-signature"); }
189
+
190
+ // Verify `sig` over `message` with the SPKI public key `spkiBytes` under COSE `alg`.
191
+ // A wrong signature resolves `false` from subtle.verify without throwing (a false
192
+ // verdict is a verdict); a structural failure -- an unimportable key, a bad
193
+ // descriptor -- is re-thrown as a typed fail-closed error, never masked as false.
194
+ function _verifySig(alg, sig, spkiBytes, message, E) {
195
+ var d = COSE_ALG[String(alg)];
196
+ if (!d) throw _err("webauthn/unsupported-algorithm", "unsupported COSE algorithm " + alg);
197
+ var imp = d.imp, ver = d.verify;
198
+ // COSE alg -8 (EdDSA) covers Ed25519 and Ed448; the WebCrypto name follows the
199
+ // signing key's own SPKI algorithm OID, not the (curve-agnostic) alg id.
200
+ if (alg === -8) { var nm = _edName(spkiBytes, E); imp = { name: nm }; ver = { name: nm }; }
201
+ // node/OpenSSL imports any Ed25519/Ed448 SPKI without validating the point, and a
202
+ // low-order (e.g. all-zeroes) key verifies a trivial signature -- so validate the OKP
203
+ // point before verify. This covers EVERY key that signs a WebAuthn statement: the x5c
204
+ // attestation-certificate key (packed/tpm/apple) AND the self-attestation credential key.
205
+ if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name, E);
206
+ var s = d.ecdsa ? _derEcdsaToRaw(sig, d.ecdsa) : sig;
207
+ return subtle.importKey("spki", spkiBytes, imp, false, ["verify"])
208
+ .then(function (key) { return subtle.verify(ver, key, s, message); })
209
+ .catch(function (e) { throw _err("webauthn/verify-error", "the attestation signature could not be evaluated", e); });
210
+ }
211
+ // The WebCrypto EdDSA name (Ed25519 / Ed448) an SPKI declares via its algorithm OID.
212
+ var ED_OID_NAME = {};
213
+ ED_OID_NAME[oid.byName("Ed25519")] = "Ed25519";
214
+ ED_OID_NAME[oid.byName("Ed448")] = "Ed448";
215
+ function _edName(spkiBytes, E) {
216
+ var algOid;
217
+ try { algOid = asn1.read.oid(asn1.decode(spkiBytes).children[0].children[0]); }
218
+ catch (e) { throw E("webauthn/bad-signature", "the EdDSA public key is not a well-formed SPKI", e); }
219
+ var nm = ED_OID_NAME[algOid];
220
+ if (!nm) throw E("webauthn/unsupported-algorithm", "unsupported EdDSA curve OID " + algOid);
221
+ return nm;
222
+ }
223
+ // The raw Edwards point an OKP SPKI carries (its BIT STRING body, past the unused-bits
224
+ // octet) MUST be a valid, full-order point -- reject an off-curve or low-order key before it
225
+ // verifies a signature (WebCrypto import does not check it). Curve from the WebCrypto name.
226
+ function _requireValidEdPoint(spkiBytes, name, E) {
227
+ var content;
228
+ try { content = asn1.decode(spkiBytes).children[1].content; }
229
+ catch (e) { throw E("webauthn/bad-signature", "the EdDSA public key is not a well-formed SPKI", e); }
230
+ var point = content && content.length ? content.subarray(1) : Buffer.alloc(0);
231
+ if (!edwardsPoint.validate(point, name === "Ed25519" ? 6 : 7)) {
232
+ throw E("webauthn/bad-signature", "the EdDSA public key is not a valid, full-order Edwards point");
233
+ }
234
+ }
235
+
236
+ // A validated COSE credential key -> a self-contained SPKI the WebCrypto import
237
+ // consumes, so a credential public key and a certificate key verify by one path.
238
+ // validator-cose owns the encoding (EC2 / RSA / OKP), composed here.
239
+ function _coseKeyToSpki(key) { return validator.cose.toSpki(key, WebauthnError, "webauthn/bad-cose-key"); }
240
+
241
+ // The attestation-certificate subject public key MUST equal the credential public
242
+ // key that authenticatorData carries (WebAuthn 8.4/8.8 item 30). Compare the raw
243
+ // key material unsigned: an EC2 uncompressed point's X/Y vs the COSE x/y; an RSA
244
+ // modulus/exponent vs the COSE n/e. `cert.subjectPublicKeyInfo.publicKey.bytes` is
245
+ // the BIT STRING key material the SPKI carries.
246
+ // The named-curve OID a certificate's EC SubjectPublicKeyInfo declares (the DER
247
+ // OBJECT IDENTIFIER in the algorithm parameters). Fail-closed: absent or malformed
248
+ // curve parameters throw a typed key-mismatch -- never swallowed to a null the
249
+ // caller would compare as "no match by default".
250
+ function _certEcCurveOid(cert, E) {
251
+ var params = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.algorithm && cert.subjectPublicKeyInfo.algorithm.parameters;
252
+ if (!Buffer.isBuffer(params)) throw E("webauthn/key-mismatch", "the attestation certificate EC key carries no named-curve parameters");
253
+ try { return asn1.read.oid(asn1.decode(params)); }
254
+ catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate EC curve is not a valid OBJECT IDENTIFIER", e); }
255
+ }
256
+ // A void assert: throws webauthn/key-mismatch on any inequality, returns nothing on
257
+ // success (called for its throw side-effect, like the other _check* asserts).
258
+ function _certPubKeyEqualsCose(cert, cose, E) {
259
+ var raw = cert.subjectPublicKeyInfo && cert.subjectPublicKeyInfo.publicKey && cert.subjectPublicKeyInfo.publicKey.bytes;
260
+ if (!raw) throw E("webauthn/key-mismatch", "the attestation certificate exposes no public key");
261
+ if (cose.kty === 2) {
262
+ // The certificate's declared EC curve MUST equal the credential key's curve --
263
+ // a curve substitution is a different key even if the coordinate bytes line up.
264
+ var wantCurve = validator.cose.EC2_CRV_OID[cose.crv];
265
+ if (!wantCurve) throw E("webauthn/key-mismatch", "unsupported credential EC curve " + cose.crv);
266
+ if (_certEcCurveOid(cert, E) !== oid.byName(wantCurve)) throw E("webauthn/key-mismatch", "the attestation certificate EC curve does not equal the credential key curve");
267
+ if (raw.length < 1 || raw[0] !== 0x04) throw E("webauthn/key-mismatch", "the attestation certificate key is not an uncompressed EC point");
268
+ var coordLen = (raw.length - 1) >> 1;
269
+ var cx = raw.subarray(1, 1 + coordLen), cy = raw.subarray(1 + coordLen);
270
+ 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");
271
+ return;
272
+ }
273
+ if (cose.kty === 3) {
274
+ var seq;
275
+ try { seq = asn1.decode(raw); } catch (e) { throw E("webauthn/key-mismatch", "the attestation certificate RSA key is not decodable", e); }
276
+ // RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER }; both
277
+ // MUST be primitive INTEGERs (a constructed child has a null content buffer).
278
+ if (!seq.children || seq.children.length !== 2 || !_isInteger(seq.children[0]) || !_isInteger(seq.children[1])) {
279
+ throw E("webauthn/key-mismatch", "the attestation certificate RSA key is malformed");
280
+ }
281
+ var n = seq.children[0].content, ee = seq.children[1].content;
282
+ 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");
283
+ return;
284
+ }
285
+ if (cose.kty === 1) {
286
+ // OKP Ed25519 / Ed448: the SPKI BIT STRING body IS the raw public key (fixed
287
+ // width, so a byte-exact compare, not a leading-zero-stripping unsigned compare).
288
+ 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");
289
+ return;
290
+ }
291
+ throw E("webauthn/key-mismatch", "unsupported credential key type for the certificate comparison");
292
+ }
293
+
294
+ // ---- TPM structure conformance (WebAuthn 8.3; TCG TPM 2.0 Part 2) -------------
295
+ // The TPMT_PUBLIC (pubArea) + TPMS_ATTEST (certInfo) decode + the pubArea-key binding
296
+ // live in validator-tpm, composed here. A malformed structure is bad-tpm; a key that
297
+ // does not equal the credential key is key-mismatch.
298
+ function _parseTpmPubArea(buf) { return validator.tpm.parsePubArea(buf, WebauthnError, "webauthn/bad-tpm"); }
299
+ function _parseTpmCertInfo(buf) { return validator.tpm.parseCertInfo(buf, WebauthnError, "webauthn/bad-tpm"); }
300
+ function _tpmPubKeyEqualsCose(pub, cose) { validator.tpm.pubKeyEqualsCose(pub, cose, WebauthnError, "webauthn/key-mismatch", "webauthn/bad-tpm"); }
301
+
302
+ // ---- extension helpers -------------------------------------------------------
303
+ // `oidName` is a registered name (byName resolves it at call time); an unregistered
304
+ // name is a programming error that surfaces as an undefined target, never swallowed.
305
+ function _findExt(cert, oidName) {
306
+ var target = oid.byName(oidName);
307
+ return (cert.extensions || []).filter(function (e) { return e.oid === target; })[0] || null;
308
+ }
309
+
310
+ // ---- public: parseAttestationObject -----------------------------------------
311
+
312
+ /**
313
+ * @primitive pki.webauthn.parseAttestationObject
314
+ * @signature pki.webauthn.parseAttestationObject(bytes) -> { fmt, attStmt, authData, authDataBytes }
315
+ * @since 0.2.5
316
+ * @status experimental
317
+ * @spec W3C WebAuthn Level 3 sec. 6.5.4 / 6.1
318
+ * @related pki.webauthn.verify
319
+ *
320
+ * Structurally decode a WebAuthn attestation object (the CBOR `{fmt, attStmt,
321
+ * authData}`) and its authenticatorData, fail-closed. `authData` carries the decoded
322
+ * rpIdHash / flags / signCount and, when the AT flag is set, the attestedCredentialData
323
+ * (aaguid, credentialId, and the decoded COSE `credentialPublicKey`). `authDataBytes`
324
+ * is the raw authenticatorData -- the exact bytes an attestation signature covers.
325
+ * A malformed object throws `webauthn/bad-attestation-object`.
326
+ *
327
+ * @example
328
+ * var att = pki.webauthn.parseAttestationObject(attestationObject);
329
+ * att.fmt; // "packed"
330
+ * att.authData.credentialPublicKey.kty; // 2 (EC2)
331
+ */
332
+ function parseAttestationObject(bytes) {
333
+ var root;
334
+ try { root = cbor.decode(bytes); } catch (e) { throw _err("webauthn/bad-attestation-object", "the attestation object is not well-formed CBOR", e); }
335
+ if (root.majorType !== 5) throw _err("webauthn/bad-attestation-object", "the attestation object must be a CBOR map { fmt, attStmt, authData }");
336
+ var fmtN = _mapGet(root, "fmt"), attStmtN = _mapGet(root, "attStmt"), authDataN = _mapGet(root, "authData");
337
+ // The attestation object is EXACTLY { fmt, attStmt, authData } -- no more, no fewer
338
+ // (WebAuthn 6.5.4); an extra top-level key is a non-canonical envelope, rejected.
339
+ if (root.children.length !== 3 || !fmtN || !attStmtN || !authDataN) throw _err("webauthn/bad-attestation-object", "the attestation object must be exactly { fmt, attStmt, authData }");
340
+ if (fmtN.majorType !== 3) throw _err("webauthn/bad-attestation-object", "attestation object 'fmt' must be a text string");
341
+ if (authDataN.majorType !== 2) throw _err("webauthn/bad-attestation-object", "attestation object 'authData' must be a byte string");
342
+ var authDataBytes = cbor.read.byteString(authDataN);
343
+ return {
344
+ fmt: cbor.read.textString(fmtN),
345
+ attStmt: attStmtN,
346
+ authData: _parseAuthData(authDataBytes, _err),
347
+ authDataBytes: authDataBytes,
348
+ };
349
+ }
350
+
351
+ // ---- attestation-format verifiers -------------------------------------------
352
+
353
+ function _reqAttr(map, key) {
354
+ var n = _mapGet(map, key);
355
+ if (!n) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + key + "' field");
356
+ return n;
357
+ }
358
+ // Read a required attStmt field of an expected CBOR type, mapping a wrong-type
359
+ // cbor/* fault to the webauthn domain (an attStmt field is not-well-formed input to
360
+ // this layer, so it is a webauthn/bad-att-stmt verdict, not a leaked codec error).
361
+ function _attRead(map, key, reader, what) {
362
+ var n = _reqAttr(map, key);
363
+ try { return reader(n); }
364
+ catch (e) { throw _err("webauthn/bad-att-stmt", "the attestation statement '" + key + "' must be " + what, e); }
365
+ }
366
+ function _algOf(attStmt) { return Number(_attRead(attStmt, "alg", cbor.read.int, "an integer")); }
367
+ function _sigOf(attStmt) { return _attRead(attStmt, "sig", cbor.read.byteString, "a byte string"); }
368
+ // A format's attStmt is its CANONICAL field set: every present key must be in
369
+ // `allowed`, and every key in `required` must be present. An unexpected field is a
370
+ // non-canonical statement, rejected before any field is trusted (WebAuthn sec. 8.*).
371
+ function _requireAttShape(attStmt, allowed, required) {
372
+ var have = {};
373
+ if (attStmt && attStmt.children) attStmt.children.forEach(function (kv) {
374
+ // attStmt keys are text strings; a non-text key is a malformed statement,
375
+ // rejected (not silently skipped, which would evade the unexpected-field check).
376
+ if (kv[0].majorType !== 3) throw _err("webauthn/bad-att-stmt", "the attestation statement has a non-text-string field key");
377
+ have[cbor.read.textString(kv[0])] = true;
378
+ });
379
+ 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 + "'"); });
380
+ required.forEach(function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
381
+ }
382
+ function _readX5c(attStmt) {
383
+ var x5cN = _mapGet(attStmt, "x5c");
384
+ 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");
385
+ return x5cN.children.map(function (c) {
386
+ var der;
387
+ try { der = cbor.read.byteString(c); } catch (e) { throw _err("webauthn/bad-att-stmt", "an x5c entry must be a byte string", e); }
388
+ 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); }
389
+ });
390
+ }
391
+ // The WebAuthn attestation-certificate profile checks (WebAuthn 8.2.1 packed / 8.3.1 TPM
392
+ // AIK / the id-fido-gen-ce-aaguid extension) live in validator-attcert, composed here. The
393
+ // extension-accessor object hands the validator this format's fail-closed extension
394
+ // decoders so it stays decoupled from the webauthn error namespace.
395
+ var _exts = { find: _findExt, decode: _decodeExt };
396
+ function _requireV3(cert) { validator.attcert.requireV3(cert, WebauthnError, "webauthn/bad-att-cert"); }
397
+ function _checkPackedCert(cert) { validator.attcert.packedCert(cert, _exts, WebauthnError, "webauthn/bad-att-cert"); }
398
+ function _checkAikCert(cert) { validator.attcert.aikCert(cert, _exts, WebauthnError, "webauthn/bad-att-cert"); }
399
+ function _checkAaguidExt(cert, aaguid) { validator.attcert.aaguidExt(cert, aaguid, _exts, WebauthnError, "webauthn/bad-att-cert", "webauthn/aaguid-mismatch"); }
400
+
401
+ var VERIFIERS = {
402
+ // packed (WebAuthn 8.2): the x5c arm (Basic/AttCA) or self-attestation.
403
+ packed: function (att, clientDataHash) {
404
+ var isX5c = !!_mapGet(att.attStmt, "x5c");
405
+ _requireAttShape(att.attStmt, isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"], isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"]);
406
+ var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
407
+ var message = Buffer.concat([att.authDataBytes, clientDataHash]);
408
+ if (isX5c) {
409
+ var chain = _readX5c(att.attStmt), leaf = chain[0];
410
+ _checkPackedCert(leaf); // 8.2.1: v3, non-CA, OU=Authenticator Attestation
411
+ return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err).then(function (ok) {
412
+ if (!ok) throw _err("webauthn/verify-failed", "the packed attestation signature does not verify under the x5c leaf key");
413
+ _checkAaguidExt(leaf, att.authData.aaguid);
414
+ return _result("packed", "Basic", chain, att);
415
+ });
416
+ }
417
+ // Self-attestation: the statement alg MUST equal the credential key's own alg
418
+ // (WebAuthn 8.2), then sig verifies under the credential key itself.
419
+ 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)");
420
+ var spki = _coseKeyToSpki(att.authData.credentialPublicKey);
421
+ return _verifySig(alg, sig, spki, message, _err).then(function (ok) {
422
+ if (!ok) throw _err("webauthn/verify-failed", "the packed self-attestation signature does not verify under the credential key");
423
+ return _result("packed", "Self", [], att);
424
+ });
425
+ },
426
+
427
+ // fido-u2f (WebAuthn 8.6): reconstruct the U2F verificationData and verify with
428
+ // the single x5c cert. The credential key MUST be EC2/P-256.
429
+ "fido-u2f": function (att, clientDataHash) {
430
+ _requireAttShape(att.attStmt, ["sig", "x5c"], ["sig", "x5c"]);
431
+ var chain = _readX5c(att.attStmt);
432
+ if (chain.length !== 1) throw _err("webauthn/bad-att-stmt", "fido-u2f x5c MUST contain exactly one certificate (WebAuthn 8.6)");
433
+ // WebAuthn 8.6 does not require a version-3 certificate for fido-u2f (unlike the
434
+ // packed 8.2.1 / tpm 8.3.1 leaves), so a legacy v1 U2F attestation cert is valid.
435
+ var leaf = chain[0];
436
+ var sig = _sigOf(att.attStmt);
437
+ var key = att.authData.credentialPublicKey;
438
+ // WebAuthn 8.6: the fido-u2f credential public key MUST be alg -7 (ES256) on EC2 P-256 --
439
+ // the newer ESP256 (-9) id, though the same curve, is not a valid fido-u2f credential.
440
+ if (key.alg !== -7 || key.kty !== 2 || key.crv !== 1 || !key.x || !key.y || key.x.length !== 32 || key.y.length !== 32) {
441
+ throw _err("webauthn/bad-att-stmt", "fido-u2f requires an ES256 (-7) EC2 P-256 credential public key (WebAuthn 8.6)");
442
+ }
443
+ var publicKeyU2F = Buffer.concat([Buffer.from([0x04]), key.x, key.y]);
444
+ var verificationData = Buffer.concat([Buffer.from([0x00]), att.authData.rpIdHash, clientDataHash, att.authData.credentialId, publicKeyU2F]);
445
+ return _verifySig(-7, sig, leaf.subjectPublicKeyInfo.bytes, verificationData, _err).then(function (ok) {
446
+ if (!ok) throw _err("webauthn/verify-failed", "the fido-u2f attestation signature does not verify under the x5c leaf key");
447
+ return _result("fido-u2f", "Basic", chain, att);
448
+ });
449
+ },
450
+
451
+ // apple (WebAuthn 8.8): the binding is the SHA-256 nonce over authData ||
452
+ // clientDataHash embedded in the leaf cert; there is no signature field.
453
+ apple: function (att, clientDataHash) {
454
+ _requireAttShape(att.attStmt, ["alg", "x5c"], ["x5c"]); // alg optional, ignored
455
+ var chain = _readX5c(att.attStmt), leaf = chain[0];
456
+ _requireV3(leaf);
457
+ var nonce = _sha("sha256", Buffer.concat([att.authDataBytes, clientDataHash]));
458
+ var ext = _findExt(leaf, "appleAnonymousAttestation");
459
+ if (!ext) throw _err("webauthn/bad-att-cert", "the apple attestation certificate is missing the anonymous-attestation extension (WebAuthn 8.8)");
460
+ var embedded = _appleNonce(ext.value);
461
+ if (!embedded.equals(nonce)) throw _err("webauthn/verify-failed", "the apple attestation nonce does not equal SHA-256(authData || clientDataHash)");
462
+ _certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err); // 8.8 item 30
463
+ return Promise.resolve(_result("apple", "AnonCA", chain, att));
464
+ },
465
+
466
+ // android-key (WebAuthn 8.4): verify sig with the leaf key, bind the leaf key to
467
+ // the credential key, and enforce the four KeyDescription checks (8.4.1).
468
+ "android-key": function (att, clientDataHash) {
469
+ _requireAttShape(att.attStmt, ["alg", "sig", "x5c"], ["alg", "sig", "x5c"]);
470
+ var chain = _readX5c(att.attStmt), leaf = chain[0];
471
+ _requireV3(leaf);
472
+ var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
473
+ var message = Buffer.concat([att.authDataBytes, clientDataHash]);
474
+ return _verifySig(alg, sig, leaf.subjectPublicKeyInfo.bytes, message, _err).then(function (ok) {
475
+ if (!ok) throw _err("webauthn/verify-failed", "the android-key attestation signature does not verify under the x5c leaf key");
476
+ _certPubKeyEqualsCose(leaf, att.authData.credentialPublicKey, _err);
477
+ _checkAndroidKeyDescription(leaf, clientDataHash); // 8.4.1
478
+ return _result("android-key", "Basic", chain, att);
479
+ });
480
+ },
481
+
482
+ // tpm (WebAuthn 8.3): decode certInfo/pubArea, enforce magic/type/extraData/Name,
483
+ // bind pubArea to the credential key, and verify sig over certInfo with the AIK.
484
+ tpm: function (att, clientDataHash) {
485
+ _requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
486
+ var verN = _mapGet(att.attStmt, "ver");
487
+ 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)");
488
+ var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
489
+ var pubAreaBytes = _attRead(att.attStmt, "pubArea", cbor.read.byteString, "a byte string");
490
+ var certInfoBytes = _attRead(att.attStmt, "certInfo", cbor.read.byteString, "a byte string");
491
+ var chain = _readX5c(att.attStmt), aik = chain[0];
492
+
493
+ var pub = _parseTpmPubArea(pubAreaBytes);
494
+ _tpmPubKeyEqualsCose(pub, att.authData.credentialPublicKey); // 8.3 item 22
495
+
496
+ // validator-tpm decodes certInfo AND validates magic == TPM_GENERATED_VALUE / type ==
497
+ // TPM_ST_ATTEST_CERTIFY, so the returned structure is already self-consistent.
498
+ var certInfo = _parseTpmCertInfo(certInfoBytes);
499
+
500
+ // extraData == hash_alg(authData || clientDataHash) (bare digest, no method id).
501
+ var attToBeSigned = Buffer.concat([att.authDataBytes, clientDataHash]);
502
+ if (!certInfo.extraData.equals(_sha(_coseAlgHash(alg, _err), attToBeSigned))) throw _err("webauthn/verify-failed", "certInfo extraData does not equal the hash of authData || clientDataHash");
503
+
504
+ // attested.name == nameAlg || H_nameAlg(pubArea).
505
+ var nameHash = validator.tpm.TPM_ALG_HASH[pub.nameAlg];
506
+ if (!nameHash) throw _err("webauthn/bad-tpm", "unsupported TPM nameAlg 0x" + pub.nameAlg.toString(16));
507
+ var computedName = Buffer.concat([pub.nameAlgBytes, _sha(nameHash, pubAreaBytes)]);
508
+ if (!certInfo.attestedName.equals(computedName)) throw _err("webauthn/verify-failed", "certInfo attested Name does not match the pubArea TPM Name");
509
+
510
+ // WebAuthn 8.3 verification step: "Verify the sig is a valid signature over
511
+ // certInfo ... with the algorithm specified in alg" -- sig is verified DIRECTLY
512
+ // with alg, not parsed as a TPMT_SIGNATURE and unwrapped. The attStmt-syntax
513
+ // "in the form of a TPMT_SIGNATURE" is a description of the byte string; real
514
+ // authenticators put the raw signature here (the interop KAT's tpm sig is a bare
515
+ // 256-byte RSASSA signature that verifies as-is), and the reference verifier
516
+ // (py_webauthn) verifies it directly with alg the same way.
517
+ return _verifySig(alg, sig, aik.subjectPublicKeyInfo.bytes, certInfoBytes, _err).then(function (ok) {
518
+ if (!ok) throw _err("webauthn/verify-failed", "the tpm attestation signature does not verify over certInfo under the AIK");
519
+ _checkAikCert(aik); // 8.3.1
520
+ _checkAaguidExt(aik, att.authData.aaguid); // 8.3.1: aaguid ext, if present, MUST match
521
+ return _result("tpm", "AttCA", chain, att);
522
+ });
523
+ },
524
+
525
+ // none (WebAuthn 8.7): the authenticator provides no attestation. attStmt MUST be
526
+ // an empty map; there is no statement to verify, so the result carries no trust
527
+ // path. The credential public key still binds via authenticatorData (AT flag).
528
+ none: function (att) {
529
+ // attStmt MUST BE an empty CBOR map -- reject a missing, non-map, or non-empty
530
+ // attStmt, not only a non-empty map (WebAuthn 8.7).
531
+ if (!att.attStmt || att.attStmt.majorType !== 5 || (att.attStmt.children && att.attStmt.children.length !== 0)) {
532
+ throw _err("webauthn/bad-att-stmt", "the none attestation statement MUST be an empty map (WebAuthn 8.7)");
533
+ }
534
+ return Promise.resolve(_result("none", "None", [], att));
535
+ },
536
+ };
537
+
538
+ // `chain` is the x5c order (leaf-first); trustPath is surfaced in pki.path.validate
539
+ // order (anchor-adjacent first, target/leaf last) so the caller passes it straight
540
+ // to the path validator without re-ordering. The input array is not mutated.
541
+ function _result(fmt, attestationType, chain, att) {
542
+ return { verified: true, fmt: fmt, attestationType: attestationType, trustPath: (chain || []).slice().reverse(), aaguid: att.authData.aaguid, credentialPublicKey: att.authData.credentialPublicKey };
543
+ }
544
+
545
+ // Decode the Apple extension AppleAnonymousAttestation ::= SEQUENCE { nonce [1]
546
+ // EXPLICIT OCTET STRING } (WebAuthn 8.8 item 29) and return the 32-byte nonce.
547
+ function _appleNonce(extValue) {
548
+ var seq;
549
+ try { seq = asn1.decode(extValue); } catch (e) { throw _err("webauthn/bad-att-cert", "the apple attestation extension is not decodable", e); }
550
+ var tagged = seq.children && seq.children[0];
551
+ if (!tagged || tagged.tagClass !== "context" || tagged.tagNumber !== 1 || !tagged.children || !tagged.children[0]) {
552
+ throw _err("webauthn/bad-att-cert", "the apple attestation extension is not SEQUENCE { [1] OCTET STRING }");
553
+ }
554
+ 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); }
555
+ }
556
+
557
+ // The complete Android Key Attestation KeyDescription conformance (AOSP schema + WebAuthn
558
+ // 8.4.1) lives in validator-keydesc, composed here. A structural fault is bad-att-cert; a
559
+ // well-formed description that fails an 8.4.1 MUST is verify-failed.
560
+ function _checkAndroidKeyDescription(cert, clientDataHash) {
561
+ validator.keydesc.androidKeyDescription(cert, clientDataHash, _exts, WebauthnError, "webauthn/bad-att-cert", "webauthn/verify-failed");
562
+ }
563
+
564
+
565
+ /**
566
+ * @primitive pki.webauthn.verify
567
+ * @signature pki.webauthn.verify(attestationObject, clientDataHash, opts) -> Promise<{ verified, fmt, attestationType, trustPath, aaguid, credentialPublicKey }>
568
+ * @since 0.2.5
569
+ * @status experimental
570
+ * @spec W3C WebAuthn Level 3 sec. 8
571
+ * @related pki.webauthn.parseAttestationObject
572
+ *
573
+ * Verify a WebAuthn attestation statement: the attestation signature over
574
+ * `authenticatorData || clientDataHash` and (for the x5c formats) the format's
575
+ * certificate requirements. `clientDataHash` is the SHA-256 of the serialized client
576
+ * data, supplied by the relying party. Resolves the attestation type + trust path or
577
+ * throws a typed `webauthn/*` error; a signature that does not verify is a
578
+ * `webauthn/verify-failed` verdict, never a silent pass.
579
+ *
580
+ * @intro This verifies the attestation STATEMENT -- the signature and the format's
581
+ * structural bindings (the x5c leaf key == credential key, the apple nonce, the tpm
582
+ * certInfo Name/extraData, the android KeyDescription, the fido-u2f verificationData).
583
+ * Chaining the returned `trustPath` (the x5c certificates in `pki.path.validate`
584
+ * order -- anchor-adjacent first, leaf last) to a trusted root is the caller's
585
+ * step: anchor it with `pki.path.validate` against roots you pin. Resolving an
586
+ * authenticator's root from its aaguid via the FIDO Metadata Service, plus the
587
+ * chain-to-anchor call, are not built in yet -- re-open when the caller supplies an
588
+ * explicit trusted-root set or a FIDO MDS BLOB (`opts.mdsBlob`) to bind the path.
589
+ *
590
+ * @example
591
+ * var res = await pki.webauthn.verify(attestationObject, clientDataHash, {});
592
+ * res.verified; // true (statement signature + bindings hold)
593
+ * res.attestationType; // "Basic"
594
+ * // anchor res.trustPath to your pinned roots with pki.path.validate
595
+ */
596
+ function verify(attestationObject, clientDataHash, opts) {
597
+ opts = opts || {};
598
+ if (!Buffer.isBuffer(clientDataHash) || clientDataHash.length !== 32) {
599
+ return Promise.reject(_err("webauthn/bad-input", "clientDataHash must be a 32-byte SHA-256 digest"));
600
+ }
601
+ var att;
602
+ try { att = parseAttestationObject(attestationObject); } catch (e) { return Promise.reject(e); }
603
+ // A registration attestation MUST carry attestedCredentialData (the AT flag): the
604
+ // whole point is to bind the attestation to a credential public key. Reject an
605
+ // AT-clear authenticatorData up front -- else the packed x5c arm could resolve a
606
+ // positive verdict bound to NO credential, and every arm would dereference the
607
+ // null credential key with a raw (untyped) throw (WebAuthn 6.1 / 7.1).
608
+ if (!att.authData.flags.at || !att.authData.credentialPublicKey) {
609
+ return Promise.reject(_err("webauthn/bad-auth-data", "attestation requires attestedCredentialData (the AT flag must be set)"));
610
+ }
611
+ var verifier = VERIFIERS[att.fmt];
612
+ if (!verifier) return Promise.reject(_err("webauthn/unsupported-format", "attestation statement format '" + att.fmt + "' is not supported"));
613
+ return Promise.resolve().then(function () { return verifier(att, clientDataHash, opts); });
614
+ }
615
+
616
+ void constants;
617
+
618
+ module.exports = {
619
+ parseAttestationObject: parseAttestationObject,
620
+ verify: verify,
621
+ };
package/lib/webcrypto.js CHANGED
@@ -658,7 +658,11 @@ function _algFromImport(name, alg, keyObject) {
658
658
  }
659
659
 
660
660
  function _curveFromKey(ko) {
661
- try { var jwk = ko.export({ format: "jwk" }); for (var k in CURVE_NODE) { if (jwk.crv === k) return k; } } catch (_e) { /* best-effort */ }
661
+ try {
662
+ var jwk = ko.export({ format: "jwk" });
663
+ for (var k in CURVE_NODE) { if (jwk.crv === k) return k; }
664
+ }
665
+ catch (_e) { /* best-effort */ }
662
666
  return undefined;
663
667
  }
664
668