@blamejs/pki 0.4.11 → 0.4.12

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,26 @@ 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.4.12 — 2026-08-09
8
+
9
+ A CMS message can no longer declare one content cipher and be opened with another: the declared algorithm's mode is now bound to the container that carries it, so an EnvelopedData naming an authenticated cipher is refused rather than opened, unauthenticated, under a result that reported it as authenticated.
10
+
11
+ ### Added
12
+
13
+ - pki.lint reports the RFC 5280 sec. 4.2.1.4 rules for a certificate policy's user notice, at the strength the specification states each one: encoding a notice as VisibleString or BMPString is an error, since conforming CAs must not; a notice past 200 characters, an empty one, and one containing control characters are warnings; a UTF8String notice that is not in Unicode normalization form C is a notice. The length is measured in characters, so a conforming notice whose accented or emoji characters occupy more storage than 200 units is not reported, and a value whose contents do not decode under its own declared string type is not measured at all -- though the encoding rule, which the ASN.1 tag alone answers, still reports it. The rules live in the linter and not the decoder deliberately: the same section directs certificate users to handle an over-long notice gracefully, so a verifier that refused one would reject certificates that exist and are otherwise valid.
14
+ - The two ends of the SIZE (1..200) bound are separate findings rather than one, because the section treats them differently: it directs certificate users to handle a notice ABOVE 200 characters gracefully and says nothing of the sort about an empty one, so suppressing the first must not silently suppress the second. Both cover a notice reference's organization as well as the explicit text, since the bound belongs to the DisplayText type rather than to one of the two fields that use it.
15
+
16
+ ### Changed
17
+
18
+ - pki.inspect renders a certificate policy's user notice as text. A user notice is a constructed value, so it previously fell to the hexadecimal fallback and an operator could not read the notice the qualifier exists to display; its explicit text and its notice reference now render, the reference carrying its organization together with the notice numbers that identify which notice is meant.
19
+ - pki.inspect renders an authority-information-access location given as a directory name. It previously printed a bare form tag, hiding the responder or issuer identity the entry exists to convey, while the same name form already printed as a distinguished name elsewhere in the report.
20
+ - The producing entry points state their error contract completely. pki.x509.sign, pki.csr.sign, pki.crl.sign and pki.attrcert.sign accept raw DER for a name, a pre-encoded extension, or a public key; a structural fault in those bytes raises the format's own error, while a malformed leaf inside them raises the codec's, which the parsing entry points already documented and these did not.
21
+
22
+ ### Fixed
23
+
24
+ - A CMS content cipher is now bound to the container that declares it. An EnvelopedData must name a CBC cipher and an AuthEnvelopedData an AEAD one, checked before the content-encryption key is used; a mismatch is refused as an unsupported algorithm naming both the cipher and the container. Previously only the cipher's key length was resolved, and because AES-CBC and AES-GCM share key lengths, an EnvelopedData whose algorithm identifier had been changed to the same-size AES-GCM identifier decrypted successfully as unauthenticated CBC while reporting the AEAD algorithm in its result -- so a caller inspecting contentEncryptionAlgorithm to establish that the content was authenticated was answered from a field the decryption had not honoured. The reverse pairing was refused only incidentally, by a later dereference of parameters the AEAD path expects, rather than by a stated rule.
25
+ - The password-recipient inner cipher is resolved through the same identifier-keyed table. It previously required a CBC mode by matching the algorithm identifier's display name, which pki.oid.register can rebind, so a caller that had registered a name over a built-in one could change which ciphers that check admitted.
26
+
7
27
  ## v0.4.11 — 2026-08-09
8
28
 
9
29
  A WebAuthn attestation can now be bound to the roots the authenticator's own model actually registered, by reading a FIDO Metadata Service BLOB that is verified and chained to a root you supply before any of its contents are parsed.
