@blamejs/pki 0.2.18 → 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,36 @@ 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
+
23
+ ## v0.2.19 — 2026-07-14
24
+
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.
26
+
27
+ ### Added
28
+
29
+ - pki.tsp.request builds an RFC 3161 TimeStampReq around a message imprint, with the optional nonce, requested TSA policy, certReq, and extensions -- canonical DER, the DEFAULT-FALSE certReq omitted unless true -- and pki.tsp.parseRequest parses one (a new TimeStampReq decoder, also exposed as pki.schema.tsp.parseRequest). pki.tsp.response builds a TimeStampResp -- a granted status wrapping the token pki.tsp.sign produces, or a rejection carrying a PKIStatus, status text, and PKIFailureInfo names -- and pki.tsp.parseResponse parses one; the section 2.4.2 status-to-token coupling (a granted response carries a token, any other status must not) is enforced on build and parse alike. These are the byte payloads an RFC 3161 transport carries, completing the protocol message surface around pki.tsp.sign and pki.schema.tsp.parseToken.
30
+ - pki.tsp.verify(token, data, opts) verifies a timestamp token and returns a verdict carrying the verified genTime, serial number, and TSTInfo fields. It checks the CMS signature over the exact signed bytes, recomputes the message imprint from the supplied data (or compares a precomputed imprint), requires the encapsulated content be a TSTInfo, binds the token to the TSA certificate by recomputing the ESSCertID(V2) certificate hash (RFC 5816) and matching its issuerSerial when present, and enforces RFC 3161 section 2.3 on the TSA certificate: its extendedKeyUsage must be present, critical, and contain exactly id-kp-timeStamping, and -- when the certificate asserts a keyUsage -- that keyUsage must permit signing, so a certificate not issued for timestamping cannot mint a trusted token. When a trust anchor is supplied, the TSA certificate chain -- ordered from the token's embedded certificates, so a TSA under an intermediate CA validates -- receives full certification-path validation at the token's genTime, including optional revocation; when the request carried a nonce, the token must echo it. Every checked field is read from the verified encapsulated content, never a caller-supplied parsed object; a well-formed token failing any check is a fail-closed { valid: false } verdict with a stable reason code.
31
+
32
+ ### Fixed
33
+
34
+ - CMS signer-certificate lookup now matches the certificate's issuer name in addition to its serial number when a signer is identified by issuerAndSerialNumber (RFC 5652); the issuer comparison was previously inert, so a signer was located by serial number alone. The verification verdict is unchanged -- the signature check remains the authority -- but the correct signer certificate is now selected precisely.
35
+ - Malformed input to several verifiers now fails closed with a typed pki.errors.PkiError instead of a raw TypeError: a signer or issuer distinguished name carrying an embedded control byte (the RFC 5280 section 7.1 name comparison, CVE-2009-2408) and oversized or malformed JSON are rejected with a domain error code across pki.cms.verify, pki.tsp.verify, pki.jose, pki.sigstore, and pki.webcrypto key import.
36
+
7
37
  ## v0.2.18 — 2026-07-14
8
38
 
9
39
  Composite ML-DSA signatures join CMS SignedData: pki.cms.sign and pki.cms.verify now produce and verify a composite SignerInfo -- a post-quantum ML-DSA paired with a traditional RSA, ECDSA, or EdDSA -- accepted only when BOTH components verify.
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 |
@@ -209,7 +209,7 @@ is callable today; nothing below is a stub.
209
209
  | `pki.schema.pkcs8` | Parse DER / PEM PKCS#8 private keys per RFC 5208 / 5958 — algorithm, raw key bytes, attributes, optional public key, fail-closed; encrypted keys recognized (not decrypted) — `parse`, `parseEncrypted`, `pemDecode`, `pemEncode` |
210
210
  | `pki.schema.cms` | Parse DER / PEM CMS per RFC 5652 / 5083 / 9629 — SignedData (§5, signer infos + raw signed-attribute bytes for external verification), EnvelopedData (§6, all five RecipientInfo kinds incl. RFC 5753 key-agreement and RFC 9629 KEM recipients with ML-KEM validation), EncryptedData (§8), AuthenticatedData (§9, MAC surface + raw `authAttrsBytes`), and AuthEnvelopedData (RFC 5083, with RFC 5084 AES-GCM/CCM parameter validation); §11 attribute placement enforced, countersignatures validated recursively, certificates / CRLs validated against the closed CHOICE sets and kept raw, every result tagged `contentTypeName`, fail-closed — `parse`, `pemDecode`, `pemEncode` |
211
211
  | `pki.schema.ocsp` | Parse DER / PEM OCSP requests and responses per RFC 6960 — per-certificate status (good / revoked / unknown), responder identity, raw tbs bytes for external verification, certificates kept raw, fail-closed; non-basic response types recognized (not decoded) — `parseRequest`, `parseResponse`, `pemDecode`, `pemEncode` |
212
- | `pki.schema.tsp` | Parse DER / PEM RFC 3161 timestamp responses and tokens — the TSTInfo payload (imprint, genTime with sub-second precision, serial, nonce, accuracy), the status-to-token coupling, and the token wrapper composed over CMS with the single-signer rule, fail-closed — `parse`, `parseTstInfo`, `parseToken`, `pemDecode`, `pemEncode` |
212
+ | `pki.schema.tsp` | Parse DER / PEM RFC 3161 timestamp requests, responses, and tokens — the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), the TSTInfo payload (imprint, genTime with sub-second precision, serial, nonce, accuracy), the status-to-token coupling, and the token wrapper composed over CMS with the single-signer rule, fail-closed — `parse`, `parseRequest`, `parseResponse`, `parseTstInfo`, `parseToken`, `pemDecode`, `pemEncode` |
213
213
  | `pki.schema.attrcert` | Parse DER / PEM RFC 5755 attribute certificates — the holder and issuer identities (validated GeneralNames), the validity window (real `Date`s), the privilege attributes (role / group / clearance) and extensions, with the raw signed region for a verifier; the obsolete v1 form is recognized and deferred, fail-closed — `parse`, `pemDecode`, `pemEncode` |
214
214
  | `pki.schema.crmf` | Parse DER / PEM RFC 4211 certificate request messages (CertReqMessages — the CMP / EST enrollment body) — the requested-certificate template (subject, public key, validity, extensions), proof-of-possession, and registration controls, with the raw `CertRequest` region a POP verifier hashes; names dual-accepted (IMPLICIT and EXPLICIT), fail-closed — `parse`, `pemDecode`, `pemEncode` |
215
215
  | `pki.schema.pkcs12` | Parse DER / BER / PEM RFC 7292 PKCS#12 (PFX) stores — key bags via the PKCS#8 parser, shrouded keys (algorithm surfaced, ciphertext opaque), cert / CRL / secret bags raw and byte-exact, encrypted and enveloped safes structurally via CMS, `friendlyName` / `localKeyId` decoded, and the exact MAC byte range (`macedBytes`) plus RFC 9579 PBMAC1 recognition for external verification; BER accepted exactly where §4.1 requires it, fail-closed — `parse`, `pemDecode`, `pemEncode` |
@@ -222,7 +222,7 @@ is callable today; nothing below is a stub.
222
222
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
223
223
  | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
224
224
  | `pki.cms` | RFC 5652 §5 CMS SignedData signing + signature verification — `sign(content, signers, opts)` produces a SignedData (attached or detached, one or many signers, RSA / RSASSA-PSS / ECDSA / EdDSA, the post-quantum ML-DSA-44/65/87 (RFC 9882) and SLH-DSA (all twelve FIPS 205 sets, RFC 9814), and composite ML-DSA (pairing ML-DSA with a traditional RSA / ECDSA / EdDSA — accepted only when **both** components verify — draft-ietf-lamps-cms-composite-sigs)); it builds the signed attributes (content-type, message-digest, signing-time) as canonical DER, signs the exact §5.4 preimage, and emits a DER `Buffer` or PEM. `verify(input, opts)` parses a SignedData over the strict `pki.schema.cms` codec, locates each SignerInfo's signer certificate by its issuerAndSerialNumber or subjectKeyIdentifier, and checks the signature over the exact §5.4 preimage: when signed attributes are present it confirms the message-digest attribute equals the content digest and verifies over the DER re-encoding of the SignedAttributes (the on-wire `[0]` tag replaced by a universal SET OF), otherwise directly over the content. It returns a per-signer verdict with the matched signer certificate; it does not chain that certificate to a trust anchor — that is the caller's step through `pki.path.validate`. Fail-closed with typed `cms/*` errors — `sign`, `verify` |
