@blamejs/pki 0.2.8 → 0.2.10
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 +18 -0
- package/README.md +3 -2
- package/index.js +2 -0
- package/lib/framework-error.js +8 -0
- package/lib/lint.js +567 -0
- package/lib/oid.js +24 -1
- package/lib/path-validate.js +190 -0
- package/lib/webcrypto.js +24 -2
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ 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.2.10 — 2026-07-13
|
|
8
|
+
|
|
9
|
+
Certificate linting arrives as pki.lint -- graded, advisory conformance findings against the RFC 5280 profile and a representative CA/Browser Forum TLS Baseline Requirements subset.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.lint.certificate(input, opts) lints a certificate against the RFC 5280 profile and a representative CA/Browser Forum TLS BR subset, returning a report of graded advisory findings (id, severity, source, spec citation, message). The data path never throws -- malformed input becomes a fatal lint/unparseable finding rather than an exception -- so a corpus lints without per-file error handling; only config-time misuse raises a typed LintError.
|
|
14
|
+
- pki.lint.rules(profile) and pki.lint.profiles() enumerate the rule registry and the available profiles (rfc5280, cabf-tls) so findings are traceable to a stable id and a spec clause.
|
|
15
|
+
|
|
16
|
+
## v0.2.9 — 2026-07-13
|
|
17
|
+
|
|
18
|
+
Certification-path validation verifies composite ML-DSA signatures, accepting a certificate only when both its post-quantum and traditional components verify.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- pki.path.validate verifies composite ML-DSA certificate signatures (draft-ietf-lamps-pq-composite-sigs): a post-quantum ML-DSA paired with a traditional RSA / ECDSA / EdDSA, accepted only when BOTH components verify over the domain-separated message representative -- an all-components-must-verify rule (an OR would be a downgrade). The same combinator verifies a composite-signed CRL or OCSP response. Proven against the draft's official known-answer test vectors.
|
|
23
|
+
- The 18 composite algorithm identifiers (1.3.6.1.5.5.7.6.37-54) are registered in the OID registry and their AlgorithmIdentifier parameters-absent requirement is enforced across every format that carries a signature algorithm.
|
|
24
|
+
|
|
7
25
|
## v0.2.8 — 2026-07-13
|
|
8
26
|
|
|
9
27
|
pki.webcrypto rejects an AES key of invalid length at import instead of deferring the failure to first use.
|
package/README.md
CHANGED
|
@@ -220,7 +220,7 @@ is callable today; nothing below is a stub.
|
|
|
220
220
|
| `pki.acme` | RFC 8555 / 8737 / 8738 / 9773 ACME message layer over `pki.jose` — resource-object validators (closed status enums, conditional-required fields, unknown fields ignored), the three §7.1.6 state machines, request builders (newAccount + EAB, newOrder + `replaces`, finalize with CSR identifier-set match and account-key-reuse rejection, challenge responses, deactivation, revokeCert in both key modes, the keyChange nested JWS, POST-as-GET), the http-01 / dns-01 / tls-alpn-01 challenge computations, the dns/ip identifier validators, and the ARI certID (serial sign-padding preserved). A message layer, not an HTTP client — transport-injectable, fail-closed — `validate`, `identify`, `assertTransition`, the builders, `keyAuthorization`, `http01`, `dns01`, `tlsAlpn01Extension`, `verifyTlsAlpn01`, `ariCertId` |
|
|
221
221
|
| `pki.schema.smime` | Decode S/MIME ESS signed-attribute values (RFC 5035 / RFC 8551) — `parseSigningCertificate` / `parseSigningCertificateV2` bind a signature to its signing certificate (cert hash, hash algorithm, issuer `GeneralNames` + serial), `parseSmimeCapabilities` decodes the ordered capability list, and `decodeAttribute` OID-dispatches a CMS attribute (enforcing the single-value rule, recognize-and-defer for unknown types). A companion decoder for CMS signed attributes, not an auto-routed format, fail-closed — `parseSigningCertificate`, `parseSigningCertificateV2`, `parseSmimeCapabilities`, `decodeAttribute` |
|
|
222
222
|
| `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` / `encode` / `embeddedDer` plus the schema combinators |
|
|
223
|
-
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining, validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
|
|
223
|
+
| `pki.path` | RFC 5280 §6 certification-path validation — `validate` runs the §6.1 state machine (signature chaining across RSA, ECDSA, EdDSA, ML-DSA, SLH-DSA and hybrid composite ML-DSA signatures — a composite is accepted only when **both** its post-quantum and traditional components verify; validity windows, name chaining, basic constraints and path length, key usage, name constraints, the certificate-policy tree) over an ordered path and a trust anchor, returning a structured verdict with per-check reason codes, and enforces a `pki.trust` anchor's per-purpose distrust-after dates and delegator purposes via `checkPurpose`; `crlChecker` supplies CRL-based revocation — including partitioned/sharded CRLs, whose §6.3.3 Distribution Point ↔ IDP correspondence lets a corresponding full-reason shard establish non-revocation — and `ocspChecker` supplies OCSP-based revocation (RFC 6960 — CertID binding, responder authorization, signature, currency) over the same pluggable hook. Pure and re-entrant, fail-closed — `validate`, `crlChecker`, `ocspChecker` |
|
|
224
224
|
| `pki.ct` | Parse RFC 6962 Certificate Transparency SCT lists — `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries in the SCT extension (a TLS-presentation-language payload inside the §3.3 double DER wrap) into per-SCT log id, exact `timestamp` (BigInt), named signature algorithm, and raw signature; `reconstructSignedData` rebuilds the exact `digitally-signed` preimage for external verification. Structure decoded, crypto surfaced raw, bounded decode, fail-closed — `parseSctList`, `reconstructSignedData` |
|
|
225
225
|
| `pki.merkle` | RFC 6962 / RFC 9162 Merkle-tree proof verification — `leafHash` / `nodeHash` / `emptyRootHash` build the domain-separated (0x00 leaf / 0x01 node) SHA-256 tree hashes; `verifyInclusion` folds an audit proof back to a root and `verifyConsistency` reconstructs both the old and new root (the append-only guarantee), each constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
|
|
226
226
|
| `pki.trust` | Mozilla / CCADB trust-store ingestion — `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export into one identical constraint-carrying anchor shape: the per-purpose trust bits (only `CKT_NSS_TRUSTED_DELEGATOR` grants) and the per-purpose distrust-after dates the bare root list omits. Certificate and trust objects pair by byte-exact issuer + serial (never adjacency) and are cross-checked against the parsed DER, so metadata can never attach to the wrong root; `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
|
@@ -229,8 +229,9 @@ is callable today; nothing below is a stub.
|
|
|
229
229
|
| `pki.sigstore` | Offline verifier for a Sigstore bundle — the exact artifact `npm publish --provenance` produces and the registry serves. `verifyBundle` composes five fail-closed legs against caller-pinned trust (Fulcio CA roots + Rekor log keys, never trusted from the bundle): the DSSE signature over its PAE preimage under the Fulcio leaf key; the ephemeral Fulcio certificate chain, validated as of the Rekor log time; the RFC 9162 inclusion proof folded to a Rekor-signed tree root; the log entry bound to this exact signature; and the in-toto SLSA subject digest the caller confirms against the published artifact. Zero runtime deps — reuses the X.509 parser, RFC 5280 path validator, and Merkle verifier; the net-new codecs are the DSSE PAE byte-builder and a fail-closed JSON reader. `pae`, `parseBundle`, `verifyBundle` |
|
|
230
230
|
| `pki.inspect` | Human-readable certificate inspection — the pure-JS equivalent of `openssl x509 -text`. `certificate(pem \| der \| parsed)` renders a familiar OpenSSL-style report: version, serial, signature algorithm, issuer/subject distinguished names, validity, public-key details (curve or modulus size + the raw point/modulus), every decoded extension with its critical flag, and the signature. Built over the strict parser and the two-way OID registry, so it names extension and algorithm OIDs an OpenSSL build shows only as raw bytes. No OpenSSL dependency; the format is stable and OpenSSL-familiar rather than pinned to one OpenSSL version; a malformed extension falls back to a hex dump rather than throwing — `certificate` |
|
|
231
231
|
| `pki.webauthn` | WebAuthn / passkey attestation verification — offline trust evaluation of a W3C WebAuthn (Level 3) attestation. `parseAttestationObject(bytes)` decodes the CBOR attestation object + authenticatorData + COSE credential key over the strict `pki.cbor` codec; `verify(attestationObject, clientDataHash, opts)` checks the attestation-statement signature and each format's structural bindings for **packed / tpm / android-key / apple / fido-u2f / none** — the x5c leaf key, the apple nonce, the tpm `certInfo` Name/`extraData` over the `pubArea`, the android `KeyDescription`, the fido-u2f `verificationData` — binding the credential public key to each attestation (via the signed authenticatorData for packed/fido-u2f, or a cert/`pubArea`-key equality check for android-key/apple/tpm) and enforcing each leaf's certificate requirements. The credential-key check covers the full WebAuthn COSE algorithm set — ES256/384/512, RS256/384/512, PS256, EdDSA (Ed25519), and the RFC 9864 fully-specified identifiers **ESP256/384/512, Ed25519, and Ed448** — validating the public-key point on its curve, rejecting the compressed EC point form, and enforcing a minimally-encoded DER ECDSA signature. A verifier, not a ceremony client; fail-closed with typed `webauthn/*` errors. Chaining the returned trust path to a pinned root (and aaguid→root via FIDO MDS) is the caller's step through `pki.path.validate` — `parseAttestationObject`, `verify` |
|
|
232
|
+
| `pki.lint` | Certificate linting — the zlint / pkilint of JavaScript. `certificate(pem \| der \| parsed, opts)` walks a parsed certificate and emits graded, advisory findings — each with a stable id, a severity (`fatal` > `error` > `warn` > `notice`), a source, a spec-clause citation, and a message — against the RFC 5280 profile plus a representative CA/Browser Forum TLS BR subset (serial sign/size, validity ordering + the the SC081v3 reducing validity schedule, keyCertSign coherence, unknown critical extensions, empty-subject SAN, SKI/AKI presence, SAN required + CN-in-SAN, dNSName syntax, serverAuth EKU, weak keys). Unlike every other entry the DATA path never throws: hostile bytes return a `fatal` `lint/unparseable` finding (with the strict parser's code) so a whole directory lints without a try/catch; only config-time misuse throws a typed `LintError`. `certificate`, `rules`, `profiles` |
|
|
232
233
|
| `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
|
|
233
|
-
| `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`, each carrying a stable `code` in `domain/reason` form |
|
|
234
|
+
| `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError` / `AttrCertError` / `CrmfError` / `Pkcs12Error` / `CmpError` / `PathError` / `CtError` / `JoseError` / `AcmeError` / `WebauthnError` / `LintError`, each carrying a stable `code` in `domain/reason` form |
|
|
234
235
|
| `pki` CLI | `pki version`, `pki oid <dotted\|name>`, `pki parse <cert>` |
|
|
235
236
|
|
|
236
237
|
### CLI
|
package/index.js
CHANGED
|
@@ -45,6 +45,7 @@ var jose = require("./lib/jose");
|
|
|
45
45
|
var acme = require("./lib/acme");
|
|
46
46
|
var trust = require("./lib/trust");
|
|
47
47
|
var inspect = require("./lib/inspect");
|
|
48
|
+
var lint = require("./lib/lint");
|
|
48
49
|
var webauthn = require("./lib/webauthn");
|
|
49
50
|
|
|
50
51
|
module.exports = {
|
|
@@ -117,6 +118,7 @@ module.exports = {
|
|
|
117
118
|
// OID registry, naming extension/algorithm OIDs OpenSSL shows only as raw bytes.
|
|
118
119
|
// Pure, no OpenSSL dependency; best-effort (a bad extension falls back to hex).
|
|
119
120
|
inspect: inspect,
|
|
121
|
+
lint: lint,
|
|
120
122
|
// `webauthn` verifies a W3C WebAuthn / passkey attestation -- pki.webauthn.verify
|
|
121
123
|
// checks the attestation-statement signature + each format's structural bindings
|
|
122
124
|
// (packed / tpm / android-key / apple / fido-u2f / none) and surfaces the x5c chain
|
package/lib/framework-error.js
CHANGED
|
@@ -303,6 +303,13 @@ var InspectError = defineClass("InspectError", { withCause: true });
|
|
|
303
303
|
// its bounded layout, an attestation-statement signature that does not verify, or a
|
|
304
304
|
// trust path that does not reach the caller's anchor. Fail-closed, typed reason.
|
|
305
305
|
var WebauthnError = defineClass("WebauthnError", { withCause: true });
|
|
306
|
+
// LintError -- CONFIG-time misuse of the certificate linter (pki.lint) ONLY: an
|
|
307
|
+
// unknown profile name, an out-of-range severity threshold, or an input that is
|
|
308
|
+
// neither a parsed structure, DER Buffer, nor PEM string. This is the lint engine's
|
|
309
|
+
// SOLE throw path: unlike every other toolkit entry, the lint DATA path never throws
|
|
310
|
+
// on malformed input -- a linter surveys a corpus that includes malformed members,
|
|
311
|
+
// so hostile bytes surface a `fatal` finding (lint/unparseable) and return a report.
|
|
312
|
+
var LintError = defineClass("LintError", { withCause: true });
|
|
306
313
|
|
|
307
314
|
module.exports = {
|
|
308
315
|
PkiError: PkiError,
|
|
@@ -331,6 +338,7 @@ module.exports = {
|
|
|
331
338
|
SigstoreError: SigstoreError,
|
|
332
339
|
InspectError: InspectError,
|
|
333
340
|
WebauthnError: WebauthnError,
|
|
341
|
+
LintError: LintError,
|
|
334
342
|
MerkleError: MerkleError,
|
|
335
343
|
SmimeError: SmimeError,
|
|
336
344
|
CsrattrsError: CsrattrsError,
|
package/lib/lint.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @module pki.lint
|
|
6
|
+
* @nav Tooling
|
|
7
|
+
* @title Lint
|
|
8
|
+
* @intro The certificate LINTING engine -- the zlint / pkilint of JavaScript. It walks an
|
|
9
|
+
* ALREADY-PARSED certificate (from `pki.schema.x509.parse`, whose extension values it
|
|
10
|
+
* decodes with the shared RFC 5280 decoders) and emits graded, advisory FINDINGS: each
|
|
11
|
+
* with a stable id, a severity (`fatal` > `error` > `warn` > `notice` > `pass`), a source,
|
|
12
|
+
* a spec-clause citation, and a human message. It ships the RFC 5280 certificate profile
|
|
13
|
+
* plus a representative CA/Browser Forum TLS Baseline Requirements subset.
|
|
14
|
+
*
|
|
15
|
+
* Unlike every other toolkit entry, the lint DATA path NEVER throws. A linter surveys a
|
|
16
|
+
* corpus that includes malformed members, so `pki.lint.certificate(hostileBytes)` returns
|
|
17
|
+
* a report whose worst finding is a `fatal` id `lint/unparseable` (carrying the inner
|
|
18
|
+
* `PkiError.code`) rather than raising. The SOLE throw path is CONFIG-time misuse -- an
|
|
19
|
+
* unknown profile, an out-of-range severity threshold, or a wrong-type input -- which
|
|
20
|
+
* raises a typed `LintError`. This deliberate inversion of the toolkit's fail-closed-throw
|
|
21
|
+
* posture is what lets an operator lint a whole directory without a try/catch per file.
|
|
22
|
+
* @spec RFC 5280, CA/Browser Forum TLS Baseline Requirements
|
|
23
|
+
* @card Lint a certificate against RFC 5280 + CABF TLS BR, in pure JS.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
var net = require("net");
|
|
27
|
+
var frameworkError = require("./framework-error");
|
|
28
|
+
var asn1 = require("./asn1-der");
|
|
29
|
+
var oid = require("./oid");
|
|
30
|
+
var x509 = require("./schema-x509");
|
|
31
|
+
var pkix = require("./schema-pkix");
|
|
32
|
+
var C = require("./constants");
|
|
33
|
+
|
|
34
|
+
var MS_PER_DAY = C.TIME.days(1);
|
|
35
|
+
|
|
36
|
+
var LintError = frameworkError.LintError;
|
|
37
|
+
function _cfg(code, message, cause) { return new LintError(code, message, cause); }
|
|
38
|
+
|
|
39
|
+
// Strict-parse pre-emption: this toolkit's parser is strict-DER, so several RFC 5280
|
|
40
|
+
// profile violations a lenient linter (zlint/pkilint) would report as a SPECIFIC finding
|
|
41
|
+
// are instead rejected fail-closed at PARSE and surface here as a single `fatal`
|
|
42
|
+
// lint/unparseable whose context.code names the exact structural violation -- e.g. a
|
|
43
|
+
// duplicate extension OID (x509/duplicate-extension), a pathLenConstraint without cA
|
|
44
|
+
// (bad-basic-constraints), an explicit cA=FALSE, or a signatureAlgorithm/tbs mismatch.
|
|
45
|
+
// The registry therefore ships no dedicated rule for a violation the parser already
|
|
46
|
+
// rejects (a dead rule that could never fire); the specific reason is preserved in the
|
|
47
|
+
// unparseable finding's context.
|
|
48
|
+
//
|
|
49
|
+
// The shared RFC 5280 extension decoders, composed exactly as path-validate / inspect
|
|
50
|
+
// compose them -- the linter owns no second decoder table.
|
|
51
|
+
var NS = pkix.makeNS("lint", LintError, oid);
|
|
52
|
+
var EXT_DECODERS = pkix.certExtensionDecoders(NS).byOid;
|
|
53
|
+
|
|
54
|
+
// Coverage note: the rules and helpers below carry defensive guards -- `|| []` / `|| ""`
|
|
55
|
+
// on fields the strict parser ALWAYS sets (extensions, serialNumberHex, subject.rdns), and
|
|
56
|
+
// `!Buffer.isBuffer` / `instanceof Date` / minimal-encoding checks against malformed-but-
|
|
57
|
+
// parsed shapes the strict parser never produces. These are fail-safe belts, not reachable
|
|
58
|
+
// paths; they stay verified-unreachable (documented) rather than removed, since the lint
|
|
59
|
+
// data path must never throw even on an unexpected upstream shape.
|
|
60
|
+
|
|
61
|
+
// ---- the advisory result surface (the Do-FIRST: graded findings, not a throw) ----
|
|
62
|
+
|
|
63
|
+
// Severity ordering (zlint's LintStatus). NA (not applicable) and NE (not effective --
|
|
64
|
+
// outside a rule's date window) are rule OUTCOMES, tracked in counts, not severities.
|
|
65
|
+
var SEVERITY = { fatal: 5, error: 4, warn: 3, notice: 2, pass: 1 };
|
|
66
|
+
var VALID_SEVERITY = Object.keys(SEVERITY);
|
|
67
|
+
|
|
68
|
+
function _worst(findings) {
|
|
69
|
+
var w = null, wv = 0;
|
|
70
|
+
findings.forEach(function (f) { var v = SEVERITY[f.severity] || 0; if (v > wv) { wv = v; w = f.severity; } });
|
|
71
|
+
return w;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A profile groups rules by source; a rule is `{ id, severity, source, citation,
|
|
75
|
+
// appliesTo?, effectiveDate?, check }`. `check(cert, ctx)` returns null (pass) or a detail
|
|
76
|
+
// / array of details, each becoming one finding under the rule. A detail carries only an
|
|
77
|
+
// optional `context` object; the human message is the rule's.
|
|
78
|
+
function _finding(rule, detail) {
|
|
79
|
+
var f = { id: rule.id, severity: rule.severity, source: rule.source, citation: rule.citation, message: rule.message };
|
|
80
|
+
if (detail && detail.context) f.context = detail.context;
|
|
81
|
+
return f;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- ingestion: bytes/PEM/parsed -> parsed, or a fatal lint/unparseable finding ----
|
|
85
|
+
|
|
86
|
+
function _looksParsed(o) {
|
|
87
|
+
return o && typeof o === "object" && !Buffer.isBuffer(o) &&
|
|
88
|
+
Buffer.isBuffer(o.tbsBytes) && o.validity && o.subjectPublicKeyInfo && Array.isArray(o.extensions);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Returns { cert } on success, or { fatal: <Finding> } when hostile bytes do not parse
|
|
92
|
+
// (the never-throw data path). Throws LintError ONLY on a wrong-TYPE input (config misuse).
|
|
93
|
+
function _ingest(input) {
|
|
94
|
+
if (_looksParsed(input)) return { cert: input };
|
|
95
|
+
var der;
|
|
96
|
+
if (Buffer.isBuffer(input)) der = input;
|
|
97
|
+
else if (typeof input === "string") {
|
|
98
|
+
try { der = x509.pemDecode(input, "CERTIFICATE"); }
|
|
99
|
+
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 } } }; }
|
|
100
|
+
} else {
|
|
101
|
+
throw _cfg("lint/bad-input", "pki.lint input must be a parsed certificate, a DER Buffer, or a PEM string");
|
|
102
|
+
}
|
|
103
|
+
try { return { cert: x509.parse(der) }; }
|
|
104
|
+
catch (e) { return { fatal: { id: "lint/unparseable", severity: "fatal", source: "engine", citation: "RFC 5280", message: "input is not a well-formed X.509 certificate", context: { code: e.code } } }; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- per-cert context the rules read ----
|
|
108
|
+
|
|
109
|
+
function _findRaw(cert, name) {
|
|
110
|
+
var dotted = oid.byName(name);
|
|
111
|
+
var list = cert.extensions || [];
|
|
112
|
+
for (var i = 0; i < list.length; i++) { if (list[i].oid === dotted) return list[i]; }
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// Decode a known extension to { critical, value }, or null when absent OR undecodable.
|
|
116
|
+
// The undecodable case is surfaced by the dedicated `extension-undecodable` rule; every
|
|
117
|
+
// other rule treats an undecodable extension as "not usable" and simply does not fire.
|
|
118
|
+
// allow:swallow-unverified the decode fault is reported by the extension-undecodable rule
|
|
119
|
+
function _decodeOrNull(cert, name) {
|
|
120
|
+
var ext = _findRaw(cert, name);
|
|
121
|
+
if (!ext) return null;
|
|
122
|
+
var dec = EXT_DECODERS[oid.byName(name)];
|
|
123
|
+
if (!dec) return null;
|
|
124
|
+
try { return { critical: ext.critical, value: dec(ext.value) }; }
|
|
125
|
+
catch (_e) { return null; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _hasEku(cert, ekuName) {
|
|
129
|
+
var d = _decodeOrNull(cert, "extKeyUsage");
|
|
130
|
+
return !!(d && Array.isArray(d.value) && d.value.indexOf(oid.byName(ekuName)) !== -1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _makeCtx(cert, profile) {
|
|
134
|
+
var explicitTls = profile === "cabf-tls";
|
|
135
|
+
// A CA certificate is not a TLS server (leaf) certificate even when it carries a
|
|
136
|
+
// serverAuth EKU, so the default profile must NOT apply the CABF leaf rules (SAN
|
|
137
|
+
// required, CN-in-SAN, validity ceiling, ...) to it. An explicit cabf-tls selection
|
|
138
|
+
// still lints whatever the caller hands it as a server cert.
|
|
139
|
+
var bc = _decodeOrNull(cert, "basicConstraints");
|
|
140
|
+
var isCa = !!(bc && bc.value && bc.value.cA === true);
|
|
141
|
+
return {
|
|
142
|
+
profile: profile,
|
|
143
|
+
explicitTlsProfile: explicitTls,
|
|
144
|
+
isTlsServerCert: explicitTls || (_hasEku(cert, "serverAuth") && !isCa),
|
|
145
|
+
decode: function (name) { return _decodeOrNull(cert, name); },
|
|
146
|
+
raw: function (name) { return _findRaw(cert, name); },
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- the runner ----
|
|
151
|
+
|
|
152
|
+
// A date-gated rule is effective for a cert only when the cert's notBefore is at or after
|
|
153
|
+
// the rule's effectiveDate; a cert issued before it reports NE (not effective, never fires).
|
|
154
|
+
// This encodes a scheduled requirement (the CABF validity-day ceiling) against issuance date.
|
|
155
|
+
function _effective(rule, cert) {
|
|
156
|
+
if (!rule.effectiveDate) return true;
|
|
157
|
+
var nb = cert.validity && cert.validity.notBefore;
|
|
158
|
+
return (nb instanceof Date) && nb.getTime() >= rule.effectiveDate.getTime();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _runLints(rules, cert, ctx) {
|
|
162
|
+
var findings = [], counts = { fatal: 0, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }, ran = [];
|
|
163
|
+
rules.forEach(function (rule) {
|
|
164
|
+
if (rule.appliesTo && !rule.appliesTo(cert, ctx)) { counts.na++; return; }
|
|
165
|
+
if (!_effective(rule, cert)) { counts.ne++; return; }
|
|
166
|
+
ran.push(rule.id);
|
|
167
|
+
var res = rule.check(cert, ctx);
|
|
168
|
+
if (res == null || res === false) { counts.pass++; return; }
|
|
169
|
+
var details = Array.isArray(res) ? res : [res];
|
|
170
|
+
details.forEach(function (d) {
|
|
171
|
+
var f = _finding(rule, d === true ? null : d);
|
|
172
|
+
findings.push(f);
|
|
173
|
+
counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
return { findings: findings, counts: counts, ran: ran };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- rule registry ----
|
|
180
|
+
|
|
181
|
+
function _serialOctets(cert) {
|
|
182
|
+
var hex = cert.serialNumberHex || "";
|
|
183
|
+
if (hex.length % 2) hex = "0" + hex;
|
|
184
|
+
var buf = Buffer.from(hex, "hex");
|
|
185
|
+
// Strip a single DER positive-sign 00 (present only when the value's high bit is set);
|
|
186
|
+
// the octet COUNT the profile bounds is the value's, not the encoding's leading pad.
|
|
187
|
+
if (buf.length > 1 && buf[0] === 0x00 && (buf[1] & 0x80)) buf = buf.subarray(1);
|
|
188
|
+
return buf.length;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// dNSName syntax (a representative CABF check): no whitespace, no leading/trailing dot,
|
|
192
|
+
// no empty label, and no underscore (forbidden in a dNSName). IDN must already be an
|
|
193
|
+
// A-label (we do not transcode). Returns a reason string, or null when well-formed.
|
|
194
|
+
function _dnsNameProblem(s) {
|
|
195
|
+
if (typeof s !== "string" || !s.length) return "empty";
|
|
196
|
+
if (s.length > 253) return "exceeds 253 octets";
|
|
197
|
+
if (/\s/.test(s)) return "whitespace";
|
|
198
|
+
if (s.charAt(0) === "." || s.charAt(s.length - 1) === ".") return "leading/trailing dot";
|
|
199
|
+
if (s.indexOf("_") !== -1) return "underscore forbidden in dNSName";
|
|
200
|
+
var labels = s.split(".");
|
|
201
|
+
for (var i = 0; i < labels.length; i++) {
|
|
202
|
+
var label = labels[i];
|
|
203
|
+
if (label.length === 0) return "empty label";
|
|
204
|
+
if (label.length > 63) return "label exceeds 63 octets";
|
|
205
|
+
// A leftmost "*" wildcard label is permitted only when at least one more label follows
|
|
206
|
+
// (a bare "*" is not a domain name).
|
|
207
|
+
if (i === 0 && label === "*") {
|
|
208
|
+
if (labels.length < 2) return "bare wildcard";
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
// RFC 1034 preferred name syntax: an LDH label that neither begins nor ends with a
|
|
212
|
+
// hyphen. Rejects "-bad" / "bad-" and any non-letter/digit/hyphen character.
|
|
213
|
+
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(label)) return "invalid label syntax";
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A genuine IPv4 or IPv6 literal (validated by net.isIP, which rejects an out-of-range
|
|
219
|
+
// dotted quad like 999.999.999.999 or a colon-bearing non-address like "api:443") -- a CN
|
|
220
|
+
// validated against an iPAddress SAN rather than a dNSName.
|
|
221
|
+
function _looksLikeIp(s) { return typeof s === "string" && net.isIP(s) !== 0; }
|
|
222
|
+
|
|
223
|
+
function _sanDnsNames(ctx) {
|
|
224
|
+
var d = ctx.decode("subjectAltName");
|
|
225
|
+
if (!d || !d.value || !Array.isArray(d.value.names)) return [];
|
|
226
|
+
return d.value.names.filter(function (n) { return n && n.tagNumber === 2; }).map(function (n) { return n.value; });
|
|
227
|
+
}
|
|
228
|
+
// The subject CN string values (an RDN attribute of type commonName), if any.
|
|
229
|
+
function _subjectCNs(cert) {
|
|
230
|
+
var out = [];
|
|
231
|
+
((cert.subject && cert.subject.rdns) || []).forEach(function (rdn) {
|
|
232
|
+
rdn.forEach(function (a) { if (a.type === oid.byName("commonName") && typeof a.value === "string") out.push(a.value); });
|
|
233
|
+
});
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// An extension is "recognized" (RFC 5280 4.2: a consumer MUST reject a critical extension
|
|
238
|
+
// it does NOT recognize / cannot process) when the toolkit has a DECODER for it -- the
|
|
239
|
+
// shared certExtensionDecoders table IS the set of extensions this toolkit processes. This
|
|
240
|
+
// is registry-driven (no curated list to omit a legitimately-critical extension such as
|
|
241
|
+
// policyMappings, which the table decodes) and correctly scoped to EXTENSION OIDs: an
|
|
242
|
+
// algorithm / EKU-purpose OID the name registry happens to resolve is NOT in the decoder
|
|
243
|
+
// table, so a critical extension carrying such an OID is still flagged.
|
|
244
|
+
function _isUnknownExtension(extOid) { return !EXT_DECODERS[extOid]; }
|
|
245
|
+
|
|
246
|
+
// RSA-modulus / EC-curve helpers for the weak-key lint. Each returns null when the key
|
|
247
|
+
// material cannot be read as expected (a malformed RSA modulus, or EC parameters that are
|
|
248
|
+
// absent / explicit / not a named-curve OID); the weak-key rule treats null as fail-closed
|
|
249
|
+
// (a key it cannot confirm meets the minimum is flagged, not silently passed).
|
|
250
|
+
function _rsaModulusBits(spki) {
|
|
251
|
+
try {
|
|
252
|
+
var pk = spki.publicKey && (spki.publicKey.bytes || spki.publicKey);
|
|
253
|
+
if (!Buffer.isBuffer(pk)) return null;
|
|
254
|
+
var seq = asn1.decode(pk);
|
|
255
|
+
// RSAPublicKey ::= SEQUENCE { modulus INTEGER, publicExponent INTEGER } -- exactly two
|
|
256
|
+
// INTEGERs, and the modulus MUST be positive. A negative/zero modulus (a high-bit key
|
|
257
|
+
// mis-encoded without a sign pad) or a missing exponent is malformed, not a real key.
|
|
258
|
+
if (!seq.children || seq.children.length !== 2) return null;
|
|
259
|
+
var mod = asn1.read.integer(seq.children[0]);
|
|
260
|
+
asn1.read.integer(seq.children[1]); // the exponent must be a valid INTEGER
|
|
261
|
+
if (mod <= 0n) return null;
|
|
262
|
+
return mod.toString(2).length;
|
|
263
|
+
} catch (_e) { return null; } // a modulus that will not decode cannot be sized
|
|
264
|
+
}
|
|
265
|
+
// Every RSA-family SPKI (rsaEncryption, RSASSA-PSS, RSAES-OAEP) carries an RSAPublicKey
|
|
266
|
+
// SEQUENCE, so all are weighed by modulus size; matching only "rsaEncryption" would let an
|
|
267
|
+
// RSA-PSS or RSAES-OAEP key skip the check.
|
|
268
|
+
var RSA_KEY_ALGS = { rsaEncryption: 1, rsassaPss: 1, rsaesOaep: 1 };
|
|
269
|
+
var APPROVED_EC_CURVES = ["prime256v1", "secp384r1", "secp521r1"]; // P-256 / P-384 / P-521
|
|
270
|
+
function _ecCurveName(spki) {
|
|
271
|
+
try {
|
|
272
|
+
var params = spki.algorithm && spki.algorithm.parameters;
|
|
273
|
+
if (!Buffer.isBuffer(params)) return null;
|
|
274
|
+
return oid.name(asn1.read.oid(asn1.decode(params))); // named-curve OID, or throw on explicit params
|
|
275
|
+
} catch (_e) { return null; } // explicit / invalid EC parameters are not an approved named curve
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
var RFC5280_RULES = [
|
|
279
|
+
{
|
|
280
|
+
id: "lint/rfc5280/serial-not-positive", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.2",
|
|
281
|
+
message: "the certificate serialNumber must be a positive integer",
|
|
282
|
+
check: function (cert) { return cert.serialNumber <= 0n ? true : null; },
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
id: "lint/rfc5280/serial-too-long", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.2",
|
|
286
|
+
message: "the certificate serialNumber must not exceed 20 octets",
|
|
287
|
+
// Counts the VALUE magnitude (a single DER sign-pad 0x00 stripped), not the encoded
|
|
288
|
+
// length: a 20-byte random serial whose high bit is set encodes as 21 octets but is a
|
|
289
|
+
// conformant 20-octet value, so this avoids false-positiving on the common CA serial.
|
|
290
|
+
check: function (cert) { return _serialOctets(cert) > 20 ? { context: { octets: _serialOctets(cert) } } : null; },
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
id: "lint/rfc5280/validity-inverted", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.5",
|
|
294
|
+
message: "the certificate notBefore must not be later than notAfter",
|
|
295
|
+
check: function (cert) {
|
|
296
|
+
var v = cert.validity;
|
|
297
|
+
return (v.notBefore instanceof Date && v.notAfter instanceof Date && v.notBefore.getTime() > v.notAfter.getTime()) ? true : null;
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
id: "lint/rfc5280/ca-without-keycertsign", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.3 / 4.2.1.9",
|
|
302
|
+
message: "a CA certificate (basicConstraints cA=TRUE) must assert the keyCertSign key usage",
|
|
303
|
+
appliesTo: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return !!(bc && bc.value && bc.value.cA === true); },
|
|
304
|
+
check: function (cert, ctx) { var ku = ctx.decode("keyUsage"); return (ku && ku.value && ku.value.keyCertSign === true) ? null : true; },
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
id: "lint/rfc5280/keycertsign-without-ca", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2.1.3",
|
|
308
|
+
message: "a certificate asserting the keyCertSign key usage must also assert basicConstraints cA=TRUE",
|
|
309
|
+
// The inverse coherence check: keyCertSign asserted -> cA MUST be TRUE. Fires when a
|
|
310
|
+
// cert claims certificate-signing usage but omits basicConstraints or is not a CA.
|
|
311
|
+
appliesTo: function (cert, ctx) { var ku = ctx.decode("keyUsage"); return !!(ku && ku.value && ku.value.keyCertSign === true); },
|
|
312
|
+
check: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return (bc && bc.value && bc.value.cA === true) ? null : true; },
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
id: "lint/rfc5280/unknown-critical-extension", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2",
|
|
316
|
+
message: "a critical extension is not recognized by the profile -- a conforming consumer must reject it",
|
|
317
|
+
check: function (cert) {
|
|
318
|
+
var out = [];
|
|
319
|
+
(cert.extensions || []).forEach(function (e) {
|
|
320
|
+
if (e.critical === true && _isUnknownExtension(e.oid)) out.push({ context: { oid: e.oid, name: e.name || null } });
|
|
321
|
+
});
|
|
322
|
+
return out.length ? out : null;
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
id: "lint/rfc5280/empty-subject-san-not-critical", severity: "error", source: "rfc5280", citation: "RFC 5280 4.1.2.6",
|
|
327
|
+
message: "a certificate with an empty subject must carry a subjectAltName marked critical",
|
|
328
|
+
appliesTo: function (cert) { return !((cert.subject && cert.subject.rdns) || []).length; },
|
|
329
|
+
check: function (cert, ctx) { var san = ctx.decode("subjectAltName"); return (san && san.critical === true) ? null : true; },
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
id: "lint/rfc5280/extension-undecodable", severity: "error", source: "rfc5280", citation: "RFC 5280 4.2",
|
|
333
|
+
message: "a recognized extension's value does not decode under its RFC 5280 syntax",
|
|
334
|
+
check: function (cert) {
|
|
335
|
+
var out = [];
|
|
336
|
+
(cert.extensions || []).forEach(function (e) {
|
|
337
|
+
var dec = EXT_DECODERS[e.oid];
|
|
338
|
+
if (!dec) return;
|
|
339
|
+
try { dec(e.value); }
|
|
340
|
+
catch (err) {
|
|
341
|
+
out.push({ context: { oid: e.oid, name: e.name || null, code: err.code } });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
return out.length ? out : null;
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
id: "lint/rfc5280/ski-missing", severity: "notice", source: "rfc5280", citation: "RFC 5280 4.2.1.2",
|
|
349
|
+
message: "a CA certificate should carry a subjectKeyIdentifier extension",
|
|
350
|
+
appliesTo: function (cert, ctx) { var bc = ctx.decode("basicConstraints"); return !!(bc && bc.value && bc.value.cA === true); },
|
|
351
|
+
check: function (cert, ctx) { return ctx.raw("subjectKeyIdentifier") ? null : true; },
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: "lint/rfc5280/aki-missing", severity: "notice", source: "rfc5280", citation: "RFC 5280 4.2.1.1",
|
|
355
|
+
message: "a non-self-issued certificate should carry an authorityKeyIdentifier extension",
|
|
356
|
+
appliesTo: function (cert) { return !!(cert.issuer && cert.subject && cert.issuer.dn !== cert.subject.dn); },
|
|
357
|
+
check: function (cert, ctx) { return ctx.raw("authorityKeyIdentifier") ? null : true; },
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
|
|
361
|
+
function _isTls(cert, ctx) { return ctx.isTlsServerCert; }
|
|
362
|
+
|
|
363
|
+
// The CABF TLS BR maximum validity period (in days) by certificate issuance date --
|
|
364
|
+
// the SC22 398-day ceiling (2020-09-01) reduced on the SC081v3 schedule: 200 days from
|
|
365
|
+
// 2026-03-15, 100 days from 2027-03-15, 47 days from 2029-03-15. Newest-first so the first
|
|
366
|
+
// entry whose `from` is at or before the cert's notBefore gives the applicable ceiling.
|
|
367
|
+
var VALIDITY_SCHEDULE = [
|
|
368
|
+
{ from: new Date("2029-03-15T00:00:00Z"), maxDays: 47 },
|
|
369
|
+
{ from: new Date("2027-03-15T00:00:00Z"), maxDays: 100 },
|
|
370
|
+
{ from: new Date("2026-03-15T00:00:00Z"), maxDays: 200 },
|
|
371
|
+
{ from: new Date("2020-09-01T00:00:00Z"), maxDays: 398 },
|
|
372
|
+
];
|
|
373
|
+
var VALIDITY_SCHEDULE_START = VALIDITY_SCHEDULE[VALIDITY_SCHEDULE.length - 1].from;
|
|
374
|
+
function _validityCeilingDays(notBefore) {
|
|
375
|
+
for (var i = 0; i < VALIDITY_SCHEDULE.length; i++) {
|
|
376
|
+
if (notBefore.getTime() >= VALIDITY_SCHEDULE[i].from.getTime()) return VALIDITY_SCHEDULE[i].maxDays;
|
|
377
|
+
}
|
|
378
|
+
// Unreachable: the rule's effectiveDate (VALIDITY_SCHEDULE_START) reports NE for any cert
|
|
379
|
+
// whose notBefore predates the schedule, so the loop always matches the last entry.
|
|
380
|
+
return VALIDITY_SCHEDULE[VALIDITY_SCHEDULE.length - 1].maxDays;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
var CABF_TLS_RULES = [
|
|
384
|
+
{
|
|
385
|
+
id: "lint/cabf-tls/san-missing", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.4.2.1",
|
|
386
|
+
message: "a TLS server certificate must include a usable subjectAltName extension",
|
|
387
|
+
appliesTo: _isTls,
|
|
388
|
+
// A SAN that is absent -- OR present but undecodable / carrying no names -- is not a
|
|
389
|
+
// usable subjectAltName, so all three cases are treated as missing for the TLS profile.
|
|
390
|
+
check: function (cert, ctx) { var san = ctx.decode("subjectAltName"); return (san && san.value && san.value.names && san.value.names.length) ? null : true; },
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
id: "lint/cabf-tls/cn-not-in-san", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.4.2.2",
|
|
394
|
+
message: "a subject commonName value must also appear as a subjectAltName dNSName",
|
|
395
|
+
appliesTo: _isTls,
|
|
396
|
+
check: function (cert, ctx) {
|
|
397
|
+
// dNSNames are case-insensitive (RFC 4343), so match case-folded.
|
|
398
|
+
var sans = _sanDnsNames(ctx).map(function (s) { return typeof s === "string" ? s.toLowerCase() : s; }), out = [];
|
|
399
|
+
_subjectCNs(cert).forEach(function (cn) {
|
|
400
|
+
// A commonName MUST match a dNSName OR an iPAddress SAN (CABF 7.1.4.2.2). This
|
|
401
|
+
// subset checks dNSName values only, so an IP-literal CN (validated against the
|
|
402
|
+
// iPAddress SAN this subset does not yet render) is skipped, not false-flagged.
|
|
403
|
+
if (_looksLikeIp(cn)) return;
|
|
404
|
+
if (sans.indexOf(cn.toLowerCase()) === -1) out.push({ context: { cn: cn } });
|
|
405
|
+
});
|
|
406
|
+
return out.length ? out : null;
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
id: "lint/cabf-tls/dnsname-bad-syntax", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.4.2.1",
|
|
411
|
+
message: "a subjectAltName dNSName is not well-formed",
|
|
412
|
+
appliesTo: _isTls,
|
|
413
|
+
check: function (cert, ctx) {
|
|
414
|
+
var out = [];
|
|
415
|
+
_sanDnsNames(ctx).forEach(function (name) { var p = _dnsNameProblem(name); if (p) out.push({ context: { dnsName: name, problem: p } }); });
|
|
416
|
+
return out.length ? out : null;
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: "lint/cabf-tls/eku-missing-serverauth", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 7.1.2.7.6",
|
|
421
|
+
message: "a TLS server certificate's extKeyUsage must include id-kp-serverAuth",
|
|
422
|
+
// Only meaningful when the caller EXPLICITLY lints against the TLS profile; under the
|
|
423
|
+
// default profile a cert is recognized as TLS BY its serverAuth EKU, so the check is
|
|
424
|
+
// vacuous and reports NA rather than firing on every non-TLS certificate.
|
|
425
|
+
appliesTo: function (cert, ctx) { return ctx.explicitTlsProfile; },
|
|
426
|
+
check: function (cert, ctx) { return _hasEku(cert, "serverAuth") ? null : true; },
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
id: "lint/cabf-tls/weak-key", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 6.1.5",
|
|
430
|
+
message: "the subject public key is below the CABF TLS BR minimum (RSA < 2048 bits, or a non-approved EC curve)",
|
|
431
|
+
appliesTo: _isTls,
|
|
432
|
+
check: function (cert) {
|
|
433
|
+
var spki = cert.subjectPublicKeyInfo, name = spki && spki.algorithm && spki.algorithm.name;
|
|
434
|
+
// Fail closed: a key whose size/curve cannot be confirmed to meet the minimum is
|
|
435
|
+
// flagged, not passed. RSA below 2048 bits (or an unreadable modulus), or an EC key
|
|
436
|
+
// NOT on an approved named curve (including absent / explicit / invalid parameters,
|
|
437
|
+
// which resolve to a null curve) is a finding -- CABF permits only P-256/384/521.
|
|
438
|
+
if (RSA_KEY_ALGS[name]) { var bits = _rsaModulusBits(spki); return (bits === null || bits < 2048) ? { context: { rsaBits: bits } } : null; }
|
|
439
|
+
if (name === "ecPublicKey") { var curve = _ecCurveName(spki); return APPROVED_EC_CURVES.indexOf(curve) === -1 ? { context: { curve: curve } } : null; }
|
|
440
|
+
// Any other key type (e.g. EdDSA) is out of this rule's RSA-size / EC-curve scope --
|
|
441
|
+
// key-TYPE approval for TLS is a separate concern, so weak-key does not fire here.
|
|
442
|
+
return null;
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
id: "lint/cabf-tls/validity-too-long", severity: "error", source: "cabf-tls", citation: "CABF TLS BR 6.3.2 (Ballots SC22 + SC081v3)",
|
|
447
|
+
message: "a TLS server certificate validity period exceeds the CABF maximum for its issuance date",
|
|
448
|
+
appliesTo: _isTls,
|
|
449
|
+
effectiveDate: VALIDITY_SCHEDULE_START,
|
|
450
|
+
check: function (cert) {
|
|
451
|
+
var v = cert.validity;
|
|
452
|
+
if (!(v.notBefore instanceof Date) || !(v.notAfter instanceof Date)) return null;
|
|
453
|
+
var maxDays = _validityCeilingDays(v.notBefore);
|
|
454
|
+
var days = (v.notAfter.getTime() - v.notBefore.getTime()) / MS_PER_DAY;
|
|
455
|
+
return days > maxDays ? { context: { days: Math.round(days), maxDays: maxDays } } : null;
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
];
|
|
459
|
+
|
|
460
|
+
var PROFILES = {
|
|
461
|
+
"rfc5280": RFC5280_RULES,
|
|
462
|
+
"cabf-tls": CABF_TLS_RULES,
|
|
463
|
+
};
|
|
464
|
+
var ALL_RULES = RFC5280_RULES.concat(CABF_TLS_RULES);
|
|
465
|
+
|
|
466
|
+
function _selectRules(profile) {
|
|
467
|
+
if (profile == null || profile === "all" || profile === "default") return ALL_RULES;
|
|
468
|
+
if (!PROFILES[profile]) throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\" (known: " + Object.keys(PROFILES).join(", ") + ")");
|
|
469
|
+
// An explicit cabf-tls run still needs the always-on rfc5280 structural rules? No: the
|
|
470
|
+
// caller asked for ONE profile. Return exactly that profile's rules.
|
|
471
|
+
return PROFILES[profile];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Filter the emitted findings to the threshold floor (default `notice`). The threshold
|
|
475
|
+
// is validated at the certificate() entry, so it is a known severity here; `counts` and
|
|
476
|
+
// `worst` are computed before this filter and stay complete.
|
|
477
|
+
function _applyThreshold(report, severity) {
|
|
478
|
+
var floor = SEVERITY[severity == null ? "notice" : severity];
|
|
479
|
+
return report.findings.filter(function (f) { return (SEVERITY[f.severity] || 0) >= floor; });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- public surface ----
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* @primitive pki.lint.certificate
|
|
486
|
+
* @signature pki.lint.certificate(input, opts?) -> LintReport
|
|
487
|
+
* @since 0.2.10
|
|
488
|
+
* @status experimental
|
|
489
|
+
* @spec RFC 5280, CA/Browser Forum TLS BR
|
|
490
|
+
* @related pki.schema.x509.parse, pki.inspect.certificate, pki.path.validate
|
|
491
|
+
*
|
|
492
|
+
* Lint a certificate against the RFC 5280 profile plus a representative CABF TLS BR
|
|
493
|
+
* subset. `input` is a PEM string, a DER `Buffer`, or an already-parsed
|
|
494
|
+
* `pki.schema.x509` object. Returns a `LintReport`
|
|
495
|
+
* `{ findings: [{id, severity, source, citation, message, context?}], counts, worst, ran }`.
|
|
496
|
+
*
|
|
497
|
+
* The DATA path never throws: hostile bytes produce a single `fatal` finding
|
|
498
|
+
* `lint/unparseable` rather than raising. The ONLY throw path is config-time misuse
|
|
499
|
+
* (`opts.profile` unknown, `opts.severity` out of range, or a wrong-type input) -- a
|
|
500
|
+
* typed `LintError`.
|
|
501
|
+
*
|
|
502
|
+
* @opts profile One of `pki.lint.profiles()` (default runs every profile). Selecting
|
|
503
|
+
* `"cabf-tls"` lints the input AS a TLS server certificate.
|
|
504
|
+
* @opts severity Suppress findings below this floor (default `"notice"`). `counts` and
|
|
505
|
+
* `worst` always reflect the complete, unfiltered result.
|
|
506
|
+
* @example
|
|
507
|
+
* var report = pki.lint.certificate(pemString);
|
|
508
|
+
* report.worst; // "notice" | "error" | ...
|
|
509
|
+
* report.findings.map(function (f) { return f.id; });
|
|
510
|
+
*/
|
|
511
|
+
function certificate(input, opts) {
|
|
512
|
+
opts = opts || {};
|
|
513
|
+
if (typeof opts !== "object") throw _cfg("lint/bad-input", "pki.lint options must be an object");
|
|
514
|
+
// Validate BOTH config options up front so a config error fails fast, before any work.
|
|
515
|
+
if (opts.severity != null && VALID_SEVERITY.indexOf(opts.severity) === -1) {
|
|
516
|
+
throw _cfg("lint/bad-severity", "unknown severity threshold \"" + opts.severity + "\" (known: " + VALID_SEVERITY.join(", ") + ")");
|
|
517
|
+
}
|
|
518
|
+
var rules = _selectRules(opts.profile);
|
|
519
|
+
var ingested = _ingest(input);
|
|
520
|
+
var report;
|
|
521
|
+
if (ingested.fatal) {
|
|
522
|
+
report = { findings: [ingested.fatal], counts: { fatal: 1, error: 0, warn: 0, notice: 0, pass: 0, na: 0, ne: 0 }, ran: [] };
|
|
523
|
+
} else {
|
|
524
|
+
var ctx = _makeCtx(ingested.cert, opts.profile);
|
|
525
|
+
report = _runLints(rules, ingested.cert, ctx);
|
|
526
|
+
}
|
|
527
|
+
report.worst = _worst(report.findings);
|
|
528
|
+
report.findings = _applyThreshold(report, opts.severity);
|
|
529
|
+
return report;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* @primitive pki.lint.rules
|
|
534
|
+
* @signature pki.lint.rules(profile?) -> [{id, severity, source, citation}]
|
|
535
|
+
* @since 0.2.10
|
|
536
|
+
* @status experimental
|
|
537
|
+
* @spec RFC 5280, CA/Browser Forum TLS BR
|
|
538
|
+
*
|
|
539
|
+
* Enumerate the rule registry (all rules, or one profile's). Each entry exposes its
|
|
540
|
+
* stable `id`, `severity`, `source`, and spec-clause `citation` for documentation and
|
|
541
|
+
* corpus tooling.
|
|
542
|
+
* @example
|
|
543
|
+
* pki.lint.rules("rfc5280").length; // -> a positive count
|
|
544
|
+
*/
|
|
545
|
+
function rules(profile) {
|
|
546
|
+
var set = profile == null ? ALL_RULES : (PROFILES[profile] || (function () { throw _cfg("lint/unknown-profile", "unknown lint profile \"" + profile + "\""); })());
|
|
547
|
+
return set.map(function (r) { return { id: r.id, severity: r.severity, source: r.source, citation: r.citation }; });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @primitive pki.lint.profiles
|
|
552
|
+
* @signature pki.lint.profiles() -> [string]
|
|
553
|
+
* @since 0.2.10
|
|
554
|
+
* @status experimental
|
|
555
|
+
* @spec RFC 5280, CA/Browser Forum TLS BR
|
|
556
|
+
*
|
|
557
|
+
* List the known lint-profile names.
|
|
558
|
+
* @example
|
|
559
|
+
* pki.lint.profiles(); // -> ["rfc5280", "cabf-tls"]
|
|
560
|
+
*/
|
|
561
|
+
function profiles() { return Object.keys(PROFILES); }
|
|
562
|
+
|
|
563
|
+
module.exports = {
|
|
564
|
+
certificate: certificate,
|
|
565
|
+
rules: rules,
|
|
566
|
+
profiles: profiles,
|
|
567
|
+
};
|
package/lib/oid.js
CHANGED
|
@@ -222,7 +222,19 @@ var FAMILIES = {
|
|
|
222
222
|
// identifiers (RFC 9802 sec. 4). HSS/LMS additionally has the SMIME
|
|
223
223
|
// id-alg-hss-lms-hashsig OID above (RFC 9708 / RFC 9802 share it).
|
|
224
224
|
pkixAlg: { base: [1, 3, 6, 1, 5, 5, 7, 6], of: {
|
|
225
|
-
"id-alg-xmss-hashsig": 34, "id-alg-xmssmt-hashsig": 35
|
|
225
|
+
"id-alg-xmss-hashsig": 34, "id-alg-xmssmt-hashsig": 35,
|
|
226
|
+
// Composite ML-DSA signature algorithms (draft-ietf-lamps-pq-composite-sigs
|
|
227
|
+
// sec. 6): a PQ ML-DSA paired with a traditional RSA / ECDSA / EdDSA so the
|
|
228
|
+
// certificate stays trustworthy if EITHER primitive is later broken.
|
|
229
|
+
"id-MLDSA44-RSA2048-PSS-SHA256": 37, "id-MLDSA44-RSA2048-PKCS15-SHA256": 38,
|
|
230
|
+
"id-MLDSA44-Ed25519-SHA512": 39, "id-MLDSA44-ECDSA-P256-SHA256": 40,
|
|
231
|
+
"id-MLDSA65-RSA3072-PSS-SHA512": 41, "id-MLDSA65-RSA3072-PKCS15-SHA512": 42,
|
|
232
|
+
"id-MLDSA65-RSA4096-PSS-SHA512": 43, "id-MLDSA65-RSA4096-PKCS15-SHA512": 44,
|
|
233
|
+
"id-MLDSA65-ECDSA-P256-SHA512": 45, "id-MLDSA65-ECDSA-P384-SHA512": 46,
|
|
234
|
+
"id-MLDSA65-ECDSA-brainpoolP256r1-SHA512": 47, "id-MLDSA65-Ed25519-SHA512": 48,
|
|
235
|
+
"id-MLDSA87-ECDSA-P384-SHA512": 49, "id-MLDSA87-ECDSA-brainpoolP384r1-SHA512": 50,
|
|
236
|
+
"id-MLDSA87-Ed448-SHAKE256": 51, "id-MLDSA87-RSA3072-PSS-SHA512": 52,
|
|
237
|
+
"id-MLDSA87-RSA4096-PSS-SHA512": 53, "id-MLDSA87-ECDSA-P521-SHA512": 54 } },
|
|
226
238
|
|
|
227
239
|
// RSA-KEM key-transport algorithm (RFC 9690, obsoletes RFC 5990) on the ISO
|
|
228
240
|
// 18033-2 arc -- the kem OID an RSA KEMRecipientInfo carries (distinct from the
|
|
@@ -549,6 +561,17 @@ var _PARAMS_ABSENT = new Set();
|
|
|
549
561
|
// Stateful hash-based signatures (RFC 9802 sec. 4 / RFC 9708): the parameters
|
|
550
562
|
// field MUST be absent for HSS/LMS, XMSS, and XMSS^MT public keys and signatures.
|
|
551
563
|
"id-alg-hss-lms-hashsig", "id-alg-xmss-hashsig", "id-alg-xmssmt-hashsig",
|
|
564
|
+
// Composite ML-DSA (draft-ietf-lamps-pq-composite-sigs sec. 5.3 + Figure 1:
|
|
565
|
+
// the parameters field MUST be absent for every composite AlgorithmIdentifier).
|
|
566
|
+
"id-MLDSA44-RSA2048-PSS-SHA256", "id-MLDSA44-RSA2048-PKCS15-SHA256",
|
|
567
|
+
"id-MLDSA44-Ed25519-SHA512", "id-MLDSA44-ECDSA-P256-SHA256",
|
|
568
|
+
"id-MLDSA65-RSA3072-PSS-SHA512", "id-MLDSA65-RSA3072-PKCS15-SHA512",
|
|
569
|
+
"id-MLDSA65-RSA4096-PSS-SHA512", "id-MLDSA65-RSA4096-PKCS15-SHA512",
|
|
570
|
+
"id-MLDSA65-ECDSA-P256-SHA512", "id-MLDSA65-ECDSA-P384-SHA512",
|
|
571
|
+
"id-MLDSA65-ECDSA-brainpoolP256r1-SHA512", "id-MLDSA65-Ed25519-SHA512",
|
|
572
|
+
"id-MLDSA87-ECDSA-P384-SHA512", "id-MLDSA87-ECDSA-brainpoolP384r1-SHA512",
|
|
573
|
+
"id-MLDSA87-Ed448-SHAKE256", "id-MLDSA87-RSA3072-PSS-SHA512",
|
|
574
|
+
"id-MLDSA87-RSA4096-PSS-SHA512", "id-MLDSA87-ECDSA-P521-SHA512",
|
|
552
575
|
].forEach(function (nm) {
|
|
553
576
|
var d = byName(nm);
|
|
554
577
|
// A seed-list typo must fail at module load -- admitting undefined would
|
package/lib/path-validate.js
CHANGED
|
@@ -258,6 +258,16 @@ function isDerNull(p) { return p && p.length === 2 && p[0] === 0x05 && p[1] ===
|
|
|
258
258
|
|
|
259
259
|
function resolveDescriptor(sigAlg) {
|
|
260
260
|
if (sigAlg.oid === OID_RSA_PSS) return resolveRsaPss(sigAlg.parameters);
|
|
261
|
+
var comp = COMPOSITE_ALGS[sigAlg.oid];
|
|
262
|
+
if (comp) {
|
|
263
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 5.3: parameters MUST be absent.
|
|
264
|
+
if (sigAlg.parameters !== null && sigAlg.parameters !== undefined) {
|
|
265
|
+
throw E("path/unsupported-algorithm", "composite signature algorithm parameters must be absent (draft-ietf-lamps-pq-composite-sigs sec. 5.3)");
|
|
266
|
+
}
|
|
267
|
+
// The composite public-key algorithm OID equals the signature OID; enforce
|
|
268
|
+
// the RFC 9814-style key<->signature consistency structurally (sameKeyOid).
|
|
269
|
+
return { composite: comp, sameKeyOid: true };
|
|
270
|
+
}
|
|
261
271
|
var d = SIG_ALGS[sigAlg.oid];
|
|
262
272
|
if (!d) throw E("path/unsupported-algorithm", "no verify descriptor for signature algorithm " + (sigAlg.name || sigAlg.oid));
|
|
263
273
|
// The signatureAlgorithm's parameters MUST match the algorithm's fixed shape:
|
|
@@ -316,6 +326,165 @@ function ecdsaDerToP1363(der, curve) {
|
|
|
316
326
|
return Buffer.concat([pad(r), pad(s)]);
|
|
317
327
|
}
|
|
318
328
|
|
|
329
|
+
// ---- composite ML-DSA signatures (draft-ietf-lamps-pq-composite-sigs) -------
|
|
330
|
+
// A composite signature pairs a post-quantum ML-DSA with a traditional RSA /
|
|
331
|
+
// ECDSA / EdDSA. The public key is the raw concatenation mldsaPK || tradPK
|
|
332
|
+
// (sec. 4.1) in the SPKI BIT STRING; the signature is mldsaSig || tradSig
|
|
333
|
+
// (sec. 4.3) in the signatureValue BIT STRING. Both are surfaced RAW by the
|
|
334
|
+
// x509 parser (no sub-walk). Verification (sec. 2): reconstruct
|
|
335
|
+
// M' = Prefix || Label || len(ctx) || ctx || PH(M),
|
|
336
|
+
// verify the ML-DSA component over M' with ctx = the composite Label, verify the
|
|
337
|
+
// traditional component over M' under its own hash, and accept IFF BOTH pass
|
|
338
|
+
// (THREAT-MODEL: all components must verify -- never an AND-to-OR downgrade). The
|
|
339
|
+
// ML-DSA component is the fixed-length FIRST half; the split point is its length.
|
|
340
|
+
var COMPOSITE_PREFIX = Buffer.from("CompositeAlgorithmSignatures2025", "ascii");
|
|
341
|
+
var MLDSA_COMPONENT = {
|
|
342
|
+
"ML-DSA-44": { pk: 1312, sig: 2420, oid: "id-ml-dsa-44" },
|
|
343
|
+
"ML-DSA-65": { pk: 1952, sig: 3309, oid: "id-ml-dsa-65" },
|
|
344
|
+
"ML-DSA-87": { pk: 2592, sig: 4627, oid: "id-ml-dsa-87" },
|
|
345
|
+
};
|
|
346
|
+
var EC_CURVE_OID = { "P-256": "prime256v1", "P-384": "secp384r1", "P-521": "secp521r1" };
|
|
347
|
+
var COMPOSITE_ALGS = {};
|
|
348
|
+
// _comp(name, mldsa, ph, label, trad). `trad` is exactly one component shape:
|
|
349
|
+
// { ec, hash } | { eddsa } | { rsaPss, hash, salt } | { rsaPkcs1, hash } |
|
|
350
|
+
// { unsupported } for the arms Node's WebCrypto surface cannot verify (brainpool
|
|
351
|
+
// curves; the SHAKE256/64 pre-hash) -- registered + params-guarded, deferred at
|
|
352
|
+
// verify to path/unsupported-algorithm rather than silently accepted.
|
|
353
|
+
function _comp(name, mldsa, ph, label, trad) {
|
|
354
|
+
var sz = MLDSA_COMPONENT[mldsa];
|
|
355
|
+
COMPOSITE_ALGS[oid.byName(name)] = {
|
|
356
|
+
name: name, mldsa: mldsa, mldsaPk: sz.pk, mldsaSig: sz.sig, mldsaOid: sz.oid,
|
|
357
|
+
ph: ph, label: Buffer.from(label, "ascii"), trad: trad,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
_comp("id-MLDSA44-RSA2048-PSS-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-RSA2048-PSS-SHA256", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 2048 });
|
|
361
|
+
_comp("id-MLDSA44-RSA2048-PKCS15-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-RSA2048-PKCS15-SHA256", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 2048 });
|
|
362
|
+
_comp("id-MLDSA44-Ed25519-SHA512", "ML-DSA-44", "SHA-512", "COMPSIG-MLDSA44-Ed25519-SHA512", { eddsa: "Ed25519" });
|
|
363
|
+
_comp("id-MLDSA44-ECDSA-P256-SHA256", "ML-DSA-44", "SHA-256", "COMPSIG-MLDSA44-ECDSA-P256-SHA256", { ec: "P-256", hash: "SHA-256" });
|
|
364
|
+
_comp("id-MLDSA65-RSA3072-PSS-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA3072-PSS-SHA512", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 3072 });
|
|
365
|
+
_comp("id-MLDSA65-RSA3072-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA3072-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-256", rsaBits: 3072 });
|
|
366
|
+
_comp("id-MLDSA65-RSA4096-PSS-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA4096-PSS-SHA512", { rsaPss: true, hash: "SHA-384", salt: 48, rsaBits: 4096 });
|
|
367
|
+
_comp("id-MLDSA65-RSA4096-PKCS15-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-RSA4096-PKCS15-SHA512", { rsaPkcs1: true, hash: "SHA-384", rsaBits: 4096 });
|
|
368
|
+
_comp("id-MLDSA65-ECDSA-P256-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P256-SHA512", { ec: "P-256", hash: "SHA-256" });
|
|
369
|
+
_comp("id-MLDSA65-ECDSA-P384-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
|
|
370
|
+
_comp("id-MLDSA65-ECDSA-brainpoolP256r1-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-ECDSA-BP256-SHA512", { unsupported: "brainpoolP256r1 is not in the WebCrypto ECDSA curve set" });
|
|
371
|
+
_comp("id-MLDSA65-Ed25519-SHA512", "ML-DSA-65", "SHA-512", "COMPSIG-MLDSA65-Ed25519-SHA512", { eddsa: "Ed25519" });
|
|
372
|
+
_comp("id-MLDSA87-ECDSA-P384-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P384-SHA512", { ec: "P-384", hash: "SHA-384" });
|
|
373
|
+
_comp("id-MLDSA87-ECDSA-brainpoolP384r1-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-BP384-SHA512", { unsupported: "brainpoolP384r1 is not in the WebCrypto ECDSA curve set" });
|
|
374
|
+
_comp("id-MLDSA87-Ed448-SHAKE256", "ML-DSA-87", "SHAKE256", "COMPSIG-MLDSA87-Ed448-SHAKE256", { unsupported: "the SHAKE256/64 pre-hash is not in the WebCrypto digest set" });
|
|
375
|
+
_comp("id-MLDSA87-RSA3072-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA3072-PSS-SHA512", { rsaPss: true, hash: "SHA-256", salt: 32, rsaBits: 3072 });
|
|
376
|
+
_comp("id-MLDSA87-RSA4096-PSS-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-RSA4096-PSS-SHA512", { rsaPss: true, hash: "SHA-384", salt: 48, rsaBits: 4096 });
|
|
377
|
+
_comp("id-MLDSA87-ECDSA-P521-SHA512", "ML-DSA-87", "SHA-512", "COMPSIG-MLDSA87-ECDSA-P521-SHA512", { ec: "P-521", hash: "SHA-512" });
|
|
378
|
+
|
|
379
|
+
var _b = asn1.build;
|
|
380
|
+
// Wrap a raw component public key in the SPKI its WebCrypto import expects, so
|
|
381
|
+
// each half is verified through the SAME import + verify seam the classical path
|
|
382
|
+
// uses (no second parallel verify path). tradPK for RSA is the RSAPublicKey DER;
|
|
383
|
+
// for EC the uncompressed point; for EdDSA the raw public key.
|
|
384
|
+
function _spkiFor(algNode, keyBytes) { return _b.sequence([algNode, _b.bitString(keyBytes, 0)]); }
|
|
385
|
+
function _mldsaSpki(mldsaOid, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(mldsaOid))]), raw); }
|
|
386
|
+
function _ecSpki(curve, point) { return _spkiFor(_b.sequence([_b.oid(oid.byName("ecPublicKey")), _b.oid(oid.byName(EC_CURVE_OID[curve]))]), point); }
|
|
387
|
+
function _edSpki(name, raw) { return _spkiFor(_b.sequence([_b.oid(oid.byName(name))]), raw); }
|
|
388
|
+
function _rsaSpki(rsaPub) { return _spkiFor(_b.sequence([_b.oid(oid.byName("rsaEncryption")), _b.nullValue()]), rsaPub); }
|
|
389
|
+
|
|
390
|
+
// The exact modulus bit length of an RSAPublicKey (SEQUENCE { modulus, exponent }).
|
|
391
|
+
function _rsaModulusBits(rsaPubDer) {
|
|
392
|
+
return asn1.read.integer(asn1.decode(rsaPubDer).children[0]).toString(2).length;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function _verifyTradComponent(trad, tradPK, tradSig, mprime) {
|
|
396
|
+
if (trad.ec) {
|
|
397
|
+
// draft sec. 5.1: the EC point MUST be the uncompressed X9.62 form (leading 0x04).
|
|
398
|
+
// A compressed / hybrid point is a non-conforming composite component encoding.
|
|
399
|
+
if (tradPK.length < 1 || tradPK[0] !== 0x04) return Promise.resolve(false);
|
|
400
|
+
return subtle.importKey("spki", _ecSpki(trad.ec, tradPK), { name: "ECDSA" }, false, ["verify"]).then(function (k) {
|
|
401
|
+
// The traditional ECDSA signature is DER Ecdsa-Sig-Value; convert to P1363
|
|
402
|
+
// through the shared reader that also rejects r/s outside [1,n-1] (CVE-2022-21449).
|
|
403
|
+
var p1363 = ecdsaDerToP1363(tradSig, trad.ec);
|
|
404
|
+
return subtle.verify({ name: "ECDSA", hash: trad.hash }, k, p1363, mprime);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
if (trad.eddsa) {
|
|
408
|
+
return subtle.importKey("spki", _edSpki(trad.eddsa, tradPK), { name: trad.eddsa }, false, ["verify"])
|
|
409
|
+
.then(function (k) { return subtle.verify({ name: trad.eddsa }, k, tradSig, mprime); });
|
|
410
|
+
}
|
|
411
|
+
if (trad.rsaPss || trad.rsaPkcs1) {
|
|
412
|
+
// The composite OID fixes the RSA modulus size: a downgraded or mismatched modulus
|
|
413
|
+
// under the declared OID (an id-MLDSA44-RSA2048-* whose component is really 1024-bit)
|
|
414
|
+
// is rejected BEFORE verify, so a weak RSA component cannot satisfy an arm that
|
|
415
|
+
// promises 2048/3072/4096 bits. A malformed RSAPublicKey rejects the same way.
|
|
416
|
+
var bits;
|
|
417
|
+
try { bits = _rsaModulusBits(tradPK); }
|
|
418
|
+
catch (_e) { return Promise.resolve(false); }
|
|
419
|
+
if (bits !== trad.rsaBits) return Promise.resolve(false);
|
|
420
|
+
if (trad.rsaPss) {
|
|
421
|
+
return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSA-PSS", hash: trad.hash }, false, ["verify"])
|
|
422
|
+
.then(function (k) { return subtle.verify({ name: "RSA-PSS", saltLength: trad.salt }, k, tradSig, mprime); });
|
|
423
|
+
}
|
|
424
|
+
return subtle.importKey("spki", _rsaSpki(tradPK), { name: "RSASSA-PKCS1-v1_5", hash: trad.hash }, false, ["verify"])
|
|
425
|
+
.then(function (k) { return subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, k, tradSig, mprime); });
|
|
426
|
+
}
|
|
427
|
+
return Promise.resolve(false);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Verify a composite signature: `spkiBytes` the issuer SPKI DER, `sigBytes` the
|
|
431
|
+
// raw signatureValue (mldsaSig || tradSig), `message` the signed region (tbsBytes).
|
|
432
|
+
function compositeVerify(spkiBytes, sigBytes, message, d) {
|
|
433
|
+
if (d.trad.unsupported) {
|
|
434
|
+
return Promise.resolve({ ok: false, code: "path/unsupported-algorithm",
|
|
435
|
+
error: E("path/unsupported-algorithm", "composite " + d.name + ": " + d.trad.unsupported) });
|
|
436
|
+
}
|
|
437
|
+
var rawKey;
|
|
438
|
+
try {
|
|
439
|
+
var bs = asn1.read.bitString(asn1.decode(spkiBytes).children[1]);
|
|
440
|
+
// The composite subjectPublicKey is an octet-aligned concatenation (no unused
|
|
441
|
+
// bits); a non-zero unused-bit count is malformed.
|
|
442
|
+
if (bs.unusedBits !== 0) throw E("path/bad-signature", "composite subjectPublicKey has unused bits");
|
|
443
|
+
rawKey = bs.bytes;
|
|
444
|
+
} catch (e) { return Promise.resolve({ ok: false, code: pathCode(e, "path/bad-signature"), error: e }); }
|
|
445
|
+
// The ML-DSA half is fixed-length and FIRST; the traditional half is the
|
|
446
|
+
// remainder. Both must be non-empty for a well-formed composite.
|
|
447
|
+
if (rawKey.length <= d.mldsaPk || sigBytes.length <= d.mldsaSig) {
|
|
448
|
+
return Promise.resolve({ ok: false, code: "path/bad-signature",
|
|
449
|
+
error: E("path/bad-signature", "composite key/signature shorter than the fixed ML-DSA component") });
|
|
450
|
+
}
|
|
451
|
+
var mldsaPK = rawKey.subarray(0, d.mldsaPk), tradPK = rawKey.subarray(d.mldsaPk);
|
|
452
|
+
var mldsaSig = sigBytes.subarray(0, d.mldsaSig), tradSig = sigBytes.subarray(d.mldsaSig);
|
|
453
|
+
return subtle.digest({ name: d.ph }, message).then(function (phBuf) {
|
|
454
|
+
// M' = Prefix || Label || len(ctx)=0 || ctx="" || PH(M). In X.509 path
|
|
455
|
+
// validation the application context is empty (a caller ctx is out of scope).
|
|
456
|
+
var mprime = Buffer.concat([COMPOSITE_PREFIX, d.label, Buffer.from([0]), Buffer.from(phBuf)]);
|
|
457
|
+
var mldsaP = subtle.importKey("spki", _mldsaSpki(d.mldsaOid, mldsaPK), { name: d.mldsa }, false, ["verify"])
|
|
458
|
+
.then(function (mk) { return subtle.verify({ name: d.mldsa, context: d.label }, mk, mldsaSig, mprime); });
|
|
459
|
+
var tradP = _verifyTradComponent(d.trad, tradPK, tradSig, mprime);
|
|
460
|
+
return Promise.all([mldsaP, tradP]).then(function (r) { return { ok: r[0] === true && r[1] === true }; });
|
|
461
|
+
}).catch(function (e) { return { ok: false, code: pathCode(e, "path/bad-signature"), error: e }; });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 5.2: a certificate whose SubjectPublicKeyInfo
|
|
465
|
+
// carries a composite ML-DSA OID, IF it has a keyUsage extension, MUST assert at least
|
|
466
|
+
// one signature bit (digitalSignature / nonRepudiation / keyCertSign / cRLSign) and MUST
|
|
467
|
+
// NOT assert any encryption or key-establishment bit -- a composite ML-DSA key is a
|
|
468
|
+
// signature-only key (ML-DSA cannot encrypt or agree, so a "dual usage" key is forbidden
|
|
469
|
+
// even when the traditional component could encrypt). The caller invokes this only for a
|
|
470
|
+
// composite-keyed certificate; an absent keyUsage places no restriction (RFC 5280 4.2.1.3).
|
|
471
|
+
function compositeKeyUsageCheck(cert) {
|
|
472
|
+
var ku;
|
|
473
|
+
try { ku = decodeExt(cert, OID.keyUsage); }
|
|
474
|
+
catch (e) { return { ok: false, code: "path/composite-key-usage", error: e }; }
|
|
475
|
+
if (!ku) return { ok: true };
|
|
476
|
+
var v = ku.value;
|
|
477
|
+
if (v.keyEncipherment || v.dataEncipherment || v.keyAgreement || v.encipherOnly || v.decipherOnly) {
|
|
478
|
+
return { ok: false, code: "path/composite-key-usage",
|
|
479
|
+
error: E("path/composite-key-usage", "a composite ML-DSA key asserts a forbidden encryption/key-establishment keyUsage bit (draft-ietf-lamps-pq-composite-sigs sec. 5.2)") };
|
|
480
|
+
}
|
|
481
|
+
if (!(v.digitalSignature || v.nonRepudiation || v.keyCertSign || v.cRLSign)) {
|
|
482
|
+
return { ok: false, code: "path/composite-key-usage",
|
|
483
|
+
error: E("path/composite-key-usage", "a composite ML-DSA key's keyUsage asserts no signature bit (draft-ietf-lamps-pq-composite-sigs sec. 5.2)") };
|
|
484
|
+
}
|
|
485
|
+
return { ok: true };
|
|
486
|
+
}
|
|
487
|
+
|
|
319
488
|
// Verify cert.signatureValue over cert.tbsBytes with the working public key.
|
|
320
489
|
function builtinVerify(state, cert) {
|
|
321
490
|
var d;
|
|
@@ -326,6 +495,10 @@ function builtinVerify(state, cert) {
|
|
|
326
495
|
// The signature is an octet-aligned BIT STRING (no unused bits) for every
|
|
327
496
|
// supported algorithm; a non-zero unused-bit count is malformed.
|
|
328
497
|
if (!guard.crypto.isOctetAligned(cert.signatureValue)) return Promise.resolve({ ok: false, code: "path/bad-signature" });
|
|
498
|
+
// A composite signature verifies its ML-DSA and traditional halves and accepts
|
|
499
|
+
// IFF both pass -- delegated to the composite combinator (which reuses this
|
|
500
|
+
// file's ECDSA range-check + the same import/verify seam).
|
|
501
|
+
if (d.composite) return compositeVerify(state.workingPublicKey, cert.signatureValue.bytes, cert.tbsBytes, d.composite);
|
|
329
502
|
var key;
|
|
330
503
|
return subtle.importKey("spki", state.workingPublicKey, d.imp, false, ["verify"]).then(function (k) {
|
|
331
504
|
key = k;
|
|
@@ -1165,6 +1338,15 @@ async function validate(path, opts) {
|
|
|
1165
1338
|
checks.push({ name: "signature", ok: sigRes.ok, code: sigRes.ok ? undefined : (sigRes.code || "path/bad-signature") });
|
|
1166
1339
|
if (!sigRes.ok) failed = true;
|
|
1167
1340
|
|
|
1341
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 5.2: a composite-keyed certificate's
|
|
1342
|
+
// keyUsage must be signature-only (no dual-usage). Runs for the target AND every
|
|
1343
|
+
// intermediate whose own subject key is composite.
|
|
1344
|
+
if (COMPOSITE_ALGS[cert.subjectPublicKeyInfo.algorithm.oid]) {
|
|
1345
|
+
var cku = compositeKeyUsageCheck(cert);
|
|
1346
|
+
checks.push({ name: "compositeKeyUsage", ok: cku.ok, code: cku.ok ? undefined : cku.code });
|
|
1347
|
+
if (!cku.ok) failed = true;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1168
1350
|
// 6.1.3(a)(2) validity window.
|
|
1169
1351
|
var t = opts.time;
|
|
1170
1352
|
var vOk = true, vCode;
|
|
@@ -1733,6 +1915,10 @@ function _verifyWithSpki(sigAlg, rawSig, spkiBytes, tbsBytes) {
|
|
|
1733
1915
|
d = resolveDescriptor(sigAlg);
|
|
1734
1916
|
assertKeyMatchesSigAlg(spkiBytes, sigAlg.oid, d);
|
|
1735
1917
|
} catch (_e) { return Promise.resolve(false); }
|
|
1918
|
+
// A composite-signed CRL / OCSP response verifies through the same combinator
|
|
1919
|
+
// (both halves must pass) that the certificate path uses -- one composite verify,
|
|
1920
|
+
// never a second parallel one.
|
|
1921
|
+
if (d.composite) return compositeVerify(spkiBytes, rawSig, tbsBytes, d.composite).then(function (r) { return r.ok === true; });
|
|
1736
1922
|
return subtle.importKey("spki", spkiBytes, d.imp, false, ["verify"]).then(function (key) {
|
|
1737
1923
|
var sig = rawSig;
|
|
1738
1924
|
if (d.ecdsa) sig = ecdsaDerToP1363(sig, key.algorithm.namedCurve);
|
|
@@ -1893,6 +2079,10 @@ async function ocspAuthorizeResponder(basicResponse, cert, issuer, issuerKeyBits
|
|
|
1893
2079
|
// responder's own status opts in through a future checker; absent nocheck, fail
|
|
1894
2080
|
// closed (unknown) rather than trust an unvalidated responder.
|
|
1895
2081
|
if (!findExt(rc, OID_OCSP_NOCHECK)) continue;
|
|
2082
|
+
// A composite-keyed delegate is an out-of-path signer cert and gets the same
|
|
2083
|
+
// composite keyUsage gate the path certificates do (draft sec. 5.2): a dual-usage
|
|
2084
|
+
// composite responder key (a forbidden encryption bit set) is not authorized.
|
|
2085
|
+
if (COMPOSITE_ALGS[rc.subjectPublicKeyInfo.algorithm.oid] && !compositeKeyUsageCheck(rc).ok) continue;
|
|
1896
2086
|
return ocspResponderSpki(rc, issuer);
|
|
1897
2087
|
}
|
|
1898
2088
|
return null;
|
package/lib/webcrypto.js
CHANGED
|
@@ -267,6 +267,16 @@ function _generateKeyPair(alg) {
|
|
|
267
267
|
}
|
|
268
268
|
|
|
269
269
|
function _hashObj(h) { if (!h) return undefined; return { name: (typeof h === "string" ? h : h.name) }; }
|
|
270
|
+
// The node key argument for an ML-DSA sign/verify: the bare handle, or a
|
|
271
|
+
// { key, context } when the caller supplies an ML-DSA context. FIPS 204 bounds the
|
|
272
|
+
// context to 0..255 bytes; a longer one is a DataError at the API boundary, not a
|
|
273
|
+
// raw ERR_OUT_OF_RANGE from node.
|
|
274
|
+
function _mldsaKeyArg(alg, keyHandle, who) {
|
|
275
|
+
if (alg.context == null) return keyHandle;
|
|
276
|
+
var ctx = _toBuf(alg.context, who + " context");
|
|
277
|
+
if (ctx.length > 255) throw new WebCryptoError("webcrypto/data", who + ": an ML-DSA context must be at most 255 bytes (FIPS 204)");
|
|
278
|
+
return { key: keyHandle, context: ctx };
|
|
279
|
+
}
|
|
270
280
|
// publicExponent arrives as a W3C BigInteger octet string but node:crypto
|
|
271
281
|
// takes a JS number, so the value is bounds-checked BEFORE the Number()
|
|
272
282
|
// narrowing: an empty buffer has no integer value (BigInt("0x") is a raw
|
|
@@ -305,7 +315,13 @@ SubtleCrypto.prototype.sign = async function sign(algorithm, key, data) {
|
|
|
305
315
|
if (name === "ECDSA") {
|
|
306
316
|
return _toArrayBuffer(nodeCrypto.sign(_hashNode(alg.hash, "sign"), buf, { key: key._handle, dsaEncoding: "ieee-p1363" }));
|
|
307
317
|
}
|
|
308
|
-
if (
|
|
318
|
+
if (ML_DSA_NODE[name]) {
|
|
319
|
+
// ML-DSA (FIPS 204) accepts an optional context octet string; thread it through
|
|
320
|
+
// signing symmetrically with verify so a context signature round-trips (a
|
|
321
|
+
// signature made with a context verifies only under the SAME context).
|
|
322
|
+
return _toArrayBuffer(nodeCrypto.sign(null, buf, _mldsaKeyArg(alg, key._handle, "sign")));
|
|
323
|
+
}
|
|
324
|
+
if (name === "ED25519" || name === "ED448" || SLH_DSA_NODE[name]) {
|
|
309
325
|
return _toArrayBuffer(nodeCrypto.sign(null, buf, key._handle));
|
|
310
326
|
}
|
|
311
327
|
if (name === "HMAC") {
|
|
@@ -336,7 +352,13 @@ SubtleCrypto.prototype.verify = async function verify(algorithm, key, signature,
|
|
|
336
352
|
if (name === "ECDSA") {
|
|
337
353
|
return nodeCrypto.verify(_hashNode(alg.hash, "verify"), buf, { key: key._handle, dsaEncoding: "ieee-p1363" }, sig);
|
|
338
354
|
}
|
|
339
|
-
if (
|
|
355
|
+
if (ML_DSA_NODE[name]) {
|
|
356
|
+
// ML-DSA (FIPS 204) accepts an optional context octet string (the composite
|
|
357
|
+
// signature construction passes the composite Domain as the ML-DSA context,
|
|
358
|
+
// draft-ietf-lamps-pq-composite-sigs sec. 2). A context mismatch fails.
|
|
359
|
+
return nodeCrypto.verify(null, buf, _mldsaKeyArg(alg, key._handle, "verify"), sig);
|
|
360
|
+
}
|
|
361
|
+
if (name === "ED25519" || name === "ED448" || SLH_DSA_NODE[name]) {
|
|
340
362
|
return nodeCrypto.verify(null, buf, key._handle, sig);
|
|
341
363
|
}
|
|
342
364
|
if (name === "HMAC") {
|
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:63ae20ee-b75f-4b87-9846-27c435f26822",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-13T16:00:51.631Z",
|
|
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.2.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.2.10",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.10",
|
|
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.2.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.2.10",
|
|
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.2.
|
|
57
|
+
"ref": "@blamejs/pki@0.2.10",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|