@blamejs/core 0.18.11 → 0.18.12

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.18.x
10
10
 
11
+ - v0.18.12 (2026-08-05) — **Certificate Transparency SCT verification now works for real certificates (it previously rejected every embedded SCT); b.network.tls.ct.verifyScts / requireScts now require the issuing CA; broader Redis-client and TLS-verification test coverage.** b.network.tls.ct.verifyScts reconstructed the wrong RFC 6962 signed input for the SCTs embedded in a certificate — it built an x509_entry over the bare TBSCertificate instead of the precert_entry (issuer_key_hash + TBSCertificate) the log actually signed, and separately read the SCT's signature algorithm from a field the parser does not emit. Either alone made verification impossible: verifyScts and requireScts rejected every real embedded SCT. Verification now builds the RFC 6962 §3.2 precert_entry and requires the issuing CA so it can bind issuer_key_hash. This is a fail-closed fix — nothing invalid was ever trusted — but SCT enforcement now needs the issuer supplied. **Changed:** *b.network.tls.ct.verifyScts and requireScts now require the issuing CA* — Because an embedded SCT binds the issuing CA's key, verifying one needs that key. Pass the issuer to verifyScts as issuerCertDer (the issuer certificate DER), issuerSpkiDer (its SubjectPublicKeyInfo DER), or issuerKeyHash (the precomputed 32-byte SHA-256). Without it, verifyScts returns { ok: false, reason: "issuer-key-required" } and does not verify. requireScts derives the issuer from the peer's certificate chain automatically and accepts the same options; supply one explicitly only when the issuer is not present in the chain. verifyInclusion takes the same issuer options for embedded (precert_entry) leaves. · *Vendored @blamejs/pki refreshed to 0.3.32* — The vendored @blamejs/pki bundle behind the mutual-TLS CA engine is refreshed to 0.3.32, which adds C509 (CBOR-encoded certificate) extended-key-usage and extension registries (RFC 7633 TLS Feature, RFC 4556 PKINIT, and SSH/CMC/Bundle-Security purposes). No blamejs public API changes. · *Error- and edge-path tests for the Redis client and TLS-verification primitives* — b.redisClient (RESP framing and protocol-error edges, split replies, AUTH / SELECT on connect, push-message demux, connect/command timeouts, socket-error and close teardown) and b.network.tls (OCSP and Certificate-Transparency parse and verification branches, ECH config framing, and PKIX host/SAN matching) now have explicit tests for their boundary branches, driven through local fake servers and crafted certificates. **Fixed:** *Certificate Transparency SCT verification now works for certificate-embedded SCTs* — For an SCT embedded in a certificate's X.509v3 extension, the log computed its signature over the RFC 6962 §3.2 precert_entry — issuer_key_hash (SHA-256 of the issuing CA's SubjectPublicKeyInfo) followed by the TBSCertificate with the SCT extension removed. b.network.tls.ct.verifyScts instead reconstructed an x509_entry over just the stripped TBSCertificate, with no issuer binding, so the reconstructed input never matched what the log signed and no real embedded SCT could verify. It additionally read the SCT's signature-algorithm from a field name the parser does not emit, so the algorithm cross-check failed before the signature was even reached. verifyScts now builds the precert_entry and reads the parsed algorithm field, so a valid SCT verifies and a genuine mismatch is still refused. b.network.tls.ct.verifyInclusion builds the RFC 9162 §4.6 leaf the same way. The prior behaviour failed closed (it rejected rather than accepted), so no invalid certificate was ever trusted. **Security:** *SCT verification now binds each log key to the SCT's log ID* — An SCT's log ID is the SHA-256 of the log's public key (RFC 6962 §3.2). b.network.tls.ct.verifyScts now recomputes that hash from the configured key and refuses the SCT (reason "log-id-key-mismatch") when it does not equal the log ID the SCT names, before checking the signature. This prevents a misconfigured logKeys map from letting a key impersonate an approved log. · *SCT verification counts distinct logs, refuses future timestamps, and validates the minimum* — verifyScts now counts SCTs from distinct logs, so duplicate SCTs from a single log can no longer satisfy a "minimum 2 SCTs" diversity policy (verifiedCount reflects distinct logs). It refuses an SCT whose timestamp is in the future (RFC 6962 §5.2; opts.now / opts.clockSkewMs override the clock and the default five-minute skew). A minScts that is not a positive integer is rejected at the entry point rather than silently accepting an unverified certificate. Parsing an SCT extension from a peer certificate now fails closed on malformed DER — it returns no SCT instead of throwing, so a hostile certificate cannot raise an uncaught error out of verifyScts, parseScts, or the requireScts predicate.
12
+
11
13
  - v0.18.11 (2026-08-03) — **A static file that is a symlink at the final path component now fails closed with a clean 404 instead of an uncaught error; broader DNS, field-crypto, and static-server coverage.** The static server refuses a symlink at the final path component (its O_NOFOLLOW TOCTOU defense) — but that refusal now falls back to a clean 404 / NOT_FOUND rather than propagating an uncaught error that hung the request or surfaced a raw ELOOP from staticServe.integrity(). Alongside the fix, error and edge branches of b.network.dns, b.cryptoField, and b.staticServe come under explicit test. **Changed:** *Error- and edge-path tests for the DNS, field-crypto, and static-server primitives* — b.network.dns (DoH oversized-body handling, DoT mid-query connection reset and socket reuse, cache expiry, system-resolver error paths), b.cryptoField (tampered / relocated / downgraded cell fail-closed nulling, key-row AAD and rowId resolution, unseal rate-cap configuration, on-read upgrade branches), and b.staticServe (integrity and confinement edge cases, idle-timeout teardown, the final-component symlink refusal above) now have explicit tests for their boundary branches. An unused internal date-formatting helper was removed. **Fixed:** *A final-component symlink (or a mid-hash read error) on a static file fails closed with a clean 404* — When computing a served file's SRI and ETag, the static server opens it with O_NOFOLLOW so a symlink swapped in at the final path component after the in-root check cannot be followed (a TOCTOU / CWE-22 / CWE-367 defense). On POSIX that open raises ELOOP for a symlinked target — the common atomic-deploy pattern where a stable name links to a hashed file (latest.js pointing at app.<hash>.js). That error, and any read error mid-hash, previously propagated uncaught: the middleware request hung with no response, and staticServe.integrity() threw a raw ELOOP instead of its documented NOT_FOUND. It now falls back exactly as a read error does — the middleware returns 404 (falls through) and integrity() throws NOT_FOUND. The symlink is still not served; the refusal is just clean. Windows is unaffected (O_NOFOLLOW is a no-op there).
12
14
 
13
15
  - v0.18.10 (2026-08-03) — **Encrypted-at-rest boot fails closed on a truncated db.enc instead of silently starting fresh over it; broader SAML and database test coverage.** A database running encrypted at rest whose db.enc snapshot is truncated below a valid envelope (an interrupted external copy or backup restore, a full tmpfs, filesystem corruption) no longer boots onto a fresh empty database that the next flush would persist over the corrupt copy — it now fails closed so the operator can restore from backup. Alongside the fix, adversarial and edge branches of b.auth.saml and b.db come under explicit test, and the vendored @blamejs/pki toolkit is refreshed. **Changed:** *Verification- and edge-path tests for the SAML SP and database primitives* — b.auth.saml (the XML-signature Reference handling with no URI or no Transforms, a foreign-namespace Conditions element failing closed to no-audience-restriction, RSA-OAEP SHA-384/SHA-512 key transport with AES-128-GCM assertion decryption, ed25519 sign/verify dispatch, and the single-logout build/parse paths) and b.db (encrypted-boot key and envelope fallbacks, the truncated-snapshot refusal above, flush-to-disk after close, the audit-chain rollback check against a non-empty log, and the boot-time NTP check thresholds) now have explicit tests for their boundary branches. · *Vendored @blamejs/pki refreshed to 0.3.29* — The vendored @blamejs/pki toolkit that backs the mutual-TLS CA engine (X.509 / CRL / PKCS#12 signing) is refreshed from 0.3.28 to 0.3.29. The upstream change is in that toolkit's ACME client and Retry-After parser, which the CA engine does not call, so there is no behaviour change to the certificate-authority surface — this is a supply-chain currency refresh. **Fixed:** *Encrypted boot fails closed on a truncated db.enc rather than starting fresh over it* — When a database is encrypted at rest and its durable db.enc snapshot exists but is too short to be a valid AEAD envelope — a truncation from an interrupted external copy or backup restore, a full tmpfs, or filesystem corruption — boot silently opened a fresh empty database. The next flush or close then persisted that empty database over the corrupt-but-present snapshot, masking the corruption and destroying the operator's chance to recover. Boot now fails closed with db/enc-truncated; restore db.enc from backup, or remove it to intentionally start empty. A valid snapshot is always written atomically and is never this short, so no healthy deployment is affected. **Detectors:** *Truncated db.enc must fail closed, not start fresh* — A source guard keeps the encrypted-boot path throwing on an under-length db.enc rather than returning to open a fresh database — so a truncated durable snapshot can never again be silently overwritten by the next flush.
package/NOTICE CHANGED
@@ -60,7 +60,7 @@ Used for: WebAuthn / passkey registration + authentication response
60
60
  server.cjs).
61
61
  --------------------------------------------------------------------------------
62
62
  Component: @blamejs/pki
63
- Version: 0.3.29
63
+ Version: 0.3.32
64
64
  Source: https://github.com/blamejs/pki
65
65
  License: Apache-2.0
66
66
  Copyright: Copyright (c) blamejs contributors
@@ -1710,36 +1710,40 @@ function _extractSctExtensionFromCert(certDer) {
1710
1710
  var tbsChildren;
1711
1711
  try { tbsChildren = asn1.readSequence(tbs.value); }
1712
1712
  catch (_e) { return { sctListRaw: null }; }
1713
- var extensionsNode = null;
1714
- for (var i = 0; i < tbsChildren.length; i += 1) {
1715
- var ch = tbsChildren[i];
1716
- if (ch.tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC && ch.tag === 3) { // X.509 [3] EXPLICIT extensions tag
1717
- extensionsNode = asn1.readNode(ch.value, 0);
1718
- break;
1713
+ // The extensions walk is fully guarded: a hostile peer controls this cert,
1714
+ // so any malformed node fails closed to "no SCT extension" (the caller then
1715
+ // refuses) rather than throwing out of verifyScts / parseScts / requireScts.
1716
+ try {
1717
+ var extensionsNode = null;
1718
+ for (var i = 0; i < tbsChildren.length; i += 1) {
1719
+ var ch = tbsChildren[i];
1720
+ if (ch.tagClass === asn1.TAG_CLASS.CONTEXT_SPECIFIC && ch.tag === 3) { // X.509 [3] EXPLICIT extensions tag
1721
+ extensionsNode = asn1.readNode(ch.value, 0);
1722
+ break;
1723
+ }
1719
1724
  }
1720
- }
1721
- if (!extensionsNode || extensionsNode.tag !== asn1.TAG.SEQUENCE) {
1725
+ if (!extensionsNode || extensionsNode.tag !== asn1.TAG.SEQUENCE) {
1726
+ return { sctListRaw: null };
1727
+ }
1728
+ var extensions = asn1.readSequence(extensionsNode.value);
1729
+ for (var e = 0; e < extensions.length; e += 1) {
1730
+ var ext = extensions[e]; // Extension ::= SEQUENCE { extnID OID, critical BOOL OPTIONAL, extnValue OCTET STRING }
1731
+ if (ext.tag !== asn1.TAG.SEQUENCE) continue;
1732
+ var extChildren = asn1.readSequence(ext.value);
1733
+ if (extChildren.length === 0) continue;
1734
+ var extOid = asn1.readOid(extChildren[0]);
1735
+ if (extOid !== OID_CT_SCT_LIST) continue;
1736
+ // The last child is the OCTET STRING extnValue. Per RFC 6962 §3.3
1737
+ // that OCTET STRING wraps a SECOND OCTET STRING which contains the
1738
+ // raw SignedCertificateTimestampList (TLS-encoded).
1739
+ var extnValueOuter = asn1.readOctetString(extChildren[extChildren.length - 1]);
1740
+ var inner = asn1.readNode(extnValueOuter);
1741
+ if (inner.tag !== asn1.TAG.OCTET_STRING) return { sctListRaw: null }; // malformed SCT ext → fail closed
1742
+ return { sctListRaw: inner.value };
1743
+ }
1744
+ } catch (_e) {
1722
1745
  return { sctListRaw: null };
1723
1746
  }
1724
- var extensions = asn1.readSequence(extensionsNode.value);
1725
- for (var e = 0; e < extensions.length; e += 1) {
1726
- var ext = extensions[e]; // Extension ::= SEQUENCE { extnID OID, critical BOOL OPTIONAL, extnValue OCTET STRING }
1727
- if (ext.tag !== asn1.TAG.SEQUENCE) continue;
1728
- var extChildren = asn1.readSequence(ext.value);
1729
- if (extChildren.length === 0) continue;
1730
- var extOid = asn1.readOid(extChildren[0]);
1731
- if (extOid !== OID_CT_SCT_LIST) continue;
1732
- // The last child is the OCTET STRING extnValue. Per RFC 6962 §3.3
1733
- // that OCTET STRING wraps a SECOND OCTET STRING which contains the
1734
- // raw SignedCertificateTimestampList (TLS-encoded).
1735
- var extnValueOuter = asn1.readOctetString(extChildren[extChildren.length - 1]);
1736
- var inner = asn1.readNode(extnValueOuter);
1737
- if (inner.tag !== asn1.TAG.OCTET_STRING) {
1738
- throw new TlsTrustError("tls/ct-bad-extension",
1739
- "SCT extension extnValue does not wrap a second OCTET STRING");
1740
- }
1741
- return { sctListRaw: inner.value };
1742
- }
1743
1747
  return { sctListRaw: null };
1744
1748
  }
1745
1749
 
@@ -1887,27 +1891,67 @@ function _parseSct(sctBuf) {
1887
1891
  };
1888
1892
  }
1889
1893
 
1890
- // Build the canonical signed-entry per RFC 6962 §3.2 for X.509
1891
- // pre-cert-free chains (issued cert path):
1894
+ // Serialize the RFC 6962 §3.2 / RFC 9162 §4.6 entry_type + signed_entry
1895
+ // pair. An SCT embedded in the leaf's X.509v3 extension was signed by the
1896
+ // log over the PRECERTIFICATE, so it is a precert_entry — signed_entry is
1897
+ // PreCert { opaque issuer_key_hash[32]; TBSCertificate tbs_certificate; }
1898
+ // = issuer_key_hash (32 raw bytes) || tbs_certificate (24-bit length ||
1899
+ // DER TBSCertificate with the SCT extension removed). A directly-logged
1900
+ // final certificate is an x509_entry: ASN.1Cert (24-bit length || DER).
1901
+ // A 32-byte issuerKeyHash selects precert_entry; its absence selects
1902
+ // x509_entry. issuer_key_hash is SHA-256 over the DER SubjectPublicKeyInfo
1903
+ // of the issuing CA's public key (RFC 6962 §3.2).
1904
+ function _ctSignedEntry(tbsOrCertDer, issuerKeyHash) {
1905
+ var lenBytes = Buffer.alloc(3); // RFC 6962 24-bit length prefix
1906
+ lenBytes.writeUIntBE(tbsOrCertDer.length, 0, 3);
1907
+ if (Buffer.isBuffer(issuerKeyHash)) {
1908
+ if (issuerKeyHash.length !== 32) { // RFC 6962 opaque issuer_key_hash[32]
1909
+ throw new TlsTrustError("tls/ct-bad-issuer-key-hash",
1910
+ "issuer_key_hash must be 32 bytes (SHA-256 of issuer SubjectPublicKeyInfo), got " +
1911
+ issuerKeyHash.length);
1912
+ }
1913
+ return { entryType: 1, // precert_entry
1914
+ signedEntry: Buffer.concat([issuerKeyHash, lenBytes, tbsOrCertDer]) };
1915
+ }
1916
+ return { entryType: 0, // x509_entry
1917
+ signedEntry: Buffer.concat([lenBytes, tbsOrCertDer]) };
1918
+ }
1919
+
1920
+ // Resolve the RFC 6962 issuer_key_hash from operator-supplied opts, in
1921
+ // precedence order: issuerKeyHash (precomputed 32-byte Buffer) |
1922
+ // issuerSpkiDer (DER SubjectPublicKeyInfo, hashed) | issuerCertDer (issuing
1923
+ // CA cert; its SPKI is extracted and hashed). Returns a 32-byte Buffer, or
1924
+ // null when no issuer material was supplied.
1925
+ function _resolveIssuerKeyHash(opts) {
1926
+ if (Buffer.isBuffer(opts.issuerKeyHash)) return opts.issuerKeyHash;
1927
+ if (Buffer.isBuffer(opts.issuerSpkiDer)) {
1928
+ return nodeCrypto.createHash("sha256").update(opts.issuerSpkiDer).digest(); // RFC 6962 SHA-256 of SPKI
1929
+ }
1930
+ if (Buffer.isBuffer(opts.issuerCertDer)) {
1931
+ var spkiDer = new nodeCrypto.X509Certificate(opts.issuerCertDer)
1932
+ .publicKey.export({ type: "spki", format: "der" });
1933
+ return nodeCrypto.createHash("sha256").update(spkiDer).digest();
1934
+ }
1935
+ return null;
1936
+ }
1937
+
1938
+ // Build the canonical signed-entry per RFC 6962 §3.2. For an embedded SCT
1939
+ // (issuerKeyHash present) this is a precert_entry:
1892
1940
  // sct_version (1) || signature_type (1=certificate_timestamp) ||
1893
- // timestamp (8) || entry_type (0=x509_entry) ||
1894
- // signed_entry (3-byte length || ASN.1 cert without SCT extension) ||
1895
- // ct_extensions (2-byte length || N)
1896
- function _buildSctSignedEntry(certWithoutSctDer, sct) {
1941
+ // timestamp (8) || entry_type (1=precert_entry) ||
1942
+ // issuer_key_hash (32) || tbs_certificate (3-byte length || TBS without
1943
+ // the SCT extension) || ct_extensions (2-byte length || N)
1944
+ function _buildSctSignedEntry(certWithoutSctDer, sct, issuerKeyHash) {
1945
+ var entry = _ctSignedEntry(certWithoutSctDer, issuerKeyHash);
1897
1946
  var head = Buffer.alloc(1 + 1 + 8 + 2); // fixed-shape header bytes
1898
1947
  head[0] = sct.version;
1899
1948
  head[1] = 0; // signature_type = certificate_timestamp
1900
1949
  head.writeBigUInt64BE(BigInt(sct.timestamp), 2); // past version+sig-type
1901
- head.writeUInt16BE(0, 10); // entry_type = x509_entry (2 bytes; high byte = 0, low byte = 0)
1902
- // signed_entry: 3-byte length prefix + cert DER.
1903
- var lenBytes = Buffer.alloc(3); // RFC 6962 24-bit length prefix
1904
- lenBytes[0] = (certWithoutSctDer.length >> 16) & 0xff; // base-256 length high byte
1905
- lenBytes[1] = (certWithoutSctDer.length >> 8) & 0xff; // base-256 length mid byte
1906
- lenBytes[2] = certWithoutSctDer.length & 0xff; // base-256 length low byte
1950
+ head.writeUInt16BE(entry.entryType, 10); // entry_type (precert_entry=1 / x509_entry=0)
1907
1951
  // ct_extensions: 2-byte length + bytes.
1908
1952
  var extHead = Buffer.alloc(2); // RFC 6962 2-byte ct_extensions length prefix
1909
1953
  extHead.writeUInt16BE(sct.extensions.length, 0);
1910
- return Buffer.concat([head, lenBytes, certWithoutSctDer, extHead, sct.extensions]);
1954
+ return Buffer.concat([head, entry.signedEntry, extHead, sct.extensions]);
1911
1955
  }
1912
1956
 
1913
1957
  // Strip the SCT extension from a DER cert + return the rebuilt cert
@@ -2029,6 +2073,15 @@ function _encodeAsn1FromNode(node) {
2029
2073
  // CT log list (https://www.gstatic.com/ct/log_list/v3/log_list.json
2030
2074
  // or equivalent) — log keys rotate, so the framework does NOT bake
2031
2075
  // them in; that drift is the operator's to manage.
2076
+ //
2077
+ // An SCT embedded in the certificate was signed by the log over the
2078
+ // PRECERTIFICATE (RFC 6962 §3.2 precert_entry), whose signed-entry binds
2079
+ // the issuing CA via issuer_key_hash = SHA-256(issuer SubjectPublicKeyInfo).
2080
+ // The issuing CA is therefore REQUIRED to verify: supply it as
2081
+ // opts.issuerCertDer (issuer cert DER), opts.issuerSpkiDer (DER
2082
+ // SubjectPublicKeyInfo), or opts.issuerKeyHash (precomputed 32-byte hash).
2083
+ // Without it verification cannot proceed and returns reason
2084
+ // "issuer-key-required" (fails closed).
2032
2085
  function verifyScts(certDer, opts) {
2033
2086
  opts = opts || {};
2034
2087
  if (!Buffer.isBuffer(certDer)) {
@@ -2036,6 +2089,12 @@ function verifyScts(certDer, opts) {
2036
2089
  "verifyScts: certDer must be a Buffer");
2037
2090
  }
2038
2091
  var logKeys = opts.logKeys || {};
2092
+ if (opts.minScts !== undefined &&
2093
+ (typeof opts.minScts !== "number" || !isFinite(opts.minScts) ||
2094
+ opts.minScts < 1 || Math.floor(opts.minScts) !== opts.minScts)) {
2095
+ throw new TlsTrustError("tls/ct-bad-input",
2096
+ "verifyScts: minScts must be a positive integer (a policy of 0 would accept unverified certs)");
2097
+ }
2039
2098
  var minScts = typeof opts.minScts === "number" ? opts.minScts : 2; // Chrome CT policy min-2-SCTs
2040
2099
  var ext = _extractSctExtensionFromCert(certDer);
2041
2100
  if (!ext.sctListRaw) {
@@ -2047,6 +2106,19 @@ function verifyScts(certDer, opts) {
2047
2106
  return { ok: false, reason: "parse-error",
2048
2107
  error: (e && e.message) || String(e), scts: [] };
2049
2108
  }
2109
+ // Resolve the issuing-CA key hash — embedded SCTs are precert_entry and
2110
+ // cannot be verified without it (RFC 6962 §3.2). Fail closed when absent.
2111
+ var issuerKeyHash;
2112
+ try { issuerKeyHash = _resolveIssuerKeyHash(opts); }
2113
+ catch (e) {
2114
+ return { ok: false, reason: "bad-issuer-key",
2115
+ error: (e && e.message) || String(e), scts: scts,
2116
+ minScts: minScts, verifiedCount: 0, totalScts: scts.length };
2117
+ }
2118
+ if (!issuerKeyHash) {
2119
+ return { ok: false, reason: "issuer-key-required", scts: [],
2120
+ minScts: minScts, verifiedCount: 0, totalScts: scts.length };
2121
+ }
2050
2122
  // Strip the SCT extension to compute the signed-entry per §3.2.
2051
2123
  var stripped;
2052
2124
  try { stripped = _stripSctExtensionFromCert(certDer); }
@@ -2054,10 +2126,24 @@ function verifyScts(certDer, opts) {
2054
2126
  return { ok: false, reason: "strip-failed",
2055
2127
  error: (e && e.message) || String(e), scts: scts };
2056
2128
  }
2057
- var verifiedCount = 0;
2129
+ // Chrome CT policy counts SCTs from DISTINCT logs (diversity), so a single
2130
+ // log — in the trivial case the same SCT duplicated — cannot alone satisfy a
2131
+ // minScts >= 2 gate. Track the set of logs that produced a verified SCT.
2132
+ var verifiedLogIds = Object.create(null);
2133
+ var now = (typeof opts.now === "number" && isFinite(opts.now)) ? opts.now : Date.now();
2134
+ var futureSkewMs = (typeof opts.clockSkewMs === "number" && isFinite(opts.clockSkewMs) &&
2135
+ opts.clockSkewMs >= 0) ? opts.clockSkewMs : C.TIME.minutes(5);
2058
2136
  var perSctResults = [];
2059
2137
  for (var s = 0; s < scts.length; s += 1) {
2060
2138
  var sct = scts[s];
2139
+ // RFC 6962 §5.2 — a future SCT timestamp signals log misbehaviour
2140
+ // (out-of-order issuance or a log clock fault); refuse it, allowing a
2141
+ // small skew for honest clock drift.
2142
+ if (sct.timestamp > now + futureSkewMs) {
2143
+ perSctResults.push({ logIdHex: sct.logIdHex, verified: false,
2144
+ reason: "timestamp-in-future", timestamp: sct.timestamp });
2145
+ continue;
2146
+ }
2061
2147
  var pem = logKeys[sct.logIdHex];
2062
2148
  if (!pem) {
2063
2149
  perSctResults.push({ logIdHex: sct.logIdHex, verified: false,
@@ -2065,7 +2151,7 @@ function verifyScts(certDer, opts) {
2065
2151
  continue;
2066
2152
  }
2067
2153
  var signedEntry;
2068
- try { signedEntry = _buildSctSignedEntry(stripped, sct); }
2154
+ try { signedEntry = _buildSctSignedEntry(stripped, sct, issuerKeyHash); }
2069
2155
  catch (e) {
2070
2156
  perSctResults.push({ logIdHex: sct.logIdHex, verified: false,
2071
2157
  reason: "build-entry-failed",
@@ -2089,13 +2175,25 @@ function verifyScts(certDer, opts) {
2089
2175
  error: (e && e.message) || String(e) });
2090
2176
  continue;
2091
2177
  }
2178
+ // RFC 6962 §3.2 — the SCT's log_id is the SHA-256 of the log's public key
2179
+ // (DER SubjectPublicKeyInfo). Enforce that the configured key actually
2180
+ // hashes to the claimed log_id before trusting its signature, so a
2181
+ // misconfigured logKeys map cannot let a key impersonate an approved log.
2182
+ var configuredLogId = nodeCrypto.createHash("sha256")
2183
+ .update(keyObj.export({ type: "spki", format: "der" })).digest();
2184
+ if (!configuredLogId.equals(sct.logId)) {
2185
+ perSctResults.push({ logIdHex: sct.logIdHex, verified: false,
2186
+ reason: "log-id-key-mismatch",
2187
+ configuredKeyId: configuredLogId.toString("hex") });
2188
+ continue;
2189
+ }
2092
2190
  // RFC 6962 §2.1.4 — log-key SignatureAndHashAlgorithm pair must
2093
2191
  // match the SCT's signatureAlgorithm. signatureAlgo enum 1=RSA,
2094
2192
  // 3=ECDSA. Cross-check against the actual log-key type so a
2095
2193
  // malformed log-keys map can't silently accept SCTs signed
2096
2194
  // under one algorithm against a key registered under another.
2097
2195
  var keyType = keyObj.asymmetricKeyType;
2098
- var sctSigAlgo = sct.signatureAlgo;
2196
+ var sctSigAlgo = sct.sigAlgo; // the field _parseSct emits (RFC 5246 SignatureAlgorithm enum)
2099
2197
  var algoOk = (sctSigAlgo === 1 && keyType === "rsa") || // TLS 1.2 SignatureAlgorithm rsa
2100
2198
  (sctSigAlgo === 3 && (keyType === "ec" || keyType === "ecdsa")); // TLS 1.2 SignatureAlgorithm ecdsa
2101
2199
  if (!algoOk) {
@@ -2113,8 +2211,9 @@ function verifyScts(certDer, opts) {
2113
2211
  continue;
2114
2212
  }
2115
2213
  perSctResults.push({ logIdHex: sct.logIdHex, verified: verified });
2116
- if (verified) verifiedCount += 1;
2214
+ if (verified) verifiedLogIds[sct.logIdHex] = true;
2117
2215
  }
2216
+ var verifiedCount = Object.keys(verifiedLogIds).length; // distinct verified logs (Chrome CT diversity)
2118
2217
  return {
2119
2218
  ok: verifiedCount >= minScts,
2120
2219
  reason: verifiedCount >= minScts ? null : "insufficient-verified",
@@ -2340,7 +2439,15 @@ var ct = Object.freeze({
2340
2439
  },
2341
2440
  // verifyScts — full RFC 6962 verification. opts.logKeys maps
2342
2441
  // log_id (hex SHA-256 of the log's pubkey) → PEM public key.
2343
- // Operators populate from the Chrome CT log list. Returns
2442
+ // Operators populate from the Chrome CT log list. Embedded SCTs are
2443
+ // precert_entry, so the issuing CA is required — pass opts.issuerCertDer
2444
+ // (issuer cert DER), opts.issuerSpkiDer, or opts.issuerKeyHash (32-byte
2445
+ // SHA-256 of the issuer SubjectPublicKeyInfo); without it verification
2446
+ // fails closed with reason "issuer-key-required". verifiedCount counts
2447
+ // DISTINCT logs (Chrome CT diversity), so duplicate SCTs from one log do
2448
+ // not inflate it. opts.minScts (default 2) must be a positive integer.
2449
+ // Future-dated SCTs are refused (RFC 6962 §5.2; opts.now / opts.clockSkewMs
2450
+ // override the wall-clock and the default 5-minute skew). Returns
2344
2451
  // { ok, verifiedCount, totalScts, scts: [{ logIdHex, verified, ... }] }.
2345
2452
  verifyScts: verifyScts,
2346
2453
  // Operator middleware predicate: refuse a peer cert lacking SCT
@@ -2354,6 +2461,10 @@ var ct = Object.freeze({
2354
2461
  // opts: {
2355
2462
  // sct: { logIdHex, timestamp, signedEntryDer? } — from parseScts
2356
2463
  // leafCertificate: Buffer — leaf cert DER (the entry hashed at the leaf)
2464
+ // issuerCertDer / issuerSpkiDer / issuerKeyHash: the issuing CA — an
2465
+ // embedded SCT is a precert_entry, so the leaf binds
2466
+ // issuer_key_hash; omit only for a directly-logged
2467
+ // (x509_entry) certificate
2357
2468
  // leafIndex: integer — position in the tree (from RFC 9162 §6.7 get-proof-by-hash)
2358
2469
  // auditPath: [Buffer] — the inclusion-proof siblings, bottom-up
2359
2470
  // sthFromLog: { treeSize, rootHash[, sha256RootHash] }
@@ -2389,11 +2500,20 @@ var ct = Object.freeze({
2389
2500
  return { valid: false, reason: "bad-audit-path" };
2390
2501
  }
2391
2502
 
2392
- // Build the leaf bytes per RFC 9162 §4.6 — TimestampedEntry.
2393
- // entry_type = x509_entry (0); signed_entry = strip-SCT-extension(cert).
2394
- // Operators may pass a pre-built signedEntryDer when the SCT was
2503
+ // Build the leaf bytes per RFC 9162 §4.6 — TimestampedEntry. An SCT
2504
+ // embedded in the leaf's X.509v3 extension is a precert_entry: supply
2505
+ // the issuing CA (opts.issuerCertDer / issuerSpkiDer / issuerKeyHash)
2506
+ // and the leaf binds issuer_key_hash || tbs, matching what the log
2507
+ // hashed. Without an issuer the leaf is an x509_entry over the cert
2508
+ // bytes. Operators may pass a pre-built signedEntryDer when the SCT was
2395
2509
  // already extracted via parseScts() + the framework has the
2396
2510
  // pre-issuance cert; otherwise we strip the SCT extension here.
2511
+ var issuerKeyHash;
2512
+ try { issuerKeyHash = _resolveIssuerKeyHash(opts); }
2513
+ catch (e) {
2514
+ return { valid: false, reason: "bad-issuer-key",
2515
+ error: (e && e.message) || String(e) };
2516
+ }
2397
2517
  var signedEntryDer = opts.sct.signedEntryDer;
2398
2518
  if (!Buffer.isBuffer(signedEntryDer)) {
2399
2519
  try { signedEntryDer = _stripSctExtensionFromCert(opts.leafCertificate); }
@@ -2404,9 +2524,9 @@ var ct = Object.freeze({
2404
2524
  }
2405
2525
 
2406
2526
  // RFC 9162 §4.6 MerkleTreeLeaf — version (1) + leaf_type (0) +
2407
- // timestamp (uint64) + entry_type (uint16) + signed_entry (variable-
2408
- // length cert DER with 24-bit length prefix) + extensions (variable-
2409
- // length, 16-bit length prefix, empty for x509_entry).
2527
+ // timestamp (uint64) + entry_type (uint16) + signed_entry (issuer_key_
2528
+ // hash for precert_entry, then a 24-bit-length cert/TBS) + extensions
2529
+ // (16-bit length prefix, empty here).
2410
2530
  var ts = opts.sct.timestamp;
2411
2531
  if (typeof ts !== "number" && typeof ts !== "bigint") {
2412
2532
  return { valid: false, reason: "bad-sct-timestamp" };
@@ -2414,16 +2534,21 @@ var ct = Object.freeze({
2414
2534
  var tsBuf = Buffer.alloc(8); // TLS uint64 width
2415
2535
  var tsBig = typeof ts === "bigint" ? ts : BigInt(Math.floor(ts));
2416
2536
  tsBuf.writeBigUInt64BE(tsBig);
2417
- var entryTypeBuf = Buffer.from([0x00, 0x00]);
2418
- var lenBuf = Buffer.alloc(3); // TLS uint24 length prefix
2419
- lenBuf.writeUIntBE(signedEntryDer.length, 0, 3);
2537
+ var leafEntry;
2538
+ try { leafEntry = _ctSignedEntry(signedEntryDer, issuerKeyHash); }
2539
+ catch (e) {
2540
+ return { valid: false, reason: "bad-issuer-key",
2541
+ error: (e && e.message) || String(e) };
2542
+ }
2543
+ var entryTypeBuf = Buffer.from([(leafEntry.entryType >> 8) & 0xff, // entry_type uint16
2544
+ leafEntry.entryType & 0xff]);
2420
2545
  var extensionsBuf = Buffer.from([0x00, 0x00]); // empty extensions vector
2421
2546
  var leafBytes = Buffer.concat([
2422
2547
  Buffer.from([0x00]), // version v1
2423
2548
  Buffer.from([0x00]), // leaf_type timestamped_entry
2424
2549
  tsBuf,
2425
2550
  entryTypeBuf,
2426
- lenBuf, signedEntryDer,
2551
+ leafEntry.signedEntry,
2427
2552
  extensionsBuf,
2428
2553
  ]);
2429
2554
 
@@ -2533,7 +2658,22 @@ var ct = Object.freeze({
2533
2658
  return new TlsTrustError("tls/ct-no-cert",
2534
2659
  "requireScts: peer cert.raw missing");
2535
2660
  }
2536
- var rv = verifyScts(peerCert.raw, opts);
2661
+ // Embedded SCTs are precert_entry and need the issuing CA key
2662
+ // (RFC 6962 §3.2). Derive it from the peer's issuer in the chain when
2663
+ // the operator did not supply issuer material explicitly. Node sets
2664
+ // issuerCertificate to the cert itself at the self-signed root, so
2665
+ // guard against binding a cert to its own key.
2666
+ var effOpts = opts;
2667
+ if (!Buffer.isBuffer(opts.issuerKeyHash) &&
2668
+ !Buffer.isBuffer(opts.issuerSpkiDer) &&
2669
+ !Buffer.isBuffer(opts.issuerCertDer) &&
2670
+ peerCert.issuerCertificate &&
2671
+ peerCert.issuerCertificate !== peerCert &&
2672
+ Buffer.isBuffer(peerCert.issuerCertificate.raw)) {
2673
+ effOpts = Object.assign({}, opts,
2674
+ { issuerCertDer: peerCert.issuerCertificate.raw });
2675
+ }
2676
+ var rv = verifyScts(peerCert.raw, effOpts);
2537
2677
  if (!rv.ok) {
2538
2678
  // Map verifier reason → operator-facing error code so call
2539
2679
  // sites can distinguish "no SCT extension at all" from
@@ -3434,4 +3574,6 @@ module.exports = {
3434
3574
  NetworkTlsError: NetworkTlsError,
3435
3575
  _resetForTest: _resetForTest,
3436
3576
  _checkServerIdentityStrict: _checkServerIdentityStrict,
3577
+ _stripSctExtensionFromCert: _stripSctExtensionFromCert,
3578
+ _buildSctSignedEntry: _buildSctSignedEntry,
3437
3579
  };
@@ -18,7 +18,7 @@
18
18
  "hashes": {
19
19
  "server": "sha256:2b30a26f728c5349f4c4b47834f862a4f77393b1224fc12b22abe3ce2cfab78f"
20
20
  },
21
- "refreshedAt": "2026-08-03T19:33:52.595Z"
21
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
22
22
  },
23
23
  "@noble/curves": {
24
24
  "version": "2.2.0",
@@ -40,7 +40,7 @@
40
40
  "hashes": {
41
41
  "server": "sha256:2880c288b1285ef51d356d057bee6f0c8a00de36638cf47b47617e8c1faf10d5"
42
42
  },
43
- "refreshedAt": "2026-08-03T19:33:52.595Z"
43
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
44
44
  },
45
45
  "@noble/post-quantum": {
46
46
  "version": "0.6.1",
@@ -71,7 +71,7 @@
71
71
  "hashes": {
72
72
  "server": "sha256:f9c94094b3c10fe73dac5343289da582454ea6053494fab2bf66099d9103d6c3"
73
73
  },
74
- "refreshedAt": "2026-08-03T19:33:52.595Z"
74
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
75
75
  },
76
76
  "@simplewebauthn/server": {
77
77
  "version": "13.3.2",
@@ -94,7 +94,7 @@
94
94
  "hashes": {
95
95
  "server": "sha256:e83195dc9f189385da9c856ef38843f4466f93ea8f3d7fc2efcb1e1b18da6f20"
96
96
  },
97
- "refreshedAt": "2026-08-03T19:33:52.595Z"
97
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
98
98
  },
99
99
  "SecLists-common-passwords-top-10000": {
100
100
  "version": "10k-most-common (master)",
@@ -114,7 +114,7 @@
114
114
  },
115
115
  "runtime_artifact": "lib/vendor/common-passwords-top-10000.data.js",
116
116
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
117
- "refreshedAt": "2026-08-03T19:33:52.595Z"
117
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
118
118
  },
119
119
  "bimi-trust-anchors": {
120
120
  "version": "operator-managed",
@@ -139,7 +139,7 @@
139
139
  },
140
140
  "runtime_artifact": "lib/vendor/bimi-trust-anchors.data.js",
141
141
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
142
- "refreshedAt": "2026-08-03T19:33:52.595Z"
142
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
143
143
  },
144
144
  "publicsuffix-list": {
145
145
  "version": "master",
@@ -159,10 +159,10 @@
159
159
  },
160
160
  "runtime_artifact": "lib/vendor/public-suffix-list.data.js",
161
161
  "integrity_layers": "sha256 + sha3-512 + SLH-DSA-SHAKE-256f signature + in-payload canary (where applicable)",
162
- "refreshedAt": "2026-08-03T19:33:52.595Z"
162
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
163
163
  },
164
164
  "@blamejs/pki": {
165
- "version": "0.3.29",
165
+ "version": "0.3.32",
166
166
  "license": "Apache-2.0",
167
167
  "author": "blamejs",
168
168
  "source": "https://github.com/blamejs/pki",
@@ -182,12 +182,12 @@
182
182
  "server": "lib/vendor/blamejs-pki.cjs"
183
183
  },
184
184
  "bundler": "esbuild --format=cjs --platform=node --external:crypto --external:node:crypto",
185
- "bundledAt": "2026-08-03T00:00:00Z",
186
- "cpe": "cpe:2.3:a:blamejs:pki:0.3.29:*:*:*:*:node.js:*:*",
185
+ "bundledAt": "2026-08-05T00:00:00Z",
186
+ "cpe": "cpe:2.3:a:blamejs:pki:0.3.32:*:*:*:*:node.js:*:*",
187
187
  "hashes": {
188
- "server": "sha256:8eb19192bcc9e9660b5fe72376d27de33a1c2c3b44ba18b1b3aeb9f0d95ed2bf"
188
+ "server": "sha256:275a0ef678a74e9ce035658ae95b18c05dcf69200bf934f609f149d1c8e739e3"
189
189
  },
190
- "refreshedAt": "2026-08-03T19:33:52.595Z"
190
+ "refreshedAt": "2026-08-06T00:01:44.784Z"
191
191
  }
192
192
  }
193
193
  }