@blamejs/pki 0.1.9 → 0.1.11

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,30 @@ 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.1.11 — 2026-07-05
8
+
9
+ An OCSP request and response parser joins the pki.schema family.
10
+
11
+ ### Added
12
+
13
+ - pki.schema.ocsp.parseRequest and pki.schema.ocsp.parseResponse — an OCSP request / response parser per RFC 6960 (§4.1 OCSPRequest, §4.2 OCSPResponse). parseRequest turns a DER Buffer or an 'OCSP REQUEST' PEM string into { tbsRequestBytes, version, requestorName, requestList, requestExtensions, optionalSignature }, each requestList entry carrying its CertID with the two issuer hashes raw. parseResponse turns a DER Buffer or an 'OCSP RESPONSE' PEM string into { responseStatus, responseBytes, basicResponse }; for a successful basic response, basicResponse carries { tbsResponseDataBytes, responderID, producedAt, responses, signatureAlgorithm, signature, certs } and each responses[i].certStatus is { type: 'good' | 'revoked' | 'unknown' } (a revoked entry adds its revocationTime and revocationReason). A malformed structure throws a typed OcspError (ocsp/*), an unsupported responseType throws ocsp/unsupported-response-type, and a leaf-level codec fault surfaces as asn1/*. pki.schema.ocsp.pemDecode handles the PEM envelope, and pki.schema.parse detect-and-routes both OCSP shapes.
14
+ - pki.asn1.read.nullImplicit and the pki.schema.engine.implicitNull(tag) leaf — read a context-tagged IMPLICIT NULL (the shape the OCSP CertStatus good [0] and unknown [2] arms take), the primitive-leaf sibling of implicitBitString and implicitOctetString. It rejects a constructed or non-empty context node fail-closed.
15
+ - An ocsp-parse coverage-guided fuzz harness joins the CI fuzzing matrix (jazzer.js + libFuzzer, per pull request and nightly), driving both OCSP entry points on mutated input.
16
+
17
+ ## v0.1.10 — 2026-07-05
18
+
19
+ A CMS SignedData parser joins the pki.schema family; coverage-guided fuzzing now runs in CI.
20
+
21
+ ### Added
22
+
23
+ - pki.schema.cms.parse — a CMS SignedData parser per RFC 5652 (§3 ContentInfo + §5 SignedData). It turns a DER Buffer or a 'CMS' PEM string into { version, digestAlgorithms, encapContentInfo, certificates, crls, signerInfos }. encapContentInfo.eContent is the raw content (or null when the signature is detached); each SignerInfo carries its raw signature and, when signed attributes are present, the on-wire signedAttrsBytes for external verification. The signer identifier is an issuerAndSerialNumber or a subjectKeyIdentifier, with the version-to-identifier rule (RFC 5652 §5.3) enforced, and a degenerate certificates-only SignedData (empty digest algorithms and signer infos) is accepted. A ContentInfo whose content type is not id-signedData throws cms/unsupported-content-type (a recognized PKCS#7 type) or cms/unknown-content-type; a malformed structure throws a typed CmsError (cms/*) and a leaf-level codec fault surfaces as asn1/*. pki.schema.cms.pemDecode / pemEncode handle the PEM envelope, and pki.schema.parse detect-and-routes a CMS message.
24
+ - pki.asn1.read.octetStringImplicit and the pki.schema.engine.implicitOctetString(tag) leaf — read a context-tagged IMPLICIT OCTET STRING (the shape the CMS SignerIdentifier subjectKeyIdentifier [0] takes), the primitive-leaf sibling of implicitBitString.
25
+ - Coverage-guided fuzzing in CI — a ClusterFuzzLite integration (.clusterfuzzlite/) plus pull-request and nightly-batch workflows run jazzer.js + libFuzzer against the pki.asn1.decode, pki.schema.x509.parse and pki.schema.cms.parse harnesses. A finding fails the run with the reproducer inline and uploads the crash input as an artifact. The integration is detectable by OpenSSF Scorecard's Fuzzing check.
26
+
27
+ ### Fixed
28
+
29
+ - The OpenSSL interop runner counted a cross-check the oracle could not perform (for example, an OpenSSL predating ML-DSA) as a pass. Such a cross-check is now recorded as a skip and reported separately, so the interop pass count is not inflated by checks that never executed.
30
+
7
31
  ## v0.1.9 — 2026-07-05
8
32
 
9
33
  A PKCS#8 private-key parser joins the pki.schema family.
package/README.md CHANGED
@@ -195,9 +195,11 @@ is callable today; nothing below is a stub.
195
195
  | `pki.schema.crl` | Parse DER / PEM X.509 CRLs per RFC 5280 §5 — revoked serials with real-`Date` revocation times, named + partly-decoded extensions, fail-closed — `parse`, `pemDecode` |
196
196
  | `pki.schema.csr` | Parse DER / PEM PKCS#10 certification requests per RFC 2986 — subject DN, public key, requested attributes, signature, fail-closed — `parse`, `pemDecode`, `pemEncode` |
197
197
  | `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` |
198
+ | `pki.schema.cms` | Parse DER / PEM CMS SignedData per RFC 5652 — encapsulated content, signer infos, raw signed-attribute bytes for external verification, certificates / CRLs kept raw, fail-closed; non-SignedData content types recognized (not decoded) — `parse`, `pemDecode`, `pemEncode` |
199
+ | `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` |
198
200
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` plus the schema combinators |
199
201
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
200
- | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error`, each carrying a stable `code` in `domain/reason` form |
202
+ | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError`, each carrying a stable `code` in `domain/reason` form |
201
203
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
202
204
 
203
205
  ### CLI
@@ -219,6 +221,63 @@ composites) are on the roadmap and ride this same core. See
219
221
  [ROADMAP.md](ROADMAP.md) for the full plan and current status of each area, and
220
222
  [CHANGELOG.md](CHANGELOG.md) for what has landed.
221
223
 
224
+ ## Architecture
225
+
226
+ Every PKI format is a thin, declarative schema over one shared engine. A parser
227
+ declares the ASN.1 structure as data and hands it to `walk`; it never advances a
228
+ child cursor, re-checks a tag, or re-rolls PEM handling by hand. So each
229
+ structural rule — bounds-checked positional reads, optional / context-tagged
230
+ field ordering, SET-OF ascending-order and uniqueness, arity, and fail-closed
231
+ typed errors — is written **once** in the engine, and no new format can
232
+ reintroduce the bug class it prevents. Adding a format is a schema declaration
233
+ plus a documentation comment block, not new parse logic.
234
+
235
+ ```
236
+ Detect + route pki.schema.parse ── detects the format, routes to a sibling
237
+
238
+ Format parsers x509 crl csr pkcs8 cms ocsp (siblings)
239
+ (one schema each) └──────┴─────┴──────┴──────┴─────┘
240
+ │ every sibling COMPOSES ↓
241
+ Shared structure pki.schema.engine PKIX sub-schemas
242
+ walk + combinators · AlgorithmIdentifier · Name ·
243
+ (positional reads, Extension · version reader ·
244
+ tag ordering, SET-OF the one coerce → decode → walk
245
+ uniqueness, arity, parse-entry (PEM cap + DER
246
+ fail-closed errors) wrapping shared, never copied)
247
+ │ built on ↓
248
+ Foundation pki.asn1 pki.oid pki.errors pki.C
249
+ DER codec · OID registry · PkiError · constants
250
+ (strict, (two-way, taxonomy (LIMITS,
251
+ bounded) PQC-seeded) (domain/reason) scale)
252
+
253
+ Crypto pki.webcrypto ──▶ node:crypto
254
+ W3C SubtleCrypto over the platform: the classical set +
255
+ post-quantum ML-DSA / SLH-DSA signatures + ML-KEM keygen
256
+ ```
257
+
258
+ **Foundation.** The strict, bounded DER codec (`pki.asn1`), the two-way OID
259
+ registry (`pki.oid`), the `PkiError` taxonomy (`pki.errors`), and the
260
+ version-stable constants (`pki.C`). These have no PKI knowledge; they are the
261
+ bytes-and-names layer everything else stands on.
262
+
263
+ **Shared structure.** The declarative schema engine (`pki.schema.engine`) and the
264
+ PKIX sub-schemas it is fed — `AlgorithmIdentifier`, `Name`, `Extension`, the
265
+ bounded version reader, and the single coerce → decode → walk parse-entry that
266
+ every format's `parse` is bound to. Because input coercion, the PEM size cap, and
267
+ the DER-decode wrapping live here once, a format cannot diverge on a guard.
268
+
269
+ **Format parsers.** `x509`, `crl`, `csr`, `pkcs8`, `cms`, and `ocsp` are siblings:
270
+ each is a schema declaration composed from the shared pieces, emitting its own
271
+ typed `domain/reason` error codes. `pki.schema.parse` inspects a decoded root and
272
+ detect-and-routes to the matching sibling; the detectors are mutually exclusive by
273
+ construction, so routing is unambiguous regardless of registration order.
274
+
275
+ **Crypto.** `pki.webcrypto` is a W3C `SubtleCrypto` engine over `node:crypto`,
276
+ carrying the classical suite plus post-quantum ML-DSA / SLH-DSA signatures and
277
+ ML-KEM key generation. Sign/verify resolves algorithms through the same OID
278
+ registry the parsers read, so the signing surface and the parsing surface share
279
+ one algorithm vocabulary.
280
+
222
281
  ## Security posture
223
282
 
224
283
  - **Zero npm runtime dependencies, nothing vendored.** The cryptography runs on
package/lib/asn1-der.js CHANGED
@@ -360,6 +360,20 @@ function readOctetString(node) {
360
360
  return node.content;
361
361
  }
362
362
 
363
+ // Read a [tag] IMPLICIT OCTET STRING — a context-class PRIMITIVE node whose
364
+ // content is the octet-string body (the IMPLICIT tag replaces the universal one,
365
+ // so there is no inner universal node). Primitive-only, like its universal
366
+ // counterpart, so the BER constructed/streamed form is rejected. Used for the CMS
367
+ // SignerIdentifier subjectKeyIdentifier [0] (RFC 5652 §5.3).
368
+ function readOctetStringImplicit(node, tag) {
369
+ if (node.tagClass !== "context" || node.tagNumber !== tag) {
370
+ throw new Asn1Error("asn1/unexpected-tag", "readOctetStringImplicit: expected context tag [" + tag +
371
+ "], got " + node.tagClass + "/" + node.tagNumber);
372
+ }
373
+ _expectPrimitive(node, "readOctetStringImplicit");
374
+ return node.content;
375
+ }
376
+
363
377
  function readNull(node) {
364
378
  _expectUniversal(node, TAGS.NULL, "readNull");
365
379
  _expectPrimitive(node, "readNull");
@@ -367,6 +381,20 @@ function readNull(node) {
367
381
  return null;
368
382
  }
369
383
 
384
+ // Read a [tag] IMPLICIT NULL — a context-class PRIMITIVE node with empty content.
385
+ // The IMPLICIT tag replaces the universal NULL tag, so there is no inner universal
386
+ // node. The empty-content and primitive-form rules of a universal NULL still hold.
387
+ // Used for the OCSP CertStatus good [0] / unknown [2] arms (RFC 6960 §4.2.1).
388
+ function readNullImplicit(node, tag) {
389
+ if (node.tagClass !== "context" || node.tagNumber !== tag) {
390
+ throw new Asn1Error("asn1/unexpected-tag", "readNullImplicit: expected context tag [" + tag +
391
+ "], got " + node.tagClass + "/" + node.tagNumber);
392
+ }
393
+ _expectPrimitive(node, "readNullImplicit");
394
+ if (node.content.length !== 0) throw new Asn1Error("asn1/bad-null", "IMPLICIT NULL must have empty content");
395
+ return null;
396
+ }
397
+
370
398
  /**
371
399
  * @primitive pki.asn1.readOid
372
400
  * @signature pki.asn1.read.oid(node) -> "1.2.840.113549.1.1.11"
@@ -794,7 +822,9 @@ module.exports = {
794
822
  bitString: readBitString,
795
823
  bitStringImplicit: readBitStringImplicit,
796
824
  octetString: readOctetString,
825
+ octetStringImplicit: readOctetStringImplicit,
797
826
  nullValue: readNull,
827
+ nullImplicit: readNullImplicit,
798
828
  oid: readOid,
799
829
  string: readString,
800
830
  time: readTime,
@@ -137,6 +137,14 @@ var CsrError = defineClass("CsrError", { withCause: true });
137
137
  // OneAsymmetricKey or EncryptedPrivateKeyInfo (RFC 5208 §5, RFC 5958 §2/§3).
138
138
  var Pkcs8Error = defineClass("Pkcs8Error", { withCause: true });
139
139
 
140
+ // CmsError — a byte sequence that is not a well-formed CMS ContentInfo /
141
+ // SignedData (RFC 5652). Carries the underlying leaf fault as `.cause`.
142
+ var CmsError = defineClass("CmsError", { withCause: true });
143
+
144
+ // OcspError — a byte sequence that is not a well-formed OCSP request or response
145
+ // (OCSPRequest / OCSPResponse — RFC 6960 §4). Carries the leaf fault as `.cause`.
146
+ var OcspError = defineClass("OcspError", { withCause: true });
147
+
140
148
  module.exports = {
141
149
  PkiError: PkiError,
142
150
  defineClass: defineClass,
@@ -149,4 +157,6 @@ module.exports = {
149
157
  SchemaError: SchemaError,
150
158
  CsrError: CsrError,
151
159
  Pkcs8Error: Pkcs8Error,
160
+ CmsError: CmsError,
161
+ OcspError: OcspError,
152
162
  };
package/lib/oid.js CHANGED
@@ -65,13 +65,38 @@ var FAMILIES = {
65
65
  // PKIX private extensions (authorityInfoAccess et al).
66
66
  pkixAccess: { base: [1, 3, 6, 1, 5, 5, 7, 1], of: { authorityInfoAccess: 1 } },
67
67
 
68
+ // PKIX Access Descriptor methods (id-ad, RFC 5280 §4.2.2.1/§4.2.2.2). id-ad-ocsp
69
+ // is the arc the OCSP responder OIDs hang under; id-ad-caIssuers names the AIA
70
+ // CA-issuers access method.
71
+ adAccess: { base: [1, 3, 6, 1, 5, 5, 7, 48], of: { ocsp: 1, caIssuers: 2 } },
72
+
73
+ // OCSP (RFC 6960) on the id-pkix-ocsp arc (= id-ad-ocsp). id-pkix-ocsp-basic is
74
+ // the ResponseBytes.responseType this build decodes; id-pkix-ocsp-nonce (§4.4.1)
75
+ // names the nonce extension; the remaining members name the other OCSP extensions
76
+ // (CRL references, acceptable-response-types, archive-cutoff, service-locator,
77
+ // preferred-signature-algorithms, extended-revoke — RFC 6960 §4.4, RFC 9654).
78
+ ocsp: { base: [1, 3, 6, 1, 5, 5, 7, 48, 1], of: {
79
+ ocspBasic: 1, ocspNonce: 2, ocspCrl: 3, ocspResponse: 4, ocspNoCheck: 5,
80
+ ocspArchiveCutoff: 6, ocspServiceLocator: 7, ocspPrefSigAlgs: 8, ocspExtendedRevoke: 9 } },
81
+
68
82
  // PKCS#1 RSA public-key + RSASSA signature algorithms.
69
83
  rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
70
84
  rsaEncryption: 1, rsassaPss: 10, sha256WithRSAEncryption: 11,
71
85
  sha384WithRSAEncryption: 12, sha512WithRSAEncryption: 13 } },
72
86
 
73
- // PKCS#9 attribute types.
74
- pkcs9: { base: [1, 2, 840, 113549, 1, 9], of: { emailAddress: 1, challengePassword: 7, extensionRequest: 14 } },
87
+ // PKCS#7 / CMS content types (RFC 5652 §4, RFC 2315). id-signedData is the one
88
+ // this toolkit structurally decodes; the rest are recognized-and-deferred.
89
+ pkcs7: { base: [1, 2, 840, 113549, 1, 7], of: {
90
+ data: 1, signedData: 2, envelopedData: 3, signedAndEnvelopedData: 4,
91
+ digestedData: 5, encryptedData: 6 } },
92
+
93
+ // PKCS#9 attribute types — incl. the CMS signed-attribute OIDs (RFC 5652 §11).
94
+ pkcs9: { base: [1, 2, 840, 113549, 1, 9], of: {
95
+ emailAddress: 1, contentType: 3, messageDigest: 4, signingTime: 5,
96
+ challengePassword: 7, extensionRequest: 14 } },
97
+
98
+ // S/MIME content types on the PKCS#9 smime arc (RFC 5652, RFC 3161): id-ct.
99
+ smimeCt: { base: [1, 2, 840, 113549, 1, 9, 16, 1], of: { authData: 2, tSTInfo: 4 } },
75
100
 
76
101
  // ANSI X9.62 EC public key, named curve, and ECDSA signatures.
77
102
  ansiX962: { base: [1, 2, 840, 10045], of: {
@@ -84,6 +109,10 @@ var FAMILIES = {
84
109
  // Edwards / Montgomery curves (RFC 8410).
85
110
  edwards: { base: [1, 3, 101], of: { X25519: 110, X448: 111, Ed25519: 112, Ed448: 113 } },
86
111
 
112
+ // OIW Secsig SHA-1 (1.3.14.3.2.26) — the most common OCSP CertID hashAlgorithm;
113
+ // nistHash covers only SHA-256 and above.
114
+ oiwSecsig: { base: [1, 3, 14, 3, 2], of: { sha1: 26 } },
115
+
87
116
  // NIST hash functions (SHA-2, SHA-3, SHAKE).
88
117
  nistHash: { base: [2, 16, 840, 1, 101, 3, 4, 2], of: {
89
118
  sha256: 1, sha384: 2, sha512: 3, "sha3-256": 8, "sha3-512": 10, shake256: 12 } },
package/lib/schema-all.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * @intro
13
13
  * The schema family: a declarative ASN.1 structure-schema engine and the
14
14
  * per-format parsers built on it. Every format (X.509 certificates, CRLs,
15
- * PKCS#10 certification requests, and as they land — CMS / PKCS#8) is a
15
+ * PKCS#10 certification requests, PKCS#8 private keys, and CMS SignedData) is a
16
16
  * member that COMPOSES the
17
17
  * shared engine and the shared PKIX sub-schemas (AlgorithmIdentifier, Name,
18
18
  * Extension), so a structural rule — bounds-checked positional reads,
@@ -36,6 +36,8 @@ var x509 = require("./schema-x509");
36
36
  var crl = require("./schema-crl");
37
37
  var csr = require("./schema-csr");
38
38
  var pkcs8 = require("./schema-pkcs8");
39
+ var cms = require("./schema-cms");
40
+ var ocsp = require("./schema-ocsp");
39
41
  var frameworkError = require("./framework-error");
40
42
 
41
43
  var SchemaError = frameworkError.SchemaError;
@@ -52,6 +54,38 @@ var ENTRY = { pemLabel: null, PemError: PemError, ErrorClass: SchemaError, prefi
52
54
  // logic. Order matters: the most specific detector wins, so a new member is
53
55
  // inserted ahead of any more-permissive one.
54
56
  var FORMATS = [
57
+ {
58
+ // CMS ContentInfo (RFC 5652 §3) — the only registered root that leads with an
59
+ // OBJECT IDENTIFIER child (a SEQUENCE of exactly 2: contentType OID + a
60
+ // context [0] EXPLICIT content wrapper). Disjoint from the INTEGER-first pkcs8
61
+ // and the tbs-SEQUENCE-first signed-envelope trio, so it detects unambiguously.
62
+ // A non-SignedData content type routes here and gets a precise
63
+ // cms/unsupported-content-type rather than schema/unknown-format.
64
+ name: "cms",
65
+ module: cms,
66
+ detect: cms.matches,
67
+ parse: function (input) { return cms.parse(input); },
68
+ },
69
+ {
70
+ // OCSPRequest ::= SEQUENCE { tbsRequest SEQUENCE, optionalSignature [0] EXPLICIT
71
+ // OPTIONAL } — a SEQUENCE of 1-2 whose first child is the tbsRequest SEQUENCE.
72
+ // Leads with a SEQUENCE like the signed-envelope trio, but is excluded by arity
73
+ // (the trio is EXACTLY 3 children; an OCSPRequest is 1-2), so it detects
74
+ // unambiguously regardless of registry order (RFC 6960 §4.1.1).
75
+ name: "ocsp-request",
76
+ module: ocsp,
77
+ detect: ocsp.matchesRequest,
78
+ parse: function (input) { return ocsp.parseRequest(input); },
79
+ },
80
+ {
81
+ // OCSPResponse ::= SEQUENCE { responseStatus ENUMERATED, responseBytes [0]
82
+ // EXPLICIT OPTIONAL } — the only registered root that leads with an ENUMERATED
83
+ // child, so it is disjoint from every other format (RFC 6960 §4.2.1).
84
+ name: "ocsp-response",
85
+ module: ocsp,
86
+ detect: ocsp.matchesResponse,
87
+ parse: function (input) { return ocsp.parseResponse(input); },
88
+ },
55
89
  {
56
90
  // PKCS#8 PrivateKeyInfo / OneAsymmetricKey — SEQUENCE whose first child is an
57
91
  // INTEGER (version) and third an OCTET STRING (privateKey); disjoint from the
@@ -107,7 +141,7 @@ var FORMATS = [
107
141
  * The names of every registered format, in detection order.
108
142
  *
109
143
  * @example
110
- * pki.schema.all(); // → ["pkcs8", "csr", "crl", "x509"]
144
+ * pki.schema.all(); // → ["cms", "ocsp-request", "ocsp-response", "pkcs8", "csr", "crl", "x509"]
111
145
  */
112
146
  function all() { return FORMATS.map(function (f) { return f.name; }); }
113
147
 
@@ -149,6 +183,8 @@ module.exports = {
149
183
  crl: { parse: crl.parse, pemDecode: crl.pemDecode },
150
184
  csr: { parse: csr.parse, pemDecode: csr.pemDecode, pemEncode: csr.pemEncode },
151
185
  pkcs8: { parse: pkcs8.parse, parseEncrypted: pkcs8.parseEncrypted, pemDecode: pkcs8.pemDecode, pemEncode: pkcs8.pemEncode },
186
+ cms: { parse: cms.parse, pemDecode: cms.pemDecode, pemEncode: cms.pemEncode },
187
+ ocsp: { parseRequest: ocsp.parseRequest, parseResponse: ocsp.parseResponse, pemDecode: ocsp.pemDecode },
152
188
  all: all,
153
189
  parse: parse,
154
190
  };