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