@blamejs/pki 0.4.10 → 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,7 +4,54 @@ 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.10 — 2026-08-09
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
+
27
+ ## v0.4.11 — 2026-08-09
28
+
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.
30
+
31
+ ### Added
32
+
33
+ - pki.webauthn.verifyMetadataBlob reads a FIDO Metadata Service (MDS v3) BLOB and returns its entries indexed by aaguid. The BLOB is a JWS: its signature is checked under the certificate in its own header, that chain is validated to one of the roots the caller pins, and only then is the payload parsed -- the ordering is the point, because a reader that parses first hands an attacker every structure behind the signature.
34
+ - The catalogue's freshness is enforced rather than reported, and enforced again wherever it is used. A BLOB whose sequence number does not exceed the one the caller already holds is refused as a rollback, and requireRollbackCheck makes supplying that number mandatory so the check cannot be skipped by forgetting the option. A BLOB past its nextUpdate is refused as stale -- and because a verified result is an ordinary object a relying party may cache, its expiry is re-checked each time it is passed to verify, so a catalogue fetched while current cannot keep authorizing an authenticator whose status reports have since revoked it. Both checks fail closed, and the caller's own allowStale decision rides on the result rather than needing to be repeated.
35
+ - Passing the verified result to pki.webauthn.verify as opts.metadata binds the attestation to its own model: the authenticator's registered attestation roots are resolved from its identifier, and its trust path must fully validate to one of them -- signature chaining, validity, and constraints, the same path validation any certificate chain gets, not a name comparison against the top of the path. An authenticator whose model the catalogue does not list, whose entry registers no attestation root, or whose status reports disqualify it, is refused. Which reports disqualify is selectable -- any report ever filed, only the most recent one so that a later remediation clears an earlier revocation, or a predicate of the caller's own -- and an unrecognised status is ignored by default, as the specification requires, or treated as disqualifying on request.
36
+ - Authenticators that carry no aaguid are covered too. A U2F authenticator declares no model identity, and the catalogue keys it by the key identifiers of its attestation certificates instead; both key spaces are indexed and looked up, so a U2F registration binds to its registered roots rather than being refused as unlisted. The identifier is read from the entry and from its metadata statement, since live entries populate both, and is computed as RFC 5280 sec. 4.2.1.2 method 1 defines it. A compound attestation is covered as well: its elements carry independent trust paths, and every one of them must reach a registered root.
37
+ - Which identifier is allowed to select an entry depends on what the attestation signature covers. The fido-u2f signature is computed over named fields and does not include the aaguid, so for that format the certificate decides and the declared aaguid is ignored -- otherwise setting it to a listed model that shares the vendor's registered root would resolve to that model's entry and skip the real one's status reports. For relying parties: res.aaguid reports what the authenticator presented and is not signature-bound for that format; res.metadata.aaguid names the entry that actually matched.
38
+ - Only a catalogue this library verified can decide anything. A metadata result is recognised by provenance rather than by its shape, so an object restored from a cache -- which has been through none of the signature and chain checks, and whose contents an attacker able to write that cache would choose -- is refused rather than accepted as a catalogue. Re-verify the BLOB instead, which the freshness rule asks for anyway. The verified result is also frozen, so the catalogue that decides a later verification is the one the signature covered rather than a copy something has since edited.
39
+ - A stored attestation is anchored at the instant its own format judged it. An android-safetynet response carries its signing time and its service chain has usually expired since, so the metadata anchor check reuses that instant rather than the current clock -- otherwise it would refuse the very registration the format verifier had just accepted. An explicit opts.time still takes precedence.
40
+ - Status reports are read against the instant being judged: a report dated in the future has not taken effect, so it cannot displace a revocation that is in force now, and a deliberately historical verification does not see reports filed after the time it asks about.
41
+ - A status report that names a single certificate is judged against the certificate actually presented. A whole batch of authenticators is commonly listed under one entry, so a key-compromise report naming one attestation certificate denies that one rather than every device the entry covers; a report that names nothing, or names something that does not decode, still applies to the entry as a whole. Trust anchors are recognised by name and public key rather than by self-issuedness, so a chain terminating in a cross-signed form of a root you supplied still anchors to it.
42
+ - The BLOB's own signing certificate must be permitted to sign. A certificate that carries a key-usage extension omitting digitalSignature is refused before its key is used to check the BLOB signature, so a certificate restricted to some other purpose cannot confer metadata-signing authority just because it chains to the root you supplied.
43
+ - pki.webauthn.metadataFor looks an entry up against a verified result only, never raw bytes, so a lookup cannot be answered out of a catalogue nobody verified. It takes either identifier -- an aaguid or an attestation-certificate key identifier -- dispatching on the form, which are disjoint by shape. pki.webauthn.metadataAnchors decodes an entry's registered attestation roots, per entry rather than for the whole catalogue: a handful of certificates in the live metadata do not parse under a strict decoder, and decoding everything up front would let one vendor's malformed root refuse the entire catalogue for every other authenticator in it.
44
+
45
+ ### Changed
46
+
47
+ - pki.webauthn.verify now rejects an unrecognised option key instead of ignoring it, and validates opts.time at the boundary. Every option it takes either gates the verdict or supplies the trust material a gate needs, so a misspelled key was not harmless: asking for metadata enforcement and mistyping the key left the gate switched off and returned a pass the caller believed had been checked against the catalogue. An invalid time is now a configuration fault reported as such, rather than surfacing later as an authenticator trust failure.
48
+ - Configuration objects across certificate, CRL, attribute-certificate, CSR, and CMP issuance now reject an unrecognised option key through one shared check instead of a dozen separate ones. Two cases that a hand-written check gets wrong are fixed everywhere at once: a key that every JavaScript object inherits, such as constructor or toString, is no longer accepted as a recognised option name, and an option object built by parsing JSON that carries its own __proto__ key is now inspected rather than skipped. The wording of every rejection is unchanged. This matters because the failure is silent in the quietest possible way -- a misspelled option key leaves the default in force, so a caller who asked for a stricter check gets the looser behaviour and no error anywhere.
49
+
50
+ ### Fixed
51
+
52
+ - An invalid opts.time passed to pki.webauthn.verify for an android-safetynet attestation raised an untyped internal error instead of the typed webauthn/bad-input verdict. Every failure from the verifier is a typed error again, so a caller catching pki.errors.PkiError no longer has a hole on that path.
53
+
54
+ ## v0.4.10 — 2026-08-08
8
55
 
