@blamejs/pki 0.3.20 → 0.3.21
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 -1
- package/README.md +1 -1
- package/lib/ct.js +186 -8
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +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.3.
|
|
7
|
+
## v0.3.21 — 2026-07-26
|
|
8
|
+
|
|
9
|
+
A Certificate Transparency log-list live-fetch client ships -- pki.ct.fetchLogList fetches and verifies the CT log list over HTTPS before trusting a single log.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.ct.fetchLogList(opts) -- fetch the Certificate Transparency log list live and return the trusted-log set only after the detached signature verifies against the caller-pinned distributor key. It GETs opts.url (the log_list.json) and the detached opts.sigUrl (the log_list.sig, by default opts.url with a .json path suffix rewritten to .sig) over the shared pki.transport (or an injected opts.transport), verifies pki.ct.verifyLogListSignature over the raw JSON bytes against opts.signerKey, and only on a valid signature ingests the same bytes through pki.ct.parseLogList -- returning { logs, byLogId, version, timestamp, raw, status, contentType, tls }. No baked-in vendor URL and no baked-in key (both are caller-pinned); explicit TLS trust (an anchor set or an opts.tls.useSystemStore opt-in, rejectUnauthorized always on); each GET is size-capped before verify/parse; every fetch / verify / parse failure is a typed CtError. RFC 6962.
|
|
14
|
+
- pki.ct.parseLogList now also returns the document's version (a string or null) and timestamp (the parsed log_list_timestamp as a Date, or null when absent/unparseable) -- the freshness surface a caller polices, read leniently from the same document. Existing callers of the { logs, byLogId } shape are unaffected.
|
|
15
|
+
|
|
16
|
+
## v0.3.20 — 2026-07-25
|
|
8
17
|
|
|
9
18
|
PKCS#12 public-key privacy ships -- encrypt a store's contents to a recipient public key with pki.pkcs12.build/open, plus a webcrypto RSA algorithm-name fix.
|
|
10
19
|
|
package/README.md
CHANGED
|
@@ -235,7 +235,7 @@ is callable today; nothing below is a stub.
|
|
|
235
235
|
| `pki.smime` | RFC 8551 S/MIME message assembly, verification, encryption, and compression over the CMS layer — `sign(content, signers, opts)` wraps a MIME entity as a signed S/MIME message in either form: `multipart/signed` (clear-signed — the content stays readable in any MUA, a detached CMS SignedData rides alongside as `application/pkcs7-signature` with a matching `micalg`) or `application/pkcs7-mime; smime-type=signed-data` (opaque — the whole entity is a base64 CMS SignedData). The signed bytes are the entity's RFC 8551 §3.1.1 canonical form (CRLF line endings); `verify(message, opts)` unwraps both forms and recomputes over the same canonicalizer, so a transport that re-wraps line endings still verifies and 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 — `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, not the header, and decryption is fail-closed and oracle-free. The crypto is entirely `pki.cms.sign` / `verify` / `encrypt` / `decrypt` — 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 (algorithm-agnostic). Like `cms.verify`, `verify` returns the per-signer cryptographic verdict plus the recovered content; chaining a signer to a trust anchor is the caller's `pki.path.validate` step. `compress(content, opts)` / `decompress(message, opts)` add the opaque `application/pkcs7-mime; smime-type=compressed-data; name=smime.p7z` frame (RFC 8551 §3.6, RFC 3274) — a size transform with no integrity/confidentiality (§2.4.5), decompress bounded against a bomb; the recovered content, which may itself be signed or enveloped, is returned for the caller to re-verify. Bidirectionally interoperable with `openssl smime` / `openssl cms`. Fail-closed with typed `smime/*` errors — `sign`, `verify`, `encrypt`, `decrypt`, `compress`, `decompress` |
|
|
236
236
|
| `pki.tsp` | RFC 3161 Time-Stamp Protocol — `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 / ordering), plus the RFC 3161 §2.4.2 signing-certificate attribute binding the token to the TSA certificate (SHA-2 imprints, any `pki.cms.sign` TSA key). `request` / `parseRequest` build and parse the TimeStampReq a client sends (imprint, requested policy, nonce, certReq), `response` / `parseResponse` the TimeStampResp a TSA returns — a granted status wrapping a token, or a rejection with PKIStatus and failure info, the §2.4.2 status↔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`, returning `{ valid, genTime, serialNumber, tstInfo, … }` — `sign`, `request`, `parseRequest`, `response`, `parseResponse`, `verify` |
|
|
237
237
|
| `pki.ocsp` | RFC 6960 Online Certificate Status Protocol — the responder and relying-party surface. `buildRequest(query, opts)` builds an OCSPRequest for one or more `{ cert, issuer }` pairs (CertID hashed under SHA-1 by default per the RFC 5019 lightweight profile, or SHA-2; optional RFC 9654 nonce, optional requestor signature). `sign(responseData, responder, opts)` produces a signed BasicOCSPResponse over the exact `ResponseData` DER — the issuing CA directly or a delegated responder, any `pki.cms.sign` key including the post-quantum ML-DSA / SLH-DSA sets, with `good` / `revoked` (reason + time) / `unknown` per-certificate status, and `buildErrorResponse(status)` the unsigned §2.3 error (`tryLater` / `unauthorized` / …). `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, passing the full out-of-path certificate gates), the signature over `tbsResponseDataBytes`, currency (`thisUpdate`/`nextUpdate`), and the request-nonce echo — returning `{ status: "good" / "revoked" / "unknown", … }`, never a silent accept. Transport-free — `buildRequest`, `sign`, `buildErrorResponse`, `verify` |
|
|
238
|
-
| `pki.ct` | RFC 6962 Certificate Transparency SCTs — `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; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, 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. The producing side: `encodeSctList` builds the extension value byte-for-byte (the exact inverse of `parseSctList`) and `signSct` performs a log's signing step (rebuilding the same signed-data preimage the verifier hashes and signing it with the log's ECDSA-P-256 / RSA key). The trust surface: `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), decoding the state + temporal-interval constraints — and `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable/qualified/readonly trusted; retired only before retirement; pending/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, EC P-256 arm; forgeable-key defenses fail closed) — cross-checked against `openssl dgst`, completing the offline log-list trust chain. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature` |
|
|
238
|
+
| `pki.ct` | RFC 6962 Certificate Transparency SCTs — `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; `verifySct` verifies an SCT signature against a log's public key by reconstructing the signed data, 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. The producing side: `encodeSctList` builds the extension value byte-for-byte (the exact inverse of `parseSctList`) and `signSct` performs a log's signing step (rebuilding the same signed-data preimage the verifier hashes and signing it with the log's ECDSA-P-256 / RSA key). The trust surface: `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), decoding the state + temporal-interval constraints — and `verifySctWithLogList` resolves the log key from an SCT's log id, enforces the state (usable/qualified/readonly trusted; retired only before retirement; pending/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, EC P-256 arm; forgeable-key defenses fail closed) — cross-checked against `openssl dgst`, completing the offline log-list trust chain. `fetchLogList(opts)` turns that chain into a live client: it GETs the `log_list.json` and its detached `log_list.sig` over the shared `pki.transport`, verifies the detached signature over the raw fetched bytes against a caller-pinned distributor key **before** parsing (verify-before-parse — 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`/`timestamp`. No baked-in vendor URL or key (both caller-pinned); explicit TLS trust with `rejectUnauthorized` always on; each response is size-capped before the trust chain; the transport is dependency-injectable so it is fully testable offline. Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct`, `parseLogList`, `verifySctWithLogList`, `verifyLogListSignature`, `fetchLogList` |
|
|
239
239
|
| `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` |
|
|
240
240
|
| `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` |
|
|
241
241
|
| `pki.shbs` | Stateful hash-based signature **verification** — HSS/LMS (RFC 8554), carried in X.509 by RFC 9802 and CMS by RFC 9708, profiled by NIST SP 800-208 (CNSA 2.0 firmware signing). `verify` checks an HSS signature (every level must pass) and `verifyLms` a single-tree LMS, over the raw public-key / signature blobs the parsers already surface. Pure public-input SHA-256 / SHAKE256 hashing, a data-driven typecode registry, bounds-before-slice reads; a malformed blob throws a typed `ShbsError`, a well-formed-but-wrong signature returns `false`. **Verify only by design** — stateful signing needs atomic one-time-key state that belongs in an HSM — `verify`, `verifyLms` |
|
package/lib/ct.js
CHANGED
|
@@ -49,14 +49,17 @@ var oid = require("./oid.js");
|
|
|
49
49
|
var webcrypto = require("./webcrypto.js");
|
|
50
50
|
var validator = require("./validator-all.js");
|
|
51
51
|
var rfc3339 = require("./rfc3339.js");
|
|
52
|
+
var httpTransport = require("./http-transport.js");
|
|
52
53
|
var subtle = webcrypto.webcrypto.subtle;
|
|
53
54
|
|
|
54
55
|
var CtError = frameworkError.CtError;
|
|
56
|
+
var PkiError = frameworkError.PkiError;
|
|
55
57
|
var C = constants;
|
|
56
58
|
|
|
57
|
-
// (code, message) -> CtError, the factory the composed guards
|
|
58
|
-
// malformed value keeps the ct/* typed verdict
|
|
59
|
-
|
|
59
|
+
// (code, message[, cause]) -> CtError, the factory the composed guards + the shared transport throw
|
|
60
|
+
// through so a malformed value keeps the ct/* typed verdict (the optional cause carries the underlying
|
|
61
|
+
// transport / evaluation error; existing 2-arg callers pass undefined -- behavior-preserving).
|
|
62
|
+
function _ctErr(c, m, cause) { return new CtError(c, m, cause); }
|
|
60
63
|
|
|
61
64
|
// RFC 5246 sec. 7.4.1.4.1 code points -- 1-byte, NOT OIDs. Surfaced named; an
|
|
62
65
|
// unknown code surfaces as its numeric byte with a null name (never rejected --
|
|
@@ -664,7 +667,7 @@ function _logsAgree(a, b) {
|
|
|
664
667
|
|
|
665
668
|
/**
|
|
666
669
|
* @primitive pki.ct.parseLogList
|
|
667
|
-
* @signature pki.ct.parseLogList(json, opts?) -> { logs, byLogId }
|
|
670
|
+
* @signature pki.ct.parseLogList(json, opts?) -> { logs, byLogId, version, timestamp }
|
|
668
671
|
* @since 0.2.28
|
|
669
672
|
* @status experimental
|
|
670
673
|
* @spec RFC 6962
|
|
@@ -678,9 +681,11 @@ function _logsAgree(a, b) {
|
|
|
678
681
|
* `SHA-256(SPKI)` and fail-closed **requires** it equal the stated `log_id` (RFC 6962 sec. 3.2 -- a log
|
|
679
682
|
* whose stated id disagrees with its key is refused as `ct/log-id-mismatch`), and decodes the `state`
|
|
680
683
|
* (exactly one of pending/qualified/usable/readonly/retired/rejected) and `temporal_interval`. Returns
|
|
681
|
-
* `{ logs, byLogId }` where each log is `{ logId, logIdHex, key, description, url, mmd,
|
|
682
|
-
* { name, since, trusted, conditional }, temporalInterval, trusted }
|
|
683
|
-
* `{ logIdHex: log }` map
|
|
684
|
+
* `{ logs, byLogId, version, timestamp }` where each log is `{ logId, logIdHex, key, description, url, mmd,
|
|
685
|
+
* operator, state: { name, since, trusted, conditional }, temporalInterval, trusted }`, `byLogId` is a
|
|
686
|
+
* null-proto `{ logIdHex: log }` map, `version` is the document's version string (or null), and `timestamp`
|
|
687
|
+
* is the parsed `log_list_timestamp` `Date` (or null when absent/unparseable -- the staleness surface, read
|
|
688
|
+
* leniently, never a throw). Every malformed / oversized / mis-bound input is a typed `CtError`.
|
|
684
689
|
*
|
|
685
690
|
* @example
|
|
686
691
|
* var logList = pki.ct.parseLogList(logListJsonBytes);
|
|
@@ -716,7 +721,14 @@ function parseLogList(json, opts) {
|
|
|
716
721
|
}
|
|
717
722
|
}
|
|
718
723
|
}
|
|
719
|
-
|
|
724
|
+
// Surface the document's own version + timestamp additively (the staleness surface the live-fetch client
|
|
725
|
+
// returns for the caller to police freshness). Read LENIENTLY -- a missing / non-string / unparseable
|
|
726
|
+
// scalar yields null, never a new throw, so every existing caller of the { logs, byLogId } shape is
|
|
727
|
+
// behavior-preserving.
|
|
728
|
+
var version = typeof doc.version === "string" ? doc.version : null;
|
|
729
|
+
var timestamp = (typeof doc.log_list_timestamp === "string" && rfc3339.isValid(doc.log_list_timestamp))
|
|
730
|
+
? rfc3339.parse(doc.log_list_timestamp, _ctErr, "ct/bad-date", "log_list_timestamp") : null;
|
|
731
|
+
return { logs: logs, byLogId: byLogId, version: version, timestamp: timestamp };
|
|
720
732
|
}
|
|
721
733
|
|
|
722
734
|
// The covered certificate's notAfter for the temporal gate: an explicit opts.certNotAfter, else derived
|
|
@@ -843,6 +855,171 @@ async function verifyLogListSignature(json, signature, publicKey) {
|
|
|
843
855
|
}
|
|
844
856
|
}
|
|
845
857
|
|
|
858
|
+
// ---- CT log-list live-fetch client (RFC 6962 sec. 3.2; the Chrome/Apple detached log_list.sig model) ----
|
|
859
|
+
|
|
860
|
+
var DEFAULT_FETCH_TIMEOUT = C.TIME.seconds(30);
|
|
861
|
+
var MAX_FETCH_TIMEOUT = C.TIME.seconds(600);
|
|
862
|
+
var KNOWN_FETCH_OPTS = { url: 1, signerKey: 1, sigUrl: 1, transport: 1, tls: 1, headers: 1, timeout: 1, maxResponseBytes: 1, requireJsonContentType: 1 };
|
|
863
|
+
|
|
864
|
+
// Map opts.tls (operator-facing) to the transport request.tls shape. rejectUnauthorized is NOT here -- the
|
|
865
|
+
// transport forces it on unconditionally; the verb never disables server verification.
|
|
866
|
+
function _tlsForFetch(opts) {
|
|
867
|
+
var t = opts.tls || {};
|
|
868
|
+
return { anchors: t.anchors, useSystemStore: t.useSystemStore, cert: t.cert, key: t.key, minVersion: t.minVersion, servername: t.servername, checkServerIdentity: t.checkServerIdentity };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Do the two URLs share an origin (scheme + host + port)? Both args are already-validated absolute URL
|
|
872
|
+
// strings (each came from `new URL(...).href` after the https gate), so the parse cannot fail here.
|
|
873
|
+
function _sameOrigin(a, b) { return new URL(a).origin === new URL(b).origin; }
|
|
874
|
+
|
|
875
|
+
// The response content-type token (before any ;-parameters), lowercased.
|
|
876
|
+
function _fetchCtypeToken(headers) { return String((headers || {})["content-type"] || "").split(";")[0].trim().toLowerCase(); }
|
|
877
|
+
|
|
878
|
+
// Parse a URL and require https BEFORE any request -- runs even when a transport is INJECTED, so an insecure
|
|
879
|
+
// URL never reaches the wire (the CT log list is fetched over TLS; the detached signature is the authenticity
|
|
880
|
+
// gate, but the fetch stays confined to https, consistent with the est/acme clients). A parse failure is
|
|
881
|
+
// ct/bad-url; a non-https scheme is ct/insecure-url.
|
|
882
|
+
function _parseHttpsUrl(u, label) {
|
|
883
|
+
var parsed;
|
|
884
|
+
try { parsed = new URL(String(u)); }
|
|
885
|
+
catch (e) { throw _ctErr("ct/bad-url", "the CT log-list " + label + " did not parse: " + String(u), e); }
|
|
886
|
+
if (parsed.protocol !== "https:") throw _ctErr("ct/insecure-url", "the CT log-list " + label + " must be https, got " + parsed.protocol + " for " + String(u));
|
|
887
|
+
return parsed;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Derive the detached-signature URL from the (already parsed, https) log_list.json URL by the Chrome/Apple
|
|
891
|
+
// convention: the final ".json" path suffix rewritten to ".sig". A path that does NOT end in ".json" cannot
|
|
892
|
+
// yield an unambiguous sig URL, so the caller must pass opts.sigUrl explicitly (guards never guess a value).
|
|
893
|
+
// The derived URL inherits the json URL's https origin.
|
|
894
|
+
function _sigUrlFor(parsed) {
|
|
895
|
+
if (!/\.json$/.test(parsed.pathname)) throw _ctErr("ct/bad-input", "cannot derive the detached-signature URL from a non-.json path (" + JSON.stringify(parsed.pathname) + "); pass opts.sigUrl explicitly");
|
|
896
|
+
var sig = new URL(parsed.href);
|
|
897
|
+
sig.pathname = parsed.pathname.replace(/\.json$/, ".sig");
|
|
898
|
+
return sig.href;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// One bounded GET returning { body: Buffer, status, contentType, tls }. The body is re-viewed through the
|
|
902
|
+
// byte guard (a detached backing buffer fails as ct/bad-input, never a silent empty body); a string body
|
|
903
|
+
// falls back to a UTF-8 encode -- the width a real socket already counts -- so the size cap and verify/parse
|
|
904
|
+
// see the same bytes, never a typed array stringified to "48,130,..." garbage. An oversized body ->
|
|
905
|
+
// ct/response-too-large (the post-hoc twin of the transport's streaming abort); a non-200 -> ct/http-error
|
|
906
|
+
// carrying the status; an empty 200 body -> ct/empty-response. Both GETs must succeed before verify.
|
|
907
|
+
function _fetchBody(transport, url, req, label) {
|
|
908
|
+
// Wrap the transport call in a Promise.resolve().then so a SYNCHRONOUS throw from an injected transport
|
|
909
|
+
// becomes a rejection the catch below can type (not an uncaught throw crossing the API boundary).
|
|
910
|
+
return Promise.resolve().then(function () {
|
|
911
|
+
return transport({ method: "GET", url: url, headers: req.headers, tls: req.tls, timeout: req.timeout, maxResponseBytes: req.maxResponseBytes });
|
|
912
|
+
}).then(function (res) {
|
|
913
|
+
res = res || {};
|
|
914
|
+
var h = {};
|
|
915
|
+
Object.keys(res.headers || {}).forEach(function (k) { h[k.toLowerCase()] = res.headers[k]; });
|
|
916
|
+
var body = (Buffer.isBuffer(res.body) || res.body instanceof Uint8Array)
|
|
917
|
+
? guard.bytes.view(res.body, CtError, "ct/bad-input", label)
|
|
918
|
+
: Buffer.from(String(res.body == null ? "" : res.body), "utf8");
|
|
919
|
+
if (body.length > req.maxResponseBytes) throw _ctErr("ct/response-too-large", "the " + label + " (" + body.length + " bytes) exceeds the " + req.maxResponseBytes + "-byte cap");
|
|
920
|
+
if (res.status !== 200) throw _ctErr("ct/http-error", "the CT server returned HTTP " + JSON.stringify(res.status) + " for the " + label);
|
|
921
|
+
if (body.length === 0) throw _ctErr("ct/empty-response", "the CT server returned a 200 with an empty " + label);
|
|
922
|
+
return { body: body, status: res.status, contentType: _fetchCtypeToken(h), tls: res.tls || null };
|
|
923
|
+
}).catch(function (e) {
|
|
924
|
+
// An already-typed PkiError (the default transport's ct/*, this function's own ct/* verdicts, or a
|
|
925
|
+
// caller-thrown typed error) propagates UNCHANGED; any other throw from an injected transport -- a raw
|
|
926
|
+
// network Error, a synchronous throw -- is wrapped as a typed ct/transport-error so a fetch failure never
|
|
927
|
+
// crosses the API boundary untyped (the documented typed-error contract).
|
|
928
|
+
if (e instanceof PkiError) throw e;
|
|
929
|
+
throw _ctErr("ct/transport-error", "the CT " + label + " request failed in the transport", e);
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* @primitive pki.ct.fetchLogList
|
|
935
|
+
* @signature pki.ct.fetchLogList(opts) -> Promise<{ logs, byLogId, version, timestamp, raw, status, contentType, tls }>
|
|
936
|
+
* @since 0.3.21
|
|
937
|
+
* @status experimental
|
|
938
|
+
* @spec RFC 6962
|
|
939
|
+
* @related pki.ct.parseLogList, pki.ct.verifyLogListSignature, pki.ct.verifySctWithLogList
|
|
940
|
+
*
|
|
941
|
+
* Fetch the Certificate Transparency log list live and return the trusted-log set ONLY after the detached
|
|
942
|
+
* signature verifies against the caller-pinned distributor key. It GETs `opts.url` (the `log_list.json`)
|
|
943
|
+
* and the detached `opts.sigUrl` (the `log_list.sig`, by default `opts.url` with a `.json` path suffix
|
|
944
|
+
* rewritten to `.sig`) over the shared, fail-closed `pki.transport` (or an injected `opts.transport`), then
|
|
945
|
+
* verifies the detached signature over the RAW fetched JSON bytes against `opts.signerKey` and only on a
|
|
946
|
+
* strict `true` verdict ingests the SAME bytes through `parseLogList` -- so the client never parses, reads,
|
|
947
|
+
* caches, or surfaces any field of an unverified document (verify-before-parse). No baked-in vendor URL and
|
|
948
|
+
* no baked-in key: the caller pins both out-of-band. Trust is EXPLICIT -- an `opts.tls.anchors` set or an
|
|
949
|
+
* `opts.tls.useSystemStore` opt-in, `rejectUnauthorized` always on. The returned `timestamp` is surfaced
|
|
950
|
+
* (never policed) so the caller enforces its own freshness policy; chaining a resolved log to an SCT is the
|
|
951
|
+
* caller's `verifySctWithLogList` step. Every fetch / verify / parse failure is a typed `CtError`.
|
|
952
|
+
*
|
|
953
|
+
* @opts url REQUIRED -- the `log_list.json` URL; must be https (no baked-in vendor URL).
|
|
954
|
+
* @opts signerKey REQUIRED, PINNED -- the distributor SubjectPublicKeyInfo as a DER Buffer; no baked-in key.
|
|
955
|
+
* @opts sigUrl OPTIONAL -- the detached `log_list.sig` URL (https, must share the log-list URL's origin); default `url` with `.json` -> `.sig` (a non-.json url requires an explicit sigUrl).
|
|
956
|
+
* @opts transport OPTIONAL injectable `transport(request) -> Promise<{status,headers,body}>` (default `pki.transport.https`); the test seam.
|
|
957
|
+
* @opts tls OPTIONAL `{ anchors, useSystemStore, cert, key, minVersion, servername, checkServerIdentity }` threaded to the default transport (ignored when a transport is injected); `rejectUnauthorized` is always on.
|
|
958
|
+
* @opts headers OPTIONAL extra request headers (the request-framing headers are stripped; the verb owns the GET method).
|
|
959
|
+
* @opts timeout OPTIONAL ms budget, default 30s (cap-validated).
|
|
960
|
+
* @opts maxResponseBytes OPTIONAL per-GET size cap, default 4 MiB, tightenable DOWNWARD only.
|
|
961
|
+
* @opts requireJsonContentType OPTIONAL boolean, default false -- opt in to a strict `ct/bad-content-type` gate on the JSON GET.
|
|
962
|
+
* @example
|
|
963
|
+
* // a live distributor uses the default pki.transport.https; here an injected transport returns the pair
|
|
964
|
+
* var r = await pki.ct.fetchLogList({ url: "https://ct.example/log_list.json", signerKey: googleSignerSpki,
|
|
965
|
+
* transport: function (req) {
|
|
966
|
+
* var isSig = /\.sig$/.test(req.url);
|
|
967
|
+
* return Promise.resolve({ status: 200, headers: { "content-type": isSig ? "application/octet-stream" : "application/json" }, body: isSig ? logListSig : logListJsonBytes });
|
|
968
|
+
* } });
|
|
969
|
+
* r.logs[0] && r.logs[0].trusted; // the verified, trusted-log set (the detached signature checked first)
|
|
970
|
+
*/
|
|
971
|
+
async function fetchLogList(opts) {
|
|
972
|
+
opts = opts || {};
|
|
973
|
+
Object.keys(opts).forEach(function (k) { if (!KNOWN_FETCH_OPTS[k]) throw _ctErr("ct/bad-input", "unknown opts field " + JSON.stringify(k)); });
|
|
974
|
+
if (opts.signerKey == null) throw _ctErr("ct/bad-input", "opts.signerKey is required -- the caller-pinned CT log-list distributor SPKI (there is no baked-in key)");
|
|
975
|
+
if (opts.url == null) throw _ctErr("ct/bad-input", "opts.url is required -- the log_list.json URL (there is no baked-in vendor URL)");
|
|
976
|
+
// Parse + https-gate both URLs BEFORE any transport call -- the gate runs across the injectable seam so an
|
|
977
|
+
// http URL never reaches even an injected transport (the documented HTTPS guarantee holds either way).
|
|
978
|
+
var jsonParsed = _parseHttpsUrl(opts.url, "URL");
|
|
979
|
+
var jsonUrl = jsonParsed.href;
|
|
980
|
+
var sigUrl = opts.sigUrl != null ? _parseHttpsUrl(opts.sigUrl, "signature URL").href : _sigUrlFor(jsonParsed);
|
|
981
|
+
// The detached signature MUST share the log-list URL's origin. The log-list trust rests on the pinned-key
|
|
982
|
+
// signature, not on where the signature is hosted; requiring one origin means both GETs carry the same
|
|
983
|
+
// per-origin credentials + TLS identity with NO cross-origin leak (an Authorization/Cookie header or an
|
|
984
|
+
// mTLS client certificate configured for the log-list host can never reach a different signature host). A
|
|
985
|
+
// caller with a genuinely separate signature host fetches it itself and composes verifyLogListSignature +
|
|
986
|
+
// parseLogList directly.
|
|
987
|
+
if (!_sameOrigin(jsonUrl, sigUrl)) throw _ctErr("ct/bad-input", "opts.sigUrl must share the log-list URL's origin (" + jsonParsed.origin + "); a cross-origin detached-signature host is not supported");
|
|
988
|
+
var transport = opts.transport;
|
|
989
|
+
if (!transport) {
|
|
990
|
+
var t = opts.tls || {};
|
|
991
|
+
var hasAnchors = t.anchors !== undefined && t.anchors !== null && !(Array.isArray(t.anchors) && t.anchors.length === 0);
|
|
992
|
+
if (!hasAnchors && t.useSystemStore !== true) throw _ctErr("ct/no-trust-anchors", "no explicit trust anchor and tls.useSystemStore not set to true -- refusing an unpinned CT server (RFC 6962 sec. 3.2)");
|
|
993
|
+
transport = httpTransport.https({ E: _ctErr, errPrefix: "ct" });
|
|
994
|
+
}
|
|
995
|
+
var timeout = guard.limits.cap(opts.timeout, "timeout", DEFAULT_FETCH_TIMEOUT, { E: _ctErr, code: "ct/bad-input", min: 1, max: MAX_FETCH_TIMEOUT });
|
|
996
|
+
var maxResponseBytes = guard.limits.cap(opts.maxResponseBytes, "maxResponseBytes", C.LIMITS.CT_LOG_LIST_MAX_BYTES, { E: _ctErr, code: "ct/bad-input", min: 1, max: C.LIMITS.CT_LOG_LIST_MAX_BYTES });
|
|
997
|
+
// Forward custom opts.headers but STRIP the request-framing headers so a caller cannot desync the request
|
|
998
|
+
// framing; the verb owns the GET method (no body).
|
|
999
|
+
var headers = {};
|
|
1000
|
+
Object.keys(opts.headers || {}).forEach(function (k) {
|
|
1001
|
+
var lk = k.toLowerCase();
|
|
1002
|
+
if (lk !== "content-length" && lk !== "transfer-encoding") headers[k] = opts.headers[k];
|
|
1003
|
+
});
|
|
1004
|
+
var req = { headers: headers, tls: _tlsForFetch(opts), timeout: timeout, maxResponseBytes: maxResponseBytes };
|
|
1005
|
+
// Two bounded GETs; BOTH must succeed before verify. The JSON is fetched first (its content-type is the one
|
|
1006
|
+
// opts.requireJsonContentType polices). Both requests carry the same per-origin credentials + TLS identity,
|
|
1007
|
+
// which is sound because the signature shares the log-list origin (gated above).
|
|
1008
|
+
var jsonRes = await _fetchBody(transport, jsonUrl, req, "CT log-list JSON");
|
|
1009
|
+
if (opts.requireJsonContentType === true && jsonRes.contentType !== "application/json") throw _ctErr("ct/bad-content-type", "the CT log-list JSON GET returned content-type " + JSON.stringify(jsonRes.contentType || null) + " (opts.requireJsonContentType is set)");
|
|
1010
|
+
var sigRes = await _fetchBody(transport, sigUrl, req, "CT log-list signature");
|
|
1011
|
+
// VERIFY BEFORE PARSE: the fetched JSON is untrusted until the detached signature verifies over the RAW
|
|
1012
|
+
// bytes against the caller-pinned key. parseLogList runs ONLY on a strict === true verdict, over the SAME
|
|
1013
|
+
// Buffer -- nothing re-serialized between verify and parse.
|
|
1014
|
+
var ok = await verifyLogListSignature(jsonRes.body, sigRes.body, opts.signerKey);
|
|
1015
|
+
if (ok !== true) throw _ctErr("ct/log-list-untrusted", "the CT log-list detached signature did not verify against the pinned distributor key -- the fetched list is untrusted and was not parsed");
|
|
1016
|
+
var parsed = parseLogList(jsonRes.body);
|
|
1017
|
+
return {
|
|
1018
|
+
logs: parsed.logs, byLogId: parsed.byLogId, version: parsed.version, timestamp: parsed.timestamp,
|
|
1019
|
+
raw: { json: jsonRes.body, sig: sigRes.body }, status: jsonRes.status, contentType: jsonRes.contentType, tls: jsonRes.tls,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
846
1023
|
module.exports = {
|
|
847
1024
|
parseSctList: parseSctList,
|
|
848
1025
|
reconstructSignedData: reconstructSignedData,
|
|
@@ -852,6 +1029,7 @@ module.exports = {
|
|
|
852
1029
|
parseLogList: parseLogList,
|
|
853
1030
|
verifySctWithLogList: verifySctWithLogList,
|
|
854
1031
|
verifyLogListSignature: verifyLogListSignature,
|
|
1032
|
+
fetchLogList: fetchLogList,
|
|
855
1033
|
HASH_ALGORITHMS: HASH_ALGORITHMS,
|
|
856
1034
|
SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
|
|
857
1035
|
};
|
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:b01f035a-7844-490a-a236-5c901a15cfa3",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-26T07:13:35.044Z",
|
|
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.3.
|
|
22
|
+
"bom-ref": "@blamejs/pki@0.3.21",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.3.
|
|
25
|
+
"version": "0.3.21",
|
|
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.3.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/pki@0.3.21",
|
|
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.3.
|
|
57
|
+
"ref": "@blamejs/pki@0.3.21",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|