Fold tiledump into ctfetch. Add +sct, +issuer and +ctlog flags to print additional info

This commit is contained in:
2026-04-05 21:49:10 +02:00
parent 66835aab9d
commit a36e913e27
4 changed files with 471 additions and 161 deletions

View File

@@ -6,17 +6,18 @@ Tools for working with Certificate Transparency log tiles.
```bash ```bash
go install ./cmd/ctfetch go install ./cmd/ctfetch
go install ./cmd/tiledump
``` ```
## Commands ## Usage
### ctfetch `ctfetch` operates in two modes depending on the arguments given.
Fetch and dump leaf entries from CT logs. ### Leaf-index mode
Fetch a specific entry (or all entries in its tile) by leaf index:
```bash ```bash
ctfetch [--dumpall] <log-url> <leaf-index> ctfetch [flags] <log-url> <leaf-index> [+sct] [+issuer] [+ctlog]
``` ```
**Examples:** **Examples:**
@@ -26,35 +27,58 @@ Dump a specific entry:
ctfetch https://halloumi2026h1.mon.ct.ipng.ch 629794635 ctfetch https://halloumi2026h1.mon.ct.ipng.ch 629794635
``` ```
Dump all entries in the tile: Dump with SCTs, issuer chain, and CT log details:
```bash ```bash
ctfetch --dumpall https://halloumi2026h1.mon.ct.ipng.ch 629794635 ctfetch https://halloumi2026h1.mon.ct.ipng.ch 629794635 +sct +issuer +ctlog
``` ```
**Options:** ### Tile-dump mode
- `--dumpall`: Dump all entries in the tile instead of just the specified leaf
### tiledump Fetch all entries from a tile URL or a local file. Automatically detects data tiles (log entries) and hash tiles (Merkle tree hashes).
Read a CT log tile file or URL and dump contents. Automatically detects and handles both data tiles (log entries) and hash tiles (Merkle tree hashes).
```bash ```bash
tiledump <tile-file-or-url> ctfetch [flags] <tile-url-or-file> [+sct] [+issuer] [+ctlog]
``` ```
**Examples:** **Examples:**
Data tile from a file:
```bash
tiledump tile.data
```
Data tile from a URL: Data tile from a URL:
```bash ```bash
tiledump https://halloumi2026h1.mon.ct.ipng.ch/tile/data/x002/x460/135 ctfetch https://halloumi2026h1.mon.ct.ipng.ch/tile/data/x002/x460/135
```
Data tile with SCTs and CT log details:
```bash
ctfetch https://halloumi2026h1.mon.ct.ipng.ch/tile/data/x002/x460/135 +sct +ctlog
``` ```
Hash tile from a URL: Hash tile from a URL:
```bash ```bash
tiledump https://halloumi2026h1.mon.ct.ipng.ch/tile/0/x100/999 ctfetch https://halloumi2026h1.mon.ct.ipng.ch/tile/0/x100/999
``` ```
Data tile from a local file (with issuer resolution):
```bash
ctfetch --monitoring-url https://halloumi2026h1.mon.ct.ipng.ch tile.bin +issuer
```
## Output modifiers
| Modifier | Description |
|---|---|
| `+sct` | Parse and include embedded Signed Certificate Timestamps from final (non-precert) certificates |
| `+issuer` | Fetch and include issuer certificate details from the log's `/issuer/<fp>` endpoint |
| `+ctlog` | Look up each SCT's log ID in the CT log list and include operator/state details |
## Flags
| Flag | Default | Description |
|---|---|---|
| `--logs-list-url` | `https://www.gstatic.com/ct/log_list/v3/all_logs_list.json` | URL of the CT log list JSON used for `+ctlog` lookups |
| `--monitoring-url` | _(none)_ | Log root URL for issuer lookups when input is a local file |
## Notes
- In tile-dump mode with a tile URL, `+issuer` automatically derives the log root by stripping the `/tile/...` path. With a local file, `--monitoring-url` must be provided.
- Partial tiles (`.p/N` suffix) are tried first; on 404 the full tile is fetched automatically.
- The CT log list and issuer certificates are cached in memory, so each unique resource is fetched only once per invocation.