9
56
  A TPM attestation now reports the credential key's own object attributes and access policy, so a relying party can require the properties it cares about -- a key bound to one TPM, generated by that TPM, not duplicable -- instead of taking the attestation on trust.
10
57
 
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` |
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. Chaining the returned trust path to a pinned root (and aaguid→root via FIDO MDS) is the caller's step through `pki.path.validate` — `parseAttestationObject`, `verify` |
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` |
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
+ | `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, 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>` |
@@ -136,7 +136,7 @@ function _objectDigestInfoContent(odi) {
136
136
  // objectDigestInfo [2] IMPLICIT ObjectDigestInfo } -- exactly one form (the profile binds a real holder).
137
137
  function _encodeHolder(holder) {
138
138
  if (!holder || typeof holder !== "object" || Buffer.isBuffer(holder)) throw _err("attrcert/bad-input", "holder must be an object with exactly one form");
139
- Object.keys(holder).forEach(function (k) { if (!KNOWN_HOLDER_KEYS[k]) throw _err("attrcert/bad-input", "unknown holder form " + JSON.stringify(k)); });
139
+ guard.identifier.assertKnownKeys(holder, KNOWN_HOLDER_KEYS, _err, "attrcert/bad-input", "unknown holder form ");
140
140
  var forms = Object.keys(holder).filter(function (k) { return holder[k] != null; });
141
141
  if (forms.length !== 1) throw _err("attrcert/bad-input", "holder must carry exactly one form (entityName, baseCertificateID, fromCertificate, or objectDigestInfo), got " + forms.length);
142
142
  if (holder.entityName != null) {
@@ -348,8 +348,10 @@ function _buildAttributes(attrSpec) {
348
348
  return b.sequence(attrs);
349
349
  }
350
350
  if (!attrSpec || typeof attrSpec !== "object") throw _err("attrcert/bad-input", "attributes must be an object or an array of pre-encoded Attribute DER");
351
+ guard.identifier.assertKnownKeys(attrSpec, ATTR_VALUE_ENCODER, _err, "attrcert/bad-input", function (k) {
352
+ return "unknown attribute " + JSON.stringify(k) + "; pass a pre-encoded Attribute DER via the array form for a custom attribute";
353
+ });
351
354
  Object.keys(attrSpec).forEach(function (k) {
352
- if (!ATTR_VALUE_ENCODER[k]) throw _err("attrcert/bad-input", "unknown attribute " + JSON.stringify(k) + "; pass a pre-encoded Attribute DER via the array form for a custom attribute");
353
355
  add(O(ATTR_OID_NAME[k]), ATTR_VALUE_ENCODER[k](attrSpec[k]));
354
356
  });
355
357
  if (!attrs.length) throw _err("attrcert/bad-attributes", "attributes must carry at least one Attribute (RFC 5755 sec. 4.2.7)");
@@ -389,8 +391,8 @@ function _buildExtensions(extSpec, aaSpki) {
389
391
  return b.sequence(exts);
390
392
  }
391
393
  if (typeof extSpec !== "object") throw _err("attrcert/bad-input", "extensions must be an object or an array of pre-encoded Extension DER");
392
- Object.keys(extSpec).forEach(function (k) {
393
- if (!EXT_META[k]) throw _err("attrcert/bad-input", "unknown extension " + JSON.stringify(k) + "; pass a pre-encoded Extension DER via the array form for a custom extension");
394
+ guard.identifier.assertKnownKeys(extSpec, EXT_META, _err, "attrcert/bad-input", function (k) {
395
+ return "unknown extension " + JSON.stringify(k) + "; pass a pre-encoded Extension DER via the array form for a custom extension";
394
396
  });
395
397
  var out = [], seen = {};
396
398
  Object.keys(extSpec).forEach(function (k) {
@@ -426,7 +428,9 @@ function _buildExtensions(extSpec, aaSpki) {
426
428
  * attribute certificate is never self-signed. The signature algorithm is resolved from the AA key (RSA
427
429
  * PKCS#1 v1.5 or PSS, ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm), and the signature is verified
428
430
  * under the AA public key before the certificate is returned. Returns DER, or a PEM `ATTRIBUTE
429
- * 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
430
434
  * profile (RFC 5755 sec. 4.5) and validity are a verification-layer concern -- validate the AA
431
435
  * certificate with `pki.path.validate` before trusting the attribute certificate. Parsing is
432
436
  * `pki.schema.attrcert.parse`.
@@ -450,7 +454,7 @@ function sign(spec, issuer, opts) {
450
454
  function _sign(spec, issuer, opts) {
451
455
  opts = opts || {};
452
456
  if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("attrcert/bad-input", "the attribute-certificate spec must be an object");
453
- Object.keys(spec).forEach(function (k) { if (!KNOWN_SPEC_KEYS[k]) throw _err("attrcert/bad-input", "unknown spec field " + JSON.stringify(k)); });
457
+ guard.identifier.assertKnownKeys(spec, KNOWN_SPEC_KEYS, _err, "attrcert/bad-input", "unknown spec field ");
454
458
  issuer = issuer || {};
455
459
  if (issuer.key == null) throw _err("attrcert/bad-input", "a signing key (issuer.key, the AA's PKCS#8 private key) is required");
456
460
 
package/lib/cmp-build.js CHANGED
@@ -75,6 +75,7 @@ var BODY_TAG = {
75
75
  var CRMF_BODY = { ir: 1, cr: 1, kur: 1 }; // arms whose content is a CertReqMessages via pki.crmf.build
76
76
  var CERT_REP_ARM = { ip: 1, cp: 1, kup: 1, ccp: 1 }; // arms carrying a CertRepMessage
77
77
  var KNOWN_OPTS_KEYS = { key: 1, cert: 1, mac: 1, extraCerts: 1, pem: 1, pss: 1, digestAlgorithm: 1 };
78
+ var KNOWN_MESSAGE_KEYS = { header: 1, body: 1 };
78
79
  var KNOWN_MAC_KEYS = { secret: 1, salt: 1, iterationCount: 1, prf: 1, keyLength: 1, algorithm: 1 };
79
80
 
80
81
  var PBMAC1_DEFAULT_ITER = 100000;
@@ -161,7 +162,7 @@ function _algIdNoParams(name) { return b.sequence([b.oid(O(name))]); }
161
162
  // the exact headerTLV, built ONCE and reused in both the envelope and the ProtectedPart (RFC 9810 5.1.3).
162
163
  function _encodeHeader(headerSpec, protectionAlgDer, pvno) {
163
164
  if (!headerSpec || typeof headerSpec !== "object" || Buffer.isBuffer(headerSpec)) throw _err("cmp/bad-input", "message.header must be an object");
164
- Object.keys(headerSpec).forEach(function (k) { if (!KNOWN_HEADER_KEYS[k]) throw _err("cmp/bad-input", "unknown header field " + JSON.stringify(k)); });
165
+ guard.identifier.assertKnownKeys(headerSpec, KNOWN_HEADER_KEYS, _err, "cmp/bad-input", "unknown header field ");
165
166
  if (headerSpec.sender == null) throw _err("cmp/bad-input", "message.header.sender is required (GeneralName)");
166
167
  if (headerSpec.recipient == null) throw _err("cmp/bad-input", "message.header.recipient is required (GeneralName)");
167
168
 
@@ -510,7 +511,7 @@ function _resolveProtection(opts) {
510
511
  // PBMAC1
511
512
  var m = opts.mac;
512
513
  if (!m || typeof m !== "object" || Buffer.isBuffer(m)) throw _err("cmp/bad-input", "opts.mac must be an object { secret, salt?, iterationCount?, prf?, keyLength? }");
513
- Object.keys(m).forEach(function (k) { if (!KNOWN_MAC_KEYS[k]) throw _err("cmp/bad-input", "unknown opts.mac field " + JSON.stringify(k)); });
514
+ guard.identifier.assertKnownKeys(m, KNOWN_MAC_KEYS, _err, "cmp/bad-input", "unknown opts.mac field ");
514
515
  if (m.algorithm != null && m.algorithm !== "pbmac1") throw _err("cmp/unsupported-algorithm", "opts.mac.algorithm " + JSON.stringify(m.algorithm) + " is not supported (v1 ships pbmac1; passwordBasedMac is deferred)");
515
516
  var secret = m.secret;
516
517
  if (typeof secret !== "string" || !secret) {
@@ -560,8 +561,10 @@ function build(message, opts) {
560
561
  function _build(message, opts) {
561
562
  opts = opts || {};
562
563
  if (!message || typeof message !== "object" || Buffer.isBuffer(message)) throw _err("cmp/bad-input", "the PKIMessage spec must be an object { header, body }");
563
- Object.keys(message).forEach(function (k) { if (k !== "header" && k !== "body") throw _err("cmp/bad-input", "unknown message field " + JSON.stringify(k) + " (a message carries only header + body)"); });
564
- Object.keys(opts).forEach(function (k) { if (!KNOWN_OPTS_KEYS[k]) throw _err("cmp/bad-input", "unknown opts field " + JSON.stringify(k)); });
564
+ guard.identifier.assertKnownKeys(message, KNOWN_MESSAGE_KEYS, _err, "cmp/bad-input", function (k) {
565
+ return "unknown message field " + JSON.stringify(k) + " (a message carries only header + body)";
566
+ });
567
+ guard.identifier.assertKnownKeys(opts, KNOWN_OPTS_KEYS, _err, "cmp/bad-input", "unknown opts field ");
565
568
  if (message.header == null) throw _err("cmp/bad-input", "message.header is required");
566
569
  if (message.body == null) throw _err("cmp/bad-input", "message.body is required");
567
570
 
@@ -734,7 +737,7 @@ function transfer(url, message, opts) {
734
737
 
735
738
  function _transfer(url, message, opts) {
736
739
  opts = opts || {};
737
- Object.keys(opts).forEach(function (k) { if (!KNOWN_TRANSFER_OPTS[k]) throw _err("cmp/bad-input", "unknown opts field " + JSON.stringify(k)); });
740
+ guard.identifier.assertKnownKeys(opts, KNOWN_TRANSFER_OPTS, _err, "cmp/bad-input", "unknown opts field ");
738
741
  var der = _cmpMessageDer(message); // config gate: before any transport call
739
742
  var parsedUrl;
740
743
  try { parsedUrl = new URL(String(url)); } // parse only -- NO client scheme gate (the transport owns socket security)
@@ -793,7 +796,9 @@ function wellKnownUrl(base, opts) {
793
796
  opts = opts || {};
794
797
  // Reject an unknown option key (a typo like { lable } / { operaton }), as build/transfer do -- a silently
795
798
  // ignored option would build the DEFAULT endpoint and send the request to a different CA/profile.
796
- Object.keys(opts).forEach(function (k) { if (!KNOWN_WELLKNOWN_OPTS[k]) throw _err("cmp/bad-input", "unknown wellKnownUrl option " + JSON.stringify(k) + " (expected label / operation)"); });
799
+ guard.identifier.assertKnownKeys(opts, KNOWN_WELLKNOWN_OPTS, _err, "cmp/bad-input", function (k) {
800
+ return "unknown wellKnownUrl option " + JSON.stringify(k) + " (expected label / operation)";
801
+ });
797
802
  var u;
798
803
  try { u = new URL(String(base)); }
799
804
  catch (e) { throw _err("cmp/bad-url", "the CMP base URL did not parse: " + String(base), e); }
@@ -335,7 +335,7 @@ function _certConfHash(certDer) {
335
335
  function session(opts) {
336
336
  if (opts == null) opts = {};
337
337
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cmp/bad-input", "opts must be an object");
338
- Object.keys(opts).forEach(function (k) { if (!KNOWN_SESSION_OPTS[k]) throw _err("cmp/bad-input", "unknown session opts field " + JSON.stringify(k)); });
338
+ guard.identifier.assertKnownKeys(opts, KNOWN_SESSION_OPTS, _err, "cmp/bad-input", "unknown session opts field ");
339
339
  // Work on a SHALLOW COPY so a later normalization (e.g. an empty MAC trustAnchors list) never mutates the
340
340
  // caller's object -- which may be frozen or reused across sessions -- and normalizing a frozen input does not
341
341
  // throw a raw TypeError. Only top-level fields are reassigned; nested values are read, never mutated.
package/lib/cmp-verify.js CHANGED
@@ -616,7 +616,7 @@ function verify(message, opts) {
616
616
  async function _verify(message, opts) {
617
617
  if (opts == null) opts = {}; // ONLY null / undefined -> the default empty opts (a falsy false/0/"" is a bad config, not a default)
618
618
  if (typeof opts !== "object" || Buffer.isBuffer(opts)) throw _err("cmp/bad-input", "opts must be an object");
619
- Object.keys(opts).forEach(function (k) { if (!KNOWN_VERIFY_OPTS[k]) throw _err("cmp/bad-input", "unknown opts field " + JSON.stringify(k)); });
619
+ guard.identifier.assertKnownKeys(opts, KNOWN_VERIFY_OPTS, _err, "cmp/bad-input", "unknown opts field ");
620
620
  // The opt-in echo values are byte buffers: a non-buffer (e.g. a string from JSON config) is a DEPLOYMENT
621
621
  // error that throws cmp/bad-input, never a routine transaction/nonce mismatch verdict that would misreport
622
622
  // a caller's typing mistake as a peer authentication failure.
@@ -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/constants.js CHANGED
@@ -297,6 +297,29 @@ var LIMITS = {
297
297
  // terminates but exhausts memory. This matches PATH_MAX_CERTS: a chain longer than the path
298
298
  // validator will ever accept has nothing to offer, so refusing it at the decoder is free.
299
299
  TLS_CERT_MAX_ENTRIES: 100,
300
+ // A FIDO Metadata Service BLOB is a JWS whose payload carries every registered authenticator's
301
+ // metadata; the live one runs about 10 MB across ~500 entries. The byte cap bounds the decode
302
+ // before the payload is materialized, and the entry and anchor caps bound the per-entry work
303
+ // (each anchor is a DER certificate parse), since a byte ceiling alone does not bound how many
304
+ // items are declared inside it (CWE-770).
305
+ MDS_BLOB_MAX_BYTES: BYTES.mib(32),
306
+ // The BLOB's JWS protected header is read BEFORE the signature is checked, so it carries its own,
307
+ // far smaller ceiling: a header is a few hundred bytes plus the x5c chain, and the envelope cap
308
+ // would otherwise let unauthenticated input buy heap ahead of every authentication step.
309
+ MDS_BLOB_HEADER_MAX_BYTES: BYTES.kib(256),
310
+ // The JWS signature, likewise read before anything is authenticated. Every algorithm this reader
311
+ // supports has a tightly bounded signature: 64/96/132 bytes for ECDSA P-256/384/521, and the
312
+ // modulus size for RSA. 2 KiB covers an RSA-16384 key with room to spare, and refuses a segment
313
+ // whose only purpose is to make the verifier allocate.
314
+ MDS_BLOB_SIG_MAX_BYTES: BYTES.kib(2),
315
+ MDS_MAX_ENTRIES: 4096,
316
+ // A model's certification history: bounded like the other repeated per-entry structures, because
317
+ // the status gate walks the whole array on every verification.
318
+ MDS_MAX_STATUS_REPORTS_PER_ENTRY: 256,
319
+ MDS_MAX_ANCHORS_PER_ENTRY: 16,
320
+ // A U2F authenticator carries no AAGUID, so its entry is keyed by the key identifiers of its
321
+ // attestation certificates instead -- a batch-attested product line legitimately registers dozens.
322
+ MDS_MAX_KEY_IDS_PER_ENTRY: 64,
300
323
  // The most nested statements a WebAuthn compound attestation (sec. 8.9) may carry. The syntax
301
324
  // says "2*" -- unbounded -- so this is a resource bound this toolkit chooses, NOT a spec MUST.
302
325
  // The CBOR parse caps bound the decode; they do not bound the crypto, and each element costs a
package/lib/crl-sign.js CHANGED
@@ -160,7 +160,9 @@ function _akiKeyId(val, ctx) {
160
160
  // scope boolean TRUE; onlyContainsAttributeCerts MUST be FALSE; DEFAULT-FALSE booleans omitted.
161
161
  function _idpValue(idp) {
162
162
  if (!idp || typeof idp !== "object" || Array.isArray(idp) || Buffer.isBuffer(idp)) throw _err("crl/bad-idp", "issuingDistributionPoint must be an object");
163
- Object.keys(idp).forEach(function (k) { if (!KNOWN_IDP_KEYS[k]) throw _err("crl/bad-idp", "unknown issuingDistributionPoint field " + JSON.stringify(k) + " (pass a pre-encoded Extension DER via the extensions array for an exotic field like onlySomeReasons)"); });
163
+ guard.identifier.assertKnownKeys(idp, KNOWN_IDP_KEYS, _err, "crl/bad-idp", function (k) {
164
+ return "unknown issuingDistributionPoint field " + JSON.stringify(k) + " (pass a pre-encoded Extension DER via the extensions array for an exotic field like onlySomeReasons)";
165
+ });
164
166
  if (idp.onlyContainsAttributeCerts === true) throw _err("crl/bad-idp", "onlyContainsAttributeCerts=TRUE is not permitted for a conforming CRL issuer (RFC 5280 sec. 5.2.5)");
165
167
  var children = [];
166
168
  if (idp.fullName != null) {
@@ -273,7 +275,9 @@ function _buildCrlExtensions(spec, ctx) {
273
275
  return { exts: out, isDelta: isDelta };
274
276
  }
275
277
  if (typeof ext !== "object") throw _err("crl/bad-input", "extensions must be an object or an array of pre-encoded Extension DER");
276
- Object.keys(ext).forEach(function (k) { if (!KNOWN_CRL_EXT_KEYS[k]) throw _err("crl/bad-input", "unknown CRL extension " + JSON.stringify(k) + " in the extensions spec; pass a pre-encoded Extension DER via the array form"); });
278
+ guard.identifier.assertKnownKeys(ext, KNOWN_CRL_EXT_KEYS, _err, "crl/bad-input", function (k) {
279
+ return "unknown CRL extension " + JSON.stringify(k) + " in the extensions spec; pass a pre-encoded Extension DER via the array form";
280
+ });
277
281
  isDelta = ext.deltaCRLIndicator != null;
278
282
  if (ext.authorityKeyIdentifier != null) push("authorityKeyIdentifier", false, _extAki(_akiKeyId(ext.authorityKeyIdentifier, ctx)));
279
283
  if (ext.issuingDistributionPoint != null) push("issuingDistributionPoint", true, _idpValue(ext.issuingDistributionPoint));
@@ -474,7 +478,8 @@ function _sign(spec, issuer, opts) {
474
478
  * empty revocation list omits `revokedCertificates` rather than emitting an empty SEQUENCE (sec. 5.1.2.6);
475
479
  * `reasonCode` is an ENUMERATED and `invalidityDate` is always GeneralizedTime (sec. 5.3.1/5.3.2);
476
480
  * per-extension criticality is fixed by the RFC; and the produced signature is verified under the issuer
477
- * 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`.
478
483
  *
479
484
  * @opts
480
485
  * - `pem` (boolean) -- return a PEM `X509 CRL` string instead of DER.
package/lib/crmf-sign.js CHANGED
@@ -31,6 +31,7 @@ var signScheme = require("./sign-scheme");
31
31
  var pkix = require("./schema-pkix");
32
32
  var pkiBuild = require("./pki-build");
33
33
  var frameworkError = require("./framework-error");
34
+ var guard = require("./guard-all");
34
35
 
35
36
  var CrmfError = frameworkError.CrmfError;
36
37
  var b = asn1.build;
@@ -46,6 +47,7 @@ var _b = pkiBuild.makeBuilder({
46
47
  });
47
48
 
48
49
  var KNOWN_SPEC_KEYS = { certReqId: 1, certTemplate: 1, controls: 1, regInfo: 1, pop: 1 };
50
+ var KNOWN_BATCH_KEYS = { messages: 1 };
49
51
  var KNOWN_TEMPLATE_KEYS = { version: 1, subject: 1, publicKey: 1, validity: 1, extensions: 1, issuer: 1 };
50
52
  var REVOCATION_TEMPLATE_KEYS = { version: 1, subject: 1, publicKey: 1, validity: 1, extensions: 1, issuer: 1, serialNumber: 1 };
51
53
  // The controls (RFC 4211 sec. 6) and regInfo (sec. 7) are DISJOINT AttributeTypeAndValue namespaces --
@@ -83,7 +85,7 @@ function _encodeCertTemplate(tpl, opts) {
83
85
  // A REQUEST template omits serialNumber (CA-assigned, RFC 4211 sec. 5); a REVOCATION template (CMP rr,
84
86
  // RFC 9810 sec. 5.3.9) carries serialNumber [1] to name the certificate to revoke -- allowed only then.
85
87
  var allowed = (opts && opts.revocation) ? REVOCATION_TEMPLATE_KEYS : KNOWN_TEMPLATE_KEYS;
86
- Object.keys(tpl).forEach(function (k) { if (!allowed[k]) throw _err("crmf/bad-input", "unknown certTemplate field " + JSON.stringify(k)); });
88
+ guard.identifier.assertKnownKeys(tpl, allowed, _err, "crmf/bad-input", "unknown certTemplate field ");
87
89
  var fields = [];
88
90
  if (tpl.version != null) {
89
91
  if (tpl.version !== 2) throw _err("crmf/bad-version", "certTemplate version MUST be 2 (v3) if supplied (RFC 4211 sec. 5)");
@@ -127,9 +129,11 @@ function _buildAttrTypeAndValues(spec, code, label, valueMap) {
127
129
  }
128
130
  if (!spec || typeof spec !== "object") throw _err("crmf/bad-input", label + " must be an object or an array of pre-encoded AttributeTypeAndValue DER");
129
131
  var out = [], seen = {};
132
+ guard.identifier.assertKnownKeys(spec, valueMap, _err, "crmf/bad-input", function (k) {
133
+ return "unknown " + label + " " + JSON.stringify(k) + "; pass a pre-encoded AttributeTypeAndValue DER via the array form for a " + label + " entry outside " + Object.keys(valueMap).join("/");
134
+ });
130
135
  Object.keys(spec).forEach(function (k) {
131
136
  var enc = valueMap[k];
132
- if (!enc) throw _err("crmf/bad-input", "unknown " + label + " " + JSON.stringify(k) + "; pass a pre-encoded AttributeTypeAndValue DER via the array form for a " + label + " entry outside " + Object.keys(valueMap).join("/"));
133
137
  var typeOid = O(k);
134
138
  if (seen[typeOid]) throw _err(code, "duplicate " + label + " type " + k);
135
139
  seen[typeOid] = true;
@@ -236,7 +240,7 @@ function build(spec, key, opts) {
236
240
 
237
241
  function _buildCertReqMsg(spec, key, opts) {
238
242
  if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("crmf/bad-input", "each certificate-request-message spec must be an object");
239
- Object.keys(spec).forEach(function (k) { if (!KNOWN_SPEC_KEYS[k]) throw _err("crmf/bad-input", "unknown spec field " + JSON.stringify(k)); });
243
+ guard.identifier.assertKnownKeys(spec, KNOWN_SPEC_KEYS, _err, "crmf/bad-input", "unknown spec field ");
240
244
  if (spec.certTemplate == null) throw _err("crmf/bad-input", "spec.certTemplate is required");
241
245
 
242
246
  var signingKey = (key && typeof key === "object" && !Buffer.isBuffer(key) && !(key instanceof Uint8Array) && key.type == null && "key" in key) ? key.key : key;
@@ -261,7 +265,9 @@ function _build(spec, key, opts) {
261
265
  // Batch form: the envelope carries ONLY `messages` -- reject a stray field so a request spec written at
262
266
  // the wrong nesting level (e.g. certTemplate alongside messages) is not silently dropped.
263
267
  if (!Array.isArray(spec.messages)) throw _err("crmf/bad-input", "spec.messages must be an array of certificate-request-message specs");
264
- Object.keys(spec).forEach(function (k) { if (k !== "messages") throw _err("crmf/bad-input", "unknown batch-envelope field " + JSON.stringify(k) + " -- a batch spec carries only 'messages'"); });
268
+ guard.identifier.assertKnownKeys(spec, KNOWN_BATCH_KEYS, _err, "crmf/bad-input", function (k) {
269
+ return "unknown batch-envelope field " + JSON.stringify(k) + " -- a batch spec carries only 'messages'";
270
+ });
265
271
  specs = spec.messages;
266
272
  } else {
267
273
  specs = [spec];
package/lib/csr-sign.js CHANGED
@@ -28,8 +28,10 @@ var signScheme = require("./sign-scheme");
28
28
  var pkix = require("./schema-pkix");
29
29
  var pkiBuild = require("./pki-build");
30
30
  var frameworkError = require("./framework-error");
31
+ var guard = require("./guard-all");
31
32
 
32
33
  var CsrError = frameworkError.CsrError;
34
+ var KNOWN_SPEC_KEYS = { subject: 1, subjectPublicKey: 1, extensionRequest: 1, challengePassword: 1, attributes: 1 };
33
35
  var b = asn1.build;
34
36
  function _err(code, message, cause) { return new CsrError(code, message, cause); }
35
37
  function _signE(kind, message, cause) { return new CsrError("csr/" + kind, message, cause); }
@@ -80,7 +82,9 @@ function _challengePassword(pw) {
80
82
  * to prove possession of the private half of `subjectPublicKey`, and that proof is verified before the
81
83
  * request is returned. The signature algorithm is resolved from the subject key (RSA PKCS#1 v1.5 or PSS,
82
84
  * ECDSA, EdDSA, ML-DSA, SLH-DSA, or a composite arm). Returns DER, or a PEM `CERTIFICATE REQUEST` with
83
- * `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`.
84
88
  *
85
89
  * @opts
86
90
  * - `pem` (boolean) -- return a PEM `CERTIFICATE REQUEST` string instead of DER.
@@ -102,9 +106,7 @@ function _sign(spec, key, opts) {
102
106
  if (!spec || typeof spec !== "object" || Buffer.isBuffer(spec)) throw _err("csr/bad-input", "the certification-request spec must be an object");
103
107
  // Reject a typo'd spec field at config-time rather than silently dropping it (a misspelled `subjcet`
104
108
  // would otherwise yield an empty-subject request).
105
- Object.keys(spec).forEach(function (k) {
106
- if (k !== "subject" && k !== "subjectPublicKey" && k !== "extensionRequest" && k !== "challengePassword" && k !== "attributes") throw _err("csr/bad-input", "unknown spec field " + JSON.stringify(k));
107
- });
109
+ guard.identifier.assertKnownKeys(spec, KNOWN_SPEC_KEYS, _err, "csr/bad-input", "unknown spec field ");
108
110
  // The signing key is the second argument (a PKCS#8 key / CryptoKey / composite key pair), or { key }.
109
111
  var signingKey = (key && typeof key === "object" && !Buffer.isBuffer(key) && !(key instanceof Uint8Array) && key.type == null && "key" in key) ? key.key : key;
110
112
  if (signingKey == null) throw _err("csr/bad-input", "a signing key (the subject's private key) is required");
package/lib/ct.js CHANGED
@@ -970,7 +970,7 @@ function _fetchBody(transport, url, req, label) {
970
970
  */
971
971
  async function fetchLogList(opts) {
972
972
  opts = opts || {};
973
- Object.keys(opts).forEach(function (k) { if (!KNOWN_FETCH_OPTS[k]) throw _ctErr("ct/bad-input", "unknown opts field " + JSON.stringify(k)); });
973
+ guard.identifier.assertKnownKeys(opts, KNOWN_FETCH_OPTS, _ctErr, "ct/bad-input", "unknown opts field ");
974
974
  if (opts.signerKey == null) throw _ctErr("ct/bad-input", "opts.signerKey is required -- the caller-pinned CT log-list distributor SPKI (there is no baked-in key)");
975
975
  if (opts.url == null) throw _ctErr("ct/bad-input", "opts.url is required -- the log_list.json URL (there is no baked-in vendor URL)");
976
976
  // Parse + https-gate both URLs BEFORE any transport call -- the gate runs across the injectable seam so an
@@ -53,4 +53,34 @@ function assertCanonicalOid(str, E, code, label, boundsCode) {
53
53
  return str;
54
54
  }
55
55
 
56
- module.exports = { assertCanonicalOid: assertCanonicalOid };
56
+ // assertKnownKeys(obj, known, E, code, label) -- every own key of `obj` must appear in `known`.
57
+ //
58
+ // The shape this replaces was hand-written in a dozen callers, and it is worth one home because
59
+ // getting it wrong fails OPEN in the quietest possible way: an options object whose key is
60
+ // misspelled silently carries the default, so a caller who asked for a stricter check gets the
61
+ // looser behaviour and no error. Two details are easy to lose in a re-inline and are fixed here.
62
+ // `known` is consulted with hasOwnProperty rather than a truthiness test, so an inherited Object
63
+ // member ("constructor", "toString") cannot read as a recognised key; and the walk is over own
64
+ // enumerable keys, so a `__proto__` arriving from JSON is inspected rather than skipped.
65
+ //
66
+ // @enforced-by behavioral -- an options-shape walk has no rename-proof code shape; the RED vectors
67
+ // (an unknown key, an inherited name, a JSON-borne __proto__) are the guard.
68
+ //
69
+ // `message` is the caller's OWN wording, so routing an existing check through this guard changes
70
+ // what is checked, never what the operator reads: a string is the prefix the quoted key is appended
71
+ // to, and a function (key) -> string builds the whole sentence, for the many callers that name the
72
+ // key mid-sentence and follow it with the hint that says what to pass instead.
73
+ //
74
+ // @enforced-by guard-shape-reinlined
75
+ // The shape requires the THROW: an identical walk whose body FILTERS on the same table (copying the
76
+ // recognised keys onward rather than rejecting the unrecognised ones) is a different operation with
77
+ // no fail-open risk, and must not be dragged through a guard that only knows how to reject.
78
+ // @guard-shape Object\.keys\(\w+(?:\.\w+)*\)\.forEach\(function \((\w+)\) \{\s*if \(![\w.]+\[\1\]\) (?:\{\s*)?throw
79
+ function assertKnownKeys(obj, known, E, code, message) {
80
+ var describe = typeof message === "function" ? message : function (k) { return message + JSON.stringify(k); };
81
+ Object.keys(obj).forEach(function (k) {
82
+ if (!Object.prototype.hasOwnProperty.call(known, k)) throw E(code, describe(k));
83
+ });
84
+ }
85
+
86
+ module.exports = { assertCanonicalOid: assertCanonicalOid, assertKnownKeys: assertKnownKeys };
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");