225
- | `pki.tsp` | RFC 3161 timestamp token creation — `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData (over `pki.cms.sign`) whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` (with optional accuracy / nonce / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate. The producing side of `pki.schema.tsp.parseToken`; SHA-2 imprints, and any `pki.cms.sign` TSA key — `sign` |
225
+ | `pki.tsp` | RFC 3161 Time-Stamp Protocol — `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData (over `pki.cms.sign`) whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` (with optional accuracy / nonce / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and with a trust anchor supplied — full certification-path validation of the TSA certificate at the token's `genTime`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
226
226
  | `pki.ct` | RFC 6962 Certificate Transparency SCTs — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, routing an ECDSA signature through the strict DER-conformance gate, and verifying through the crypto engine — resolving true or false, and throwing a typed error on a structural fault. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct` |
227
227
  | `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
228
228
  | `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
@@ -343,7 +343,7 @@ SCTs), `pki.hpke` (RFC 9180), `pki.shbs` (HSS/LMS stateful hash signatures),
343
343
  `pki.merkle` (RFC 9162 transparency proofs), `pki.sigstore` (offline npm-provenance
344
344
  verification), `pki.webauthn` (WebAuthn / passkey attestation verification),
345
345
  `pki.cms` (RFC 5652 SignedData signing + signature verification), `pki.tsp` (RFC 3161
346
- timestamp token creation), and the
346
+ timestamping — requests, responses, token creation and verification), and the
347
347
  `jose` / `acme` / `est` enrollment surfaces. Each composes
348
348
  the shared structure, foundation, and crypto layers directly. Alongside the schema
349
349
  engine, the fail-closed **guard family** (`guard-*`) centralizes each CVE-class
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,
package/lib/cms-verify.js CHANGED
@@ -120,7 +120,7 @@ function _findSignerCerts(sid, parsedCerts) {
120
120
  if (sid.subjectKeyIdentifier != null) {
121
121
  if (c.ski && c.ski.equals(_toBuf(sid.subjectKeyIdentifier, "sid.subjectKeyIdentifier"))) out.push(c);
122
122
  } else if (sid.issuer && sid.serialNumberHex != null) {
123
- if (c.cert.serialNumberHex === sid.serialNumberHex && guard.name.dnEqual(c.cert.issuer, sid.issuer, CmsError, "cms/bad-name")) out.push(c);
123
+ if (c.cert.serialNumberHex === sid.serialNumberHex && guard.name.dnEqual(c.cert.issuer.rdns, sid.issuer.rdns, _err, "cms/bad-name", "the signer certificate issuer")) out.push(c);
124
124
  }
125
125
  }
126
126
  return out;
package/lib/jose.js CHANGED
@@ -117,7 +117,7 @@ function parseJson(input) {
117
117
  // The strict bounded reader (byte + depth caps, duplicate-member reject at every
118
118
  // depth, __proto__-safe own-property assignment, fatal UTF-8, RFC 8259 grammar)
119
119
  // lives once in the shared JSON guard; the frozen jose/* codes are threaded through.
120
- return guard.json.parse(input, JoseError, {
120
+ return guard.json.parse(input, E, {
121
121
  maxBytes: LIMITS.JSON_MAX_BYTES, maxDepth: LIMITS.JSON_MAX_DEPTH,
122
122
  badJson: "jose/bad-json", tooDeep: "jose/too-deep", duplicateMember: "jose/duplicate-member",
123
123
  tooLarge: "jose/too-large", badInput: "jose/bad-input", label: "the JSON document",
package/lib/schema-all.js CHANGED
@@ -301,7 +301,7 @@ module.exports = {
301
301
  pkcs12: { parse: pkcs12.parse, pemDecode: pkcs12.pemDecode, pemEncode: pkcs12.pemEncode },
302
302
  cms: { parse: cms.parse, pemDecode: cms.pemDecode, pemEncode: cms.pemEncode },
303
303
  ocsp: { parseRequest: ocsp.parseRequest, parseResponse: ocsp.parseResponse, pemDecode: ocsp.pemDecode, pemEncode: ocsp.pemEncode },
304
- tsp: { parse: tsp.parse, parseTstInfo: tsp.parseTstInfo, parseToken: tsp.parseToken, pemDecode: tsp.pemDecode, pemEncode: tsp.pemEncode },
304
+ tsp: { parse: tsp.parse, parseResponse: tsp.parseResponse, parseRequest: tsp.parseRequest, parseTstInfo: tsp.parseTstInfo, parseToken: tsp.parseToken, pemDecode: tsp.pemDecode, pemEncode: tsp.pemEncode },
305
305
  crmf: { parse: crmf.parse, pemDecode: crmf.pemDecode, pemEncode: crmf.pemEncode },
306
306
  cmp: { parse: cmp.parse, pemDecode: cmp.pemDecode, pemEncode: cmp.pemEncode },
307
307
  csrattrs: { parse: csrattrs.parse },
package/lib/schema-tsp.js CHANGED
@@ -272,6 +272,64 @@ var TIME_STAMP_RESP = schema.seq([
272
272
  },
273
273
  });
274
274
 
275
+ // ---- TimeStampReq ----------------------------------------------------
276
+
277
+ // TimeStampReq ::= SEQUENCE { version INTEGER { v1(1) }, messageImprint MessageImprint,
278
+ // reqPolicy TSAPolicyId OPTIONAL, nonce INTEGER OPTIONAL, certReq BOOLEAN DEFAULT FALSE,
279
+ // extensions [0] IMPLICIT Extensions OPTIONAL } (RFC 3161 sec. 2.4.1). version + messageImprint
280
+ // are mandatory; the three optionals are consumed by universal tag (OID / INTEGER / BOOLEAN),
281
+ // then the [0] extensions. certReq DEFAULT FALSE follows the TSTInfo.ordering explicit-FALSE rule.
282
+ var TIME_STAMP_REQ = schema.seq([
283
+ schema.field("version", VERSION),
284
+ schema.field("messageImprint", MESSAGE_IMPRINT),
285
+ schema.optional("reqPolicy", schema.oidLeaf(), { whenUniversal: [TAGS.OBJECT_IDENTIFIER] }),
286
+ schema.optional("nonce", schema.integerLeaf(), { whenUniversal: [TAGS.INTEGER] }),
287
+ schema.optional("certReq", schema.boolean(), { whenUniversal: [TAGS.BOOLEAN] }),
288
+ schema.trailing([
289
+ { tag: 0, name: "extensions", schema: schema.implicitSeqOf(0, EXTENSION, { min: 1, unique: function (it) { return it.value.oid; }, dupCode: "tsp/duplicate-extension", code: "tsp/bad-extensions", what: "extensions" }) },
290
+ ], { minTag: 0, maxTag: 0, unexpectedCode: "tsp/bad-request", orderCode: "tsp/bad-request" }),
291
+ ], {
292
+ assert: "sequence", code: "tsp/bad-request", what: "TimeStampReq",
293
+ build: function (m, ctx) {
294
+ // certReq BOOLEAN DEFAULT FALSE -- an explicit FALSE must be omitted under DER.
295
+ if (m.fields.certReq.present && m.fields.certReq.value === false) {
296
+ throw NS.E("tsp/bad-request", "certReq is BOOLEAN DEFAULT FALSE -- an explicit FALSE must be omitted (DER)");
297
+ }
298
+ var reqPolicy = m.fields.reqPolicy.present ? m.fields.reqPolicy.value : null;
299
+ return {
300
+ version: m.fields.version.value,
301
+ messageImprint: m.fields.messageImprint.value.result,
302
+ reqPolicy: reqPolicy,
303
+ reqPolicyName: reqPolicy ? (ctx.oid.name(reqPolicy) || null) : null,
304
+ nonce: m.fields.nonce.present ? m.fields.nonce.value : null,
305
+ nonceHex: m.fields.nonce.present ? m.fields.nonce.node.content.toString("hex") : null,
306
+ certReq: m.fields.certReq.present ? m.fields.certReq.value : false,
307
+ extensions: m.fields.extensions.present ? m.fields.extensions.value.items.map(function (it) { return it.value; }) : null,
308
+ };
309
+ },
310
+ });
311
+
312
+ /**
313
+ * @primitive pki.schema.tsp.parseRequest
314
+ * @signature pki.schema.tsp.parseRequest(input) -> timeStampReq
315
+ * @since 0.2.19
316
+ * @status experimental
317
+ * @spec RFC 3161
318
+ * @related pki.schema.tsp.parse, pki.tsp.request
319
+ *
320
+ * Parse a DER `Buffer` or PEM string into a `TimeStampReq` (RFC 3161 sec. 2.4.1):
321
+ * `{ version, messageImprint, reqPolicy, reqPolicyName, nonce, nonceHex, certReq, extensions }`.
322
+ * `version` MUST be 1; `certReq` is BOOLEAN DEFAULT FALSE (an explicit FALSE is non-DER and
323
+ * rejected `tsp/bad-request`); `nonce` is lossless (BigInt + hex); `messageImprint.hashedMessage`
324
+ * is the raw digest. A malformed structure throws a typed `TspError`.
325
+ *
326
+ * @example
327
+ * var req = pki.schema.tsp.parseRequest(der);
328
+ * req.certReq; // -> boolean
329
+ * req.messageImprint.hashedMessage; // -> Buffer (the raw digest)
330
+ */
331
+ var parseRequest = pkix.makeParser({ pemLabel: null, PemError: PemError, ErrorClass: TspError, prefix: "tsp", what: "TimeStampReq", topSchema: TIME_STAMP_REQ, ns: NS });
332
+
275
333
  /**
276
334
  * @primitive pki.schema.tsp.parseTstInfo
277
335
  * @signature pki.schema.tsp.parseTstInfo(input) -> tstInfo
@@ -410,6 +468,13 @@ function parseToken(input) {
410
468
  * @example
411
469
  * var der = pki.schema.tsp.pemDecode(pemText);
412
470
  */
471
+ // Decode a raw GeneralName TLV (the tsa field and the ESS issuerSerial issuer are surfaced raw) into
472
+ // { tagClass, tagNumber, value } -- for a directoryName [4] the value is a decoded Name { rdns },
473
+ // so a verifier canonically compares it (guard.name.dnEqual) rather than by raw bytes. @internal.
474
+ function decodeGeneralName(bytes) {
475
+ return schema.embeddedDer(pkix.generalName(NS, { decodeValue: true, code: "tsp/bad-tsa" }), bytes, NS, { code: "tsp/bad-tsa", what: "GeneralName" });
476
+ }
477
+
413
478
  function pemDecode(text, label) { return pkix.pemDecode(text, label || null, PemError); }
414
479
 
415
480
  /**
@@ -445,6 +510,9 @@ function matches(root) {
445
510
 
446
511
  module.exports = {
447
512
  parse: parse,
513
+ parseResponse: parse, // TimeStampResp parsing == parse; named for symmetry with parseRequest
514
+ parseRequest: parseRequest,
515
+ decodeGeneralName: decodeGeneralName, // @internal -- the verifier's tsa / issuerSerial DN decode
448
516
  parseTstInfo: parseTstInfo,
449
517
  parseToken: parseToken,
450
518
  pemDecode: pemDecode,
package/lib/sigstore.js CHANGED
@@ -47,7 +47,7 @@ var NOTE_SIG = new RegExp("^" + String.fromCharCode(0x2014) + " (\\S+) (\\S+)$")
47
47
 
48
48
  // Parse untrusted JSON through the bounded, duplicate-member-rejecting guard.
49
49
  function _jsonParse(input, code, label) {
50
- return guard.json.parse(input, SigstoreError, {
50
+ return guard.json.parse(input, _err, {
51
51
  maxBytes: JSON_MAX, maxDepth: 64,
52
52
  badJson: code, tooDeep: code, duplicateMember: code, tooLarge: code, badInput: code, label: label,
53
53
  });
package/lib/tsp-sign.js CHANGED
@@ -18,6 +18,13 @@ var nodeCrypto = require("crypto");
18
18
  var asn1 = require("./asn1-der");
19
19
  var oid = require("./oid");
20
20
  var cmsSign = require("./cms-sign");
21
+ var cmsVerify = require("./cms-verify");
22
+ var pathValidate = require("./path-validate");
23
+ var pkiX509 = require("./schema-x509");
24
+ var smime = require("./schema-smime");
25
+ var schemaTsp = require("./schema-tsp");
26
+ var schema = require("./schema-engine");
27
+ var guard = require("./guard-all");
21
28
  var frameworkError = require("./framework-error");
22
29
 
23
30
  var TspError = frameworkError.TspError;
@@ -158,4 +165,519 @@ function _subMilliBytes(n, label) {
158
165
  // Coverage residual -- `_hashAlgId`'s unsupported-hash throw is unreachable through the shipped
159
166
  // path: both callers (the messageImprint hash and the ESSCertIDv2 certHashAlgorithm) validate
160
167
  // the name against NODE_DIGEST before `_hashAlgId` runs, so the guard is belt-and-suspenders.
161
- module.exports = { sign: sign };
168
+
169
+ // PKIFailureInfo name -> NamedBitList bit index (RFC 3161 sec. 2.4.2; the reverse of the
170
+ // schema-tsp FAILURE_BITS decode map). Build maps names -> bits; an unknown name fails closed.
171
+ var FAILINFO_BIT = { badAlg: 0, badRequest: 2, badDataFormat: 5, timeNotAvailable: 14, unacceptedPolicy: 15, unacceptedExtension: 16, addInfoNotAvailable: 17, systemFailure: 25 };
172
+
173
+ // Encode a PKIFailureInfo as a minimal DER NamedBitList BIT STRING from a set of names: set each
174
+ // named bit (bit 0 = MSB of byte 0), strip trailing zero octets, and place unusedBits exactly
175
+ // below the lowest set bit (X.690 sec. 11.2.2).
176
+ function _failInfoBits(names) {
177
+ if (!Array.isArray(names)) throw _err("tsp/bad-input", "failInfo must be an array of PKIFailureInfo names");
178
+ var idxs = names.map(function (n) {
179
+ var i = FAILINFO_BIT[n];
180
+ if (i == null) throw _err("tsp/bad-input", "unknown PKIFailureInfo name " + JSON.stringify(n) + " (RFC 3161 sec. 2.4.2)");
181
+ return i;
182
+ });
183
+ if (!idxs.length) return b.bitString(Buffer.alloc(0), 0); // no bits -> empty BIT STRING
184
+ // buf is sized to the highest set bit, so its last octet always carries that bit (non-zero) --
185
+ // there is never a trailing zero octet to strip; only the trailing zero BITS of the last octet
186
+ // are removed, via the DER unusedBits count (X.690 sec. 11.2.2).
187
+ var buf = Buffer.alloc((Math.max.apply(null, idxs) >> 3) + 1);
188
+ idxs.forEach(function (i) { buf[i >> 3] |= 0x80 >> (i & 7); });
189
+ var unused = 0, last = buf[buf.length - 1];
190
+ while (unused < 7 && ((last >> unused) & 1) === 0) unused++; // unusedBits below the lowest set bit
191
+ return b.bitString(buf, unused);
192
+ }
193
+
194
+ // Coerce a timeStampToken (a CMS ContentInfo the token producer emits) to DER for embedding in a
195
+ // TimeStampResp -- a DER Buffer as-is, a Uint8Array copied, or a PEM string de-armored.
196
+ function _tokenDer(t) {
197
+ if (Buffer.isBuffer(t) && t[0] === 0x30) return t;
198
+ if (t instanceof Uint8Array && !Buffer.isBuffer(t)) { var u = Buffer.from(t); if (u[0] === 0x30) return u; }
199
+ if (typeof t === "string") return schemaTsp.pemDecode(t);
200
+ throw _err("tsp/bad-input", "the timeStampToken must be a DER Buffer or a PEM string");
201
+ }
202
+
203
+ function _assertImprint(mi) {
204
+ if (!mi.hashAlgorithm || !NODE_DIGEST[mi.hashAlgorithm]) throw _err("tsp/unsupported-algorithm", "messageImprint.hashAlgorithm must be a supported hash name");
205
+ if (!Buffer.isBuffer(mi.hashedMessage) && !(mi.hashedMessage instanceof Uint8Array)) throw _err("tsp/bad-input", "messageImprint.hashedMessage must be a Buffer");
206
+ if (mi.hashedMessage.length !== HASH_LEN[mi.hashAlgorithm]) throw _err("tsp/bad-input", "messageImprint.hashedMessage length (" + mi.hashedMessage.length + ") does not match the " + mi.hashAlgorithm + " digest length (" + HASH_LEN[mi.hashAlgorithm] + ")");
207
+ }
208
+
209
+ /**
210
+ * @primitive pki.tsp.request
211
+ * @signature pki.tsp.request(messageImprint, opts) -> Buffer|string
212
+ * @since 0.2.19
213
+ * @status experimental
214
+ * @spec RFC 3161
215
+ * @related pki.tsp.parseRequest, pki.tsp.sign
216
+ *
217
+ * Build an RFC 3161 `TimeStampReq` (sec. 2.4.1) over `messageImprint`
218
+ * (`{ hashAlgorithm, hashedMessage }` -- the same shape `pki.tsp.sign` takes). `version` is 1;
219
+ * `certReq` is BOOLEAN DEFAULT FALSE, so it is emitted only when explicitly `true`. Returns DER
220
+ * (or PEM when `opts.pem`).
221
+ *
222
+ * @opts reqPolicy The requested TSA policy (an OID name or dotted string).
223
+ * @opts nonce A large random nonce (number/BigInt) the client checks the token echoes.
224
+ * @opts certReq Whether the TSA should include its certificate in the token (boolean).
225
+ * @opts extensions An array of encoded Extension DER buffers ([0] IMPLICIT Extensions).
226
+ * @opts pem Return a PEM "TIMESTAMP REQUEST" string instead of DER (boolean).
227
+ * @example
228
+ * var req = pki.tsp.request({ hashAlgorithm: "sha256", hashedMessage: sha256Digest }, { nonce: 0x0102030405060708n, certReq: true });
229
+ */
230
+ function request(messageImprint, opts) {
231
+ opts = opts || {};
232
+ if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.request options must be an object");
233
+ var mi = messageImprint || {};
234
+ _assertImprint(mi);
235
+ var imprint = b.sequence([_hashAlgId(mi.hashAlgorithm), b.octetString(Buffer.from(mi.hashedMessage))]);
236
+ // TimeStampReq: version(1), messageImprint, then OPTIONAL reqPolicy / nonce / certReq /
237
+ // extensions in schema order. certReq DEFAULT FALSE -> only an explicit TRUE is encoded (DER).
238
+ var fields = [b.integer(1n), imprint];
239
+ if (opts.reqPolicy != null) fields.push(_policy(opts.reqPolicy));
240
+ if (opts.nonce != null) fields.push(b.integer(BigInt(opts.nonce)));
241
+ if (opts.certReq != null && typeof opts.certReq !== "boolean") throw _err("tsp/bad-input", "certReq must be a boolean");
242
+ if (opts.certReq === true) fields.push(b.boolean(true));
243
+ if (opts.extensions != null) {
244
+ if (!Array.isArray(opts.extensions) || !opts.extensions.every(function (e) { return Buffer.isBuffer(e) || e instanceof Uint8Array; })) throw _err("tsp/bad-input", "extensions must be an array of encoded Extension DER buffers");
245
+ if (opts.extensions.length) fields.push(b.contextConstructed(0, Buffer.concat(opts.extensions.map(function (e) { return Buffer.from(e); }))));
246
+ }
247
+ var der = b.sequence(fields);
248
+ return opts.pem ? schemaTsp.pemEncode(der, "TIMESTAMP REQUEST") : der;
249
+ }
250
+
251
+ /**
252
+ * @primitive pki.tsp.parseRequest
253
+ * @signature pki.tsp.parseRequest(input) -> timeStampReq
254
+ * @since 0.2.19
255
+ * @status experimental
256
+ * @spec RFC 3161
257
+ * @related pki.tsp.request, pki.schema.tsp.parseRequest
258
+ *
259
+ * Parse a `TimeStampReq` (DER `Buffer` or PEM) -- the `pki.schema.tsp.parseRequest` decoder on the
260
+ * `pki.tsp` namespace. Returns `{ version, messageImprint, reqPolicy, reqPolicyName, nonce,
261
+ * nonceHex, certReq, extensions }`; a malformed structure throws a typed `TspError`.
262
+ *
263
+ * @example
264
+ * var req = pki.tsp.parseRequest(der);
265
+ * req.certReq; // -> boolean
266
+ */
267
+ function parseRequest(input) { return schemaTsp.parseRequest(input); }
268
+
269
+ /**
270
+ * @primitive pki.tsp.response
271
+ * @signature pki.tsp.response(token, opts) -> Buffer|string
272
+ * @since 0.2.19
273
+ * @status experimental
274
+ * @spec RFC 3161
275
+ * @related pki.tsp.parseResponse, pki.tsp.sign
276
+ *
277
+ * Build an RFC 3161 `TimeStampResp` (sec. 2.4.2): a `PKIStatusInfo` and, on success, the
278
+ * `timeStampToken`. Pass a `token` (the CMS ContentInfo `pki.tsp.sign` produces) with the default
279
+ * granted status, or build a rejection with `response(null, { status, failInfo, statusString })`.
280
+ * The status-to-token coupling is enforced (granted 0/1 carries a token, any other status must not),
281
+ * mirroring the parse-side gate. Returns DER (or PEM when `opts.pem`).
282
+ *
283
+ * @opts status PKIStatus 0..5 (default 0 granted). granted(0)/grantedWithMods(1) carry a token.
284
+ * @opts failInfo Array of PKIFailureInfo names (only on rejection(2)): e.g. ["badAlg"].
285
+ * @opts statusString Human-readable PKIFreeText (string or array of strings).
286
+ * @opts pem Return a PEM "TIMESTAMP RESPONSE" string instead of DER (boolean).
287
+ * @example
288
+ * var resp = pki.tsp.response(token, {}); // granted
289
+ * var rej = pki.tsp.response(null, { status: 2, failInfo: ["badAlg"] }); // rejection
290
+ */
291
+ function response(token, opts) {
292
+ opts = opts || {};
293
+ if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.response options must be an object");
294
+ var status = opts.status == null ? 0 : Number(opts.status);
295
+ if (!Number.isInteger(status) || status < 0 || status > 5) throw _err("tsp/bad-input", "PKIStatus must be an integer in 0..5 (RFC 3161 sec. 2.4.2)");
296
+ var granted = status === 0 || status === 1;
297
+ if (granted && token == null) throw _err("tsp/missing-token", "a granted TimeStampResp requires a timeStampToken (RFC 3161 sec. 2.4.2)");
298
+ if (!granted && token != null) throw _err("tsp/unexpected-token", "a non-granted TimeStampResp must not carry a timeStampToken (RFC 3161 sec. 2.4.2)");
299
+ var siFields = [b.integer(BigInt(status))];
300
+ if (opts.statusString != null) {
301
+ var texts = Array.isArray(opts.statusString) ? opts.statusString : [opts.statusString];
302
+ // PKIFreeText is SEQUENCE SIZE (1..MAX) OF UTF8String -- an empty statusString would build an
303
+ // empty SEQUENCE the parser (seqOf min:1) rejects, so reject it at the builder instead.
304
+ if (!texts.length) throw _err("tsp/bad-input", "statusString must carry at least one PKIFreeText element (RFC 4210 SEQUENCE SIZE 1..MAX)");
305
+ siFields.push(b.sequence(texts.map(function (t) { return b.utf8(String(t)); })));
306
+ }
307
+ if (opts.failInfo != null) {
308
+ if (status !== 2) throw _err("tsp/unexpected-failinfo", "failInfo is present only when the status is rejection(2) (RFC 3161 sec. 2.4.2)");
309
+ siFields.push(_failInfoBits(opts.failInfo));
310
+ }
311
+ var respFields = [b.sequence(siFields)];
312
+ if (token != null) respFields.push(b.raw(_tokenDer(token)));
313
+ var der = b.sequence(respFields);
314
+ return opts.pem ? schemaTsp.pemEncode(der, "TIMESTAMP RESPONSE") : der;
315
+ }
316
+
317
+ /**
318
+ * @primitive pki.tsp.parseResponse
319
+ * @signature pki.tsp.parseResponse(input) -> timeStampResp
320
+ * @since 0.2.19
321
+ * @status experimental
322
+ * @spec RFC 3161
323
+ * @related pki.tsp.response, pki.schema.tsp.parse
324
+ *
325
+ * Parse a `TimeStampResp` (DER `Buffer` or PEM) -- the shipped `pki.schema.tsp.parse` decoder on
326
+ * the `pki.tsp` namespace. Returns `{ status, statusString, failInfo, timeStampToken }` with the
327
+ * status-to-token coupling enforced; a granted response's token is decoded via `parseToken`.
328
+ *
329
+ * @example
330
+ * var resp = pki.tsp.parseResponse(der);
331
+ * resp.timeStampToken.tstInfo.genTime; // -> Date (on a granted response)
332
+ */
333
+ function parseResponse(input) { return schemaTsp.parse(input); }
334
+
335
+ // Coverage residual -- the null-guards and decode/parse catches in the verify helpers below are
336
+ // belt-and-suspenders on values already guaranteed upstream, so their failure arms are unreachable
337
+ // through the shipped path: parseToken has already proven the token is a single-signer SignedData
338
+ // carrying a signing-certificate attribute, so `signerInfo`/`signedAttrs`/the signing-cert attr are
339
+ // present; cms.verify returns a parsed, matched signer certificate, so `tsaCertDer` is set and
340
+ // re-parsing it (or decoding its extensions) cannot fail here; decodeAttribute yields at least one
341
+ // ESSCertID (an empty SEQUENCE OF is rejected at decode -> the `!essCert` guard is defensive); and
342
+ // path.validate returns a fail-closed verdict for a bad anchor rather than throwing, so its catch
343
+ // is defensive. Likewise, in the out-of-path chain step, `parsed.certificates` is always an array
344
+ // (empty when absent), the embedded certs the chain walk parses are the X.509 (universal) choices,
345
+ // and guard.name.dnEqual's control-byte reject is defensive on a cert x509.parse already accepted --
346
+ // so the `|| []`, the non-universal filter arm, and that throw are unreachable through the shipped
347
+ // path. In the tsa-hint check, the tsa GeneralName's decode catch is likewise defensive: parseToken
348
+ // eager-decodes the token, so a constructed GeneralName that would fail decodeGeneralName has already
349
+ // failed there, and a primitive one decodes -- the catch only guards decodeGeneralName's standalone
350
+ // use and yields the same safe no-match. A malformed ESS value, a malformed EKU/keyUsage extnValue,
351
+ // and a malformed subjectAltName ARE reachable (a signed-but-broken cert/attribute) and ARE covered;
352
+ // the reachable verdicts (unsupported hash, EKU shape/wrapper, keyUsage, imprint/nonce/policy/binding
353
+ // mismatch, tsa-hint match + mismatch, unknown critical extension, untrusted TSA, embedded-chain
354
+ // ordering) are each driven by a RED conformance vector.
355
+
356
+ // The imprint / ESSCertID hash algorithms a verifier can recompute: the SHA-2 family, plus SHA-1
357
+ // for MATCHING a legacy token's imprint (a verify-side digest, never a signing choice). An unknown
358
+ // or weak (e.g. MD5) algorithm cannot be recomputed, so the check fails closed rather than assume.
359
+ var _VERIFY_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512", sha1: "sha1" };
360
+ // The digest output length each verify hash MUST produce: a messageImprint or precomputed imprint
361
+ // of any other length is not a real digest and can never correspond to data (RFC 3161 sec. 2.4.1).
362
+ var _VERIFY_DIGEST_LEN = { sha1: 20, sha256: 32, sha384: 48, sha512: 64 };
363
+ // The ESSCertID(V2) certificate binding is a pure identity pin, so its digest MUST be
364
+ // collision-resistant: a SHA-1 certHash would let a chosen-prefix collision substitute the TSA
365
+ // certificate. RFC 5816 moved this binding from the SHA-1 ESSCertID to the SHA-2 ESSCertIDv2 for
366
+ // exactly this reason -- accept only SHA-2 here (the messageImprint stays algorithm-agile, since
367
+ // that hash is the requester's documented RFC 3161 choice, not an identity pin).
368
+ var _BIND_DIGEST = { sha256: "sha256", sha384: "sha384", sha512: "sha512" };
369
+
370
+ // M10 -- recompute the imprint over `data` under the token's messageImprint hash algorithm (read
371
+ // from the verified eContent) and compare to hashedMessage. `data` is raw bytes (hashed) or a
372
+ // precomputed { hashAlgorithm, hashedMessage } (algorithm-exact + byte-exact). Returns true or a
373
+ // tsp/* verdict code; a config-shape error throws (the caller's contract).
374
+ function _imprintMatches(mi, data) {
375
+ var name = mi.hashAlgorithm && mi.hashAlgorithm.name;
376
+ var nodeAlg = name && _VERIFY_DIGEST[name];
377
+ if (!nodeAlg) return "tsp/unsupported-algorithm"; // cannot recompute -> never assume a match
378
+ var wantLen = _VERIFY_DIGEST_LEN[name];
379
+ // The token's messageImprint MUST be a full-length digest for its algorithm. A short/empty
380
+ // hashedMessage is not a hash and cannot correspond to any data, so it fails closed here -- a
381
+ // matching short precomputed imprint must never make it verify (RFC 3161 sec. 2.4.1).
382
+ if (mi.hashedMessage.length !== wantLen) return "tsp/imprint-mismatch";
383
+ var actual;
384
+ if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
385
+ actual = nodeCrypto.createHash(nodeAlg).update(Buffer.from(data)).digest();
386
+ } else if (data && typeof data === "object" && (Buffer.isBuffer(data.hashedMessage) || data.hashedMessage instanceof Uint8Array)) {
387
+ if (data.hashAlgorithm !== name) return "tsp/imprint-mismatch"; // a precomputed imprint under a different algorithm
388
+ // a caller-supplied precomputed imprint of the wrong length is a config error (the sign path
389
+ // rejects it identically) -- throw rather than silently mismatch (three-tier: caller input).
390
+ if (data.hashedMessage.length !== wantLen) throw _err("tsp/bad-input", "precomputed imprint hashedMessage length (" + data.hashedMessage.length + ") does not match the " + name + " digest length (" + wantLen + ")");
391
+ actual = Buffer.from(data.hashedMessage);
392
+ } else {
393
+ throw _err("tsp/bad-input", "pki.tsp.verify data must be a Buffer or a { hashAlgorithm, hashedMessage } imprint");
394
+ }
395
+ return Buffer.compare(actual, Buffer.from(mi.hashedMessage)) === 0 ? true : "tsp/imprint-mismatch";
396
+ }
397
+
398
+ // M13 -- the token MUST be bound to the exact signer certificate via the ESS SigningCertificate(V2)
399
+ // signed attribute (RFC 3161 sec. 2.4.2 / RFC 5816): recompute hash(tsaCertDer) under the
400
+ // ESSCertID(V2) hashAlgorithm (v1 implies SHA-1, v2 defaults SHA-256) and compare to certHash.
401
+ function _checkCertBinding(signerInfo, tsaCertDer) {
402
+ var attrs = (signerInfo && signerInfo.signedAttrs) || [];
403
+ // Prefer the RFC 5816 SigningCertificateV2 (SHA-2) binding over the legacy RFC 2634
404
+ // SigningCertificate (SHA-1). A token may carry both for compatibility, and the SHA-1 attribute
405
+ // sorts first in the signed-attribute SET; picking it would fail the SHA-2-only binding pin even
406
+ // though a usable v2 binding is present. Fall back to the legacy attribute only when no v2 exists.
407
+ var sc = null, scV1 = null;
408
+ for (var i = 0; i < attrs.length; i++) {
409
+ if (attrs[i].type === O("signingCertificateV2")) { sc = attrs[i]; break; }
410
+ if (attrs[i].type === O("signingCertificate") && !scV1) scV1 = attrs[i];
411
+ }
412
+ if (!sc) sc = scV1;
413
+ if (!sc) return "tsp/missing-signing-certificate"; // parseToken guarantees presence; belt-and-suspenders
414
+ var decoded;
415
+ try { decoded = smime.decodeAttribute(sc); }
416
+ catch (_e) { return "tsp/bad-signing-certificate"; }
417
+ var essCert = decoded.certs && decoded.certs[0];
418
+ if (!essCert) return "tsp/bad-signing-certificate";
419
+ var nodeAlg = essCert.hashAlgorithm && _BIND_DIGEST[essCert.hashAlgorithm.name];
420
+ if (!nodeAlg) return "tsp/unsupported-algorithm"; // SHA-1 (or an unknown hash) is not a valid identity pin
421
+ var actual = nodeCrypto.createHash(nodeAlg).update(tsaCertDer).digest();
422
+ if (Buffer.compare(actual, Buffer.from(essCert.certHash)) !== 0) return "tsp/cert-binding-mismatch";
423
+ // RFC 5035 sec. 5: if the ESSCertID(V2) carries the OPTIONAL issuerSerial, it MUST identify the
424
+ // signer certificate -- confirm its serialNumber. The certHash already pins the exact cert
425
+ // cryptographically; the serialNumber is that cert's unambiguous identity field.
426
+ if (essCert.issuerSerial) {
427
+ var signerCert;
428
+ try { signerCert = pkiX509.parse(tsaCertDer); }
429
+ catch (_e) { return "tsp/bad-signing-certificate"; }
430
+ if (essCert.issuerSerial.serialNumber !== signerCert.serialNumber) return "tsp/cert-binding-mismatch";
431
+ // the issuerSerial issuer is a single directoryName (RFC 5035 sec. 5) that MUST canonically
432
+ // equal the signer cert's issuer DN -- decode it and compare through the shared name guard.
433
+ var names = essCert.issuerSerial.issuer.names || [];
434
+ var gn = null;
435
+ try { if (names.length === 1) gn = schemaTsp.decodeGeneralName(names[0].bytes); }
436
+ catch (_e) { return "tsp/cert-binding-mismatch"; }
437
+ if (!gn || gn.tagNumber !== 4 || !gn.value || !guard.name.dnEqual(gn.value.rdns, signerCert.issuer.rdns, _err, "tsp/cert-binding-mismatch", "the ESSCertID issuer")) {
438
+ return "tsp/cert-binding-mismatch";
439
+ }
440
+ }
441
+ return true;
442
+ }
443
+
444
+ // M11 -- RFC 3161 sec. 2.3: the TSA certificate MUST contain exactly ONE extendedKeyUsage instance
445
+ // whose sole KeyPurposeID is id-kp-timeStamping, and that extension MUST be CRITICAL. path.validate's
446
+ // requiredEku only checks the purpose is ASSERTED (an absent EKU is unrestricted), so this
447
+ // single-purpose + critical gate is layered on top -- the OCSP-delegate precedent for a format gate
448
+ // above the path checks. Additionally (RFC 5280 sec. 4.2.1.3) a keyUsage extension, if present, MUST
449
+ // permit signing for the cert to sign the token. Returns true or a tsp/* code.
450
+ function _checkTsaCertUsage(tsaCertDer) {
451
+ var cert;
452
+ try { cert = pkiX509.parse(tsaCertDer); }
453
+ catch (_e) { return "tsp/bad-tsa-certificate"; }
454
+ var exts = (cert.extensions || []).filter(function (e) { return e.oid === O("extKeyUsage"); });
455
+ if (exts.length !== 1) return "tsp/bad-eku"; // absent, or a duplicate extension (RFC 5280 -- one instance)
456
+ if (!exts[0].critical) return "tsp/eku-not-critical";
457
+ var purposes, ekuNode;
458
+ try { ekuNode = asn1.decode(exts[0].value); }
459
+ catch (_e) { return "tsp/bad-eku"; }
460
+ // the extnValue MUST be a universal SEQUENCE OF KeyPurposeId -- a non-SEQUENCE constructed wrapper
461
+ // (e.g. a SET) carrying OID children would otherwise slip through the bare children walk.
462
+ if (ekuNode.tagClass !== "universal" || ekuNode.tagNumber !== asn1.TAGS.SEQUENCE || !ekuNode.children) return "tsp/bad-eku";
463
+ try { purposes = ekuNode.children.map(function (c) { return asn1.read.oid(c); }); }
464
+ catch (_e) { return "tsp/bad-eku"; }
465
+ if (purposes.length !== 1 || purposes[0] !== O("timeStamping")) return "tsp/eku-not-exclusive";
466
+ // A keyUsage that forbids signing cannot mint a token (RFC 5280 sec. 4.2.1.3); an absent keyUsage
467
+ // is unrestricted. Require digitalSignature (bit 0) or nonRepudiation/contentCommitment (bit 1) --
468
+ // the signing bits, the TSA analogue of the OCSP-responder keyUsage gate.
469
+ var kuExts = (cert.extensions || []).filter(function (e) { return e.oid === O("keyUsage"); });
470
+ if (kuExts.length) {
471
+ var ku;
472
+ try {
473
+ ku = asn1.read.bitString(asn1.decode(kuExts[0].value));
474
+ // KeyUsage is a NamedBitList: enforce the X.690 sec. 11.2.2 minimal-DER rule (no trailing zero
475
+ // bits) that the shared certExtensionDecoders keyUsage decoder applies, so a non-minimal
476
+ // encoding other paths reject cannot slip a "permits signing" verdict through here.
477
+ schema.assertMinimalNamedBits(ku.unusedBits, ku.bytes, function (m) { throw _err("tsp/bad-key-usage", m); });
478
+ } catch (_e) { return "tsp/bad-key-usage"; }
479
+ var byte0 = ku.bytes.length ? ku.bytes[0] : 0;
480
+ if (!((byte0 >> 7) & 1) && !((byte0 >> 6) & 1)) return "tsp/bad-key-usage"; // no digitalSignature / nonRepudiation
481
+ }
482
+ return true;
483
+ }
484
+
485
+ // RFC 5816 sec. 2.2.2: if TSTInfo.tsa is present, it is a hint that MUST correspond to one of the
486
+ // signing certificate's subject names -- its subject DN (a directoryName, compared canonically) or a
487
+ // subjectAltName entry (a byte-exact GeneralName match). `tsaBytes` is the raw tsa GeneralName TLV.
488
+ function _tsaMatchesCert(tsaBytes, signerCert) {
489
+ var gn;
490
+ try { gn = schemaTsp.decodeGeneralName(tsaBytes); }
491
+ catch (_e) { return false; }
492
+ if (gn.tagNumber === 4 && gn.value && guard.name.dnEqual(gn.value.rdns, signerCert.subject.rdns, _err, "tsp/tsa-mismatch", "the TSA subject")) return true;
493
+ var sanExt = (signerCert.extensions || []).filter(function (e) { return e.oid === O("subjectAltName"); })[0];
494
+ if (sanExt) {
495
+ var san;
496
+ try { san = asn1.decode(sanExt.value); }
497
+ catch (_e) { return false; }
498
+ // subjectAltName MUST be a universal SEQUENCE OF GeneralName -- a malformed wrapper (e.g. a SET)
499
+ // carrying a byte-equal child must not contribute an identity (mirrors the EKU wrapper gate).
500
+ if (san.tagClass === "universal" && san.tagNumber === asn1.TAGS.SEQUENCE && san.children) {
501
+ for (var i = 0; i < san.children.length; i++) {
502
+ if (san.children[i].bytes.equals(tsaBytes)) return true;
503
+ }
504
+ }
505
+ }
506
+ return false;
507
+ }
508
+
509
+ // Bound on the candidate certification paths enumerated from a token's certificate pool -- a guard
510
+ // against a hostile embed of many same-subject certificates blowing up the search (CWE-834).
511
+ var MAX_TSA_CHAINS = 32;
512
+
513
+ // Enumerate candidate certification paths for the TSA leaf from a pool of embedded and caller-supplied
514
+ // certificates. path.validate validates a FIXED ordered path (it is not a path builder), so the
515
+ // ordering + backtracking lives here: at every step EVERY unused pool certificate whose subject DN
516
+ // equals the working issuer DN is a candidate issuer -- CA rollover / cross-certification embed
517
+ // several with the same subject, and a same-subject wrong-key certificate must not shadow the real
518
+ // issuer -- and each accumulated prefix is itself a candidate (the anchor may have issued the leaf
519
+ // directly or any intermediate). A certificate is never chained to itself (RFC 5280 identity is
520
+ // issuer + serialNumber, never the serial alone). Each chain is returned anchor-down (highest issuer
521
+ // first, the leaf last) as path.validate takes it; longer chains first so a fully-embedded path is
522
+ // preferred. Bounded by MAX_TSA_CHAINS. The caller (verify) tries each until one validates.
523
+ function _buildTsaChains(leaf, pool) {
524
+ var chains = [];
525
+ function dfs(current, acc, used) {
526
+ if (chains.length >= MAX_TSA_CHAINS) return;
527
+ chains.push(acc.slice()); // acc is leaf..current; the anchor may validate the path at this length
528
+ for (var i = 0; i < pool.length && chains.length < MAX_TSA_CHAINS; i++) {
529
+ if (used[i]) continue;
530
+ // never chain a certificate to itself (issuer + serialNumber identity, not the serial alone).
531
+ if (pool[i].serialNumberHex === current.serialNumberHex && guard.name.dnEqual(pool[i].issuer.rdns, current.issuer.rdns, _err, "tsp/bad-tsa-certificate", "the TSA certificate issuer")) continue;
532
+ if (guard.name.dnEqual(pool[i].subject.rdns, current.issuer.rdns, _err, "tsp/bad-tsa-certificate", "the TSA certificate subject")) {
533
+ used[i] = true; acc.push(pool[i]);
534
+ dfs(pool[i], acc, used);
535
+ acc.pop(); used[i] = false;
536
+ }
537
+ }
538
+ }
539
+ dfs(leaf, [leaf], []);
540
+ return chains.map(function (c) { return c.slice().reverse(); }).sort(function (a, b) { return b.length - a.length; });
541
+ }
542
+
543
+ /**
544
+ * @primitive pki.tsp.verify
545
+ * @signature pki.tsp.verify(token, data, opts) -> Promise<result>
546
+ * @since 0.2.19
547
+ * @status experimental
548
+ * @spec RFC 3161, RFC 5816
549
+ * @related pki.tsp.sign, pki.cms.verify, pki.path.validate
550
+ *
551
+ * Verify an RFC 3161 TimeStampToken against the data it should cover. `token` is the token DER /
552
+ * PEM (never a parsed object -- every checked field is read from the CMS-verified eContent, so a
553
+ * mutated parsed structure cannot desynchronize the checks from the signed bytes). `data` is the
554
+ * original bytes (hashed under the token's messageImprint algorithm) or a precomputed
555
+ * `{ hashAlgorithm, hashedMessage }`. Returns `{ valid, genTime, accuracy, serialNumber,
556
+ * serialNumberHex, policy, nonce, tsa, tstInfo, signer, code?, reason? }`. `valid` is true only
557
+ * when the CMS signature, the imprint match, the eContentType, the ESSCertID(V2) binding, the
558
+ * RFC 3161 sec. 2.3 critical single-`timeStamping` extendedKeyUsage rule, the requested nonce (when
559
+ * supplied), and -- when a `trustAnchor` is supplied -- the full out-of-path TSA-cert path
560
+ * validation all pass. A conformance / trust failure of a well-formed token is a
561
+ * `{ valid:false, code }` verdict; malformed or config input throws a typed `TspError`.
562
+ *
563
+ * @opts trustAnchor Anchor `{ name, publicKey, algorithm }` -- runs `pki.path.validate` on the
564
+ * TSA certificate chain ordered from the token's embedded certificates
565
+ * (validity at genTime, requiredEku timeStamping, revocation), so a TSA under
566
+ * an intermediate CA validates, not only one directly under the anchor. Omit
567
+ * to verify signature + imprint + binding + EKU only and anchor the cert yourself.
568
+ * @opts nonce Require the token's TSTInfo.nonce to equal this (a number/BigInt).
569
+ * @opts reqPolicy Require the token's policy to equal this (an OID name or dotted string).
570
+ * @opts certs Out-of-band TSA certificates (an array of DER `Buffer`s) added to the signer
571
+ * candidates and the path pool, so a cert-less token (the TSA omits its
572
+ * certificate when `certReq` was false) and an intermediate absent from the
573
+ * token can still verify and chain.
574
+ * @opts revocationChecker Passed through to `pki.path.validate`.
575
+ * @example
576
+ * var imprint = { hashAlgorithm: "sha256", hashedMessage: sha256Digest };
577
+ * var token = await pki.tsp.sign(imprint, { cert: signerCertDer, key: signerKeyPkcs8 }, { policy: "1.3.6.1.4.1.1", serialNumber: 1 });
578
+ * var res = await pki.tsp.verify(token, Buffer.from("hello"), {});
579
+ * res.valid; // boolean; pass opts.trustAnchor to also chain the TSA cert to a root
580
+ * res.genTime; // Date, read from the verified eContent
581
+ */
582
+ async function verify(token, data, opts) {
583
+ opts = opts || {};
584
+ if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("tsp/bad-input", "pki.tsp.verify options must be an object");
585
+ if (opts.certs != null && (!Array.isArray(opts.certs) || !opts.certs.every(function (c) { return Buffer.isBuffer(c) || c instanceof Uint8Array; }))) {
586
+ throw _err("tsp/bad-input", "pki.tsp.verify opts.certs must be an array of DER certificate Buffers"); // a bad element is a caller error, never silently dropped
587
+ }
588
+ var tokenDer = _tokenDer(token); // DER Buffer / PEM -> DER; a non-token input throws tsp/bad-input
589
+ // M7 -- parseToken enforces the SignedData + id-ct-TSTInfo + single-signer + signing-cert-present
590
+ // structure and decodes the TSTInfo FROM the raw eContent; a structural defect throws.
591
+ var parsed = schemaTsp.parseToken(tokenDer);
592
+ var tst = parsed.tstInfo;
593
+ function fail(code, reason) {
594
+ return { valid: false, code: code, reason: reason || null, genTime: tst.genTime, accuracy: tst.accuracy, serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex, policy: tst.policy, nonce: tst.nonce, tsa: tst.tsa, tstInfo: tst, signer: null };
595
+ }
596
+ // M12 -- the CMS signature over the exact RFC 5652 sec. 5.4 preimage (message-digest bound to the
597
+ // authenticated eContent). cms.verify re-parses the same bytes; a failure is a fail-closed verdict.
598
+ // Caller-supplied out-of-band certificates (opts.certs) join the signer-certificate candidates, so
599
+ // a cert-less token (RFC 3161 -- the TSA omits its certificate when certReq was false) can verify.
600
+ var cmsRes = await cmsVerify.verify(tokenDer, { certs: opts.certs });
601
+ var signer = cmsRes.signers[0];
602
+ if (!cmsRes.valid) return fail("tsp/bad-signature", signer && (signer.code || signer.message));
603
+ var tsaCertDer = signer && signer.cert;
604
+ if (!tsaCertDer) return fail("tsp/bad-signature", "the TSA signer certificate was not found");
605
+ // M10 -- what was timestamped MUST correspond to the data (imprint recompute + match).
606
+ var mi = _imprintMatches(tst.messageImprint, data);
607
+ if (mi !== true) return fail(mi);
608
+ // M14 -- if a request nonce is supplied, the token MUST echo it (BigInt-exact).
609
+ if (opts.nonce != null) {
610
+ var wantNonce = BigInt(opts.nonce);
611
+ if (tst.nonce == null || tst.nonce !== wantNonce) return fail("tsp/nonce-mismatch");
612
+ }
613
+ // M15 -- if the requested policy is supplied, the token's policy MUST equal it.
614
+ if (opts.reqPolicy != null) {
615
+ var wantPolicy = /^\d+(\.\d+)+$/.test(opts.reqPolicy) ? opts.reqPolicy : O(opts.reqPolicy);
616
+ if (tst.policy !== wantPolicy) return fail("tsp/policy-mismatch");
617
+ }
618
+ // A TSTInfo carrying a CRITICAL extension the verifier cannot process is unusable: RFC 3161 sec.
619
+ // 2.4.2 defers extension handling to RFC 5280 sec. 4.2, and defines no TSTInfo extension, so any
620
+ // critical one is unrecognized (the unknown-critical-extension gate the cert path also applies).
621
+ if (tst.extensions && tst.extensions.some(function (e) { return e.critical; })) {
622
+ return fail("tsp/unknown-critical-extension");
623
+ }
624
+ // M13 -- the ESSCertID(V2) binding: the verifying cert MUST be the one the signer identified.
625
+ var bind = _checkCertBinding(parsed.signerInfo, tsaCertDer);
626
+ if (bind !== true) return fail(bind);
627
+ // M11 -- the RFC 3161 sec. 2.3 critical single-timeStamping EKU gate + the keyUsage-permits-signing check.
628
+ var usage = _checkTsaCertUsage(tsaCertDer);
629
+ if (usage !== true) return fail(usage);
630
+ // RFC 5816 sec. 2.2.2 -- the tsa hint, when present, MUST name the signer certificate.
631
+ if (tst.tsa) {
632
+ var sc;
633
+ try { sc = pkiX509.parse(tsaCertDer); }
634
+ catch (_e) { return fail("tsp/bad-tsa-certificate"); }
635
+ if (!_tsaMatchesCert(tst.tsa.bytes, sc)) return fail("tsp/tsa-mismatch");
636
+ }
637
+ // M18 -- full out-of-path TSA-cert validation (issuer-sig, validity at genTime, unknown-critical,
638
+ // key-param inheritance, requiredEku, optional revocation), only when a trustAnchor is supplied.
639
+ // The path is ordered from the token's embedded certificates (leaf + any intermediates), so a TSA
640
+ // issued under an intermediate CA -- not just directly under the anchor -- validates.
641
+ if (opts.trustAnchor) {
642
+ var pathRes = null;
643
+ // tst.genTime floors to millisecond precision. When genTime carries sub-millisecond digits the true
644
+ // instant lies in (floor, floor+1ms], so fail closed on BOTH validity boundaries: require each
645
+ // candidate path to validate at the floor AND the ceil. A token minted just after the certificate
646
+ // expired is rejected at the ceil (floor <= notAfter but ceil > notAfter); one minted just before
647
+ // notBefore is rejected at the floor (ceil >= notBefore but floor < notBefore). Certificate
648
+ // validity is seconds-precise (RFC 5280 sec. 4.1.2.5), so this never over-rejects a valid token.
649
+ var floorT = tst.genTime;
650
+ var ceilT = (tst.genTimeFraction != null && /[1-9]/.test(tst.genTimeFraction.slice(3))) ? new Date(floorT.getTime() + 1) : floorT;
651
+ try {
652
+ // parseToken surfaces each embedded cert as { bytes, tagClass, ... }; take the X.509 certs
653
+ // (universal SEQUENCE), skipping any attribute-certificate choices, and parse their raw bytes.
654
+ // Caller out-of-band certificates (opts.certs) join the pool so an intermediate absent from the
655
+ // token can still complete the path.
656
+ var pool = (parsed.certificates || []).filter(function (c) { return c.tagClass === "universal"; }).map(function (c) { return pkiX509.parse(c.bytes); });
657
+ (opts.certs || []).forEach(function (c) { pool.push(pkiX509.parse(c)); });
658
+ // path.validate validates a FIXED path, so backtracking over same-subject issuer candidates
659
+ // happens here -- accept the TSA certificate if ANY enumerated chain validates to the anchor at
660
+ // both window endpoints.
661
+ var chains = _buildTsaChains(pkiX509.parse(tsaCertDer), pool);
662
+ for (var ci = 0; ci < chains.length && !(pathRes && pathRes.valid); ci++) {
663
+ pathRes = await pathValidate.validate(chains[ci], {
664
+ time: floorT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], revocationChecker: opts.revocationChecker,
665
+ });
666
+ if (pathRes.valid && ceilT !== floorT) {
667
+ pathRes = await pathValidate.validate(chains[ci], {
668
+ time: ceilT, trustAnchor: opts.trustAnchor, requiredEku: ["timeStamping"], revocationChecker: opts.revocationChecker,
669
+ });
670
+ }
671
+ }
672
+ } catch (e) { return fail("tsp/untrusted-tsa", (e && e.message) || String(e)); }
673
+ if (!pathRes || !pathRes.valid) return fail("tsp/untrusted-tsa", "the TSA certificate did not validate to the trust anchor at genTime");
674
+ }
675
+ return {
676
+ valid: true, genTime: tst.genTime, accuracy: tst.accuracy,
677
+ serialNumber: tst.serialNumber, serialNumberHex: tst.serialNumberHex,
678
+ policy: tst.policy, policyName: tst.policyName, nonce: tst.nonce, tsa: tst.tsa,
679
+ tstInfo: tst, signer: { cert: tsaCertDer, sid: signer.sid },
680
+ };
681
+ }
682
+
683
+ module.exports = { sign: sign, request: request, parseRequest: parseRequest, response: response, parseResponse: parseResponse, verify: verify };
@@ -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/lib/webcrypto.js CHANGED
@@ -627,7 +627,7 @@ SubtleCrypto.prototype.unwrapKey = async function unwrapKey(format, wrappedKey,
627
627
  // notice. The shared JSON guard parses strictly (bounded, fatal UTF-8, and a
628
628
  // smuggled duplicate member rejected rather than resolved last-wins), and its
629
629
  // failure surfaces as the module's typed webcrypto/data (W3C: DataError).
630
- keyData = guard.json.parse(bytes, WebCryptoError, {
630
+ keyData = guard.json.parse(bytes, _wcErr, {
631
631
  maxBytes: constants.LIMITS.JSON_MAX_BYTES, maxDepth: constants.LIMITS.JSON_MAX_DEPTH,
632
632
  badJson: "webcrypto/data", tooDeep: "webcrypto/data", duplicateMember: "webcrypto/data",
633
633
  tooLarge: "webcrypto/data", badInput: "webcrypto/data", label: "the unwrapped JWK",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.2.18",
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:bec63d3e-7332-4a32-bcc8-ac8614e7402a",
5
+ "serialNumber": "urn:uuid:2c42308d-4c79-437b-8bf2-36089abf8898",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-14T07:20:40.274Z",
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.18",
22
+ "bom-ref": "@blamejs/pki@0.2.20",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.18",
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.18",
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.18",
57
+ "ref": "@blamejs/pki@0.2.20",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]