@blamejs/pki 0.4.8 → 0.4.10

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 CHANGED
@@ -4,6 +4,35 @@ 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.4.10 — 2026-08-09
8
+
9
+ A TPM attestation now reports the credential key's own object attributes and access policy, so a relying party can require the properties it cares about -- a key bound to one TPM, generated by that TPM, not duplicable -- instead of taking the attestation on trust.
10
+
11
+ ### Added
12
+
13
+ - A verified TPM attestation reports the credential key's object attributes as named flags, the raw attribute word, and the key's access-policy digest. Previously both fields were read past and discarded, so a relying party that wanted to know whether the key was bound to its TPM had to re-parse the public area itself.
14
+ - opts.tpmPolicy requires any of those attributes by name, in either direction -- an attribute may be required set or required clear -- and refuses the attestation naming which one disagreed. A single profile, hardware-bound, is the shorthand for the six attributes every genuine attestation examined agrees on: the key is bound to one TPM and one parent, the TPM generated it, it can sign, and it is neither a restricted key nor an X.509 signing key. An explicit attribute layers over the profile, so one flag can be overridden without losing the rest. Three attributes are deliberately absent from it, because they differ across genuine authenticators and requiring any of them would reject working hardware.
15
+ - The policy also covers the key's access policy: requiring that one is present at all rather than the empty policy, and requiring it to be one of an allow-list of digests, compared in constant time. Two structural opt-ins are available for callers who want them: rejecting an attribute word that sets a bit the specification reserves, and rejecting attribute combinations the specification does not define.
16
+ - A mistyped policy is refused when it is set, not ignored: an unknown key at any level, an unknown profile, an unknown attribute name, a non-boolean value, or an allow-list entry that is not a certificate digest all fail immediately, so a typo can never silently disable the check a caller believes they enabled. Names that every JavaScript object inherits are not accepted as policy names either, since a lookup would otherwise report them as recognised and leave the policy applying nothing. Requiring that the TPM generated the key without also requiring the key be non-duplicable is refused for the same reason -- on its own it establishes nothing, because the key could have been imported.
17
+ - Requesting a TPM policy also requires an attestation that can satisfy it. The policy is evaluated against the TPM public area, which only a TPM attestation carries, so an attestation in any other format would never reach it -- a relying party that demanded a TPM-bound key would have accepted a credential with no attestation at all. Such a request is now refused up front, naming the format, and a compound attestation qualifies only when it actually contains a TPM statement.
18
+
19
+ ### Fixed
20
+
21
+ - Calendar dates without a time are now read through the same strict reader as full timestamps, rejecting a date that does not exist rather than rolling it into the following month the way the language would. A day such as the thirtieth of February is refused instead of silently becoming the second of March, and a parsed date is anchored to UTC so a freshness or expiry comparison does not shift with the host's time zone.
22
+
23
+ ## v0.4.9 — 2026-08-08
24
+
25
+ A WebAuthn compound attestation now verifies -- every nested statement must pass, so a wrapper cannot launder a failed attestation behind one that succeeds -- and the certificate chains an attestation carries are bounded by count, not only by size.
26
+
27
+ ### Added
28
+
29
+ - pki.webauthn.verify verifies the compound attestation format, which it previously refused as unsupported. Every nested statement must verify for the attestation to verify -- the specification leaves the threshold to relying-party policy, and this is the fail-closed reading of it. The result reports attestation type Compound and carries each element's own verdict, attestation type and certificate chain in order, so a caller applies its own policy to the parts rather than to a merged verdict that could overstate or understate any of them. The combined trust path is empty by construction: several elements produce several independent chains, and presenting them as one ordered path would misrepresent what was validated.
30
+ - The nested statements are held to the format's own syntax: at least two of them, each exactly a format identifier and a statement, each identifier matched case-sensitively against the supported set, and none of them compound -- the specification spells that exclusion out, so nesting is impossible by construction rather than by a depth counter. Which CBOR shape a statement takes is now a property of the format rather than a fixed rule, so accommodating the array-shaped compound statement leaves every other format's contract unchanged, and a compound presented in the older map shape is refused.
31
+
32
+ ### Fixed
33
+
34
+ - The number of certificates an attestation may carry is now bounded. Both the attestation statement's certificate array and a JSON Web Signature certificate header capped the size of each certificate but not how many there were, so a statement could present thousands of small certificates and each one cost a parse and, downstream, a full path validation -- work far out of proportion to the bytes on the wire. A single bound now covers every place a chain arrives, set well above any real attestation chain.
35
+
7
36
  ## v0.4.8 — 2026-08-08
8
37
 
9
38
  A stored android-safetynet WebAuthn attestation can be re-verified in full -- the signature, the registration binding, and the certificate chain -- behind an opt-in and against a root the caller supplies.