package/README.md CHANGED
@@ -243,9 +243,9 @@ is callable today; nothing below is a stub.
243
243
  | `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
244
244
  | `pki.hpke` | Hybrid Public Key Encryption (RFC 9180) — the encrypt-to-a-public-key primitive behind TLS ECH, MLS, and OHTTP. `setupS`/`setupR` establish a sender/recipient context (KEM encapsulation + HKDF key schedule); the context's `seal`/`open` AEAD-encrypt with a sequence-counter nonce and `export` derives further secrets; `seal`/`open` are single-shot wrappers. DHKEM (P-256, P-521, X25519, X448) × HKDF-SHA256/SHA512 × AES-GCM/ChaCha20Poly1305/export-only × all four modes, proven against the RFC 9180 Appendix A vectors. DHKEM(P-384) and HKDF-SHA384 are RFC-registered but Appendix A ships no vector for them, so they fail closed until an authoritative KAT exists. Pure composition over `node:crypto`; ML-KEM / X-Wing are a registry data-row extension pending stable drafts — `suites`, `setupS`, `setupR`, `seal`, `open` |
245
245
  | `pki.sigstore` | Offline verifier for a Sigstore bundle — the exact artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots + Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key; the ephemeral Fulcio certificate chain, validated as of the Rekor log time; the RFC 9162 inclusion proof folded to a Rekor-signed tree root; the log entry bound to this exact signature; and the in-toto SLSA subject digest the caller confirms against the published artifact. Zero runtime deps — reuses the X.509 parser, RFC 5280 path validator, and Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `pae`, `parseBundle`, `verifyBundle` |
246
- | `pki.inspect` | Human-readable inspection — the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. `crl` / `csr` / `cms` render the non-certificate formats the same way — a CRL like `openssl crl -text` (issuer, Last/Next Update, CRL extensions, each revoked entry with its serial, revocation date, and named reason), a CSR like `openssl req -text` (subject, key, requested extensions and attributes), and a CMS message like `openssl cms -cmsout -print` (a SignedData's content type, digest algorithms, embedded certificates, and each SignerInfo with its signer identifier, algorithms, attributes, and signature; a non-SignedData ContentInfo gets a stable summary) — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry, reusing one set of field renderers, so it names extension/algorithm OIDs an OpenSSL build shows only as raw bytes and never drifts. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
246
+ | `pki.inspect` | Human-readable inspection — the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. `crl` / `csr` / `cms` render the non-certificate formats the same way — a CRL like `openssl crl -text` (issuer, Last/Next Update, CRL extensions, each revoked entry with its serial, revocation date, and named reason), a CSR like `openssl req -text` (subject, key, requested extensions and attributes), and a CMS message like `openssl cms -cmsout -print` (a SignedData's content type, digest algorithms, embedded certificates, and each SignerInfo with its signer identifier, algorithms, attributes, and signature; a non-SignedData ContentInfo gets a stable summary) — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry, reusing one set of field renderers, so it names extension/algorithm OIDs an OpenSSL build shows only as raw bytes and never drifts. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a certificate policy's user notice renders as text (its explicit text, and a notice reference with the notice numbers that identify it) rather than hex, and a malformed part falls back to a hex dump rather than throwing — `certificate`, `crl`, `csr`, `cms`, `any` |
247
247
  | `pki.webauthn` | WebAuthn / passkey attestation verification — offline trust evaluation of a W3C WebAuthn (Level 3) attestation. `parseAttestationObject(bytes)` decodes the CBOR attestation object + authenticatorData + COSE credential key over the strict `pki.cbor` codec; `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for **packed / tpm / android-key / apple / fido-u2f / none** — the x5c leaf key, the apple nonce, the tpm `certInfo` Name/`extraData` over the `pubArea`, the android `KeyDescription`, the fido-u2f `verificationData` — binding the credential public key to each attestation (via the signed authenticatorData for packed/fido-u2f, or a cert/`pubArea`-key equality check for android-key/apple/tpm) and enforcing each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers **ESP256/384/512, Ed25519, and Ed448** — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally-encoded DER ECDSA signature. A verifier, not a ceremony client; fail-closed with typed `webauthn/*` errors. `verifyMetadataBlob(blob, opts)` reads a **FIDO Metadata Service (MDS v3)** BLOB — the signed catalogue of registered authenticator models — verifying its JWS and chaining its signer to an operator-supplied FIDO root **before** the payload is parsed, with sequence-number rollback and `nextUpdate` freshness checks; passing the result as `opts.metadata` to `verify` resolves the authenticator's registered attestation roots from its identifier and requires the trust path to fully validate to one of them, refusing an unlisted or revoked model. Both of the catalogue's key spaces are covered — an aaguid, and the attestation-certificate key identifiers a U2F authenticator is listed under instead. No FIDO root is bundled and there is no trust-on-first-use; retrieving the BLOB is out of scope — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
