From a36e913e27e064a55be3dfa5f2e973745737870c Mon Sep 17 00:00:00 2001 From: Pim van Pelt Date: Sun, 5 Apr 2026 21:49:10 +0200 Subject: [PATCH] Fold tiledump into ctfetch. Add +sct, +issuer and +ctlog flags to print additional info --- README.md | 64 ++++++--- cmd/ctfetch/main.go | 172 ++++++++++++++++------ cmd/tiledump/main.go | 86 ----------- internal/utils/utils.go | 310 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 471 insertions(+), 161 deletions(-) delete mode 100644 cmd/tiledump/main.go diff --git a/README.md b/README.md index c9133fc..64395ab 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,18 @@ Tools for working with Certificate Transparency log tiles. ```bash 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 -ctfetch [--dumpall] +ctfetch [flags] [+sct] [+issuer] [+ctlog] ``` **Examples:** @@ -26,35 +27,58 @@ Dump a specific entry: ctfetch https://halloumi2026h1.mon.ct.ipng.ch 629794635 ``` -Dump all entries in the tile: +Dump with SCTs, issuer chain, and CT log details: ```bash -ctfetch --dumpall https://halloumi2026h1.mon.ct.ipng.ch 629794635 +ctfetch https://halloumi2026h1.mon.ct.ipng.ch 629794635 +sct +issuer +ctlog ``` -**Options:** -- `--dumpall`: Dump all entries in the tile instead of just the specified leaf +### Tile-dump mode -### tiledump - -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). +Fetch all entries from a tile URL or a local file. Automatically detects data tiles (log entries) and hash tiles (Merkle tree hashes). ```bash -tiledump +ctfetch [flags] [+sct] [+issuer] [+ctlog] ``` **Examples:** -Data tile from a file: -```bash -tiledump tile.data -``` - Data tile from a URL: ```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: ```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/` 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. diff --git a/cmd/ctfetch/main.go b/cmd/ctfetch/main.go index cf1c294..f5f0aba 100644 --- a/cmd/ctfetch/main.go +++ b/cmd/ctfetch/main.go @@ -1,5 +1,10 @@ -// Command ctfetch fetches and dumps a specific leaf entry from a given Static CT log. -// It can also dump the whole contents of the tile, if the -dumpall flag is specified. +// Command ctfetch fetches and dumps entries from a Static CT log. +// +// Two modes: +// +// ctfetch [flags] [+sct] [+issuer] [+ctlog] fetch one entry by leaf index +// ctfetch [flags] [+sct] [+issuer] [+ctlog] dump all entries in a tile +// // (C) Copyright 2026 Pim van Pelt package main @@ -18,34 +23,85 @@ import ( ) 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() { - fmt.Fprintf(os.Stderr, "Usage: %s [--dumpall] \n", os.Args[0]) - fmt.Fprintf(os.Stderr, "Example: %s https://halloumi2026h1.mon.ct.ipng.ch 457683896\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, " %s [flags] [+sct] [+issuer] [+ctlog] fetch one entry\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s [flags] [+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") flag.PrintDefaults() } flag.Parse() - if flag.NArg() != 2 { + if flag.NArg() < 1 { flag.Usage() os.Exit(1) } - logURL := strings.TrimSuffix(flag.Arg(0), "/") - leafIndex, err := strconv.ParseInt(flag.Arg(1), 10, 64) + // Determine mode: if second positional arg parses as an integer → leaf-index mode. + _, 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 { fatal("invalid leaf index: %v", err) } - // Convert leaf index to tile coordinates 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) - - // For full tile path, we need to remove the .p/W suffix if present fullTile := tile fullTile.W = sunlight.TileWidth fullPath := sunlight.TilePath(fullTile) @@ -57,53 +113,82 @@ func main() { fmt.Fprintf(os.Stderr, "Partial tile path: %s\n", partialPath) 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 fmt.Fprintf(os.Stderr, "Trying: %s\n", partialURL) - tileData, err = utils.FetchURL(partialURL) - if err == nil { - fetchedPath = partialPath - fmt.Fprintf(os.Stderr, "Successfully fetched partial tile\n") - } else { - // Fall back to full tile + tileData, err := utils.FetchTile(partialURL) + if err != nil { fullURL := logURL + "/" + fullPath fmt.Fprintf(os.Stderr, "Partial tile failed, trying: %s\n", fullURL) tileData, err = utils.FetchURL(fullURL) if err != nil { fatal("failed to fetch tile: %v", err) } - fetchedPath = fullPath 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) if err != nil { 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)) - fmt.Fprintf(os.Stderr, "Fetched path: %s\n\n", fetchedPath) - - 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) + entry, err := utils.DumpEntryAtPosition(tileData, int(positionInTile), leafIndex, opts) + if err != nil { + fatal("%v", err) } + 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{}) { @@ -111,7 +196,6 @@ func printJSON(v interface{}) { if err != nil { fatal("failed to marshal JSON: %v", err) } - fmt.Println(string(data)) } diff --git a/cmd/tiledump/main.go b/cmd/tiledump/main.go deleted file mode 100644 index 2081dc5..0000000 --- a/cmd/tiledump/main.go +++ /dev/null @@ -1,86 +0,0 @@ -// Command tiledump reads a CT log tile file and dumps all entries. -// (C) Copyright 2026 Pim van Pelt -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "ctfetch/internal/utils" -) - -func main() { - if len(os.Args) != 2 { - fmt.Fprintf(os.Stderr, "Usage: %s \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) -} diff --git a/internal/utils/utils.go b/internal/utils/utils.go index c7a5830..d45acb1 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -5,18 +5,75 @@ package utils import ( "bytes" "compress/gzip" + "crypto/x509" + "encoding/asn1" + "encoding/base64" + "encoding/binary" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" + "strings" + "sync" + "time" "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 +// 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. type Entry struct { EntryNumber int `json:"entry_number"` @@ -27,6 +84,8 @@ type Entry struct { CertificateSize int `json:"certificate_size"` PreCertificateSize *int `json:"precertificate_size,omitempty"` ChainFingerprints []string `json:"chain_fingerprints"` + Issuers []IssuerInfo `json:"issuers,omitempty"` + SCTs []SCT `json:"scts,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. type DumpResult struct { - Entries []Entry `json:"entries,omitempty"` - HashTile *HashTileOutput `json:"hash_tile,omitempty"` - TotalEntries int `json:"total_entries,omitempty"` + Entries []Entry `json:"entries,omitempty"` + HashTile *HashTileOutput `json:"hash_tile,omitempty"` + TotalEntries int `json:"total_entries,omitempty"` } // FetchURL fetches data from a URL. @@ -58,6 +117,23 @@ func FetchURL(url string) ([]byte, error) { 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. func Decompress(data []byte) ([]byte, error) { 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. // 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 - result, err := dumpDataTile(tileData) + result, err := dumpDataTile(tileData, opts) if err != nil { // If it fails, try as hash tile 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 } -func dumpDataTile(tileData []byte) (*DumpResult, error) { +func dumpDataTile(tileData []byte, opts Options) (*DumpResult, error) { entryNum := 0 var entries []Entry for len(tileData) > 0 { @@ -92,7 +168,7 @@ func dumpDataTile(tileData []byte) (*DumpResult, error) { } tileData = remaining - entry := convertEntry(e, entryNum) + entry := convertEntry(e, entryNum, opts) entries = append(entries, entry) entryNum++ } @@ -127,7 +203,7 @@ func dumpHashTile(tileData []byte) (*DumpResult, error) { } // 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 for len(tileData) > 0 { 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", expectedIndex, e.LeafIndex, position) } - entry := convertEntry(e, entryNum) + entry := convertEntry(e, entryNum, opts) return &entry, nil } 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) } -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/ 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{ EntryNumber: entryNum, LeafIndex: e.LeafIndex, @@ -168,11 +432,35 @@ func convertEntry(e *sunlight.LogEntry, entryNum int) Entry { 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)) for i, fp := range e.ChainFingerprints { 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 if trimmed, err := e.TrimmedEntry(); err == nil {