@blamejs/pki 0.1.20 → 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,33 @@ 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
+
19
+ ## v0.1.21 — 2026-07-09
20
+
21
+ SLH-DSA signatures verify in certification-path validation, and the post-quantum / EdDSA parameters-absent rule is enforced across every format.
22
+
23
+ ### Added
24
+
25
+ - SLH-DSA signature verification in pki.path.validate — all twelve FIPS 205 parameter sets (id-slh-dsa-sha2-{128,192,256}{s,f} and the SHAKE sets). A certificate or CRL signed with SLH-DSA now verifies by importing the issuer's SLH-DSA public key and checking the one-shot signature over the raw signed region; the same rows serve both the certificate signature check and the CRL revocation checker. ML-DSA and the classical RSA / ECDSA / EdDSA set were already wired.
26
+ - pki.oid.paramsMustBeAbsent(oid) — a predicate that reports whether an AlgorithmIdentifier bearing the given OID must encode its parameters field as absent (the ML-DSA and SLH-DSA families and the RFC 8410 Edwards / Montgomery curves). It is the single source the shared AlgorithmIdentifier decoder consults.
27
+ - The OID registry now names the twelve pre-hash HashSLH-DSA identifiers (id-hash-slh-dsa-*, RFC 9909 §3), so a certificate or CRL that carries a HashSLH-DSA algorithm resolves to a name and is covered by the parameters-absent rule.
28
+
29
+ ### Fixed
30
+
31
+ - The shared AlgorithmIdentifier decoder now rejects a present parameters field on the algorithms whose parameters MUST be absent — ML-DSA, SLH-DSA, Ed25519, Ed448, X25519, and X448 (RFC 9909 §3, RFC 9814 §4, RFC 9881 §2, RFC 8410 §3) — failing closed with a <format>/bad-algorithm-parameters code. Previously a stray explicit NULL or arbitrary bytes in that field were surfaced raw. The rule is enforced once in the shared decoder, so every format that names an algorithm inherits it; a conforming identifier, which omits the field, is unaffected.
32
+ - Certification-path validation now enforces issuer-key / signature-algorithm consistency for the one-shot families whose public-key OID equals the signature OID — EdDSA, ML-DSA, and SLH-DSA — rejecting a mismatch with a path/algorithm-mismatch reason (RFC 9814 §4). Because the underlying WebCrypto import binds a public key of a different type to the requested algorithm name and verifies with the real key, a certificate or CRL signed by one key type but labelling its signatureAlgorithm as another one-shot type could otherwise validate; the check closes that algorithm-confusion path for both the certificate signature and the CRL revocation checker.
33
+
7
34
  ## v0.1.20 — 2026-07-09
8
35
 
9
36
  An RFC 6962 Certificate Transparency SCT-list parser joins the toolkit.
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.
@@ -217,9 +222,10 @@ var FAMILIES = {
217
222
  sha256: 1, sha384: 2, sha512: 3, "sha3-256": 8, "sha3-512": 10, shake256: 12 } },
218
223
 
219
224
  // NIST signature algorithms — FIPS 204 ML-DSA + FIPS 205 SLH-DSA share the