248
- | `pki.lint` | Certificate linting — the zlint / pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded, advisory findings — each with a stable id, a severity (`fatal` > `error` > `warn` > `notice`), a source, a spec-clause citation, and a message — against the RFC 5280 profile plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality — basicConstraints/nameConstraints/policyConstraints/inhibitAnyPolicy must be critical and keyUsage should be, nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI/AKI presence including the end-entity subjectKeyIdentifier, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
248
+ | `pki.lint` | Certificate linting — the zlint / pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded, advisory findings — each with a stable id, a severity (`fatal` > `error` > `warn` > `notice`), a source, a spec-clause citation, and a message — against the RFC 5280 profile plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality — basicConstraints/nameConstraints/policyConstraints/inhibitAnyPolicy must be critical and keyUsage should be, nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI/AKI presence including the end-entity subjectKeyIdentifier, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, and the sec. 4.2.1.4 certificate-policy user-notice rules — a VisibleString/BMPString explicitText, a notice past 200 characters, an empty notice, control characters, and a non-NFC UTF8String notice, each at the strength the clause states). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
249
249
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
250
250
  | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError` / `WebauthnError` / `LintError`, each carrying a stable `code` in `domain/reason` form |
251
251
  | `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>`, `pki inspect <cert>`, `pki lint <cert>`, `pki convert <file> --to der\|pem`, `pki verify <cert>... --anchor <cert>`, `pki sign <file> --cert <c> --key <k>` |
@@ -428,7 +428,9 @@ function _buildExtensions(extSpec, aaSpki) {
428
428
  * attribute certificate is never self-signed. The signature algorithm is resolved from the AA key (RSA
429
429
  * PKCS#1 v1.5 or PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm), and the signature is verified
430
430
  * under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE
431
- * CERTIFICATE` with `opts.pem`. Malformed input throws a typed `AttrCertError`. The AA certificate's own
431
+ * CERTIFICATE` with `opts.pem`. Malformed input throws a typed `AttrCertError`; where the spec carries
432
+ * raw DER -- a holder or issuer `Name` Buffer, a pre-encoded `Extension` -- a malformed leaf inside
433
+ * those bytes throws `Asn1Error`. The AA certificate's own
432
434
  * profile (RFC 5755 sec. 4.5) and validity are a verification-layer concern -- validate the AA
433
435
  * certificate with `pki.path.validate` before trusting the attribute certificate. Parsing is
434
436
  * `pki.schema.attrcert.parse`.
@@ -36,6 +36,7 @@ function _err(code, message, cause) { return new CmsError(code, message, cause);
36
36
  function _fail() { return new CmsError("cms/decrypt-failed", "the CMS content could not be decrypted (uniform by design -- padding / integrity / key-unwrap failures are indistinguishable to defeat oracles)"); }
37
37
 
38
38
  var CONTENT_KEYBITS = pbes2.CONTENT_KEYBITS; // content-encryption OID -> key bits (the shared PBES2 table)
39
+ var CONTENT_MODE = pbes2.CONTENT_MODE; // the same rows' cipher mode ("cbc" | "gcm")
39
40
 
40
41
  // ---- entry -----------------------------------------------------------------
41
42
  async function decrypt(input, keyMaterial, opts) {
@@ -63,6 +64,13 @@ async function decryptEnvelopedData(parsed, keyMaterial, opts, contentTypeName)
63
64
  var recips = parsed.recipientInfos || [];
64
65
  var candidates = _selectCandidates(recips, keyMaterial, opts); // stage 1 (typed, distinct)
65
66
  var eci = parsed.encryptedContentInfo;
67
+ // The content cipher's MODE is a structural property of the message -- readable from the algorithm
68
+ // identifier with no key material at all -- so it is decided HERE, before any recipient is tried.
69
+ // Deciding it per candidate would make a structural verdict depend on key acquisition succeeding
70
+ // first: several password or KEK recipients can match the same key material, so the distinct
71
+ // cms/unsupported-algorithm would be swallowed by the ambiguous-candidate loop below and the caller
72
+ // told their key was wrong, after repeating a PBKDF2 derivation for every candidate to learn it.
73
+ _assertContentCipherMode(eci, ct);
66
74
  // Password- and KEK-based recipients carry no rid, so several may match the same key material; try
67
75
  // each until one yields a CEK that opens the content. A single candidate propagates its exact
68
76
  // verdict; multiple ambiguous candidates collapse to the uniform verdict so nothing is leaked.
@@ -274,7 +282,9 @@ async function _pwriCek(ri, km, opts) {
274
282
  var inner = asn1.decode(kea.parameters); // inner AES-CBC AlgorithmIdentifier
275
283
  var innerOid = asn1.read.oid(inner.children[0]);
276
284
  var innerBits = CONTENT_KEYBITS[innerOid];
277
- if (!innerBits || !/CBC/.test(oid.name(innerOid) || "")) throw _err("cms/unsupported-algorithm", "unsupported pwri inner cipher");
285
+ // The mode comes from the OID-keyed table, not from the OID's display NAME: pki.oid.register() can
286
+ // override a built-in name, and a name-matched mode check would then admit a non-CBC inner cipher.
287
+ if (!innerBits || CONTENT_MODE[innerOid] !== "cbc") throw _err("cms/unsupported-algorithm", "unsupported pwri inner cipher");
278
288
  var iv = asn1.read.octetString(inner.children[1]);
279
289
  var kek = nodeCrypto.pbkdf2Sync(pbes2.passwordBytes(km.password, _err, "cms"), pb.salt, pb.iterations, innerBits / 8, pb.prfNode);
280
290
  return _pwriUnwrap(kek, ri.encryptedKey, iv, innerBits);
@@ -304,6 +314,22 @@ async function _kemriCek(ri, km) {
304
314
  return await _aesKwUnwrap(kek, k.encryptedKey);
305
315
  }
306
316
 
317
+ // The declared cipher MODE must match the container's authentication model: an AEAD cipher belongs in
318
+ // AuthEnvelopedData and a plain CBC cipher in EnvelopedData (RFC 5083 sec. 2.1 / RFC 5084 sec. 3). Key
319
+ // length alone does not separate them, so without this an EnvelopedData naming AES-GCM was opened as
320
+ // unauthenticated CBC while the result still reported the AEAD algorithm -- telling the caller the
321
+ // content was authenticated when nothing had authenticated it. The mirror case dereferenced AEAD
322
+ // parameters the parser leaves null for a non-AEAD algorithm, faulting instead of failing closed.
323
+ // An unresolvable OID is left to _openContent, which names it as the unsupported algorithm it is.
324
+ function _assertContentCipherMode(eci, ct) {
325
+ var oidStr = eci.contentEncryptionAlgorithm.oid;
326
+ if (!CONTENT_MODE[oidStr]) return;
327
+ var wantMode = ct === "authEnvelopedData" ? "gcm" : "cbc";
328
+ if (CONTENT_MODE[oidStr] !== wantMode) {
329
+ throw _err("cms/unsupported-algorithm", "contentEncryptionAlgorithm " + oidStr + " is not a " + wantMode.toUpperCase() + " cipher, which " + ct + " requires (RFC 5083 sec. 2.1 / RFC 5084 sec. 3)");
330
+ }
331
+ }
332
+
307
333
  // ---- stage 3: open the content (uniform failure) ---------------------------
308
334
  async function _openContent(parsed, eci, cek, ct) {
309
335
  var alg = eci.contentEncryptionAlgorithm;
package/lib/crl-sign.js CHANGED
@@ -478,7 +478,8 @@ function _sign(spec, issuer, opts) {
478
478
  * empty revocation list omits `revokedCertificates` rather than emitting an empty SEQUENCE (sec. 5.1.2.6);
479
479
  * `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime (sec. 5.3.1/5.3.2);
480
480
  * per-extension criticality is fixed by the RFC; and the produced signature is verified under the issuer
481
- * key before return. A violation throws a typed `CrlError`.
481
+ * key before return. A violation throws a typed `CrlError`; where the spec carries raw DER -- an issuer
482
+ * `Name` Buffer or a pre-encoded `Extension` -- a malformed leaf inside those bytes throws `Asn1Error`.
482
483
  *
483
484
  * @opts
484
485
  * - `pem` (boolean) -- return a PEM `X509 CRL` string instead of DER.
