@blamejs/pki 0.2.18 → 0.2.19

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,20 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.2.19 — 2026-07-14
8
+
9
+ 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.
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+ - 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.
15
+
16
+ ### Fixed
17
+
18
+ - 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.
19
+ - 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.
20
+
7
21
  ## v0.2.18 — 2026-07-14
8
22
 
9
23
  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
@@ -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/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 };
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.19",
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:d6e0649c-f4db-4241-8784-6f3c9d3d663f",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-14T07:20:40.274Z",
8
+ "timestamp": "2026-07-14T16:14:40.970Z",
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.19",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.2.18",
25
+ "version": "0.2.19",
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.19",
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.19",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]