View File

@@ -1,5 +1,10 @@
// Command ctfetch fetches and dumps a specific leaf entry from a given Static CT log. // Command ctfetch fetches and dumps entries from a Static CT log.
// It can also dump the whole contents of the tile, if the -dumpall flag is specified. //
// Two modes:
//
// ctfetch [flags] <log-url> <leaf-index> [+sct] [+issuer] [+ctlog] fetch one entry by leaf index
// ctfetch [flags] <tile-url-or-file> [+sct] [+issuer] [+ctlog] dump all entries in a tile
//
// (C) Copyright 2026 Pim van Pelt <pim@ipng.ch> // (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
package main package main
@@ -18,34 +23,85 @@ import (
) )
func main() { func main() {
dumpAll := flag.Bool("dumpall", false, "dump all entries in the tile") logsListURL := flag.String("logs-list-url", "https://www.gstatic.com/ct/log_list/v3/all_logs_list.json", "URL of the CT log list JSON")
monitoringURL := flag.String("monitoring-url", "", "log root URL for issuer lookups when input is a file")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [--dumpall] <log-url> <leaf-index>\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Usage:\n")
fmt.Fprintf(os.Stderr, "Example: %s https://halloumi2026h1.mon.ct.ipng.ch 457683896\n", os.Args[0]) fmt.Fprintf(os.Stderr, " %s [flags] <log-url> <leaf-index> [+sct] [+issuer] [+ctlog] fetch one entry\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s [flags] <tile-url-or-file> [+sct] [+issuer] [+ctlog] dump all entries in a tile\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nExamples:\n")
fmt.Fprintf(os.Stderr, " %s https://halloumi2026h1.mon.ct.ipng.ch 457683896 +sct +issuer +ctlog\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s https://halloumi2026h1.mon.ct.ipng.ch/tile/data/x002/x460/135 +sct\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --monitoring-url https://halloumi2026h1.mon.ct.ipng.ch tile.bin +issuer\n", os.Args[0])
fmt.Fprintf(os.Stderr, "\nFlags:\n") fmt.Fprintf(os.Stderr, "\nFlags:\n")
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
if flag.NArg() != 2 { if flag.NArg() < 1 {
flag.Usage() flag.Usage()
os.Exit(1) os.Exit(1)
} }
logURL := strings.TrimSuffix(flag.Arg(0), "/") // Determine mode: if second positional arg parses as an integer → leaf-index mode.
leafIndex, err := strconv.ParseInt(flag.Arg(1), 10, 64) _, secondIsInt := func() (int64, bool) {
if flag.NArg() < 2 {
return 0, false
}
v, err := strconv.ParseInt(flag.Arg(1), 10, 64)
return v, err == nil
}()
var modifiers []string
if secondIsInt {
modifiers = flag.Args()[2:]
} else {
modifiers = flag.Args()[1:]
}
opts := utils.Options{}
for _, arg := range modifiers {
switch arg {
case "+sct":
opts.ShowSCT = true
case "+issuer":
opts.ShowIssuer = true
case "+ctlog":
opts.ShowCTLog = true
default:
fatal("unknown argument %q (expected +sct, +issuer, or +ctlog)", arg)
}
}
if opts.ShowCTLog && *logsListURL != "" {
ctlogs, err := utils.FetchCTLogList(*logsListURL)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: could not fetch CT log list: %v\n", err)
} else {
opts.CTLogs = ctlogs
}
}
if secondIsInt {
runLeafIndex(flag.Arg(0), flag.Arg(1), opts)
} else {
runTileDump(flag.Arg(0), *monitoringURL, opts)
}
}
func runLeafIndex(logURL, indexStr string, opts utils.Options) {
logURL = strings.TrimSuffix(logURL, "/")
opts.LogURL = logURL
leafIndex, err := strconv.ParseInt(indexStr, 10, 64)
if err != nil { if err != nil {
fatal("invalid leaf index: %v", err) fatal("invalid leaf index: %v", err)
} }
// Convert leaf index to tile coordinates
tile := tlog.TileForIndex(sunlight.TileHeight, tlog.StoredHashIndex(0, leafIndex)) tile := tlog.TileForIndex(sunlight.TileHeight, tlog.StoredHashIndex(0, leafIndex))
tile.L = -1 // Data tiles are at level -1 tile.L = -1
// Get the tile path (both partial and full versions)
partialPath := sunlight.TilePath(tile) partialPath := sunlight.TilePath(tile)
// For full tile path, we need to remove the .p/W suffix if present
fullTile := tile fullTile := tile
fullTile.W = sunlight.TileWidth fullTile.W = sunlight.TileWidth
fullPath := sunlight.TilePath(fullTile) fullPath := sunlight.TilePath(fullTile)
@@ -57,53 +113,82 @@ func main() {
fmt.Fprintf(os.Stderr, "Partial tile path: %s\n", partialPath) fmt.Fprintf(os.Stderr, "Partial tile path: %s\n", partialPath)
fmt.Fprintf(os.Stderr, "Full tile path: %s\n", fullPath) fmt.Fprintf(os.Stderr, "Full tile path: %s\n", fullPath)
// Try to fetch the tile (partial first, then full)
var tileData []byte
var fetchedPath string
// Try partial tile first
partialURL := logURL + "/" + partialPath partialURL := logURL + "/" + partialPath
fmt.Fprintf(os.Stderr, "Trying: %s\n", partialURL) fmt.Fprintf(os.Stderr, "Trying: %s\n", partialURL)
tileData, err = utils.FetchURL(partialURL) tileData, err := utils.FetchTile(partialURL)
if err == nil { if err != nil {
fetchedPath = partialPath
fmt.Fprintf(os.Stderr, "Successfully fetched partial tile\n")
} else {
// Fall back to full tile
fullURL := logURL + "/" + fullPath fullURL := logURL + "/" + fullPath
fmt.Fprintf(os.Stderr, "Partial tile failed, trying: %s\n", fullURL) fmt.Fprintf(os.Stderr, "Partial tile failed, trying: %s\n", fullURL)
tileData, err = utils.FetchURL(fullURL) tileData, err = utils.FetchURL(fullURL)
if err != nil { if err != nil {
fatal("failed to fetch tile: %v", err) fatal("failed to fetch tile: %v", err)
} }
fetchedPath = fullPath
fmt.Fprintf(os.Stderr, "Successfully fetched full tile\n") fmt.Fprintf(os.Stderr, "Successfully fetched full tile\n")
} else {
fmt.Fprintf(os.Stderr, "Successfully fetched partial tile\n")
} }
// Decompress if needed
tileData, err = utils.Decompress(tileData) tileData, err = utils.Decompress(tileData)
if err != nil { if err != nil {
fatal("failed to decompress tile: %v", err) fatal("failed to decompress tile: %v", err)
} }
fmt.Fprintf(os.Stderr, "Tile size: %d bytes\n\n", len(tileData))
fmt.Fprintf(os.Stderr, "Tile size: %d bytes\n", len(tileData)) entry, err := utils.DumpEntryAtPosition(tileData, int(positionInTile), leafIndex, opts)
fmt.Fprintf(os.Stderr, "Fetched path: %s\n\n", fetchedPath) if err != nil {
fatal("%v", err)
if *dumpAll {
// Dump all entries in the tile
result, err := utils.DumpAllEntries(tileData)
if err != nil {
fatal("%v", err)
}
printJSON(result)
} else {
// Dump only the specific entry at the position
entry, err := utils.DumpEntryAtPosition(tileData, int(positionInTile), leafIndex)
if err != nil {
fatal("%v", err)
}
printJSON(entry)
} }
printJSON(entry)
}
func runTileDump(arg, monitoringURL string, opts utils.Options) {
var tileData []byte
var err error
if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") {
// Derive log root from tile URL for issuer lookups.
if opts.ShowIssuer {
if idx := strings.Index(arg, "/tile/"); idx != -1 {
opts.LogURL = strings.TrimSuffix(arg[:idx], "/")
} else if monitoringURL != "" {
opts.LogURL = strings.TrimSuffix(monitoringURL, "/")
} else {
fatal("+issuer requires a log root URL; none could be derived from %q and --monitoring-url is not set", arg)
}
}
fmt.Fprintf(os.Stderr, "Fetching: %s\n", arg)
tileData, err = utils.FetchTile(arg)
if err != nil {
fatal("failed to fetch tile: %v", err)
}
fmt.Fprintf(os.Stderr, "Fetched %d bytes\n", len(tileData))
} else {
// File input.
if opts.ShowIssuer {
if monitoringURL != "" {
opts.LogURL = strings.TrimSuffix(monitoringURL, "/")
} else {
fatal("+issuer requires --monitoring-url when input is a file")
}
}
tileData, err = os.ReadFile(arg)
if err != nil {
fatal("failed to read file: %v", err)
}
fmt.Fprintf(os.Stderr, "Read %d bytes from %s\n", len(tileData), arg)
}
tileData, err = utils.Decompress(tileData)
if err != nil {
fatal("failed to decompress tile: %v", err)
}
fmt.Fprintf(os.Stderr, "Tile size: %d bytes\n\n", len(tileData))
result, err := utils.DumpAllEntries(tileData, opts)
if err != nil {
fatal("%v", err)
}
printJSON(result)
} }
func printJSON(v interface{}) { func printJSON(v interface{}) {
@@ -111,7 +196,6 @@ func printJSON(v interface{}) {
if err != nil { if err != nil {
fatal("failed to marshal JSON: %v", err) fatal("failed to marshal JSON: %v", err)
} }
fmt.Println(string(data)) fmt.Println(string(data))
} }

View File

@@ -1,86 +0,0 @@
// Command tiledump reads a CT log tile file and dumps all entries.
// (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"ctfetch/internal/utils"
)
func main() {
if len(os.Args) != 2 {
fmt.Fprintf(os.Stderr, "Usage: %s <tile-file-or-url>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Examples:\n")
fmt.Fprintf(os.Stderr, " %s tile.data\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s https://halloumi2026h1.mon.ct.ipng.ch/tile/data/x002/x460/135\n", os.Args[0])
os.Exit(1)
}
arg := os.Args[1]
var tileData []byte
var err error
// Check if argument is a URL
if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") {
// Fetch from URL
fmt.Fprintf(os.Stderr, "Fetching: %s\n", arg)
tileData, err = utils.FetchURL(arg)
if err != nil {
// If it's a 404 and the URL is for a partial tile, try the full tile
if err.Error() == "HTTP 404" && strings.Contains(arg, ".p/") {
fullTileURL := arg[:strings.Index(arg, ".p/")]
fmt.Fprintf(os.Stderr, "Partial tile not found, trying full tile: %s\n", fullTileURL)
tileData, err = utils.FetchURL(fullTileURL)
if err != nil {
fatal("failed to fetch full tile: %v", err)
}
fmt.Fprintf(os.Stderr, "Fetched %d bytes from full tile\n", len(tileData))
} else {
fatal("failed to fetch URL: %v", err)
}
} else {
fmt.Fprintf(os.Stderr, "Fetched %d bytes\n", len(tileData))
}
} else {
// Read from file
tileData, err = os.ReadFile(arg)
if err != nil {
fatal("failed to read file: %v", err)
}
fmt.Fprintf(os.Stderr, "Read %d bytes from %s\n", len(tileData), arg)
}
// Decompress if needed
tileData, err = utils.Decompress(tileData)
if err != nil {
fatal("failed to decompress tile: %v", err)
}
fmt.Fprintf(os.Stderr, "Tile size: %d bytes\n\n", len(tileData))
// Dump all entries
result, err := utils.DumpAllEntries(tileData)
if err != nil {
fatal("%v", err)
}
printJSON(result)
}
func printJSON(v interface{}) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
fatal("failed to marshal JSON: %v", err)
}
fmt.Println(string(data))
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...)
os.Exit(1)
}