package/lib/csr-sign.js CHANGED
@@ -82,7 +82,9 @@ function _challengePassword(pw) {
82
82
  * to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the
83
83
  * request is returned. The signature algorithm is resolved from the subject key (RSA PKCS#1 v1.5 or PSS,
84
84
  * ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm). Returns DER, or a PEM `CERTIFICATE REQUEST` with
85
- * `opts.pem`. Malformed input throws a typed `CsrError`. Certificate-request parsing is `pki.schema.csr.parse`.
85
+ * `opts.pem`. Malformed input throws a typed `CsrError`; where the spec carries raw DER -- a `Name`
86
+ * Buffer, a pre-encoded requested `Extension` or `Attribute` -- a malformed leaf inside those bytes
87
+ * throws `Asn1Error` instead. Certificate-request parsing is `pki.schema.csr.parse`.
86
88
  *
87
89
  * @opts
88
90
  * - `pem` (boolean) -- return a PEM `CERTIFICATE REQUEST` string instead of DER.
package/lib/inspect.js CHANGED
@@ -46,6 +46,9 @@ function _err(code, message, cause) { return new InspectError(code, message, cau
46
46
  // caught by the renderer and fall back to a hex dump (inspection is best-effort).
47
47
  var NS = pkix.makeNS("inspect", InspectError, oid);
48
48
  var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
49
+ // Dispatch on the stable OID, not the display name: pki.oid.register() can override a built-in
50
+ // name, which would silently skip the userNotice rendering and hex-dump the notice instead.
51
+ var OID_UNOTICE = oid.byName("unotice");
49
52
 
50
53
  // ---- formatting helpers ------------------------------------------------------
51
54
 
@@ -196,14 +199,17 @@ function _ipString(buf) {
196
199
  // their structural separators are left as-is, matching OpenSSL (a GeneralName has no
197
200
  // RFC 4514-equivalent escaping profile, and escaping a legitimate comma in a URI
198
201
  // would misrepresent it).
202
+ // The DN of a decoded directoryName GeneralName value. Shared so every renderer that meets this
203
+ // form -- the SAN/AKI GeneralName path and the AIA/SIA accessLocation path, which carry different
204
+ // decoded shapes -- prints the same DN rather than one of them falling back to a bare tag number.
205
+ function _gnDn(value) {
206
+ return (value && Array.isArray(value.rdns)) ? _dnString(value) : ((value && value.dn) || "");
207
+ }
199
208
  function _gn(g) {
200
209
  if (!g || typeof g !== "object") return "";
201
210
  var t = g.tagNumber;
202
211
  if (t === 7 && Buffer.isBuffer(g.value)) return "IP Address:" + _ipString(g.value);
203
- if (t === 4) {
204
- var dn = (g.value && Array.isArray(g.value.rdns)) ? _dnString(g.value) : ((g.value && g.value.dn) || "");
205
- return "DirName:" + dn;
206
- }
212
+ if (t === 4) return "DirName:" + _gnDn(g.value);
207
213
  if (t === 0) return "othername:" + (Buffer.isBuffer(g.bytes) ? _hexColon(g.bytes, {}) : "<unsupported>");
208
214
  var kind = GN_KIND[t] || ("tag" + t);
209
215
  var v = (typeof g.value === "string") ? _clean(g.value)
@@ -368,6 +374,30 @@ var EXT_RENDERERS = {
368
374
  var label = null;
369
375
  try { label = oid.name(qid); }
370
376
  catch (_e) { /* unregistered qualifier */ }
377
+ // A userNotice is a constructed SEQUENCE, so the printable-content test below can never
378
+ // read it and it would hex-dump -- leaving the operator unable to read the very text the
379
+ // qualifier exists to display. Render its DisplayText members through the shared pkix
380
+ // reader (the same one pki.lint measures), so both agree on what the notice says.
381
+ if (qid === OID_UNOTICE) {
382
+ // Render only when EVERY member decoded under its declared string type. A null text means
383
+ // the value did not, and showing the members that happened to decode would present a
384
+ // partial notice as a complete one -- so the whole qualifier falls through to the hex
385
+ // dump, where the operator sees the bytes the certificate actually holds.
386
+ // A null noticeNumbers means the reference did not fully decode, and is refused for the
387
+ // same reason as a null text: a partially decoded notice must not be shown as a whole one.
388
+ var texts = pkix.userNoticeTexts(q);
389
+ if (texts.length && texts.every(function (t) {
390
+ return t.text !== null && (t.field !== "organization" || t.noticeNumbers !== null);
391
+ })) {
392
+ texts.forEach(function (t) {
393
+ // A NoticeReference is identified by organization AND number, so the numbers ride with
394
+ // the organization -- printing the text alone would drop the key that names the notice.
395
+ var nums = (t.noticeNumbers && t.noticeNumbers.length) ? " #" + t.noticeNumbers.join(", ") : "";
396
+ lines.push(inner + " " + (label || qid) + " " + t.field + ": " + _clean(t.text) + nums);
397
+ });
398
+ return;
399
+ }
400
+ }
371
401
  var val = (q && !q.constructed && Buffer.isBuffer(q.content) && _printable(q.content))
372
402
  ? _clean(q.content.toString("latin1"))
373
403
  : _hexColon(q && Buffer.isBuffer(q.bytes) ? q.bytes : Buffer.alloc(0), {});
@@ -397,6 +427,10 @@ var EXT_RENDERERS = {
397
427
  else if (loc.tag === 2) lv = "DNS:" + loc.value;
398
428
  else if (loc.tag === 1) lv = "email:" + loc.value;
399
429
  else if (loc.tag === 7) lv = "IP:" + _ipString(loc.value);
430
+ // A directoryName accessLocation carries a decoded Name, so print the DN. Without this it fell
431
+ // to the bracketed-tag fallback and rendered a bare "[4]", hiding the responder/issuer identity
432
+ // the entry exists to convey -- while the same form already printed as DirName elsewhere.
433
+ else if (loc.tag === 4) lv = "DirName:" + _gnDn(loc.value);
400
434
  else lv = typeof loc.value === "string" ? loc.value : "[" + loc.tag + "]";
401
435
  return inner + (LABEL[m] || m || ad.accessMethod) + " - " + lv;
402
436
  }).join("\n");
package/lib/lint.js CHANGED
@@ -274,6 +274,58 @@ function _ecCurveName(spki) {
274
274
  } catch (_e) { return null; } // explicit / invalid EC parameters are not an approved named curve
275
275
  }
276
276
 
277
+ // RFC 5280 marks several extensions MUST (error) or SHOULD (warn) be critical. The shape is
278
+ // uniform: applies when the extension is present, fires when its raw `critical` flag is not
279
+ // true. The rule reads `ctx.raw(name).critical` WITHOUT decoding the value -- criticality is
280
+ // a structural property of the extension, independent of its contents.
281
+ // ---- RFC 5280 4.2.1.4 userNotice DisplayText ----
282
+ // certificatePolicies surfaces each policy's qualifiers as RAW bytes (an external verifier may hash
283
+ // them), so the DisplayText values are read out of those bytes here. The UserNotice walk itself lives
284
+ // in schema-pkix (pki.inspect renders the same values), so the two consumers cannot disagree about
285
+ // which members are DisplayText or how a BMPString decodes.
286
+ var OID_UNOTICE = oid.byName("unotice");
287
+ var _T_BMP = asn1.TAGS.BMP_STRING, _T_VISIBLE = asn1.TAGS.VISIBLE_STRING, _T_UTF8 = asn1.TAGS.UTF8_STRING;
288
+ // The C0 and C1 control ranges, tested by code point rather than a regex: the characters this rule is
289
+ // ABOUT cannot appear in the source, and a regex holding them would be a control byte here.
290
+ function _hasControlChar(str) {
291
+ for (var i = 0; i < str.length; i++) {
292
+ var c = str.charCodeAt(i);
293
+ if (c <= 0x1f || (c >= 0x7f && c <= 0x9f)) return true;
294
+ }
295
+ return false;
296
+ }
297
+ // Every DisplayText in the certificate's certificatePolicies. Both positions are collected --
298
+ // explicitText AND NoticeReference.organization -- because the SIZE bound is on the DisplayText type,
299
+ // so reporting only explicitText would leave its sibling unmeasured.
300
+ // The two decodes below are fail-safe belts on the never-throw data path, not reachable paths: the
301
+ // shared decoder ran pkix.assertPolicyQualifiers before surfacing qualifiersBytes, so the bytes are
302
+ // already known to decode as a SEQUENCE whose every element is a two-member PolicyQualifierInfo led
303
+ // by a readable OID. An undecodable extension never reaches here at all -- it is the separate
304
+ // extension-undecodable finding.
305
+ function _policyDisplayTexts(ctx) {
306
+ var d = ctx.decode("certificatePolicies");
307
+ if (!d || !Array.isArray(d.value)) return [];
308
+ var out = [];
309
+ d.value.forEach(function (pi) {
310
+ if (!pi.qualifiersBytes || !pi.qualifiersBytes.length) return;
311
+ var quals;
312
+ // allow:swallow-unverified re-decoding bytes that already decoded under assertPolicyQualifiers cannot throw
313
+ try { quals = asn1.decode(pi.qualifiersBytes).children; } catch (_e) { return; }
314
+ (quals || []).forEach(function (pq) {
315
+ var qid;
316
+ // allow:swallow-unverified assertPolicyQualifiers already read this OID, so re-reading it cannot throw
317
+ try { qid = asn1.read.oid(pq.children[0]); } catch (_e2) { return; }
318
+ if (qid !== OID_UNOTICE) return;
319
+ // Entries whose contents did not decode arrive with a null `text` and keep their tag: the rules
320
+ // below that read the text skip them, while the encoding rule -- which the ASN.1 tag alone
321
+ // answers -- still sees them.
322
+ out = out.concat(pkix.userNoticeTexts(pq.children[1]));
323
+ });
324
+ });
325
+ return out;
326
+ }
327
+ function _hasPolicyDisplayText(cert, ctx) { return _policyDisplayTexts(ctx).length > 0; }
328
+
277
329
  // RFC 5280 marks several extensions MUST (error) or SHOULD (warn) be critical. The shape is
278
330
  // uniform: applies when the extension is present, fires when its raw `critical` flag is not
279
331
  // true. The rule reads `ctx.raw(name).critical` WITHOUT decoding the value -- criticality is
@@ -413,6 +465,65 @@ var RFC5280_RULES = [
413
465
  appliesTo: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return !(bc && bc.value && bc.value.cA === true); },
414
466
  check: function (cert, ctx) { return ctx.raw("subjectKeyIdentifier") ? null : true; },
415
467
  },
468
+ // 4.2.1.4 governs the userNotice qualifier's DisplayText with one MUST NOT and three SHOULD-level
469
+ // rules. None of them can live in the decoder: the section closes by directing certificate users to
470
+ // "gracefully handle explicitText with more than 200 characters", so a verifier that rejected these
471
+ // would refuse certificates that are in the wild and otherwise valid. Reporting them is exactly what
472
+ // a linter is for, so each rule carries the severity its normative word does.
473
+ {
474
+ // DisplayText is SIZE (1..200) -- both ends -- but the two ends get SEPARATE ids rather than one
475
+ // length rule, because the section treats them differently: it tells certificate users to handle a
476
+ // notice ABOVE 200 gracefully, and says nothing of the sort about an empty one. An operator acting
477
+ // on that advice suppresses the over-long finding; folding both into a single id would silently
478
+ // suppress the empty case along with it, which has no such carve-out.
479
+ id: "lint/rfc5280/explicit-text-too-long", severity: "warn", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
480
+ message: "a userNotice DisplayText should not exceed 200 characters",
481
+ appliesTo: _hasPolicyDisplayText,
482
+ check: function (cert, ctx) {
483
+ var over = _policyDisplayTexts(ctx).filter(function (d) { return d.text !== null && d.chars > pkix.DISPLAY_TEXT_MAX; });
484
+ return over.length ? { context: { count: over.length, longest: Math.max.apply(null, over.map(function (d) { return d.chars; })) } } : null;
485
+ },
486
+ },
487
+ {
488
+ id: "lint/rfc5280/explicit-text-empty", severity: "warn", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
489
+ message: "a userNotice DisplayText must not be empty (SIZE (1..200))",
490
+ appliesTo: _hasPolicyDisplayText,
491
+ check: function (cert, ctx) {
492
+ var empty = _policyDisplayTexts(ctx).filter(function (d) { return d.text !== null && d.chars < 1; });
493
+ return empty.length ? { context: { count: empty.length } } : null;
494
+ },
495
+ },
496
+ {
497
+ id: "lint/rfc5280/explicit-text-bad-encoding", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
498
+ message: "conforming CAs must not encode explicitText as VisibleString or BMPString",
499
+ appliesTo: _hasPolicyDisplayText,
500
+ check: function (cert, ctx) {
501
+ var bad = _policyDisplayTexts(ctx).filter(function (d) { return d.field === "explicitText" && (d.tagNumber === _T_VISIBLE || d.tagNumber === _T_BMP); });
502
+ return bad.length ? { context: { count: bad.length, encoding: bad[0].tagNumber === _T_BMP ? "BMPString" : "VisibleString" } } : null;
503
+ },
504
+ },
505
+ {
506
+ id: "lint/rfc5280/explicit-text-control-chars", severity: "warn", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
507
+ message: "an explicitText should not include control characters (U+0000 to U+001F, U+007F to U+009F)",
508
+ appliesTo: _hasPolicyDisplayText,
509
+ check: function (cert, ctx) {
510
+ var bad = _policyDisplayTexts(ctx).filter(function (d) { return d.field === "explicitText" && d.text !== null && _hasControlChar(d.text); });
511
+ return bad.length ? { context: { count: bad.length } } : null;
512
+ },
513
+ },
514
+ {
515
+ id: "lint/rfc5280/explicit-text-not-nfc", severity: "notice", source: "rfc5280", citation: "RFC 5280 4.2.1.4",
516
+ message: "a UTF8String explicitText should be normalized to Unicode normalization form C (NFC)",
517
+ appliesTo: _hasPolicyDisplayText,
518
+ check: function (cert, ctx) {
519
+ // Only the utf8String arm carries the NFC recommendation; the other arms cannot express the
520
+ // combining sequences the rule is about.
521
+ var bad = _policyDisplayTexts(ctx).filter(function (d) {
522
+ return d.field === "explicitText" && d.text !== null && d.tagNumber === _T_UTF8 && d.text.normalize("NFC") !== d.text;
523
+ });
524
+ return bad.length ? { context: { count: bad.length } } : null;
525
+ },
526
+ },
416
527
  ];
417
528
 
418
529
  function _isTls(cert, ctx) { return ctx.isTlsServerCert; }
package/lib/pbes2.js CHANGED
@@ -26,8 +26,15 @@ var PRF_NODE_BY_NAME = { hmacWithSHA1: "sha1", hmacWithSHA256: "sha256", hmacWit
26
26
  var PRF_NODE_BY_OID = {}; Object.keys(PRF_NODE_BY_NAME).forEach(function (n) { PRF_NODE_BY_OID[O(n)] = PRF_NODE_BY_NAME[n]; });
27
27
 
28
28
  // content-encryption OID -> AES key bits (CBC + GCM). The PBES2 encryptionScheme + CMS content cipher table.
29
- var CONTENT_KEYBITS = {};
30
- [["aes128-CBC", 128], ["aes192-CBC", 192], ["aes256-CBC", 256], ["aes128-GCM", 128], ["aes192-GCM", 192], ["aes256-GCM", 256]].forEach(function (r) { CONTENT_KEYBITS[O(r[0])] = r[1]; });
29
+ // CONTENT_MODE rides the SAME rows so a cipher can never be present for its key length but absent for its
30
+ // mode: key length alone does not distinguish an AEAD cipher from a plain one, and a consumer that resolves
31
+ // only the length would open content in the wrong mode whenever the two happen to share a key size.
32
+ var CONTENT_KEYBITS = {}, CONTENT_MODE = {};
33
+ [["aes128-CBC", 128, "cbc"], ["aes192-CBC", 192, "cbc"], ["aes256-CBC", 256, "cbc"],
34
+ ["aes128-GCM", 128, "gcm"], ["aes192-GCM", 192, "gcm"], ["aes256-GCM", 256, "gcm"]].forEach(function (r) {
35
+ CONTENT_KEYBITS[O(r[0])] = r[1];
36
+ CONTENT_MODE[O(r[0])] = r[2];
37
+ });
31
38
 
32
39
  // A password is an octet string (RFC 8018 sec. 2): a string is UTF-8-encoded deterministically (correct for
33
40
  // non-ASCII, and byte-identical to OpenSSL), a Buffer/Uint8Array used verbatim.
@@ -211,6 +218,6 @@ module.exports = {
211
218
  prfNodeByName: prfNodeByName, prfNodeByOid: prfNodeByOid,
212
219
  pbkdf2ParamsSeq: pbkdf2ParamsSeq, pbes2AlgId: pbes2AlgId, parsePbkdf2Params: parsePbkdf2Params,
213
220
  requireChildren: requireChildren, seqChildren: seqChildren,
214
- cbcEncrypt: cbcEncrypt, cbcDecrypt: cbcDecrypt, pbes2Encrypt: pbes2Encrypt, pbes2Decrypt: pbes2Decrypt, CONTENT_KEYBITS: CONTENT_KEYBITS,
221
+ cbcEncrypt: cbcEncrypt, cbcDecrypt: cbcDecrypt, pbes2Encrypt: pbes2Encrypt, pbes2Decrypt: pbes2Decrypt, CONTENT_KEYBITS: CONTENT_KEYBITS, CONTENT_MODE: CONTENT_MODE,
215
222
  pbmac1AlgId: pbmac1AlgId, pbmac1: pbmac1,
216
223
  };
@@ -1031,7 +1031,11 @@ function _qualifierToDer(qidNode, qtextNode) {
1031
1031
  if (qtextNode.majorType !== 3) throw _err("c509/bad-extensions", "a policyQualifier value must be a CBOR text string");
1032
1032
  var text = cbor.read.textString(qtextNode);
1033
1033
  if (qi === 1) return b.sequence([b.oid(oid.byName("cps")), _ia5Universal(text, "a CPSuri")]); // CPSuri ::= IA5String
1034
- // id-qt-unotice: UserNotice ::= SEQUENCE { explicitText utf8String } -- noticeRef omitted, DisplayText SIZE 1..200 floor.
1034
+ // id-qt-unotice: UserNotice ::= SEQUENCE { explicitText utf8String } -- noticeRef omitted. Only the SIZE
1035
+ // (1..200) FLOOR is enforced: an empty explicitText is a degenerate value no encoder can have produced,
1036
+ // while RFC 5280 sec. 4.2.1.4 directs certificate users to gracefully handle a notice ABOVE 200
1037
+ // characters, and draft-20 sec. 3.3's compact predicate is SIZE-silent -- so an over-long notice
1038
+ // transcodes rather than being refused.
1035
1039
  if (text.length === 0) throw _err("c509/bad-extensions", "a UserNotice explicitText must be non-empty (DisplayText SIZE 1..200)");
1036
1040
  return b.sequence([b.oid(oid.byName("unotice")), b.sequence([b.utf8(text)])]);
1037
1041
  }
@@ -703,6 +703,108 @@ function assertPolicyQualifiers(qNode, fail) {
703
703
  });
704
704
  }
705
705
 
706
+ // DisplayText ::= CHOICE { ia5String, visibleString, bmpString, utf8String } each SIZE (1..200)
707
+ // -- RFC 5280 sec. 4.2.1.4. The bound is deliberately NOT enforced here, and that is a normative
708
+ // requirement rather than a gap: the same section closes with "While the explicitText has a maximum
709
+ // size of 200 characters, some non-conforming CAs exceed this limit. Therefore, certificate users
710
+ // SHOULD gracefully handle explicitText with more than 200 characters." A decoder IS the certificate
711
+ // user that note addresses, so refusing an over-long notice would violate a SHOULD and reject
712
+ // certificates that are in the wild and otherwise valid. The bound belongs to the two layers that
713
+ // can act on it: an issuer, which must not MINT one, and pki.lint, which reports it as an advisory.
714
+ //
715
+ // Measured in CHARACTERS, not octets: a conforming 200-character UTF8String notice can occupy 800
716
+ // octets, so `.length` (UTF-16 units) or a byte count would misjudge a valid value. Exported so
717
+ // every layer that reports the bound counts it identically instead of keeping its own copy.
718
+ var DISPLAY_TEXT_MAX = 200;
719
+ function displayTextChars(str) { return Array.from(str).length; }
720
+
721
+ // @internal
722
+ // userNoticeTexts(qualifier) -- the DisplayText values of a decoded UserNotice qualifier, as
723
+ // [{ field, tagNumber, text, chars }] in encounter order. `field` is "explicitText" or
724
+ // "organization" (the NoticeReference member), so a caller that must treat the two alike (the
725
+ // SIZE bound applies to both) and one that must not (only explicitText carries the encoding and
726
+ // normalization rules) can each select correctly.
727
+ //
728
+ // UserNotice ::= SEQUENCE { noticeRef NoticeReference OPTIONAL, explicitText DisplayText OPTIONAL }
729
+ // NoticeReference ::= SEQUENCE { organization DisplayText, noticeNumbers SEQUENCE OF INTEGER }
730
+ // Both members are OPTIONAL and distinguished by tag, so a leading SEQUENCE is the noticeRef and any
731
+ // DisplayText member is the explicitText. Returns [] for a qualifier that is not a UserNotice --
732
+ // this reads an already-decoded node for display and reporting, and is never a validity verdict.
733
+ var _DISPLAY_TEXT_TAGS = null;
734
+ function _isDisplayTextNode(n) {
735
+ if (!_DISPLAY_TEXT_TAGS) {
736
+ _DISPLAY_TEXT_TAGS = {};
737
+ _DISPLAY_TEXT_TAGS[_T.IA5_STRING] = 1; _DISPLAY_TEXT_TAGS[_T.VISIBLE_STRING] = 1;
738
+ _DISPLAY_TEXT_TAGS[_T.BMP_STRING] = 1; _DISPLAY_TEXT_TAGS[_T.UTF8_STRING] = 1;
739
+ }
740
+ return !!n && n.tagClass === "universal" && _DISPLAY_TEXT_TAGS[n.tagNumber] === 1;
741
+ }
742
+ // Decoded through the STRICT reader, which validates each arm against its own declared string type
743
+ // (invalid UTF-8, a high bit in an IA5String, an odd-length or lone-surrogate BMPString all throw).
744
+ // Reading the content bytes directly would repair the value instead: `toString("utf8")` substitutes
745
+ // U+FFFD for invalid sequences and a hand-rolled UCS-2 loop drops a trailing odd octet, so a caller
746
+ // would render or measure text the certificate does not contain. Returns null when the value does
747
+ // not decode, so the caller can take its own fallback rather than trust a repair.
748
+ // An undecodable value yields an entry whose `text` and `chars` are null but whose `field` and
749
+ // `tagNumber` are still present. The two are separable facts: which ASN.1 string type was used is
750
+ // established by the tag alone, so a rule about the ENCODING stays answerable even when the contents
751
+ // do not decode, while every rule that reads the TEXT must skip the entry rather than analyze a
752
+ // repair. Collapsing both into "not analyzable" would let a prohibited encoding escape its finding
753
+ // by also being malformed inside.
754
+ function _dtEntry(field, node) {
755
+ var text;
756
+ // allow:swallow-unverified an undecodable DisplayText keeps its tag and drops its text; the callers branch on text === null
757
+ try { text = asn1.read.string(node); } catch (_e) { text = null; }
758
+ return { field: field, tagNumber: node.tagNumber, text: text, chars: text === null ? null : displayTextChars(text) };
759
+ }
760
+ // A NoticeReference names its notice by ORGANIZATION **and** NUMBER -- the organization alone does not
761
+ // identify which notice is meant, so a consumer that surfaced only the text would lose the lookup key.
762
+ // Returns null when the numbers are absent, wrongly shaped, or contain a member that is not a readable
763
+ // INTEGER: an incompletely decoded reference must NOT be presentable, because dropping the members that
764
+ // failed would render a partial reference indistinguishable from a whole one -- the same defect as
765
+ // omitting the numbers entirely, in a form that is harder to notice. Numbers are decimal strings.
766
+ function _noticeNumbers(node) {
767
+ if (!node || node.tagClass !== "universal" || node.tagNumber !== _T.SEQUENCE || !node.children) return null;
768
+ var nums = [], ok = true;
769
+ node.children.forEach(function (n) {
770
+ // allow:swallow-unverified a non-INTEGER member makes the whole reference undecodable; the caller falls back rather than rendering part of it
771
+ try { nums.push(String(asn1.read.integer(n))); } catch (_e) { ok = false; }
772
+ });
773
+ return ok ? nums : null;
774
+ }
775
+ // Every DisplayText member is returned, including one whose contents did not decode -- that entry
776
+ // carries its tag with a null `text`. A caller that RENDERS the notice must check that every entry
777
+ // decoded before showing any of it (a partial notice is indistinguishable from a complete one); a
778
+ // caller that only classifies the encoding can read the tags regardless.
779
+ // The LAYOUT is validated before any member is read: UserNotice fixes the order (noticeRef first) and
780
+ // the cardinality (each member at most once, nothing else present). Collecting every recognized member
781
+ // and ignoring the rest would accept a duplicated explicitText, a reversed order, or an extra member,
782
+ // and the caller -- which can only see whether the entries it got look complete -- would then present
783
+ // a structurally invalid notice as a whole one. A malformed layout yields NO entries, so a renderer
784
+ // falls back to hex and a reporter measures nothing.
785
+ function userNoticeTexts(qualifier) {
786
+ if (!qualifier || qualifier.tagClass !== "universal" || qualifier.tagNumber !== _T.SEQUENCE || !qualifier.children) return [];
787
+ var kids = qualifier.children;
788
+ if (kids.length > 2) return [];
789
+ var i = 0, out = [];
790
+ // noticeRef [absent | first]: a SEQUENCE of { organization DisplayText, noticeNumbers }.
791
+ if (i < kids.length && kids[i].tagClass === "universal" && kids[i].tagNumber === _T.SEQUENCE) {
792
+ var nr = kids[i];
793
+ if (!nr.children || nr.children.length !== 2 || !_isDisplayTextNode(nr.children[0])) return [];
794
+ var org = _dtEntry("organization", nr.children[0]);
795
+ org.noticeNumbers = _noticeNumbers(nr.children[1]);
796
+ out.push(org);
797
+ i++;
798
+ }
799
+ // explicitText [absent | last]: a DisplayText. Anything else remaining is not a UserNotice member.
800
+ if (i < kids.length) {
801
+ if (!_isDisplayTextNode(kids[i])) return [];
802
+ out.push(_dtEntry("explicitText", kids[i]));
803
+ i++;
804
+ }
805
+ return i === kids.length ? out : [];
806
+ }
807
+
706
808
  // Shared imperative decode helpers for the certExtensionDecoders + attrValueDecoders factories. Both
707
809
  // take the caller's ns, so the helpers close over ns.E / ns.oid; extracted once so the two factories
708
810
  // share one copy (a per-OID value-decoder body is the identical idiom to a per-OID extension body).
@@ -1468,6 +1570,9 @@ module.exports = {
1468
1570
  signedEnvelopeTbs: signedEnvelopeTbs,
1469
1571
  rootSequenceChildren: rootSequenceChildren,
1470
1572
  assertPolicyQualifiers: assertPolicyQualifiers,
1573
+ DISPLAY_TEXT_MAX: DISPLAY_TEXT_MAX,
1574
+ displayTextChars: displayTextChars,
1575
+ userNoticeTexts: userNoticeTexts,
1471
1576
  signedEnvelope: signedEnvelope,
1472
1577
  attrValueToString: attrValueToString,
1473
1578
  attributeTypeAndValue: attributeTypeAndValue,
package/lib/x509-sign.js CHANGED
@@ -245,6 +245,9 @@ function _hasCriticalSan(extSpec) {
245
245
  * (positive, <= 20 octets), the validity UTCTime/GeneralizedTime cutover, the DER DEFAULT omissions
246
246
  * (v1 tag, `critical=FALSE`, `cA=FALSE`), and the CA cross-field rules (keyCertSign and
247
247
  * pathLenConstraint require cA=TRUE) are all enforced; a violation throws a typed `CertificateError`.
248
+ * Where the spec carries raw DER -- a `Name` Buffer, a pre-encoded `Extension`, an issuer `publicKey`
249
+ * SPKI -- a structural fault throws `CertificateError`, while a malformed leaf inside those bytes
250
+ * throws `Asn1Error`, the same two-error contract the parsers present.
248
251
  *
249
252
  * @opts
250
253
  * - `pem` (boolean) -- return a PEM `CERTIFICATE` string instead of DER.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
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:37941dc8-becd-4983-b264-d6897e595ed1",
5
+ "serialNumber": "urn:uuid:14a71e45-54e7-49bd-8fe1-ac576a769036",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-08-09T10:47:07.155Z",
8
+ "timestamp": "2026-08-10T02:28:13.520Z",
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.4.11",
22
+ "bom-ref": "@blamejs/pki@0.4.12",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.4.11",
25
+ "version": "0.4.12",
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.4.11",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.4.12",
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.4.11",
57
+ "ref": "@blamejs/pki@0.4.12",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]