@blamejs/pki 0.8.17 → 0.8.18
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 +30 -0
- package/README.md +1 -1
- package/lib/lint.js +870 -36
- package/lib/oid.js +17 -1
- package/lib/pki-build.js +76 -1
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ 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.18 — 2026-09-24
|
|
8
|
+
|
|
9
|
+
An S/MIME certificate is linted against the generation it declares itself to be.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `pki.lint.certificate(der, { profile: "cabf-smime" })` runs the S/MIME BR v1.0.15 subscriber profile of section 7.1.2.3. The rows reach a certificate unprompted too, because one that asserts a reserved policy identifier has declared itself to be one; a certificate asserting none is left alone. A certificate issued to a subordinate CA carries a reserved identifier as well under section 7.1.6.3, so it is not read as a subscriber; naming the profile in the call still lints it as one.
|
|
14
|
+
- certificatePolicies (a): the extension is required, should not be critical, and must include exactly one reserved policy identifier, which is also what leaves the generation unambiguous. An `id-qt-cps` qualifier must be a HTTP or HTTPS URL, where the URI scheme tables elsewhere in the section name HTTP alone. This clause asks for a URL rather than a scheme, so a bare `http://` naming no host is reported. An `id-qt-unotice` qualifier must carry `explicitText` and must not carry a `noticeRef`. A certificate issued under a Legacy generation profile on or after 15 July 2025 is reported, gated to the issuance date it states.
|
|
15
|
+
- cRLDistributionPoints (b): the extension is required and should not be critical, and the URI scheme rule follows the generation. Strict and Multipurpose require every `uniformResourceIdentifier` to be HTTP; Legacy requires at least one and permits others beside it. HTTP means HTTP: the profile writes "a HTTP or HTTPS URL" where both are meant and "the URI scheme HTTP" here, so an HTTPS distribution point does not satisfy it. Having that scheme means being a URI of it, which RFC 7230 section 2.7.1 writes with an authority and no empty host, so `http:crl.example/a.crl` and `http://:80/a.crl` do not satisfy it either. An extension carrying no `uniformResourceIdentifier` at all is reported. A distribution point without one is not, where another point in the same extension carries a conforming URI.
|
|
16
|
+
- authorityInformationAccess (c): the extension should be present and must not be critical, and it should carry an `id-ad-caIssuers` accessMethod. `id-ad-ocsp` and `id-ad-caIssuers` get a table each, unlike the distribution points, whose single table covers the whole extension. Strict and Multipurpose require every URI of a method to be HTTP; Legacy requires at least one of that method's own, so an HTTP OCSP URL does not answer for a caIssuers list that carries only an LDAP one. An accessLocation that is not a `uniformResourceIdentifier` carries no scheme, so it is reported rather than passed over. The profile asks for this extension at SHOULD where the TLS profile asks at SHALL, so the two report the same absence at different strengths.
|
|
17
|
+
- basicConstraints (d) must not assert `cA` and must not carry a `pathLenConstraint`.
|
|
18
|
+
- keyUsage (e): the extension is required, should be critical, and a bit outside the cell for the certificate's own key algorithm is reported. The cells differ, so `keyEncipherment` is conformant on an RSA key and reported on an elliptic-curve one, whose cell gives `keyAgreement` instead, and an ML-KEM key permits `keyEncipherment` alone. The RSA cell also differs by generation: Multipurpose and Legacy admit `dataEncipherment` where Strict does not. A bit set past the nine RFC 5280 named positions is outside every cell and is reported for every key algorithm. A key algorithm the section gives no cell for is held to none.
|
|
19
|
+
- keyUsage (e) is read as the usage modes the cell states, not as a flat set of permitted bits. A conforming certificate asserts every bit of one mode and no bit outside that same mode, so bits the cell names can still combine into nothing it allows: `nonRepudiation` alone matches no mode, and on RSA `digitalSignature` with `dataEncipherment` matches none either, because `dataEncipherment` belongs to the key-management modes and those require `keyEncipherment`. On an elliptic curve `encipherOnly` and `decipherOnly` appear only in the modes that require `keyAgreement`, which is how the cell states "only if keyAgreement is set". A bit the cell names nowhere is reported by the row above this one, so one violation draws one finding.
|
|
20
|
+
- extKeyUsage (f): `id-kp-emailProtection` is required, and `id-kp-serverAuth`, `id-kp-codeSigning`, `id-kp-timeStamping` and `anyExtendedKeyUsage` are forbidden at every generation. Under Strict alone, any other purpose is forbidden too.
|
|
21
|
+
- authorityKeyIdentifier (g): the extension is required and must not be critical, its `keyIdentifier` field must be present, and `authorityCertIssuer` and `authorityCertSerialNumber` must not be. An extension naming only an issuer and serial satisfies the presence rule and is reported by the field rules.
|
|
22
|
+
- subjectAltName (h and 7.1.4.2.1): the extension is required, must carry at least one `rfc822Name` or an `otherName` of type id-on-SmtpUTF8Mailbox, and should not be critical unless the subject is an empty sequence, which is the one condition that lifts it. An otherName is read for its type identifier, since every otherName carries the same tag and only the type tells a mailbox from anything else.
|
|
23
|
+
- The optional extensions and the tables that admit them: smimeCapabilities (i), qcStatements (k) and subjectKeyIdentifier (n) must not be critical, and a subjectKeyIdentifier should be present. subjectDirectoryAttributes (j) is prohibited under Strict and Multipurpose and must not be critical under Legacy. The Adobe timestamp and ArchiveRevInfo extensions (m) are prohibited under Strict and must not be critical elsewhere. The Legal Entity Identifier extensions (l) are keyed on the certificate type rather than the generation: both are prohibited on a Mailbox-validated or Individual-validated certificate, the role identifier is prohibited on an Organization-validated one, and neither may be critical where it is admitted.
|
|
24
|
+
- A cell that reads "Prohibited" carries no criticality sentence, so a prohibited extension that is also critical draws the prohibition alone. A certificate that declares no generation names no cell, and every table the section keys on the generation reads it against Strict, the narrowest of the three.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- The twelve reserved S/MIME policy identifiers of section 7.1.6.1 resolve through `pki.oid`, named for the certificate type and generation each one declares, so an S/MIME certificate reads back with the profile it asserts rather than a bare dotted string. The Legal Entity Identifier and Adobe extension identifiers the profile reads resolve there too.
|
|
29
|
+
- The fuzz harness for `pki.lint.certificate` drives every profile a caller can name as well as the default rule set, and `pki.lint.csr` has a harness of its own. Both hold the same contract: hostile bytes return a report carrying a `lint/unparseable` finding, never a throw.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `pki.lint.certificate`, `csr`, `crl` and `ocsp` take any BufferSource, as every parser in the toolkit already did. They took a `Buffer` alone, so bytes read over the network as a `Uint8Array`, or handed over as an `ArrayBuffer`, raised `lint/bad-input` from the one surface whose data path is documented never to throw. The bytes are snapshotted at the door, so a caller cannot neuter the backing buffer between the check and the read, and a source whose buffer was already detached returns a `fatal` finding like any other unreadable input rather than aborting a scan. A value that is neither bytes, a PEM string nor a parsed object still raises `lint/bad-input`, which is caller error rather than a verdict on a certificate.
|
|
34
|
+
- `pki.lint.rules("all")` and `pki.lint.rules("default")` return every row rather than throwing `lint/unknown-profile`. The lint verbs read both names as naming no profile, so a caller who linted under one and then asked which rows had run was refused the answer by the registry that ran them.
|
|
35
|
+
- Naming a profile no longer reads an extension whose value does not decode as one that is absent. Every row asks for extensions by name and the shared decoder answers null for both cases, so `pki.lint.certificate(der, { profile: "cabf-tls" })` returned a clean report for a certificate whose basicConstraints, keyUsage or subjectAltName was malformed, and so did the RFC 9881, RFC 9909 and RFC 9935 profiles. The RFC 5280 row that reports the encoding runs under every named certificate profile now, as it already did for `pki.lint.csr`. Callers who pass no profile were never affected.
|
|
36
|
+
|
|
7
37
|
## v0.8.17 — 2026-09-24
|
|
8
38
|
|
|
9
39
|
The CA/Browser Forum subscriber certificate profile is linted against its own tables, not a subset of them.
|
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 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` |
|
|
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 CA/Browser Forum S/MIME BR subscriber profile of §7.1.2.3 (a) through (n) as `cabf-smime`, whose requirements follow the generation a certificate declares through its reserved policy identifier (the distribution-point and accessMethod URI scheme rules, the policy qualifier rules for an id-qt-cps URL and an id-qt-unotice explicitText and noticeRef, the key-usage table for the certificate's own key algorithm including a bit set past the nine named positions, the extended key purposes forbidden at every generation and the ones Strict alone forbids, the authorityKeyIdentifier field rules, a required mailbox in the subjectAltName as an rfc822Name or an id-on-SmtpUTF8Mailbox otherName, the criticality each extension must carry, the subjectDirectoryAttributes and Adobe extensions the Strict generation prohibits, the Legal Entity Identifier extensions keyed on the certificate type rather than the generation, and the Legacy generation retired on 15 July 2025), 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
|
@@ -37,6 +37,7 @@ var crlSchema = require("./schema-crl");
|
|
|
37
37
|
var csrSchema = require("./schema-csr");
|
|
38
38
|
var ocspSchema = require("./schema-ocsp");
|
|
39
39
|
var pkix = require("./schema-pkix");
|
|
40
|
+
var pkiBuild = require("./pki-build");
|
|
40
41
|
var schema = require("./schema-engine");
|
|
41
42
|
var path = require("./path-validate");
|
|
42
43
|
var C = require("./constants");
|
|
@@ -84,11 +85,32 @@ function _derivedCert(o) {
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/** @internal The bytes a lint verb was handed, as a Buffer, or null where the input is not bytes
|
|
89
|
+
* at all and the caller's own branches answer for it. Every parser in this toolkit reads any
|
|
90
|
+
* BufferSource, so the one surface that promises never to throw on the data path does not turn
|
|
91
|
+
* away a caller holding a Uint8Array from the network or an ArrayBuffer from a reader. Taking a
|
|
92
|
+
* snapshot rather than the caller's view is also what closes the detached-backing-buffer hazard,
|
|
93
|
+
* since a caller can neuter the buffer between the check and the read. */
|
|
94
|
+
function _derBytesOf(input) {
|
|
95
|
+
if (!guard.bytes.isByteSource(input)) return null;
|
|
96
|
+
try {
|
|
97
|
+
return { der: guard.bytes.snapshotSource(input, LintError, "lint/bad-input", "pki.lint input") };
|
|
98
|
+
} catch (e) {
|
|
99
|
+
/** @internal A byte source whose backing buffer was detached is still a byte source, and it
|
|
100
|
+
* fails at the door rather than at the parse. The data path answers with a verdict either
|
|
101
|
+
* way, or one neutered buffer aborts the scan of a whole corpus. */
|
|
102
|
+
return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint",
|
|
103
|
+
message: "input is a byte source whose bytes could not be read", context: { code: e.code } } };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
function _ingest(input) {
|
|
88
108
|
var derived = _derivedCert(input);
|
|
89
109
|
if (derived) return { cert: derived };
|
|
90
110
|
var der;
|
|
91
|
-
|
|
111
|
+
var bytes = _derBytesOf(input);
|
|
112
|
+
if (bytes !== null && bytes.fatal) return { fatal: bytes.fatal };
|
|
113
|
+
if (bytes !== null) der = bytes.der;
|
|
92
114
|
else if (typeof input === "string") {
|
|
93
115
|
try { der = x509.pemDecode(input, "CERTIFICATE"); }
|
|
94
116
|
catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER certificate", context: { code: e.code } } }; }
|
|
@@ -128,6 +150,11 @@ function _makeCtx(cert, profile) {
|
|
|
128
150
|
return {
|
|
129
151
|
profile: profile,
|
|
130
152
|
explicitTlsProfile: explicitTls,
|
|
153
|
+
/** @internal An S/MIME subscriber certificate names its exact profile through a reserved
|
|
154
|
+
* policy identifier, so unlike the TLS case the rows detect it from the certificate itself and
|
|
155
|
+
* the flag only records that the caller also named it. */
|
|
156
|
+
explicitSmimeProfile: profile === "cabf-smime",
|
|
157
|
+
isCaCert: isCa,
|
|
131
158
|
isTlsServerCert: explicitTls || (_hasEku(cert, "serverAuth") && !isCa),
|
|
132
159
|
decode: function (name) { return _decodeOrNull(cert, name); },
|
|
133
160
|
raw: function (name) { return _findRaw(cert, name); },
|
|
@@ -257,23 +284,40 @@ function _hasControlChar(str) {
|
|
|
257
284
|
}
|
|
258
285
|
return false;
|
|
259
286
|
}
|
|
260
|
-
|
|
287
|
+
/** @internal Every policy qualifier of every PolicyInformation, handed to `fn` as its qualifier
|
|
288
|
+
* OID, its value node, and the PolicyInformation carrying it. The decoder surfaces the qualifiers
|
|
289
|
+
* as raw DER, because a qualifier may carry any type and the reader does not choose one for the
|
|
290
|
+
* caller, so every walk re-decodes bytes that already passed the policy-qualifier door. */
|
|
291
|
+
function _eachPolicyQualifier(ctx, fn) {
|
|
261
292
|
var d = ctx.decode("certificatePolicies");
|
|
262
|
-
if (!d || !Array.isArray(d.value)) return
|
|
263
|
-
var out = [];
|
|
293
|
+
if (!d || !Array.isArray(d.value)) return;
|
|
264
294
|
d.value.forEach(function (pi) {
|
|
265
295
|
if (!pi.qualifiersBytes || !pi.qualifiersBytes.length) return;
|
|
266
296
|
var quals;
|
|
267
297
|
// allow:swallow-unverified re-decoding bytes that already decoded under assertPolicyQualifiers cannot throw
|
|
268
|
-
try {
|
|
298
|
+
try {
|
|
299
|
+
quals = asn1.decode(pi.qualifiersBytes).children;
|
|
300
|
+
} catch (_e) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
269
303
|
(quals || []).forEach(function (pq) {
|
|
270
304
|
var qid;
|
|
271
305
|
// allow:swallow-unverified assertPolicyQualifiers already read this OID, so re-reading it cannot throw
|
|
272
|
-
try {
|
|
273
|
-
|
|
274
|
-
|
|
306
|
+
try {
|
|
307
|
+
qid = asn1.read.oid(pq.children[0]);
|
|
308
|
+
} catch (_e2) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
fn(qid, pq.children[1], pi);
|
|
275
312
|
});
|
|
276
313
|
});
|
|
314
|
+
}
|
|
315
|
+
function _policyDisplayTexts(ctx) {
|
|
316
|
+
var out = [];
|
|
317
|
+
_eachPolicyQualifier(ctx, function (qid, value) {
|
|
318
|
+
if (qid !== OID_UNOTICE) return;
|
|
319
|
+
out = out.concat(pkix.userNoticeTexts(value));
|
|
320
|
+
});
|
|
277
321
|
return out;
|
|
278
322
|
}
|
|
279
323
|
function _hasPolicyDisplayText(cert, ctx) { return _policyDisplayTexts(ctx).length > 0; }
|
|
@@ -600,25 +644,801 @@ function _subjectIsEmpty(cert) {
|
|
|
600
644
|
return !(Array.isArray(rdns) && rdns.length);
|
|
601
645
|
}
|
|
602
646
|
|
|
603
|
-
/** @internal Every policy qualifier of every PolicyInformation, as its qualifier OID.
|
|
604
|
-
* are re-decoded the way the display-text walk above re-decodes them. */
|
|
647
|
+
/** @internal Every policy qualifier of every PolicyInformation, as its qualifier OID. */
|
|
605
648
|
function _policyQualifierIds(ctx) {
|
|
649
|
+
var out = [];
|
|
650
|
+
_eachPolicyQualifier(ctx, function (qid) { out.push(qid); });
|
|
651
|
+
return out;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** @internal The S/MIME BR sec. 7.1.6.1 reserved policy identifiers, keyed by OID to the
|
|
655
|
+
* generation that OID declares. A subscriber certificate asserts exactly one, so reading it is
|
|
656
|
+
* how this profile learns which generation's table to hold the certificate to, and the presence
|
|
657
|
+
* of one is also what says the certificate is one of these at all. */
|
|
658
|
+
var SMIME_GENERATION_BY_POLICY = Object.create(null);
|
|
659
|
+
/** @internal The same twelve identifiers keyed to the certificate TYPE they declare. The tables of
|
|
660
|
+
* sec. 7.1.2.3 (l) are keyed on the type where the rest of the section is keyed on the
|
|
661
|
+
* generation, so both directions of the matrix are read. */
|
|
662
|
+
var SMIME_TYPE_BY_POLICY = Object.create(null);
|
|
663
|
+
["mailbox", "organization", "sponsor", "individual"].forEach(function (type) {
|
|
664
|
+
["legacy", "multipurpose", "strict"].forEach(function (gen) {
|
|
665
|
+
var d = oid.byName("smime-" + type + "-" + gen);
|
|
666
|
+
if (d) { SMIME_GENERATION_BY_POLICY[d] = gen; SMIME_TYPE_BY_POLICY[d] = type; }
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
/** @internal The keyUsage tables of sec. 7.1.2.3 (e). Each cell states its usage MODES (signing
|
|
671
|
+
* only, key management only, dual use), and each mode fixes bits with SHALL and admits others
|
|
672
|
+
* with MAY, so a conforming certificate asserts every bit of some one mode and no bit outside
|
|
673
|
+
* that same mode. Flattening a cell into one permitted set loses that: on RSA, digitalSignature
|
|
674
|
+
* with dataEncipherment sets only bits the cell names somewhere and still matches no mode,
|
|
675
|
+
* because dataEncipherment belongs to the key-management modes and those require keyEncipherment.
|
|
676
|
+
* The RSA cell is the one that also differs by GENERATION, Multipurpose and Legacy admitting
|
|
677
|
+
* dataEncipherment where Strict does not. The elliptic-curve cell names encipherOnly and
|
|
678
|
+
* decipherOnly only in the modes that require keyAgreement, which is how it states "only if
|
|
679
|
+
* keyAgreement is set". A certificate declaring no generation is held to the Strict cell, the
|
|
680
|
+
* narrowest of the three. */
|
|
681
|
+
function _smimeKuModes(kind, gen) {
|
|
682
|
+
var relaxed = gen === "multipurpose" || gen === "legacy";
|
|
683
|
+
if (kind === "rsa") {
|
|
684
|
+
return [
|
|
685
|
+
{ required: ["digitalSignature"], optional: ["nonRepudiation"] },
|
|
686
|
+
{ required: ["keyEncipherment"], optional: relaxed ? ["dataEncipherment"] : [] },
|
|
687
|
+
{ required: ["digitalSignature", "keyEncipherment"],
|
|
688
|
+
optional: relaxed ? ["nonRepudiation", "dataEncipherment"] : ["nonRepudiation"] },
|
|
689
|
+
];
|
|
690
|
+
}
|
|
691
|
+
if (kind === "ec") {
|
|
692
|
+
return [
|
|
693
|
+
{ required: ["digitalSignature"], optional: ["nonRepudiation"] },
|
|
694
|
+
{ required: ["keyAgreement"], optional: ["encipherOnly", "decipherOnly"] },
|
|
695
|
+
{ required: ["digitalSignature", "keyAgreement"],
|
|
696
|
+
optional: ["nonRepudiation", "encipherOnly", "decipherOnly"] },
|
|
697
|
+
];
|
|
698
|
+
}
|
|
699
|
+
if (kind === "eddsa" || kind === "mldsa") {
|
|
700
|
+
return [{ required: ["digitalSignature"], optional: ["nonRepudiation"] }];
|
|
701
|
+
}
|
|
702
|
+
if (kind === "mlkem") return [{ required: ["keyEncipherment"], optional: [] }];
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
/** @internal Every bit any mode of the cell names, which is the complement of "Other bit positions
|
|
706
|
+
* SHALL NOT be set". */
|
|
707
|
+
function _smimeKuNamedAnywhere(modes) {
|
|
708
|
+
var out = [];
|
|
709
|
+
modes.forEach(function (m) {
|
|
710
|
+
m.required.concat(m.optional).forEach(function (bit) {
|
|
711
|
+
if (out.indexOf(bit) === -1) out.push(bit);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
function _smimeKuAsserted(value) {
|
|
717
|
+
var out = [];
|
|
718
|
+
KU_BIT_NAMES.forEach(function (bit) { if (value[bit] === true) out.push(bit); });
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
/** @internal Whether the asserted bits are exactly some mode: every bit that mode requires, and no
|
|
722
|
+
* bit it does not name. */
|
|
723
|
+
function _smimeKuMatchesAMode(modes, asserted) {
|
|
724
|
+
for (var i = 0; i < modes.length; i++) {
|
|
725
|
+
var allowed = modes[i].required.concat(modes[i].optional);
|
|
726
|
+
var ok = true;
|
|
727
|
+
modes[i].required.forEach(function (bit) { if (asserted.indexOf(bit) === -1) ok = false; });
|
|
728
|
+
asserted.forEach(function (bit) { if (allowed.indexOf(bit) === -1) ok = false; });
|
|
729
|
+
if (ok) return true;
|
|
730
|
+
}
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** @internal Sec. 7.1.2.3 (c) gives a URI scheme table for each of these two accessMethods and
|
|
735
|
+
* none for any other, so a scheme is measured on these and an unrecognized accessMethod has no
|
|
736
|
+
* table to be held to. */
|
|
737
|
+
var SMIME_AIA_METHODS = ["ocsp", "caIssuers"].map(function (n) { return oid.byName(n); });
|
|
738
|
+
|
|
739
|
+
/** @internal Sec. 7.1.2.3 (l): the Legal Entity Identifier table is keyed on the certificate type.
|
|
740
|
+
* The identifier is prohibited outright for two types; for Organization-validated the role
|
|
741
|
+
* identifier alone is prohibited, and Sponsor-validated admits both. */
|
|
742
|
+
var SMIME_LEI_EXTS = Object.assign(Object.create(null), { identifier: "lei-identifier", role: "lei-role" });
|
|
743
|
+
/** @internal Whether the cell for a certificate type ADMITS one of the two extensions. A cell that
|
|
744
|
+
* reads "Prohibited" carries no criticality sentence, so the criticality row is asked only where
|
|
745
|
+
* this is true and a prohibited extension breaks one clause rather than two. A certificate
|
|
746
|
+
* declaring no type names no cell and is held to neither row. */
|
|
747
|
+
function _smimeLeiAdmits(type, which) {
|
|
748
|
+
if (type === "sponsor") return true;
|
|
749
|
+
if (type === "organization") return which === "identifier";
|
|
750
|
+
return false;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** @internal Sec. 7.1.2.3 (m): both Adobe extensions the table names, prohibited under Strict. */
|
|
754
|
+
var SMIME_ADOBE_EXTS = ["adobe-timestamp", "adobe-archive-rev-info"];
|
|
755
|
+
|
|
756
|
+
/** @internal Sec. 7.1.2.3 (f): these four SHALL NOT be present at any generation, where any other
|
|
757
|
+
* purpose is forbidden under Strict alone. */
|
|
758
|
+
var SMIME_FORBIDDEN_EKU = ["serverAuth", "codeSigning", "timeStamping", "anyExtendedKeyUsage"]
|
|
759
|
+
.map(function (n) { var d = oid.byName(n); return d ? { name: n, oid: d } : null; })
|
|
760
|
+
.filter(function (e) { return !!e; });
|
|
761
|
+
|
|
762
|
+
/** @internal "Effective July 15, 2025 S/MIME Subscriber Certificates SHALL NOT be issued using the
|
|
763
|
+
* Legacy Generation profiles" (sec. 7.1.6.1). Dated, so a certificate issued before it is not
|
|
764
|
+
* held to a rule that did not yet apply. */
|
|
765
|
+
var SMIME_LEGACY_RETIRED = new Date("2025-07-15T00:00:00Z");
|
|
766
|
+
|
|
767
|
+
function _smimeGeneration(ctx) {
|
|
606
768
|
var d = ctx.decode("certificatePolicies");
|
|
769
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
770
|
+
var found = null;
|
|
771
|
+
d.value.forEach(function (pi) {
|
|
772
|
+
var gen = SMIME_GENERATION_BY_POLICY[pi.policyIdentifier];
|
|
773
|
+
if (gen && found === null) found = gen;
|
|
774
|
+
});
|
|
775
|
+
return found;
|
|
776
|
+
}
|
|
777
|
+
/** @internal A reserved policy identifier alone does not make a certificate a SUBSCRIBER one: sec.
|
|
778
|
+
* 7.1.6.3 says a certificate issued to a Subordinate CA "SHALL include one or more explicit policy
|
|
779
|
+
* identifiers defined in Section 7.1.6.1", so an intermediate carries them too. Detection
|
|
780
|
+
* therefore asks for the identifier AND that the certificate is not a CA, or these subscriber-only
|
|
781
|
+
* rows would report an intermediate for asserting cA and keyCertSign and naming no mailbox.
|
|
782
|
+
* Naming the profile still overrides, which is how a malformed subscriber is checked. */
|
|
783
|
+
function _isSmime(cert, ctx) {
|
|
784
|
+
if (ctx.explicitSmimeProfile) return true;
|
|
785
|
+
return _smimeGeneration(ctx) !== null && !ctx.isCaCert;
|
|
786
|
+
}
|
|
787
|
+
function _smimeStrict(cert, ctx) { return _isSmime(cert, ctx) && _smimeGenerationOrStrict(ctx) === "strict"; }
|
|
788
|
+
/** @internal The cell every table in sec. 7.1.2.3 is read against. A certificate declaring no
|
|
789
|
+
* generation names no cell, and is held to Strict, the narrowest of the three. */
|
|
790
|
+
function _smimeGenerationOrStrict(ctx) {
|
|
791
|
+
var gen = _smimeGeneration(ctx);
|
|
792
|
+
return gen === null ? "strict" : gen;
|
|
793
|
+
}
|
|
794
|
+
/** @internal The certificate type the asserted reserved identifier declares, for the sec.
|
|
795
|
+
* 7.1.2.3 (l) tables. Null where none is asserted, which is every certificate the caller named
|
|
796
|
+
* the profile for rather than one that declared itself. */
|
|
797
|
+
function _smimeType(ctx) {
|
|
798
|
+
var d = ctx.decode("certificatePolicies");
|
|
799
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
800
|
+
var found = null;
|
|
801
|
+
for (var i = 0; i < d.value.length; i++) {
|
|
802
|
+
var t = SMIME_TYPE_BY_POLICY[d.value[i].policyIdentifier];
|
|
803
|
+
if (t && found === null) found = t;
|
|
804
|
+
}
|
|
805
|
+
return found;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** @internal Which sec. 7.1.2.3 (e) table applies, from the subject key's own algorithm. A key
|
|
809
|
+
* outside them has no table to be held to. */
|
|
810
|
+
function _smimeKeyKind(cert) {
|
|
811
|
+
var spki = cert.subjectPublicKeyInfo;
|
|
812
|
+
var name = spki && spki.algorithm && spki.algorithm.name;
|
|
813
|
+
if (RSA_KEY_ALGS[name]) return "rsa";
|
|
814
|
+
if (name === "ecPublicKey") return "ec";
|
|
815
|
+
if (name === "Ed25519" || name === "Ed448") return "eddsa";
|
|
816
|
+
if (typeof name === "string" && name.indexOf("ml-dsa") !== -1) return "mldsa";
|
|
817
|
+
if (typeof name === "string" && name.indexOf("ml-kem") !== -1) return "mlkem";
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** @internal The URI of a GeneralName carried as raw DER, or null where it is not one. The
|
|
822
|
+
* cRLDistributionPoints decoder surfaces each fullName entry as bytes, since a distribution point
|
|
823
|
+
* may name any GeneralName type and the reader does not choose one for the caller. */
|
|
824
|
+
function _generalNameUri(der) {
|
|
825
|
+
var n;
|
|
826
|
+
// allow:swallow-unverified an entry that does not decode is answered by the extension-syntax row
|
|
827
|
+
try { n = asn1.decode(der); } catch (_e) { return null; }
|
|
828
|
+
if (!n || n.tagClass !== "context" || n.tagNumber !== 6) return null;
|
|
829
|
+
return n.content.toString("latin1");
|
|
830
|
+
}
|
|
831
|
+
/** @internal HTTP, not HTTPS. The S/MIME BR distinguishes the two by wording: the policy qualifier
|
|
832
|
+
* of sec. 7.1.2.3 (a) "SHALL be a HTTP or HTTPS URL", where the distribution point and access
|
|
833
|
+
* method tables say "SHALL have the URI scheme HTTP" and "Other schemes SHALL NOT be present".
|
|
834
|
+
* HTTPS is another scheme, and a revocation list fetched over TLS is a circular dependency.
|
|
835
|
+
* Having that scheme means being a URI of it, so both readings go through the one place that
|
|
836
|
+
* decides what an http or https URL is and differ only in which answer they accept. */
|
|
837
|
+
function _isHttpUri(u) {
|
|
838
|
+
return pkiBuild.httpUrlScheme(u) === "http";
|
|
839
|
+
}
|
|
840
|
+
/** @internal The policy-qualifier clause of sec. 7.1.2.3 (a) names both schemes, where the URI
|
|
841
|
+
* scheme tables name HTTP alone. It also asks for a URL rather than a scheme, so a bare
|
|
842
|
+
* "http://" naming no host does not satisfy it. The shape is the one a reader holds a URL it was
|
|
843
|
+
* given to, not the narrower one the builders hold what they emit to. */
|
|
844
|
+
function _isHttpOrHttpsUrl(u) {
|
|
845
|
+
return pkiBuild.httpUrlScheme(u) !== null;
|
|
846
|
+
}
|
|
847
|
+
/** @internal The accessLocation URIs of sec. 7.1.2.3 (c), grouped BY accessMethod. The clause
|
|
848
|
+
* gives id-ad-ocsp and id-ad-caIssuers a table each, where clause (b) gives the whole extension
|
|
849
|
+
* one, so each method's URIs answer that method's table alone and an accessMethod with no table
|
|
850
|
+
* is not measured. An accessLocation of another GeneralName type carries no URI to measure a
|
|
851
|
+
* scheme on. */
|
|
852
|
+
function _smimeAiaLocationsByMethod(ctx) {
|
|
853
|
+
var d = ctx.decode("authorityInfoAccess");
|
|
854
|
+
var out = Object.create(null);
|
|
855
|
+
if (!d || !Array.isArray(d.value)) return out;
|
|
856
|
+
for (var i = 0; i < d.value.length; i++) {
|
|
857
|
+
var ad = d.value[i];
|
|
858
|
+
if (SMIME_AIA_METHODS.indexOf(ad.accessMethod) === -1) continue;
|
|
859
|
+
if (!out[ad.accessMethod]) out[ad.accessMethod] = [];
|
|
860
|
+
/** @internal A location that is not a uniformResourceIdentifier carries no scheme, so it is
|
|
861
|
+
* kept as null rather than dropped: the method is still provided and still has no HTTP URI. */
|
|
862
|
+
var loc = ad.accessLocation;
|
|
863
|
+
out[ad.accessMethod].push(loc && loc.tag === 6 && typeof loc.value === "string" ? loc.value : null);
|
|
864
|
+
}
|
|
865
|
+
return out;
|
|
866
|
+
}
|
|
867
|
+
function _crldpUris(ctx) {
|
|
868
|
+
var d = ctx.decode("cRLDistributionPoints");
|
|
607
869
|
if (!d || !Array.isArray(d.value)) return [];
|
|
608
870
|
var out = [];
|
|
609
|
-
d.value.forEach(function (
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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; }
|
|
871
|
+
d.value.forEach(function (dp) {
|
|
872
|
+
var point = dp.distributionPoint;
|
|
873
|
+
if (!point || point.kind !== "fullName" || !Array.isArray(point.names)) return;
|
|
874
|
+
point.names.forEach(function (raw) {
|
|
875
|
+
var u = _generalNameUri(raw);
|
|
876
|
+
if (u !== null) out.push(u);
|
|
617
877
|
});
|
|
618
878
|
});
|
|
619
879
|
return out;
|
|
620
880
|
}
|
|
621
881
|
|
|
882
|
+
var SMIME_OID_SMTP_UTF8 = oid.byName("smtpUtf8Mailbox");
|
|
883
|
+
|
|
884
|
+
/** @internal The nine named keyUsage bits of RFC 5280 sec. 4.2.1.3, in their bit order. */
|
|
885
|
+
var KU_BIT_NAMES = ["digitalSignature", "nonRepudiation", "keyEncipherment", "dataEncipherment",
|
|
886
|
+
"keyAgreement", "keyCertSign", "cRLSign", "encipherOnly", "decipherOnly"];
|
|
887
|
+
|
|
888
|
+
/** @internal The type OID of an otherName GeneralName. Every otherName is a [0], so the type is
|
|
889
|
+
* the only thing telling one apart from another, and a decoded entry carries it on `value` where
|
|
890
|
+
* the reader recognized the form and as bytes where it did not. */
|
|
891
|
+
function _otherNameTypeOf(gn) {
|
|
892
|
+
if (gn && gn.value && typeof gn.value.typeId === "string") return gn.value.typeId;
|
|
893
|
+
if (gn && gn.value && typeof gn.value.type === "string") return gn.value.type;
|
|
894
|
+
var src = gn && (gn.bytes || gn.value);
|
|
895
|
+
if (!Buffer.isBuffer(src)) return null;
|
|
896
|
+
var n;
|
|
897
|
+
// allow:swallow-unverified an entry that does not decode is answered by the extension-syntax row
|
|
898
|
+
try { n = asn1.decode(src); } catch (_e) { return null; }
|
|
899
|
+
if (!n || !n.children || !n.children.length) return null;
|
|
900
|
+
// allow:swallow-unverified a first child that is not an OID is not an otherName type
|
|
901
|
+
try { return asn1.read.oid(n.children[0]); } catch (_e2) { return null; }
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
var CABF_SMIME_RULES = [
|
|
905
|
+
{
|
|
906
|
+
id: "lint/cabf-smime/policies-critical", severity: "warn", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (a)",
|
|
907
|
+
/** @internal SHOULD NOT, so a warning rather than an error: the clause states a preference and
|
|
908
|
+
* the certificate is still conformant against the MUSTs around it. */
|
|
909
|
+
message: "certificatePolicies should not be marked critical",
|
|
910
|
+
appliesTo: _isSmime,
|
|
911
|
+
check: function (cert, ctx) { var e = ctx.raw("certificatePolicies"); return (e && e.critical === true) ? true : null; },
|
|
912
|
+
},
|
|
913
|
+
{
|
|
914
|
+
id: "lint/cabf-smime/policies-missing", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (a)",
|
|
915
|
+
/** @internal Reachable only where the caller named the profile: without the extension there is
|
|
916
|
+
* no reserved identifier, and the reserved identifier is what otherwise says the certificate
|
|
917
|
+
* is one of these. Naming the profile is the caller supplying that statement. */
|
|
918
|
+
message: "a subscriber certificate must carry a certificatePolicies extension",
|
|
919
|
+
appliesTo: _isSmime,
|
|
920
|
+
check: function (cert, ctx) { return ctx.raw("certificatePolicies") ? null : true; },
|
|
921
|
+
},
|
|
922
|
+
{
|
|
923
|
+
id: "lint/cabf-smime/policies-reserved-identifier-count", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (a)",
|
|
924
|
+
/** @internal "It SHALL include exactly one of the reserved policyIdentifiers", so two is as
|
|
925
|
+
* much a fault as none and the count says which. A certificate's own generation is read from
|
|
926
|
+
* that identifier, so two of them also leaves the generation ambiguous. */
|
|
927
|
+
message: "certificatePolicies must include exactly one reserved policy identifier",
|
|
928
|
+
appliesTo: _isSmime,
|
|
929
|
+
check: function (cert, ctx) {
|
|
930
|
+
/** @internal An absent extension is the presence row's answer, not this one's, so it is
|
|
931
|
+
* skipped here and counted where it is present but names no reserved identifier. */
|
|
932
|
+
if (!ctx.raw("certificatePolicies")) return null;
|
|
933
|
+
var d = ctx.decode("certificatePolicies");
|
|
934
|
+
var list = (d && Array.isArray(d.value)) ? d.value : [];
|
|
935
|
+
var found = list.filter(function (pi) { return !!SMIME_GENERATION_BY_POLICY[pi.policyIdentifier]; });
|
|
936
|
+
return found.length === 1 ? null : { context: { count: found.length } };
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
{
|
|
940
|
+
id: "lint/cabf-smime/legacy-generation-retired", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.6.1",
|
|
941
|
+
message: "a subscriber certificate must not be issued under a Legacy generation profile",
|
|
942
|
+
appliesTo: _isSmime,
|
|
943
|
+
effectiveDate: SMIME_LEGACY_RETIRED,
|
|
944
|
+
check: function (cert, ctx) {
|
|
945
|
+
return _smimeGeneration(ctx) === "legacy" ? true : null;
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
id: "lint/cabf-smime/crldp-missing", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (b)",
|
|
950
|
+
message: "a subscriber certificate must carry a cRLDistributionPoints extension",
|
|
951
|
+
appliesTo: _isSmime,
|
|
952
|
+
check: function (cert, ctx) { return ctx.raw("cRLDistributionPoints") ? null : true; },
|
|
953
|
+
},
|
|
954
|
+
{
|
|
955
|
+
id: "lint/cabf-smime/crldp-non-http-uri", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (b)",
|
|
956
|
+
/** @internal The scheme rule differs by generation: Strict and Multipurpose require EVERY
|
|
957
|
+
* uniformResourceIdentifier to be HTTP, where Legacy requires at least one and permits others
|
|
958
|
+
* beside it. One row measures both, because the generation decides which question to ask. */
|
|
959
|
+
message: "a cRLDistributionPoints URI does not meet the URI scheme rule for this generation",
|
|
960
|
+
appliesTo: _isSmime,
|
|
961
|
+
check: function (cert, ctx) {
|
|
962
|
+
/** @internal An extension carrying no URI at all fails both readings: every generation
|
|
963
|
+
* requires an HTTP one, so an empty list is a violation rather than nothing to measure. The
|
|
964
|
+
* presence row answers only for the extension being absent. */
|
|
965
|
+
if (!ctx.raw("cRLDistributionPoints")) return null;
|
|
966
|
+
var uris = _crldpUris(ctx);
|
|
967
|
+
var gen = _smimeGeneration(ctx);
|
|
968
|
+
if (gen === "legacy" || !uris.length) {
|
|
969
|
+
return uris.some(_isHttpUri) ? null : { context: { generation: gen || "strict", uris: uris } };
|
|
970
|
+
}
|
|
971
|
+
var bad = uris.filter(function (u) { return !_isHttpUri(u); });
|
|
972
|
+
return bad.length ? { context: { generation: gen, uris: bad } } : null;
|
|
973
|
+
},
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
id: "lint/cabf-smime/aia-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (c)",
|
|
977
|
+
message: "authorityInformationAccess must not be marked critical",
|
|
978
|
+
appliesTo: _isSmime,
|
|
979
|
+
check: function (cert, ctx) { var e = ctx.raw("authorityInfoAccess"); return (e && e.critical === true) ? true : null; },
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
id: "lint/cabf-smime/basic-constraints-ca-true", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (d)",
|
|
983
|
+
message: "a subscriber certificate's basicConstraints cA field must not be true",
|
|
984
|
+
appliesTo: _isSmime,
|
|
985
|
+
check: function (cert, ctx) {
|
|
986
|
+
var d = ctx.decode("basicConstraints");
|
|
987
|
+
return (d && d.value && d.value.cA === true) ? true : null;
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
{
|
|
991
|
+
id: "lint/cabf-smime/basic-constraints-path-len", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (d)",
|
|
992
|
+
message: "a subscriber certificate's basicConstraints must not carry a pathLenConstraint",
|
|
993
|
+
appliesTo: _isSmime,
|
|
994
|
+
check: function (cert, ctx) {
|
|
995
|
+
var d = ctx.decode("basicConstraints");
|
|
996
|
+
var pl = d && d.value ? d.value.pathLenConstraint : null;
|
|
997
|
+
return pl === null || pl === undefined ? null : { context: { pathLenConstraint: pl } };
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
id: "lint/cabf-smime/key-usage-missing", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (e)",
|
|
1002
|
+
message: "a subscriber certificate must carry a keyUsage extension",
|
|
1003
|
+
appliesTo: _isSmime,
|
|
1004
|
+
check: function (cert, ctx) { return ctx.raw("keyUsage") ? null : true; },
|
|
1005
|
+
},
|
|
1006
|
+
{
|
|
1007
|
+
id: "lint/cabf-smime/key-usage-forbidden-bit", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (e)",
|
|
1008
|
+
/** @internal "Other bit positions SHALL NOT be set", over a table that differs by key
|
|
1009
|
+
* algorithm: keyEncipherment belongs to the RSA table and keyAgreement to the EC one, so the
|
|
1010
|
+
* same bit is conformant on one key and not on the other. A key algorithm the section gives no
|
|
1011
|
+
* table for is held to neither. "Other bit positions" reaches past the nine RFC 5280 names as
|
|
1012
|
+
* well, so a bit set beyond them is reported for every key algorithm. */
|
|
1013
|
+
message: "a keyUsage bit this key algorithm's table does not permit is set",
|
|
1014
|
+
appliesTo: _isSmime,
|
|
1015
|
+
check: function (cert, ctx) {
|
|
1016
|
+
var d = ctx.decode("keyUsage");
|
|
1017
|
+
if (!d || !d.value) return null;
|
|
1018
|
+
var kind = _smimeKeyKind(cert);
|
|
1019
|
+
if (kind === null) return null;
|
|
1020
|
+
var modes = _smimeKuModes(kind, _smimeGeneration(ctx));
|
|
1021
|
+
if (modes === null) return null;
|
|
1022
|
+
var named = _smimeKuNamedAnywhere(modes);
|
|
1023
|
+
var out = [];
|
|
1024
|
+
KU_BIT_NAMES.forEach(function (bit) {
|
|
1025
|
+
if (d.value[bit] === true && named.indexOf(bit) === -1) {
|
|
1026
|
+
out.push({ context: { keyUsage: bit, keyAlgorithm: kind } });
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
if (d.value.reservedBitsSet === true) {
|
|
1030
|
+
out.push({ context: { reservedBitSet: true, keyAlgorithm: kind } });
|
|
1031
|
+
}
|
|
1032
|
+
return out.length ? out : null;
|
|
1033
|
+
},
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
id: "lint/cabf-smime/key-usage-matches-no-mode", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (e)",
|
|
1037
|
+
/** @internal A cell is a set of modes rather than a set of bits, so bits that all appear
|
|
1038
|
+
* somewhere in the cell can still combine into no mode: digitalSignature with dataEncipherment
|
|
1039
|
+
* on RSA, or encipherOnly without keyAgreement on an elliptic curve. This row runs only where
|
|
1040
|
+
* every asserted bit is one the cell names, since a bit outside the cell is the row above. */
|
|
1041
|
+
message: "the keyUsage bits set match no usage mode of this key algorithm's table",
|
|
1042
|
+
appliesTo: _isSmime,
|
|
1043
|
+
check: function (cert, ctx) {
|
|
1044
|
+
var d = ctx.decode("keyUsage");
|
|
1045
|
+
if (!d || !d.value || d.value.reservedBitsSet === true) return null;
|
|
1046
|
+
var kind = _smimeKeyKind(cert);
|
|
1047
|
+
if (kind === null) return null;
|
|
1048
|
+
var gen = _smimeGeneration(ctx);
|
|
1049
|
+
var modes = _smimeKuModes(kind, gen);
|
|
1050
|
+
if (modes === null) return null;
|
|
1051
|
+
var asserted = _smimeKuAsserted(d.value);
|
|
1052
|
+
var named = _smimeKuNamedAnywhere(modes);
|
|
1053
|
+
for (var i = 0; i < asserted.length; i++) {
|
|
1054
|
+
if (named.indexOf(asserted[i]) === -1) return null;
|
|
1055
|
+
}
|
|
1056
|
+
return _smimeKuMatchesAMode(modes, asserted)
|
|
1057
|
+
? null
|
|
1058
|
+
: { context: { keyAlgorithm: kind, generation: gen || "strict", keyUsage: asserted } };
|
|
1059
|
+
},
|
|
1060
|
+
},
|
|
1061
|
+
{
|
|
1062
|
+
id: "lint/cabf-smime/eku-missing-email-protection", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (f)",
|
|
1063
|
+
message: "extKeyUsage must include id-kp-emailProtection",
|
|
1064
|
+
appliesTo: _isSmime,
|
|
1065
|
+
check: function (cert, ctx) {
|
|
1066
|
+
var d = ctx.decode("extKeyUsage");
|
|
1067
|
+
if (!d || !Array.isArray(d.value)) return true;
|
|
1068
|
+
return d.value.indexOf(oid.byName("emailProtection")) === -1 ? true : null;
|
|
1069
|
+
},
|
|
1070
|
+
},
|
|
1071
|
+
{
|
|
1072
|
+
id: "lint/cabf-smime/eku-forbidden-purpose", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (f)",
|
|
1073
|
+
message: "a key purpose the profile forbids at every generation is present in extKeyUsage",
|
|
1074
|
+
appliesTo: _isSmime,
|
|
1075
|
+
check: function (cert, ctx) {
|
|
1076
|
+
var d = ctx.decode("extKeyUsage");
|
|
1077
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
1078
|
+
var out = [];
|
|
1079
|
+
SMIME_FORBIDDEN_EKU.forEach(function (e) {
|
|
1080
|
+
if (d.value.indexOf(e.oid) !== -1) out.push({ context: { keyPurpose: e.name, oid: e.oid } });
|
|
1081
|
+
});
|
|
1082
|
+
return out.length ? out : null;
|
|
1083
|
+
},
|
|
1084
|
+
},
|
|
1085
|
+
{
|
|
1086
|
+
id: "lint/cabf-smime/eku-extra-purpose-strict", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (f)",
|
|
1087
|
+
/** @internal Strict alone says "Other values SHALL NOT be present"; Multipurpose and Legacy
|
|
1088
|
+
* say they MAY be. So this row is the one place the generation turns a permission into a
|
|
1089
|
+
* prohibition, and it runs only where the certificate declared Strict. The four values the
|
|
1090
|
+
* sentence after the table forbids at every generation are left to the row that owns them, so
|
|
1091
|
+
* one purpose draws one finding citing the narrower of the two rules it breaks. */
|
|
1092
|
+
message: "under the Strict generation, extKeyUsage must carry no purpose beyond id-kp-emailProtection",
|
|
1093
|
+
appliesTo: _smimeStrict,
|
|
1094
|
+
check: function (cert, ctx) {
|
|
1095
|
+
var d = ctx.decode("extKeyUsage");
|
|
1096
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
1097
|
+
var email = oid.byName("emailProtection");
|
|
1098
|
+
var extra = d.value.filter(function (o) {
|
|
1099
|
+
if (o === email) return false;
|
|
1100
|
+
for (var i = 0; i < SMIME_FORBIDDEN_EKU.length; i++) {
|
|
1101
|
+
if (SMIME_FORBIDDEN_EKU[i].oid === o) return false;
|
|
1102
|
+
}
|
|
1103
|
+
return true;
|
|
1104
|
+
});
|
|
1105
|
+
return extra.length ? { context: { purposes: extra } } : null;
|
|
1106
|
+
},
|
|
1107
|
+
},
|
|
1108
|
+
{
|
|
1109
|
+
id: "lint/cabf-smime/aki-missing", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (g)",
|
|
1110
|
+
message: "a subscriber certificate must carry an authorityKeyIdentifier extension",
|
|
1111
|
+
appliesTo: _isSmime,
|
|
1112
|
+
check: function (cert, ctx) { return ctx.raw("authorityKeyIdentifier") ? null : true; },
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
id: "lint/cabf-smime/aki-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (g)",
|
|
1116
|
+
message: "authorityKeyIdentifier must not be marked critical",
|
|
1117
|
+
appliesTo: _isSmime,
|
|
1118
|
+
check: function (cert, ctx) { var e = ctx.raw("authorityKeyIdentifier"); return (e && e.critical === true) ? true : null; },
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
id: "lint/cabf-smime/san-no-mailbox", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.4.2.1",
|
|
1122
|
+
/** @internal "SHALL contain at least one GeneralName entry of the following types: Rfc822Name
|
|
1123
|
+
* and/or otherName of type id-on-SmtpUTF8Mailbox". An otherName is read for its type OID,
|
|
1124
|
+
* since any otherName is a [0] and only that one carries a mailbox. */
|
|
1125
|
+
message: "subjectAltName must carry at least one rfc822Name or SmtpUTF8Mailbox otherName",
|
|
1126
|
+
appliesTo: _isSmime,
|
|
1127
|
+
check: function (cert, ctx) {
|
|
1128
|
+
var d = ctx.decode("subjectAltName");
|
|
1129
|
+
if (!d || !d.value || !Array.isArray(d.value.names)) return true;
|
|
1130
|
+
var found = d.value.names.some(function (gn) {
|
|
1131
|
+
if (gn.tagNumber === 1) return true;
|
|
1132
|
+
if (gn.tagNumber !== 0) return false;
|
|
1133
|
+
return _otherNameTypeOf(gn) === SMIME_OID_SMTP_UTF8;
|
|
1134
|
+
});
|
|
1135
|
+
return found ? null : true;
|
|
1136
|
+
},
|
|
1137
|
+
},
|
|
1138
|
+
{
|
|
1139
|
+
id: "lint/cabf-smime/ski-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (n)",
|
|
1140
|
+
message: "subjectKeyIdentifier must not be marked critical",
|
|
1141
|
+
appliesTo: _isSmime,
|
|
1142
|
+
check: function (cert, ctx) { var e = ctx.raw("subjectKeyIdentifier"); return (e && e.critical === true) ? true : null; },
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
id: "lint/cabf-smime/smime-capabilities-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (i)",
|
|
1146
|
+
message: "smimeCapabilities must not be marked critical",
|
|
1147
|
+
appliesTo: _isSmime,
|
|
1148
|
+
check: function (cert, ctx) { var e = ctx.raw("smimeCapabilities"); return (e && e.critical === true) ? true : null; },
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
id: "lint/cabf-smime/subject-directory-attributes-prohibited", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (j)",
|
|
1152
|
+
/** @internal Prohibited under Strict and Multipurpose, permitted under Legacy. The generation
|
|
1153
|
+
* the certificate declares decides, so this runs at two of the three. */
|
|
1154
|
+
message: "subjectDirectoryAttributes is prohibited under the Strict and Multipurpose generations",
|
|
1155
|
+
appliesTo: function (cert, ctx) {
|
|
1156
|
+
return _isSmime(cert, ctx) && _smimeGenerationOrStrict(ctx) !== "legacy";
|
|
1157
|
+
},
|
|
1158
|
+
check: function (cert, ctx) { return ctx.raw("subjectDirectoryAttributes") ? true : null; },
|
|
1159
|
+
},
|
|
1160
|
+
{
|
|
1161
|
+
id: "lint/cabf-smime/subject-directory-attributes-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (j)",
|
|
1162
|
+
/** @internal The criticality sentence sits in the Legacy row alone, which reads "MAY be present
|
|
1163
|
+
* and SHALL NOT be marked critical". The other row reads "Prohibited" and carries no
|
|
1164
|
+
* criticality sentence, so a critical extension there breaks one clause rather than two. */
|
|
1165
|
+
message: "subjectDirectoryAttributes must not be marked critical",
|
|
1166
|
+
appliesTo: function (cert, ctx) {
|
|
1167
|
+
return _isSmime(cert, ctx) && _smimeGenerationOrStrict(ctx) === "legacy";
|
|
1168
|
+
},
|
|
1169
|
+
check: function (cert, ctx) {
|
|
1170
|
+
var e = ctx.raw("subjectDirectoryAttributes");
|
|
1171
|
+
return (e && e.critical === true) ? true : null;
|
|
1172
|
+
},
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
id: "lint/cabf-smime/policies-cps-not-http-url", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (a)",
|
|
1176
|
+
/** @internal "the value of the qualifier SHALL be a HTTP or HTTPS URL". This clause names both
|
|
1177
|
+
* schemes where the URI scheme tables of (b) and (c) name HTTP alone. */
|
|
1178
|
+
message: "an id-qt-cps policy qualifier must be a HTTP or HTTPS URL",
|
|
1179
|
+
appliesTo: _isSmime,
|
|
1180
|
+
check: function (cert, ctx) {
|
|
1181
|
+
var out = [];
|
|
1182
|
+
_eachPolicyQualifier(ctx, function (qid, value, pi) {
|
|
1183
|
+
if (qid !== OID_CPS) return;
|
|
1184
|
+
var uri = null;
|
|
1185
|
+
// allow:swallow-unverified a CPSuri that will not read as a string is not a URL either, so it falls to the report below rather than out of the rule
|
|
1186
|
+
try {
|
|
1187
|
+
uri = asn1.read.string(value);
|
|
1188
|
+
} catch (_e) {
|
|
1189
|
+
void 0;
|
|
1190
|
+
}
|
|
1191
|
+
if (!_isHttpOrHttpsUrl(uri)) {
|
|
1192
|
+
out.push({ context: { policyIdentifier: pi.policyIdentifier, cps: uri } });
|
|
1193
|
+
}
|
|
1194
|
+
});
|
|
1195
|
+
return out.length ? out : null;
|
|
1196
|
+
},
|
|
1197
|
+
},
|
|
1198
|
+
{
|
|
1199
|
+
id: "lint/cabf-smime/policies-unotice-no-explicit-text", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (a)",
|
|
1200
|
+
/** @internal "If a qualifier of type id-qt-unotice is included, then it SHALL contain
|
|
1201
|
+
* explicitText". Both halves of that sentence are rows, since a notice can fail either. */
|
|
1202
|
+
message: "an id-qt-unotice policy qualifier must contain explicitText",
|
|
1203
|
+
appliesTo: _isSmime,
|
|
1204
|
+
check: function (cert, ctx) {
|
|
1205
|
+
var out = [];
|
|
1206
|
+
_eachPolicyQualifier(ctx, function (qid, value, pi) {
|
|
1207
|
+
if (qid !== OID_UNOTICE) return;
|
|
1208
|
+
var carries = pkix.userNoticeTexts(value).some(function (t) { return t.field === "explicitText"; });
|
|
1209
|
+
if (!carries) out.push({ context: { policyIdentifier: pi.policyIdentifier } });
|
|
1210
|
+
});
|
|
1211
|
+
return out.length ? out : null;
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
id: "lint/cabf-smime/policies-unotice-notice-ref", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (a)",
|
|
1216
|
+
/** @internal "...and SHALL NOT contain noticeRef." */
|
|
1217
|
+
message: "an id-qt-unotice policy qualifier must not contain a noticeRef",
|
|
1218
|
+
appliesTo: _isSmime,
|
|
1219
|
+
check: function (cert, ctx) {
|
|
1220
|
+
var out = [];
|
|
1221
|
+
_eachPolicyQualifier(ctx, function (qid, value, pi) {
|
|
1222
|
+
if (qid !== OID_UNOTICE) return;
|
|
1223
|
+
/** @internal NoticeReference requires its organization DisplayText, which userNoticeTexts
|
|
1224
|
+
* surfaces under that field name, so the field's presence is the noticeRef's presence. */
|
|
1225
|
+
var carries = pkix.userNoticeTexts(value).some(function (t) { return t.field === "organization"; });
|
|
1226
|
+
if (carries) out.push({ context: { policyIdentifier: pi.policyIdentifier } });
|
|
1227
|
+
});
|
|
1228
|
+
return out.length ? out : null;
|
|
1229
|
+
},
|
|
1230
|
+
},
|
|
1231
|
+
{
|
|
1232
|
+
id: "lint/cabf-smime/crldp-critical", severity: "warn", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (b)",
|
|
1233
|
+
message: "cRLDistributionPoints should not be marked critical",
|
|
1234
|
+
appliesTo: _isSmime,
|
|
1235
|
+
check: function (cert, ctx) {
|
|
1236
|
+
var e = ctx.raw("cRLDistributionPoints");
|
|
1237
|
+
return (e && e.critical === true) ? true : null;
|
|
1238
|
+
},
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
id: "lint/cabf-smime/aia-missing", severity: "warn", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (c)",
|
|
1242
|
+
/** @internal "(SHOULD be present)", where the TLS profile's own heading for the same extension
|
|
1243
|
+
* reads SHALL, so the two profiles report it at different strengths. */
|
|
1244
|
+
message: "a subscriber certificate should carry an authorityInformationAccess extension",
|
|
1245
|
+
appliesTo: _isSmime,
|
|
1246
|
+
check: function (cert, ctx) { return ctx.raw("authorityInfoAccess") ? null : true; },
|
|
1247
|
+
},
|
|
1248
|
+
{
|
|
1249
|
+
id: "lint/cabf-smime/aia-ca-issuers-missing", severity: "warn", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (c)",
|
|
1250
|
+
/** @internal "SHOULD contain at least one accessMethod value of type id-ad-caIssuers". Read
|
|
1251
|
+
* only where the extension is there at all, since its absence is the row above. */
|
|
1252
|
+
message: "authorityInformationAccess should carry an id-ad-caIssuers accessMethod",
|
|
1253
|
+
appliesTo: function (cert, ctx) { return _isSmime(cert, ctx) && !!ctx.raw("authorityInfoAccess"); },
|
|
1254
|
+
check: function (cert, ctx) {
|
|
1255
|
+
var d = ctx.decode("authorityInfoAccess");
|
|
1256
|
+
if (!d || !Array.isArray(d.value)) return null;
|
|
1257
|
+
var wanted = oid.byName("caIssuers");
|
|
1258
|
+
for (var i = 0; i < d.value.length; i++) {
|
|
1259
|
+
if (d.value[i].accessMethod === wanted) return null;
|
|
1260
|
+
}
|
|
1261
|
+
return true;
|
|
1262
|
+
},
|
|
1263
|
+
},
|
|
1264
|
+
{
|
|
1265
|
+
id: "lint/cabf-smime/aia-non-http-uri", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (c)",
|
|
1266
|
+
/** @internal Strict and Multipurpose require every accessMethod URI to be HTTP, Legacy at
|
|
1267
|
+
* least one. Each accessMethod has its OWN table, so "at least one" is answered per method and
|
|
1268
|
+
* an HTTP URI on one does not satisfy the other's row. */
|
|
1269
|
+
message: "an authorityInformationAccess accessMethod URI does not meet the URI scheme rule for this generation",
|
|
1270
|
+
appliesTo: function (cert, ctx) { return _isSmime(cert, ctx) && !!ctx.raw("authorityInfoAccess"); },
|
|
1271
|
+
check: function (cert, ctx) {
|
|
1272
|
+
var byMethod = _smimeAiaLocationsByMethod(ctx);
|
|
1273
|
+
var gen = _smimeGeneration(ctx);
|
|
1274
|
+
var out = [];
|
|
1275
|
+
function report(method, offending) {
|
|
1276
|
+
out.push({ context: { generation: gen || "strict", accessMethod: oid.name(method) || method,
|
|
1277
|
+
uris: offending.filter(function (u) { return u !== null; }),
|
|
1278
|
+
nonUriLocations: offending.filter(function (u) { return u === null; }).length } });
|
|
1279
|
+
}
|
|
1280
|
+
SMIME_AIA_METHODS.forEach(function (method) {
|
|
1281
|
+
var locs = byMethod[method];
|
|
1282
|
+
if (!locs || !locs.length) return;
|
|
1283
|
+
if (gen === "legacy") {
|
|
1284
|
+
if (!locs.some(_isHttpUri)) report(method, locs);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
var bad = locs.filter(function (u) { return !_isHttpUri(u); });
|
|
1288
|
+
if (bad.length) report(method, bad);
|
|
1289
|
+
});
|
|
1290
|
+
return out.length ? out : null;
|
|
1291
|
+
},
|
|
1292
|
+
},
|
|
1293
|
+
{
|
|
1294
|
+
id: "lint/cabf-smime/key-usage-not-critical", severity: "warn", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (e)",
|
|
1295
|
+
message: "keyUsage should be marked critical",
|
|
1296
|
+
appliesTo: function (cert, ctx) { return _isSmime(cert, ctx) && !!ctx.raw("keyUsage"); },
|
|
1297
|
+
check: function (cert, ctx) {
|
|
1298
|
+
var e = ctx.raw("keyUsage");
|
|
1299
|
+
return (e && e.critical === true) ? null : true;
|
|
1300
|
+
},
|
|
1301
|
+
},
|
|
1302
|
+
{
|
|
1303
|
+
id: "lint/cabf-smime/aki-key-identifier-missing", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (g)",
|
|
1304
|
+
/** @internal "The keyIdentifier field SHALL be present", which is a field rule rather than the
|
|
1305
|
+
* extension-presence one above it: an authorityKeyIdentifier naming only an issuer and serial
|
|
1306
|
+
* satisfies that row and not this one. */
|
|
1307
|
+
message: "the authorityKeyIdentifier keyIdentifier field must be present",
|
|
1308
|
+
appliesTo: function (cert, ctx) { return _isSmime(cert, ctx) && !!ctx.raw("authorityKeyIdentifier"); },
|
|
1309
|
+
check: function (cert, ctx) {
|
|
1310
|
+
var d = ctx.decode("authorityKeyIdentifier");
|
|
1311
|
+
if (!d || !d.value) return null;
|
|
1312
|
+
var kid = d.value.keyIdentifier;
|
|
1313
|
+
return (kid === null || kid === undefined) ? true : null;
|
|
1314
|
+
},
|
|
1315
|
+
},
|
|
1316
|
+
{
|
|
1317
|
+
id: "lint/cabf-smime/aki-issuer-or-serial-present", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (g)",
|
|
1318
|
+
/** @internal "authorityCertIssuer and authorityCertSerialNumber fields SHALL NOT be present."
|
|
1319
|
+
* Either one alone is a violation, so both are reported by name. */
|
|
1320
|
+
message: "the authorityKeyIdentifier must not carry authorityCertIssuer or authorityCertSerialNumber",
|
|
1321
|
+
appliesTo: function (cert, ctx) { return _isSmime(cert, ctx) && !!ctx.raw("authorityKeyIdentifier"); },
|
|
1322
|
+
check: function (cert, ctx) {
|
|
1323
|
+
var d = ctx.decode("authorityKeyIdentifier");
|
|
1324
|
+
if (!d || !d.value) return null;
|
|
1325
|
+
var out = [];
|
|
1326
|
+
["authorityCertIssuer", "authorityCertSerialNumber"].forEach(function (f) {
|
|
1327
|
+
var v = d.value[f];
|
|
1328
|
+
if (v !== null && v !== undefined) out.push({ context: { field: f } });
|
|
1329
|
+
});
|
|
1330
|
+
return out.length ? out : null;
|
|
1331
|
+
},
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
id: "lint/cabf-smime/san-missing", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (h)",
|
|
1335
|
+
message: "a subscriber certificate must carry a subjectAltName extension",
|
|
1336
|
+
appliesTo: _isSmime,
|
|
1337
|
+
check: function (cert, ctx) { return ctx.raw("subjectAltName") ? null : true; },
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
id: "lint/cabf-smime/san-critical", severity: "warn", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (h)",
|
|
1341
|
+
/** @internal "SHOULD NOT be marked critical unless the subject field is an empty sequence",
|
|
1342
|
+
* which is the one condition that lifts it, so the subject is read rather than assumed. */
|
|
1343
|
+
message: "subjectAltName should not be marked critical where the subject is not an empty sequence",
|
|
1344
|
+
appliesTo: _isSmime,
|
|
1345
|
+
check: function (cert, ctx) {
|
|
1346
|
+
var e = ctx.raw("subjectAltName");
|
|
1347
|
+
if (!e || e.critical !== true) return null;
|
|
1348
|
+
var rdns = cert.subject && cert.subject.rdns;
|
|
1349
|
+
var empty = !Array.isArray(rdns) || rdns.length === 0;
|
|
1350
|
+
return empty ? null : true;
|
|
1351
|
+
},
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
id: "lint/cabf-smime/qc-statements-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (k)",
|
|
1355
|
+
message: "qcStatements must not be marked critical",
|
|
1356
|
+
appliesTo: _isSmime,
|
|
1357
|
+
check: function (cert, ctx) {
|
|
1358
|
+
var e = ctx.raw("qcStatements");
|
|
1359
|
+
return (e && e.critical === true) ? true : null;
|
|
1360
|
+
},
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
id: "lint/cabf-smime/lei-prohibited", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (l)",
|
|
1364
|
+
/** @internal The table is keyed on the certificate TYPE: Mailbox-validated and
|
|
1365
|
+
* Individual-validated admit neither identifier, Organization-validated admits the LEI but not
|
|
1366
|
+
* the role, and Sponsor-validated admits both. A certificate declaring no type is held to
|
|
1367
|
+
* none, since the table has no row to select. */
|
|
1368
|
+
message: "a Legal Entity Identifier extension is prohibited for this certificate type",
|
|
1369
|
+
appliesTo: _isSmime,
|
|
1370
|
+
check: function (cert, ctx) {
|
|
1371
|
+
var type = _smimeType(ctx);
|
|
1372
|
+
if (type === null) return null;
|
|
1373
|
+
var out = [];
|
|
1374
|
+
["identifier", "role"].forEach(function (which) {
|
|
1375
|
+
if (_smimeLeiAdmits(type, which)) return;
|
|
1376
|
+
if (ctx.raw(SMIME_LEI_EXTS[which])) out.push({ context: { certificateType: type, extension: which } });
|
|
1377
|
+
});
|
|
1378
|
+
return out.length ? out : null;
|
|
1379
|
+
},
|
|
1380
|
+
},
|
|
1381
|
+
{
|
|
1382
|
+
id: "lint/cabf-smime/lei-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (l)",
|
|
1383
|
+
/** @internal Where the table admits one it reads "MAY be present and SHALL NOT be marked
|
|
1384
|
+
* critical", so criticality is fixed in the cells that admit it and nowhere else. */
|
|
1385
|
+
message: "a Legal Entity Identifier extension must not be marked critical",
|
|
1386
|
+
appliesTo: _isSmime,
|
|
1387
|
+
check: function (cert, ctx) {
|
|
1388
|
+
var type = _smimeType(ctx);
|
|
1389
|
+
if (type === null) return null;
|
|
1390
|
+
var out = [];
|
|
1391
|
+
["identifier", "role"].forEach(function (which) {
|
|
1392
|
+
if (!_smimeLeiAdmits(type, which)) return;
|
|
1393
|
+
var e = ctx.raw(SMIME_LEI_EXTS[which]);
|
|
1394
|
+
if (e && e.critical === true) out.push({ context: { certificateType: type, extension: which } });
|
|
1395
|
+
});
|
|
1396
|
+
return out.length ? out : null;
|
|
1397
|
+
},
|
|
1398
|
+
},
|
|
1399
|
+
{
|
|
1400
|
+
id: "lint/cabf-smime/adobe-extension-prohibited", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (m)",
|
|
1401
|
+
/** @internal Prohibited under Strict, permitted under Multipurpose and Legacy. */
|
|
1402
|
+
message: "an Adobe extension is prohibited under the Strict generation",
|
|
1403
|
+
appliesTo: function (cert, ctx) {
|
|
1404
|
+
return _isSmime(cert, ctx) && _smimeGenerationOrStrict(ctx) === "strict";
|
|
1405
|
+
},
|
|
1406
|
+
check: function (cert, ctx) {
|
|
1407
|
+
var out = [];
|
|
1408
|
+
SMIME_ADOBE_EXTS.forEach(function (name) {
|
|
1409
|
+
if (ctx.raw(name)) out.push({ context: { extension: name } });
|
|
1410
|
+
});
|
|
1411
|
+
return out.length ? out : null;
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
{
|
|
1415
|
+
id: "lint/cabf-smime/adobe-extension-critical", severity: "error", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (m)",
|
|
1416
|
+
/** @internal The criticality sentence sits in the Multipurpose and Legacy row. The Strict row
|
|
1417
|
+
* reads "Prohibited" and carries none, so a critical extension there breaks one clause. */
|
|
1418
|
+
message: "an Adobe extension must not be marked critical",
|
|
1419
|
+
appliesTo: function (cert, ctx) {
|
|
1420
|
+
return _isSmime(cert, ctx) && _smimeGenerationOrStrict(ctx) !== "strict";
|
|
1421
|
+
},
|
|
1422
|
+
check: function (cert, ctx) {
|
|
1423
|
+
var out = [];
|
|
1424
|
+
SMIME_ADOBE_EXTS.forEach(function (name) {
|
|
1425
|
+
var e = ctx.raw(name);
|
|
1426
|
+
if (e && e.critical === true) out.push({ context: { extension: name } });
|
|
1427
|
+
});
|
|
1428
|
+
return out.length ? out : null;
|
|
1429
|
+
},
|
|
1430
|
+
},
|
|
1431
|
+
{
|
|
1432
|
+
id: "lint/cabf-smime/ski-missing", severity: "warn", source: "cabf-smime", citation: "CABF S/MIME BR 7.1.2.3 (n)",
|
|
1433
|
+
/** @internal "(SHOULD be present)". The same clause adds that the value SHOULD be derived from
|
|
1434
|
+
* the public key, which names no derivation, so a certificate using one this cannot recompute
|
|
1435
|
+
* is conformant and no row reports it. */
|
|
1436
|
+
message: "a subscriber certificate should carry a subjectKeyIdentifier extension",
|
|
1437
|
+
appliesTo: _isSmime,
|
|
1438
|
+
check: function (cert, ctx) { return ctx.raw("subjectKeyIdentifier") ? null : true; },
|
|
1439
|
+
},
|
|
1440
|
+
];
|
|
1441
|
+
|
|
622
1442
|
var CABF_TLS_RULES = [
|
|
623
1443
|
{
|
|
624
1444
|
id: "lint/cabf-tls/aia-missing", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
|
|
@@ -2253,28 +3073,32 @@ var CABF_TLS_CSR_RULE_IDS = [
|
|
|
2253
3073
|
"lint/cabf-tls/weak-key",
|
|
2254
3074
|
];
|
|
2255
3075
|
|
|
3076
|
+
/** @internal RFC 5280 sec. 4.2 fixes each extension's syntax wherever it is carried. Every row in
|
|
3077
|
+
* every profile below asks for an extension BY NAME, and the shared decoder returns null both for
|
|
3078
|
+
* one that is absent and for one whose value does not decode, so without this row a malformed
|
|
3079
|
+
* value reads as absence and passes every row that asks for it. The certificate's own row is
|
|
3080
|
+
* reused rather than restated, and it is folded into each named profile rather than into the
|
|
3081
|
+
* *_RULES arrays, which `ALL_RULES` concatenates and would otherwise report it twice. */
|
|
3082
|
+
var RFC5280_EXTENSION_SYNTAX_RULES = RFC5280_RULES.filter(function (r) {
|
|
3083
|
+
return r.id === "lint/rfc5280/extension-undecodable";
|
|
3084
|
+
});
|
|
2256
3085
|
var PROFILES = Object.assign(Object.create(null), {
|
|
2257
3086
|
"rfc5280": RFC5280_RULES,
|
|
2258
|
-
"rfc9881": RFC9881_RULES,
|
|
2259
|
-
"rfc9909": RFC9909_RULES,
|
|
2260
|
-
"rfc9935": RFC9935_RULES,
|
|
2261
|
-
"cabf-tls": CABF_TLS_RULES,
|
|
3087
|
+
"rfc9881": RFC9881_RULES.concat(RFC5280_EXTENSION_SYNTAX_RULES),
|
|
3088
|
+
"rfc9909": RFC9909_RULES.concat(RFC5280_EXTENSION_SYNTAX_RULES),
|
|
3089
|
+
"rfc9935": RFC9935_RULES.concat(RFC5280_EXTENSION_SYNTAX_RULES),
|
|
3090
|
+
"cabf-tls": CABF_TLS_RULES.concat(RFC5280_EXTENSION_SYNTAX_RULES),
|
|
3091
|
+
"cabf-smime": CABF_SMIME_RULES.concat(RFC5280_EXTENSION_SYNTAX_RULES),
|
|
2262
3092
|
});
|
|
2263
|
-
var ALL_RULES = RFC5280_RULES.concat(RFC9881_RULES).concat(RFC9909_RULES).concat(RFC9935_RULES)
|
|
3093
|
+
var ALL_RULES = RFC5280_RULES.concat(RFC9881_RULES).concat(RFC9909_RULES).concat(RFC9935_RULES)
|
|
3094
|
+
.concat(CABF_TLS_RULES).concat(CABF_SMIME_RULES);
|
|
2264
3095
|
|
|
2265
3096
|
var CABF_TLS_CSR_RULES = CABF_TLS_RULES.filter(function (r) {
|
|
2266
3097
|
return CABF_TLS_CSR_RULE_IDS.indexOf(r.id) !== -1;
|
|
2267
3098
|
});
|
|
2268
|
-
/** @internal
|
|
2269
|
-
*
|
|
2270
|
-
|
|
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);
|
|
3099
|
+
/** @internal A requested extension whose value does not decode is the same fault in a request as
|
|
3100
|
+
* in a certificate, so the request profiles take the same row the certificate profiles do. */
|
|
3101
|
+
var CSR_STRUCTURAL_RULES = RFC2986_CSR_RULES.concat(RFC5280_EXTENSION_SYNTAX_RULES);
|
|
2278
3102
|
var CSR_PROFILES = Object.assign(Object.create(null), {
|
|
2279
3103
|
"rfc2986": CSR_STRUCTURAL_RULES,
|
|
2280
3104
|
"cabf-tls": CSR_STRUCTURAL_RULES.concat(CABF_TLS_CSR_RULES),
|
|
@@ -2378,7 +3202,9 @@ function _ingestCsr(input) {
|
|
|
2378
3202
|
var derived = _derivedCsr(input);
|
|
2379
3203
|
if (derived) return { csr: derived };
|
|
2380
3204
|
var der;
|
|
2381
|
-
|
|
3205
|
+
var bytes = _derBytesOf(input);
|
|
3206
|
+
if (bytes !== null && bytes.fatal) return { fatal: bytes.fatal };
|
|
3207
|
+
if (bytes !== null) der = bytes.der;
|
|
2382
3208
|
else if (typeof input === "string") {
|
|
2383
3209
|
try { der = csrSchema.pemDecode(input); }
|
|
2384
3210
|
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 } } }; }
|
|
@@ -2487,7 +3313,9 @@ function _ingestCrl(input) {
|
|
|
2487
3313
|
var derived = _derivedCrl(input);
|
|
2488
3314
|
if (derived) return { crl: derived };
|
|
2489
3315
|
var der;
|
|
2490
|
-
|
|
3316
|
+
var bytes = _derBytesOf(input);
|
|
3317
|
+
if (bytes !== null && bytes.fatal) return { fatal: bytes.fatal };
|
|
3318
|
+
if (bytes !== null) der = bytes.der;
|
|
2491
3319
|
else if (typeof input === "string") {
|
|
2492
3320
|
try { der = crlSchema.pemDecode(input); }
|
|
2493
3321
|
catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER CRL", context: { code: e.code } } }; }
|
|
@@ -2660,7 +3488,9 @@ var _OCSP_CLAIM = ["responseStatus", "basicResponse", "tbsResponseDataBytes"];
|
|
|
2660
3488
|
* responses. Bytes and PEM take the fatal path below, never a throw. */
|
|
2661
3489
|
function _ingestOcsp(input) {
|
|
2662
3490
|
var der;
|
|
2663
|
-
|
|
3491
|
+
var bytes = _derBytesOf(input);
|
|
3492
|
+
if (bytes !== null && bytes.fatal) return { fatal: bytes.fatal };
|
|
3493
|
+
if (bytes !== null) der = bytes.der;
|
|
2664
3494
|
else if (typeof input === "string") {
|
|
2665
3495
|
try { der = ocspSchema.pemDecode(input); }
|
|
2666
3496
|
catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "pki.lint", message: "input is not a decodable PEM/DER OCSP response", context: { code: e.code } } }; }
|
|
@@ -2793,6 +3623,10 @@ var BY_ARTIFACT = Object.assign(Object.create(null), {
|
|
|
2793
3623
|
});
|
|
2794
3624
|
|
|
2795
3625
|
function rules(profile, artifact) {
|
|
3626
|
+
/** @internal Every lint verb reads "all" and "default" as naming no profile, so the enumerator
|
|
3627
|
+
* reads them the same way: a caller who linted under one of those names and then asked which
|
|
3628
|
+
* rows ran would otherwise be refused the answer by the same registry that ran them. */
|
|
3629
|
+
if (profile === "all" || profile === "default") profile = null;
|
|
2796
3630
|
if (artifact != null && !Object.prototype.hasOwnProperty.call(BY_ARTIFACT, artifact)) {
|
|
2797
3631
|
throw _cfg("lint/bad-input", "unknown lint artifact \"" + artifact +
|
|
2798
3632
|
"\" (known: " + Object.keys(BY_ARTIFACT).join(", ") + ")");
|
package/lib/oid.js
CHANGED
|
@@ -118,11 +118,27 @@ var FAMILIES = Object.assign(Object.create(null), {
|
|
|
118
118
|
|
|
119
119
|
pkixOn: { base: [1, 3, 6, 1, 5, 5, 7, 8], of: { hardwareModuleName: 4, dnsSRV: 7, smtpUtf8Mailbox: 9, macAddress: 12 } },
|
|
120
120
|
|
|
121
|
+
/** @internal The S/MIME BR reserved policy identifiers of sec. 7.1.6.1 are a 4x3 matrix of
|
|
122
|
+
* certificate type by generation, and a subscriber certificate asserts exactly one. Reading it
|
|
123
|
+
* is how a linter learns which generation's table to hold the certificate to, so all twelve are
|
|
124
|
+
* named rather than only the current ones. */
|
|
121
125
|
cabfPolicy: { base: [2, 23, 140, 1], of: {
|
|
122
|
-
"ev-guidelines": 1, "domain-validated": [2, 1], "organization-validated": [2, 2], "individual-validated": [2, 3]
|
|
126
|
+
"ev-guidelines": 1, "domain-validated": [2, 1], "organization-validated": [2, 2], "individual-validated": [2, 3],
|
|
127
|
+
"smime-mailbox-legacy": [5, 1, 1], "smime-mailbox-multipurpose": [5, 1, 2], "smime-mailbox-strict": [5, 1, 3],
|
|
128
|
+
"smime-organization-legacy": [5, 2, 1], "smime-organization-multipurpose": [5, 2, 2], "smime-organization-strict": [5, 2, 3],
|
|
129
|
+
"smime-sponsor-legacy": [5, 3, 1], "smime-sponsor-multipurpose": [5, 3, 2], "smime-sponsor-strict": [5, 3, 3],
|
|
130
|
+
"smime-individual-legacy": [5, 4, 1], "smime-individual-multipurpose": [5, 4, 2], "smime-individual-strict": [5, 4, 3] } },
|
|
123
131
|
|
|
124
132
|
idCp: { base: [1, 3, 6, 1, 5, 5, 7, 14], of: { "id-cp-ipAddr-asNumber": 2, "id-cp-ipAddr-asNumber-v2": 3 } },
|
|
125
133
|
|
|
134
|
+
/** @internal The Legal Entity Identifier extensions of S/MIME BR sec. 7.1.2.3 (l), which the
|
|
135
|
+
* certificate type decides the admissibility of. */
|
|
136
|
+
lei: { base: [1, 3, 6, 1, 4, 1, 52266], of: { "lei-identifier": 1, "lei-role": 2 } },
|
|
137
|
+
|
|
138
|
+
/** @internal The Adobe extensions of S/MIME BR sec. 7.1.2.3 (m), prohibited under the Strict
|
|
139
|
+
* generation. */
|
|
140
|
+
adobe: { base: [1, 2, 840, 113583, 1, 1, 9], of: { "adobe-timestamp": 1, "adobe-archive-rev-info": 2 } },
|
|
141
|
+
|
|
126
142
|
gsmaRspRole: { base: [2, 23, 146, 1, 2, 1], of: {
|
|
127
143
|
"id-rspRole-ci": 0, "id-rspRole-euicc-v2": 1, "id-rspRole-euicc": [0, 0, 0, 0, 0],
|
|
128
144
|
"id-rspRole-eum-v2": 2, "id-rspRole-eum": [0, 0, 0],
|
package/lib/pki-build.js
CHANGED
|
@@ -42,6 +42,9 @@ function _isSubDelim(c) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
function _isUserinfoByte(c) { return _isUnreserved(c) || _isSubDelim(c) || c === 58; }
|
|
45
|
+
/** @internal The bytes RFC 3986 sec. 3.2.2 admits in a reg-name, beside a percent-encoding. A DNS
|
|
46
|
+
* label admits fewer, which is why a reader does not hold a host it was given to that rule. */
|
|
47
|
+
function _isRegNameByte(c) { return _isUnreserved(c) || _isSubDelim(c); }
|
|
45
48
|
function _isUriByte(c) {
|
|
46
49
|
return _isUnreserved(c) || _isSubDelim(c) || c === 58 || c === 47 || c === 63 || c === 35 || c === 64;
|
|
47
50
|
}
|
|
@@ -99,7 +102,61 @@ function _validUriIp6Host(s, from, to) {
|
|
|
99
102
|
return packed !== null && intrinsic.sizeOf(packed) === 16;
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
/** @internal A port as RFC 3986 sec. 3.2.3 writes it: ":" *DIGIT, or nothing. An empty port after
|
|
106
|
+
* the colon is what the grammar allows, so it is not a reason to refuse. */
|
|
107
|
+
function _validUriPort(s, at, to) {
|
|
108
|
+
if (at === to) return true;
|
|
109
|
+
if (_charCodeAt(s, at) !== 58) return false;
|
|
110
|
+
for (var i = at + 1; i < to; i += 1) { if (!_isDigitCode(_charCodeAt(s, i))) return false; }
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/** @internal An IPvFuture literal, RFC 3986 sec. 3.2.2: "v" 1*HEXDIG "." 1*( unreserved /
|
|
114
|
+
* sub-delims / ":" ). */
|
|
115
|
+
function _validIpvFuture(s, from, to) {
|
|
116
|
+
if (to - from < 4) return false;
|
|
117
|
+
var c = _charCodeAt(s, from);
|
|
118
|
+
if (c !== 118 && c !== 86) return false;
|
|
119
|
+
var i = from + 1, hex = 0;
|
|
120
|
+
while (i < to && _isHexCode(_charCodeAt(s, i))) { i += 1; hex += 1; }
|
|
121
|
+
if (hex === 0 || i >= to || _charCodeAt(s, i) !== 46) return false;
|
|
122
|
+
i += 1;
|
|
123
|
+
if (i >= to) return false;
|
|
124
|
+
for (; i < to; i += 1) {
|
|
125
|
+
c = _charCodeAt(s, i);
|
|
126
|
+
if (!_isUnreserved(c) && !_isSubDelim(c) && c !== 58) return false;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
/** @internal The authority of a URL a reader was GIVEN, as RFC 3986 sec. 3.2 writes it:
|
|
131
|
+
* [ userinfo "@" ] host [ ":" port ], where host is a non-empty reg-name, an IPv6 literal in
|
|
132
|
+
* brackets, or an IPvFuture literal. RFC 7230 sec. 2.7.1 forbids an empty host for http, so the
|
|
133
|
+
* host is required here. The structure is checked; the reg-name's own bytes are not held to DNS
|
|
134
|
+
* label rules, which is what a URL this toolkit EMITS must satisfy and is not what RFC 3986
|
|
135
|
+
* conformance means. Applying the emit rule to a value someone else wrote rejects legal URLs, and
|
|
136
|
+
* dropping the structure with it accepts "http://:80". */
|
|
137
|
+
function _validReadAuthority(s, from, to) {
|
|
138
|
+
var i, at = -1;
|
|
139
|
+
for (i = from; i < to; i += 1) { if (_charCodeAt(s, i) === 64) at = i; }
|
|
140
|
+
if (at !== -1 && !_componentBytesOk(s, from, at, _isUserinfoByte)) return false;
|
|
141
|
+
var hostStart = at === -1 ? from : at + 1;
|
|
142
|
+
if (hostStart >= to) return false;
|
|
143
|
+
if (_charCodeAt(s, hostStart) === 91) {
|
|
144
|
+
var close = -1;
|
|
145
|
+
for (i = hostStart + 1; i < to; i += 1) { if (_charCodeAt(s, i) === 93) { close = i; break; } }
|
|
146
|
+
if (close === -1) return false;
|
|
147
|
+
if (!_validUriIp6Host(s, hostStart + 1, close) && !_validIpvFuture(s, hostStart + 1, close)) return false;
|
|
148
|
+
return _validUriPort(s, close + 1, to);
|
|
149
|
+
}
|
|
150
|
+
var colon = -1;
|
|
151
|
+
for (i = hostStart; i < to; i += 1) { if (_charCodeAt(s, i) === 58) { colon = i; break; } }
|
|
152
|
+
var hostEnd = colon === -1 ? to : colon;
|
|
153
|
+
if (hostEnd === hostStart) return false;
|
|
154
|
+
if (!_componentBytesOk(s, hostStart, hostEnd, _isRegNameByte)) return false;
|
|
155
|
+
return colon === -1 ? true : _validUriPort(s, colon, to);
|
|
156
|
+
}
|
|
157
|
+
|
|
102
158
|
function _validUriAuthority(s, from, to, shape) {
|
|
159
|
+
if (shape.anyAuthority) return _validReadAuthority(s, from, to);
|
|
103
160
|
var i, c, at = -1;
|
|
104
161
|
for (i = from; i < to; i += 1) {
|
|
105
162
|
c = _charCodeAt(s, i);
|
|
@@ -136,6 +193,13 @@ var _URI_SHAPE = intrinsic.assign(intrinsic.create(null), { singleLabel: false,
|
|
|
136
193
|
/** @internal An HTTP URL (RFC 2616 sec. 3.2.2: "http:" "//" host [ ":" port ] [ abs_path [ "?" query ]]):
|
|
137
194
|
* no userinfo, a host of one label or many, a query only after an absolute path, no fragment. */
|
|
138
195
|
var _HTTP_URL_SHAPE = intrinsic.assign(intrinsic.create(null), { singleLabel: true, userinfo: false, emptyAuthority: false, emptyHost: false, queryNeedsPath: true, fragment: false });
|
|
196
|
+
/** @internal An http or https URL as RFC 7230 sec. 2.7.1 and 2.7.2 write it: "http:" "//"
|
|
197
|
+
* authority path-abempty [ "?" query ], where path-abempty MAY be empty, so a query sits directly
|
|
198
|
+
* after the authority, and where a fragment is allowed on the URI reference (RFC 3986 sec. 3.5).
|
|
199
|
+
* This is the shape a reader holds a URL it was GIVEN to. The shape above is what the builders
|
|
200
|
+
* hold the URLs they EMIT to, which is deliberately narrower, and applying it to a value someone
|
|
201
|
+
* else wrote reports a conforming URL. */
|
|
202
|
+
var _HTTP_URL_READ_SHAPE = intrinsic.assign(intrinsic.create(null), { singleLabel: true, userinfo: true, emptyAuthority: false, emptyHost: false, queryNeedsPath: false, fragment: true, anyAuthority: true });
|
|
139
203
|
/** @internal An LDAP URL (RFC 4516 sec. 2: scheme "://" [host [":" port]] ["/" dn ...]): no
|
|
140
204
|
* userinfo, a host of any label count that may be left out, with or without a port, and no
|
|
141
205
|
* fragment. The dn, attributes, scope and filter after the "/" are not parsed. */
|
|
@@ -143,6 +207,17 @@ var _LDAP_URL_SHAPE = intrinsic.assign(intrinsic.create(null), { singleLabel: tr
|
|
|
143
207
|
|
|
144
208
|
function _looksLikeUri(s) { return _uriShape(s, _URI_SHAPE); }
|
|
145
209
|
function isHttpUrl(s) { return typeof s === "string" && guard.name.uriSchemeIs(s, "http") && _uriShape(s, _HTTP_URL_SHAPE); }
|
|
210
|
+
/** @internal The scheme of a well-formed http or https URL as a READER judges one, or null where
|
|
211
|
+
* the string is not one of either. Carrying the scheme token is not the same as being a URI of
|
|
212
|
+
* that scheme: RFC 7230 sec. 2.7.1 writes http-URI as "http:" "//" authority path-abempty and
|
|
213
|
+
* forbids an empty host, so "http:example.com/a" and "http://:80" are neither. A caller that
|
|
214
|
+
* admits one scheme and not the other compares the answer rather than testing a prefix. */
|
|
215
|
+
function httpUrlScheme(s) {
|
|
216
|
+
if (typeof s !== "string" || !_uriShape(s, _HTTP_URL_READ_SHAPE)) return null;
|
|
217
|
+
if (guard.name.uriSchemeIs(s, "http")) return "http";
|
|
218
|
+
if (guard.name.uriSchemeIs(s, "https")) return "https";
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
146
221
|
function isLdapUrl(s) { return typeof s === "string" && guard.name.uriSchemeIs(s, "ldap") && _uriShape(s, _LDAP_URL_SHAPE); }
|
|
147
222
|
|
|
148
223
|
function _uriShape(s, shape) {
|
|
@@ -1378,4 +1453,4 @@ function samePublicKey(spkiA, spkiB) {
|
|
|
1378
1453
|
return _keyEquals(ka, kb) === true;
|
|
1379
1454
|
}
|
|
1380
1455
|
|
|
1381
|
-
module.exports = intrinsic.freeze({ makeBuilder: makeBuilder, reqDenseArray: reqDenseArrayImpl, KU_BIT: KU_BIT, tbsNameField: tbsNameField, tbsSerialNumber: tbsSerialNumber, randomSerial: randomSerial, skiValueOf: skiValueOf, isHttpUrl: isHttpUrl, isLdapUrl: isLdapUrl });
|
|
1456
|
+
module.exports = intrinsic.freeze({ makeBuilder: makeBuilder, reqDenseArray: reqDenseArrayImpl, KU_BIT: KU_BIT, tbsNameField: tbsNameField, tbsSerialNumber: tbsSerialNumber, randomSerial: randomSerial, skiValueOf: skiValueOf, isHttpUrl: isHttpUrl, httpUrlScheme: httpUrlScheme, isLdapUrl: isLdapUrl });
|
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:d298e2df-b5dc-4670-a00b-3f63a3d1a174",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-09-
|
|
8
|
+
"timestamp": "2026-09-24T09:13:14.788Z",
|
|
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.18",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.8.
|
|
25
|
+
"version": "0.8.18",
|
|
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.18",
|
|
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.18",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|