View File

@@ -5,18 +5,75 @@ package utils
import ( import (
"bytes" "bytes"
"compress/gzip" "compress/gzip"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/binary"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"strings"
"sync"
"time"
"filippo.io/sunlight" "filippo.io/sunlight"
) )
var (
ctLogCache = map[string]map[string]CTLogInfo{}
ctLogCacheMu sync.Mutex
issuerCache = map[string]*IssuerInfo{}
issuerCacheMu sync.Mutex
)
var oidSCTList = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2}
// CTLogInfo holds details about a CT log from the log list.
type CTLogInfo struct {
Description string `json:"description"`
URL string `json:"url"`
Operator string `json:"operator"`
State string `json:"state"`
}
// SCT represents a Signed Certificate Timestamp.
type SCT struct {
Version int `json:"version"`
LogID string `json:"log_id"`
Timestamp int64 `json:"timestamp"`
TimestampHuman string `json:"timestamp_human"`
Extensions string `json:"extensions,omitempty"`
HashAlgorithm int `json:"hash_algorithm"`
SigAlgorithm int `json:"sig_algorithm"`
Signature string `json:"signature"`
CTLog *CTLogInfo `json:"ctlog,omitempty"`
}
const maxCompressRatio = 100 const maxCompressRatio = 100
// Options controls which optional fields are fetched and included in output.
type Options struct {
LogURL string
ShowSCT bool
ShowIssuer bool
ShowCTLog bool
CTLogs map[string]CTLogInfo // keyed by hex log_id
}
// IssuerInfo holds parsed details of an issuer certificate fetched from the log.
type IssuerInfo struct {
Fingerprint string `json:"fingerprint"`
Subject string `json:"subject"`
Issuer string `json:"issuer"`
NotBefore string `json:"not_before"`
NotAfter string `json:"not_after"`
SerialNumber string `json:"serial_number"`
}
// Entry represents a CT log entry in JSON format. // Entry represents a CT log entry in JSON format.
type Entry struct { type Entry struct {
EntryNumber int `json:"entry_number"` EntryNumber int `json:"entry_number"`
@@ -27,6 +84,8 @@ type Entry struct {
CertificateSize int `json:"certificate_size"` CertificateSize int `json:"certificate_size"`
PreCertificateSize *int `json:"precertificate_size,omitempty"` PreCertificateSize *int `json:"precertificate_size,omitempty"`
ChainFingerprints []string `json:"chain_fingerprints"` ChainFingerprints []string `json:"chain_fingerprints"`
Issuers []IssuerInfo `json:"issuers,omitempty"`
SCTs []SCT `json:"scts,omitempty"`
ParsedCertInfo json.RawMessage `json:"parsed_cert_info,omitempty"` ParsedCertInfo json.RawMessage `json:"parsed_cert_info,omitempty"`
} }
@@ -38,9 +97,9 @@ type HashTileOutput struct {
// DumpResult is the result of dumping entries or hashes from a tile. // DumpResult is the result of dumping entries or hashes from a tile.
type DumpResult struct { type DumpResult struct {
Entries []Entry `json:"entries,omitempty"` Entries []Entry `json:"entries,omitempty"`
HashTile *HashTileOutput `json:"hash_tile,omitempty"` HashTile *HashTileOutput `json:"hash_tile,omitempty"`
TotalEntries int `json:"total_entries,omitempty"` TotalEntries int `json:"total_entries,omitempty"`
} }
// FetchURL fetches data from a URL. // FetchURL fetches data from a URL.
@@ -58,6 +117,23 @@ func FetchURL(url string) ([]byte, error) {
return io.ReadAll(resp.Body) return io.ReadAll(resp.Body)
} }
// FetchTile fetches a tile from a URL, falling back from partial to full tile on 404.
func FetchTile(url string) ([]byte, error) {
data, err := FetchURL(url)
if err == nil {
return data, nil
}
// On 404, try stripping the partial-tile suffix (.p/NNN)
if err.Error() == "HTTP 404" {
if idx := strings.Index(url, ".p/"); idx != -1 {
fullURL := url[:idx]
fmt.Fprintf(os.Stderr, "Partial tile not found, trying full tile: %s\n", fullURL)
return FetchURL(fullURL)
}
}
return nil, err
}
// Decompress decompresses gzip-compressed data, or returns the data as-is if not compressed. // Decompress decompresses gzip-compressed data, or returns the data as-is if not compressed.
func Decompress(data []byte) ([]byte, error) { func Decompress(data []byte) ([]byte, error) {
r, err := gzip.NewReader(bytes.NewReader(data)) r, err := gzip.NewReader(bytes.NewReader(data))
@@ -71,9 +147,9 @@ func Decompress(data []byte) ([]byte, error) {
// DumpAllEntries reads and returns all entries from tile data as JSON-serializable structures. // DumpAllEntries reads and returns all entries from tile data as JSON-serializable structures.
// Automatically detects if the tile is a data tile or hash tile. // Automatically detects if the tile is a data tile or hash tile.
func DumpAllEntries(tileData []byte) (*DumpResult, error) { func DumpAllEntries(tileData []byte, opts Options) (*DumpResult, error) {
// Try to read as data tile first // Try to read as data tile first
result, err := dumpDataTile(tileData) result, err := dumpDataTile(tileData, opts)
if err != nil { if err != nil {
// If it fails, try as hash tile // If it fails, try as hash tile
fmt.Fprintf(os.Stderr, "Not a data tile, trying as hash tile...\n") fmt.Fprintf(os.Stderr, "Not a data tile, trying as hash tile...\n")
@@ -82,7 +158,7 @@ func DumpAllEntries(tileData []byte) (*DumpResult, error) {
return result, nil return result, nil
} }
func dumpDataTile(tileData []byte) (*DumpResult, error) { func dumpDataTile(tileData []byte, opts Options) (*DumpResult, error) {
entryNum := 0 entryNum := 0
var entries []Entry var entries []Entry
for len(tileData) > 0 { for len(tileData) > 0 {
@@ -92,7 +168,7 @@ func dumpDataTile(tileData []byte) (*DumpResult, error) {
} }
tileData = remaining tileData = remaining
entry := convertEntry(e, entryNum) entry := convertEntry(e, entryNum, opts)
entries = append(entries, entry) entries = append(entries, entry)
entryNum++ entryNum++
} }
@@ -127,7 +203,7 @@ func dumpHashTile(tileData []byte) (*DumpResult, error) {
} }
// DumpEntryAtPosition reads and returns a specific entry at the given position. // DumpEntryAtPosition reads and returns a specific entry at the given position.
func DumpEntryAtPosition(tileData []byte, position int, expectedIndex int64) (*Entry, error) { func DumpEntryAtPosition(tileData []byte, position int, expectedIndex int64, opts Options) (*Entry, error) {
entryNum := 0 entryNum := 0
for len(tileData) > 0 { for len(tileData) > 0 {
e, remaining, err := sunlight.ReadTileLeaf(tileData) e, remaining, err := sunlight.ReadTileLeaf(tileData)
@@ -141,7 +217,7 @@ func DumpEntryAtPosition(tileData []byte, position int, expectedIndex int64) (*E
fmt.Fprintf(os.Stderr, "WARNING: Expected leaf index %d but found %d at position %d\n", fmt.Fprintf(os.Stderr, "WARNING: Expected leaf index %d but found %d at position %d\n",
expectedIndex, e.LeafIndex, position) expectedIndex, e.LeafIndex, position)
} }
entry := convertEntry(e, entryNum) entry := convertEntry(e, entryNum, opts)
return &entry, nil return &entry, nil
} }
entryNum++ entryNum++
@@ -150,7 +226,195 @@ func DumpEntryAtPosition(tileData []byte, position int, expectedIndex int64) (*E
return nil, fmt.Errorf("position %d not found in tile (only %d entries)", position, entryNum) return nil, fmt.Errorf("position %d not found in tile (only %d entries)", position, entryNum)
} }
func convertEntry(e *sunlight.LogEntry, entryNum int) Entry { // parseEmbeddedSCTs extracts SCTs from the SCT list extension of a DER-encoded certificate.
func parseEmbeddedSCTs(certDER []byte) ([]SCT, error) {
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
for _, ext := range cert.Extensions {
if !ext.Id.Equal(oidSCTList) {
continue
}
// ext.Value is the DER encoding of the extension value, which is an OCTET STRING
// wrapping the TLS-encoded SCTList.
var inner []byte
if rest, err := asn1.Unmarshal(ext.Value, &inner); err != nil || len(rest) != 0 {
return nil, fmt.Errorf("unmarshal SCT extension: %w", err)
}
return parseSCTList(inner)
}
return nil, nil
}
// parseSCTList parses a TLS-encoded SignedCertificateTimestampList.
func parseSCTList(data []byte) ([]SCT, error) {
if len(data) < 2 {
return nil, fmt.Errorf("SCT list too short")
}
listLen := int(binary.BigEndian.Uint16(data[:2]))
data = data[2:]
if len(data) < listLen {
return nil, fmt.Errorf("SCT list truncated")
}
data = data[:listLen]
var scts []SCT
for len(data) > 0 {
if len(data) < 2 {
return nil, fmt.Errorf("SCT entry length truncated")
}
sctLen := int(binary.BigEndian.Uint16(data[:2]))
data = data[2:]
if len(data) < sctLen {
return nil, fmt.Errorf("SCT entry truncated")
}
sct, err := parseSCT(data[:sctLen])
if err != nil {
return nil, err
}
scts = append(scts, sct)
data = data[sctLen:]
}
return scts, nil
}
// parseSCT parses a single v1 SCT from raw bytes.
func parseSCT(data []byte) (SCT, error) {
// version(1) + log_id(32) + timestamp(8) + ext_len(2) = 43 bytes minimum
if len(data) < 43 {
return SCT{}, fmt.Errorf("SCT too short: %d bytes", len(data))
}
version := int(data[0])
logID := hex.EncodeToString(data[1:33])
ts := int64(binary.BigEndian.Uint64(data[33:41]))
extLen := int(binary.BigEndian.Uint16(data[41:43]))
pos := 43
if len(data) < pos+extLen+4 {
return SCT{}, fmt.Errorf("SCT extensions/signature truncated")
}
extensions := ""
if extLen > 0 {
extensions = hex.EncodeToString(data[pos : pos+extLen])
}
pos += extLen
hashAlg := int(data[pos])
sigAlg := int(data[pos+1])
sigLen := int(binary.BigEndian.Uint16(data[pos+2 : pos+4]))
pos += 4
if len(data) < pos+sigLen {
return SCT{}, fmt.Errorf("SCT signature truncated")
}
sig := hex.EncodeToString(data[pos : pos+sigLen])
return SCT{
Version: version,
LogID: logID,
Timestamp: ts,
TimestampHuman: time.UnixMilli(ts).UTC().Format(time.RFC3339),
Extensions: extensions,
HashAlgorithm: hashAlg,
SigAlgorithm: sigAlg,
Signature: sig,
}, nil
}
// FetchCTLogList fetches the CT log list JSON and returns a map keyed by hex log_id.
// Results are cached by URL so the network is only hit once per process.
func FetchCTLogList(url string) (map[string]CTLogInfo, error) {
ctLogCacheMu.Lock()
if cached, ok := ctLogCache[url]; ok {
ctLogCacheMu.Unlock()
return cached, nil
}
ctLogCacheMu.Unlock()
data, err := FetchURL(url)
if err != nil {
return nil, err
}
var list struct {
Operators []struct {
Name string `json:"name"`
Logs []struct {
Description string `json:"description"`
LogID string `json:"log_id"`
URL string `json:"url"`
State map[string]json.RawMessage `json:"state"`
} `json:"logs"`
} `json:"operators"`
}
if err := json.Unmarshal(data, &list); err != nil {
return nil, fmt.Errorf("parse log list: %w", err)
}
result := make(map[string]CTLogInfo)
for _, op := range list.Operators {
for _, log := range op.Logs {
raw, err := base64.StdEncoding.DecodeString(log.LogID)
if err != nil {
continue
}
hexID := hex.EncodeToString(raw)
state := ""
for k := range log.State {
state = k
break
}
result[hexID] = CTLogInfo{
Description: log.Description,
URL: log.URL,
Operator: op.Name,
State: state,
}
}
}
ctLogCacheMu.Lock()
ctLogCache[url] = result
ctLogCacheMu.Unlock()
return result, nil
}
// fetchIssuer fetches the issuer certificate at /issuer/<fingerprint> and returns parsed info.
// Results are cached by URL so the same issuer is only fetched once per process.
func fetchIssuer(logURL, fingerprint string) (*IssuerInfo, error) {
url := logURL + "/issuer/" + fingerprint
issuerCacheMu.Lock()
if cached, ok := issuerCache[url]; ok {
issuerCacheMu.Unlock()
return cached, nil
}
issuerCacheMu.Unlock()
data, err := FetchURL(url)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(data)
if err != nil {
return nil, fmt.Errorf("parse issuer cert: %w", err)
}
info := &IssuerInfo{
Fingerprint: fingerprint,
Subject: cert.Subject.String(),
Issuer: cert.Issuer.String(),
NotBefore: cert.NotBefore.UTC().Format(time.RFC3339),
NotAfter: cert.NotAfter.UTC().Format(time.RFC3339),
SerialNumber: cert.SerialNumber.String(),
}
issuerCacheMu.Lock()
issuerCache[url] = info
issuerCacheMu.Unlock()
return info, nil
}
func convertEntry(e *sunlight.LogEntry, entryNum int, opts Options) Entry {
entry := Entry{ entry := Entry{
EntryNumber: entryNum, EntryNumber: entryNum,
LeafIndex: e.LeafIndex, LeafIndex: e.LeafIndex,
@@ -168,11 +432,35 @@ func convertEntry(e *sunlight.LogEntry, entryNum int) Entry {
entry.PreCertificateSize = &size entry.PreCertificateSize = &size
} }
// Convert chain fingerprints to hex strings // Convert chain fingerprints to hex strings and optionally fetch issuer details.
entry.ChainFingerprints = make([]string, len(e.ChainFingerprints)) entry.ChainFingerprints = make([]string, len(e.ChainFingerprints))
for i, fp := range e.ChainFingerprints { for i, fp := range e.ChainFingerprints {
entry.ChainFingerprints[i] = hex.EncodeToString(fp[:]) entry.ChainFingerprints[i] = hex.EncodeToString(fp[:])
} }
if opts.ShowIssuer && opts.LogURL != "" {
for _, fp := range entry.ChainFingerprints {
info, err := fetchIssuer(opts.LogURL, fp)
if err != nil {
fmt.Fprintf(os.Stderr, "WARNING: could not fetch issuer %s: %v\n", fp, err)
continue
}
entry.Issuers = append(entry.Issuers, *info)
}
}
// Optionally extract embedded SCTs from final (non-precert) certificates.
if opts.ShowSCT && !e.IsPrecert && len(e.Certificate) > 0 {
if scts, err := parseEmbeddedSCTs(e.Certificate); err == nil {
if opts.ShowCTLog {
for i := range scts {
if info, ok := opts.CTLogs[scts[i].LogID]; ok {
scts[i].CTLog = &info
}
}
}
entry.SCTs = scts
}
}
// Try to extract parsed certificate info // Try to extract parsed certificate info
if trimmed, err := e.TrimmedEntry(); err == nil { if trimmed, err := e.TrimmedEntry(); err == nil {