package/lib/constants.js CHANGED
@@ -297,6 +297,19 @@ var LIMITS = {
297
297
  // terminates but exhausts memory. This matches PATH_MAX_CERTS: a chain longer than the path
298
298
  // validator will ever accept has nothing to offer, so refusing it at the decoder is free.
299
299
  TLS_CERT_MAX_ENTRIES: 100,
300
+ // The most nested statements a WebAuthn compound attestation (sec. 8.9) may carry. The syntax
301
+ // says "2*" -- unbounded -- so this is a resource bound this toolkit chooses, NOT a spec MUST.
302
+ // The CBOR parse caps bound the decode; they do not bound the crypto, and each element costs a
303
+ // signature verification plus, for some formats, a full certificate-path validation. Seven
304
+ // non-compound formats are registered, so a conforming statement carrying one of each is 7.
305
+ WEBAUTHN_COMPOUND_MAX_STATEMENTS: 16,
306
+ // The most certificates a WebAuthn attestation chain may carry, in an attStmt x5c array or a JWS
307
+ // x5c header. A real attestation chain is one to four; the byte ceiling on a single entry does
308
+ // not bound the COUNT, so an array of thousands of small certificates is an unbounded parse and
309
+ // signature-check fanout (CWE-770) that terminates but costs far more CPU than wire. This is a
310
+ // resource bound this toolkit chooses -- no specification states it -- and it is far tighter than
311
+ // PATH_MAX_CERTS because an attestation chain is not a general PKI path.
312
+ WEBAUTHN_X5C_MAX_CERTS: 10,
300
313
  // A WebAuthn android-safetynet attestation statement carries the SafetyNet response as a JWS
301
314
  // compact serialization: two small JSON objects plus a signature, with the certificate chain
302
315
  // inline in the header. Real ones run a few kilobytes. The cap bounds the text decode before the
package/lib/rfc3339.js CHANGED
@@ -41,4 +41,31 @@ function parse(v, E, code, label) {
41
41
  return new Date(v);
42
42
  }
43
43
 
44
- module.exports = { isValid: isValid, parse: parse };
44
+ // RFC 3339 sec. 5.6 `full-date`: a calendar date with NO time component. A distinct production from
45
+ // the date-time above, and deliberately a separate pair of functions rather than a flag: a consumer
46
+ // that expects one and is handed the other has a malformed value, not a permissible variant.
47
+ var FULL_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
48
+
49
+ // isValidDate(v) -> boolean: v is a syntactically well-formed AND calendar-valid RFC 3339 full-date.
50
+ // Calendar validity matters as much as shape -- "2021-02-30" parses as a Date in JS by rolling over
51
+ // into March, so a shape-only check would silently accept a day that does not exist.
52
+ function isValidDate(v) {
53
+ if (typeof v !== "string") return false;
54
+ var m = FULL_DATE_RE.exec(v);
55
+ if (!m) return false;
56
+ var year = +m[1], month = +m[2], day = +m[3];
57
+ if (month < 1 || month > 12) return false;
58
+ var leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
59
+ var daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
60
+ return day >= 1 && day <= daysInMonth[month - 1];
61
+ }
62
+
63
+ // parseDate(v, E, code, label) -> Date at UTC midnight. Anchored explicitly at UTC ("T00:00:00Z")
64
+ // rather than left to `new Date("YYYY-MM-DD")`, so the instant does not shift with the host time
65
+ // zone -- a freshness or rollback comparison must not depend on where it runs.
66
+ function parseDate(v, E, code, label) {
67
+ if (!isValidDate(v)) throw E(code, (label || "the value") + " is not a valid RFC 3339 full-date (YYYY-MM-DD)");
68
+ return new Date(v + "T00:00:00Z");
69
+ }
70
+
71
+ module.exports = { isValid: isValid, parse: parse, isValidDate: isValidDate, parseDate: parseDate };
@@ -27,6 +27,7 @@
27
27
  // coordinate by a leading 0x00), the RSA exponent over its full UINT32 width.
28
28
 
29
29
  var ByteReader = require("./byte-reader");
30
+ var guard = require("./guard-all");
30
31
 
31
32
  var TPM_GENERATED_VALUE = 0xff544347;
32
33
  var TPM_ST_ATTEST_CERTIFY = 0x8017;
@@ -52,15 +53,47 @@ function _symDef(r) { if (r.u16() !== TPM_ALG.NULL) { r.u16(); r.u16(); } }
52
53
  // UINT16 hashAlg) follows for the signing/kdf schemes in scope.
53
54
  function _scheme(r) { if (r.u16() !== TPM_ALG.NULL) { r.u16(); } }
54
55
 
56
+ // TPMA_OBJECT bit positions (TPM 2.0 Part 2 sec. 8.3.2, Table 33). A data row set, not a switch:
57
+ // a bit index absent here is reserved by definition, and the reserved mask below is its complement
58
+ // over the positions Table 33 marks "shall be zero" (bits 0, 3, 15:12 and 31:20).
59
+ // Null-prototype: every one of these tables is indexed by a CALLER-SUPPLIED name, and an inherited
60
+ // member ("constructor", "toString", "__proto__") would resolve to a truthy non-value. In a lookup
61
+ // that decides whether a name is known, that reads as "known" and the caller's policy silently
62
+ // applies nothing at all -- a fail-open. Applied to all three tables here, not only the one where
63
+ // it was noticed.
64
+ var TPMA_OBJECT_BITS = Object.assign(Object.create(null), {
65
+ fixedTPM: 1, stClear: 2, fixedParent: 4, sensitiveDataOrigin: 5, userWithAuth: 6,
66
+ adminWithPolicy: 7, firmwareLimited: 8, svnLimited: 9, noDA: 10,
67
+ encryptedDuplication: 11, restricted: 16, decrypt: 17, sign: 18, x509sign: 19,
68
+ });
69
+ var TPMA_OBJECT_RESERVED = 0xfff0f009;
70
+
71
+ // _decodeAttributes(oa) -> the named booleans of a TPMA_OBJECT word.
72
+ // @enforced-by behavioral -- a bit-position table lookup has no rename-proof code shape; the RED
73
+ // vectors (each named bit read off a real attestation, and the sign-bit case) are the guard.
74
+ function _decodeAttributes(oa) {
75
+ var out = {};
76
+ Object.keys(TPMA_OBJECT_BITS).forEach(function (n) { out[n] = ((oa >>> TPMA_OBJECT_BITS[n]) & 1) === 1; });
77
+ return out;
78
+ }
79
+
55
80
  // parsePubArea(buf, E, code) -> the decoded TPMT_PUBLIC (WebAuthn 8.3 item 17-20).
56
81
  // @enforced-by behavioral -- a packed TPM structure decode has no rename-proof code shape;
57
82
  // the RED vectors (trailing bytes, an unsupported type, a truncated TPM2B) are the guard.
58
83
  function parsePubArea(buf, E, code) {
59
84
  var r = new ByteReader(buf, 0, buf.length, E, code);
60
85
  var type = r.u16(), nameAlg = r.u16();
61
- r.u32(); // objectAttributes (full policy validation deferred)
62
- r.vector(2, 0, null); // authPolicy TPM2B_DIGEST
63
- var pub = { type: type, nameAlg: nameAlg, nameAlgBytes: buf.subarray(2, 4) };
86
+ // objectAttributes (TPM 2.0 Part 2 sec. 8.3.2 Table 33) and authPolicy (Table 87) are SURFACED,
87
+ // not gated: WebAuthn sec. 8.3 constrains only the `parameters` and `unique` fields of pubArea,
88
+ // so enforcing these would invent a conformance rule. A relying party that wants them applies its
89
+ // own policy, which it cannot do to a field it never sees. The bytes are trustworthy by the time
90
+ // a caller reads them -- pubArea is hashed into certInfo.attested.name, which the AIK signature
91
+ // covers, and both are verified before the result is built.
92
+ var objectAttributes = r.u32();
93
+ var authPolicy = r.vector(2, 0, null); // a zero-length digest IS the Empty Policy, not "absent"
94
+ var pub = { type: type, nameAlg: nameAlg, nameAlgBytes: buf.subarray(2, 4),
95
+ objectAttributes: objectAttributes >>> 0, attributes: _decodeAttributes(objectAttributes),
96
+ authPolicy: authPolicy };
64
97
  if (type === TPM_ALG.RSA) {
65
98
  _symDef(r); // symmetric TPMT_SYM_DEF_OBJECT
66
99
  _scheme(r); // scheme TPMT_RSA_SCHEME
@@ -127,8 +160,148 @@ function pubKeyEqualsCose(pub, cose, E, mismatchCode, code) {
127
160
  throw new E(code, "unsupported TPM pubArea key type");
128
161
  }
129
162
 
163
+ // The one defined preset: the six TPMA_OBJECT bits every genuine attestation observed sets the same
164
+ // way. `decrypt`, `noDA` and `userWithAuth` are deliberately absent -- they DIFFER across real
165
+ // Windows Hello statements, so requiring any of them rejects working hardware. A second preset
166
+ // would be policy invention; a caller who wants different bits spells them out.
167
+ var TPM_POLICY_PROFILES = Object.assign(Object.create(null), {
168
+ "hardware-bound": { fixedTPM: true, fixedParent: true, sensitiveDataOrigin: true, sign: true, restricted: false, x509sign: false },
169
+ });
170
+ var _TPM_POLICY_KEYS = Object.assign(Object.create(null), { profile: 1, objectAttributes: 1, reservedBitsClear: 1, consistency: 1, authPolicy: 1 });
171
+
172
+ // Config-time validation of opts.tpmPolicy: a typo must never silently disable a check, so an
173
+ // unknown key or attribute name throws at the boundary rather than being ignored.
174
+ // @enforced-by behavioral -- an opts-shape validator has no rename-proof code shape; the RED
175
+ // vectors (unknown key, unknown profile, unknown attribute, non-boolean value, and the
176
+ // sensitiveDataOrigin-without-fixedTPM rule) are the guard.
177
+ function normalizeObjectAttributePolicy(policy, E, code) {
178
+ if (policy === undefined) return null;
179
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) throw new E(code, "opts.tpmPolicy must be an object");
180
+ Object.keys(policy).forEach(function (k) {
181
+ if (!_TPM_POLICY_KEYS[k]) throw new E(code, "opts.tpmPolicy has an unknown key " + JSON.stringify(k));
182
+ });
183
+ var want = {};
184
+ if (policy.profile !== undefined) {
185
+ var preset = TPM_POLICY_PROFILES[policy.profile];
186
+ if (!preset) throw new E(code, "opts.tpmPolicy.profile " + JSON.stringify(policy.profile) + " is not a defined profile");
187
+ Object.keys(preset).forEach(function (n) { want[n] = preset[n]; });
188
+ }
189
+ // An explicit map layers OVER the preset, so a caller can keep the profile and override one bit.
190
+ var explicit = policy.objectAttributes;
191
+ if (explicit !== undefined) {
192
+ if (!explicit || typeof explicit !== "object" || Array.isArray(explicit)) throw new E(code, "opts.tpmPolicy.objectAttributes must be an object");
193
+ Object.keys(explicit).forEach(function (n) {
194
+ if (TPMA_OBJECT_BITS[n] === undefined) throw new E(code, "opts.tpmPolicy.objectAttributes has an unknown attribute " + JSON.stringify(n));
195
+ if (typeof explicit[n] !== "boolean") throw new E(code, "opts.tpmPolicy.objectAttributes." + n + " must be a boolean");
196
+ want[n] = explicit[n];
197
+ });
198
+ }
199
+ // sec. 8.3.3.5 NOTE 1: sensitiveDataOrigin only asserts the TPM generated the key when fixedTPM
200
+ // is also SET -- otherwise the object could have been imported. Demanding it alone asserts
201
+ // nothing, so say so at config time rather than letting a caller believe it is protected.
202
+ if (want.sensitiveDataOrigin === true && want.fixedTPM !== true) {
203
+ throw new E(code, "opts.tpmPolicy requires sensitiveDataOrigin without fixedTPM; on its own it does not establish that the TPM generated the key (TPM 2.0 Part 2 sec. 8.3.3.5)");
204
+ }
205
+ ["reservedBitsClear", "consistency"].forEach(function (k) {
206
+ if (policy[k] !== undefined && typeof policy[k] !== "boolean") throw new E(code, "opts.tpmPolicy." + k + " must be a boolean");
207
+ });
208
+ var ap = policy.authPolicy;
209
+ var allow = null;
210
+ if (ap !== undefined) {
211
+ if (!ap || typeof ap !== "object" || Array.isArray(ap)) throw new E(code, "opts.tpmPolicy.authPolicy must be an object");
212
+ // The nested keys are enumerated for the same reason the top-level ones are: a misspelled
213
+ // `alow` would leave the allow-list unset, and the assertion would then impose no digest
214
+ // restriction at all -- the caller's policy silently doing nothing.
215
+ Object.keys(ap).forEach(function (k) {
216
+ if (k !== "present" && k !== "allow") throw new E(code, "opts.tpmPolicy.authPolicy has an unknown key " + JSON.stringify(k));
217
+ });
218
+ if (ap.present !== undefined && typeof ap.present !== "boolean") throw new E(code, "opts.tpmPolicy.authPolicy.present must be a boolean");
219
+ if (ap.allow !== undefined) {
220
+ if (!Array.isArray(ap.allow)) throw new E(code, "opts.tpmPolicy.authPolicy.allow must be an array");
221
+ // Decode every entry HERE, at config time, rather than inside the comparison. Node's hex
222
+ // decoder is permissive: it stops at the first character that is not a hex digit, so
223
+ // "<digest>zz" decodes back to the digest and a non-string decodes to an empty buffer --
224
+ // either of which could match a key this policy was written to exclude, including the
225
+ // Empty Policy. An entry that is not a Buffer or a canonical even-length hex string is a
226
+ // caller error, and it fails here rather than becoming a digest nobody intended.
227
+ allow = ap.allow.map(function (entry, i) {
228
+ if (Buffer.isBuffer(entry)) return entry;
229
+ if (typeof entry !== "string" || entry.length === 0 || entry.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(entry)) {
230
+ throw new E(code, "opts.tpmPolicy.authPolicy.allow[" + i + "] must be a Buffer or an even-length hex string");
231
+ }
232
+ return Buffer.from(entry, "hex");
233
+ });
234
+ }
235
+ }
236
+ return { objectAttributes: want, reservedBitsClear: policy.reservedBitsClear === true,
237
+ consistency: policy.consistency === true,
238
+ authPolicy: ap ? { present: ap.present === true, allow: allow } : null };
239
+ }
240
+
241
+ // Apply a normalized policy to a parsed pubArea. Nothing here is a WebAuthn sec. 8.3 requirement --
242
+ // that section constrains only pubArea's `parameters` and `unique` fields -- so every rule runs
243
+ // only because a caller asked for it by name.
244
+ // @enforced-by behavioral -- an opt-gated policy gate has no rename-proof code shape, and there is
245
+ // no general vector shape to detect: the RED vectors (each required bit in both directions, the
246
+ // reserved-bit mask across the sign bit, the restricted sign+decrypt combination, the over-long
247
+ // digest, the Empty Policy, and the allow-list miss) are the guard.
248
+ function assertObjectAttributePolicy(pub, policy, E, policyCode, structuralCode) {
249
+ if (!policy) return;
250
+ var oa = pub.objectAttributes >>> 0;
251
+ // Bit 31 lies inside the reserved mask, so the AND must be coerced back to unsigned: a bare
252
+ // `oa & 0xfff0f009` is a signed int32 and would read negative rather than "a bit is set".
253
+ if (policy.reservedBitsClear && ((oa & TPMA_OBJECT_RESERVED) >>> 0) !== 0) {
254
+ throw new E(structuralCode, "the TPMT_PUBLIC objectAttributes sets a reserved bit (TPM 2.0 Part 2 sec. 8.3.2 Table 33: shall be zero)");
255
+ }
256
+ if (policy.consistency) {
257
+ // sec. 8.3.3.11, verbatim: "This attribute shall not be SET in any object that has fixedTPM
258
+ // SET." A key that cannot leave its TPM cannot be duplicated, so requiring its duplication be
259
+ // encrypted describes an object that cannot exist.
260
+ if (pub.attributes.encryptedDuplication && pub.attributes.fixedTPM) {
261
+ throw new E(structuralCode, "the TPMT_PUBLIC objectAttributes sets encryptedDuplication on an object with fixedTPM SET (TPM 2.0 Part 2 sec. 8.3.3.11)");
262
+ }
263
+ // sec. 8.3.3.16/.17: a restricted key is a TPM-internal key, and the two use bits are a choice
264
+ // -- a restricted object that both signs and decrypts is not a defined combination.
265
+ if (pub.attributes.sign && pub.attributes.decrypt && pub.attributes.restricted) {
266
+ throw new E(structuralCode, "the TPMT_PUBLIC objectAttributes sets restricted with both sign and decrypt (TPM 2.0 Part 2 sec. 8.3.3)");
267
+ }
268
+ // A TPM2B_DIGEST carries at most a SHA-512 digest.
269
+ if (pub.authPolicy.length > 64) {
270
+ throw new E(structuralCode, "the TPMT_PUBLIC authPolicy is " + pub.authPolicy.length + " octets, above the 64-octet TPM2B_DIGEST maximum");
271
+ }
272
+ // The REMAINING sec. 8.3.3 "shall" statements are all parent-relative -- fixedTPM, stClear and
273
+ // encryptedDuplication are each constrained against the value the object's PARENT carries. An
274
+ // attestation presents one public area and no parent, so a verifier cannot evaluate them at
275
+ // all; they are not omitted by oversight, they are unverifiable from what is on the wire.
276
+ }
277
+ Object.keys(policy.objectAttributes).forEach(function (name) {
278
+ var want = policy.objectAttributes[name];
279
+ if (pub.attributes[name] !== want) {
280
+ throw new E(policyCode, "the TPM credential key has " + name + " " + (pub.attributes[name] ? "SET" : "CLEAR") +
281
+ "; opts.tpmPolicy requires it " + (want ? "SET" : "CLEAR"));
282
+ }
283
+ });
284
+ var ap = policy.authPolicy;
285
+ if (!ap) return;
286
+ if (ap.present === true && pub.authPolicy.length === 0) {
287
+ throw new E(policyCode, "the TPM credential key carries the Empty Policy; opts.tpmPolicy.authPolicy.present requires a policy digest");
288
+ }
289
+ if (ap.allow) {
290
+ // Every entry was decoded and validated at config time, so this compares digests only.
291
+ var got = pub.authPolicy;
292
+ var ok = ap.allow.some(function (want) {
293
+ return want.length === got.length && guard.crypto.constantTimeEqual(want, got);
294
+ });
295
+ if (!ok) throw new E(policyCode, "the TPM credential key authPolicy is not in opts.tpmPolicy.authPolicy.allow");
296
+ }
297
+ }
298
+
130
299
  module.exports = {
131
300
  parsePubArea: parsePubArea,
301
+ TPMA_OBJECT_BITS: TPMA_OBJECT_BITS,
302
+ decodeObjectAttributes: _decodeAttributes,
303
+ normalizeObjectAttributePolicy: normalizeObjectAttributePolicy,
304
+ assertObjectAttributePolicy: assertObjectAttributePolicy,
132
305
  parseCertInfo: parseCertInfo,
133
306
  pubKeyEqualsCose: pubKeyEqualsCose,
134
307
  TPM_ALG_HASH: TPM_ALG_HASH,
package/lib/webauthn.js CHANGED
@@ -8,8 +8,9 @@
8
8
  * @intro Trust evaluation of a W3C WebAuthn (Level 3) / passkey attestation: parse
9
9
  * the attestation object + authenticatorData, decode the COSE credential public
10
10
  * key, and verify each defined attestation-statement format (packed, tpm,
11
- * android-key, apple, fido-u2f, none, and android-safetynet behind an opt-in) --
12
- * the attestation-statement signature and each format's structural bindings. The attestation CBOR is decoded by the strict,
11
+ * android-key, apple, fido-u2f, none, compound, and android-safetynet behind an
12
+ * opt-in) -- the attestation-statement signature and each format's structural
13
+ * bindings. The attestation CBOR is decoded by the strict,
13
14
  * fail-closed `pki.cbor` codec (WebAuthn keys are CTAP2-canonical), the signature by
14
15
  * `pki.webcrypto`. Chaining the returned x5c trust path to a caller-pinned root via
15
16
  * `pki.path.validate` is the caller's step: this module verifies the statement, not
@@ -327,11 +328,16 @@ function parseAttestationObject(bytes) {
327
328
  // (WebAuthn 6.5.4); an extra top-level key is a non-canonical envelope, rejected.
328
329
  if (root.children.length !== 3 || !fmtN || !attStmtN || !authDataN) throw _err("webauthn/bad-attestation-object", "the attestation object must be exactly { fmt, attStmt, authData }");
329
330
  if (fmtN.majorType !== 3) throw _err("webauthn/bad-attestation-object", "attestation object 'fmt' must be a text string");
330
- // attStmt is the attestation statement, a CBOR map keyed by field name (WebAuthn 6.5.4);
331
- // a non-map attStmt is a malformed attestation OBJECT, rejected here rather than deferred
332
- // to a format verifier -- a non-map value (e.g. a CBOR array, whose children are single
333
- // nodes, not { key, value } pairs) must never reach the per-field statement walk.
334
- if (attStmtN.majorType !== 5) throw _err("webauthn/bad-attestation-object", "attestation object 'attStmt' must be a CBOR map");
331
+ // attStmt is the attestation statement, a CBOR map keyed by field name (WebAuthn 6.5.4) for every
332
+ // format but one: sec. 8.9 gives compound an ARRAY of nested statements. Which shape a format
333
+ // takes is a registry row, not a branch, so adding a format cannot silently widen the envelope
334
+ // for the others -- a non-map value (whose children are single nodes, not { key, value } pairs)
335
+ // must never reach the per-field statement walk of a format that expects a map.
336
+ var wantMajor = ATT_STMT_MAJOR[cbor.read.textString(fmtN)];
337
+ if (wantMajor === undefined) wantMajor = 5;
338
+ if (attStmtN.majorType !== wantMajor) {
339
+ throw _err("webauthn/bad-attestation-object", "attestation object 'attStmt' must be a CBOR " + (wantMajor === 4 ? "array" : "map") + " for format " + JSON.stringify(cbor.read.textString(fmtN)));
340
+ }
335
341
  if (authDataN.majorType !== 2) throw _err("webauthn/bad-attestation-object", "attestation object 'authData' must be a byte string");
336
342
  var authDataBytes = cbor.read.byteString(authDataN);
337
343
  return {
@@ -377,9 +383,24 @@ function _requireAttShape(attStmt, allowed, required) {
377
383
  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 + "'"); });
378
384
  required.forEach(function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
379
385
  }
386
+ // The CBOR major type each format's attStmt takes. Every format uses a map (5) except compound,
387
+ // whose sec. 8.9 syntax is an array of nested statements. A data row rather than a branch, so a
388
+ // future format declares its shape here instead of loosening the shared envelope check.
389
+ var ATT_STMT_MAJOR = { compound: 4 };
390
+
391
+ // One bound for every attestation certificate chain, wherever it arrives from -- an attStmt x5c
392
+ // array or a JWS x5c header. Capping the bytes of a single entry does not bound the COUNT, and the
393
+ // cost of an entry is a DER parse plus, downstream, a signature check or a path validation. Kept in
394
+ // one place so a new chain-bearing format cannot reintroduce the unbounded fanout.
395
+ function _requireX5cCount(n) {
396
+ if (n > constants.LIMITS.WEBAUTHN_X5C_MAX_CERTS) {
397
+ throw _err("webauthn/bad-att-stmt", "an attestation certificate chain carries " + n + " certificates, above the " + constants.LIMITS.WEBAUTHN_X5C_MAX_CERTS + " this toolkit will parse");
398
+ }
399
+ }
380
400
  function _readX5c(attStmt) {
381
401
  var x5cN = cbor.read.mapGet(attStmt, "x5c");
382
402
  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");
403
+ _requireX5cCount(x5cN.children.length);
383
404
  return x5cN.children.map(function (c) {
384
405
  var der;
385
406
  try { der = cbor.read.byteString(c); } catch (e) { throw _err("webauthn/bad-att-stmt", "an x5c entry must be a byte string", e); }
@@ -479,7 +500,10 @@ var VERIFIERS = {
479
500
 
480
501
  // tpm (WebAuthn 8.3): decode certInfo/pubArea, enforce magic/type/extraData/Name,
481
502
  // bind pubArea to the credential key, and verify sig over certInfo with the AIK.
482
- tpm: function (att, clientDataHash) {
503
+ tpm: function (att, clientDataHash, opts) {
504
+ // Config-time: a malformed or mistyped tpmPolicy is a caller error, caught before any parsing so
505
+ // a typo cannot silently disable the check the caller believes they enabled.
506
+ var tpmPolicy = validator.tpm.normalizeObjectAttributePolicy((opts || {}).tpmPolicy, WebauthnError, "webauthn/bad-input");
483
507
  _requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
484
508
  var verN = cbor.read.mapGet(att.attStmt, "ver");
485
509
  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)");
@@ -516,7 +540,15 @@ var VERIFIERS = {
516
540
  if (!ok) throw _err("webauthn/verify-failed", "the tpm attestation signature does not verify over certInfo under the AIK");
517
541
  _checkAikCert(aik); // 8.3.1
518
542
  _checkAaguidExt(aik, att.authData.aaguid); // 8.3.1: aaguid ext, if present, MUST match
519
- return _result("tpm", "AttCA", chain, att);
543
+ // The TPMT_PUBLIC object attributes and authPolicy are properties of the credential key that
544
+ // sec. 8.3 does not constrain -- it bounds only pubArea's `parameters` and `unique`. They are
545
+ // surfaced for relying-party policy, and gated only when the caller supplies opts.tpmPolicy.
546
+ // Applied AFTER the signature and Name checks, so the bytes being judged are ones the AIK
547
+ // signature already covers rather than attacker-chosen input.
548
+ validator.tpm.assertObjectAttributePolicy(pub, tpmPolicy, WebauthnError, "webauthn/tpm-policy", "webauthn/bad-tpm");
549
+ var tpmRes = _result("tpm", "AttCA", chain, att);
550
+ tpmRes.tpm = { objectAttributes: pub.objectAttributes, attributes: pub.attributes, authPolicy: pub.authPolicy };
551
+ return tpmRes;
520
552
  });
521
553
  },
522
554
 
@@ -572,6 +604,7 @@ var VERIFIERS = {
572
604
  if (!Array.isArray(header.x5c) || header.x5c.length === 0) {
573
605
  throw _err("webauthn/bad-att-stmt", "the android-safetynet JWS header carries no x5c certificate chain (RFC 7515 sec. 4.1.6)");
574
606
  }
607
+ _requireX5cCount(header.x5c.length);
575
608
  // x5c entries are STANDARD base64 (RFC 7515 sec. 4.1.6), not base64url like the segments.
576
609
  var chain = header.x5c.map(function (entry, i) {
577
610
  if (typeof entry !== "string") throw _err("webauthn/bad-att-stmt", "the android-safetynet x5c entry " + i + " is not a string");
@@ -636,6 +669,71 @@ var VERIFIERS = {
636
669
  });
637
670
  },
638
671
 
672
+ // compound (WebAuthn 8.9): the attStmt is an ARRAY of nested attestation statements, each
673
+ // verified over the SAME authenticatorData and clientDataHash as the outer object. sec. 8.9
674
+ // leaves the acceptance threshold to relying-party policy ("if validation fails for one or more
675
+ // subStmt, decide the appropriate result based on RP policy"); this toolkit's policy is
676
+ // fail-closed -- EVERY element must verify, because accepting a compound whose strong element
677
+ // failed and whose `none` element passed would let a wrapper launder a failed attestation.
678
+ compound: function (att, clientDataHash, opts) {
679
+ var kids = att.attStmt.children || [];
680
+ // sec. 8.9 syntax `2*`: at least two nested statements, or it is not a compound.
681
+ if (kids.length < 2) throw _err("webauthn/bad-att-stmt", "a compound attestation statement must carry at least two nested statements (WebAuthn 8.9)");
682
+ // Not a spec rule: a resource bound this toolkit chooses. The CBOR caps bound the PARSE; they
683
+ // do not bound the crypto, and each element costs a signature verify plus, for some formats, a
684
+ // certificate-chain path validation.
685
+ if (kids.length > constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS) {
686
+ throw _err("webauthn/bad-att-stmt", "a compound attestation statement carries " + kids.length + " nested statements, above the " + constants.LIMITS.WEBAUTHN_COMPOUND_MAX_STATEMENTS + " this toolkit will verify");
687
+ }
688
+ var elements = kids.map(function (el, i) {
689
+ // sec. 8.9: nonCompoundAttStmt = { $$attStmtType } -- each element is exactly { fmt, attStmt }.
690
+ if (!el || el.majorType !== 5) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be a CBOR map { fmt, attStmt } (WebAuthn 8.9)");
691
+ var fN = cbor.read.mapGet(el, "fmt"), sN = cbor.read.mapGet(el, "attStmt");
692
+ if (el.children.length !== 2 || !fN || !sN) throw _err("webauthn/bad-att-stmt", "compound element " + i + " must be exactly { fmt, attStmt } (WebAuthn 8.9)");
693
+ if (fN.majorType !== 3) throw _err("webauthn/bad-att-stmt", "compound element " + i + " 'fmt' must be a text string");
694
+ var f = cbor.read.textString(fN);
695
+ // sec. 8.9 spells the element type `.ne "compound"`: nesting is forbidden by the syntax
696
+ // itself, which is what fixes the evaluation depth at one. No depth parameter is needed --
697
+ // and adding one would imply a nesting this format does not have.
698
+ if (f === "compound") throw _err("webauthn/bad-att-stmt", "a compound attestation statement must not nest another compound (WebAuthn 8.9)");
699
+ // sec. 8.1: identifiers match case-sensitively, which the registry lookup already is.
700
+ var v = VERIFIERS[f];
701
+ if (!v) throw _err("webauthn/unsupported-format", "compound element " + i + " uses unsupported attestation statement format '" + f + "'");
702
+ var wantMajor = ATT_STMT_MAJOR[f] === undefined ? 5 : ATT_STMT_MAJOR[f];
703
+ if (sN.majorType !== wantMajor) throw _err("webauthn/bad-att-stmt", "compound element " + i + " 'attStmt' has the wrong CBOR shape for format '" + f + "'");
704
+ // Each element verifies against the OUTER authenticatorData: sec. 8.9 passes the same
705
+ // verification-procedure inputs down, so an element cannot bind a different credential.
706
+ return { fmt: f, index: i, att: { fmt: f, attStmt: sN, authData: att.authData, authDataBytes: att.authDataBytes } };
707
+ });
708
+ // Sequential, not Promise.all: a compound may hold many elements, and each can cost a
709
+ // signature verify plus a full path validation. Fanning them out concurrently would turn one
710
+ // attestation into a burst of crypto work.
711
+ var out = [];
712
+ return elements.reduce(function (p, e) {
713
+ return p.then(function () {
714
+ // The verifier is CALLED inside the promise chain, not evaluated as an argument to
715
+ // Promise.resolve: most arms do their structural checks synchronously, so an argument-
716
+ // position call would let those throws escape the handler below and reach the caller
717
+ // bare -- the same failure reported with the element's context or without it, depending
718
+ // only on whether it happened before or after the first await.
719
+ return Promise.resolve().then(function () { return VERIFIERS[e.fmt](e.att, clientDataHash, opts); })
720
+ .then(function (r) { out.push(r); }, function (err) {
721
+ throw _err("webauthn/compound-element-failed", "compound element " + e.index + " (format '" + e.fmt + "') did not verify", err);
722
+ });
723
+ });
724
+ }, Promise.resolve()).then(function () {
725
+ // sec. 8.9 lists the supported attestation type as "Any" and authorises returning
726
+ // "implementation-specific values representing any combination of outputs". A distinct type
727
+ // rather than a merge: collapsing to the strongest element would let a wrapper upgrade a
728
+ // caller's attestationType check, and collapsing to the weakest would spuriously fail one.
729
+ // The trust path is empty because two elements yield two independent chains and there is no
730
+ // single ordered path -- each element's own path is on its entry in `compound`.
731
+ var res = _result("compound", "Compound", [], att);
732
+ res.compound = out;
733
+ return res;
734
+ });
735
+ },
736
+
639
737
  // none (WebAuthn 8.7): the authenticator provides no attestation. attStmt MUST be
640
738
  // an empty map; there is no statement to verify, so the result carries no trust
641
739
  // path. The credential public key still binds via authenticatorData (AT flag).
@@ -789,9 +887,29 @@ function verify(attestationObject, clientDataHash, opts) {
789
887
  }
790
888
  var verifier = VERIFIERS[att.fmt];
791
889
  if (!verifier) return Promise.reject(_err("webauthn/unsupported-format", "attestation statement format '" + att.fmt + "' is not supported"));
890
+ // A policy about TPM key properties is checked INSIDE the tpm arm, so an attestation in any other
891
+ // format would never reach it and the policy would silently apply to nothing -- a caller who
892
+ // demanded a TPM-bound key would accept a `none` attestation instead. The requirement therefore
893
+ // belongs at the dispatch, where it can refuse a format that cannot satisfy it, not in the arm
894
+ // that only runs once that format was already chosen.
895
+ if (opts.tpmPolicy !== undefined && !_formatCanSatisfyTpmPolicy(att)) {
896
+ return Promise.reject(_err("webauthn/tpm-policy", "opts.tpmPolicy requires a TPM attestation, but this attestation is format '" + att.fmt + "', which carries no TPM public area"));
897
+ }
792
898
  return Promise.resolve().then(function () { return verifier(att, clientDataHash, opts); });
793
899
  }
794
900
 
901
+ // Only an attestation that actually carries a TPM public area can satisfy a TPM policy: the tpm
902
+ // format directly, or a compound holding at least one tpm element (whose own arm applies it).
903
+ function _formatCanSatisfyTpmPolicy(att) {
904
+ if (att.fmt === "tpm") return true;
905
+ if (att.fmt !== "compound") return false;
906
+ return (att.attStmt.children || []).some(function (el) {
907
+ if (!el || el.majorType !== 5) return false;
908
+ var fN = cbor.read.mapGet(el, "fmt");
909
+ return !!fN && fN.majorType === 3 && cbor.read.textString(fN) === "tpm";
910
+ });
911
+ }
912
+
795
913
  void constants;
796
914
 
797
915
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.4.8",
3
+ "version": "0.4.10",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
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:4a21ad89-1930-46c8-811e-74c422b23db6",
5
+ "serialNumber": "urn:uuid:d2b1e2eb-a5be-45c0-b69c-dd481476df78",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-08T23:49:55.172Z",
8
+ "timestamp": "2026-08-09T03:25:40.339Z",
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.4.8",
22
+ "bom-ref": "@blamejs/pki@0.4.10",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.4.8",
25
+ "version": "0.4.10",
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.4.8",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.4.10",
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.4.8",
57
+ "ref": "@blamejs/pki@0.4.10",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]