@blamejs/pki 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,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.1.10 — 2026-07-05
8
+
9
+ A CMS SignedData parser joins the pki.schema family; coverage-guided fuzzing now runs in CI.
10
+
11
+ ### Added
12
+
13
+ - 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.
14
+ - 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.
15
+ - 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.
16
+
17
+ ### Fixed
18
+
19
+ - 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.
20
+
7
21
  ## v0.1.9 — 2026-07-05
8
22
 
9
23
  A PKCS#8 private-key parser joins the pki.schema family.
package/README.md CHANGED
@@ -195,9 +195,10 @@ 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` |
198
199
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` plus the schema combinators |
199
200
  | `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 |
201
+ | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError`, each carrying a stable `code` in `domain/reason` form |
201
202
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
202
203
 
203
204
  ### CLI
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");
@@ -794,6 +808,7 @@ module.exports = {
794
808
  bitString: readBitString,
795
809
  bitStringImplicit: readBitStringImplicit,
796
810
  octetString: readOctetString,
811
+ octetStringImplicit: readOctetStringImplicit,
797
812
  nullValue: readNull,
798
813
  oid: readOid,
799
814
  string: readString,
@@ -137,6 +137,10 @@ 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
+
140
144
  module.exports = {
141
145
  PkiError: PkiError,
142
146
  defineClass: defineClass,
@@ -149,4 +153,5 @@ module.exports = {
149
153
  SchemaError: SchemaError,
150
154
  CsrError: CsrError,
151
155
  Pkcs8Error: Pkcs8Error,
156
+ CmsError: CmsError,
152
157
  };
package/lib/oid.js CHANGED
@@ -70,8 +70,19 @@ var FAMILIES = {
70
70
  rsaEncryption: 1, rsassaPss: 10, sha256WithRSAEncryption: 11,
71
71
  sha384WithRSAEncryption: 12, sha512WithRSAEncryption: 13 } },
72
72
 
73
- // PKCS#9 attribute types.
74
- pkcs9: { base: [1, 2, 840, 113549, 1, 9], of: { emailAddress: 1, challengePassword: 7, extensionRequest: 14 } },
73
+ // PKCS#7 / CMS content types (RFC 5652 §4, RFC 2315). id-signedData is the one
74
+ // this toolkit structurally decodes; the rest are recognized-and-deferred.
75
+ pkcs7: { base: [1, 2, 840, 113549, 1, 7], of: {
76
+ data: 1, signedData: 2, envelopedData: 3, signedAndEnvelopedData: 4,
77
+ digestedData: 5, encryptedData: 6 } },
78
+
79
+ // PKCS#9 attribute types — incl. the CMS signed-attribute OIDs (RFC 5652 §11).
80
+ pkcs9: { base: [1, 2, 840, 113549, 1, 9], of: {
81
+ emailAddress: 1, contentType: 3, messageDigest: 4, signingTime: 5,
82
+ challengePassword: 7, extensionRequest: 14 } },
83
+
84
+ // S/MIME content types on the PKCS#9 smime arc (RFC 5652, RFC 3161): id-ct.
85
+ smimeCt: { base: [1, 2, 840, 113549, 1, 9, 16, 1], of: { authData: 2, tSTInfo: 4 } },
75
86
 
76
87
  // ANSI X9.62 EC public key, named curve, and ECDSA signatures.
77
88
  ansiX962: { base: [1, 2, 840, 10045], of: {
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,7 @@ 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");
39
40
  var frameworkError = require("./framework-error");
40
41
 
41
42
  var SchemaError = frameworkError.SchemaError;
@@ -52,6 +53,18 @@ var ENTRY = { pemLabel: null, PemError: PemError, ErrorClass: SchemaError, prefi
52
53
  // logic. Order matters: the most specific detector wins, so a new member is
53
54
  // inserted ahead of any more-permissive one.
54
55
  var FORMATS = [
56
+ {
57
+ // CMS ContentInfo (RFC 5652 §3) — the only registered root that leads with an
58
+ // OBJECT IDENTIFIER child (a SEQUENCE of exactly 2: contentType OID + a
59
+ // context [0] EXPLICIT content wrapper). Disjoint from the INTEGER-first pkcs8
60
+ // and the tbs-SEQUENCE-first signed-envelope trio, so it detects unambiguously.
61
+ // A non-SignedData content type routes here and gets a precise
62
+ // cms/unsupported-content-type rather than schema/unknown-format.
63
+ name: "cms",
64
+ module: cms,
65
+ detect: cms.matches,
66
+ parse: function (input) { return cms.parse(input); },
67
+ },
55
68
  {
56
69
  // PKCS#8 PrivateKeyInfo / OneAsymmetricKey — SEQUENCE whose first child is an
57
70
  // INTEGER (version) and third an OCTET STRING (privateKey); disjoint from the
@@ -107,7 +120,7 @@ var FORMATS = [
107
120
  * The names of every registered format, in detection order.
108
121
  *
109
122
  * @example
110
- * pki.schema.all(); // → ["pkcs8", "csr", "crl", "x509"]
123
+ * pki.schema.all(); // → ["cms", "pkcs8", "csr", "crl", "x509"]
111
124
  */
112
125
  function all() { return FORMATS.map(function (f) { return f.name; }); }
113
126
 
@@ -149,6 +162,7 @@ module.exports = {
149
162
  crl: { parse: crl.parse, pemDecode: crl.pemDecode },
150
163
  csr: { parse: csr.parse, pemDecode: csr.pemDecode, pemEncode: csr.pemEncode },
151
164
  pkcs8: { parse: pkcs8.parse, parseEncrypted: pkcs8.parseEncrypted, pemDecode: pkcs8.pemDecode, pemEncode: pkcs8.pemEncode },
165
+ cms: { parse: cms.parse, pemDecode: cms.pemDecode, pemEncode: cms.pemEncode },
152
166
  all: all,
153
167
  parse: parse,
154
168
  };
@@ -0,0 +1,375 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.schema.cms
6
+ * @nav Schema
7
+ * @title CMS
8
+ * @order 160
9
+ * @slug cms
10
+ *
11
+ * @intro
12
+ * CMS SignedData handling per RFC 5652 (§3 ContentInfo envelope, §5 SignedData).
13
+ * `parse` turns a DER or PEM (`CMS`) message into a structured object: the
14
+ * SignedData version, the digest algorithms, the encapsulated content, the
15
+ * certificate / CRL sets, and the signer infos. It is an OID-dispatch envelope —
16
+ * ContentInfo reads its `contentType` and structurally decodes only
17
+ * `id-signedData`; the other PKCS#7 content types (EnvelopedData, EncryptedData,
18
+ * …) are recognized and rejected with a precise `cms/unsupported-content-type`
19
+ * rather than a generic unknown-format error.
20
+ *
21
+ * CMS is a signed container: the bytes an external verifier must hash are
22
+ * surfaced RAW and never re-serialized. `encapContentInfo.eContent` is the raw
23
+ * content (or `null` for a detached signature); each SignerInfo's `signature` is
24
+ * raw, and `signedAttrsBytes` is the on-wire `[0]` SignedAttributes TLV so a
25
+ * verifier can re-tag it to the universal SET the signature is computed over
26
+ * (§5.4). Embedded certificates and CRLs are surfaced as raw DER + their outer
27
+ * tag, so an obsolete or unknown alternative never fails the parse. DER-only,
28
+ * fail-closed.
29
+ *
30
+ * @card
31
+ * Parse DER / PEM CMS SignedData (RFC 5652) into structured, validated fields —
32
+ * encapsulated content, signer infos, raw signed-attribute bytes for external
33
+ * verification, certificates/CRLs kept raw, fail-closed.
34
+ */
35
+
36
+ var asn1 = require("./asn1-der");
37
+ var schema = require("./schema-engine");
38
+ var pkix = require("./schema-pkix");
39
+ var oid = require("./oid");
40
+ var frameworkError = require("./framework-error");
41
+
42
+ var CmsError = frameworkError.CmsError;
43
+ var PemError = frameworkError.PemError;
44
+
45
+ // The cms error namespace the schema engine walks under.
46
+ var NS = pkix.makeNS("cms", CmsError, oid);
47
+
48
+ var ALGORITHM_IDENTIFIER = pkix.algorithmIdentifier(NS);
49
+ var ATTRIBUTE = pkix.attribute(NS);
50
+ var NAME = pkix.name(NS);
51
+
52
+ // CMSVersion ::= INTEGER — the SignedData version is {1,3,4,5} and the SignerInfo
53
+ // version is {1,3} (RFC 5652 §5.1, §5.3). Wider accept maps than any other format.
54
+ var SIGNED_DATA_VERSION = pkix.versionReader(NS, { "1": 1, "3": 3, "4": 4, "5": 5 });
55
+ var SIGNER_VERSION = pkix.versionReader(NS, { "1": 1, "3": 3 });
56
+
57
+ // id-signedData is the one content type this build structurally decodes; the rest
58
+ // are recognized-and-deferred (a precise diagnostic, not a silent unknown-format).
59
+ // OIDs resolve from the registry (pkcs7 / smimeCt families), never dotted literals.
60
+ var OID_SIGNED_DATA = oid.byName("signedData");
61
+ var OID_DATA = oid.byName("data");
62
+ var DEFERRED = new Set([
63
+ oid.byName("data"), oid.byName("envelopedData"), oid.byName("signedAndEnvelopedData"),
64
+ oid.byName("digestedData"), oid.byName("encryptedData"), oid.byName("authData"),
65
+ ]);
66
+ // The two mandatory signed-attribute types (RFC 5652 §5.3, §11.1/§11.2).
67
+ var OID_CONTENT_TYPE = oid.byName("contentType");
68
+ var OID_MESSAGE_DIGEST = oid.byName("messageDigest");
69
+
70
+ // Enforce the SignedAttributes value constraints (RFC 5652 §5.3, §11) — if
71
+ // signedAttrs is present it MUST contain exactly one content-type attribute and
72
+ // exactly one message-digest attribute, each single-valued. The content-type value
73
+ // == eContentType and messageDigest == the actual content hash are §5.6
74
+ // VERIFICATION concerns (surfaced, checked by the verifier), not structural-parse
75
+ // concerns.
76
+ function _checkSignedAttrs(attrs) {
77
+ var ct = 0, md = 0, seen = Object.create(null);
78
+ for (var i = 0; i < attrs.length; i++) {
79
+ var a = attrs[i];
80
+ // RFC 5652 §5.3 — a signerInfo MUST NOT include multiple instances of the
81
+ // same signed-attribute type (specific codes for the two mandatory types).
82
+ if (seen[a.type]) {
83
+ if (a.type === OID_CONTENT_TYPE) throw NS.E("cms/duplicate-content-type", "signedAttrs must not repeat the content-type attribute");
84
+ if (a.type === OID_MESSAGE_DIGEST) throw NS.E("cms/duplicate-message-digest", "signedAttrs must not repeat the message-digest attribute");
85
+ throw NS.E("cms/duplicate-signed-attr", "signedAttrs must not include multiple instances of the same attribute type (" + a.type + ")");
86
+ }
87
+ seen[a.type] = true;
88
+ if (a.type === OID_CONTENT_TYPE) {
89
+ ct += 1;
90
+ if (a.values.length !== 1) throw NS.E("cms/bad-content-type-attr", "the content-type signed attribute must be single-valued");
91
+ // ContentType ::= OBJECT IDENTIFIER (RFC 5652 §11.1) — validate the value's
92
+ // full syntax (tag AND minimal base-128 OID content), not just the tag, so a
93
+ // truncated / non-minimal subidentifier is rejected here, not at verify time.
94
+ try { asn1.read.oid(asn1.decode(a.values[0])); }
95
+ catch (e) { throw NS.E("cms/bad-content-type-attr", "the content-type signed attribute value must be a valid OBJECT IDENTIFIER", e); }
96
+ } else if (a.type === OID_MESSAGE_DIGEST) {
97
+ md += 1;
98
+ if (a.values.length !== 1) throw NS.E("cms/bad-message-digest-attr", "the message-digest signed attribute must be single-valued");
99
+ // MessageDigest ::= OCTET STRING (RFC 5652 §11.2) — validate the full syntax.
100
+ try { asn1.read.octetString(asn1.decode(a.values[0])); }
101
+ catch (e) { throw NS.E("cms/bad-message-digest-attr", "the message-digest signed attribute value must be an OCTET STRING", e); }
102
+ }
103
+ }
104
+ // Duplicates are rejected in the loop (the seen-set); here only presence.
105
+ if (ct === 0) throw NS.E("cms/missing-content-type", "signedAttrs must contain a content-type attribute (RFC 5652 §11.1)");
106
+ if (md === 0) throw NS.E("cms/missing-message-digest", "signedAttrs must contain a message-digest attribute (RFC 5652 §11.2)");
107
+ }
108
+
109
+ // A CertificateChoices / RevocationInfoChoice element, surfaced RAW (its DER +
110
+ // outer tag) rather than recursively parsed — the obsolete CHOICE alternatives
111
+ // (extendedCertificate, attribute certs, otherRevocationInfo) never fail the
112
+ // parse, and a caller re-parses a `certificate`/`CertificateList` element itself.
113
+ function rawElement(item) {
114
+ return { bytes: item.node.bytes, tagClass: item.node.tagClass, tagNumber: item.node.tagNumber };
115
+ }
116
+
117
+ // RFC 5652 §5.1 — the exact SignedData CMSVersion, computed from the raw
118
+ // CertificateChoices / RevocationInfoChoice outer tags (a certificate `other` is
119
+ // [3], a v2AttrCert [2], a v1AttrCert [1]; a RevocationInfoChoice `other` is [1]),
120
+ // the SignerInfo versions, and whether the content type is id-data.
121
+ function _expectedSignedDataVersion(certificates, crls, signerInfos, eContentType) {
122
+ function ctx(el, n) { return el.tagClass === "context" && el.tagNumber === n; }
123
+ var otherCert = certificates.some(function (c) { return ctx(c, 3); });
124
+ var otherCrl = crls.some(function (c) { return ctx(c, 1); });
125
+ if (otherCert || otherCrl) return 5;
126
+ if (certificates.some(function (c) { return ctx(c, 2); })) return 4; // v2AttrCert [2]
127
+ if (certificates.some(function (c) { return ctx(c, 1); }) || // v1AttrCert [1]
128
+ signerInfos.some(function (si) { return si.version === 3; }) ||
129
+ eContentType !== OID_DATA) return 3;
130
+ return 1;
131
+ }
132
+
133
+ // EncapsulatedContentInfo ::= SEQUENCE { eContentType OID,
134
+ // eContent [0] EXPLICIT OCTET STRING OPTIONAL } (RFC 5652 §5.2). Absent eContent
135
+ // is a detached signature (surfaced as null); present is the raw content bytes.
136
+ var ENCAP_CONTENT_INFO = schema.seq([
137
+ schema.field("eContentType", schema.oidLeaf()),
138
+ schema.optional("eContent", schema.octetString(), { tag: 0, explicit: true, emptyCode: "cms/bad-econtent" }),
139
+ ], {
140
+ assert: "sequence", arity: { min: 1, max: 2 }, code: "cms/bad-encap-content-info", what: "EncapsulatedContentInfo",
141
+ build: function (m) {
142
+ return {
143
+ eContentType: m.fields.eContentType.value,
144
+ eContent: m.fields.eContent.present ? m.fields.eContent.value : null,
145
+ };
146
+ },
147
+ });
148
+
149
+ // IssuerAndSerialNumber ::= SEQUENCE { issuer Name, serialNumber INTEGER }
150
+ // (RFC 5652 §10.2.4). serialNumberHex preserves the DER sign padding.
151
+ var ISSUER_AND_SERIAL = schema.seq([
152
+ schema.field("issuer", NAME),
153
+ schema.field("serialNumber", schema.integerLeaf()),
154
+ ], {
155
+ assert: "sequence", arity: { exact: 2 }, code: "cms/bad-issuer-and-serial", what: "IssuerAndSerialNumber",
156
+ build: function (m) {
157
+ return {
158
+ issuer: m.fields.issuer.value.result,
159
+ serialNumber: m.fields.serialNumber.value,
160
+ serialNumberHex: m.fields.serialNumber.node.content.toString("hex"),
161
+ };
162
+ },
163
+ });
164
+
165
+ // SignerIdentifier ::= CHOICE { issuerAndSerialNumber IssuerAndSerialNumber,
166
+ // subjectKeyIdentifier [0] IMPLICIT OCTET STRING } (RFC 5652 §5.3) — the arm is
167
+ // disambiguated by tag (universal SEQUENCE vs context [0]).
168
+ var SIGNER_IDENTIFIER = schema.choice([
169
+ { when: { tagClass: "universal", tagNumber: asn1.TAGS.SEQUENCE }, schema: ISSUER_AND_SERIAL },
170
+ { when: { tagClass: "context", tagNumber: 0 }, schema: schema.implicitOctetString(0) },
171
+ ], { code: "cms/bad-signer-identifier", what: "SignerIdentifier" });
172
+
173
+ // SignerInfo ::= SEQUENCE { version, sid SignerIdentifier, digestAlgorithm,
174
+ // signedAttrs [0] IMPLICIT OPTIONAL, signatureAlgorithm, signature OCTET STRING,
175
+ // unsignedAttrs [1] IMPLICIT OPTIONAL } (RFC 5652 §5.3). signedAttrs/unsignedAttrs
176
+ // are positional optionals (a required signatureAlgorithm sits between them, so
177
+ // they cannot be a trailing block).
178
+ var SIGNER_INFO = schema.seq([
179
+ schema.field("version", SIGNER_VERSION),
180
+ schema.field("sid", SIGNER_IDENTIFIER),
181
+ schema.field("digestAlgorithm", ALGORITHM_IDENTIFIER),
182
+ schema.optional("signedAttrs", schema.implicitSetOf(0, ATTRIBUTE, { min: 1, code: "cms/bad-signed-attrs", what: "signedAttrs" }), { tag: 0 }),
183
+ schema.field("signatureAlgorithm", ALGORITHM_IDENTIFIER),
184
+ schema.field("signature", schema.octetString()),
185
+ schema.optional("unsignedAttrs", schema.implicitSetOf(1, ATTRIBUTE, { min: 1, code: "cms/bad-unsigned-attrs", what: "unsignedAttrs" }), { tag: 1 }),
186
+ ], {
187
+ assert: "sequence", code: "cms/bad-signer-info", what: "SignerInfo",
188
+ build: function (m) {
189
+ var version = m.fields.version.value;
190
+ var sidNode = m.fields.sid.node;
191
+ var isSkid = sidNode.tagClass === "context" && sidNode.tagNumber === 0;
192
+ var sid;
193
+ if (isSkid) {
194
+ // RFC 5652 §5.3 — a subjectKeyIdentifier sid forces SignerInfo version 3.
195
+ if (version !== 3) throw NS.E("cms/bad-signer-version", "a subjectKeyIdentifier signer identifier requires SignerInfo version 3");
196
+ sid = { subjectKeyIdentifier: m.fields.sid.value };
197
+ } else {
198
+ // RFC 5652 §5.3 — an issuerAndSerialNumber sid forces SignerInfo version 1.
199
+ if (version !== 1) throw NS.E("cms/bad-signer-version", "an issuerAndSerialNumber signer identifier requires SignerInfo version 1");
200
+ sid = m.fields.sid.value.result;
201
+ }
202
+ var signedAttrs = null, signedAttrsBytes = null;
203
+ if (m.fields.signedAttrs.present) {
204
+ signedAttrs = m.fields.signedAttrs.value.items.map(function (it) { return it.value.result; });
205
+ _checkSignedAttrs(signedAttrs);
206
+ // The raw on-wire signedAttrs bytes (leading 0xA0) so a verifier can re-tag to
207
+ // a universal SET and reproduce the signed hash (RFC 5652 §5.4).
208
+ signedAttrsBytes = m.fields.signedAttrs.node.bytes;
209
+ }
210
+ return {
211
+ version: version,
212
+ sid: sid,
213
+ digestAlgorithm: m.fields.digestAlgorithm.value.result,
214
+ signedAttrs: signedAttrs,
215
+ signedAttrsBytes: signedAttrsBytes,
216
+ signatureAlgorithm: m.fields.signatureAlgorithm.value.result,
217
+ signature: m.fields.signature.value,
218
+ unsignedAttrs: m.fields.unsignedAttrs.present ? m.fields.unsignedAttrs.value.items.map(function (it) { return it.value.result; }) : null,
219
+ };
220
+ },
221
+ });
222
+
223
+ // SignedData ::= SEQUENCE { version CMSVersion, digestAlgorithms SET OF,
224
+ // encapContentInfo, certificates [0] IMPLICIT OPTIONAL, crls [1] IMPLICIT
225
+ // OPTIONAL, signerInfos SET OF } (RFC 5652 §5.1). digestAlgorithms and
226
+ // signerInfos are min:0 — a degenerate certs-only SignedData carries neither.
227
+ var SIGNED_DATA = schema.seq([
228
+ schema.field("version", SIGNED_DATA_VERSION),
229
+ schema.field("digestAlgorithms", schema.setOf(ALGORITHM_IDENTIFIER, { min: 0, code: "cms/bad-digest-algorithms", what: "digestAlgorithms" })),
230
+ schema.field("encapContentInfo", ENCAP_CONTENT_INFO),
231
+ schema.optional("certificates", schema.implicitSetOf(0, schema.any(), { min: 1, code: "cms/bad-certificates", what: "certificates" }), { tag: 0 }),
232
+ schema.optional("crls", schema.implicitSetOf(1, schema.any(), { min: 1, code: "cms/bad-crls", what: "crls" }), { tag: 1 }),
233
+ schema.field("signerInfos", schema.setOf(SIGNER_INFO, { min: 0, code: "cms/bad-signer-infos", what: "signerInfos" })),
234
+ ], {
235
+ assert: "sequence", code: "cms/bad-signed-data", what: "SignedData",
236
+ build: function (m) {
237
+ var version = m.fields.version.value;
238
+ var encapContentInfo = m.fields.encapContentInfo.value.result;
239
+ var certificates = m.fields.certificates.present ? m.fields.certificates.value.items.map(rawElement) : [];
240
+ var crls = m.fields.crls.present ? m.fields.crls.value.items.map(rawElement) : [];
241
+ var signerInfos = m.fields.signerInfos.value.items.map(function (it) { return it.value.result; });
242
+
243
+ // RFC 5652 §5.3 — signedAttrs MAY be omitted only when the content type is
244
+ // id-data; any other eContentType requires each SignerInfo to carry signedAttrs
245
+ // (so the content-type + message-digest attributes bind the signature).
246
+ if (encapContentInfo.eContentType !== OID_DATA) {
247
+ for (var s = 0; s < signerInfos.length; s++) {
248
+ if (signerInfos[s].signedAttrs === null) throw NS.E("cms/missing-signed-attrs", "a SignerInfo must carry signedAttrs when the content type is not id-data (RFC 5652 §5.3)");
249
+ }
250
+ }
251
+
252
+ // RFC 5652 §5.3 — when signedAttrs are present, the content-type attribute's
253
+ // value MUST equal the eContentType (a cross-field consistency both parsed
254
+ // here; a mismatch is an internally-inconsistent SignedData).
255
+ for (var si = 0; si < signerInfos.length; si++) {
256
+ var sa = signerInfos[si].signedAttrs;
257
+ if (!sa) continue;
258
+ for (var ai = 0; ai < sa.length; ai++) {
259
+ if (sa[ai].type !== OID_CONTENT_TYPE) continue;
260
+ var ctv = asn1.read.oid(asn1.decode(sa[ai].values[0]));
261
+ if (ctv !== encapContentInfo.eContentType) throw NS.E("cms/content-type-mismatch", "the content-type signed attribute (" + ctv + ") must equal the eContentType (" + encapContentInfo.eContentType + ") (RFC 5652 §5.3)");
262
+ }
263
+ }
264
+
265
+ // RFC 5652 §5.1 — the SignedData CMSVersion is determined by its contents.
266
+ var expected = _expectedSignedDataVersion(certificates, crls, signerInfos, encapContentInfo.eContentType);
267
+ if (version !== expected) throw NS.E("cms/bad-version", "SignedData version " + version + " does not match its contents (RFC 5652 §5.1 requires v" + expected + ")");
268
+
269
+ return {
270
+ version: version,
271
+ digestAlgorithms: m.fields.digestAlgorithms.value.items.map(function (it) { return it.value.result; }),
272
+ encapContentInfo: encapContentInfo,
273
+ certificates: certificates,
274
+ crls: crls,
275
+ signerInfos: signerInfos,
276
+ };
277
+ },
278
+ });
279
+
280
+ // ContentInfo ::= SEQUENCE { contentType OID, content [0] EXPLICIT ANY DEFINED BY
281
+ // contentType } (RFC 5652 §3). The content is captured raw (explicit(0, any()))
282
+ // and re-dispatched by contentType inside the build: id-signedData walks
283
+ // SIGNED_DATA, the other PKCS#7 types are recognized-and-deferred, unknown OIDs
284
+ // are rejected.
285
+ var CONTENT_INFO = schema.seq([
286
+ schema.field("contentType", schema.oidLeaf()),
287
+ schema.field("content", schema.explicit(0, schema.any(), { code: "cms/not-a-content-info" })),
288
+ ], {
289
+ assert: "sequence", arity: { exact: 2 }, code: "cms/not-a-content-info", what: "ContentInfo",
290
+ build: function (m, ctx) {
291
+ var ct = m.fields.contentType.value;
292
+ if (ct === OID_SIGNED_DATA) return schema.walk(SIGNED_DATA, m.fields.content.value, ctx).result;
293
+ if (DEFERRED.has(ct)) {
294
+ throw NS.E("cms/unsupported-content-type", (ctx.oid.name(ct) || ct) + " is recognized but not parsed by this build");
295
+ }
296
+ throw NS.E("cms/unknown-content-type", "unrecognized ContentInfo content type " + ct);
297
+ },
298
+ });
299
+
300
+ /**
301
+ * @primitive pki.schema.cms.parse
302
+ * @signature pki.schema.cms.parse(input) -> signedData
303
+ * @since 0.1.10
304
+ * @status experimental
305
+ * @spec RFC 5652
306
+ * @related pki.schema.parse, pki.schema.x509.parse
307
+ *
308
+ * Parse a DER `Buffer` or a PEM (`CMS`) string into a structured CMS SignedData:
309
+ * `{ version, digestAlgorithms, encapContentInfo, certificates, crls,
310
+ * signerInfos }`. `encapContentInfo.eContent` is the raw content (or `null` when
311
+ * detached); each SignerInfo carries its raw `signature` and, when present, the
312
+ * on-wire `signedAttrsBytes` for external verification. A ContentInfo whose type
313
+ * is not `id-signedData` throws `cms/unsupported-content-type` (a recognized
314
+ * PKCS#7 type) or `cms/unknown-content-type`; a malformed structure throws a typed
315
+ * `CmsError` (`cms/*`) and a leaf-level codec fault surfaces as `asn1/*`.
316
+ *
317
+ * @example
318
+ * var cms = pki.schema.cms.parse(der);
319
+ * cms.signerInfos[0].sid.serialNumberHex; // -> "0a1b..."
320
+ * cms.encapContentInfo.eContent; // -> Buffer | null (detached)
321
+ */
322
+ var parse = pkix.makeParser({ pemLabel: "CMS", PemError: PemError, ErrorClass: CmsError, prefix: "cms", what: "CMS ContentInfo", topSchema: CONTENT_INFO, ns: NS });
323
+
324
+ /**
325
+ * @primitive pki.schema.cms.pemDecode
326
+ * @signature pki.schema.cms.pemDecode(text, label?) -> Buffer
327
+ * @since 0.1.10
328
+ * @status experimental
329
+ * @spec RFC 7468, RFC 5652
330
+ * @related pki.schema.cms.parse
331
+ *
332
+ * Extract the DER bytes from a PEM CMS block (default label `CMS`). Throws
333
+ * `PemError` on a missing / mismatched envelope or a non-base64 body.
334
+ *
335
+ * @example
336
+ * var der = pki.schema.cms.pemDecode(pemText);
337
+ */
338
+ function pemDecode(text, label) { return pkix.pemDecode(text, label || "CMS", PemError); }
339
+
340
+ /**
341
+ * @primitive pki.schema.cms.pemEncode
342
+ * @signature pki.schema.cms.pemEncode(der, label?) -> string
343
+ * @since 0.1.10
344
+ * @status experimental
345
+ * @spec RFC 7468
346
+ * @related pki.schema.cms.pemDecode
347
+ *
348
+ * Wrap DER bytes in a PEM CMS envelope (default label `CMS`).
349
+ *
350
+ * @example
351
+ * var pem = pki.schema.cms.pemEncode(der);
352
+ */
353
+ function pemEncode(der, label) { return pkix.pemEncode(der, label || "CMS", PemError); }
354
+
355
+ // A CMS ContentInfo root is the only registered structure whose root leads with an
356
+ // OBJECT IDENTIFIER child: a SEQUENCE of exactly 2 whose first child is an OID and
357
+ // second a context [0] constructed wrapper. x509/crl/csr lead with a tbs SEQUENCE
358
+ // and pkcs8 with an INTEGER, so the detectors are mutually exclusive regardless of
359
+ // registry order.
360
+ function matches(root) {
361
+ var TAGS = asn1.TAGS;
362
+ if (!root || root.tagClass !== "universal" || root.tagNumber !== TAGS.SEQUENCE) return false;
363
+ var k = root.children;
364
+ if (!k || k.length !== 2) return false;
365
+ if (!(k[0].tagClass === "universal" && k[0].tagNumber === TAGS.OBJECT_IDENTIFIER)) return false;
366
+ if (!(k[1].tagClass === "context" && k[1].tagNumber === 0 && k[1].children)) return false;
367
+ return true;
368
+ }
369
+
370
+ module.exports = {
371
+ parse: parse,
372
+ pemDecode: pemDecode,
373
+ pemEncode: pemEncode,
374
+ matches: matches,
375
+ };
@@ -100,6 +100,10 @@ function bitString() { return { kind: "leaf", read: function (n) { var b =
100
100
  // A [tag] IMPLICIT BIT STRING leaf (context-class primitive) — the primitive-leaf
101
101
  // counterpart to implicitSetOf, for e.g. the PKCS#8 publicKey [1] (RFC 5958 §2).
102
102
  function implicitBitString(tag) { return { kind: "leaf", read: function (n) { var b = asn1.read.bitStringImplicit(n, tag); return { unusedBits: b.unusedBits, bytes: b.bytes }; } }; }
103
+ // A [tag] IMPLICIT OCTET STRING leaf (context-class primitive) — the sibling of
104
+ // implicitBitString, for e.g. the CMS SignerIdentifier subjectKeyIdentifier [0]
105
+ // (RFC 5652 §5.3). Yields the raw content bytes.
106
+ function implicitOctetString(tag) { return { kind: "leaf", read: function (n) { return asn1.read.octetStringImplicit(n, tag); } }; }
103
107
  function any() { return { kind: "any" }; }
104
108
  function decode(fn) { return { kind: "decode", fn: fn }; }
105
109
 
@@ -371,7 +375,8 @@ module.exports = {
371
375
  seqOf: seqOf, setOf: setOf, setOfUnique: setOfUnique, implicitSetOf: implicitSetOf, choice: choice,
372
376
  // leaves
373
377
  oidLeaf: oidLeaf, integerLeaf: integerLeaf, boolean: boolean, octetString: octetString,
374
- bitString: bitString, implicitBitString: implicitBitString, any: any, decode: decode, time: time,
378
+ bitString: bitString, implicitBitString: implicitBitString, implicitOctetString: implicitOctetString,
379
+ any: any, decode: decode, time: time,
375
380
  // engine
376
381
  walk: walk,
377
382
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:3cf64a06-eef0-417c-a199-787130b9149a",
5
+ "serialNumber": "urn:uuid:e5089c51-5287-4fe7-bbfc-520b07cbb2f2",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-05T08:37:25.927Z",
8
+ "timestamp": "2026-07-05T18:32:46.745Z",
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.1.9",
22
+ "bom-ref": "@blamejs/pki@0.1.10",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.9",
25
+ "version": "0.1.10",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.1.9",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.1.10",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.1.9",
57
+ "ref": "@blamejs/pki@0.1.10",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]