@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 +48 -1
- package/README.md +3 -3
- package/lib/attrcert-sign.js +10 -6
- package/lib/cmp-build.js +11 -6
- package/lib/cmp-session.js +1 -1
- package/lib/cmp-verify.js +1 -1
- package/lib/cms-decrypt.js +27 -1
- package/lib/constants.js +23 -0
- package/lib/crl-sign.js +8 -3
- package/lib/crmf-sign.js +10 -4
- package/lib/csr-sign.js +6 -4
- package/lib/ct.js +1 -1
- package/lib/guard-identifier.js +31 -1
- package/lib/inspect.js +38 -4
- package/lib/lint.js +111 -0
- package/lib/path-validate.js +3 -1
- package/lib/pbes2.js +10 -3
- package/lib/pki-build.js +4 -5
- package/lib/schema-c509.js +5 -1
- package/lib/schema-pkix.js +105 -0
- package/lib/validator-tpm.js +3 -6
- package/lib/webauthn-mds.js +753 -0
- package/lib/webauthn.js +287 -9
- package/lib/x509-sign.js +5 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
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.
|
|
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.
|
|
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>` |
|
package/lib/attrcert-sign.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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); }
|
package/lib/cmp-session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
package/lib/cms-decrypt.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/guard-identifier.js
CHANGED
|
@@ -53,4 +53,34 @@ function assertCanonicalOid(str, E, code, label, boundsCode) {
|
|
|
53
53
|
return str;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
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");
|