@blamejs/pki 0.6.0 → 0.6.1
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 +10 -0
- package/README.md +1 -1
- package/lib/ct.js +270 -13
- package/lib/path-validate.js +70 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ 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.6.1 — 2026-08-30
|
|
8
|
+
|
|
9
|
+
pki.ct verifies all of a certificate's SCTs against a trusted log-list and renders a certificate-level Certificate Transparency verdict, and pki.path.validate gains an optional CT gate.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.ct.verifySctList(entry, list, logList, opts?) renders a certificate-level Certificate Transparency verdict (RFC 6962 sec. 3.3) over the set of SCTs a certificate carries, rather than one SCT at a time. It verifies each SCT against the trusted log-list through the same signature check pki.ct.verifySct applies, and reports how many distinct trusted logs verified an SCT (validScts, so a duplicated SCT cannot inflate the count), from how many distinct trusted log operators (operatorCount and operators), and whether that meets the CT policy. The policy is minScts and minOperators, both defaulting to the RFC floor of one valid SCT; the distinct-operator requirement is a browser CT policy layered on top of the RFC, so the caller sets the threshold and the verdict surfaces the counts. A policy shortfall is a verdict (policyOk: false), not an error, and a per-SCT trust or signature failure is recorded in its result row rather than sinking the verdict; only a mis-shaped entry, list, logList, or opts throws a typed CtError.
|
|
14
|
+
- pki.ct.x509CertEntry(cert, issuer) reconstructs the precertificate log entry from a final certificate that carries embedded SCTs (RFC 6962 sec. 3.2), the piece the embedded-SCT verification path needs end to end. It removes the signedCertificateTimestampList extension from the certificate's TBSCertificate by byte surgery, leaving every other byte of the CA-signed structure intact, and computes issuerKeyHash as SHA-256 of the issuer's SubjectPublicKeyInfo. The returned entry feeds pki.ct.verifySctList or pki.ct.reconstructSignedData directly.
|
|
15
|
+
- pki.path.validate accepts an optional Certificate Transparency gate: pass ctLogList (a pki.ct.parseLogList result) and ctPolicy ({ minScts, minOperators }) to verify the target certificate's embedded SCTs against the trusted log-list as part of path validation. The verdict carries a ct check on the target certificate's checks; a target that declares a CT policy but carries no SCT-list extension fails closed, and an SCT dated after the validation time (opts.time) is rejected rather than counted (RFC 6962 sec. 5.2, a client rejects a future-dated SCT). Callers that pass neither option are unaffected.
|
|
16
|
+
|
|
7
17
|
## v0.6.0 — 2026-08-30
|
|
8
18
|
|
|
9
19
|
The toolkit's public APIs graduate to stable, and pki.ocsp.verifyRequest verifies a signed OCSP request for responder implementers.
|
package/README.md
CHANGED
|
@@ -242,7 +242,7 @@ comment blocks, is at [pkijs.com](https://pkijs.com).
|
|
|
242
242
|
| `pki.smime` | S/MIME message assembly, verification, encryption, and compression over the CMS layer (RFC 8551). `sign(content, signers, opts)` wraps a MIME entity in either form: `multipart/signed`, where the content stays readable in any MUA and a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`, or `application/pkcs7-mime; smime-type=signed-data`, where the whole entity is a base64 CMS SignedData. The signed bytes are the entity's §3.1.1 canonical form with CRLF line endings, and `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies while a tampered part fails. `encrypt(content, recipients, opts)` envelopes a MIME entity as an opaque `application/pkcs7-mime` message and `decrypt(message, keyMaterial, opts)` opens one, as `smime-type=authEnveloped-data` (AES-GCM, confidentiality and integrity, the default) or `smime-type=enveloped-data` (AES-CBC, confidentiality only, so `decrypt` reports `authenticated: false`, the §3.3 no-integrity caveat). The `smime-type` is derived from the CMS body rather than the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt`, so it is algorithm-agnostic: any RSA / RSASSA-PSS / ECDSA / EdDSA / ML-DSA / SLH-DSA signer and any RSA-OAEP / ECDH / X25519 / X448 / AES-KW / PBKDF2 / ML-KEM recipient carries through. As with `cms.verify`, `verify` returns the per-signer verdict plus the recovered content, and `valid` and `trusted` are separate claims: `valid` says the signature is sound under a certificate the message carried, `trusted` says every signer chained to a root named in `opts.trustAnchors`. Anchoring here is validated for email at both ends of the chain — the signer certificate must carry `emailProtection` (RFC 8551 §4.4.4) and the anchor's own trust metadata must permit that purpose, since a root can be distrusted for email while remaining a good TLS root. Override either with `requiredEku` / `checkPurpose`. `compress(content, opts)` and `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (§3.6, RFC 3274), a size transform with no integrity or confidentiality (§2.4.5), bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Header protection (RFC 9788): `sign` and `encrypt` take `opts.protectHeaders`, which inlines the caller's `opts.headers` on the Cryptographic Payload root (its Content-Type gaining `hp="clear"` when signed or `hp="cipher"` when encrypted) so the CMS signature or encryption covers them, defeating a transport that rewrites or reads Subject, From, and the rest. `verify` and `decrypt` surface the authenticated inner set as `protectedHeaders` plus `headerProtection { present, mode, fromMismatch, confidential, legacy }`, so a tampered outer header cannot alter it and `fromMismatch` flags an outer From that disagrees. Encryption applies a Header Confidentiality Policy: the default `hcp_baseline` obscures the outer Subject to `[...]` and removes Comments and Keywords, so the real values live only in the ciphertext, and `decrypt` recovers them. Every emitted header routes through a fail-closed injection guard that rejects a CR, LF, or NUL value and a non-ftext name, and a malformed or contradictory `hp` wrap fails closed as `smime/bad-header-protection` rather than silently downgrading. The CMS crypto is unchanged. Inbound legacy RFC 8551 header protection is recognized opt-in: `verify` and `decrypt` with `opts.legacyHeaderProtection` detect a legacy `message/rfc822`-wrapped payload by the RFC 9788 §4.10.1 four-condition identification and surface the inner headers under `headerProtection.legacy = { headers, mode, fromMismatch, confidential }`, where `headers` is an ordered `[{ name, value }]` array retaining legally repeated fields such as `Received`. Those never appear in `protectedHeaders` and never set `present: true`. Because a legacy message is structurally indistinguishable from an ordinary forwarded `message/rfc822`, this is an explicit heuristic (§4.10.2, "no strong end-to-end guarantees"): a caller keying trust off `present` or `protectedHeaders` is never misled, and only one that explicitly reads `headerProtection.legacy.headers` and cross-checks `legacy.fromMismatch` consumes it. It is off by default, and a nested crypto layer, an inner `hp=`, a non-`message/rfc822` payload, or a duplicate Content-Type reports `legacy: null`. Bidirectionally interoperable with `openssl smime` and `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
|
|
243
243
|
| `pki.tsp` | Time-Stamp Protocol (RFC 3161). `sign(messageImprint, tsa, opts)` produces a TimeStampToken: a CMS SignedData over `pki.cms.sign` whose content is a `TSTInfo` carrying the timestamped message imprint, the TSA policy, a serial number, and `genTime` with optional accuracy, nonce, and ordering, plus the §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` and `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq); `response` and `parseResponse` handle the TimeStampResp a TSA returns, either a granted status wrapping a token or a rejection with PKIStatus and failure info, with the §2.4.2 status-to-token coupling enforced in both directions. `verify(token, data, opts)` verifies a token fail-closed: the CMS signature over the exact signed bytes, the message imprint recomputed from the data, the TSTInfo content type, the ESSCertID(V2) binding to the TSA certificate, the §2.3 critical timeStamping-only extendedKeyUsage, the request nonce when used, and, with a trust anchor supplied, full certification-path validation of the TSA certificate at the token's `genTime` — judged under the `timeStamping` purpose, so an anchor's own per-purpose trust metadata reaches the decision rather than sitting inert. It returns `{ valid, trusted, genTime, serialNumber, tstInfo, ... }`, where `trusted` is the separate claim that the authority chained to an anchor you named: without one there is nothing to chain to and it is `false`, which is the distinction a timestamp re-read years later turns on — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
|
|
244
244
|
| `pki.ocsp` | Online Certificate Status Protocol (RFC 6960), both the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs, with the CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile or under SHA-2, plus an optional RFC 9654 nonce and an optional requestor signature. `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER, from the issuing CA directly or a delegated responder, under any `pki.cms.sign` key including the post-quantum ML-DSA and SLH-DSA sets, with `good`, `revoked` (reason and time), or `unknown` per-certificate status. `buildErrorResponse(status)` produces the unsigned §2.3 error (`tryLater`, `unauthorized`, and the rest). `verify(response, opts)` verifies a response fail-closed against the same hardened gates `pki.path.ocspChecker` runs: the CertID binding, responder authorization (the issuing CA, or a CA-issued delegate bearing id-kp-OCSPSigning and id-pkix-ocsp-nocheck and passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency against `thisUpdate` and `nextUpdate`, and the request-nonce echo. It returns `{ status: "good" / "revoked" / "unknown", ... }` and never silently accepts. `verifyRequest(request, opts)` is the responder-side inverse of `buildRequest`: it verifies a client's signed request (RFC 6960 §4.1.1) under the requestor certificate the request carries, trying each embedded certificate since the field is unordered, and reports `signed`, `signatureValid`, every certificate whose key verified (`signerCerts`, so a key carried under an expired certificate beside its renewal is not hidden behind ordering) with the first one's decoded subject, the full embedded certificate bag (`certs`, including intermediates for path building), and the request's CertIDs; an unsigned request is reported with `signed: false` rather than refused. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify`, `verifyRequest` |
|
|
245
|
-
| `pki.ct` | Certificate Transparency (RFC 6962). `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries, 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, and `verifySct` verifies an SCT signature against a log's public key, routing an ECDSA signature through the strict DER-conformance gate and verifying through the crypto engine, resolving true or false and throwing a typed error on a structural fault. On the producing side, `encodeSctList` builds the extension value byte for byte as the exact inverse of `parseSctList`, and `signSct` performs a log's signing step. For trust, `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs, recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2) and decoding the state and temporal-interval constraints; `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable, qualified, and readonly trusted; retired only before retirement; pending and rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256 and an EC P-256 arm, with forgeable-key defenses failing closed), cross-checked against `openssl dgst`. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key before parsing, so an unverified document is never parsed, read, cached, or surfaced, then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version` and `timestamp`. The toolkit bakes in no vendor URL or key, TLS trust is explicit with `rejectUnauthorized` always on, each response is size-capped before the trust chain, and the transport is injectable so the whole path is testable offline — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature`, `fetchLogList` |
|
|
245
|
+
| `pki.ct` | Certificate Transparency (RFC 6962). `parseSctList` decodes the `SignedCertificateTimestampList` a certificate or OCSP response carries, 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, and `verifySct` verifies an SCT signature against a log's public key, routing an ECDSA signature through the strict DER-conformance gate and verifying through the crypto engine, resolving true or false and throwing a typed error on a structural fault. On the producing side, `encodeSctList` builds the extension value byte for byte as the exact inverse of `parseSctList`, and `signSct` performs a log's signing step. For trust, `parseLogList` ingests the CT log-list JSON into constraint-carrying trusted logs, recomputing each log's id as SHA-256 of its key and refusing a disagreeing id (a swapped key, §3.2) and decoding the state and temporal-interval constraints; `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable, qualified, and readonly trusted; retired only before retirement; pending and rejected refused) and the temporal-interval window, then delegates the signature check to `verifySct`. `verifyLogListSignature(json, signature, publicKey)` verifies the detached `log_list.sig` over the raw log-list bytes against a caller-pinned signer key (RSASSA-PKCS1-v1.5/SHA-256 and an EC P-256 arm, with forgeable-key defenses failing closed), cross-checked against `openssl dgst`. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key before parsing, so an unverified document is never parsed, read, cached, or surfaced, then ingests the same bytes through `parseLogList` and returns the trusted-log set plus the surfaced `version` and `timestamp`. Above the per-SCT layer, `verifySctList` renders a certificate-level verdict over every SCT a certificate carries (§3.3), reporting how many distinct trusted logs verified an SCT (so a duplicated SCT cannot inflate the count) and from how many distinct trusted operators against a caller's CT policy (`minScts` and `minOperators`, both defaulting to the RFC floor of one), where a policy shortfall is a verdict and a per-SCT failure is a recorded row rather than a thrown call; `x509CertEntry` reconstructs the precertificate log entry from a final certificate by removing the SCT-list extension from its TBSCertificate as byte surgery, so its embedded SCTs verify end to end (§3.2). The toolkit bakes in no vendor URL or key, TLS trust is explicit with `rejectUnauthorized` always on, each response is size-capped before the trust chain, and the transport is injectable so the whole path is testable offline — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifySctList`, `x509CertEntry`, `verifyLogListSignature`, `fetchLogList` |
|
|
246
246
|
| `pki.merkle` | Merkle-tree proof verification (RFC 6962 / RFC 9162). `leafHash`, `nodeHash`, and `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, which is the append-only guarantee; each is constant-time-compared to a trusted checkpoint root. Fail-closed on bad geometry, sync hashing, transport-free — `leafHash`, `nodeHash`, `emptyRootHash`, `verifyInclusion`, `verifyConsistency` |
|
|
247
247
|
| `pki.trust` | Mozilla and CCADB trust-store ingestion. `parseCertdata` reads the NSS `certdata.txt` object stream and `parseCcadbCsv` the CCADB CSV export, both into one constraint-carrying anchor shape: the per-purpose trust bits, where 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 and serial rather than adjacency and are cross-checked against the parsed DER, so metadata cannot attach to the wrong root. `anchor()` hands an entry to `pki.path.validate({ trustAnchor, checkPurpose })`. Offline, fail-closed, bounded — `parseCertdata`, `parseCcadbCsv`, `anchor` |
|
|
248
248
|
| `pki.shbs` | Stateful hash-based signature verification: HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and in CMS by RFC 9708, profiled by NIST SP 800-208 for CNSA 2.0 firmware signing. `verify` checks an HSS signature, where every level must pass, and `verifyLms` a single-tree LMS, over the raw public-key and signature blobs the parsers already surface. Pure public-input SHA-256 and SHAKE256 hashing, a data-driven typecode registry, and bounds-before-slice reads; a malformed blob throws a typed `ShbsError` while a well-formed but wrong signature returns `false`. Verification only by design, since stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
|
package/lib/ct.js
CHANGED
|
@@ -848,30 +848,285 @@ function _resolveNotAfter(entry, opts) {
|
|
|
848
848
|
* state: { usable: { timestamp: "2026-01-01T00:00:00Z" } } }] }] }), "utf8"));
|
|
849
849
|
* var ok = await pki.ct.verifySctWithLogList(sctEntry, embeddedSct, logList);
|
|
850
850
|
*/
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
851
|
+
// The pure, NON-THROWING log resolution + state + temporal classifier shared by verifySctWithLogList
|
|
852
|
+
// (which throws the mapped code on a non-ok status) and verifySctList (which records the code into a
|
|
853
|
+
// result row and continues). Returns { status, log?, code?, message? } where status is one of
|
|
854
|
+
// ok | bad-input | not-found | untrusted | temporal. A mis-shaped sct.timestamp (in the retired-log
|
|
855
|
+
// gate) or a malformed opts.certNotAfter (in the temporal gate, via within) still THROWS -- those are
|
|
856
|
+
// caller mis-shapes, not per-SCT trust outcomes (the three-tier rule: bad input throws, a per-SCT
|
|
857
|
+
// verdict is recorded). The state and temporal gate semantics are verbatim from the original
|
|
858
|
+
// verifySctWithLogList body, so its shipped RED vectors are the behavior guard for this refactor.
|
|
859
|
+
function _classifyLog(sct, logList, entry, opts) {
|
|
860
|
+
if (logList == null || typeof logList !== "object" || logList.byLogId == null) return { status: "bad-input", code: "ct/bad-input", message: "logList must be a pki.ct.parseLogList result" };
|
|
861
|
+
if (sct == null || typeof sct !== "object" || typeof sct.logIdHex !== "string") return { status: "bad-input", code: "ct/bad-input", message: "the SCT is missing its logIdHex" };
|
|
855
862
|
var log = logList.byLogId[sct.logIdHex];
|
|
856
|
-
if (log == null)
|
|
863
|
+
if (log == null) return { status: "not-found", code: "ct/log-not-found", message: "no trusted CT log matches the SCT's logId " + sct.logIdHex };
|
|
857
864
|
// State gate (fail-closed): trusted proceed; retired only before its retirement; else untrusted.
|
|
858
865
|
if (!log.state.trusted) {
|
|
859
|
-
if (!log.state.conditional)
|
|
866
|
+
if (!log.state.conditional) return { status: "untrusted", log: log, code: "ct/log-untrusted", message: "the CT log state '" + log.state.name + "' is not trusted" };
|
|
860
867
|
var ts = guard.range.uint64(sct.timestamp, _ctErr, "ct/bad-input", "sct.timestamp");
|
|
861
|
-
if (ts >= BigInt(guard.time.instantOf(log.state.since)))
|
|
868
|
+
if (ts >= BigInt(guard.time.instantOf(log.state.since))) return { status: "untrusted", log: log, code: "ct/log-untrusted", message: "the CT log is retired and the SCT is not timestamped before its retirement (" + log.state.since.toISOString() + ")" };
|
|
862
869
|
}
|
|
863
870
|
// Temporal gate (fail-closed): the covered cert's notAfter must be in [start_inclusive, end_exclusive).
|
|
864
871
|
if (log.temporalInterval) {
|
|
865
872
|
var notAfter = _resolveNotAfter(entry, opts);
|
|
866
|
-
//
|
|
867
|
-
// Date is instanceof Date yet NaN, and
|
|
868
|
-
//
|
|
869
|
-
// inside within (
|
|
873
|
+
// An unresolvable notAfter is a failure for this windowed SCT, never a silent skip. within() then
|
|
874
|
+
// validates notAfter fail-closed before comparing: an Invalid Date is instanceof Date yet NaN, and
|
|
875
|
+
// NaN < x / NaN >= x are both false, so an unvalidated notAfter would BYPASS the containment. A
|
|
876
|
+
// malformed opts.certNotAfter throws inside within (a caller mis-shape); an out-of-window one is
|
|
877
|
+
// this recorded temporal status.
|
|
878
|
+
if (notAfter == null) return { status: "temporal", log: log, code: "ct/temporal-interval", message: "the windowed CT log has no resolvable covered-certificate notAfter (pass a valid opts.certNotAfter)" };
|
|
870
879
|
if (!guard.time.within(notAfter, log.temporalInterval.startInclusive, log.temporalInterval.endExclusive, _ctErr, "ct/temporal-interval", "the covered certificate notAfter (pass a valid opts.certNotAfter)")) {
|
|
871
|
-
|
|
880
|
+
return { status: "temporal", log: log, code: "ct/temporal-interval", message: "the covered certificate's notAfter is outside the CT log's temporal_interval" };
|
|
872
881
|
}
|
|
873
882
|
}
|
|
874
|
-
return
|
|
883
|
+
return { status: "ok", log: log };
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async function verifySctWithLogList(entry, sct, logList, opts) {
|
|
887
|
+
opts = opts || {};
|
|
888
|
+
var c = _classifyLog(sct, logList, entry, opts);
|
|
889
|
+
if (c.status !== "ok") throw _ctErr(c.code, c.message);
|
|
890
|
+
return verifySct(entry, sct, c.log.key); // the shipped crypto verdict (re-checks the logId binding)
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Options verifySctList reads. All optional; the defaults are the RFC 6962 sec. 3.3 floor (>= 1).
|
|
894
|
+
var _VERIFY_SCT_LIST_OPTS = { minScts: 1, minOperators: 1, certNotAfter: 1, at: 1 };
|
|
895
|
+
|
|
896
|
+
// A caller-supplied policy threshold: a positive integer, or null when omitted (defaulted by the caller).
|
|
897
|
+
function _policyCount(v, name) {
|
|
898
|
+
if (v == null) return null;
|
|
899
|
+
if (typeof v !== "number" || !intrinsic.isInteger(v) || v < 1) throw _ctErr("ct/bad-input", name + " must be a positive integer");
|
|
900
|
+
return v;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* @primitive pki.ct.verifySctList
|
|
905
|
+
* @signature pki.ct.verifySctList(entry, list, logList, opts?) -> Promise<verdict>
|
|
906
|
+
* @since 0.6.1
|
|
907
|
+
* @status stable
|
|
908
|
+
* @spec RFC 6962
|
|
909
|
+
* @related pki.ct.verifySct, pki.ct.verifySctWithLogList, pki.ct.parseLogList, pki.ct.parseSctList
|
|
910
|
+
*
|
|
911
|
+
* Render a certificate-level Certificate Transparency verdict over the SET of SCTs a certificate carries
|
|
912
|
+
* (RFC 6962 sec. 3.3), rather than one SCT at a time. Each SCT is resolved to a trusted log through
|
|
913
|
+
* `logList` and verified with the shipped `verifySct`; the verdict reports how many distinct trusted
|
|
914
|
+
* logs verified an SCT (`validScts`, so a duplicated SCT cannot inflate the count), from how many
|
|
915
|
+
* distinct trusted operators, and whether that meets the caller's CT policy. The RFC floor is one valid
|
|
916
|
+
* SCT (sec. 3.3), so `minScts` and `minOperators` both default to 1; the distinct-operator axis is a
|
|
917
|
+
* browser CT policy layered on top of the RFC, so the caller sets the threshold and the verdict surfaces
|
|
918
|
+
* the counts rather than hardcoding a browser's numbers.
|
|
919
|
+
*
|
|
920
|
+
* `entry` is the shared log-entry context for every SCT (`{ entryType: 0, leafCert }` for SCTs delivered
|
|
921
|
+
* over TLS/OCSP, or `{ entryType: 1, tbsCertificate, issuerKeyHash }` for embedded SCTs; the shape
|
|
922
|
+
* `pki.ct.x509CertEntry` produces). `list` is a `parseSctList` result, its `.scts` array, or an array of
|
|
923
|
+
* decoded v1 SCTs; unknown-version entries are counted `unknownScts` and never as valid. `logList` is a
|
|
924
|
+
* `parseLogList` result. A policy shortfall is a verdict (`policyOk: false`), not a throw; a per-SCT
|
|
925
|
+
* trust or crypto failure is recorded into its result row (`valid: false`, with a `code` for a
|
|
926
|
+
* structural or trust exclusion, without one for a cryptographic mismatch) and the loop continues; only
|
|
927
|
+
* a mis-shaped `entry` / `list` / `logList` / `opts` throws a typed `CtError`.
|
|
928
|
+
*
|
|
929
|
+
* @opts
|
|
930
|
+
* - `minScts` (int, default 1) -- required count of valid SCTs from trusted, in-window logs.
|
|
931
|
+
* - `minOperators` (int, default 1) -- required count of distinct trusted operators among them.
|
|
932
|
+
* - `certNotAfter` (Date) -- the covered certificate's notAfter for each windowed log's temporal gate;
|
|
933
|
+
* auto-derived from `entry.leafCert` for entryType 0, required for a precert entry against a windowed log.
|
|
934
|
+
* - `at` (Date) -- the validation time; when supplied, an SCT whose timestamp is later than it is
|
|
935
|
+
* rejected and not counted (RFC 6962 sec. 5.2, a client rejects a future-dated SCT). The
|
|
936
|
+
* pki.path.validate CT gate passes its opts.time here.
|
|
937
|
+
* @example
|
|
938
|
+
* // requires: logListJsonBytes -- the CT log-list JSON a distributor publishes (the log_list.json browsers consume)
|
|
939
|
+
* // requires: sctExtValue -- a certificate's SCT-list extension value (a signedCertificateTimestampList extension)
|
|
940
|
+
* // requires: certDer -- the DER of the certificate the SCTs cover
|
|
941
|
+
* var logList = pki.ct.parseLogList(logListJsonBytes);
|
|
942
|
+
* var list = pki.ct.parseSctList(sctExtValue);
|
|
943
|
+
* var verdict = await pki.ct.verifySctList({ entryType: 0, leafCert: certDer }, list, logList,
|
|
944
|
+
* { minScts: 2, minOperators: 2 });
|
|
945
|
+
* verdict.policyOk; // did the certificate meet the CT policy?
|
|
946
|
+
* verdict.operators; // the distinct trusted operators whose logs verified an SCT
|
|
947
|
+
*/
|
|
948
|
+
async function verifySctList(entry, list, logList, opts) {
|
|
949
|
+
opts = guard.identifier.optionsObject(opts, _ctErr, "ct/bad-input", "pki.ct.verifySctList options");
|
|
950
|
+
guard.identifier.assertKnownKeys(opts, _VERIFY_SCT_LIST_OPTS, _ctErr, "ct/bad-input", "pki.ct.verifySctList has an unknown option: ");
|
|
951
|
+
var minScts = _policyCount(opts.minScts, "opts.minScts"); if (minScts == null) minScts = 1;
|
|
952
|
+
var minOperators = _policyCount(opts.minOperators, "opts.minOperators"); if (minOperators == null) minOperators = 1;
|
|
953
|
+
if (logList == null || typeof logList !== "object" || logList.byLogId == null) throw _ctErr("ct/bad-input", "logList must be a pki.ct.parseLogList result");
|
|
954
|
+
if (entry == null || (entry.entryType !== 0 && entry.entryType !== 1)) throw _ctErr("ct/bad-entry-type", "entryType must be x509_entry(0) or precert_entry(1) (RFC 6962 sec. 3.1)");
|
|
955
|
+
// The validation time, when supplied, arms the RFC 6962 sec. 5.2 rule that a future-dated SCT is
|
|
956
|
+
// rejected: a log cannot have issued an SCT after the moment we verify it, so its timestamp must not
|
|
957
|
+
// be later than `at`. Held as a millisecond Number and compared against the SCT's BigInt uint64
|
|
958
|
+
// timestamp (a BigInt-vs-Number relational comparison is exact).
|
|
959
|
+
var atMs = null;
|
|
960
|
+
if (opts.at != null) { guard.time.assertValid(opts.at, _ctErr, "ct/bad-input", "opts.at (the validation time)"); atMs = guard.time.instantOf(opts.at); }
|
|
961
|
+
// A present certNotAfter must be a valid Date: a non-Date is a caller mis-shape that throws, never a
|
|
962
|
+
// value silently ignored (entryType 0 would fall back to the certificate's own notAfter) or demoted to
|
|
963
|
+
// a per-SCT temporal failure (entryType 1) -- the malformed-options contract, matching opts.at.
|
|
964
|
+
if (opts.certNotAfter != null) guard.time.assertValid(opts.certNotAfter, _ctErr, "ct/bad-input", "opts.certNotAfter (the covered certificate's notAfter)");
|
|
965
|
+
// Validate the shared entry's required fields up front: a mis-shaped entry is a caller error that
|
|
966
|
+
// throws (not a per-SCT row or a verdict rendered without examining it), even when the SCT list is
|
|
967
|
+
// empty. The same fields reconstructSignedData reads for the preimage.
|
|
968
|
+
if (entry.entryType === 0) { _toBuffer(entry.leafCert, "entry.leafCert"); }
|
|
969
|
+
else {
|
|
970
|
+
if (_toBuffer(entry.issuerKeyHash, "entry.issuerKeyHash").length !== 32) throw _ctErr("ct/bad-issuer-key-hash", "entry.issuerKeyHash must be exactly 32 bytes (SHA-256 of the issuer SPKI, RFC 6962 sec. 3.2)");
|
|
971
|
+
_toBuffer(entry.tbsCertificate, "entry.tbsCertificate");
|
|
972
|
+
}
|
|
973
|
+
// Normalize the list into the known v1 SCTs plus the count of forward-compat unknown-version entries
|
|
974
|
+
// (M7 -- their body layout is undefined, so they are unverifiable, never a valid SCT, never a throw).
|
|
975
|
+
var scts, unknownScts;
|
|
976
|
+
if (list != null && intrinsic.isArray(list.scts)) { scts = list.scts; unknownScts = intrinsic.isArray(list.unknownScts) ? list.unknownScts.length : 0; }
|
|
977
|
+
else if (intrinsic.isArray(list)) { scts = list; unknownScts = 0; }
|
|
978
|
+
else throw _ctErr("ct/bad-input", "list must be a pki.ct.parseSctList result, its .scts array, or an array of decoded v1 SCTs");
|
|
979
|
+
// Bound the per-SCT verification work a caller-supplied array can demand to the same cap parseSctList
|
|
980
|
+
// enforces on wire input, so a bare oversized array cannot force an unbounded run of signature checks.
|
|
981
|
+
if (scts.length > C.LIMITS.SCT_MAX_COUNT) throw _ctErr("ct/too-many-scts", "the SCT list has " + scts.length + " entries, exceeding the SCT_MAX_COUNT cap of " + C.LIMITS.SCT_MAX_COUNT + " (RFC 6962 sec. 3.3)");
|
|
982
|
+
|
|
983
|
+
var results = [], validScts = 0, operators = [], seenOps = intrinsic.create(null), seenLogs = intrinsic.create(null);
|
|
984
|
+
for (var i = 0; i < scts.length; i++) {
|
|
985
|
+
var sct = scts[i];
|
|
986
|
+
var row = { logIdHex: (sct && typeof sct.logIdHex === "string") ? sct.logIdHex : null, valid: false,
|
|
987
|
+
operator: null, logState: null,
|
|
988
|
+
timestamp: (sct && typeof sct.timestamp === "bigint") ? sct.timestamp : null, code: undefined, reason: undefined };
|
|
989
|
+
// Classification can THROW for a malformed per-SCT field (a bad timestamp on a retired-log match):
|
|
990
|
+
// that is a recorded per-SCT outcome, never a thrown aggregate abort, so it matches the usable-log
|
|
991
|
+
// malformed-SCT path below and one bad SCT cannot sink the verdict or a valid sibling.
|
|
992
|
+
var c;
|
|
993
|
+
try { c = _classifyLog(sct, logList, entry, opts); }
|
|
994
|
+
catch (e) {
|
|
995
|
+
if (!(e && e.isPkiError)) throw e;
|
|
996
|
+
row.code = e.code; row.reason = e.message; intrinsic.push(results, row); continue;
|
|
997
|
+
}
|
|
998
|
+
if (c.log) { row.operator = c.log.operator; row.logState = c.log.state.name; }
|
|
999
|
+
if (c.status !== "ok") { row.code = c.code; row.reason = c.message; intrinsic.push(results, row); continue; }
|
|
1000
|
+
// A trust exclusion is a recorded code above; the crypto check below is false-is-a-verdict. But a
|
|
1001
|
+
// structurally-defective SCT from a trusted log (a malformed signature encoding, an unsupported hash
|
|
1002
|
+
// or curve) makes verifySct THROW a typed CtError -- for the aggregate that is a recorded per-SCT
|
|
1003
|
+
// outcome (M10, the verdict-collector tier), never a thrown call that would sink the verdict or a
|
|
1004
|
+
// valid sibling. A non-PkiError (an unexpected bug) is re-thrown, never swallowed into a verdict.
|
|
1005
|
+
var ok;
|
|
1006
|
+
try { ok = await verifySct(entry, sct, c.log.key); }
|
|
1007
|
+
catch (e) {
|
|
1008
|
+
if (!(e && e.isPkiError)) throw e;
|
|
1009
|
+
row.code = e.code; row.reason = e.message; intrinsic.push(results, row); continue;
|
|
1010
|
+
}
|
|
1011
|
+
// RFC 6962 sec. 5.2: reject a future-dated SCT even when its signature verifies -- a log cannot have
|
|
1012
|
+
// issued it after the validation time, so it is recorded as a rejection and never counted. An SCT
|
|
1013
|
+
// dated after the certificate's OWN notAfter is deliberately NOT rejected: RFC 6962 sec. 5.1 permits
|
|
1014
|
+
// a log to accept an already-expired certificate, so such an SCT is legitimate.
|
|
1015
|
+
if (ok === true && atMs != null && sct.timestamp > atMs) {
|
|
1016
|
+
row.code = "ct/future-timestamp"; row.reason = "the SCT timestamp is later than the validation time (RFC 6962 sec. 5.2)";
|
|
1017
|
+
intrinsic.push(results, row); continue;
|
|
1018
|
+
}
|
|
1019
|
+
row.valid = ok === true;
|
|
1020
|
+
// Count each distinct trusted log once: a duplicated SCT, or a second SCT from the same log, is one
|
|
1021
|
+
// attestation, so a repeat cannot inflate validScts past a minScts policy (the count is over the set
|
|
1022
|
+
// of logs, not raw SCT copies). The duplicate row is still recorded for diagnostics.
|
|
1023
|
+
if (row.valid && !seenLogs[sct.logIdHex]) {
|
|
1024
|
+
seenLogs[sct.logIdHex] = true;
|
|
1025
|
+
validScts++;
|
|
1026
|
+
if (c.log.operator != null && !seenOps[c.log.operator]) { seenOps[c.log.operator] = true; intrinsic.push(operators, c.log.operator); }
|
|
1027
|
+
}
|
|
1028
|
+
intrinsic.push(results, row);
|
|
1029
|
+
}
|
|
1030
|
+
var operatorCount = operators.length;
|
|
1031
|
+
var policyOk = validScts >= minScts && operatorCount >= minOperators;
|
|
1032
|
+
return {
|
|
1033
|
+
policyOk: policyOk, totalScts: scts.length + unknownScts, validScts: validScts, unknownScts: unknownScts,
|
|
1034
|
+
operatorCount: operatorCount, operators: operators, required: { minScts: minScts, minOperators: minOperators },
|
|
1035
|
+
results: results,
|
|
1036
|
+
reason: policyOk ? null : ("CT policy not met: " + validScts + " valid SCT(s) from " + operatorCount + " distinct operator(s); require at least " + minScts + " SCT(s) from at least " + minOperators + " operator(s)"),
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// A cert / issuer argument as a parsed x509: a DER Buffer, a PEM string, or an unmodified
|
|
1041
|
+
// pki.schema.x509.parse result. Routed through guard.parsed (provenance-based, getter-free) so a
|
|
1042
|
+
// caller object cannot spoof "already parsed" through a lying accessor; a malformed one is a typed
|
|
1043
|
+
// ct/bad-cert-entry, never a silently-skipped input.
|
|
1044
|
+
function _asParsedCert(v, field) {
|
|
1045
|
+
// Inline require (circular load with schema-x509 -> schema-pkix), matching _resolveNotAfter above.
|
|
1046
|
+
var x509 = require("./schema-x509.js"); // allow:inline-require -- circular load (see _resolveNotAfter)
|
|
1047
|
+
return guard.parsed.acceptDerived(v, "certificate", x509.parse, _ctErr, "ct/bad-cert-entry", field);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* @primitive pki.ct.x509CertEntry
|
|
1052
|
+
* @signature pki.ct.x509CertEntry(cert, issuer) -> { entryType, tbsCertificate, issuerKeyHash }
|
|
1053
|
+
* @since 0.6.1
|
|
1054
|
+
* @status stable
|
|
1055
|
+
* @spec RFC 6962
|
|
1056
|
+
* @related pki.ct.verifySctList, pki.ct.reconstructSignedData, pki.ct.verifySct
|
|
1057
|
+
*
|
|
1058
|
+
* Reconstruct the precertificate log entry from a FINAL certificate carrying embedded SCTs, so those
|
|
1059
|
+
* SCTs can be verified end to end (RFC 6962 sec. 3.2): "reconstruct this TBSCertificate from the final
|
|
1060
|
+
* certificate by extracting the TBSCertificate from it and deleting the SCT extension". Returns the
|
|
1061
|
+
* `{ entryType: 1, tbsCertificate, issuerKeyHash }` entry `pki.ct.verifySctList` / `reconstructSignedData`
|
|
1062
|
+
* consume, where `tbsCertificate` is the leaf TBS with ONLY the signedCertificateTimestampList extension
|
|
1063
|
+
* removed and `issuerKeyHash` is `SHA-256(issuer SubjectPublicKeyInfo DER)` (32 bytes). The signed
|
|
1064
|
+
* `tbs_certificate` an SCT covers is also without the CT poison extension (RFC 6962 sec. 3.2, "without
|
|
1065
|
+
* the signature and the poison extension"), which sits in the same position the SCT list takes in the
|
|
1066
|
+
* final certificate, so deleting the SCT-list extension IS the whole reconstruction: no poison extension
|
|
1067
|
+
* is re-added. (The Precertificate Signing Certificate case, where the precert issuer differs from the
|
|
1068
|
+
* final issuer, is out of scope; the common flow issues both from the one CA.)
|
|
1069
|
+
*
|
|
1070
|
+
* The removal is byte surgery on the CA-signed bytes, never a value re-serialization: the extensions
|
|
1071
|
+
* list and the two enclosing containers are rebuilt from every OTHER element's raw DER, so the only
|
|
1072
|
+
* bytes that change are the removed extension and the recomputed canonical DER length prefixes. `cert`
|
|
1073
|
+
* and `issuer` each accept a DER `Buffer`, a PEM string, or a `pki.schema.x509.parse` result. A
|
|
1074
|
+
* certificate with no extensions or no SCT-list extension is `ct/no-sct-extension`; an argument that is
|
|
1075
|
+
* not a certificate is `ct/bad-cert-entry`, and malformed certificate bytes carry the X.509 parse fault.
|
|
1076
|
+
*
|
|
1077
|
+
* @example
|
|
1078
|
+
* // requires: leafCertDer -- the DER of a final certificate carrying embedded SCTs
|
|
1079
|
+
* // requires: issuerCertDer -- the DER of the certificate that issued it
|
|
1080
|
+
* // requires: sctExtValue -- the leaf's signedCertificateTimestampList extension value
|
|
1081
|
+
* // requires: logList -- a pki.ct.parseLogList result
|
|
1082
|
+
* var entry = pki.ct.x509CertEntry(leafCertDer, issuerCertDer);
|
|
1083
|
+
* var verdict = await pki.ct.verifySctList(entry, pki.ct.parseSctList(sctExtValue), logList,
|
|
1084
|
+
* { certNotAfter: pki.schema.x509.parse(leafCertDer).validity.notAfter });
|
|
1085
|
+
*/
|
|
1086
|
+
// The issuer contributes only its SubjectPublicKeyInfo (issuerKeyHash = SHA-256 of it), so it accepts
|
|
1087
|
+
// anything x509CertEntry's cert does PLUS a bare { subjectPublicKeyInfo: { bytes } } -- a trust anchor
|
|
1088
|
+
// carries its key without a full certificate, and requiring a whole cert there would refuse a leaf
|
|
1089
|
+
// validated directly under an anchor.
|
|
1090
|
+
function _issuerSpki(v, field) {
|
|
1091
|
+
if (v != null && typeof v === "object" && !intrinsic.isBuffer(v) && v.subjectPublicKeyInfo != null && v.subjectPublicKeyInfo.bytes != null) {
|
|
1092
|
+
// Validate the bare-form bytes as a BufferSource so a malformed value fails through the typed CT
|
|
1093
|
+
// error contract, not a native TypeError from the SHA-256 update downstream.
|
|
1094
|
+
return guard.bytes.view(v.subjectPublicKeyInfo.bytes, CtError, "ct/bad-cert-entry", field + " subjectPublicKeyInfo.bytes");
|
|
1095
|
+
}
|
|
1096
|
+
return _asParsedCert(v, field).subjectPublicKeyInfo.bytes;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function x509CertEntry(cert, issuer) {
|
|
1100
|
+
var leaf = _asParsedCert(cert, "cert"), issSpki = _issuerSpki(issuer, "issuer");
|
|
1101
|
+
var tbs = asn1.decode(leaf.tbsBytes);
|
|
1102
|
+
// extensions [3] EXPLICIT is the last TBSCertificate field (context-constructed tag 3).
|
|
1103
|
+
var wrapIdx = -1;
|
|
1104
|
+
for (var i = 0; i < tbs.children.length; i++) {
|
|
1105
|
+
if (tbs.children[i].tagClass === "context" && tbs.children[i].tagNumber === 3) { wrapIdx = i; break; }
|
|
1106
|
+
}
|
|
1107
|
+
if (wrapIdx < 0) throw _ctErr("ct/no-sct-extension", "the certificate has no extensions, so it carries no embedded SCT list (RFC 6962 sec. 3.2)");
|
|
1108
|
+
var extsSeq = tbs.children[wrapIdx].children[0]; // the SEQUENCE OF Extension
|
|
1109
|
+
var sctOid = asn1.build.oid(oid.byName("signedCertificateTimestampList"));
|
|
1110
|
+
var kept = [], removed = 0;
|
|
1111
|
+
for (var j = 0; j < extsSeq.children.length; j++) {
|
|
1112
|
+
var ext = extsSeq.children[j];
|
|
1113
|
+
// ext.children[0] is the extnID OID node; compare its exact TLV bytes to the SCT-list OID.
|
|
1114
|
+
if (ext.children[0] && intrinsic.bufferEquals(ext.children[0].bytes, sctOid)) { removed++; continue; }
|
|
1115
|
+
intrinsic.push(kept, ext.bytes);
|
|
1116
|
+
}
|
|
1117
|
+
if (removed === 0) throw _ctErr("ct/no-sct-extension", "the certificate carries no signedCertificateTimestampList extension (RFC 6962 sec. 3.2)");
|
|
1118
|
+
// Rebuild the TBSCertificate SEQUENCE from raw child bytes; canonical DER lengths reproduce the
|
|
1119
|
+
// original framing minus the removed extension. When the SCT-list was the ONLY extension, `kept` is
|
|
1120
|
+
// empty and the extensions field is dropped ENTIRELY -- RFC 5280 Extensions is SIZE (1..MAX) and the
|
|
1121
|
+
// [3] field is OPTIONAL, so an empty [3] SEQUENCE {} would be invalid and would not match the signed
|
|
1122
|
+
// precert TBS, whose extensions field is likewise absent once its sole poison extension is removed.
|
|
1123
|
+
var tbsChildren = [];
|
|
1124
|
+
for (var k = 0; k < tbs.children.length; k++) {
|
|
1125
|
+
if (k !== wrapIdx) { intrinsic.push(tbsChildren, tbs.children[k].bytes); continue; }
|
|
1126
|
+
if (kept.length > 0) intrinsic.push(tbsChildren, asn1.build.explicit(3, asn1.build.sequence(kept)));
|
|
1127
|
+
}
|
|
1128
|
+
var issuerKeyHash = nodeCrypto.createHash("sha256").update(issSpki).digest();
|
|
1129
|
+
return { entryType: 1, tbsCertificate: asn1.build.sequence(tbsChildren), issuerKeyHash: issuerKeyHash };
|
|
875
1130
|
}
|
|
876
1131
|
|
|
877
1132
|
/**
|
|
@@ -1113,6 +1368,8 @@ module.exports = {
|
|
|
1113
1368
|
signSct: signSct,
|
|
1114
1369
|
parseLogList: parseLogList,
|
|
1115
1370
|
verifySctWithLogList: verifySctWithLogList,
|
|
1371
|
+
verifySctList: verifySctList,
|
|
1372
|
+
x509CertEntry: x509CertEntry,
|
|
1116
1373
|
verifyLogListSignature: verifyLogListSignature,
|
|
1117
1374
|
fetchLogList: fetchLogList,
|
|
1118
1375
|
HASH_ALGORITHMS: HASH_ALGORITHMS,
|
package/lib/path-validate.js
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
var webcrypto = require("./webcrypto");
|
|
34
34
|
var pkix = require("./schema-pkix");
|
|
35
35
|
var oid = require("./oid");
|
|
36
|
+
var ct = require("./ct");
|
|
36
37
|
var errors = require("./framework-error");
|
|
37
38
|
var asn1 = require("./asn1-der");
|
|
38
39
|
var schema = require("./schema-engine");
|
|
@@ -1278,17 +1279,73 @@ function validateCriticalExtensionStructure(cert) {
|
|
|
1278
1279
|
// inputs appear together. A grep of one function would have shipped a list missing four real
|
|
1279
1280
|
// options and rejected callers that work today.
|
|
1280
1281
|
var _VALIDATE_OPTS = {
|
|
1281
|
-
checkPurpose: 1,
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1282
|
+
checkPurpose: 1, ctLogList: 1, ctPolicy: 1, historicalMode: 1, initialAnyPolicyInhibit: 1,
|
|
1283
|
+
initialExcludedSubtrees: 1, initialExplicitPolicy: 1, initialPermittedSubtrees: 1,
|
|
1284
|
+
initialPolicyMappingInhibit: 1, maxPathCerts: 1, maxPolicyNodes: 1, requireRevocation: 1,
|
|
1285
|
+
requiredEku: 1, revocationChecker: 1, softFail: 1, time: 1, trustAnchor: 1,
|
|
1286
|
+
userInitialPolicySet: 1, verifier: 1
|
|
1285
1287
|
};
|
|
1286
1288
|
|
|
1289
|
+
// The opt-in Certificate Transparency gate (RFC 6962). When opts.ctLogList is supplied, the target's
|
|
1290
|
+
// embedded SCTs are verified against the trusted log-list and the caller's CT policy, and the verdict is
|
|
1291
|
+
// recorded on the target's checks. Fail-closed: a target that declares a CT policy but carries no
|
|
1292
|
+
// SCT-list extension is refused. Reached only through opts.ctLogList, so every existing caller is
|
|
1293
|
+
// unaffected. The SCT-list extension OID is looked up through the shared registry, never a literal.
|
|
1294
|
+
var OID_SCT_LIST = oid.byName("signedCertificateTimestampList");
|
|
1295
|
+
async function ctGateCheck(leaf, certs, anchor, ctLogList, ctPolicy, n, at) {
|
|
1296
|
+
var exts = leaf.extensions || [], sctExt = null;
|
|
1297
|
+
for (var i = 0; i < exts.length; i++) {
|
|
1298
|
+
if (exts[i] && (exts[i].oid === OID_SCT_LIST || exts[i].name === "signedCertificateTimestampList")) { sctExt = exts[i]; break; }
|
|
1299
|
+
}
|
|
1300
|
+
if (sctExt == null) return { name: "ct", ok: false, code: "path/ct-required" };
|
|
1301
|
+
// The leaf's issuer contributes issuerKeyHash: the certificate that signed it (certs[n-2] in the
|
|
1302
|
+
// anchor->target order), or the anchor itself when the target is validated directly beneath it.
|
|
1303
|
+
var issuer = n >= 2 ? certs[n - 2] : { subjectPublicKeyInfo: { bytes: anchor.publicKey } };
|
|
1304
|
+
var entry, list;
|
|
1305
|
+
try {
|
|
1306
|
+
entry = ct.x509CertEntry(leaf, issuer);
|
|
1307
|
+
list = ct.parseSctList(sctExt.value);
|
|
1308
|
+
} catch (e) {
|
|
1309
|
+
return { name: "ct", ok: false, code: pathCode(e, "path/ct-required") };
|
|
1310
|
+
}
|
|
1311
|
+
// verifySctList throws only for a mis-shaped caller configuration (a bad ctPolicy threshold value, a
|
|
1312
|
+
// ctLogList that is not a parseLogList result): that is the caller's error, surfaced at the path
|
|
1313
|
+
// boundary as path/bad-input rather than leaking the ct/* domain code of a layer they did not call.
|
|
1314
|
+
// verifySctList throws only for a mis-shaped caller configuration (a bad ctPolicy threshold value, a
|
|
1315
|
+
// ctLogList that is not a parseLogList result): that is the caller's error, surfaced at the path
|
|
1316
|
+
// boundary as path/bad-input rather than leaking the ct/* domain code of a layer they did not call.
|
|
1317
|
+
var verdict;
|
|
1318
|
+
try {
|
|
1319
|
+
verdict = await ct.verifySctList(entry, list, ctLogList,
|
|
1320
|
+
{ minScts: ctPolicy.minScts, minOperators: ctPolicy.minOperators, certNotAfter: leaf.validity.notAfter, at: at });
|
|
1321
|
+
} catch (e) {
|
|
1322
|
+
if (e && e.isPkiError) throw E("path/bad-input", "validate: the CT policy or log-list is invalid (" + (e.code || "ct/bad-input") + "): " + e.message);
|
|
1323
|
+
throw e;
|
|
1324
|
+
}
|
|
1325
|
+
return { name: "ct", ok: verdict.policyOk === true, code: verdict.policyOk ? undefined : "path/ct-policy-not-met" };
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1287
1328
|
async function validate(path, opts) {
|
|
1288
1329
|
opts = guard.identifier.optionsObject(opts, E, "path/bad-input", "validate: opts");
|
|
1289
1330
|
guard.identifier.assertKnownKeys(opts, _VALIDATE_OPTS, E, "path/bad-input",
|
|
1290
1331
|
"pki.path.validate has an unknown option. The anchor here is `trustAnchor`, singular; " +
|
|
1291
1332
|
"pki.path.build takes `trustAnchors`. The unknown option was: ");
|
|
1333
|
+
// A CT policy without a log-list cannot be enforced -- refuse it at entry rather than silently
|
|
1334
|
+
// skipping the CT gate and returning a valid path the caller believes was CT-checked (fail-closed).
|
|
1335
|
+
if (opts.ctPolicy != null && opts.ctLogList == null) throw E("path/bad-input", "validate: opts.ctPolicy requires opts.ctLogList (the trusted CT log-list to verify the certificate's SCTs against)");
|
|
1336
|
+
// Validate the CT policy's KEYS at entry: a misspelled key (e.g. `minSCTs`) would otherwise read as
|
|
1337
|
+
// undefined and silently default to the RFC floor of 1, applying a weaker policy than the caller asked
|
|
1338
|
+
// for. The values are validated by verifySctList when the gate runs.
|
|
1339
|
+
// Validate the whole CT configuration at entry, independent of whether the target carries an SCT
|
|
1340
|
+
// extension, so a malformed policy or log-list is always path/bad-input -- never masked as a
|
|
1341
|
+
// per-certificate path/ct-required just because the target happens to lack SCTs.
|
|
1342
|
+
if (opts.ctLogList != null) {
|
|
1343
|
+
if (typeof opts.ctLogList !== "object" || opts.ctLogList.byLogId == null) throw E("path/bad-input", "validate: opts.ctLogList must be a pki.ct.parseLogList result");
|
|
1344
|
+
var ctp = guard.identifier.optionsObject(opts.ctPolicy, E, "path/bad-input", "validate: opts.ctPolicy");
|
|
1345
|
+
guard.identifier.assertKnownKeys(ctp, { minScts: 1, minOperators: 1 }, E, "path/bad-input", "validate: opts.ctPolicy has an unknown key (the CT policy keys are minScts / minOperators): ");
|
|
1346
|
+
if (ctp.minScts != null && (typeof ctp.minScts !== "number" || !intrinsic.isInteger(ctp.minScts) || ctp.minScts < 1)) throw E("path/bad-input", "validate: opts.ctPolicy.minScts must be a positive integer");
|
|
1347
|
+
if (ctp.minOperators != null && (typeof ctp.minOperators !== "number" || !intrinsic.isInteger(ctp.minOperators) || ctp.minOperators < 1)) throw E("path/bad-input", "validate: opts.ctPolicy.minOperators must be a positive integer");
|
|
1348
|
+
}
|
|
1292
1349
|
if (!intrinsic.isArray(path)) throw E("path/bad-input", "validate: path must be an array of certificates");
|
|
1293
1350
|
// Bound the per-cert asymmetric-verify work BEFORE parsing an untrusted bundle
|
|
1294
1351
|
// (the policy-tree cap guards CVE-2023-0464-style blow-up; this guards linear
|
|
@@ -1607,6 +1664,15 @@ async function validate(path, opts) {
|
|
|
1607
1664
|
failed = true;
|
|
1608
1665
|
}
|
|
1609
1666
|
|
|
1667
|
+
// Opt-in CT gate: verify the target's embedded SCTs and record the verdict on its checks (RFC 6962).
|
|
1668
|
+
if (opts.ctLogList != null) {
|
|
1669
|
+
var ctCheck = await ctGateCheck(certs[n - 1], certs, anchor, opts.ctLogList, opts.ctPolicy || {}, n, opts.time);
|
|
1670
|
+
var target = null;
|
|
1671
|
+
for (var tr = 0; tr < state.results.length; tr++) { if (state.results[tr].index === n - 1) { target = state.results[tr]; break; } }
|
|
1672
|
+
if (target) intrinsic.push(target.checks, ctCheck);
|
|
1673
|
+
if (!ctCheck.ok) failed = true;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1610
1676
|
return {
|
|
1611
1677
|
valid: !failed,
|
|
1612
1678
|
// What the revocation rule and the anchor's own trust metadata actually decided. `valid` alone
|
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:1d7ac08a-caae-46f2-b074-9a7c370ea22f",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-08-
|
|
8
|
+
"timestamp": "2026-08-30T12:52:47.496Z",
|
|
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.6.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.6.1",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.6.
|
|
25
|
+
"version": "0.6.1",
|
|
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.6.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.6.1",
|
|
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.6.
|
|
57
|
+
"ref": "@blamejs/pki@0.6.1",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|