220
- // signature arc 2.16.840.1.101.3.4.3. The 12 Pure SLH-DSA parameter sets are
221
- // assigned sequentially (RFC 9909 §3): the SHA-2 sets .20-.25, then the SHAKE
222
- // sets .26-.31.
225
+ // signature arc 2.16.840.1.101.3.4.3. RFC 9909 §3 assigns the 12 Pure SLH-DSA
226
+ // sets .20-.31 (SHA-2 .20-.25, SHAKE .26-.31) and the 12 pre-hash HashSLH-DSA
227
+ // sets .35-.46, each pairing a parameter set with its message-digest hash. The
228
+ // parameters MUST be absent for every one of them (enforced via paramsMustBeAbsent).
223
229
  nistSig: { base: [2, 16, 840, 1, 101, 3, 4, 3], of: {
224
230
  "id-ml-dsa-44": 17, "id-ml-dsa-65": 18, "id-ml-dsa-87": 19,
225
231
  "id-slh-dsa-sha2-128s": 20, "id-slh-dsa-sha2-128f": 21,
@@ -227,7 +233,13 @@ var FAMILIES = {
227
233
  "id-slh-dsa-sha2-256s": 24, "id-slh-dsa-sha2-256f": 25,
228
234
  "id-slh-dsa-shake-128s": 26, "id-slh-dsa-shake-128f": 27,
229
235
  "id-slh-dsa-shake-192s": 28, "id-slh-dsa-shake-192f": 29,
230
- "id-slh-dsa-shake-256s": 30, "id-slh-dsa-shake-256f": 31 } },
236
+ "id-slh-dsa-shake-256s": 30, "id-slh-dsa-shake-256f": 31,
237
+ "id-hash-slh-dsa-sha2-128s-with-sha256": 35, "id-hash-slh-dsa-sha2-128f-with-sha256": 36,
238
+ "id-hash-slh-dsa-sha2-192s-with-sha512": 37, "id-hash-slh-dsa-sha2-192f-with-sha512": 38,
239
+ "id-hash-slh-dsa-sha2-256s-with-sha512": 39, "id-hash-slh-dsa-sha2-256f-with-sha512": 40,
240
+ "id-hash-slh-dsa-shake-128s-with-shake128": 41, "id-hash-slh-dsa-shake-128f-with-shake128": 42,
241
+ "id-hash-slh-dsa-shake-192s-with-shake256": 43, "id-hash-slh-dsa-shake-192f-with-shake256": 44,
242
+ "id-hash-slh-dsa-shake-256s-with-shake256": 45, "id-hash-slh-dsa-shake-256f-with-shake256": 46 } },
231
243
 
232
244
  // NIST KEM — FIPS 203 ML-KEM (arc 2.16.840.1.101.3.4.4).
