@blamejs/pki 0.2.27 → 0.2.29
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 +17 -0
- package/README.md +1 -1
- package/lib/acme.js +5 -21
- package/lib/cms-verify.js +3 -1
- package/lib/constants.js +3 -0
- package/lib/ct.js +260 -3
- package/lib/lint.js +3 -0
- package/lib/ocsp-verify.js +1 -0
- package/lib/path-validate.js +1 -0
- package/lib/rfc3339.js +44 -0
- package/lib/validator-sig.js +6 -2
- package/lib/webauthn.js +4 -4
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ 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.29 — 2026-07-16
|
|
8
|
+
|
|
9
|
+
Certificate Transparency log-list signature verification arrives as pki.ct.verifyLogListSignature: verify the detached signature published alongside the CT log list against a caller-pinned signer key, completing the offline log-list trust chain.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- pki.ct.verifyLogListSignature(json, signature, publicKey) verifies the detached signature over the Certificate Transparency log list (log_list.sig over log_list.json). The message is the raw json bytes, verified byte-for-byte with no canonicalization; publicKey is the caller-pinned signer SubjectPublicKeyInfo (there is no embedded key). The scheme is RSASSA-PKCS1-v1.5 with SHA-256 (an EC P-256 arm is accepted for future-proofing). It resolves true or false (a cryptographic verdict) and throws a typed CtError on a forgeable or unsupported key (an RSA exponent below 3, a sub-2048-bit RSA key, an unsupported key type or curve, a non-conformant ECDSA DER signature). Its verdict is cross-checked against OpenSSL's dgst -verify. Paired with pki.ct.parseLogList, this completes the offline CT log-list trust chain.
|
|
14
|
+
|
|
15
|
+
## v0.2.28 — 2026-07-16
|
|
16
|
+
|
|
17
|
+
The Certificate Transparency log-list trust surface arrives as pki.ct.parseLogList and pki.ct.verifySctWithLogList: resolve a trusted CT log's key from an SCT's log id and verify it in one step, ingesting the CT log-list JSON into state- and temporal-interval-constrained trusted logs.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- pki.ct.parseLogList(json, opts) ingests a Certificate Transparency log-list JSON document into { logs, byLogId } -- constraint-carrying trusted logs keyed by log id. Each log's key is base64-decoded to its DER SubjectPublicKeyInfo and validated as on-profile; the log id is recomputed as SHA-256 of the key and must equal the stated log_id (RFC 6962 section 3.2, ct/log-id-mismatch), so a swapped key or a flipped id is refused. The state (exactly one of pending/qualified/usable/readonly/retired/rejected) and temporal_interval decode into trust constraints, and two entries for one recomputed id must agree or the list is rejected (ct/duplicate-log). Parsing is offline and routes through the bounded, duplicate-member-rejecting JSON reader; every malformed input is a typed CtError.
|
|
22
|
+
- pki.ct.verifySctWithLogList(entry, sct, logList, opts) resolves the trusted CT log for an SCT by its log id and verifies it in one step. The log's state gates trust (usable/qualified/readonly proceed; a retired log only for an SCT timestamped before its retirement; pending/rejected are ct/log-untrusted) and its temporal_interval gates the covered certificate (the cert's notAfter -- from the leaf certificate for a precert-free entry, or opts.certNotAfter -- must fall in the log's window, and a windowed log with no resolvable notAfter is ct/temporal-interval, never silently skipped). The signature check is delegated to the shipped pki.ct.verifySct. Resolves true for a valid signature from a trusted, in-window log, false on a cryptographic mismatch, and throws a typed CtError on any structural or trust failure.
|
|
23
|
+
|
|
7
24
|
## v0.2.27 — 2026-07-16
|
|
8
25
|
|
|
9
26
|
CMS CompressedData arrives as pki.cms.compress / pki.cms.decompress, with the matching S/MIME pki.smime.compress / pki.smime.decompress: compress and recover RFC 3274 messages, with a bounded inflate that defends the decompression-bomb class.
|
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ is callable today; nothing below is a stub.
|
|
|
225
225
|
| `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` |
|
|
226
226
|
| `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` |
|
|
227
227
|
| `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` |
|
|
228
|
-
| `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). Structure decoded, crypto fail-closed — `parseSctList`, `reconstructSignedData`, `verifySct`, `encodeSctList`, `signSct` |
|
|
228
|
+
| `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` |
|
|
229
229
|
| `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` |
|
|
230
230
|
| `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` |
|
|
231
231
|
| `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/acme.js
CHANGED
|
@@ -41,6 +41,7 @@ var x509 = require("./schema-x509");
|
|
|
41
41
|
var csr = require("./schema-csr");
|
|
42
42
|
var pkix = require("./schema-pkix");
|
|
43
43
|
var constants = require("./constants");
|
|
44
|
+
var rfc3339 = require("./rfc3339");
|
|
44
45
|
var subtle = require("./webcrypto").webcrypto.subtle;
|
|
45
46
|
var frameworkError = require("./framework-error");
|
|
46
47
|
|
|
@@ -51,27 +52,10 @@ function E(code, message, cause) { return new AcmeError(code, message, cause); }
|
|
|
51
52
|
|
|
52
53
|
function _isObject(v) { return v && typeof v === "object" && !Array.isArray(v); }
|
|
53
54
|
function _isString(v) { return typeof v === "string"; }
|
|
54
|
-
// RFC 3339 date-time (
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
var RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/;
|
|
59
|
-
function _isRfc3339(v) {
|
|
60
|
-
if (!_isString(v)) return false;
|
|
61
|
-
var m = RFC3339_RE.exec(v);
|
|
62
|
-
if (!m) return false;
|
|
63
|
-
var year = +m[1], month = +m[2], day = +m[3], hour = +m[4], min = +m[5], sec = +m[6];
|
|
64
|
-
if (month < 1 || month > 12) return false;
|
|
65
|
-
// Reject a :60 leap second: Node's Date.parse returns NaN for it, so an expiry /
|
|
66
|
-
// renewal-window comparison on such a value would silently pass (NaN <= x is
|
|
67
|
-
// false). This toolkit only handles instants it can compare, so it fails closed.
|
|
68
|
-
if (hour > 23 || min > 59 || sec > 59) return false;
|
|
69
|
-
var leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
70
|
-
var daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
71
|
-
if (day < 1 || day > daysInMonth[month - 1]) return false;
|
|
72
|
-
if (m[7]) { if (+m[8] > 23 || +m[9] > 59) return false; } // numeric zone offset
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
55
|
+
// RFC 3339 date-time validity (grammar + calendar) lives in the shared lib/rfc3339.js primitive so a
|
|
56
|
+
// downstream expiry / renewal-window comparison never runs on an impossible instant (month 13, a :60
|
|
57
|
+
// leap second, a rolled-over value). pki.ct's log-list window parse composes the same primitive.
|
|
58
|
+
function _isRfc3339(v) { return rfc3339.isValid(v); }
|
|
75
59
|
// A URL string: an absolute http(s) URI with a real host (RFC 3986). ACME URLs are
|
|
76
60
|
// server-provided endpoints downstream transport will trust, so they are PARSED
|
|
77
61
|
// (not prefix-matched) -- a malformed value like "https://[" or a hostless
|
package/lib/cms-verify.js
CHANGED
|
@@ -253,7 +253,9 @@ function _verifySignature(scheme, hashName, sigBytes, spki, signedBytes, curveOi
|
|
|
253
253
|
if (scheme.kind === "ec") {
|
|
254
254
|
var ec = EC_CURVE[curveOid];
|
|
255
255
|
if (!ec) throw _err("cms/unsupported-algorithm", "the signer key is on an unsupported EC curve");
|
|
256
|
-
|
|
256
|
+
// The ORDER-AWARE gate: r,s in [1, n-1] per FIPS 186-5 sec. 6.4.2 (rejecting an out-of-range r/s >= the
|
|
257
|
+
// curve order n, not only the r=s=0 forgery) -- a verifier that knows the curve MUST use it.
|
|
258
|
+
var raw = validator.sig.ecdsaDerToP1363(sigBytes, ec.curve, CmsError, "cms/bad-signature");
|
|
257
259
|
return subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: ec.curve }, false, ["verify"])
|
|
258
260
|
.then(function (k) { return subtle.verify({ name: "ECDSA", hash: hashName }, k, raw, signedBytes); });
|
|
259
261
|
}
|
package/lib/constants.js
CHANGED
|
@@ -227,6 +227,9 @@ var LIMITS = {
|
|
|
227
227
|
// enforced BEFORE parsing so a hostile document is refused up front.
|
|
228
228
|
JSON_MAX_BYTES: BYTES.mib(1),
|
|
229
229
|
JSON_MAX_DEPTH: 32,
|
|
230
|
+
// The CT log-list JSON ceiling -- the full "all logs" variant (with historical logs) exceeds the
|
|
231
|
+
// 1 MiB JSON_MAX_BYTES used for small ACME resource objects; 4 MiB gives headroom while staying bounded.
|
|
232
|
+
CT_LOG_LIST_MAX_BYTES: BYTES.mib(4),
|
|
230
233
|
// PBKDF2 iteration ceiling on the DECRYPT side (RFC 8018 pwri / PBES2). An attacker-supplied
|
|
231
234
|
// iterationCount is attacker-controlled work; a message above this is refused before any
|
|
232
235
|
// derivation (typed cms/iteration-limit), and a caller may cap it lower via opts.maxIterations.
|
package/lib/ct.js
CHANGED
|
@@ -48,6 +48,7 @@ var ByteWriter = require("./byte-writer.js");
|
|
|
48
48
|
var oid = require("./oid.js");
|
|
49
49
|
var webcrypto = require("./webcrypto.js");
|
|
50
50
|
var validator = require("./validator-all.js");
|
|
51
|
+
var rfc3339 = require("./rfc3339.js");
|
|
51
52
|
var subtle = webcrypto.webcrypto.subtle;
|
|
52
53
|
|
|
53
54
|
var CtError = frameworkError.CtError;
|
|
@@ -395,9 +396,10 @@ async function verifySct(entry, sct, logPublicKey) {
|
|
|
395
396
|
if (!ec) throw new CtError("ct/unsupported-algorithm", "unsupported SCT log EC curve (RFC 6962 sec. 2.1.4 mandates NIST P-256)");
|
|
396
397
|
imp = { name: "ECDSA", namedCurve: ec.curve };
|
|
397
398
|
ver = { name: "ECDSA", hash: hashName };
|
|
398
|
-
// The SCT signature is a DER ECDSA-Sig-Value; route it through the
|
|
399
|
-
//
|
|
400
|
-
|
|
399
|
+
// The SCT signature is a DER ECDSA-Sig-Value; route it through the ORDER-AWARE conformance gate
|
|
400
|
+
// (primitive, minimal, and r,s in [1, n-1] per FIPS 186-5 sec. 6.4.2 -- rejecting an out-of-range
|
|
401
|
+
// r/s >= the curve order n, not only the r=s=0 shape) before converting to raw r||s.
|
|
402
|
+
sig = validator.sig.ecdsaDerToP1363(sig, ec.curve, CtError, "ct/bad-signature");
|
|
401
403
|
} else if (sigInfo.signatureName === "rsa") {
|
|
402
404
|
if (alg.algOid !== oid.byName("rsaEncryption")) throw new CtError("ct/bad-input", "the SCT declares an RSA signature but the log key is not an RSA key");
|
|
403
405
|
if (!(alg.rsaBits >= 2048)) throw new CtError("ct/unsupported-algorithm", "the SCT log RSA key is below the RFC 6962 sec. 2.1.4 minimum of 2048 bits");
|
|
@@ -592,12 +594,267 @@ async function signSct(entry, logKey, opts) {
|
|
|
592
594
|
};
|
|
593
595
|
}
|
|
594
596
|
|
|
597
|
+
// ---- CT log-list trust surface (RFC 6962 sec. 3.2 + the CT log-list v3 JSON schema) --------------
|
|
598
|
+
|
|
599
|
+
// The recognized CT log states (the schema `state` oneOf) mapped to a trust decision (the deployed
|
|
600
|
+
// Chrome/Apple CT policy): usable/qualified/readonly are trusted; retired is CONDITIONALLY trusted
|
|
601
|
+
// (only for an SCT timestamped before the retirement instant, checked at verify); pending/rejected
|
|
602
|
+
// are not trusted.
|
|
603
|
+
var LOG_STATE_TRUST = { pending: "no", qualified: "yes", usable: "yes", readonly: "yes", retired: "conditional", rejected: "no" };
|
|
604
|
+
|
|
605
|
+
// Decode a log's `state` oneOf: EXACTLY ONE recognized member, whose timestamp is an RFC 3339 instant.
|
|
606
|
+
// Zero, multiple, or an unrecognized member -> ct/bad-state (fail-closed, never a silent default-trust).
|
|
607
|
+
function _parseLogState(state) {
|
|
608
|
+
if (state == null || typeof state !== "object") throw _ctErr("ct/bad-state", "a CT log entry is missing its state");
|
|
609
|
+
var keys = Object.keys(state);
|
|
610
|
+
if (keys.length !== 1 || !Object.prototype.hasOwnProperty.call(LOG_STATE_TRUST, keys[0])) {
|
|
611
|
+
throw _ctErr("ct/bad-state", "a CT log state must carry exactly one recognized member (pending/qualified/usable/readonly/retired/rejected)");
|
|
612
|
+
}
|
|
613
|
+
var name = keys[0], member = state[name];
|
|
614
|
+
if (member == null || typeof member !== "object") throw _ctErr("ct/bad-state", "the CT log state " + name + " is malformed");
|
|
615
|
+
var since = rfc3339.parse(member.timestamp, _ctErr, "ct/bad-date", "the CT log state timestamp");
|
|
616
|
+
var trust = LOG_STATE_TRUST[name];
|
|
617
|
+
return { name: name, since: since, trusted: trust === "yes", conditional: trust === "conditional" };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Decode a log's `temporal_interval` (both bounds RFC 3339, start strictly before end) or null.
|
|
621
|
+
function _parseTemporalInterval(ti) {
|
|
622
|
+
if (ti == null) return null;
|
|
623
|
+
if (typeof ti !== "object") throw _ctErr("ct/bad-log-list", "a CT log temporal_interval must be an object");
|
|
624
|
+
var start = rfc3339.parse(ti.start_inclusive, _ctErr, "ct/bad-date", "temporal_interval.start_inclusive");
|
|
625
|
+
var end = rfc3339.parse(ti.end_exclusive, _ctErr, "ct/bad-date", "temporal_interval.end_exclusive");
|
|
626
|
+
// allow:nan-date-comparison-unguarded -- start/end are rfc3339.parse results, guaranteed non-NaN (rfc3339.isValid rejects a NaN date).
|
|
627
|
+
if (start.getTime() >= end.getTime()) throw _ctErr("ct/bad-log-list", "a CT log temporal_interval start_inclusive must be strictly before end_exclusive");
|
|
628
|
+
return { startInclusive: start, endExclusive: end };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// One log-list entry -> a constraint-carrying trusted-log record. The load-bearing M-BIND step: the
|
|
632
|
+
// log-id is RECOMPUTED as SHA-256(SPKI) and MUST equal the stated log_id (RFC 6962 sec. 3.2), so the
|
|
633
|
+
// trusted set is keyed by the authoritative binding, never the document's self-assertion -- a swapped
|
|
634
|
+
// key or a flipped id is refused. The key is also validated as a well-formed, on-profile SPKI at ingest.
|
|
635
|
+
function _parseLog(log, operatorName) {
|
|
636
|
+
if (log == null || typeof log !== "object") throw _ctErr("ct/bad-log-list", "a CT log entry is not an object");
|
|
637
|
+
if (typeof log.key !== "string" || typeof log.log_id !== "string") throw _ctErr("ct/bad-log-list", "a CT log entry is missing its key or log_id");
|
|
638
|
+
var spki = guard.encoding.base64(log.key, C.LIMITS.CT_LOG_LIST_MAX_BYTES, _ctErr, "ct/bad-log-list", "the CT log key");
|
|
639
|
+
var statedId = guard.encoding.base64(log.log_id, 64, _ctErr, "ct/bad-log-list", "the CT log id");
|
|
640
|
+
if (statedId.length !== 32) throw _ctErr("ct/bad-log-list", "a CT log_id must be 32 bytes (SHA-256), got " + statedId.length);
|
|
641
|
+
_spkiAlg(spki); // fail-fast: a non-SPKI / malformed key (or a forgeable RSA e < 3) throws ct/bad-input; the full EC-curve / RSA-size profile is enforced by verifySct at verify time
|
|
642
|
+
var logId = nodeCrypto.createHash("sha256").update(spki).digest();
|
|
643
|
+
if (!logId.equals(statedId)) throw _ctErr("ct/log-id-mismatch", "the CT log_id does not match SHA-256 of the log key (RFC 6962 sec. 3.2)");
|
|
644
|
+
return {
|
|
645
|
+
logId: logId, logIdHex: logId.toString("hex"), key: spki,
|
|
646
|
+
description: typeof log.description === "string" ? log.description : null,
|
|
647
|
+
url: typeof log.url === "string" ? log.url : (typeof log.submission_url === "string" ? log.submission_url : null),
|
|
648
|
+
mmd: typeof log.mmd === "number" ? log.mmd : null,
|
|
649
|
+
operator: operatorName, state: _parseLogState(log.state), temporalInterval: _parseTemporalInterval(log.temporal_interval),
|
|
650
|
+
trusted: false, // set below from the state (usable/qualified/readonly), so a consumer reads a plain bool
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Two entries for one recomputed log-id must be byte-identical (collapse) or the list is inconsistent.
|
|
655
|
+
function _sameTemporal(a, b) {
|
|
656
|
+
if (a == null && b == null) return true;
|
|
657
|
+
// Coverage residual: the || guard is exercised across null/interval, interval/null, and both-interval
|
|
658
|
+
// inputs; the remaining c8 sub-arm is a short-circuit path (when `a == null` is true, `b == null` is
|
|
659
|
+
// never evaluated) with no additional reachable input.
|
|
660
|
+
if (a == null || b == null) return false;
|
|
661
|
+
return a.startInclusive.getTime() === b.startInclusive.getTime() && a.endExclusive.getTime() === b.endExclusive.getTime();
|
|
662
|
+
}
|
|
663
|
+
function _logsAgree(a, b) {
|
|
664
|
+
return a.state.name === b.state.name && a.state.since.getTime() === b.state.since.getTime() && _sameTemporal(a.temporalInterval, b.temporalInterval);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* @primitive pki.ct.parseLogList
|
|
669
|
+
* @signature pki.ct.parseLogList(json, opts?) -> { logs, byLogId }
|
|
670
|
+
* @since 0.2.28
|
|
671
|
+
* @status experimental
|
|
672
|
+
* @spec RFC 6962
|
|
673
|
+
* @related pki.ct.verifySctWithLogList, pki.ct.verifySct
|
|
674
|
+
*
|
|
675
|
+
* Ingest a Certificate Transparency log-list JSON document (the `log_list.json` browsers consume) into a
|
|
676
|
+
* set of constraint-carrying trusted logs, keyed by log-id. `json` is a Buffer or string -- the caller
|
|
677
|
+
* supplies the already-fetched, already-authenticated bytes (offline, no network fetch). Parsing routes
|
|
678
|
+
* through the bounded, duplicate-member-rejecting JSON reader; for each log it base64-decodes the `key`
|
|
679
|
+
* to its DER SubjectPublicKeyInfo, validates it as a well-formed on-profile key, **recomputes**
|
|
680
|
+
* `SHA-256(SPKI)` and fail-closed **requires** it equal the stated `log_id` (RFC 6962 sec. 3.2 -- a log
|
|
681
|
+
* whose stated id disagrees with its key is refused as `ct/log-id-mismatch`), and decodes the `state`
|
|
682
|
+
* (exactly one of pending/qualified/usable/readonly/retired/rejected) and `temporal_interval`. Returns
|
|
683
|
+
* `{ logs, byLogId }` where each log is `{ logId, logIdHex, key, description, url, mmd, operator, state:
|
|
684
|
+
* { name, since, trusted, conditional }, temporalInterval, trusted }` and `byLogId` is a null-proto
|
|
685
|
+
* `{ logIdHex: log }` map. Every malformed / oversized / mis-bound input is a typed `CtError`.
|
|
686
|
+
*
|
|
687
|
+
* @example
|
|
688
|
+
* var logList = pki.ct.parseLogList(logListJsonBytes);
|
|
689
|
+
* logList.logs[0].trusted; // was the first log trusted (usable/qualified/readonly)?
|
|
690
|
+
*/
|
|
691
|
+
function parseLogList(json, opts) {
|
|
692
|
+
void opts;
|
|
693
|
+
var doc = guard.json.parse(json, _ctErr, {
|
|
694
|
+
maxBytes: C.LIMITS.CT_LOG_LIST_MAX_BYTES, maxDepth: C.LIMITS.JSON_MAX_DEPTH,
|
|
695
|
+
badJson: "ct/bad-json", tooDeep: "ct/too-deep", duplicateMember: "ct/duplicate-member",
|
|
696
|
+
tooLarge: "ct/too-large", badInput: "ct/bad-input", label: "the CT log list",
|
|
697
|
+
});
|
|
698
|
+
if (doc == null || typeof doc !== "object" || !Array.isArray(doc.operators)) throw _ctErr("ct/bad-log-list", "the CT log list must be a JSON object with an operators array");
|
|
699
|
+
var logs = [], byLogId = Object.create(null);
|
|
700
|
+
for (var i = 0; i < doc.operators.length; i++) {
|
|
701
|
+
var op = doc.operators[i];
|
|
702
|
+
if (op == null || typeof op !== "object" || typeof op.name !== "string") throw _ctErr("ct/bad-log-list", "a CT log-list operator is missing its name");
|
|
703
|
+
var arrays = [op.logs, op.tiled_logs];
|
|
704
|
+
for (var a = 0; a < arrays.length; a++) {
|
|
705
|
+
var arr = arrays[a];
|
|
706
|
+
if (arr == null) continue;
|
|
707
|
+
if (!Array.isArray(arr)) throw _ctErr("ct/bad-log-list", "a CT log-list operator's logs / tiled_logs must be an array");
|
|
708
|
+
for (var j = 0; j < arr.length; j++) {
|
|
709
|
+
var rec = _parseLog(arr[j], op.name);
|
|
710
|
+
rec.trusted = rec.state.trusted; // usable/qualified/readonly; retired stays false (conditional at verify)
|
|
711
|
+
var prev = byLogId[rec.logIdHex];
|
|
712
|
+
if (prev) {
|
|
713
|
+
if (!_logsAgree(prev, rec)) throw _ctErr("ct/duplicate-log", "two CT log entries share log-id " + rec.logIdHex + " but disagree");
|
|
714
|
+
continue; // a byte-identical duplicate collapses to the one already recorded
|
|
715
|
+
}
|
|
716
|
+
byLogId[rec.logIdHex] = rec;
|
|
717
|
+
logs.push(rec);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
return { logs: logs, byLogId: byLogId };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// The covered certificate's notAfter for the temporal gate: an explicit opts.certNotAfter, else derived
|
|
725
|
+
// from a leafCert (entryType 0) via the shipped x509 parser, else null (a precert TBS can't be parsed).
|
|
726
|
+
function _resolveNotAfter(entry, opts) {
|
|
727
|
+
if (opts.certNotAfter instanceof Date) return opts.certNotAfter;
|
|
728
|
+
if (entry && entry.entryType === 0 && entry.leafCert != null) {
|
|
729
|
+
// Inline require (circular-load): ct.js is loaded before schema-x509 finishes initializing in the
|
|
730
|
+
// index chain (schema-x509 -> schema-pkix is mid-init when ct.js is first required), so a top-level
|
|
731
|
+
// require would see a half-built pkix. Deferred to first use, when every module is initialized.
|
|
732
|
+
var x509 = require("./schema-x509.js"); // allow:inline-require -- circular load with schema-x509 -> schema-pkix (see note above)
|
|
733
|
+
try {
|
|
734
|
+
return x509.parse(_toBuffer(entry.leafCert, "entry.leafCert")).validity.notAfter;
|
|
735
|
+
} catch (_e) {
|
|
736
|
+
return null; // a malformed leafCert -> no resolvable notAfter -> fail-closed ct/temporal-interval at the caller
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* @primitive pki.ct.verifySctWithLogList
|
|
744
|
+
* @signature pki.ct.verifySctWithLogList(entry, sct, logList, opts?) -> Promise<boolean>
|
|
745
|
+
* @since 0.2.28
|
|
746
|
+
* @status experimental
|
|
747
|
+
* @spec RFC 6962
|
|
748
|
+
* @related pki.ct.parseLogList, pki.ct.verifySct
|
|
749
|
+
*
|
|
750
|
+
* Resolve the trusted CT log for an SCT and verify it in one step. `logList` is a `parseLogList` result;
|
|
751
|
+
* the log is resolved by `sct.logIdHex` (an unknown log is `ct/log-not-found`). The log's **state** gates
|
|
752
|
+
* trust (usable/qualified/readonly proceed; a retired log proceeds only for an SCT timestamped before its
|
|
753
|
+
* retirement instant; pending/rejected are `ct/log-untrusted`); its **temporal_interval** gates the
|
|
754
|
+
* covered certificate (the cert's `notAfter` -- from `entry.leafCert` when `entryType` is 0, or
|
|
755
|
+
* `opts.certNotAfter` -- must fall in `[start_inclusive, end_exclusive)`, and a windowed log with no
|
|
756
|
+
* resolvable notAfter is `ct/temporal-interval`, never silently skipped). Then the crypto is delegated to
|
|
757
|
+
* the shipped `verifySct(entry, sct, log.key)` (which independently re-checks `logId == SHA-256(key)`).
|
|
758
|
+
* Resolves `true` for a valid signature from a trusted, in-window log; `false` on a cryptographic
|
|
759
|
+
* mismatch (a verdict); throws a typed `CtError` on any structural / trust failure.
|
|
760
|
+
*
|
|
761
|
+
* @opts certNotAfter A `Date` -- the covered certificate's notAfter for the temporal-interval gate (required for a precert entry).
|
|
762
|
+
* @example
|
|
763
|
+
* var ok = await pki.ct.verifySctWithLogList(sctEntry, embeddedSct, logList);
|
|
764
|
+
*/
|
|
765
|
+
async function verifySctWithLogList(entry, sct, logList, opts) {
|
|
766
|
+
opts = opts || {};
|
|
767
|
+
if (logList == null || typeof logList !== "object" || logList.byLogId == null) throw _ctErr("ct/bad-input", "logList must be a pki.ct.parseLogList result");
|
|
768
|
+
if (sct == null || typeof sct !== "object" || typeof sct.logIdHex !== "string") throw _ctErr("ct/bad-input", "the SCT is missing its logIdHex");
|
|
769
|
+
var log = logList.byLogId[sct.logIdHex];
|
|
770
|
+
if (log == null) throw _ctErr("ct/log-not-found", "no trusted CT log matches the SCT's logId " + sct.logIdHex);
|
|
771
|
+
// State gate (fail-closed): trusted proceed; retired only before its retirement; else untrusted.
|
|
772
|
+
if (!log.state.trusted) {
|
|
773
|
+
if (!log.state.conditional) throw _ctErr("ct/log-untrusted", "the CT log state '" + log.state.name + "' is not trusted");
|
|
774
|
+
var ts = guard.range.uint64(sct.timestamp, _ctErr, "ct/bad-input", "sct.timestamp");
|
|
775
|
+
if (ts >= BigInt(log.state.since.getTime())) throw _ctErr("ct/log-untrusted", "the CT log is retired and the SCT is not timestamped before its retirement (" + log.state.since.toISOString() + ")");
|
|
776
|
+
}
|
|
777
|
+
// Temporal gate (fail-closed): the covered cert's notAfter must be in [start_inclusive, end_exclusive).
|
|
778
|
+
if (log.temporalInterval) {
|
|
779
|
+
var notAfter = _resolveNotAfter(entry, opts);
|
|
780
|
+
// An Invalid Date (getTime() === NaN) is still `instanceof Date`, and NaN < x / NaN >= x are both
|
|
781
|
+
// false, so it would silently BYPASS the window containment -- reject it fail-closed, exactly like
|
|
782
|
+
// the codec's readTime rejects a NaN instant (a caller may pass a lenient `new Date(badString)`).
|
|
783
|
+
if (!(notAfter instanceof Date) || isNaN(notAfter.getTime())) throw _ctErr("ct/temporal-interval", "the CT log has a temporal_interval but the covered certificate's notAfter is not available or not a valid date (pass a valid opts.certNotAfter)");
|
|
784
|
+
var t = notAfter.getTime();
|
|
785
|
+
if (t < log.temporalInterval.startInclusive.getTime() || t >= log.temporalInterval.endExclusive.getTime()) {
|
|
786
|
+
throw _ctErr("ct/temporal-interval", "the covered certificate's notAfter is outside the CT log's temporal_interval");
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return verifySct(entry, sct, log.key); // the shipped crypto verdict (re-checks the logId binding)
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* @primitive pki.ct.verifyLogListSignature
|
|
794
|
+
* @signature pki.ct.verifyLogListSignature(json, signature, publicKey) -> Promise<boolean>
|
|
795
|
+
* @since 0.2.29
|
|
796
|
+
* @status experimental
|
|
797
|
+
* @spec RFC 6962, RFC 8017
|
|
798
|
+
* @related pki.ct.parseLogList, pki.ct.verifySct
|
|
799
|
+
*
|
|
800
|
+
* Verify the detached signature published alongside the Certificate Transparency log list (the
|
|
801
|
+
* `log_list.sig` over `log_list.json`). `json` is the RAW log-list bytes (a Buffer, or the fetched text
|
|
802
|
+
* as a string -- verified byte-for-byte, never re-serialized), `signature` is the detached signature, and
|
|
803
|
+
* `publicKey` is the caller-PINNED signer SubjectPublicKeyInfo (DER; there is no baked-in key). The scheme
|
|
804
|
+
* is RSASSA-PKCS1-v1.5 with SHA-256 over an RSA key (the deployed scheme; an EC P-256 / ECDSA-SHA-256 arm
|
|
805
|
+
* is accepted for future-proofing). Resolves `true` for a valid signature, `false` on a cryptographic
|
|
806
|
+
* mismatch (a verdict). Fail-closed forgery defenses throw before any verify: an RSA public exponent below
|
|
807
|
+
* 3 or even (`ct/bad-input`), a sub-2048-bit RSA key or an unsupported key type / curve
|
|
808
|
+
* (`ct/unsupported-algorithm`), a non-conformant ECDSA DER Sig-Value (`ct/bad-signature`); a structural
|
|
809
|
+
* evaluation failure is `ct/verify-error`. Offline: the caller fetches and pins; the toolkit only verifies.
|
|
810
|
+
*
|
|
811
|
+
* @example
|
|
812
|
+
* var ok = await pki.ct.verifyLogListSignature(logListJsonBytes, logListSig, googleSignerSpki);
|
|
813
|
+
*/
|
|
814
|
+
async function verifyLogListSignature(json, signature, publicKey) {
|
|
815
|
+
var message = typeof json === "string" ? Buffer.from(json) : _toBuffer(json, "the CT log list JSON");
|
|
816
|
+
// Bound the signed message before the digest/verify (the same cap parseLogList enforces) so a hostile
|
|
817
|
+
// caller cannot force unbounded hashing work on an oversized input (CWE-400).
|
|
818
|
+
if (message.length > C.LIMITS.CT_LOG_LIST_MAX_BYTES) throw new CtError("ct/too-large", "the CT log list exceeds the " + C.LIMITS.CT_LOG_LIST_MAX_BYTES + "-byte cap");
|
|
819
|
+
var sig = _toBuffer(signature, "the CT log list signature");
|
|
820
|
+
var spki = _toBuffer(publicKey, "the CT log list signer public key (SPKI)");
|
|
821
|
+
var alg = _spkiAlg(spki); // fail-closed: a non-SPKI key or a forgeable RSA e < 3 throws ct/bad-input
|
|
822
|
+
var imp, ver;
|
|
823
|
+
if (alg.algOid === oid.byName("rsaEncryption")) {
|
|
824
|
+
if (!(alg.rsaBits >= 2048)) throw new CtError("ct/unsupported-algorithm", "the CT log-list signer RSA key is below the 2048-bit minimum");
|
|
825
|
+
imp = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" };
|
|
826
|
+
ver = { name: "RSASSA-PKCS1-v1_5" };
|
|
827
|
+
} else if (alg.algOid === oid.byName("ecPublicKey")) {
|
|
828
|
+
var ec = CT_EC_CURVE[alg.curveOid];
|
|
829
|
+
if (!ec) throw new CtError("ct/unsupported-algorithm", "unsupported CT log-list signer EC curve (only NIST P-256)");
|
|
830
|
+
imp = { name: "ECDSA", namedCurve: ec.curve };
|
|
831
|
+
ver = { name: "ECDSA", hash: "SHA-256" };
|
|
832
|
+
// A DER ECDSA-Sig-Value: route through the ORDER-AWARE conformance gate (primitive, minimal, and
|
|
833
|
+
// r,s in [1, n-1] per FIPS 186-5 sec. 6.4.2 -- defeating the CVE-2022-21449 r=s=0 forgery AND an
|
|
834
|
+
// out-of-range r/s >= the curve order n) before the raw r||s conversion.
|
|
835
|
+
sig = validator.sig.ecdsaDerToP1363(sig, ec.curve, CtError, "ct/bad-signature");
|
|
836
|
+
} else {
|
|
837
|
+
throw new CtError("ct/unsupported-algorithm", "unsupported CT log-list signer key algorithm (only rsaEncryption / ecPublicKey P-256)");
|
|
838
|
+
}
|
|
839
|
+
// A wrong signature resolves false from subtle.verify (a verdict); a structural failure -- an
|
|
840
|
+
// unimportable key -- is re-thrown fail-closed.
|
|
841
|
+
try {
|
|
842
|
+
var key = await subtle.importKey("spki", spki, imp, false, ["verify"]);
|
|
843
|
+
return await subtle.verify(ver, key, sig, message);
|
|
844
|
+
} catch (e) {
|
|
845
|
+
throw new CtError("ct/verify-error", "the CT log-list signature could not be evaluated", e);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
595
849
|
module.exports = {
|
|
596
850
|
parseSctList: parseSctList,
|
|
597
851
|
reconstructSignedData: reconstructSignedData,
|
|
598
852
|
verifySct: verifySct,
|
|
599
853
|
encodeSctList: encodeSctList,
|
|
600
854
|
signSct: signSct,
|
|
855
|
+
parseLogList: parseLogList,
|
|
856
|
+
verifySctWithLogList: verifySctWithLogList,
|
|
857
|
+
verifyLogListSignature: verifyLogListSignature,
|
|
601
858
|
HASH_ALGORITHMS: HASH_ALGORITHMS,
|
|
602
859
|
SIGNATURE_ALGORITHMS: SIGNATURE_ALGORITHMS,
|
|
603
860
|
};
|
package/lib/lint.js
CHANGED
|
@@ -156,6 +156,7 @@ function _makeCtx(cert, profile) {
|
|
|
156
156
|
function _effective(rule, cert) {
|
|
157
157
|
if (!rule.effectiveDate) return true;
|
|
158
158
|
var nb = cert.validity && cert.validity.notBefore;
|
|
159
|
+
// allow:nan-date-comparison-unguarded -- nb is a codec-parsed cert notBefore (asn1 readTime rejects a NaN instant); effectiveDate is a Date literal.
|
|
159
160
|
return (nb instanceof Date) && nb.getTime() >= rule.effectiveDate.getTime();
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -296,6 +297,7 @@ var RFC5280_RULES = [
|
|
|
296
297
|
message: "the certificate notBefore must not be later than notAfter",
|
|
297
298
|
check: function (cert) {
|
|
298
299
|
var v = cert.validity;
|
|
300
|
+
// allow:nan-date-comparison-unguarded -- notBefore/notAfter are codec-parsed cert dates (asn1 readTime rejects a NaN instant).
|
|
299
301
|
return (v.notBefore instanceof Date && v.notAfter instanceof Date && v.notBefore.getTime() > v.notAfter.getTime()) ? true : null;
|
|
300
302
|
},
|
|
301
303
|
},
|
|
@@ -375,6 +377,7 @@ var VALIDITY_SCHEDULE = [
|
|
|
375
377
|
var VALIDITY_SCHEDULE_START = VALIDITY_SCHEDULE[VALIDITY_SCHEDULE.length - 1].from;
|
|
376
378
|
function _validityCeilingDays(notBefore) {
|
|
377
379
|
for (var i = 0; i < VALIDITY_SCHEDULE.length; i++) {
|
|
380
|
+
// allow:nan-date-comparison-unguarded -- notBefore is a codec-parsed cert date (NaN-rejected); the schedule bounds are Date literals.
|
|
378
381
|
if (notBefore.getTime() >= VALIDITY_SCHEDULE[i].from.getTime()) return VALIDITY_SCHEDULE[i].maxDays;
|
|
379
382
|
}
|
|
380
383
|
// Unreachable: the rule's effectiveDate (VALIDITY_SCHEDULE_START) reports NE for any cert
|
package/lib/ocsp-verify.js
CHANGED
|
@@ -206,6 +206,7 @@ function makeOcspVerify(deps) {
|
|
|
206
206
|
if (st.type === "revoked") {
|
|
207
207
|
// Present-time validation revokes regardless of a future revocationTime (skew/post-dating);
|
|
208
208
|
// only explicit historical validation defers a strictly-future revocation (reports good).
|
|
209
|
+
// allow:nan-date-comparison-unguarded -- revocationTime is codec-parsed (NaN-rejected); a NaN check time makes this FAIL CLOSED (sawGood stays unset -> not treated good), and `time` is validated at the pki.ocsp.verify / path.verifyOcspResponse entry points.
|
|
209
210
|
if (historical && st.revocationTime instanceof Date && st.revocationTime.getTime() > time.getTime()) { out.sawGood = true; }
|
|
210
211
|
else if (!out.revoked) { out.revoked = { revocationReason: st.revocationReason || null, reason: "certificate reported revoked by an authorized OCSP responder" + (st.revocationReason ? " (" + st.revocationReason + ")" : "") }; }
|
|
211
212
|
} else if (st.type === "good") { out.sawGood = true; }
|
package/lib/path-validate.js
CHANGED
|
@@ -1766,6 +1766,7 @@ function crlChecker(crls) {
|
|
|
1766
1766
|
// validation (opts.historicalMode) -- validating as of a past instant,
|
|
1767
1767
|
// e.g. a timestamped signature -- does an entry dated AFTER the
|
|
1768
1768
|
// validation time not yet apply.
|
|
1769
|
+
// allow:nan-date-comparison-unguarded -- revocationDate is codec-parsed (NaN-rejected); a NaN check time makes this FAIL CLOSED (the skip is not taken -> the entry is treated as revoked), and `time` is validated at the path.validate / crlChecker entry points.
|
|
1769
1770
|
if (historical && entry.revocationDate instanceof Date && entry.revocationDate.getTime() > time.getTime()) continue;
|
|
1770
1771
|
// Record the revocation but keep scanning: a delta removeFromCRL for the
|
|
1771
1772
|
// same serial (in another CRL) overrides it (base/delta not merged).
|
package/lib/rfc3339.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (c) blamejs contributors
|
|
3
|
+
"use strict";
|
|
4
|
+
/**
|
|
5
|
+
* @internal
|
|
6
|
+
* lib/rfc3339.js -- RFC 3339 date-time parsing + validation, a fail-closed engine primitive shared by
|
|
7
|
+
* pki.acme (a boolean field-validator over server-supplied JSON) and pki.ct (parse a CT log-list
|
|
8
|
+
* timestamp / temporal-interval bound to a comparable Date). Beyond the grammar it enforces CALENDAR
|
|
9
|
+
* validity -- a syntactically well-formed but impossible instant (month 13, February 30, hour 25, a
|
|
10
|
+
* :60 leap second, a +25:00 offset) is rejected, so a downstream expiry / window comparison never runs
|
|
11
|
+
* on a value JS `Date` would silently roll over or `Date.parse` would NaN. Like the byte-reader / guard
|
|
12
|
+
* family, `parse` takes the caller's `(code, message) -> error` factory so every consumer keeps its own
|
|
13
|
+
* domain fault code.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// date "T" time with a zone (Z or a numeric offset); an optional fractional second.
|
|
17
|
+
var RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:[Zz]|([+-])(\d{2}):(\d{2}))$/;
|
|
18
|
+
|
|
19
|
+
// isValid(v) -> boolean: v is a syntactically well-formed AND calendar-valid RFC 3339 date-time string.
|
|
20
|
+
function isValid(v) {
|
|
21
|
+
if (typeof v !== "string") return false;
|
|
22
|
+
var m = RFC3339_RE.exec(v);
|
|
23
|
+
if (!m) return false;
|
|
24
|
+
var year = +m[1], month = +m[2], day = +m[3], hour = +m[4], min = +m[5], sec = +m[6];
|
|
25
|
+
if (month < 1 || month > 12) return false;
|
|
26
|
+
// Reject a :60 leap second (Node's Date.parse returns NaN for it, so a comparison on such a value
|
|
27
|
+
// would silently pass) and any hour/minute/second out of range.
|
|
28
|
+
if (hour > 23 || min > 59 || sec > 59) return false;
|
|
29
|
+
var leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
30
|
+
var daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
31
|
+
if (day < 1 || day > daysInMonth[month - 1]) return false;
|
|
32
|
+
if (m[7]) { if (+m[8] > 23 || +m[9] > 59) return false; } // a numeric zone offset must itself be valid
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// parse(v, E, code, label) -> Date: a calendar-valid RFC 3339 date-time as a comparable Date; otherwise
|
|
37
|
+
// throws E(code, message) (the caller's (code, message) factory). isValid guarantees `new Date(v)` is a
|
|
38
|
+
// real instant (never NaN), so a returned Date is always safe to compare.
|
|
39
|
+
function parse(v, E, code, label) {
|
|
40
|
+
if (!isValid(v)) throw E(code, (label || "the value") + " is not a valid RFC 3339 date-time");
|
|
41
|
+
return new Date(v);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { isValid: isValid, parse: parse };
|
package/lib/validator-sig.js
CHANGED
|
@@ -98,8 +98,12 @@ function ecdsaDerToP1363(der, curve, E, code) {
|
|
|
98
98
|
if (n.tagClass !== "universal" || n.tagNumber !== asn1.TAGS.SEQUENCE || !n.children || n.children.length !== 2) {
|
|
99
99
|
throw new E(code, "ECDSA signature must be a SEQUENCE of exactly two INTEGERs");
|
|
100
100
|
}
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
// Wrap the strict DER integer reads (they enforce PRIMITIVE + MINIMAL encoding) so a non-minimal /
|
|
102
|
+
// constructed / empty INTEGER surfaces the CALLER's typed code, not a raw asn1/* -- the same behavior
|
|
103
|
+
// as ecdsaSigToRaw, so this gate is a strict superset (DER conformance + the order bound below).
|
|
104
|
+
var r, s;
|
|
105
|
+
try { r = asn1.read.integer(n.children[0]); } catch (e) { throw new E(code, "ECDSA signature r is not a minimally-encoded DER INTEGER", e); }
|
|
106
|
+
try { s = asn1.read.integer(n.children[1]); } catch (e) { throw new E(code, "ECDSA signature s is not a minimally-encoded DER INTEGER", e); }
|
|
103
107
|
if (r < 1n || s < 1n || r >= order || s >= order) {
|
|
104
108
|
throw new E(code, "ECDSA signature component out of range [1, n-1] (CVE-2022-21449)");
|
|
105
109
|
}
|
package/lib/webauthn.js
CHANGED
|
@@ -169,9 +169,9 @@ var COSE_ALG = {
|
|
|
169
169
|
};
|
|
170
170
|
|
|
171
171
|
// DER ECDSA-Sig-Value { r, s } -> raw r||s (the ieee-p1363 form WebCrypto verify expects).
|
|
172
|
-
// validator-sig owns the complete strict-DER conformance
|
|
173
|
-
//
|
|
174
|
-
function _derEcdsaToRaw(der,
|
|
172
|
+
// validator-sig owns the complete strict-DER conformance PLUS the FIPS 186-5 sec. 6.4.2 order bound
|
|
173
|
+
// (r,s in [1, n-1] -- the order-aware gate a curve-aware verifier MUST use), composed here.
|
|
174
|
+
function _derEcdsaToRaw(der, curve) { return validator.sig.ecdsaDerToP1363(der, curve, WebauthnError, "webauthn/bad-signature"); }
|
|
175
175
|
|
|
176
176
|
// Verify `sig` over `message` with the SPKI public key `spkiBytes` under COSE `alg`.
|
|
177
177
|
// A wrong signature resolves `false` from subtle.verify without throwing (a false
|
|
@@ -189,7 +189,7 @@ function _verifySig(alg, sig, spkiBytes, message, E) {
|
|
|
189
189
|
// point before verify. This covers EVERY key that signs a WebAuthn statement: the x5c
|
|
190
190
|
// attestation-certificate key (packed/tpm/apple) AND the self-attestation credential key.
|
|
191
191
|
if (imp.name === "Ed25519" || imp.name === "Ed448") _requireValidEdPoint(spkiBytes, imp.name, E);
|
|
192
|
-
var s = d.ecdsa ? _derEcdsaToRaw(sig, d.
|
|
192
|
+
var s = d.ecdsa ? _derEcdsaToRaw(sig, d.imp.namedCurve) : sig;
|
|
193
193
|
return subtle.importKey("spki", spkiBytes, imp, false, ["verify"])
|
|
194
194
|
.then(function (key) { return subtle.verify(ver, key, s, message); })
|
|
195
195
|
.catch(function (e) { throw _err("webauthn/verify-error", "the attestation signature could not be evaluated", e); });
|
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:2699d42b-0d6d-4b83-a3b9-1e8816b4e49a",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-07-
|
|
8
|
+
"timestamp": "2026-07-16T15:08:05.156Z",
|
|
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.29",
|
|
23
23
|
"type": "application",
|
|
24
24
|
"name": "pki",
|
|
25
|
-
"version": "0.2.
|
|
25
|
+
"version": "0.2.29",
|
|
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.29",
|
|
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.29",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|