@blamejs/pki 0.2.19 → 0.2.20

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,22 @@ 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.20 — 2026-07-15
8
+
9
+ A WebAuthn attestation object whose attestation statement is not a CBOR map is now rejected with a typed webauthn/bad-attestation-object at parse instead of surfacing an untyped error from a format verifier, and the strict CBOR codec gains pki.cbor.read.mapGet -- a keyed map lookup that asserts the map's major type inside the accessor.
10
+
11
+ ### Added
12
+
13
+ - pki.cbor.read.mapGet(node, key) -- the keyed lookup over a decoded CBOR map (RFC 8949 major type 5). A text-string key matches text-string map keys; an integer key (a safe-integer number or a BigInt, as COSE labels are) matches integer map keys; matching never coerces across the two. It returns the value node, or null when the map has no such entry -- decode already enforced key uniqueness, so at most one entry can match. A non-map node throws cbor/unexpected-major, and a key that is neither a text string nor an integer throws a TypeError.
14
+
15
+ ### Changed
16
+
17
+ - A WebAuthn attestation object whose attStmt is not a CBOR map is now classified as a malformed attestation object -- webauthn/bad-attestation-object, thrown at parse for every attestation format -- where it previously surfaced as a per-format webauthn/bad-att-stmt or an untyped error depending on the CBOR type carried.
18
+
19
+ ### Fixed
20
+
21
+ - pki.webauthn.parseAttestationObject and pki.webauthn.verify no longer throw a raw TypeError when the attestation object's attStmt is a CBOR array: the attestation-statement field walk read the array's children as key/value pairs and dereferenced undefined. The attestation object's attStmt shape is now validated at parse (WebAuthn sec. 6.5.4), and the statement walk reads its pairs through pki.cbor.read.map, which asserts the major type -- malformed hostile bytes are a typed webauthn/* verdict, never an untyped crash.
22
+
7
23
  ## v0.2.19 — 2026-07-14
8
24
 
9
25
  The RFC 3161 Time-Stamp Protocol surface is complete: pki.tsp.request and pki.tsp.response build and parse the protocol's request and response messages, and pki.tsp.verify verifies a timestamp token end to end -- the CMS signature, the message imprint, the ESSCertID(V2) certificate binding, the critical timeStamping-only extendedKeyUsage, and full validation of the TSA certificate at the token's own genTime.
package/README.md CHANGED
@@ -199,7 +199,7 @@ is callable today; nothing below is a stub.
199
199
  | Namespace | What it does |
200
200
  |---|---|
201
201
  | `pki.asn1` | Strict, bounded DER codec — `decode` (zero-copy node tree), `encode`, `build.*` canonical-DER value builders, `read.*` typed readers, `TAGS`, OID-content encode/decode |
