@blamejs/pki 0.8.15 → 0.8.17
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 +39 -0
- package/README.md +1 -1
- package/lib/lint.js +756 -11
- package/lib/oid.js +18 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,45 @@ 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.8.17 — 2026-09-24
|
|
8
|
+
|
|
9
|
+
The CA/Browser Forum subscriber certificate profile is linted against its own tables, not a subset of them.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The extension presence and criticality table (section 7.1.2.7.6): `authorityInformationAccess` and `certificatePolicies` are required, `nameConstraints` is forbidden, `authorityInformationAccess` must not be critical, and a `subjectKeyIdentifier` is reported at notice as NOT RECOMMENDED. That last one is the opposite of what RFC 5280 asks of an end-entity certificate, and both ship, each under the profile that states it.
|
|
14
|
+
- The authorityInformationAccess table (section 7.1.2.7.7): an accessMethod outside `id-ad-ocsp` and `id-ad-caIssuers` is reported, as is an accessLocation that is not a `uniformResourceIdentifier`.
|
|
15
|
+
- basicConstraints (section 7.1.2.7.8): `cA` must be FALSE and `pathLenConstraint` must be absent.
|
|
16
|
+
- certificatePolicies (section 7.1.2.7.9): `anyPolicy` is forbidden, exactly one reserved policy identifier must be asserted, counted across all four the profile defines, which are the domain-, organization- and individual-validated identifiers and the Extended Validation one (naming none and naming two are the same fault, and the finding says which), and a policy qualifier other than `id-qt-cps` is forbidden, which includes the userNotice RFC 5280 otherwise allows.
|
|
17
|
+
- The extKeyUsage table (section 7.1.2.7.10): `id-kp-codeSigning`, `id-kp-emailProtection`, `id-kp-timeStamping`, `id-kp-OCSPSigning`, `anyExtendedKeyUsage` and the precertificate signing purpose are each forbidden.
|
|
18
|
+
- The two Key Usage tables (section 7.1.2.7.11), which differ by key type: a bit the table marks unpermitted for the certificate's own key is reported, so `keyEncipherment` is accepted on an RSA certificate and reported on an ECC one. An ECC certificate must assert `digitalSignature`, and an RSA certificate must set at least one of the bits its table permits. The section gives a table for an RSA key and a table for an ECC key and none for anything else, so a certificate on another key, a post-quantum one among them, is held to neither rather than to the RSA one by default.
|
|
19
|
+
- The subjectAltName table (section 7.1.2.7.12): an entry of a type outside `dNSName` and `iPAddress` is reported, a `dNSName` carrying the root zone's zero-length trailing label is reported, and the extension must be critical where the subject is an empty sequence and non-critical otherwise, which one rule measures in both directions.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- `pki.lint.rules("cabf-tls", "certificate")` returns twenty-five rules where it returned six.
|
|
24
|
+
- Selecting `opts.profile: "cabf-tls"` is documented as what it is: an assertion that the input is a TLS subscriber certificate, not a question asked of it. The section 7.1.2.7 rows then apply whatever the certificate says it is, so naming the profile for a CA certificate reports that CA against subscriber requirements. Left unnamed, those rows reach a certificate only where it asserts id-kp-serverAuth and is not a CA. The CA profiles of section 7.1.2.10 are a separate set this does not yet carry.
|
|
25
|
+
|
|
26
|
+
## v0.8.16 — 2026-09-24
|
|
27
|
+
|
|
28
|
+
A certification request is linted before it is issued, against the rows its certificate will be held to.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- `pki.lint.csr(pem | der | parsed, opts?)` lints a PKCS#10 certification request and returns the `LintReport` shape the other lint verbs return. It never throws on the data path: bytes that are not a well-formed request produce a single `fatal` finding `lint/unparseable` carrying the strict parser's own code.
|
|
33
|
+
- The `rfc2986` profile covers what the request asks for: an empty subject that names no identity, an empty subject whose subjectAltName is not critical (RFC 5280 section 4.1.2.6), a request for an extension the issuer determines rather than takes from a request, a request asking for a CA certificate, a request signed under a broken digest, and a challengePassword traveling inside the request. The broken digests are named outright rather than read out of the algorithm name, because `shaWithRSAEncryption` is the original SHA, now SHA-0, and its name carries no digest to read.
|
|
34
|
+
- `opts.profile: "cabf-tls"` adds the CA/Browser Forum rows: a commonName no subjectAltName covers, a malformed requested dNSName, a request naming no subjectAltName, and a key below the TLS minimum. They run only when named, because a request carries no extKeyUsage and nothing in its bytes says it is for TLS. Each is the certificate profile's own row, applied to what the request asks for.
|
|
35
|
+
- A request carrying more than one extensionRequest attribute is reported, and each is linted as the request it states. Two parse where they sort in DER order, and a reader asking for an extension by name takes the first match, so a basicConstraints of cA FALSE in one attribute would otherwise hide a cA TRUE in the next and the answer would be decided by how the bytes happened to sort.
|
|
36
|
+
- A requested extension whose value does not decode under its own syntax is reported, by the RFC 5280 section 4.2 row the certificate profile already carries. It counted as a passing check before, because the shared decoder returns null for a value it cannot read and every row asking for an extension by name reads that null as absence.
|
|
37
|
+
- The weak-digest row reads an RSASSA-PSS request's digest from its parameters rather than its algorithm name. RFC 4055 section 3.1 makes the hashAlgorithm field default to SHA-1, so absent parameters and an empty parameter sequence both name SHA-1: the encoding that appears to say nothing names the weakest digest.
|
|
38
|
+
- `pki.lint.rules("rfc2986")` lists the new rows with their citations, and `pki.lint.profiles()` enumerates `rfc2986` alongside the existing names.
|
|
39
|
+
- `pki.lint.rules(profile, artifact)` takes an optional second argument, one of `"certificate"`, `"csr"`, `"crl"` or `"ocsp"`. `"cabf-tls"` names a set for both a certificate and a request and the two are not the same rows, so without it a reader asking about the request was given the certificate-only extKeyUsage and validity rows and none of the structural ones. Calls passing one argument are unchanged. Each id now appears once in the listing, including where two artifacts run the same rule.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- `md2WithRSAEncryption`, `md4WithRSAEncryption-pkcs1`, `md5WithRSAEncryption`, `sha1WithRSAEncryption`, `ecdsaWithSHA1` and `dsaWithSha1` resolve through `pki.oid`, so an artifact signed under one reads back with a name instead of a bare dotted string. The weak-digest row reads that name, so an algorithm the registry could not name passed it silently. Nothing signs or verifies from that registry: the signing and verifying paths carry their own tables and neither has a row for these, so naming one does not make it usable. A request or certificate carrying one still fails to verify.
|
|
44
|
+
- Every name in the OID registry resolves back to the OID it names, which is now checked. The registry is two-way and its directions did not agree about a repeated name: the first `name -> OID` registration was kept while `OID -> name` took the last, so adding a name a family already held would silently repoint every lookup of it and leave the original OID still answering with that name. X.501 and X.520 both standardize `clearance`, at 2.5.1.5.55 and 2.5.4.55, and that pair is recorded as the one intended duplicate. PKCS#1's MD4 signature is registered as `md4WithRSAEncryption-pkcs1` for the same reason: the plain name already belongs to the OIW arc's 1.3.14.3.2.4.
|
|
45
|
+
|
|
7
46
|
## v0.8.15 — 2026-09-24
|
|
8
47
|
|
|
9
48
|
The host's own CA bundle becomes trust anchors, and says which set you got.
|
package/README.md
CHANGED
|
@@ -290,7 +290,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
290
290
|
| `pki.sigstore` | Offline verifier for a Sigstore bundle, the artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots and 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. It reuses the X.509 parser, the RFC 5280 path validator, and the Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `verified: true` says the artifact was signed and logged, not that a party you trust signed it — Fulcio issues to anyone who completes an OIDC flow, so who signed is decided only by `opts.identity`, and `identityChecked` reports which of its fields were compared. An identity policy naming no field, or a field name that is not one of the three, is refused rather than satisfied — either would accept every signer while reading as a policy in force. A sixth leg is opt-in: pinning the certificate-transparency logs in `opts.ctLogs` checks the receipt Fulcio embeds in the certificate it issues (RFC 6962 §3.2), verified over the certificate as it stood before the receipt was added and under the key of the log that issued it, so the verdict says the signing certificate was public when it was issued rather than handed out quietly. At least one receipt must verify against a pinned log, the verdict reports `sctChecked` and `validScts`, and an empty log set is refused rather than read as a policy that checks nothing. Both content arms are read: a DSSE-wrapped in-toto attestation, and a message signature over an artifact's own bytes, which `cosign sign-blob` produces. For that arm `opts.artifact` supplies the bytes, and they are held to the hash the Rekor `hashedrekord` entry records before the signature is checked over them; the `messageDigest` the bundle carries is covered by no signature, so it is compared against the computed digest and never used in its place. `contentType` names the arm and the verdict has one shape for both — `pae`, `parseBundle`, `verifyBundle` |
|
|
291
291
|
| `pki.inspect` | Human-readable inspection, the pure-JS equivalent of `openssl x509/crl/req/cms -text`. `certificate(pem \| der \| parsed)` renders an OpenSSL-style report: version, serial, signature algorithm, issuer and subject distinguished names, validity, public-key details (curve or modulus size plus the raw point or modulus), every decoded extension with its critical flag, and the signature. `crl`, `csr`, and `cms` render the other formats the same way — a CRL like `openssl crl -text`, a CSR like `openssl req -text`, and a CMS message like `openssl cms -cmsout -print`, with a stable summary for a non-SignedData ContentInfo — and `any(input)` detects the format and routes to the right report. Built over the strict parsers and the two-way OID registry with one set of field renderers, it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency, and the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version. A certificate policy's user notice renders as text, both 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` |
|
|
292
292
|
| `pki.webauthn` | WebAuthn and passkey verification, both halves: offline trust evaluation of a W3C WebAuthn (Level 3) registration, and signature verification of the assertion every login returns. `parseAttestationObject(bytes)` decodes the CBOR attestation object, authenticatorData, and COSE credential key over the strict `pki.cbor` codec; `parseAuthenticatorData(bytes)` reads the bare form an assertion carries through the same parser; `parseClientData(bytes, opts)` decodes the `clientDataJSON` no signature check looks inside, through the shared JSON guard since these are attacker-chosen bytes, returning the challenge decoded so a caller compares bytes rather than spellings, and checking the ceremony type, challenge, and origin when the relying party supplies what it issued. `verifyAssertion(input)` verifies an assertion signature over `authenticatorData \|\| SHA-256(clientDataJSON)` — raw bytes, no COSE_Sign1, an ES256 signature in ASN.1 DER — and applies the §7.2 step 21 counter rule when a stored `previousSignCount` is given, so a counter that fails to advance is refused as a cloned authenticator. `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for packed, tpm, android-key, apple, fido-u2f, and none: the x5c leaf key, the apple nonce, the tpm `certInfo` Name and `extraData` over the `pubArea`, the android `KeyDescription`, and the fido-u2f `verificationData`. It binds the credential public key to each attestation, through the signed authenticatorData for packed and fido-u2f or a cert or `pubArea`-key equality check for android-key, apple, and tpm, and enforces each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256/384/512, 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. The verdict field is `attestationVerified`, and `signatureVerified` for an assertion, rather than a bare `verified`, because a sound statement is a different claim from an acceptable ceremony: an attestation naming another origin's RP ID with user presence clear is perfectly sound and must not be registered. Pass `expectedRpId`, `requireUserPresence`, `requireUserVerification`, or `allowedAlgorithms` and those are checked, with `bindingChecked` reporting which ran, so a check that passed can be told from one that never happened. Pass `opts.clientDataJSON` instead of the digest and the client data is read here too: the ceremony type is checked unconditionally, since a login response replayed into a registration is what that rule stops, and the challenge, origin, and top-level origin are checked against what the relying party issued, with `clientData.checked` reporting which comparisons ran. A registration verdict also carries the `credentialId`, `credentialPublicKey`, and initial `signCount` a later login needs. A credential key declaring COSE algorithm `-65535` (RSASSA-PKCS1-v1_5 with SHA-1) is refused unless `allowedAlgorithms` names it, since every signature that credential ever makes would use SHA-1. Anchoring the trust path has two routes: `opts.metadata` resolves the roots the authenticator's own model registered, and `opts.rootCertificates` pins roots directly, which is what anchors the formats FIDO MDS does not cover, Apple's authenticators and the Google hardware-attestation roots among them. `metadata` governs when both are given, and `anchoredTo` names every route that anchored the path, joined with `+` when more than one did: `"metadata"`, `"rootCertificates"`, and `"safetyNetRoots"` for the android-safetynet chain, which anchors through the roots that format requires whether or not either other route was asked for. It is `null` only when nothing anchored the path. `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. `metadataAnchors(entry, opts)` applies the same status gate for a caller anchoring the path themselves: a model the catalogue has disqualified registers no anchors. 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, there is no trust-on-first-use, and retrieving the BLOB is out of scope. Fail-closed with typed `webauthn/*` errors — `parseAttestationObject`, `verify`, `verifyMetadataBlob`, `metadataFor`, `metadataAnchors` |
|
|
293
|
-
| `pki.lint` | Certificate linting, the zlint or 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, the post-quantum certificate profiles, and a representative CA/Browser Forum TLS BR subset: serial sign and size, validity ordering and the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality (basicConstraints, nameConstraints, policyConstraints and inhibitAnyPolicy must be critical, and keyUsage should be), nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI and AKI presence including the end-entity subjectKeyIdentifier, the six extensions a conforming CA must mark non-critical (reported by clause rather than as unrecognized) and the two it should (issuerAltName, cRLDistributionPoints), a policy mapping to or from anyPolicy, a unique identifier a conforming CA must not generate, a critical subjectAltName beside a non-empty subject, a critical extKeyUsage naming anyExtendedKeyUsage, a userNotice noticeRef, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, the post-quantum key-usage rules binding a certificate's keyUsage bits to its subject key type (ML-KEM under RFC 9935 §5, ML-DSA under RFC 9881 §5, and SLH-DSA under RFC 9909 §6 across both its pure and prehash identifiers) together with the ML-KEM encapsulation-key encoding and size, and the §4.2.1.4 certificate-policy user-notice rules (a VisibleString or 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). Alone among these entries the data path never throws: hostile bytes return a `fatal` `lint/unparseable` finding carrying the strict parser's code, so a whole directory lints without a try/catch, and only config-time misuse throws a typed `LintError` — `certificate`, `crl` (the RFC 5280 §5 revocation-list profile: nextUpdate presence, update ordering, cRLNumber presence and its 20-octet ceiling, authorityKeyIdentifier presence, the criticality each profiled CRL and entry extension must carry, freshestCRL in a delta CRL, and unrecognized critical extensions), `ocsp` (the RFC 6960 response profile: an empty signature, each SingleResponse's update ordering and its relation to producedAt, an empty `certs` field, the extended revoked definition and the fixed shape of a revoked answer for a non-issued certificate, the list each extension belongs in, the criticality and value syntax of the CRL entry extensions a SingleResponse may carry, unrecognized critical extensions, the reasons unspecified and removeFromCRL, and two answers for one CertID; the RFC 5019 lightweight rows run on request), `rules`, `profiles` |
|
|
293
|
+
| `pki.lint` | Certificate linting, the zlint or 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, the post-quantum certificate profiles, and a representative CA/Browser Forum TLS BR subset: serial sign and size, validity ordering and the SC081v3 reducing validity schedule, keyCertSign coherence, extension criticality (basicConstraints, nameConstraints, policyConstraints and inhibitAnyPolicy must be critical, and keyUsage should be), nameConstraints CA-scope, unknown critical extensions, empty-subject SAN, SKI and AKI presence including the end-entity subjectKeyIdentifier, the six extensions a conforming CA must mark non-critical (reported by clause rather than as unrecognized) and the two it should (issuerAltName, cRLDistributionPoints), a policy mapping to or from anyPolicy, a unique identifier a conforming CA must not generate, a critical subjectAltName beside a non-empty subject, a critical extKeyUsage naming anyExtendedKeyUsage, a userNotice noticeRef, SAN required and CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys, the CA/Browser Forum subscriber certificate profile of §7.1.2.7 (the extension presence and criticality table, the authorityInformationAccess accessMethod and accessLocation tables, basicConstraints cA and pathLenConstraint, certificatePolicies against anyPolicy and the exactly-one reserved identifier rule and the permitted-qualifier table, the forbidden extended key purposes, the two Key Usage tables that differ by key type, and the subjectAltName GeneralName table with its criticality rule in both directions), the post-quantum key-usage rules binding a certificate's keyUsage bits to its subject key type (ML-KEM under RFC 9935 §5, ML-DSA under RFC 9881 §5, and SLH-DSA under RFC 9909 §6 across both its pure and prehash identifiers) together with the ML-KEM encapsulation-key encoding and size, and the §4.2.1.4 certificate-policy user-notice rules (a VisibleString or 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). Alone among these entries the data path never throws: hostile bytes return a `fatal` `lint/unparseable` finding carrying the strict parser's code, so a whole directory lints without a try/catch, and only config-time misuse throws a typed `LintError` — `certificate`, `crl` (the RFC 5280 §5 revocation-list profile: nextUpdate presence, update ordering, cRLNumber presence and its 20-octet ceiling, authorityKeyIdentifier presence, the criticality each profiled CRL and entry extension must carry, freshestCRL in a delta CRL, and unrecognized critical extensions), `ocsp` (the RFC 6960 response profile: an empty signature, each SingleResponse's update ordering and its relation to producedAt, an empty `certs` field, the extended revoked definition and the fixed shape of a revoked answer for a non-issued certificate, the list each extension belongs in, the criticality and value syntax of the CRL entry extensions a SingleResponse may carry, unrecognized critical extensions, the reasons unspecified and removeFromCRL, and two answers for one CertID; the RFC 5019 lightweight rows run on request), `csr` (the artifact a CA reads before it issues: an empty subject naming no identity, an empty subject whose subjectAltName is not critical, a request for an extension the issuer determines, a request asking for a CA certificate, a broken signing digest, and a challengePassword traveling with the request; `opts.profile: "cabf-tls"` adds the certificate profile's own commonName-in-SAN, dNSName syntax, SAN-required and key-strength rows, applied to what the request asks for, and they run only when named because a request carries no extKeyUsage. It does not check the request's signature, which is a cryptographic operation `pki.csr.verify` owns), `rules`, `profiles` |
|
|
294
294
|
| `pki.C` / `pki.constants` | Version-stable constants: the functional scale helpers `C.TIME.*` and `C.BYTES.*`, the codec `LIMITS`, and `version` |
|
|
295
295
|
| `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`, and `LintError`, each carrying a stable `code` in `domain/reason` form |
|
|
296
296
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>`, `pki inspect <cert>`, `pki lint <cert\|crl\|ocsp-response>`, `pki convert <file> --to der\|pem`, `pki verify <cert>... --anchor <cert>`, `pki sign <file> --cert <c> --key <k>` |
|
package/lib/lint.js
CHANGED
|
@@ -34,6 +34,7 @@ var guard = require("./guard-all");
|
|
|
34
34
|
var oid = require("./oid");
|
|
35
35
|
var x509 = require("./schema-x509");
|
|
36
36
|
var crlSchema = require("./schema-crl");
|
|
37
|
+
var csrSchema = require("./schema-csr");
|
|
37
38
|
var ocspSchema = require("./schema-ocsp");
|
|
38
39
|
var pkix = require("./schema-pkix");
|
|
39
40
|
var schema = require("./schema-engine");
|
|
@@ -241,6 +242,7 @@ function _ecCurveName(spki) {
|
|
|
241
242
|
|
|
242
243
|
var OID_UNOTICE = oid.byName("unotice");
|
|
243
244
|
var OID_ANY_POLICY = oid.byName("anyPolicy");
|
|
245
|
+
var OID_CPS = oid.byName("cps");
|
|
244
246
|
/** @internal The extensions RFC 5280 says a conforming CA SHOULD mark non-critical, keyed by OID
|
|
245
247
|
* with the clause; the MUSTs live in the shared CERT_FIXED_CRITICALITY table. The subjectAltName
|
|
246
248
|
* and extKeyUsage recommendations are conditional and have their own rules. */
|
|
@@ -543,7 +545,327 @@ function _validityCeilingDays(notBefore) {
|
|
|
543
545
|
return VALIDITY_SCHEDULE[VALIDITY_SCHEDULE.length - 1].maxDays;
|
|
544
546
|
}
|
|
545
547
|
|
|
548
|
+
/** @internal All FOUR reserved policy identifiers of BR sec. 7.1.6.1, one per Subscriber
|
|
549
|
+
* Certificate type named in sec. 7.1.2.7.1: Domain, Organization and Individual Validated under
|
|
550
|
+
* the baseline-requirements arc, and Extended Validation under ev-guidelines. Sec. 7.1.2.7.9:
|
|
551
|
+
* "the Certificate Policies extension MUST contain exactly one Reserved Certificate Policy
|
|
552
|
+
* Identifier", so a list short of one type reports every certificate of that type as asserting
|
|
553
|
+
* none. */
|
|
554
|
+
var CABF_RESERVED_POLICIES = ["domain-validated", "organization-validated", "individual-validated",
|
|
555
|
+
"ev-guidelines"]
|
|
556
|
+
.map(function (n) { return oid.byName(n); }).filter(function (d) { return !!d; });
|
|
557
|
+
|
|
558
|
+
/** @internal BR sec. 7.1.2.7.10. Each is MUST NOT for a Subscriber Certificate. */
|
|
559
|
+
var CABF_FORBIDDEN_EKU = ["codeSigning", "emailProtection", "timeStamping", "ocspSigning",
|
|
560
|
+
"anyExtendedKeyUsage", "precertificateSigningCert"]
|
|
561
|
+
.map(function (n) { var d = oid.byName(n); return d ? { name: n, oid: d } : null; })
|
|
562
|
+
.filter(function (e) { return !!e; });
|
|
563
|
+
|
|
564
|
+
/** @internal BR sec. 7.1.2.7.7. Only these two accessMethods are permitted; any other MUST NOT. */
|
|
565
|
+
var CABF_AIA_METHODS = ["ocsp", "caIssuers"].map(function (n) { return oid.byName(n); });
|
|
566
|
+
|
|
567
|
+
/** @internal BR sec. 7.1.2.7.11, the two Key Usage tables. A bit marked "Permitted: N" for the
|
|
568
|
+
* certificate's key type may not be set. The tables differ by key type, which is the point: the
|
|
569
|
+
* same bit is permitted for one and forbidden for the other. */
|
|
570
|
+
var CABF_KU_FORBIDDEN_RSA = ["nonRepudiation", "keyAgreement", "keyCertSign", "cRLSign",
|
|
571
|
+
"encipherOnly", "decipherOnly"];
|
|
572
|
+
var CABF_KU_FORBIDDEN_ECC = ["nonRepudiation", "keyEncipherment", "dataEncipherment",
|
|
573
|
+
"keyCertSign", "cRLSign", "encipherOnly", "decipherOnly"];
|
|
574
|
+
var CABF_KU_RSA_BITS = ["digitalSignature", "keyEncipherment", "dataEncipherment"];
|
|
575
|
+
|
|
576
|
+
/** @internal BR sec. 7.1.2.7.12: only dNSName and iPAddress are permitted in a Subscriber
|
|
577
|
+
* Certificate's subjectAltName. The GeneralName context tag numbers are RFC 5280 sec. 4.2.1.6's. */
|
|
578
|
+
var CABF_SAN_PERMITTED_TAGS = [2, 7];
|
|
579
|
+
var CABF_SAN_TAG_NAMES = Object.assign(Object.create(null), {
|
|
580
|
+
0: "otherName", 1: "rfc822Name", 2: "dNSName", 3: "x400Address", 4: "directoryName",
|
|
581
|
+
5: "ediPartyName", 6: "uniformResourceIdentifier", 7: "iPAddress", 8: "registeredID",
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
/** @internal Which of the two Key Usage tables sec. 7.1.2.7.11 gives applies to this certificate,
|
|
585
|
+
* or null where neither does. The section opens "The acceptable Key Usage values vary based on
|
|
586
|
+
* whether the Certificate's subjectPublicKeyInfo identifies an RSA public key or an ECC public
|
|
587
|
+
* key", and gives a table for each and none for anything else. A key outside the two, such as an
|
|
588
|
+
* ML-DSA one, therefore has no table to be held to, and treating everything that is not ECC as
|
|
589
|
+
* RSA would report it against requirements written for a different algorithm. */
|
|
590
|
+
function _cabfKeyUsageTable(cert) {
|
|
591
|
+
var spki = cert.subjectPublicKeyInfo;
|
|
592
|
+
var name = spki && spki.algorithm && spki.algorithm.name;
|
|
593
|
+
if (name === "ecPublicKey") return "ecc";
|
|
594
|
+
if (RSA_KEY_ALGS[name]) return "rsa";
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
function _isEccKey(cert) { return _cabfKeyUsageTable(cert) === "ecc"; }
|
|
598
|
+
function _subjectIsEmpty(cert) {
|
|
599
|
+
var rdns = cert.subject && cert.subject.rdns;
|
|
600
|
+
return !(Array.isArray(rdns) && rdns.length);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** @internal Every policy qualifier of every PolicyInformation, as its qualifier OID. The bytes
|
|
604
|
+
* are re-decoded the way the display-text walk above re-decodes them. */
|
|
605
|
+
function _policyQualifierIds(ctx) {
|
|
606
|
+
var d = ctx.decode("certificatePolicies");
|
|
607
|
+
if (!d || !Array.isArray(d.value)) return [];
|
|
608
|
+
var out = [];
|
|
609
|
+
d.value.forEach(function (pi) {
|
|
610
|
+
if (!pi.qualifiersBytes || !pi.qualifiersBytes.length) return;
|
|
611
|
+
var quals;
|
|
612
|
+
// allow:swallow-unverified bytes that already decoded under the policy-qualifier door cannot throw
|
|
613
|
+
try { quals = asn1.decode(pi.qualifiersBytes).children; } catch (_e) { return; }
|
|
614
|
+
(quals || []).forEach(function (pq) {
|
|
615
|
+
// allow:swallow-unverified the same door already read this OID
|
|
616
|
+
try { out.push(asn1.read.oid(pq.children[0])); } catch (_e2) { void 0; }
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
return out;
|
|
620
|
+
}
|
|
621
|
+
|
|
546
622
|
var CABF_TLS_RULES = [
|
|
623
|
+
{
|
|
624
|
+
id: "lint/cabf-tls/aia-missing", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
|
|
625
|
+
message: "a subscriber certificate must carry an authorityInformationAccess extension",
|
|
626
|
+
appliesTo: _isTls,
|
|
627
|
+
check: function (cert, ctx) { return ctx.raw("authorityInfoAccess") ? null : true; },
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
id: "lint/cabf-tls/aia-critical", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
|
|
631
|
+
message: "authorityInformationAccess must not be marked critical",
|
|
632
|
+
appliesTo: _isTls,
|
|
633
|
+
check: function (cert, ctx) {
|
|
634
|
+
var e = ctx.raw("authorityInfoAccess");
|
|
635
|
+
return (e && e.critical === true) ? true : null;
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
id: "lint/cabf-tls/aia-forbidden-access-method", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.7",
|
|
640
|
+
/** @internal The table permits id-ad-ocsp and id-ad-caIssuers and says of any other value
|
|
641
|
+
* "No other accessMethods may be used". */
|
|
642
|
+
message: "an authorityInformationAccess accessMethod other than id-ad-ocsp or id-ad-caIssuers is not permitted",
|
|
643
|
+
appliesTo: _isTls,
|
|
644
|
+
check: function (cert, ctx) {
|
|
645
|
+
var d = ctx.decode("authorityInfoAccess");
|
|
646
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
647
|
+
var out = [];
|
|
648
|
+
d.value.forEach(function (ad) {
|
|
649
|
+
if (CABF_AIA_METHODS.indexOf(ad.accessMethod) === -1) {
|
|
650
|
+
out.push({ context: { accessMethod: ad.accessMethod, name: oid.name(ad.accessMethod) || null } });
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
return out.length ? out : null;
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
id: "lint/cabf-tls/aia-location-not-uri", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.7",
|
|
658
|
+
/** @internal Each permitted accessMethod states uniformResourceIdentifier as its accessLocation
|
|
659
|
+
* GeneralName type, and "each accessLocation MUST be encoded as the specified GeneralName type". */
|
|
660
|
+
message: "an authorityInformationAccess accessLocation must be a uniformResourceIdentifier",
|
|
661
|
+
appliesTo: _isTls,
|
|
662
|
+
check: function (cert, ctx) {
|
|
663
|
+
var d = ctx.decode("authorityInfoAccess");
|
|
664
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
665
|
+
var out = [];
|
|
666
|
+
d.value.forEach(function (ad) {
|
|
667
|
+
if (CABF_AIA_METHODS.indexOf(ad.accessMethod) === -1) return;
|
|
668
|
+
var loc = ad.accessLocation;
|
|
669
|
+
if (!loc || loc.tag !== 6) {
|
|
670
|
+
out.push({ context: { accessMethod: oid.name(ad.accessMethod) || ad.accessMethod,
|
|
671
|
+
generalNameTag: loc ? loc.tag : null } });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
return out.length ? out : null;
|
|
675
|
+
},
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
id: "lint/cabf-tls/policies-missing", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
|
|
679
|
+
message: "a subscriber certificate must carry a certificatePolicies extension",
|
|
680
|
+
appliesTo: _isTls,
|
|
681
|
+
check: function (cert, ctx) { return ctx.raw("certificatePolicies") ? null : true; },
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
id: "lint/cabf-tls/policies-any-policy", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.9",
|
|
685
|
+
/** @internal "The anyPolicy Policy Identifier MUST NOT be present." */
|
|
686
|
+
message: "certificatePolicies must not assert anyPolicy",
|
|
687
|
+
appliesTo: _isTls,
|
|
688
|
+
check: function (cert, ctx) {
|
|
689
|
+
var d = ctx.decode("certificatePolicies");
|
|
690
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
691
|
+
return d.value.some(function (pi) { return pi.policyIdentifier === OID_ANY_POLICY; }) ? true : null;
|
|
692
|
+
},
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
id: "lint/cabf-tls/policies-reserved-identifier-count", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.9",
|
|
696
|
+
/** @internal "Regardless of the order of PolicyInformation values, the Certificate Policies
|
|
697
|
+
* extension MUST contain exactly one Reserved Certificate Policy Identifier." Exactly one, so
|
|
698
|
+
* naming none and naming two are the same fault and the count says which. */
|
|
699
|
+
message: "certificatePolicies must assert exactly one reserved certificate policy identifier",
|
|
700
|
+
appliesTo: _isTls,
|
|
701
|
+
check: function (cert, ctx) {
|
|
702
|
+
var d = ctx.decode("certificatePolicies");
|
|
703
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
704
|
+
var found = d.value.filter(function (pi) {
|
|
705
|
+
return CABF_RESERVED_POLICIES.indexOf(pi.policyIdentifier) !== -1;
|
|
706
|
+
});
|
|
707
|
+
return found.length === 1 ? null : { context: { count: found.length,
|
|
708
|
+
identifiers: found.map(function (pi) { return pi.policyIdentifier; }) } };
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
id: "lint/cabf-tls/policy-qualifier-forbidden", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.9",
|
|
713
|
+
/** @internal The permitted-qualifier table admits id-qt-cps and says of any other qualifier
|
|
714
|
+
* MUST NOT, which includes the userNotice RFC 5280 otherwise allows. */
|
|
715
|
+
message: "a certificatePolicies qualifier other than id-qt-cps is not permitted",
|
|
716
|
+
appliesTo: _isTls,
|
|
717
|
+
check: function (cert, ctx) {
|
|
718
|
+
var out = [];
|
|
719
|
+
_policyQualifierIds(ctx).forEach(function (qid) {
|
|
720
|
+
if (qid !== OID_CPS) out.push({ context: { qualifier: qid, name: oid.name(qid) || null } });
|
|
721
|
+
});
|
|
722
|
+
return out.length ? out : null;
|
|
723
|
+
},
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
id: "lint/cabf-tls/eku-forbidden-purpose", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.10",
|
|
727
|
+
message: "a key purpose the subscriber profile forbids is present in extKeyUsage",
|
|
728
|
+
appliesTo: _isTls,
|
|
729
|
+
check: function (cert, ctx) {
|
|
730
|
+
var d = ctx.decode("extKeyUsage");
|
|
731
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
732
|
+
var out = [];
|
|
733
|
+
CABF_FORBIDDEN_EKU.forEach(function (e) {
|
|
734
|
+
if (d.value.indexOf(e.oid) !== -1) out.push({ context: { keyPurpose: e.name, oid: e.oid } });
|
|
735
|
+
});
|
|
736
|
+
return out.length ? out : null;
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
id: "lint/cabf-tls/name-constraints-present", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
|
|
741
|
+
message: "a subscriber certificate must not carry a nameConstraints extension",
|
|
742
|
+
appliesTo: _isTls,
|
|
743
|
+
check: function (cert, ctx) { return ctx.raw("nameConstraints") ? true : null; },
|
|
744
|
+
},
|
|
745
|
+
{
|
|
746
|
+
id: "lint/cabf-tls/basic-constraints-ca-true", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.8",
|
|
747
|
+
message: "a subscriber certificate's basicConstraints cA must be FALSE",
|
|
748
|
+
appliesTo: _isTls,
|
|
749
|
+
check: function (cert, ctx) {
|
|
750
|
+
var d = ctx.decode("basicConstraints");
|
|
751
|
+
return (d && d.value && d.value.cA === true) ? true : null;
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
id: "lint/cabf-tls/basic-constraints-path-len", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.8",
|
|
756
|
+
message: "a subscriber certificate's basicConstraints must not carry a pathLenConstraint",
|
|
757
|
+
appliesTo: _isTls,
|
|
758
|
+
check: function (cert, ctx) {
|
|
759
|
+
var d = ctx.decode("basicConstraints");
|
|
760
|
+
var pl = d && d.value ? d.value.pathLenConstraint : null;
|
|
761
|
+
return pl === null || pl === undefined ? null : { context: { pathLenConstraint: pl } };
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: "lint/cabf-tls/key-usage-forbidden-bit", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.11",
|
|
766
|
+
/** @internal The two Key Usage tables differ by key type, which is the whole point: the same
|
|
767
|
+
* bit is permitted for one and forbidden for the other, so the key decides which table asks. */
|
|
768
|
+
message: "a keyUsage bit the subscriber profile does not permit for this key type is set",
|
|
769
|
+
appliesTo: _isTls,
|
|
770
|
+
check: function (cert, ctx) {
|
|
771
|
+
var d = ctx.decode("keyUsage");
|
|
772
|
+
if (!d || !d.value) return null;
|
|
773
|
+
var table = _cabfKeyUsageTable(cert);
|
|
774
|
+
if (table === null) return null;
|
|
775
|
+
var forbidden = table === "ecc" ? CABF_KU_FORBIDDEN_ECC : CABF_KU_FORBIDDEN_RSA;
|
|
776
|
+
var out = [];
|
|
777
|
+
forbidden.forEach(function (bit) {
|
|
778
|
+
if (d.value[bit] === true) out.push({ context: { keyUsage: bit, keyType: table } });
|
|
779
|
+
});
|
|
780
|
+
return out.length ? out : null;
|
|
781
|
+
},
|
|
782
|
+
},
|
|
783
|
+
{
|
|
784
|
+
id: "lint/cabf-tls/key-usage-ecc-without-digital-signature", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.11",
|
|
785
|
+
/** @internal The ECC table marks digitalSignature Required: MUST, where the RSA table marks it
|
|
786
|
+
* SHOULD and admits a keyEncipherment-only certificate. */
|
|
787
|
+
message: "an ECC subscriber certificate's keyUsage must assert digitalSignature",
|
|
788
|
+
appliesTo: function (cert, ctx) { return _isTls(cert, ctx) && _isEccKey(cert); },
|
|
789
|
+
check: function (cert, ctx) {
|
|
790
|
+
var d = ctx.decode("keyUsage");
|
|
791
|
+
if (!d || !d.value) return null;
|
|
792
|
+
return d.value.digitalSignature === true ? null : true;
|
|
793
|
+
},
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
id: "lint/cabf-tls/key-usage-rsa-no-bit-set", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.11",
|
|
797
|
+
/** @internal "At least one Key Usage MUST be set for RSA Public Keys." Measured over the bits
|
|
798
|
+
* that table permits, since a forbidden bit being set is the row above's answer, not this one's. */
|
|
799
|
+
message: "an RSA subscriber certificate's keyUsage must set at least one permitted bit",
|
|
800
|
+
appliesTo: function (cert, ctx) { return _isTls(cert, ctx) && _cabfKeyUsageTable(cert) === "rsa"; },
|
|
801
|
+
check: function (cert, ctx) {
|
|
802
|
+
var d = ctx.decode("keyUsage");
|
|
803
|
+
if (!d || !d.value) return null;
|
|
804
|
+
var any = CABF_KU_RSA_BITS.some(function (bit) { return d.value[bit] === true; });
|
|
805
|
+
return any ? null : true;
|
|
806
|
+
},
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
id: "lint/cabf-tls/san-forbidden-name-type", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.12",
|
|
810
|
+
/** @internal The GeneralName table permits dNSName and iPAddress and marks every other type
|
|
811
|
+
* Permitted: N, including the uniformResourceIdentifier and rfc822Name RFC 5280 allows. */
|
|
812
|
+
message: "a subjectAltName entry of a type the subscriber profile does not permit is present",
|
|
813
|
+
appliesTo: _isTls,
|
|
814
|
+
check: function (cert, ctx) {
|
|
815
|
+
var d = ctx.decode("subjectAltName");
|
|
816
|
+
if (!d || !d.value || !Array.isArray(d.value.names)) return null;
|
|
817
|
+
var out = [];
|
|
818
|
+
d.value.names.forEach(function (gn) {
|
|
819
|
+
if (CABF_SAN_PERMITTED_TAGS.indexOf(gn.tagNumber) === -1) {
|
|
820
|
+
out.push({ context: { generalName: CABF_SAN_TAG_NAMES[gn.tagNumber] || null, tag: gn.tagNumber } });
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
return out.length ? out : null;
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: "lint/cabf-tls/san-criticality-mismatch", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.12",
|
|
828
|
+
/** @internal "If the subject field of the certificate is an empty SEQUENCE, this extension MUST
|
|
829
|
+
* be marked critical ... Otherwise, this extension MUST NOT be marked critical." Both
|
|
830
|
+
* directions are MUST, so one row measures the pair and the context says which way it failed. */
|
|
831
|
+
message: "subjectAltName must be critical where the subject is empty and non-critical otherwise",
|
|
832
|
+
appliesTo: _isTls,
|
|
833
|
+
check: function (cert, ctx) {
|
|
834
|
+
var e = ctx.raw("subjectAltName");
|
|
835
|
+
if (!e) return null;
|
|
836
|
+
var empty = _subjectIsEmpty(cert);
|
|
837
|
+
var critical = e.critical === true;
|
|
838
|
+
if (empty === critical) return null;
|
|
839
|
+
return { context: { subjectEmpty: empty, critical: critical } };
|
|
840
|
+
},
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
id: "lint/cabf-tls/dnsname-trailing-dot", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.12",
|
|
844
|
+
/** @internal "The zero-length Domain Label representing the root zone ... MUST NOT be included
|
|
845
|
+
* (e.g. 'example.com' MUST be encoded as 'example.com' and MUST NOT be encoded as
|
|
846
|
+
* 'example.com.')." */
|
|
847
|
+
message: "a subjectAltName dNSName must not carry the root zone's zero-length label",
|
|
848
|
+
appliesTo: _isTls,
|
|
849
|
+
check: function (cert, ctx) {
|
|
850
|
+
var out = [];
|
|
851
|
+
_sanDnsNames(ctx).forEach(function (name) {
|
|
852
|
+
if (typeof name === "string" && name.length > 0 && name.charAt(name.length - 1) === ".") {
|
|
853
|
+
out.push({ context: { dnsName: name } });
|
|
854
|
+
}
|
|
855
|
+
});
|
|
856
|
+
return out.length ? out : null;
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
id: "lint/cabf-tls/ski-present", severity: "notice", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
|
|
861
|
+
/** @internal NOT RECOMMENDED here, which is the opposite of what RFC 5280 sec. 4.2.1.2 asks of
|
|
862
|
+
* an end-entity certificate. Both readings ship, each under the profile that states it, so a
|
|
863
|
+
* caller running one profile is not told the other's answer. Graded notice because the clause
|
|
864
|
+
* is NOT RECOMMENDED rather than MUST NOT. */
|
|
865
|
+
message: "a subscriber certificate's subjectKeyIdentifier is NOT RECOMMENDED by this profile",
|
|
866
|
+
appliesTo: _isTls,
|
|
867
|
+
check: function (cert, ctx) { return ctx.raw("subjectKeyIdentifier") ? true : null; },
|
|
868
|
+
},
|
|
547
869
|
{
|
|
548
870
|
id: "lint/cabf-tls/san-missing", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.4.2.1",
|
|
549
871
|
message: "a TLS server certificate must include a usable subjectAltName extension",
|
|
@@ -1774,6 +2096,163 @@ var RFC5019_OCSP_RULES = [
|
|
|
1774
2096
|
var OCSP_PROFILES = Object.assign(Object.create(null), { "rfc6960": RFC6960_OCSP_RULES, "rfc5019": RFC5019_OCSP_RULES });
|
|
1775
2097
|
var ALL_OCSP_RULES = RFC6960_OCSP_RULES.concat(RFC5019_OCSP_RULES);
|
|
1776
2098
|
|
|
2099
|
+
/** @internal The extensions an issuer determines, which a request may carry and a CA does not take
|
|
2100
|
+
* from one. subjectKeyIdentifier and authorityKeyIdentifier are computed from the keys, and the
|
|
2101
|
+
* three pointers name services the issuer operates. A request for any of them is not a fault in
|
|
2102
|
+
* the request, so these are reported at notice: the CA simply does not honor them. */
|
|
2103
|
+
var CA_DETERMINED_EXTENSIONS = [
|
|
2104
|
+
"authorityKeyIdentifier", "subjectKeyIdentifier", "authorityInfoAccess",
|
|
2105
|
+
"cRLDistributionPoints", "freshestCRL",
|
|
2106
|
+
];
|
|
2107
|
+
|
|
2108
|
+
/** @internal The signature algorithms a request must not be signed under. A certification request
|
|
2109
|
+
* is signed by the requester's own key, and the signature is the only evidence the requester
|
|
2110
|
+
* holds it, so a collision-broken digest removes the one thing the structure proves. */
|
|
2111
|
+
var WEAK_CSR_SIG_DIGESTS = ["md2", "md4", "md5", "sha1"];
|
|
2112
|
+
|
|
2113
|
+
/** @internal Named outright, because reading the digest out of the algorithm's NAME misses the one
|
|
2114
|
+
* whose name does not carry it: `shaWithRSAEncryption` is the original SHA, now SHA-0, and it
|
|
2115
|
+
* spells neither "sha1" nor any other substring above. The set is resolved through the registry
|
|
2116
|
+
* rather than written as OIDs, so a name that stops resolving fails the round-trip gate in
|
|
2117
|
+
* oid.test.js rather than silently dropping out of this set. */
|
|
2118
|
+
var WEAK_CSR_SIG_OIDS = Object.create(null);
|
|
2119
|
+
["shaWithRSAEncryption",
|
|
2120
|
+
"md2WithRSAEncryption", "md4WithRSAEncryption", "md4WithRSAEncryption-pkcs1",
|
|
2121
|
+
"md5WithRSAEncryption", "sha1WithRSAEncryption",
|
|
2122
|
+
"md4WithRSA", "md5WithRSA",
|
|
2123
|
+
"md2WithRSASignature", "md5WithRSASignature", "sha1WithRSASignature",
|
|
2124
|
+
"ecdsaWithSHA1", "dsaWithSha1",
|
|
2125
|
+
].forEach(function (n) { var d = oid.byName(n); if (d) WEAK_CSR_SIG_OIDS[d] = n; });
|
|
2126
|
+
|
|
2127
|
+
/** @internal RSASSA-PSS carries its digest in the parameters rather than in the algorithm name, so
|
|
2128
|
+
* the name alone never reads as weak. RFC 4055 sec. 3.1 makes the hashAlgorithm `[0]` field
|
|
2129
|
+
* DEFAULT sha1, which means ABSENT parameters and an empty parameter SEQUENCE both name SHA-1:
|
|
2130
|
+
* the shape that looks like it says nothing is the one that says the weakest thing. */
|
|
2131
|
+
function _pssDigestName(sigAlg) {
|
|
2132
|
+
if (!sigAlg || sigAlg.oid !== oid.byName("rsassaPss")) return null;
|
|
2133
|
+
var params = sigAlg.parameters;
|
|
2134
|
+
if (params === null || params === undefined) return "sha1";
|
|
2135
|
+
var node;
|
|
2136
|
+
try { node = asn1.decode(params); }
|
|
2137
|
+
// allow:swallow-unverified parameters that do not decode are reported by the value-syntax path
|
|
2138
|
+
catch (_e) { return null; }
|
|
2139
|
+
if (!node || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children) return null;
|
|
2140
|
+
for (var i = 0; i < node.children.length; i++) {
|
|
2141
|
+
var c = node.children[i];
|
|
2142
|
+
if (c.tagClass !== "context" || c.tagNumber !== 0) continue;
|
|
2143
|
+
var inner = c.children && c.children[0];
|
|
2144
|
+
if (!inner || !inner.children || !inner.children[0]) return null;
|
|
2145
|
+
var dotted;
|
|
2146
|
+
try { dotted = asn1.read.oid(inner.children[0]); }
|
|
2147
|
+
// allow:swallow-unverified a hashAlgorithm that does not read is not a digest this names
|
|
2148
|
+
catch (_e2) { return null; }
|
|
2149
|
+
return oid.name(dotted) || dotted;
|
|
2150
|
+
}
|
|
2151
|
+
return "sha1";
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
var RFC2986_CSR_RULES = [
|
|
2155
|
+
{
|
|
2156
|
+
id: "lint/rfc2986/subject-empty-no-identity", severity: "error", source: "rfc2986", citation: "RFC 5280 sec. 4.1.2.6",
|
|
2157
|
+
/** @internal Section 4.1.2.6 admits an empty subject only where the subjectAltName carries the
|
|
2158
|
+
* identity instead. A request with neither names nothing to certify, so there is no name for
|
|
2159
|
+
* the issuer to put in the certificate. */
|
|
2160
|
+
message: "a request with an empty subject must request a subjectAltName, or it names nothing to certify",
|
|
2161
|
+
check: function (csr, ctx) {
|
|
2162
|
+
if (_subjectIsNonEmpty(csr)) return null;
|
|
2163
|
+
var san = ctx.decode("subjectAltName");
|
|
2164
|
+
return (san && san.value && san.value.names && san.value.names.length) ? null : true;
|
|
2165
|
+
},
|
|
2166
|
+
},
|
|
2167
|
+
{
|
|
2168
|
+
id: "lint/rfc2986/san-not-critical-empty-subject", severity: "error", source: "rfc2986", citation: "RFC 5280 sec. 4.1.2.6 / 4.2.1.6",
|
|
2169
|
+
/** @internal "If the subject field contains an empty sequence, then the issuing CA MUST
|
|
2170
|
+
* include a subjectAltName extension that is marked as critical." A request whose only
|
|
2171
|
+
* identity is a non-critical SAN asks for a certificate the profile refuses. */
|
|
2172
|
+
message: "where the subject is empty the requested subjectAltName must be critical",
|
|
2173
|
+
check: function (csr, ctx) {
|
|
2174
|
+
if (_subjectIsNonEmpty(csr)) return null;
|
|
2175
|
+
var san = ctx.decode("subjectAltName");
|
|
2176
|
+
if (!san || !san.value || !san.value.names || !san.value.names.length) return null;
|
|
2177
|
+
return san.critical === true ? null : true;
|
|
2178
|
+
},
|
|
2179
|
+
},
|
|
2180
|
+
{
|
|
2181
|
+
id: "lint/rfc2986/ca-determined-extension-requested", severity: "notice", source: "rfc2986", citation: "RFC 5280 sec. 4.2.1.1 / 4.2.1.2 / 4.2.2",
|
|
2182
|
+
message: "the request asks for an extension the issuer determines, which a CA does not take from a request",
|
|
2183
|
+
check: function (csr, ctx) {
|
|
2184
|
+
var out = [];
|
|
2185
|
+
CA_DETERMINED_EXTENSIONS.forEach(function (name) {
|
|
2186
|
+
if (ctx.raw(name)) out.push({ context: { extension: name } });
|
|
2187
|
+
});
|
|
2188
|
+
return out.length ? out : null;
|
|
2189
|
+
},
|
|
2190
|
+
},
|
|
2191
|
+
{
|
|
2192
|
+
id: "lint/rfc2986/basic-constraints-ca-requested", severity: "warn", source: "rfc2986", citation: "RFC 5280 sec. 4.2.1.9",
|
|
2193
|
+
/** @internal A subscriber request asking for cA TRUE is the request that turns a subscriber
|
|
2194
|
+
* into an issuer if it is honored, so it is named rather than passed over. It is a warning
|
|
2195
|
+
* and not an error because a CA legitimately issues intermediates from a request. */
|
|
2196
|
+
message: "the request asks for a CA certificate (basicConstraints cA TRUE)",
|
|
2197
|
+
check: function (csr, ctx) {
|
|
2198
|
+
var bc = ctx.decode("basicConstraints");
|
|
2199
|
+
return (bc && bc.value && bc.value.cA === true) ? { context: { pathLenConstraint: bc.value.pathLenConstraint } } : null;
|
|
2200
|
+
},
|
|
2201
|
+
},
|
|
2202
|
+
{
|
|
2203
|
+
id: "lint/rfc2986/weak-signature-algorithm", severity: "error", source: "rfc2986", citation: "RFC 2986 sec. 4.2",
|
|
2204
|
+
message: "the request is signed under a digest no longer fit to carry a proof of possession",
|
|
2205
|
+
check: function (csr) {
|
|
2206
|
+
var alg = csr.signatureAlgorithm;
|
|
2207
|
+
if (alg && WEAK_CSR_SIG_OIDS[alg.oid]) {
|
|
2208
|
+
return { context: { signatureAlgorithm: WEAK_CSR_SIG_OIDS[alg.oid], from: "algorithm" } };
|
|
2209
|
+
}
|
|
2210
|
+
var pss = _pssDigestName(alg);
|
|
2211
|
+
var named = pss !== null ? pss : (alg && alg.name);
|
|
2212
|
+
if (typeof named !== "string") return null;
|
|
2213
|
+
var lower = named.toLowerCase();
|
|
2214
|
+
for (var i = 0; i < WEAK_CSR_SIG_DIGESTS.length; i++) {
|
|
2215
|
+
if (lower.indexOf(WEAK_CSR_SIG_DIGESTS[i]) !== -1) {
|
|
2216
|
+
return { context: { signatureAlgorithm: (alg && alg.name) || (alg && alg.oid),
|
|
2217
|
+
digest: WEAK_CSR_SIG_DIGESTS[i], from: pss !== null ? "parameters" : "algorithm" } };
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return null;
|
|
2221
|
+
},
|
|
2222
|
+
},
|
|
2223
|
+
{
|
|
2224
|
+
id: "lint/rfc2986/extension-request-ambiguous", severity: "error", source: "rfc2986", citation: "RFC 2985 sec. 5.4.2 / X.501 sec. 8.2",
|
|
2225
|
+
/** @internal An attribute type appears once in a SET OF Attributes, and extensionRequest is
|
|
2226
|
+
* SINGLE VALUE besides. Two of them parse when they are in DER order, and which one a reader
|
|
2227
|
+
* takes then depends on how the bytes happened to sort, so one request reads two ways. Every
|
|
2228
|
+
* one of them is linted below, and this row says the request did not ask once. */
|
|
2229
|
+
message: "the request carries more than one extensionRequest attribute, so what it asks for depends on which is read",
|
|
2230
|
+
check: function (csr) {
|
|
2231
|
+
var n = _csrAttributesOf(csr, "extensionRequest").length;
|
|
2232
|
+
return n > 1 ? { context: { count: n } } : null;
|
|
2233
|
+
},
|
|
2234
|
+
},
|
|
2235
|
+
{
|
|
2236
|
+
id: "lint/rfc2986/challenge-password-present", severity: "notice", source: "rfc2986", citation: "RFC 2985 sec. 5.4.1",
|
|
2237
|
+
/** @internal The attribute travels inside the request, so wherever the request is stored or
|
|
2238
|
+
* logged the password is too. Reported so an operator knows it is in the artifact, not
|
|
2239
|
+
* because the structure is wrong. */
|
|
2240
|
+
message: "the request carries a challengePassword, which travels and is stored with it",
|
|
2241
|
+
check: function (csr) { return _csrHasAttribute(csr, "challengePassword") ? true : null; },
|
|
2242
|
+
},
|
|
2243
|
+
];
|
|
2244
|
+
|
|
2245
|
+
/** @internal The certificate rows that read only the subject, the public key and the requested
|
|
2246
|
+
* subjectAltName, which a request carries too. They are REUSED rather than restated so the
|
|
2247
|
+
* pre-issuance answer cannot drift from the post-issuance one: the row, its severity and its
|
|
2248
|
+
* citation are the certificate profile's own. */
|
|
2249
|
+
var CABF_TLS_CSR_RULE_IDS = [
|
|
2250
|
+
"lint/cabf-tls/san-missing",
|
|
2251
|
+
"lint/cabf-tls/cn-not-in-san",
|
|
2252
|
+
"lint/cabf-tls/dnsname-bad-syntax",
|
|
2253
|
+
"lint/cabf-tls/weak-key",
|
|
2254
|
+
];
|
|
2255
|
+
|
|
1777
2256
|
var PROFILES = Object.assign(Object.create(null), {
|
|
1778
2257
|
"rfc5280": RFC5280_RULES,
|
|
1779
2258
|
"rfc9881": RFC9881_RULES,
|
|
@@ -1783,6 +2262,133 @@ var PROFILES = Object.assign(Object.create(null), {
|
|
|
1783
2262
|
});
|
|
1784
2263
|
var ALL_RULES = RFC5280_RULES.concat(RFC9881_RULES).concat(RFC9909_RULES).concat(RFC9935_RULES).concat(CABF_TLS_RULES);
|
|
1785
2264
|
|
|
2265
|
+
var CABF_TLS_CSR_RULES = CABF_TLS_RULES.filter(function (r) {
|
|
2266
|
+
return CABF_TLS_CSR_RULE_IDS.indexOf(r.id) !== -1;
|
|
2267
|
+
});
|
|
2268
|
+
/** @internal RFC 5280 sec. 4.2 fixes each extension's syntax wherever it is carried, and a
|
|
2269
|
+
* requested extension whose value does not decode is the same fault in a request as in a
|
|
2270
|
+
* certificate. The certificate's own row reads `extensions` and the request view supplies it, so
|
|
2271
|
+
* the row is reused rather than restated. Without it a malformed requested value counted as a
|
|
2272
|
+
* passing check, because the shared decoder returns null and every row that asks by name reads
|
|
2273
|
+
* that null as absence. */
|
|
2274
|
+
var RFC5280_CSR_RULES = RFC5280_RULES.filter(function (r) {
|
|
2275
|
+
return r.id === "lint/rfc5280/extension-undecodable";
|
|
2276
|
+
});
|
|
2277
|
+
var CSR_STRUCTURAL_RULES = RFC2986_CSR_RULES.concat(RFC5280_CSR_RULES);
|
|
2278
|
+
var CSR_PROFILES = Object.assign(Object.create(null), {
|
|
2279
|
+
"rfc2986": CSR_STRUCTURAL_RULES,
|
|
2280
|
+
"cabf-tls": CSR_STRUCTURAL_RULES.concat(CABF_TLS_CSR_RULES),
|
|
2281
|
+
});
|
|
2282
|
+
var ALL_CSR_RULES = CSR_STRUCTURAL_RULES;
|
|
2283
|
+
|
|
2284
|
+
function _subjectIsNonEmpty(csr) {
|
|
2285
|
+
var rdns = csr.subject && csr.subject.rdns;
|
|
2286
|
+
return !!(Array.isArray(rdns) && rdns.length);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
function _csrAttributesOf(csr, name) {
|
|
2290
|
+
var dotted = oid.byName(name);
|
|
2291
|
+
var list = csr.attributes || [];
|
|
2292
|
+
var out = [];
|
|
2293
|
+
for (var i = 0; i < list.length; i++) { if (list[i].type === dotted) out.push(list[i]); }
|
|
2294
|
+
return out;
|
|
2295
|
+
}
|
|
2296
|
+
function _csrHasAttribute(csr, name) { return _csrAttributesOf(csr, name).length > 0; }
|
|
2297
|
+
|
|
2298
|
+
/** @internal A request read the way the certificate rows read a certificate: the subject and the
|
|
2299
|
+
* public key are the request's own, and `extensions` is what the extensionRequest attribute asked
|
|
2300
|
+
* for. Shaped this way so `_findRaw`, `_decodeOrNull` and the reused CABF rows run unchanged
|
|
2301
|
+
* against a request rather than each restating how to reach a requested extension. */
|
|
2302
|
+
function _csrViewWith(csr, exts) {
|
|
2303
|
+
var view = Object.create(null);
|
|
2304
|
+
view.subject = csr.subject;
|
|
2305
|
+
view.subjectPublicKeyInfo = csr.subjectPublicKeyInfo;
|
|
2306
|
+
view.signatureAlgorithm = csr.signatureAlgorithm;
|
|
2307
|
+
view.attributes = csr.attributes;
|
|
2308
|
+
view.extensions = exts;
|
|
2309
|
+
return view;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/** @internal One view per extensionRequest attribute, rather than one view over all of them
|
|
2313
|
+
* flattened. A rule that asks for an extension BY NAME reads the first match, so flattening lets
|
|
2314
|
+
* a basicConstraints of cA FALSE in one attribute hide a cA TRUE in the next: the value that
|
|
2315
|
+
* decides the answer would be the one that happened to sort first. Each attribute is linted as
|
|
2316
|
+
* the request it states, and the findings are merged. A request carrying one attribute, which is
|
|
2317
|
+
* every conforming one, produces exactly one view and the same answer as before. */
|
|
2318
|
+
function _csrViews(csr) {
|
|
2319
|
+
var attrs = _csrAttributesOf(csr, "extensionRequest");
|
|
2320
|
+
if (!attrs.length) return [_csrViewWith(csr, [])];
|
|
2321
|
+
return attrs.map(function (attr) {
|
|
2322
|
+
return _csrViewWith(csr, Array.isArray(attr.extensions) ? attr.extensions : []);
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
/** @internal The same rule firing on two views of one request is one finding, since the rows that
|
|
2327
|
+
* do not read an extension see the identical request each time. */
|
|
2328
|
+
function _mergeCsrReports(reports) {
|
|
2329
|
+
var findings = [], ran = [], seen = Object.create(null), ranSeen = Object.create(null);
|
|
2330
|
+
reports.forEach(function (r) {
|
|
2331
|
+
r.findings.forEach(function (f) {
|
|
2332
|
+
var key = f.id + "\u0000" + JSON.stringify(f.context === undefined ? null : f.context);
|
|
2333
|
+
if (seen[key]) return;
|
|
2334
|
+
seen[key] = true;
|
|
2335
|
+
findings.push(f);
|
|
2336
|
+
});
|
|
2337
|
+
r.ran.forEach(function (id) { if (!ranSeen[id]) { ranSeen[id] = true; ran.push(id); } });
|
|
2338
|
+
});
|
|
2339
|
+
var counts = Object.assign(Object.create(null), { fatal: 0, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 });
|
|
2340
|
+
var failed = Object.create(null), failedCount = 0;
|
|
2341
|
+
findings.forEach(function (f) {
|
|
2342
|
+
counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
2343
|
+
if (!failed[f.id]) { failed[f.id] = true; failedCount += 1; }
|
|
2344
|
+
});
|
|
2345
|
+
/** @internal A rule that reports twice is still ONE rule that did not pass, which is what
|
|
2346
|
+
* `_runLints` counts, so the distinct failing ids come off the executed ones. Subtracting the
|
|
2347
|
+
* finding count instead understates `pass` the moment any rule reports more than once. */
|
|
2348
|
+
counts.pass = ran.length - failedCount > 0 ? ran.length - failedCount : 0;
|
|
2349
|
+
return { findings: findings, counts: counts, ran: ran };
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
/** @internal A request carries no extKeyUsage, so nothing in its bytes says it is for TLS. The
|
|
2353
|
+
* caller names that profile or the TLS rows do not run, which is why `isTlsServerCert` here is
|
|
2354
|
+
* the caller's statement rather than something read off the artifact. */
|
|
2355
|
+
function _makeCsrCtx(view, profile) {
|
|
2356
|
+
var explicitTls = profile === "cabf-tls";
|
|
2357
|
+
return {
|
|
2358
|
+
profile: profile,
|
|
2359
|
+
explicitTlsProfile: explicitTls,
|
|
2360
|
+
isTlsServerCert: explicitTls,
|
|
2361
|
+
decode: function (name) { return _decodeOrNull(view, name); },
|
|
2362
|
+
raw: function (name) { return _findRaw(view, name); },
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
/** @internal The kind-checked door the other lint verbs use, rather than a property test of our
|
|
2367
|
+
* own: a parsed certificate carries a subjectPublicKeyInfo too, so a shape test admits one as a
|
|
2368
|
+
* request and then reads its extensions from an extensionRequest attribute it does not have,
|
|
2369
|
+
* reporting on a request that was never made. */
|
|
2370
|
+
function _derivedCsr(o) {
|
|
2371
|
+
if (!o || typeof o !== "object" || guard.bytes.isByteSource(o)) return null;
|
|
2372
|
+
// allow:swallow-unverified an object the guard refuses is not a parsed request, and the byte paths below answer for it
|
|
2373
|
+
try { return guard.parsed.acceptDerived(o, "csr", csrSchema.parse, _cfg, "lint/bad-input", "the certification request"); }
|
|
2374
|
+
catch (_e) { return null; }
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function _ingestCsr(input) {
|
|
2378
|
+
var derived = _derivedCsr(input);
|
|
2379
|
+
if (derived) return { csr: derived };
|
|
2380
|
+
var der;
|
|
2381
|
+
if (Buffer.isBuffer(input)) der = input;
|
|
2382
|
+
else if (typeof input === "string") {
|
|
2383
|
+
try { der = csrSchema.pemDecode(input); }
|
|
2384
|
+
catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER certification request", context: { code: e.code } } }; }
|
|
2385
|
+
} else {
|
|
2386
|
+
throw _cfg("lint/bad-input", "pki.lint.csr input must be a parsed certification request, a DER Buffer, or a PEM string");
|
|
2387
|
+
}
|
|
2388
|
+
try { return { csr: csrSchema.parse(der) }; }
|
|
2389
|
+
catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "RFC 2986", message: "input is not a well-formed PKCS#10 certification request", context: { code: e.code } } }; }
|
|
2390
|
+
}
|
|
2391
|
+
|
|
1786
2392
|
function _selectRules(profile) {
|
|
1787
2393
|
if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
|
|
1788
2394
|
if (!PROFILES[profile]) {
|
|
@@ -1819,7 +2425,13 @@ function _applyThreshold(report, severity) {
|
|
|
1819
2425
|
* raises a typed `LintError`.
|
|
1820
2426
|
*
|
|
1821
2427
|
* @opts profile One of `pki.lint.profiles()` (default runs every profile). Selecting
|
|
1822
|
-
* `"cabf-tls"` lints the input AS a TLS
|
|
2428
|
+
* `"cabf-tls"` lints the input AS a TLS SUBSCRIBER certificate, which is an
|
|
2429
|
+
* assertion about the input rather than a question asked of it: the section
|
|
2430
|
+
* 7.1.2.7 rows then apply whatever the certificate says it is, so naming it for
|
|
2431
|
+
* a CA certificate reports that CA against subscriber requirements. The CA
|
|
2432
|
+
* profiles of section 7.1.2.10 are a separate set this does not yet carry. Left
|
|
2433
|
+
* unnamed, a certificate is held to those rows only where it asserts
|
|
2434
|
+
* id-kp-serverAuth and is not a CA.
|
|
1823
2435
|
* @opts severity Suppress findings below this floor (default `"notice"`). `counts` and
|
|
1824
2436
|
* `worst` always reflect the complete, unfiltered result.
|
|
1825
2437
|
* @example
|
|
@@ -1929,6 +2541,87 @@ function _ingestCrl(input) {
|
|
|
1929
2541
|
* report.worst; // -> "error", for the missing AKI
|
|
1930
2542
|
* report.findings.map(function (f) { return f.id; }); // -> ["lint/rfc5280-crl/aki-missing"]
|
|
1931
2543
|
*/
|
|
2544
|
+
/**
|
|
2545
|
+
* @primitive pki.lint.csr
|
|
2546
|
+
* @signature pki.lint.csr(pem | der | parsed, opts?) -> LintReport
|
|
2547
|
+
* @since 0.8.16
|
|
2548
|
+
* @status stable
|
|
2549
|
+
* @spec RFC 2986, RFC 2985 sec. 5.4, RFC 5280 sec. 4.1.2.6, CA/Browser Forum TLS BR
|
|
2550
|
+
* @related pki.lint.certificate, pki.csr.verify, pki.schema.csr.parse, pki.csr.sign
|
|
2551
|
+
*
|
|
2552
|
+
* Lint a PKCS#10 certification request, which is the artifact a CA reads before it issues. `input`
|
|
2553
|
+
* is a PEM string, a DER `Buffer`, or an already-parsed `pki.schema.csr` object. It returns the
|
|
2554
|
+
* same `LintReport` shape `pki.lint.certificate` returns, and it never throws on the data path:
|
|
2555
|
+
* bytes that are not a well-formed request produce a single `fatal` finding `lint/unparseable`
|
|
2556
|
+
* carrying the strict parser's own code.
|
|
2557
|
+
*
|
|
2558
|
+
* The rows cover what the request asks for: an empty subject and whether a subjectAltName carries
|
|
2559
|
+
* the identity in its place, the extensions an issuer determines rather than takes from a request,
|
|
2560
|
+
* a request asking for a CA certificate, the digest the request is signed under, and a
|
|
2561
|
+
* challengePassword traveling inside it.
|
|
2562
|
+
*
|
|
2563
|
+
* This does not check the request's signature. That signature is the requester's proof that it
|
|
2564
|
+
* holds the private key, verifying it is a cryptographic operation rather than a profile question,
|
|
2565
|
+
* and `pki.csr.verify` is the verb for it. A CA runs both.
|
|
2566
|
+
*
|
|
2567
|
+
* The CA/Browser Forum rows run only under `opts.profile: "cabf-tls"`. A request carries no
|
|
2568
|
+
* extKeyUsage, so nothing in its bytes says it is for TLS, and the caller states that. Those rows
|
|
2569
|
+
* are the certificate profile's own, applied to what the request asks for, so the answer before
|
|
2570
|
+
* issuance is the answer after it.
|
|
2571
|
+
*
|
|
2572
|
+
* Five shapes never appear here because the strict parser refuses them first and a rule for them
|
|
2573
|
+
* could not fire. A version other than 0 (RFC 2986 sec. 4.1), a challengePassword or
|
|
2574
|
+
* extensionRequest attribute carrying more than one value, an extensionRequest carrying no
|
|
2575
|
+
* extensions (RFC 2985 sec. 5.4), the same extension OID twice inside one, and attributes out of
|
|
2576
|
+
* DER order each arrive as `lint/unparseable` carrying the parser's own code. A SECOND
|
|
2577
|
+
* extensionRequest attribute does parse, where the two sort in DER order, and is reported by
|
|
2578
|
+
* `lint/rfc2986/extension-request-ambiguous`; every one of them is read, so nothing a later
|
|
2579
|
+
* attribute asks for escapes the rows below.
|
|
2580
|
+
*
|
|
2581
|
+
* @opts profile `"rfc2986"` for the structural rows alone, or `"cabf-tls"` to add the TLS rows
|
|
2582
|
+
* (default runs the structural rows). A certificate, CRL or OCSP profile name is
|
|
2583
|
+
* refused rather than run against a request.
|
|
2584
|
+
* @opts severity Suppress findings below this floor (default `"notice"`). `counts` and `worst`
|
|
2585
|
+
* always reflect the complete, unfiltered result.
|
|
2586
|
+
* @example
|
|
2587
|
+
* var pair = await pki.key.generate("Ed25519");
|
|
2588
|
+
* var der = await pki.csr.sign({ subject: [{ commonName: "example.com" }],
|
|
2589
|
+
* subjectPublicKey: await pki.key.export(pair.publicKey) },
|
|
2590
|
+
* { key: await pki.key.export(pair.privateKey) });
|
|
2591
|
+
* var report = pki.lint.csr(der, { profile: "cabf-tls" });
|
|
2592
|
+
* report.findings.map(function (f) { return f.id; }); // -> ["lint/cabf-tls/san-missing"]
|
|
2593
|
+
*/
|
|
2594
|
+
function csr(input, opts) {
|
|
2595
|
+
opts = guard.identifier.optionsObject(opts, _cfg, "lint/bad-input", "pki.lint options");
|
|
2596
|
+
guard.identifier.assertKnownKeys(opts, _CRL_OPTS, _cfg, "lint/bad-input",
|
|
2597
|
+
"pki.lint.csr has an unknown option: ");
|
|
2598
|
+
if (opts.severity != null && VALID_SEVERITY.indexOf(opts.severity) === -1) {
|
|
2599
|
+
throw _cfg("lint/bad-severity", "unknown severity threshold \"" + opts.severity + "\" (known: " + VALID_SEVERITY.join(", ") + ")");
|
|
2600
|
+
}
|
|
2601
|
+
var profile = opts.profile;
|
|
2602
|
+
var rules_;
|
|
2603
|
+
if (profile == null || profile === "all" || profile === "default") rules_ = ALL_CSR_RULES;
|
|
2604
|
+
else if (CSR_PROFILES[profile]) rules_ = CSR_PROFILES[profile];
|
|
2605
|
+
else if (CRL_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a CRL profile; lint a CRL with pki.lint.crl");
|
|
2606
|
+
else if (OCSP_PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is an OCSP profile; lint a response with pki.lint.ocsp");
|
|
2607
|
+
else if (PROFILES[profile]) throw _cfg("lint/unknown-profile", "\"" + profile + "\" is a certificate profile; lint a certificate with pki.lint.certificate");
|
|
2608
|
+
else throw _cfg("lint/unknown-profile", "unknown certification-request lint profile \"" + profile + "\" (known: " + Object.keys(CSR_PROFILES).join(", ") + ")");
|
|
2609
|
+
|
|
2610
|
+
var ingested = _ingestCsr(input);
|
|
2611
|
+
var report;
|
|
2612
|
+
if (ingested.fatal) {
|
|
2613
|
+
report = { findings: [ingested.fatal], ran: [],
|
|
2614
|
+
counts: Object.assign(Object.create(null), { fatal: 1, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }) };
|
|
2615
|
+
} else {
|
|
2616
|
+
report = _mergeCsrReports(_csrViews(ingested.csr).map(function (view) {
|
|
2617
|
+
return _runLints(rules_, view, _makeCsrCtx(view, profile));
|
|
2618
|
+
}));
|
|
2619
|
+
}
|
|
2620
|
+
report.worst = _worst(report.findings);
|
|
2621
|
+
report.findings = _applyThreshold(report, opts.severity);
|
|
2622
|
+
return report;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
1932
2625
|
function crl(input, opts) {
|
|
1933
2626
|
opts = guard.identifier.optionsObject(opts, _cfg, "lint/bad-input", "pki.lint options");
|
|
1934
2627
|
guard.identifier.assertKnownKeys(opts, _CRL_OPTS, _cfg, "lint/bad-input",
|
|
@@ -2074,23 +2767,67 @@ function ocsp(input, opts) {
|
|
|
2074
2767
|
|
|
2075
2768
|
/**
|
|
2076
2769
|
* @primitive pki.lint.rules
|
|
2077
|
-
* @signature pki.lint.rules(profile?) -> [{id, severity, source, citation}]
|
|
2770
|
+
* @signature pki.lint.rules(profile?, artifact?) -> [{id, severity, source, citation}]
|
|
2078
2771
|
* @since 0.2.10
|
|
2079
2772
|
* @status stable
|
|
2080
2773
|
* @spec RFC 5280, CA/Browser Forum TLS BR
|
|
2081
2774
|
*
|
|
2082
2775
|
* Enumerate the rule registry (all rules, or one profile's). Each entry exposes its
|
|
2083
2776
|
* stable `id`, `severity`, `source`, and spec-clause `citation` for documentation and
|
|
2084
|
-
* corpus tooling.
|
|
2777
|
+
* corpus tooling. Each id appears once, including where two artifacts run the same rule.
|
|
2778
|
+
*
|
|
2779
|
+
* `artifact` says which verb's reading of a profile name to return, and is needed because
|
|
2780
|
+
* `"cabf-tls"` names a set for both `pki.lint.certificate` and `pki.lint.csr` and the two are not
|
|
2781
|
+
* the same rows: a request has no extKeyUsage and no validity period, and carries structural rows
|
|
2782
|
+
* a certificate does not. It is `"certificate"`, `"csr"`, `"crl"` or `"ocsp"`, and defaults to the
|
|
2783
|
+
* artifact that first claims the name, which is the certificate for `"cabf-tls"`.
|
|
2784
|
+
*
|
|
2085
2785
|
* @example
|
|
2086
|
-
* pki.lint.rules("rfc5280").length;
|
|
2786
|
+
* pki.lint.rules("rfc5280").length; // -> a positive count
|
|
2787
|
+
* pki.lint.rules("cabf-tls", "csr").length; // -> the rows pki.lint.csr runs under it
|
|
2087
2788
|
*/
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2789
|
+
/** @internal Prototype-less, because `artifact` is the caller's string and a computed lookup on a
|
|
2790
|
+
* literal would answer for "constructor" and the rest of Object.prototype. */
|
|
2791
|
+
var BY_ARTIFACT = Object.assign(Object.create(null), {
|
|
2792
|
+
certificate: PROFILES, csr: CSR_PROFILES, crl: CRL_PROFILES, ocsp: OCSP_PROFILES,
|
|
2793
|
+
});
|
|
2794
|
+
|
|
2795
|
+
function rules(profile, artifact) {
|
|
2796
|
+
if (artifact != null && !Object.prototype.hasOwnProperty.call(BY_ARTIFACT, artifact)) {
|
|
2797
|
+
throw _cfg("lint/bad-input", "unknown lint artifact \"" + artifact +
|
|
2798
|
+
"\" (known: " + Object.keys(BY_ARTIFACT).join(", ") + ")");
|
|
2799
|
+
}
|
|
2800
|
+
var set;
|
|
2801
|
+
if (profile == null && artifact != null) {
|
|
2802
|
+
/** @internal An artifact without a profile asks what that VERB can run, which is the union of
|
|
2803
|
+
* its own profiles. Returning the whole registry would answer with a certificate's validity
|
|
2804
|
+
* rows and the CRL and OCSP sets, none of which a request or a response is ever held to. */
|
|
2805
|
+
set = [];
|
|
2806
|
+
Object.keys(BY_ARTIFACT[artifact]).forEach(function (name) {
|
|
2807
|
+
BY_ARTIFACT[artifact][name].forEach(function (r) { set.push(r); });
|
|
2808
|
+
});
|
|
2809
|
+
} else if (profile == null) {
|
|
2810
|
+
set = ALL_RULES.concat(ALL_CRL_RULES).concat(ALL_OCSP_RULES).concat(ALL_CSR_RULES);
|
|
2811
|
+
} else if (artifact != null) {
|
|
2812
|
+
set = BY_ARTIFACT[artifact][profile];
|
|
2813
|
+
if (!set) {
|
|
2814
|
+
throw _cfg("lint/unknown-profile", "\"" + profile + "\" is not a " + artifact +
|
|
2815
|
+
" lint profile (known: " + Object.keys(BY_ARTIFACT[artifact]).join(", ") + ")");
|
|
2816
|
+
}
|
|
2817
|
+
} else {
|
|
2818
|
+
set = PROFILES[profile] || CRL_PROFILES[profile] || OCSP_PROFILES[profile] || CSR_PROFILES[profile];
|
|
2819
|
+
if (!set) throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\"");
|
|
2820
|
+
}
|
|
2821
|
+
/** @internal One row per id: a rule reused across artifacts, like the RFC 5280 sec. 4.2
|
|
2822
|
+
* extension-syntax row a request and a certificate both run, is one rule with one id, and
|
|
2823
|
+
* tooling that counts or registers from this list would otherwise process it twice. */
|
|
2824
|
+
var seen = Object.create(null), out = [];
|
|
2825
|
+
set.forEach(function (r) {
|
|
2826
|
+
if (seen[r.id]) return;
|
|
2827
|
+
seen[r.id] = true;
|
|
2828
|
+
out.push({ id: r.id, severity: r.severity, source: r.source, citation: r.citation });
|
|
2829
|
+
});
|
|
2830
|
+
return out;
|
|
2094
2831
|
}
|
|
2095
2832
|
|
|
2096
2833
|
/**
|
|
@@ -2104,10 +2841,18 @@ function rules(profile) {
|
|
|
2104
2841
|
* @example
|
|
2105
2842
|
* pki.lint.profiles(); // -> ["rfc5280", "rfc9881", "rfc9909", "rfc9935", "cabf-tls", "rfc5280-crl", "rfc6960", "rfc5019"]
|
|
2106
2843
|
*/
|
|
2107
|
-
|
|
2844
|
+
/** @internal `cabf-tls` names the certificate profile in this list and is not repeated for the
|
|
2845
|
+
* request form: `pki.lint.csr` takes the same name and runs those rows against what a request
|
|
2846
|
+
* asks for, so one name means one set of requirements whichever artifact carries it. */
|
|
2847
|
+
function profiles() {
|
|
2848
|
+
var out = Object.keys(PROFILES).concat(Object.keys(CRL_PROFILES)).concat(Object.keys(OCSP_PROFILES));
|
|
2849
|
+
Object.keys(CSR_PROFILES).forEach(function (name) { if (out.indexOf(name) === -1) out.push(name); });
|
|
2850
|
+
return out;
|
|
2851
|
+
}
|
|
2108
2852
|
|
|
2109
2853
|
module.exports = {
|
|
2110
2854
|
certificate: certificate,
|
|
2855
|
+
csr: csr,
|
|
2111
2856
|
crl: crl,
|
|
2112
2857
|
ocsp: ocsp,
|
|
2113
2858
|
rules: rules,
|
package/lib/oid.js
CHANGED
|
@@ -165,8 +165,19 @@ var FAMILIES = Object.assign(Object.create(null), {
|
|
|
165
165
|
buildConfigURI: 18, buildConfigDigest: 19, buildTrigger: 20,
|
|
166
166
|
runInvocationURI: 21, sourceRepositoryVisibilityAtSigning: 22 } },
|
|
167
167
|
|
|
168
|
+
/** @internal md5WithRSAEncryption and sha1WithRSAEncryption are registered so the toolkit can
|
|
169
|
+
* NAME them, not so it can use them. Nothing signs or verifies from this registry: the signing
|
|
170
|
+
* and verifying paths carry their own name-keyed tables, and neither has a row for these. An
|
|
171
|
+
* unregistered OID reads back as an undefined name, which left a linter unable to say what a
|
|
172
|
+
* request from another implementation was signed under. */
|
|
168
173
|
rsa: { base: [1, 2, 840, 113549, 1, 1], of: {
|
|
169
|
-
|
|
174
|
+
/** @internal PKCS#1's MD4 signature carries the arc-qualified name because the OIW arc already
|
|
175
|
+
* holds the plain `md4WithRSAEncryption` for 1.3.14.3.2.4. The registry is two-way and a name
|
|
176
|
+
* registered twice repoints every lookup of it, so two standards using one name for two OIDs
|
|
177
|
+
* are told apart here rather than silently sharing an entry. */
|
|
178
|
+
rsaEncryption: 1, md2WithRSAEncryption: 2, "md4WithRSAEncryption-pkcs1": 3,
|
|
179
|
+
md5WithRSAEncryption: 4, sha1WithRSAEncryption: 5,
|
|
180
|
+
rsaesOaep: 7, mgf1: 8, pSpecified: 9, rsassaPss: 10,
|
|
170
181
|
sha256WithRSAEncryption: 11,
|
|
171
182
|
sha384WithRSAEncryption: 12, sha512WithRSAEncryption: 13 } },
|
|
172
183
|
|
|
@@ -271,8 +282,14 @@ var FAMILIES = Object.assign(Object.create(null), {
|
|
|
271
282
|
|
|
272
283
|
ansiX962: { base: [1, 2, 840, 10045], of: {
|
|
273
284
|
ecPublicKey: [2, 1], prime192v1: [3, 1, 1], prime256v1: [3, 1, 7],
|
|
285
|
+
ecdsaWithSHA1: [4, 1],
|
|
274
286
|
ecdsaWithSHA256: [4, 3, 2], ecdsaWithSHA384: [4, 3, 3], ecdsaWithSHA512: [4, 3, 4] } },
|
|
275
287
|
|
|
288
|
+
/** @internal X9.57 DSA, registered for naming alone. The toolkit neither signs nor verifies DSA,
|
|
289
|
+
* and dsaWithSha1 is here so a request or certificate carrying it reads back with a name rather
|
|
290
|
+
* than a bare dotted string, which is what a linter needs to say what it refused. */
|
|
291
|
+
x957: { base: [1, 2, 840, 10040, 4], of: { dsa: 1, dsaWithSha1: 3 } },
|
|
292
|
+
|
|
276
293
|
secg: { base: [1, 3, 132, 0], of: {
|
|
277
294
|
sect163k1: 1, sect163r1: 2, secp256k1: 10, sect163r2: 15, sect283k1: 16, sect283r1: 17,
|
|
278
295
|
sect233k1: 26, sect233r1: 27, secp192k1: 31, secp224k1: 32, secp224r1: 33,
|
package/package.json
CHANGED
package/sbom.cdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:1b8d2783-3d1c-49be-ad22-5e029717a170",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-09-
|
|
8
|
+
"timestamp": "2026-09-24T06:15:30.953Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/pki@0.8.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.8.17",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.17",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/pki@0.8.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.8.17",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/pki@0.8.
|
|
57
|
+
"ref": "@blamejs/pki@0.8.17",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|