233
245
  nistKem: { base: [2, 16, 840, 1, 101, 3, 4, 4], of: {
@@ -402,10 +414,58 @@ function fromDER(input) {
402
414
  return asn1.read.oid(node);
403
415
  }
404
416
 
417
+ // Algorithm identifiers whose `parameters` field MUST be absent — no explicit
418
+ // NULL, no bytes. The FIPS 204 ML-DSA and FIPS 205 SLH-DSA signature families
419
+ // and the RFC 8410 Edwards / Montgomery curves each carry this MUST: RFC 9909 §3
420
+ // (SLH-DSA in X.509), RFC 9814 §4 (SLH-DSA in CMS), RFC 9881 §2 (ML-DSA in X.509),
421
+ // RFC 8410 §3 (Ed25519 / Ed448 / X25519 / X448). Built by NAME through the same
422
+ // registry every caller uses, so a typo fails closed at load rather than silently
423
+ // dropping a member. One set drives the single shared AlgorithmIdentifier reject
424
+ // (schema-pkix), so every format consumer inherits the rule with no per-format code.
425
+ var _PARAMS_ABSENT = new Set();
426
+ [
427
+ "id-ml-dsa-44", "id-ml-dsa-65", "id-ml-dsa-87",
428
+ "id-slh-dsa-sha2-128s", "id-slh-dsa-sha2-128f", "id-slh-dsa-sha2-192s",
429
+ "id-slh-dsa-sha2-192f", "id-slh-dsa-sha2-256s", "id-slh-dsa-sha2-256f",
430
+ "id-slh-dsa-shake-128s", "id-slh-dsa-shake-128f", "id-slh-dsa-shake-192s",
431
+ "id-slh-dsa-shake-192f", "id-slh-dsa-shake-256s", "id-slh-dsa-shake-256f",
432
+ // Pre-hash HashSLH-DSA sets (RFC 9909 §3) — parameters MUST be absent too.
433
+ "id-hash-slh-dsa-sha2-128s-with-sha256", "id-hash-slh-dsa-sha2-128f-with-sha256",
434
+ "id-hash-slh-dsa-sha2-192s-with-sha512", "id-hash-slh-dsa-sha2-192f-with-sha512",
435
+ "id-hash-slh-dsa-sha2-256s-with-sha512", "id-hash-slh-dsa-sha2-256f-with-sha512",
436
+ "id-hash-slh-dsa-shake-128s-with-shake128", "id-hash-slh-dsa-shake-128f-with-shake128",
437
+ "id-hash-slh-dsa-shake-192s-with-shake256", "id-hash-slh-dsa-shake-192f-with-shake256",
438
+ "id-hash-slh-dsa-shake-256s-with-shake256", "id-hash-slh-dsa-shake-256f-with-shake256",
439
+ "Ed25519", "Ed448", "X25519", "X448",
440
+ ].forEach(function (nm) { _PARAMS_ABSENT.add(byName(nm)); });
441
+
442
+ /**
443
+ * @primitive pki.oid.paramsMustBeAbsent
444
+ * @signature pki.oid.paramsMustBeAbsent(dotted) -> boolean
445
+ * @since 0.1.21
446
+ * @status experimental
447
+ * @spec RFC 9909, RFC 9814, RFC 9881, RFC 8410
448
+ * @related pki.oid.name, pki.oid.byName
449
+ *
450
+ * True when an AlgorithmIdentifier bearing this OID MUST encode its `parameters`
451
+ * field as ABSENT (not an explicit NULL, not any bytes): the FIPS 204 ML-DSA and
452
+ * FIPS 205 SLH-DSA signature families and the RFC 8410 Edwards / Montgomery
453
+ * curves. The shared AlgorithmIdentifier decoder consults this and fails closed
454
+ * on a present-parameters violation, so every format inherits the rule.
455
+ *
456
+ * @example
457
+ * pki.oid.paramsMustBeAbsent(pki.oid.byName("id-slh-dsa-sha2-128s")); // -> true
458
+ * pki.oid.paramsMustBeAbsent(pki.oid.byName("rsaEncryption")); // -> false
459
+ */
460
+ function paramsMustBeAbsent(dotted) {
461
+ return _PARAMS_ABSENT.has(dotted);
462
+ }
463
+
405
464
  module.exports = {
406
465
  name: name,
407
466
  byName: byName,
408
467
  has: has,
468
+ paramsMustBeAbsent: paramsMustBeAbsent,
409
469
  register: register,
410
470
  registerFamily: registerFamily,
411
471
  all: all,
@@ -86,9 +86,16 @@ var SIG_ALGS = {};
86
86
  // NULL must be present — RSASSA-PKCS1-v1_5, RFC 4055 §5) or "absent" (parameters
87
87
  // must be omitted — ECDSA/EdDSA/ML-DSA, RFC 5758/8410). A cert deviating from
88
88
  // its algorithm's required shape is malformed and rejected before verify.
89
- function _sig(name, verify, imp, params, ecdsa) {
89
+ // `sameKeyOid` marks the one-shot families whose PUBLIC-KEY algorithm OID is the
90
+ // SAME as the signature algorithm OID — EdDSA, ML-DSA, SLH-DSA. For these, Node's
91
+ // WebCrypto imports an SPKI of ANOTHER type under the requested name and verifies
92
+ // with the real key (it does NOT reject a mismatched SPKI the way it does for
93
+ // RSA/ECDSA), so the issuer-key <-> signature-algorithm consistency (RFC 9814 §4)
94
+ // must be checked structurally: the SPKI OID must equal the signature OID.
95
+ function _sig(name, verify, imp, params, ecdsa, sameKeyOid) {
90
96
  var entry = { verify: verify, imp: imp, params: params };
91
97
  if (ecdsa) entry.ecdsa = true;
98
+ if (sameKeyOid) entry.sameKeyOid = true;
92
99
  SIG_ALGS[oid.byName(name)] = entry;
93
100
  }
94
101
  // RSASSA-PKCS1-v1_5 — parameters MUST be NULL.
@@ -99,13 +106,22 @@ _sig("sha512WithRSAEncryption", { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" },
99
106
  _sig("ecdsaWithSHA256", { name: "ECDSA", hash: "SHA-256" }, { name: "ECDSA" }, "absent", true);
100
107
  _sig("ecdsaWithSHA384", { name: "ECDSA", hash: "SHA-384" }, { name: "ECDSA" }, "absent", true);
101
108
  _sig("ecdsaWithSHA512", { name: "ECDSA", hash: "SHA-512" }, { name: "ECDSA" }, "absent", true);
102
- // EdDSA (one-shot, no hash parameter) — params absent.
103
- _sig("Ed25519", { name: "Ed25519" }, { name: "Ed25519" }, "absent");
104
- _sig("Ed448", { name: "Ed448" }, { name: "Ed448" }, "absent");
105
- // ML-DSA (FIPS 204) — params absent.
106
- _sig("id-ml-dsa-44", { name: "ML-DSA-44" }, { name: "ML-DSA-44" }, "absent");
107
- _sig("id-ml-dsa-65", { name: "ML-DSA-65" }, { name: "ML-DSA-65" }, "absent");
108
- _sig("id-ml-dsa-87", { name: "ML-DSA-87" }, { name: "ML-DSA-87" }, "absent");
109
+ // EdDSA (one-shot, no hash parameter) — params absent; key OID == sig OID.
110
+ _sig("Ed25519", { name: "Ed25519" }, { name: "Ed25519" }, "absent", false, true);
111
+ _sig("Ed448", { name: "Ed448" }, { name: "Ed448" }, "absent", false, true);
112
+ // ML-DSA (FIPS 204) — params absent; key OID == sig OID.
113
+ _sig("id-ml-dsa-44", { name: "ML-DSA-44" }, { name: "ML-DSA-44" }, "absent", false, true);
114
+ _sig("id-ml-dsa-65", { name: "ML-DSA-65" }, { name: "ML-DSA-65" }, "absent", false, true);
115
+ _sig("id-ml-dsa-87", { name: "ML-DSA-87" }, { name: "ML-DSA-87" }, "absent", false, true);
116
+ // SLH-DSA (FIPS 205) — params absent; key OID == sig OID. The twelve pure sets;
117
+ // the RFC 9909 §3 OID name maps to the WebCrypto set name by id-slh-dsa-<set> ->
118
+ // SLH-DSA-<SET> (the webcrypto SLH_DSA_NODE keys). One-shot verify like ML-DSA.
119
+ ["sha2-128s", "sha2-128f", "sha2-192s", "sha2-192f", "sha2-256s", "sha2-256f",
120
+ "shake-128s", "shake-128f", "shake-192s", "shake-192f", "shake-256s", "shake-256f"
121
+ ].forEach(function (set) {
122
+ var wc = "SLH-DSA-" + set.toUpperCase();
123
+ _sig("id-slh-dsa-" + set, { name: wc }, { name: wc }, "absent", false, true);
124
+ });
109
125
 
110
126
  // RSASSA-PSS resolves its hash + salt from the AlgorithmIdentifier parameters.
111
127
  var OID_RSA_PSS = oid.byName("rsassaPss");
@@ -238,6 +254,24 @@ function resolveDescriptor(sigAlg) {
238
254
  return d;
239
255
  }
240
256
 
257
+ // RFC 9814 §4 issuer-key <-> signature-algorithm consistency (algorithm-confusion
258
+ // defense). For the one-shot families whose public key shares the signature OID
259
+ // (EdDSA, ML-DSA, SLH-DSA), Node's WebCrypto imports an SPKI of a DIFFERENT type
260
+ // under the requested name and verifies with the real key — so an Ed25519-signed
261
+ // certificate labelled SLH-DSA would otherwise validate. Enforce structurally:
262
+ // the issuer SPKI's algorithm OID MUST equal the signature algorithm OID. (For
263
+ // RSA/ECDSA — different key vs signature OIDs — WebCrypto's import already rejects
264
+ // a mismatched key type, so `sameKeyOid` is not set and this is a no-op.)
265
+ function assertKeyMatchesSigAlg(spkiBytes, sigOid, d) {
266
+ if (!d || !d.sameKeyOid) return;
267
+ var keyOid;
268
+ try { keyOid = asn1.read.oid(asn1.decode(spkiBytes).children[0].children[0]); }
269
+ catch (e) { throw E("path/algorithm-mismatch", "cannot read the issuer public-key algorithm identifier", e); }
270
+ if (keyOid !== sigOid) {
271
+ throw E("path/algorithm-mismatch", "issuer public-key algorithm " + keyOid + " does not match the signature algorithm " + sigOid + " (RFC 9814 §4 - algorithm confusion)");
272
+ }
273
+ }
274
+
241
275
  // DER Ecdsa-Sig-Value SEQUENCE { r, s } -> fixed-width r||s (P1363), rejecting
242
276
  // r or s outside [1, n-1] (CVE-2022-21449 "Psychic Signatures").
243
277
  function ecdsaDerToP1363(der, curve) {
@@ -270,8 +304,10 @@ function ecdsaDerToP1363(der, curve) {
270
304
  // Verify cert.signatureValue over cert.tbsBytes with the working public key.
271
305
  function builtinVerify(state, cert) {
272
306
  var d;
273
- try { d = resolveDescriptor(cert.signatureAlgorithm); }
274
- catch (e) { return Promise.resolve({ ok: false, code: pathCode(e, "path/unsupported-algorithm"), error: e }); }
307
+ try {
308
+ d = resolveDescriptor(cert.signatureAlgorithm);
309
+ assertKeyMatchesSigAlg(state.workingPublicKey, cert.signatureAlgorithm.oid, d);
310
+ } catch (e) { return Promise.resolve({ ok: false, code: pathCode(e, "path/unsupported-algorithm"), error: e }); }
275
311
  // The signature is an octet-aligned BIT STRING (no unused bits) for every
276
312
  // supported algorithm; a non-zero unused-bit count is malformed.
277
313
  if (cert.signatureValue.unusedBits !== 0) return Promise.resolve({ ok: false, code: "path/bad-signature" });
@@ -1387,8 +1423,10 @@ function crlEntryReason(entry) {
1387
1423
 
1388
1424
  function verifyCrlSignature(theCrl, issuer) {
1389
1425
  var d;
1390
- try { d = resolveDescriptor(theCrl.signatureAlgorithm); }
1391
- catch (_e) { return Promise.resolve(false); }
1426
+ try {
1427
+ d = resolveDescriptor(theCrl.signatureAlgorithm);
1428
+ assertKeyMatchesSigAlg(issuer.workingPublicKey, theCrl.signatureAlgorithm.oid, d);
1429
+ } catch (_e) { return Promise.resolve(false); }
1392
1430
  if (theCrl.signatureValue.unusedBits !== 0) return Promise.resolve(false); // non-octet-aligned signature
1393
1431
  var key;
1394
1432
  return subtle.importKey("spki", issuer.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
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
  };
@@ -156,6 +156,15 @@ function algorithmIdentifier(ns, opts) {
156
156
  arity: { min: 1 }, code: ns.prefix + "/bad-algorithm-identifier", what: "AlgorithmIdentifier",
157
157
  build: function (m, ctx) {
158
158
  var dotted = m.fields.algorithm.value;
159
+ // RFC 9909 §3 / RFC 9814 §4 / RFC 9881 §2 / RFC 8410 §3: for the PQC
160
+ // signature families (ML-DSA, SLH-DSA) and the Edwards/Montgomery curves,
161
+ // the `parameters` field MUST be absent — no explicit NULL, no bytes.
162
+ // Enforced ONCE here so every consumer of the shared AlgorithmIdentifier
163
+ // inherits the rule; the guard-parity bug class is structurally impossible.
164
+ if (m.fields.parameters.present && ctx.oid.paramsMustBeAbsent(dotted)) {
165
+ throw ctx.E(ctx.prefix + "/bad-algorithm-parameters",
166
+ "the " + (ctx.oid.name(dotted) || dotted) + " AlgorithmIdentifier parameters field MUST be absent (RFC 9909 §3 / RFC 9814 §4 / RFC 9881 §2 / RFC 8410 §3)");
167
+ }
159
168
  return { oid: dotted, name: ctx.oid.name(dotted) || null, parameters: m.fields.parameters.present ? m.fields.parameters.node.bytes : null };
160
169
  },
161
170
  });
@@ -361,6 +370,28 @@ function generalNames(ns, opts) {
361
370
  // `decodeExt` pattern (asn1.decode → read.* / schema.walk, wrapped). Returns
362
371
  // { byOid } keyed by dotted OID so a consumer dispatches by ext.oid.
363
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
+
364
395
  function certExtensionDecoders(ns) {
365
396
  var GN_SUBTREE = generalName(ns, { decodeValue: true, subtreeBase: true, code: ns.prefix + "/bad-name-constraints" });
366
397
 
@@ -486,20 +517,9 @@ function certExtensionDecoders(ns) {
486
517
  var qualifiers = null;
487
518
  if (pi.children.length > 1) {
488
519
  var q = pi.children[1];
489
- if (q.tagClass !== "universal" || q.tagNumber !== _T.SEQUENCE || !q.children || !q.children.length) {
490
- throw ns.E(C, "PolicyInformation policyQualifiers must be a non-empty SEQUENCE (RFC 5280 §4.2.1.4)");
491
- }
492
- // Each element is a PolicyQualifierInfo ::= SEQUENCE { policyQualifierId
493
- // OID, qualifier ANY DEFINED BY policyQualifierId } — EXACTLY two members
494
- // (qualifier is not OPTIONAL). A SEQUENCE missing the qualifier or
495
- // carrying trailing extra fields is malformed; the qualifier body itself
496
- // stays opaque.
497
- q.children.forEach(function (pq) {
498
- if (pq.tagClass !== "universal" || pq.tagNumber !== _T.SEQUENCE || !pq.children || pq.children.length !== 2) {
499
- throw ns.E(C, "policyQualifiers element must be a PolicyQualifierInfo SEQUENCE { policyQualifierId, qualifier } (RFC 5280 §4.2.1.4)");
500
- }
501
- try { asn1.read.oid(pq.children[0]); } catch (e) { throw ns.E(C, "PolicyQualifierInfo must lead with a policyQualifierId OID", e); }
502
- });
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); });
503
523
  qualifiers = q.bytes;
504
524
  }
505
525
  return { policyIdentifier: pid, qualifiersBytes: qualifiers };
@@ -829,6 +849,7 @@ module.exports = {
829
849
  makeParser: makeParser,
830
850
  signedEnvelopeTbs: signedEnvelopeTbs,
831
851
  rootSequenceChildren: rootSequenceChildren,
852
+ assertPolicyQualifiers: assertPolicyQualifiers,
832
853
  signedEnvelope: signedEnvelope,
833
854
  attrValueToString: attrValueToString,
834
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.20",
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:f23ca197-835e-4ec8-8d04-486fb3d9deac",
5
+ "serialNumber": "urn:uuid:38c53855-3eaa-4d1a-8c48-b03540881d8a",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-09T14:17:20.162Z",
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.20",
22
+ "bom-ref": "@blamejs/pki@0.1.22",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.20",
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.20",
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.20",
57
+ "ref": "@blamejs/pki@0.1.22",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]