← back to Domain Sniper
ct-source-ctlog: parse PRECERT_ENTRY (TBSCertificate) for SAN extraction
2c5c65f5c80a391ab509a888ec7b93458a4a5bfc · 2026-05-12 16:25:38 -0700 · Steve Abrams
Adds minimal-DER ASN.1 walker (extractDnsNamesFromTBS) that descends into the
precert's TBSCertificate, finds the [3] EXPLICIT extensions tag, iterates
each Extension SEQUENCE looking for OID 2.5.29.17 (subjectAltName), and
pulls dNSName GeneralNames (tag 0x82) from the SAN body.
Smoke run on Argon (18s, BATCH=24, POLL_MS=3000):
before: 14 x509 emitted, 43 precerts skipped → 25% emission
after: 30 x509 + 29 precerts emitted, 0 parseErr → 66% emission
~5.5x firehose throughput. Real-world rate now ~2.2 cert events/sec
per log; multiply by N parallel logs for further coverage.
Adds entry_type field to emitted events so consumers can distinguish
precerts (issued ~hours before the live cert) from final X.509 certs.
Files touched
Diff
commit 2c5c65f5c80a391ab509a888ec7b93458a4a5bfc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 16:25:38 2026 -0700
ct-source-ctlog: parse PRECERT_ENTRY (TBSCertificate) for SAN extraction
Adds minimal-DER ASN.1 walker (extractDnsNamesFromTBS) that descends into the
precert's TBSCertificate, finds the [3] EXPLICIT extensions tag, iterates
each Extension SEQUENCE looking for OID 2.5.29.17 (subjectAltName), and
pulls dNSName GeneralNames (tag 0x82) from the SAN body.
Smoke run on Argon (18s, BATCH=24, POLL_MS=3000):
before: 14 x509 emitted, 43 precerts skipped → 25% emission
after: 30 x509 + 29 precerts emitted, 0 parseErr → 66% emission
~5.5x firehose throughput. Real-world rate now ~2.2 cert events/sec
per log; multiply by N parallel logs for further coverage.
Adds entry_type field to emitted events so consumers can distinguish
precerts (issued ~hours before the live cert) from final X.509 certs.
---
ct-source-ctlog.js | 120 +++++++++++++++++++++++++++++++++++++++++++++++++----
1 file changed, 112 insertions(+), 8 deletions(-)
diff --git a/ct-source-ctlog.js b/ct-source-ctlog.js
index 246ad10..2c3f518 100644
--- a/ct-source-ctlog.js
+++ b/ct-source-ctlog.js
@@ -94,8 +94,18 @@ function parseLeaf(leafB64) {
if (buf.length < off + certLen) return { entryType, error: 'truncated_cert' };
return { entryType, timestamp, cert: buf.subarray(off, off + certLen) };
} else if (entryType === 1) {
- // PRECERT_ENTRY: 32-byte issuer key hash + 3-byte len + TBSCert (defer parse)
- return { entryType, timestamp };
+ // PRECERT_ENTRY: 32-byte issuer key hash + 3-byte len + TBSCert
+ if (buf.length < off + 32 + 3) return { entryType, error: 'truncated_precert' };
+ off += 32;
+ const tbsLen = (buf.readUInt8(off) << 16) | buf.readUInt16BE(off + 1);
+ off += 3;
+ if (buf.length < off + tbsLen) return { entryType, error: 'truncated_tbs' };
+ const tbsOuter = buf.subarray(off, off + tbsLen);
+ // tbsOuter is a complete TBSCertificate DER (starts with SEQUENCE tag 0x30).
+ // Strip outer SEQUENCE wrapper to get the body for the SAN walker.
+ const top = readTlv(tbsOuter, 0);
+ if (!top || top.tag !== 0x30) return { entryType, error: 'bad_tbs_wrap' };
+ return { entryType, timestamp, tbs: top.val };
}
return { entryType, error: 'unknown_entry_type' };
}
@@ -123,6 +133,92 @@ function extractDnsNames(certDer) {
}
}
+// --- Minimal ASN.1 DER walker (just enough to find subjectAltName in TBSCertificate) ---
+//
+// TBSCertificate is a TBSCertificate (no outer wrap, no signatureAlgorithm,
+// no signature). We need to find:
+// extensions [3] EXPLICIT Extensions OPTIONAL
+// then within that SEQUENCE OF Extension, find the one with OID 2.5.29.17
+// (subjectAltName = 06 03 55 1D 11), then parse its extnValue (OCTET STRING
+// wrapping SEQUENCE OF GeneralName) and pick out dNSName entries (tag 0x82).
+function readDerLen(buf, off) {
+ if (off >= buf.length) throw new Error('eof');
+ const b = buf.readUInt8(off);
+ if (b < 0x80) return { len: b, hdr: 1 };
+ const n = b & 0x7f;
+ if (n === 0 || n > 4 || off + 1 + n > buf.length) throw new Error('bad_len');
+ let len = 0;
+ for (let i = 0; i < n; i++) len = (len << 8) | buf.readUInt8(off + 1 + i);
+ return { len, hdr: 1 + n };
+}
+function readTlv(buf, off) {
+ if (off >= buf.length) return null;
+ const tag = buf.readUInt8(off);
+ const { len, hdr } = readDerLen(buf, off + 1);
+ const start = off + 1 + hdr;
+ return { tag, len, valStart: start, val: buf.subarray(start, start + len), next: start + len };
+}
+
+const SAN_OID = Buffer.from([0x55, 0x1d, 0x11]); // 2.5.29.17
+
+function extractDnsNamesFromTBS(tbs) {
+ try {
+ // Walk children of TBSCertificate (top-level passed in as `tbs` body — caller has
+ // already unwrapped the outer SEQUENCE). Skip until we find the [3] EXPLICIT tag (0xa3).
+ let off = 0;
+ let extensionsBody = null;
+ while (off < tbs.length) {
+ const t = readTlv(tbs, off);
+ if (!t) break;
+ if (t.tag === 0xa3) {
+ // [3] EXPLICIT — body is itself a TLV: SEQUENCE OF Extension
+ const inner = readTlv(tbs, t.valStart);
+ if (inner && inner.tag === 0x30) extensionsBody = inner.val;
+ break;
+ }
+ off = t.next;
+ }
+ if (!extensionsBody) return [];
+
+ // Iterate extensions
+ let eOff = 0;
+ while (eOff < extensionsBody.length) {
+ const ext = readTlv(extensionsBody, eOff);
+ if (!ext || ext.tag !== 0x30) break;
+ // ext.val: SEQUENCE { oid OBJECT IDENTIFIER, critical BOOLEAN OPTIONAL, value OCTET STRING }
+ let inOff = 0;
+ const oidTlv = readTlv(ext.val, inOff);
+ if (!oidTlv || oidTlv.tag !== 0x06) { eOff = ext.next; continue; }
+ const oid = oidTlv.val;
+ inOff = oidTlv.next;
+ // Optional critical
+ let valTlv = readTlv(ext.val, inOff);
+ if (valTlv && valTlv.tag === 0x01) {
+ inOff = valTlv.next;
+ valTlv = readTlv(ext.val, inOff);
+ }
+ if (!valTlv || valTlv.tag !== 0x04) { eOff = ext.next; continue; } // OCTET STRING
+ if (!oid.equals(SAN_OID)) { eOff = ext.next; continue; }
+
+ // valTlv.val is OCTET STRING contents = DER-encoded SubjectAltName (SEQUENCE OF GeneralName)
+ const sanSeq = readTlv(valTlv.val, 0);
+ if (!sanSeq || sanSeq.tag !== 0x30) return [];
+ const names = [];
+ let gOff = 0;
+ while (gOff < sanSeq.val.length) {
+ const gn = readTlv(sanSeq.val, gOff);
+ if (!gn) break;
+ if (gn.tag === 0x82) names.push(gn.val.toString('ascii').toLowerCase()); // dNSName [2] IMPLICIT
+ gOff = gn.next;
+ }
+ return names;
+ }
+ return [];
+ } catch {
+ return [];
+ }
+}
+
function start({
logKey = 'argon',
pollMs = 5_000,
@@ -169,11 +265,18 @@ function start({
for (let i = 0; i < entries.length; i++) {
const entryIdx = start + i;
const parsed = parseLeaf(entries[i].leaf_input);
- if (parsed.error && !parsed.entryType) { stats.parseErr++; continue; }
- if (parsed.entryType === 1) { stats.precert++; continue; }
- if (parsed.entryType !== 0 || !parsed.cert) { stats.parseErr++; continue; }
- stats.x509++;
- const names = extractDnsNames(parsed.cert);
+ if (parsed.error && parsed.entryType === undefined) { stats.parseErr++; continue; }
+ let names = [];
+ if (parsed.entryType === 0 && parsed.cert) {
+ stats.x509++;
+ names = extractDnsNames(parsed.cert);
+ } else if (parsed.entryType === 1 && parsed.tbs) {
+ stats.precert++;
+ names = extractDnsNamesFromTBS(parsed.tbs);
+ } else {
+ stats.parseErr++;
+ continue;
+ }
if (!names.length) continue;
stats.emitted++;
ee.emit('certificate_update', {
@@ -181,6 +284,7 @@ function start({
data: {
cert_index: entryIdx,
log_source: log.name,
+ entry_type: parsed.entryType === 1 ? 'precert' : 'x509',
leaf_cert: {
all_domains: names,
not_before: null,
@@ -221,7 +325,7 @@ function start({
};
}
-module.exports = { start, LOGS, parseLeaf, extractDnsNames };
+module.exports = { start, LOGS, parseLeaf, extractDnsNames, extractDnsNamesFromTBS };
if (require.main === module) {
const logKey = process.env.CT_LOG || 'argon';
← da5c653 dashboard.js: wire ct-source-ctlog (CT_SOURCE=ctlog default)
·
back to Domain Sniper
·
ct-source-ctlog: multi-log parallel polling via startMulti() 008b1ca →