@blamejs/pki 0.1.21 → 0.1.22

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,18 @@ 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.22 — 2026-07-09
8
+
9
+ An RFC 5035 / RFC 8551 S/MIME ESS signed-attribute decoder joins the schema family.
10
+
11
+ ### Added
12
+
13
+ - pki.schema.smime.parseSigningCertificate(der) / parseSigningCertificateV2(der) — decode the RFC 5035 ESS SigningCertificate (v1) and SigningCertificateV2 (v2) signed-attribute values into { certs, policies }. Each certs entry is { certHash, hashAlgorithm, issuerSerial } in wire order: certHash raw; hashAlgorithm the implied SHA-1 for v1, or the decoded algorithm (or the id-sha256 default, flagged defaulted) for v2; issuerSerial (or null) the issuer GeneralNames (each CHOICE arm validated, surfaced raw) plus serialNumber as a BigInt and serialNumberHex. certs is non-empty and order-preserving; a v2 hashAlgorithm encoded byte-equal to its DEFAULT is rejected smime/non-canonical-default (X.690 §11.5). Malformed input fails closed with a typed smime/* (or leaf asn1/*) code.
14
+ - pki.schema.smime.parseSmimeCapabilities(der) — decode an RFC 8551 SMIMECapabilities value into { capabilities }, an ordered list (preference order preserved, never sorted) of { capabilityID, name, parameters } with parameters the raw ANY-DEFINED-BY bytes.
15
+ - pki.schema.smime.decodeAttribute(attr) — an OID-dispatch convenience over the three decoders for a CMS-shaped { type, values } attribute (the shape cms.parse surfaces on signerInfos[i].signedAttrs). It enforces the single-AttributeValue rule (a values length other than one is rejected smime/multi-valued-attribute), routes on the attribute OID, and recognize-and-defers an unknown type as smime/unsupported-attribute carrying the type, registry name, and raw values.
16
+ - The error taxonomy gains SmimeError, carrying a stable smime/* code.
17
+ - The OID registry gains the smimeCapabilities attribute identifier and the RFC 2634 ESS attribute names (receiptRequest through contentReference), so a CMS signed attribute of one of those types resolves to a name in an unsupported-attribute diagnostic.
18
+
7
19
  ## v0.1.21 — 2026-07-09
8
20
 
9
21
  SLH-DSA signatures verify in certification-path validation, and the post-quantum / EdDSA parameters-absent rule is enforced across every format.
package/README.md CHANGED
@@ -202,6 +202,7 @@ is callable today; nothing below is a stub.
202
202
  | `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` |
203
203
  | `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` |
204
204
  | `pki.schema.cmp` | Parse DER / PEM RFC 9810 Certificate Management Protocol messages (PKIMessage) — the header (version, sender / recipient incl. the anonymous NULL-DN, nonces, transaction id, general info), the 27-arm body (certificate requests via the CRMF parser, an encrypted certificate's EnvelopedData via CMS, response / revocation / confirmation / error / support / polling arms structural, the rest raw), and the exact `headerBytes` / `bodyBytes` slices an external verifier reconstructs the protected part from; the CMP-before-OCSP dispatch order is enforced, fail-closed — `parse`, `pemDecode`, `pemEncode` |
205
+ | `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
205
206
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
206
207
  | `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, 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; `crlChecker` supplies CRL-based revocation. Pure and re-entrant, fail-closed — `validate`, `crlChecker` |
207
208
  | `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `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 for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
@@ -179,6 +179,14 @@ var CmpError = defineClass("CmpError", { withCause: true });
179
179
  // `.cause`.
180
180
  var PathError = defineClass("PathError", { withCause: true });
181
181
 
182
+ // SmimeError — a byte sequence that is not a well-formed RFC 5035 ESS
183
+ // signing-certificate attribute (SigningCertificate / SigningCertificateV2 /
184
+ // ESSCertID(v2) / IssuerSerial) or RFC 8551 SMIMECapabilities, or a CMS
185
+ // attribute violating a §2.5 coherence rule (single-AttributeValue, certs
186
+ // non-empty, non-canonical DEFAULT hashAlgorithm). Carries the underlying leaf
187
+ // fault (e.g. the inner asn1/* decode error) as `.cause`.
188
+ var SmimeError = defineClass("SmimeError", { withCause: true });
189
+
182
190
  // CtError — a byte sequence that is not a well-formed RFC 6962 Certificate
183
191
  // Transparency SCT list: a malformed inner DER OCTET-STRING wrap, or a TLS
184
192
  // presentation-language framing violation (a lying vector length, a field read
@@ -209,4 +217,5 @@ module.exports = {
209
217
  CmpError: CmpError,
210
218
  PathError: PathError,
211
219
  CtError: CtError,
220
+ SmimeError: SmimeError,
212
221
  };
package/lib/oid.js CHANGED
@@ -141,7 +141,8 @@ var FAMILIES = {
141
141
  // and the PKCS#12 bag attributes friendlyName / localKeyId (RFC 7292 §4.2).
142
142
  pkcs9: { base: [1, 2, 840, 113549, 1, 9], of: {
143
143
  emailAddress: 1, contentType: 3, messageDigest: 4, signingTime: 5,
144
- challengePassword: 7, extensionRequest: 14, friendlyName: 20, localKeyId: 21 } },
144
+ challengePassword: 7, extensionRequest: 14, smimeCapabilities: 15,
145
+ friendlyName: 20, localKeyId: 21 } },
145
146
 
146
147
  // PKCS#9 CertBag / CRLBag value discriminators (RFC 7292 §4.2.3-§4.2.4).
147
148
  pkcs9CertTypes: { base: [1, 2, 840, 113549, 1, 9, 22], of: { x509Certificate: 1, sdsiCertificate: 2 } },
@@ -188,7 +189,11 @@ var FAMILIES = {
188
189
  // S/MIME authenticated attributes (id-aa, RFC 2634 / RFC 5035 / RFC 5816). The ESS
189
190
  // signing-certificate attributes bind a CMS / TSP SignerInfo to its signing cert;
190
191
  // signingCertificateV2 (ESSCertIDv2) carries a non-SHA-1 cert hash (RFC 5816 §2.2.1).
192
+ // The RFC 2634 originals (receiptRequest .. contentReference) are name-only: they
193
+ // resolve to a name so an unsupported-attribute diagnostic is legible, not decoded.
191
194
  smimeAa: { base: [1, 2, 840, 113549, 1, 9, 16, 2], of: {
195
+ receiptRequest: 1, eSSSecurityLabel: 2, mlExpansionHistory: 3, contentHints: 4,
196
+ msgSigDigest: 5, contentIdentifier: 7, equivalentLabels: 9, contentReference: 10,
192
197
  signingCertificate: 12, timeStampToken: 14, signingCertificateV2: 47 } },
193
198
 
194
199
  // ANSI X9.62 EC public key, named curve, and ECDSA signatures.
package/lib/schema-all.js CHANGED
@@ -43,6 +43,11 @@ var tsp = require("./schema-tsp");
43
43
  var crmf = require("./schema-crmf");
44
44
  var cmp = require("./schema-cmp");
45
45
  var attrcert = require("./schema-attrcert");
46
+ // smime is a COMPANION decoder for CMS signed-attribute values, NOT a top-level
47
+ // format — it has no self-describing DER root (an ESS attribute value is a bare
48
+ // SEQUENCE) so it is deliberately absent from FORMATS / the detect-and-route
49
+ // `parse`; it is reached only by explicit OID dispatch on a CMS attribute.
50
+ var smime = require("./schema-smime");
46
51
  var frameworkError = require("./framework-error");
47
52
 
48
53
  var SchemaError = frameworkError.SchemaError;
@@ -277,6 +282,7 @@ module.exports = {
277
282
  crmf: { parse: crmf.parse, pemDecode: crmf.pemDecode },
278
283
  cmp: { parse: cmp.parse, pemDecode: cmp.pemDecode, pemEncode: cmp.pemEncode },
279
284
  attrcert: { parse: attrcert.parse, pemDecode: attrcert.pemDecode },
285
+ smime: { parseSigningCertificate: smime.parseSigningCertificate, parseSigningCertificateV2: smime.parseSigningCertificateV2, parseSmimeCapabilities: smime.parseSmimeCapabilities, decodeAttribute: smime.decodeAttribute },
280
286
  all: all,
281
287
  parse: parse,
282
288
  };
@@ -370,6 +370,28 @@ function generalNames(ns, opts) {
370
370
  // `decodeExt` pattern (asn1.decode → read.* / schema.walk, wrapped). Returns
371
371
  // { byOid } keyed by dotted OID so a consumer dispatches by ext.oid.
372
372
  var _T = asn1.TAGS;
373
+
374
+ // assertPolicyQualifiers(qNode, fail) — RFC 5280 §4.2.1.4: a PolicyInformation's
375
+ // policyQualifiers is a non-empty SEQUENCE OF PolicyQualifierInfo, each a SEQUENCE
376
+ // { policyQualifierId OID, qualifier } of EXACTLY two members led by an OID. The
377
+ // structure is validated fail-closed; the qualifier body stays opaque (surfaced
378
+ // raw by the caller). `fail(msg)` throws the caller's typed code. Shared by the
379
+ // certificatePolicies extension decoder and the RFC 5035 ESS SigningCertificate
380
+ // policies field so the same PolicyInformation shape cannot drift between them.
381
+ // `fail(msg, cause?)` throws the caller's typed code, carrying the underlying
382
+ // leaf fault as the error cause when one is available.
383
+ function assertPolicyQualifiers(qNode, fail) {
384
+ if (qNode.tagClass !== "universal" || qNode.tagNumber !== _T.SEQUENCE || !qNode.children || !qNode.children.length) {
385
+ fail("policyQualifiers must be a non-empty SEQUENCE (RFC 5280 §4.2.1.4)");
386
+ }
387
+ qNode.children.forEach(function (pq) {
388
+ if (pq.tagClass !== "universal" || pq.tagNumber !== _T.SEQUENCE || !pq.children || pq.children.length !== 2) {
389
+ fail("policyQualifiers element must be a PolicyQualifierInfo SEQUENCE { policyQualifierId, qualifier } (RFC 5280 §4.2.1.4)");
390
+ }
391
+ try { asn1.read.oid(pq.children[0]); } catch (e) { fail("PolicyQualifierInfo must lead with a policyQualifierId OID", e); }
392
+ });
393
+ }
394
+
373
395
  function certExtensionDecoders(ns) {
374
396
  var GN_SUBTREE = generalName(ns, { decodeValue: true, subtreeBase: true, code: ns.prefix + "/bad-name-constraints" });
375
397
 
@@ -495,20 +517,9 @@ function certExtensionDecoders(ns) {
495
517
  var qualifiers = null;
496
518
  if (pi.children.length > 1) {
497
519
  var q = pi.children[1];
498
- if (q.tagClass !== "universal" || q.tagNumber !== _T.SEQUENCE || !q.children || !q.children.length) {
499
- throw ns.E(C, "PolicyInformation policyQualifiers must be a non-empty SEQUENCE (RFC 5280 §4.2.1.4)");
500
- }
501
- // Each element is a PolicyQualifierInfo ::= SEQUENCE { policyQualifierId
502
- // OID, qualifier ANY DEFINED BY policyQualifierId } — EXACTLY two members
503
- // (qualifier is not OPTIONAL). A SEQUENCE missing the qualifier or
504
- // carrying trailing extra fields is malformed; the qualifier body itself
505
- // stays opaque.
506
- q.children.forEach(function (pq) {
507
- if (pq.tagClass !== "universal" || pq.tagNumber !== _T.SEQUENCE || !pq.children || pq.children.length !== 2) {
508
- throw ns.E(C, "policyQualifiers element must be a PolicyQualifierInfo SEQUENCE { policyQualifierId, qualifier } (RFC 5280 §4.2.1.4)");
509
- }
510
- try { asn1.read.oid(pq.children[0]); } catch (e) { throw ns.E(C, "PolicyQualifierInfo must lead with a policyQualifierId OID", e); }
511
- });
520
+ // PolicyQualifierInfo structure validated by the shared assertion; the
521
+ // qualifier body stays opaque (surfaced raw).
522
+ assertPolicyQualifiers(q, function (msg, cause) { throw ns.E(C, msg, cause); });
512
523
  qualifiers = q.bytes;
513
524
  }
514
525
  return { policyIdentifier: pid, qualifiersBytes: qualifiers };
@@ -838,6 +849,7 @@ module.exports = {
838
849
  makeParser: makeParser,
839
850
  signedEnvelopeTbs: signedEnvelopeTbs,
840
851
  rootSequenceChildren: rootSequenceChildren,
852
+ assertPolicyQualifiers: assertPolicyQualifiers,
841
853
  signedEnvelope: signedEnvelope,
842
854
  attrValueToString: attrValueToString,
843
855
  attributeTypeAndValue: attributeTypeAndValue,
@@ -0,0 +1,363 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.schema.smime
6
+ * @nav Schema
7
+ * @title S/MIME (ESS)
8
+ * @order 180
9
+ * @slug smime
10
+ *
11
+ * @intro
12
+ * S/MIME Enhanced Security Services signed-attribute values per RFC 5035 (ESS)
13
+ * and RFC 8551 (S/MIME 4.0). These are the DER-decoded VALUES of CMS signed
14
+ * attributes — they ride inside a `SignerInfo.signedAttrs`, so this is a
15
+ * companion decoder a CMS consumer invokes by attribute OID, NOT a top-level
16
+ * format the schema orchestrator auto-routes.
17
+ *
18
+ * `parseSigningCertificate` / `parseSigningCertificateV2` decode the ESS
19
+ * signing-certificate attributes that bind a signature to the exact certificate
20
+ * that made it: each surfaces its list of `ESSCertID`(v2) — the certificate hash
21
+ * (raw), the hash algorithm (decoded for v2, or the implied SHA-1 for v1), and
22
+ * the optional `issuerSerial` (issuer `GeneralNames` validated + surfaced raw,
23
+ * serial as a BigInt + hex) — plus the optional certificate policies.
24
+ * `parseSmimeCapabilities` decodes the ordered `SMIMECapabilities` list (each a
25
+ * capability OID + raw parameters). `decodeAttribute` takes a CMS-shaped
26
+ * `{ type, values }` attribute, enforces the single-`AttributeValue` rule
27
+ * (RFC 8551 §2.5.2), routes on the attribute OID, and recognize-and-defers an
28
+ * unknown attribute type with its raw values intact.
29
+ *
30
+ * Structure is decoded; verification is the consumer's — the parser surfaces
31
+ * `certHash` + `hashAlgorithm` + `issuerSerial` so a verifier recomputes the
32
+ * certificate hash (compose `webcrypto`) and matches the issuer/serial against
33
+ * the actual signing certificate; it never recomputes a hash or trusts a cert.
34
+ * Whether an attribute is correctly placed in `signedAttrs` (vs `unsignedAttrs`)
35
+ * is the CMS consumer's knowledge. DER-only, fail-closed.
36
+ *
37
+ * @card
38
+ * Decode RFC 5035 ESS SigningCertificate / SigningCertificateV2 and RFC 8551
39
+ * SMIMECapabilities signed-attribute values — cert-hash binding, validated
40
+ * issuer GeneralNames, ordered capability list, OID-dispatched, fail-closed.
41
+ */
42
+
43
+ var asn1 = require("./asn1-der");
44
+ var schema = require("./schema-engine");
45
+ var pkix = require("./schema-pkix");
46
+ var oid = require("./oid");
47
+ var frameworkError = require("./framework-error");
48
+
49
+ var SmimeError = frameworkError.SmimeError;
50
+ var PemError = frameworkError.PemError;
51
+
52
+ var NS = pkix.makeNS("smime", SmimeError, oid);
53
+ var TAGS = asn1.TAGS;
54
+
55
+ var ALGID = pkix.algorithmIdentifier(NS);
56
+
57
+ // Registry constants — never a dotted literal in a format module.
58
+ var OID_SHA1 = oid.byName("sha1");
59
+ var OID_SHA256 = oid.byName("sha256");
60
+ var OID_SIGNING_CERTIFICATE = oid.byName("signingCertificate");
61
+ var OID_SIGNING_CERTIFICATE_V2 = oid.byName("signingCertificateV2");
62
+ var OID_SMIME_CAPABILITIES = oid.byName("smimeCapabilities");
63
+
64
+ // IssuerSerial ::= SEQUENCE { issuer GeneralNames, serialNumber CertificateSerialNumber }
65
+ // (RFC 5035 App A). A bare universal SEQUENCE of exactly two fields — distinct
66
+ // from the RFC 5755 attribute-certificate IssuerSerial (three fields, reached
67
+ // IMPLICIT-tagged), so declared per-format. `issuer` composes the shared
68
+ // GeneralNames factory (every CHOICE arm validated + surfaced raw, SIZE 1..MAX)
69
+ // and is surfaced unchanged here: the RFC 5035 §5 directoryName narrowing applies
70
+ // only to a non-attribute (public-key) certificate reference, and an ESSCertID
71
+ // entry may reference an attribute certificate whose issuer is a full GeneralNames.
72
+ // The FIRST cert is known to be the signing public-key cert (RFC 5035 §3), so the
73
+ // directoryName rule is enforced there — in the SigningCertificate build — not here.
74
+ var GENERAL_NAME_DIRECTORY = 4;
75
+ var ISSUER_SERIAL = schema.seq([
76
+ schema.field("issuer", pkix.generalNames(NS, { code: "smime/bad-general-names" })),
77
+ schema.field("serialNumber", schema.integerLeaf()),
78
+ ], {
79
+ assert: "sequence", arity: { exact: 2 }, code: "smime/bad-issuer-serial", what: "IssuerSerial",
80
+ build: function (m) {
81
+ return {
82
+ issuer: m.fields.issuer.value.result,
83
+ serialNumber: m.fields.serialNumber.value,
84
+ serialNumberHex: m.fields.serialNumber.node.content.toString("hex"),
85
+ };
86
+ },
87
+ });
88
+
89
+ // RFC 5035 §5: the signing certificate (certs[0], the public-key cert that made
90
+ // the signature per §3) MUST identify its issuer as exactly one directoryName [4].
91
+ // Additional certs[1..] may reference attribute certificates, whose issuer is a
92
+ // GeneralNames — left unconstrained (a verifier-tier profile concern). `fail`
93
+ // throws the caller's code.
94
+ function assertSignerIssuerIsDirectoryName(certs, fail) {
95
+ if (!certs.length || !certs[0].issuerSerial) return;
96
+ var names = certs[0].issuerSerial.issuer.names;
97
+ if (names.length !== 1 || names[0].tagClass !== "context" || names[0].tagNumber !== GENERAL_NAME_DIRECTORY) {
98
+ fail("the signing certificate's IssuerSerial issuer MUST be exactly one directoryName [4] GeneralName (RFC 5035 §5)");
99
+ }
100
+ }
101
+
102
+ // PolicyInformation ::= SEQUENCE { policyIdentifier CertPolicyId,
103
+ // policyQualifiers SEQUENCE OF PolicyQualifierInfo OPTIONAL } (RFC 5280 §4.2.1.4).
104
+ // Decode the policy OID (+ registry name); surface policyQualifiers raw.
105
+ var POLICY_INFORMATION = schema.seq([
106
+ schema.field("policyIdentifier", schema.oidLeaf()),
107
+ schema.optional("policyQualifiers", schema.any(), { whenUniversal: [TAGS.SEQUENCE] }),
108
+ ], {
109
+ assert: "sequence", code: "smime/bad-policy-information", what: "PolicyInformation",
110
+ build: function (m, ctx) {
111
+ var qualifiers = null;
112
+ if (m.fields.policyQualifiers.present) {
113
+ // The qualifier body stays raw (per the ESS scope), but the RFC 5280
114
+ // §4.2.1.4 structure is validated fail-closed by the shared assertion — the
115
+ // same PolicyInformation shape the certificatePolicies decoder enforces.
116
+ var q = m.fields.policyQualifiers.node;
117
+ pkix.assertPolicyQualifiers(q, function (msg, cause) { throw ctx.E("smime/bad-policy-information", msg, cause); });
118
+ qualifiers = q.bytes;
119
+ }
120
+ return {
121
+ policyIdentifier: m.fields.policyIdentifier.value,
122
+ name: ctx.oid.name(m.fields.policyIdentifier.value) || null,
123
+ policyQualifiers: qualifiers,
124
+ };
125
+ },
126
+ });
127
+ // policies SEQUENCE OF PolicyInformation OPTIONAL — order-preserving; the ASN.1
128
+ // module carries no SIZE bound, so an explicitly present but empty list is legal.
129
+ var POLICIES = schema.seqOf(POLICY_INFORMATION, {
130
+ assert: "sequence", min: 0, code: "smime/bad-policies", what: "policies",
131
+ build: function (m) { return m.items.map(function (it) { return it.value.result; }); },
132
+ });
133
+
134
+ // ESSCertID ::= SEQUENCE { certHash Hash, issuerSerial IssuerSerial OPTIONAL }
135
+ // (RFC 5035 §5.4.2). Hash ::= OCTET STRING — for v1 it is the SHA-1 hash of the
136
+ // whole certificate with NO algorithm field, so the hash algorithm is SYNTHESIZED
137
+ // as the implied SHA-1 to make v1 shape-compatible with v2 for a verifier.
138
+ var ESS_CERT_ID = schema.seq([
139
+ schema.field("certHash", schema.octetString()),
140
+ schema.optional("issuerSerial", ISSUER_SERIAL, { whenUniversal: [TAGS.SEQUENCE] }),
141
+ ], {
142
+ assert: "sequence", code: "smime/bad-ess-cert-id", what: "ESSCertID",
143
+ build: function (m) {
144
+ return {
145
+ certHash: m.fields.certHash.value,
146
+ hashAlgorithm: { oid: OID_SHA1, name: "sha1", parameters: null, implied: true },
147
+ issuerSerial: m.fields.issuerSerial.present ? m.fields.issuerSerial.value.result : null,
148
+ };
149
+ },
150
+ });
151
+
152
+ // ESSCertIDv2 ::= SEQUENCE { hashAlgorithm AlgorithmIdentifier DEFAULT
153
+ // {algorithm id-sha256}, certHash Hash, issuerSerial IssuerSerial OPTIONAL }
154
+ // (RFC 5035 §5.4.1 / §4). The mandatory certHash OCTET STRING pivots between the
155
+ // two optional SEQUENCEs, so a leading SEQUENCE is hashAlgorithm and a leading
156
+ // OCTET STRING means hashAlgorithm defaulted.
157
+ var ESS_CERT_ID_V2 = schema.seq([
158
+ schema.optional("hashAlgorithm", ALGID, { whenUniversal: [TAGS.SEQUENCE] }),
159
+ schema.field("certHash", schema.octetString()),
160
+ schema.optional("issuerSerial", ISSUER_SERIAL, { whenUniversal: [TAGS.SEQUENCE] }),
161
+ ], {
162
+ assert: "sequence", code: "smime/bad-ess-cert-id-v2", what: "ESSCertIDv2",
163
+ build: function (m, ctx) {
164
+ var hashAlgorithm;
165
+ if (m.fields.hashAlgorithm.present) {
166
+ var alg = m.fields.hashAlgorithm.value.result;
167
+ // X.690 §11.5: a DEFAULT value MUST be omitted in DER. The default is
168
+ // {algorithm id-sha256, parameters ABSENT}; an explicit encoding byte-equal
169
+ // to it is a non-canonical DEFAULT and is rejected fail-closed (the
170
+ // structured-value analogue of the primitive-DEFAULT rejection the toolkit
171
+ // already enforces). A present hashAlgorithm carrying a redundant NULL
172
+ // parameters is NOT byte-equal to the params-absent default and decodes.
173
+ if (alg.oid === OID_SHA256 && alg.parameters === null) {
174
+ throw ctx.E("smime/non-canonical-default",
175
+ "ESSCertIDv2 hashAlgorithm equal to the DEFAULT {algorithm id-sha256} MUST be omitted (X.690 §11.5)");
176
+ }
177
+ hashAlgorithm = { oid: alg.oid, name: alg.name, parameters: alg.parameters, defaulted: false };
178
+ } else {
179
+ hashAlgorithm = { oid: OID_SHA256, name: "sha256", parameters: null, defaulted: true };
180
+ }
181
+ return {
182
+ certHash: m.fields.certHash.value,
183
+ hashAlgorithm: hashAlgorithm,
184
+ issuerSerial: m.fields.issuerSerial.present ? m.fields.issuerSerial.value.result : null,
185
+ };
186
+ },
187
+ });
188
+
189
+ // SigningCertificate ::= SEQUENCE { certs SEQUENCE OF ESSCertID,
190
+ // policies SEQUENCE OF PolicyInformation OPTIONAL } (RFC 5035 §5.4.2). certs is
191
+ // order-preserving — RFC 5035 §3 makes the FIRST element the signing certificate
192
+ // — and non-empty (an empty certs cannot name a signing cert).
193
+ function signingCertificateSchema(essCertId, code, what) {
194
+ return schema.seq([
195
+ schema.field("certs", schema.seqOf(essCertId, { assert: "sequence", min: 1, code: "smime/bad-certs", what: "certs" })),
196
+ schema.optional("policies", POLICIES, { whenUniversal: [TAGS.SEQUENCE] }),
197
+ ], {
198
+ assert: "sequence", code: code, what: what,
199
+ build: function (m, ctx) {
200
+ var certs = m.fields.certs.value.items.map(function (it) { return it.value.result; });
201
+ assertSignerIssuerIsDirectoryName(certs, function (msg) { throw ctx.E("smime/bad-issuer-serial", msg); });
202
+ return {
203
+ certs: certs,
204
+ policies: m.fields.policies.present ? m.fields.policies.value.result : null,
205
+ };
206
+ },
207
+ });
208
+ }
209
+ var SIGNING_CERTIFICATE = signingCertificateSchema(ESS_CERT_ID, "smime/bad-signing-certificate", "SigningCertificate");
210
+ var SIGNING_CERTIFICATE_V2 = signingCertificateSchema(ESS_CERT_ID_V2, "smime/bad-signing-certificate-v2", "SigningCertificateV2");
211
+
212
+ // SMIMECapability ::= SEQUENCE { capabilityID OBJECT IDENTIFIER,
213
+ // parameters ANY DEFINED BY capabilityID OPTIONAL } (RFC 8551 §2.5.2). Distinct
214
+ // domain from AlgorithmIdentifier though isomorphic — surfaced as capabilityID +
215
+ // raw parameters (the ANY-DEFINED-BY interpretation is a negotiation concern).
216
+ var SMIME_CAPABILITY = schema.seq([
217
+ schema.field("capabilityID", schema.oidLeaf()),
218
+ schema.optional("parameters", schema.any(), { whenAny: true }),
219
+ ], {
220
+ assert: "sequence", code: "smime/bad-capability", what: "SMIMECapability",
221
+ build: function (m, ctx) {
222
+ return {
223
+ capabilityID: m.fields.capabilityID.value,
224
+ name: ctx.oid.name(m.fields.capabilityID.value) || null,
225
+ parameters: m.fields.parameters.present ? m.fields.parameters.node.bytes : null,
226
+ };
227
+ },
228
+ });
229
+ // SMIMECapabilities ::= SEQUENCE OF SMIMECapability — ordered by preference
230
+ // (RFC 8551 §2.5.2), never sorted; an empty list is legal.
231
+ var SMIME_CAPABILITIES = schema.seqOf(SMIME_CAPABILITY, {
232
+ assert: "sequence", min: 0, code: "smime/bad-capabilities", what: "SMIMECapabilities",
233
+ build: function (m) { return { capabilities: m.items.map(function (it) { return it.value.result; }) }; },
234
+ });
235
+
236
+ /**
237
+ * @primitive pki.schema.smime.parseSigningCertificate
238
+ * @signature pki.schema.smime.parseSigningCertificate(der) -> { certs, policies }
239
+ * @since 0.1.22
240
+ * @status experimental
241
+ * @spec RFC 5035, RFC 2634
242
+ * @related pki.schema.smime.parseSigningCertificateV2, pki.schema.smime.decodeAttribute
243
+ *
244
+ * Decode an ESS v1 `SigningCertificate` attribute value (RFC 5035 §5.4.2) — the
245
+ * raw `AttributeValue` a CMS consumer plucks off `SignerInfo.signedAttrs`. Returns
246
+ * `{ certs, policies }`: each `certs` entry is `{ certHash, hashAlgorithm,
247
+ * issuerSerial }` in wire order (the first is the signing certificate), where
248
+ * `hashAlgorithm` is the implied SHA-1 (v1 carries no algorithm field) and
249
+ * `issuerSerial` (or `null`) surfaces the issuer `GeneralNames` + serial. Throws a
250
+ * typed `smime/*` (or leaf `asn1/*`) error on malformed input.
251
+ *
252
+ * @example
253
+ * var b = pki.asn1.build;
254
+ * var essCertId = b.sequence([b.octetString(Buffer.alloc(20, 1))]); // SHA-1 hash, no issuerSerial
255
+ * var av = b.sequence([b.sequence([essCertId])]); // SigningCertificate { certs }
256
+ * var sc = pki.schema.smime.parseSigningCertificate(av);
257
+ * sc.certs[0].hashAlgorithm.name; // "sha1" (implied)
258
+ */
259
+ var parseSigningCertificate = pkix.makeParser({ pemLabel: null, PemError: PemError, ErrorClass: SmimeError, prefix: "smime", what: "SigningCertificate", topSchema: SIGNING_CERTIFICATE, ns: NS });
260
+
261
+ /**
262
+ * @primitive pki.schema.smime.parseSigningCertificateV2
263
+ * @signature pki.schema.smime.parseSigningCertificateV2(der) -> { certs, policies }
264
+ * @since 0.1.22
265
+ * @status experimental
266
+ * @spec RFC 5035, RFC 5816
267
+ * @related pki.schema.smime.parseSigningCertificate, pki.schema.smime.decodeAttribute
268
+ *
269
+ * Decode an ESS v2 `SigningCertificateV2` attribute value (RFC 5035 §5.4.1).
270
+ * Identical shape to v1 but each `certs` entry carries a real `hashAlgorithm`:
271
+ * decoded when present, or the RFC 5035 §4 default `id-sha256` (with
272
+ * `defaulted: true`) when omitted. An explicit `hashAlgorithm` byte-equal to that
273
+ * default is a non-canonical DER encoding and is rejected `smime/non-canonical-default`
274
+ * (X.690 §11.5). Throws a typed `smime/*` error on malformed input.
275
+ *
276
+ * @example
277
+ * var b = pki.asn1.build;
278
+ * var essCertId = b.sequence([b.octetString(Buffer.alloc(32, 2))]); // hashAlgorithm defaulted
279
+ * var av = b.sequence([b.sequence([essCertId])]);
280
+ * var sc = pki.schema.smime.parseSigningCertificateV2(av);
281
+ * sc.certs[0].hashAlgorithm.defaulted; // true (SHA-256 default)
282
+ */
283
+ var parseSigningCertificateV2 = pkix.makeParser({ pemLabel: null, PemError: PemError, ErrorClass: SmimeError, prefix: "smime", what: "SigningCertificateV2", topSchema: SIGNING_CERTIFICATE_V2, ns: NS });
284
+
285
+ /**
286
+ * @primitive pki.schema.smime.parseSmimeCapabilities
287
+ * @signature pki.schema.smime.parseSmimeCapabilities(der) -> { capabilities }
288
+ * @since 0.1.22
289
+ * @status experimental
290
+ * @spec RFC 8551
291
+ * @related pki.schema.smime.decodeAttribute
292
+ *
293
+ * Decode an `SMIMECapabilities` attribute value (RFC 8551 §2.5.2) into
294
+ * `{ capabilities }` — an ORDERED list (preference order, never sorted), each
295
+ * `{ capabilityID, name, parameters }` with `parameters` the raw
296
+ * `ANY DEFINED BY capabilityID` bytes (or `null`). Throws a typed `smime/*` error
297
+ * on malformed input.
298
+ *
299
+ * @example
300
+ * var b = pki.asn1.build;
301
+ * var cap = b.sequence([b.oid(pki.oid.byName("aes256-CBC"))]);
302
+ * var caps = pki.schema.smime.parseSmimeCapabilities(b.sequence([cap]));
303
+ * caps.capabilities[0].name; // "aes256-CBC"
304
+ */
305
+ var parseSmimeCapabilities = pkix.makeParser({ pemLabel: null, PemError: PemError, ErrorClass: SmimeError, prefix: "smime", what: "SMIMECapabilities", topSchema: SMIME_CAPABILITIES, ns: NS });
306
+
307
+ /**
308
+ * @primitive pki.schema.smime.decodeAttribute
309
+ * @signature pki.schema.smime.decodeAttribute(attr) -> { kind, ... }
310
+ * @since 0.1.22
311
+ * @status experimental
312
+ * @spec RFC 8551, RFC 5035
313
+ * @related pki.schema.smime.parseSigningCertificate, pki.schema.cms.parse
314
+ *
315
+ * OID-dispatch convenience over the three value decoders for a CMS-shaped
316
+ * `{ type, values }` attribute (the shape `cms.parse` surfaces on
317
+ * `signerInfos[i].signedAttrs`). Enforces the single-`AttributeValue` rule
318
+ * (RFC 8551 §2.5.2 / §2.5) — a `values` length other than 1 is rejected
319
+ * `smime/multi-valued-attribute` — then routes on `attr.type`:
320
+ * `signingCertificate` / `signingCertificateV2` / `smimeCapabilities` decode to
321
+ * `{ kind, ...result }`; any other type is recognize-and-deferred
322
+ * `smime/unsupported-attribute` (its `type`, registry `name`, and raw `values`
323
+ * carried on the error so a caller keeps the bytes).
324
+ *
325
+ * @example
326
+ * var b = pki.asn1.build;
327
+ * var essCertId = b.sequence([b.octetString(Buffer.alloc(32, 2))]); // ESSCertIDv2, hashAlgorithm defaulted
328
+ * var av = b.sequence([b.sequence([essCertId])]); // SigningCertificateV2 { certs: [ ESSCertIDv2 ] }
329
+ * var got = pki.schema.smime.decodeAttribute({ type: pki.oid.byName("signingCertificateV2"), values: [av] });
330
+ * got.kind; // "signingCertificateV2"
331
+ */
332
+ function decodeAttribute(attr) {
333
+ if (!attr || typeof attr !== "object" || typeof attr.type !== "string" || !Array.isArray(attr.values)) {
334
+ throw new SmimeError("smime/bad-input", "decodeAttribute expects a CMS attribute { type, values }");
335
+ }
336
+ // Route on the attribute OID FIRST. The single-AttributeValue MUST (RFC 8551
337
+ // §2.5.2) is specific to the ESS / SMIMECapabilities attributes, so it is
338
+ // enforced only inside the known-type branch; an unknown / custom attribute
339
+ // recognize-and-defers with its raw values intact regardless of value count
340
+ // (its own cardinality rules are not this decoder's to enforce).
341
+ if (attr.type === OID_SIGNING_CERTIFICATE || attr.type === OID_SIGNING_CERTIFICATE_V2 || attr.type === OID_SMIME_CAPABILITIES) {
342
+ if (attr.values.length !== 1) {
343
+ throw new SmimeError("smime/multi-valued-attribute",
344
+ "an ESS / SMIMECapabilities attribute MUST carry exactly one AttributeValue, got " + attr.values.length + " (RFC 8551 §2.5.2)");
345
+ }
346
+ var value = attr.values[0];
347
+ if (attr.type === OID_SIGNING_CERTIFICATE) { var v1 = parseSigningCertificate(value); return { kind: "signingCertificate", certs: v1.certs, policies: v1.policies }; }
348
+ if (attr.type === OID_SIGNING_CERTIFICATE_V2) { var v2 = parseSigningCertificateV2(value); return { kind: "signingCertificateV2", certs: v2.certs, policies: v2.policies }; }
349
+ return { kind: "smimeCapabilities", capabilities: parseSmimeCapabilities(value).capabilities };
350
+ }
351
+ var e = new SmimeError("smime/unsupported-attribute", "unsupported S/MIME attribute type " + attr.type + (oid.name(attr.type) ? " (" + oid.name(attr.type) + ")" : ""));
352
+ e.type = attr.type;
353
+ e.name = oid.name(attr.type) || null;
354
+ e.values = attr.values;
355
+ throw e;
356
+ }
357
+
358
+ module.exports = {
359
+ parseSigningCertificate: parseSigningCertificate,
360
+ parseSigningCertificateV2: parseSigningCertificateV2,
361
+ parseSmimeCapabilities: parseSmimeCapabilities,
362
+ decodeAttribute: decodeAttribute,
363
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
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:bcaea9d8-cd0d-4a9f-8ac3-72b3efecc8be",
5
+ "serialNumber": "urn:uuid:38c53855-3eaa-4d1a-8c48-b03540881d8a",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-09T15:12:46.820Z",
8
+ "timestamp": "2026-07-09T18:46:47.249Z",
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.21",
22
+ "bom-ref": "@blamejs/pki@0.1.22",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.21",
25
+ "version": "0.1.22",
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.21",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.1.22",
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.21",
57
+ "ref": "@blamejs/pki@0.1.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]