202
- | `pki.cbor` | Strict, bounded RFC 8949 deterministic CBOR codec — `decode` (zero-copy node tree) + `read.*` typed leaf readers, fail-closed on every non-canonical shape (indefinite length, non-minimal argument, unsorted / duplicate map keys, non-shortest float, trailing bytes) |
202
+ | `pki.cbor` | Strict, bounded RFC 8949 deterministic CBOR codec — `decode` (zero-copy node tree) + `read.*` typed leaf readers incl. the keyed map lookup `read.mapGet` (text or COSE-label integer key, the map's major type asserted in the accessor), fail-closed on every non-canonical shape (indefinite length, non-minimal argument, unsorted / duplicate map keys, non-shortest float, trailing bytes) |
203
203
  | `pki.oid` | Two-way OID ↔ name registry — `name`, `byName`, `register`, `toArcs`/`fromArcs`, `toDER`/`fromDER`; seeded with RFC 5280 + NIST PQC arcs |
204
204
  | `pki.webcrypto` | A W3C WebCrypto (`SubtleCrypto`) engine over `node:crypto` — `sign`/`verify`/`encrypt`/`decrypt`/`deriveBits`/`digest`/`generateKey`/`importKey`/`exportKey` across RSA, ECDSA, ECDH, Ed25519/Ed448, AES, HMAC, HKDF, PBKDF2, SHA — **and** post-quantum ML-DSA-44/65/87 and SLH-DSA signatures, plus ML-KEM-512/768/1024 key generation (KEM encapsulation on the roadmap). Zero-dependency, OpenSSL-interoperable |
205
205
  | `pki.schema` | The schema family — `parse` detects which PKI format DER / PEM encodes and routes to the right parser, `all` enumerates the registered formats, and the engine + per-format members are grouped here |
package/lib/cbor-det.js CHANGED
@@ -415,6 +415,52 @@ function readMap(node) {
415
415
  return node.children;
416
416
  }
417
417
 
418
+ /**
419
+ * @primitive pki.cbor.read.mapGet
420
+ * @signature pki.cbor.read.mapGet(node, key) -> valueNode | null
421
+ * @since 0.2.20
422
+ * @status experimental
423
+ * @spec RFC 8949 sec. 3.1 (major type 5)
424
+ *
425
+ * The value node of the map entry whose key equals `key` -- a text string
426
+ * (matched against text-string keys) or an integer (a safe-integer number or a
427
+ * BigInt, matched against integer keys, as COSE labels are) -- or null when the
428
+ * map has no such entry. Key matching never coerces across types: a text key
429
+ * only matches a text-string key node, an integer key only an integer key node.
430
+ * At most one entry can match -- decode already enforced key uniqueness. This is
431
+ * the single keyed-lookup home over a decoded map: a consumer composes it (or
432
+ * `read.map`) rather than walking `children` as pairs, so a lookup can never
433
+ * pair-index a non-map's children (single nodes, whose pair index reads
434
+ * undefined). Throws `cbor/unexpected-major` when `node` is not a map -- a
435
+ * lookup on a non-map is malformed input, never an absent-entry verdict -- and
436
+ * a TypeError for a key that is neither a text string nor an integer.
437
+ *
438
+ * @example
439
+ * pki.cbor.read.mapGet(node, "fmt"); // -> valueNode | null
440
+ * pki.cbor.read.mapGet(node, 3); // a COSE alg label -> valueNode | null
441
+ */
442
+ function readMapGet(node, key) {
443
+ _expectMajor(node, 5, "read.mapGet");
444
+ var wantText = typeof key === "string";
445
+ if (!wantText) {
446
+ if (typeof key === "number") {
447
+ if (!Number.isSafeInteger(key)) throw new TypeError("read.mapGet: a numeric key must be a safe integer");
448
+ key = BigInt(key);
449
+ } else if (typeof key !== "bigint") {
450
+ throw new TypeError("read.mapGet: key must be a text string or an integer");
451
+ }
452
+ }
453
+ for (var i = 0; i < node.children.length; i++) {
454
+ var k = node.children[i][0];
455
+ if (wantText) {
456
+ if (k.majorType === 3 && readTextString(k) === key) return node.children[i][1];
457
+ } else if ((k.majorType === 0 || k.majorType === 1) && readInt(k) === key) {
458
+ return node.children[i][1];
459
+ }
460
+ }
461
+ return null;
462
+ }
463
+
418
464
  /**
419
465
  * @primitive pki.cbor.read.boolean
420
466
  * @signature pki.cbor.read.boolean(node) -> false
@@ -550,6 +596,8 @@ function readTime(node) {
550
596
  }
551
597
  var ns = Number(secs);
552
598
  var d = new Date(ns < 0 ? -constants.TIME.seconds(-ns) : constants.TIME.seconds(ns));
599
+ // Coverage residual -- the epoch-window bound above already rejects any value the
600
+ // Date constructor cannot represent, so this backstop is defense-in-depth.
553
601
  if (isNaN(d.getTime())) throw new CborError("cbor/bad-time", "epoch time out of range");
554
602
  return d;
555
603
  }
@@ -586,6 +634,7 @@ module.exports = {
586
634
  textString: readTextString,
587
635
  array: readArray,
588
636
  map: readMap,
637
+ mapGet: readMapGet,
589
638
  boolean: readBoolean,
590
639
  nullValue: readNull,
591
640
  undefinedValue: readUndefined,
@@ -64,19 +64,6 @@ var ALG_PROFILE = {
64
64
  "-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 }, "-37": { kty: 3 }, "-65535": { kty: 3 },
65
65
  };
66
66
 
67
- // The value node of an integer-labelled COSE_Key parameter (labels are ints), or null.
68
- function _mapGetInt(node, key) {
69
- // Coverage residual -- credentialKey validates node is a majorType-5 map (before any
70
- // _mapGetInt call), and cbor-det always gives a map a (truthy) children array, so this
71
- // guard cannot fire.
72
- if (!node || node.majorType !== 5 || !node.children) return null;
73
- for (var i = 0; i < node.children.length; i++) {
74
- var k = node.children[i][0];
75
- if ((k.majorType === 0 || k.majorType === 1) && cbor.read.int(k) === BigInt(key)) return node.children[i][1];
76
- }
77
- return null;
78
- }
79
-
80
67
  // credentialKey(node, E, code) -> the decoded + validated credential public key
81
68
  // { kty, alg, crv?, x?, y?, n?, e? }, or throws new E(code, ...). The complete COSE_Key
82
69
  // conformance gate for a WebAuthn credential key; a format module MUST route a credential
@@ -90,9 +77,9 @@ function credentialKey(node, E, code) {
90
77
  if (!node || node.majorType !== 5) throw bad("a COSE_Key must be a CBOR map (RFC 9052 sec. 7)");
91
78
  // Every parameter read maps a wrong-type cbor/* fault to the caller's domain -- a
92
79
  // wrong-typed COSE label (x as an integer, kty as a string) is bad input, not a leak.
93
- function ib(label) { var n = _mapGetInt(node, label); if (!n) return null; try { return cbor.read.byteString(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be a byte string", e); } }
94
- function ii(label) { var n = _mapGetInt(node, label); if (!n) return null; try { return cbor.read.int(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be an integer", e); } }
95
- var ktyN = _mapGetInt(node, 1), algN = _mapGetInt(node, 3);
80
+ function ib(label) { var n = cbor.read.mapGet(node, label); if (!n) return null; try { return cbor.read.byteString(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be a byte string", e); } }
81
+ function ii(label) { var n = cbor.read.mapGet(node, label); if (!n) return null; try { return cbor.read.int(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be an integer", e); } }
82
+ var ktyN = cbor.read.mapGet(node, 1), algN = cbor.read.mapGet(node, 3);
96
83
  if (!ktyN) throw bad("a COSE_Key is missing the kty (label 1) parameter");
97
84
  if (!algN) throw bad("a COSE_Key is missing the alg (label 3) parameter");
98
85
  var kty, algv;
@@ -103,7 +90,7 @@ function credentialKey(node, E, code) {
103
90
  key.crv = ii(-1) != null ? Number(ii(-1)) : null;
104
91
  // WebAuthn EC2 credential keys MUST use the uncompressed point form: y (-3) is the full
105
92
  // y-coordinate byte string, never a CBOR bool sign bit (WebAuthn sec. alg identifier).
106
- var yNode = _mapGetInt(node, -3);
93
+ var yNode = cbor.read.mapGet(node, -3);
107
94
  if (yNode && yNode.majorType === 7) throw bad("an EC2 credential key must use the uncompressed point form (a compressed y sign-bit is not permitted for WebAuthn)");
108
95
  key.x = ib(-2); key.y = ib(-3);
109
96
  if (key.crv == null || !key.x || !key.y) throw bad("an EC2 COSE_Key must carry crv (-1), x (-2), and y (-3)");
package/lib/webauthn.js CHANGED
@@ -75,20 +75,6 @@ function _coseAlgHash(alg, E) {
75
75
  return h;
76
76
  }
77
77
 
78
- // ---- CBOR map access over the strict pki.cbor node tree ----------------------
79
-
80
- // A decoded CBOR map is a node whose `children` is an array of [keyNode, valNode].
81
- // Look a text-keyed entry up, or null. The strict codec already rejected a
82
- // duplicate map key, so at most one match exists.
83
- function _mapGet(node, key) {
84
- if (!node || node.majorType !== 5 || !node.children) return null;
85
- for (var i = 0; i < node.children.length; i++) {
86
- var k = node.children[i][0];
87
- if (k.majorType === 3 && cbor.read.textString(k) === key) return node.children[i][1];
88
- }
89
- return null;
90
- }
91
-
92
78
  // ---- authenticatorData bounded reader (WebAuthn sec. 6.1) --------------------
93
79
 
94
80
  // authData = rpIdHash[32] || flags[1] || signCount[4 BE] || (AT? attestedCredentialData) || (ED? extensions CBOR).
@@ -333,11 +319,16 @@ function parseAttestationObject(bytes) {
333
319
  var root;
334
320
  try { root = cbor.decode(bytes); } catch (e) { throw _err("webauthn/bad-attestation-object", "the attestation object is not well-formed CBOR", e); }
335
321
  if (root.majorType !== 5) throw _err("webauthn/bad-attestation-object", "the attestation object must be a CBOR map { fmt, attStmt, authData }");
336
- var fmtN = _mapGet(root, "fmt"), attStmtN = _mapGet(root, "attStmt"), authDataN = _mapGet(root, "authData");
322
+ var fmtN = cbor.read.mapGet(root, "fmt"), attStmtN = cbor.read.mapGet(root, "attStmt"), authDataN = cbor.read.mapGet(root, "authData");
337
323
  // The attestation object is EXACTLY { fmt, attStmt, authData } -- no more, no fewer
338
324
  // (WebAuthn 6.5.4); an extra top-level key is a non-canonical envelope, rejected.
339
325
  if (root.children.length !== 3 || !fmtN || !attStmtN || !authDataN) throw _err("webauthn/bad-attestation-object", "the attestation object must be exactly { fmt, attStmt, authData }");
340
326
  if (fmtN.majorType !== 3) throw _err("webauthn/bad-attestation-object", "attestation object 'fmt' must be a text string");
327
+ // attStmt is the attestation statement, a CBOR map keyed by field name (WebAuthn 6.5.4);
328
+ // a non-map attStmt is a malformed attestation OBJECT, rejected here rather than deferred
329
+ // to a format verifier -- a non-map value (e.g. a CBOR array, whose children are single
330
+ // nodes, not { key, value } pairs) must never reach the per-field statement walk.
331
+ if (attStmtN.majorType !== 5) throw _err("webauthn/bad-attestation-object", "attestation object 'attStmt' must be a CBOR map");
341
332
  if (authDataN.majorType !== 2) throw _err("webauthn/bad-attestation-object", "attestation object 'authData' must be a byte string");
342
333
  var authDataBytes = cbor.read.byteString(authDataN);
343
334
  return {
@@ -351,7 +342,7 @@ function parseAttestationObject(bytes) {
351
342
  // ---- attestation-format verifiers -------------------------------------------
352
343
 
353
344
  function _reqAttr(map, key) {
354
- var n = _mapGet(map, key);
345
+ var n = cbor.read.mapGet(map, key);
355
346
  if (!n) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + key + "' field");
356
347
  return n;
357
348
  }
@@ -370,7 +361,11 @@ function _sigOf(attStmt) { return _attRead(attStmt, "sig", cbor.read.byteString,
370
361
  // non-canonical statement, rejected before any field is trusted (WebAuthn sec. 8.*).
371
362
  function _requireAttShape(attStmt, allowed, required) {
372
363
  var have = {};
373
- if (attStmt && attStmt.children) attStmt.children.forEach(function (kv) {
364
+ // attStmt is the attestation-statement map (parseAttestationObject established it is a CBOR
365
+ // map). cbor.read.map asserts the major type and yields the { key, value } pairs -- never a
366
+ // raw .children walk, whose entries are single nodes (not pairs) for a non-map, so a pair
367
+ // index would read undefined and throw a raw TypeError instead of failing closed.
368
+ cbor.read.map(attStmt).forEach(function (kv) {
374
369
  // attStmt keys are text strings; a non-text key is a malformed statement,
375
370
  // rejected (not silently skipped, which would evade the unexpected-field check).
376
371
  if (kv[0].majorType !== 3) throw _err("webauthn/bad-att-stmt", "the attestation statement has a non-text-string field key");
@@ -380,7 +375,7 @@ function _requireAttShape(attStmt, allowed, required) {
380
375
  required.forEach(function (k) { if (!have[k]) throw _err("webauthn/bad-att-stmt", "the attestation statement is missing the '" + k + "' field"); });
381
376
  }
382
377
  function _readX5c(attStmt) {
383
- var x5cN = _mapGet(attStmt, "x5c");
378
+ var x5cN = cbor.read.mapGet(attStmt, "x5c");
384
379
  if (!x5cN || x5cN.majorType !== 4 || !x5cN.children || !x5cN.children.length) throw _err("webauthn/bad-att-stmt", "x5c must be a non-empty array of certificates");
385
380
  return x5cN.children.map(function (c) {
386
381
  var der;
@@ -401,7 +396,7 @@ function _checkAaguidExt(cert, aaguid) { validator.attcert.aaguidExt(cert, aagui
401
396
  var VERIFIERS = {
402
397
  // packed (WebAuthn 8.2): the x5c arm (Basic/AttCA) or self-attestation.
403
398
  packed: function (att, clientDataHash) {
404
- var isX5c = !!_mapGet(att.attStmt, "x5c");
399
+ var isX5c = !!cbor.read.mapGet(att.attStmt, "x5c");
405
400
  _requireAttShape(att.attStmt, isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"], isX5c ? ["alg", "sig", "x5c"] : ["alg", "sig"]);
406
401
  var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
407
402
  var message = Buffer.concat([att.authDataBytes, clientDataHash]);
@@ -483,7 +478,7 @@ var VERIFIERS = {
483
478
  // bind pubArea to the credential key, and verify sig over certInfo with the AIK.
484
479
  tpm: function (att, clientDataHash) {
485
480
  _requireAttShape(att.attStmt, ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"], ["ver", "alg", "sig", "certInfo", "pubArea", "x5c"]);
486
- var verN = _mapGet(att.attStmt, "ver");
481
+ var verN = cbor.read.mapGet(att.attStmt, "ver");
487
482
  if (!verN || verN.majorType !== 3 || cbor.read.textString(verN) !== "2.0") throw _err("webauthn/bad-att-stmt", "tpm attestation 'ver' MUST be \"2.0\" (WebAuthn 8.3)");
488
483
  var alg = _algOf(att.attStmt), sig = _sigOf(att.attStmt);
489
484
  var pubAreaBytes = _attRead(att.attStmt, "pubArea", cbor.read.byteString, "a byte string");
@@ -526,9 +521,9 @@ var VERIFIERS = {
526
521
  // an empty map; there is no statement to verify, so the result carries no trust
527
522
  // path. The credential public key still binds via authenticatorData (AT flag).
528
523
  none: function (att) {
529
- // attStmt MUST BE an empty CBOR map -- reject a missing, non-map, or non-empty
530
- // attStmt, not only a non-empty map (WebAuthn 8.7).
531
- if (!att.attStmt || att.attStmt.majorType !== 5 || (att.attStmt.children && att.attStmt.children.length !== 0)) {
524
+ // parseAttestationObject has already established attStmt is a CBOR map; the none format
525
+ // additionally requires it be EMPTY -- there is no statement to verify (WebAuthn 8.7).
526
+ if (att.attStmt.children.length !== 0) {
532
527
  throw _err("webauthn/bad-att-stmt", "the none attestation statement MUST be an empty map (WebAuthn 8.7)");
533
528
  }
534
529
  return Promise.resolve(_result("none", "None", [], att));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.19",
3
+ "version": "0.2.20",
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:d6e0649c-f4db-4241-8784-6f3c9d3d663f",
5
+ "serialNumber": "urn:uuid:2c42308d-4c79-437b-8bf2-36089abf8898",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-14T16:14:40.970Z",
8
+ "timestamp": "2026-07-15T22:55:37.673Z",
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.19",
22
+ "bom-ref": "@blamejs/pki@0.2.20",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.19",
25
+ "version": "0.2.20",
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.19",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.2.20",
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.19",
57
+ "ref": "@blamejs/pki@0.2.20",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]