[object Object]

← 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 →