// 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. // (C) Copyright 2026 Pim van Pelt package main import ( "bytes" "compress/gzip" "encoding/json" "flag" "fmt" "io" "net/http" "os" "strconv" "strings" "filippo.io/sunlight" "golang.org/x/mod/sumdb/tlog" ) func main() { dumpAll := flag.Bool("dumpall", false, "dump all entries in the tile") 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, "\nFlags:\n") flag.PrintDefaults() } flag.Parse() if flag.NArg() != 2 { flag.Usage() os.Exit(1) } logURL := strings.TrimSuffix(flag.Arg(0), "/") leafIndex, err := strconv.ParseInt(flag.Arg(1), 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 // 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) positionInTile := leafIndex % sunlight.TileWidth fmt.Fprintf(os.Stderr, "Leaf Index: %d\n", leafIndex) fmt.Fprintf(os.Stderr, "Position in tile: %d\n", positionInTile) 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 = fetchURL(partialURL) if err == nil { fetchedPath = partialPath fmt.Fprintf(os.Stderr, "Successfully fetched partial tile\n") } else { // Fall back to full tile fullURL := logURL + "/" + fullPath fmt.Fprintf(os.Stderr, "Partial tile failed, trying: %s\n", fullURL) tileData, err = fetchURL(fullURL) if err != nil { fatal("failed to fetch tile: %v", err) } fetchedPath = fullPath fmt.Fprintf(os.Stderr, "Successfully fetched full tile\n") } // Decompress if needed tileData, err = decompress(tileData) if err != nil { fatal("failed to decompress tile: %v", err) } 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 dumpAllEntries(tileData) } else { // Dump only the specific entry at the position dumpEntryAtPosition(tileData, int(positionInTile), leafIndex) } } func fetchURL(url string) ([]byte, error) { resp, err := http.Get(url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } return io.ReadAll(resp.Body) } func dumpAllEntries(tileData []byte) { entryNum := 0 for len(tileData) > 0 { e, remaining, err := sunlight.ReadTileLeaf(tileData) if err != nil { fatal("failed to read entry %d: %v", entryNum, err) } tileData = remaining dumpEntry(e, entryNum) fmt.Println() entryNum++ } fmt.Printf("Total entries: %d\n", entryNum) } func dumpEntryAtPosition(tileData []byte, position int, expectedIndex int64) { entryNum := 0 for len(tileData) > 0 { e, remaining, err := sunlight.ReadTileLeaf(tileData) if err != nil { fatal("failed to read entry %d: %v", entryNum, err) } tileData = remaining if entryNum == position { if e.LeafIndex != expectedIndex { fmt.Fprintf(os.Stderr, "WARNING: Expected leaf index %d but found %d at position %d\n", expectedIndex, e.LeafIndex, position) } dumpEntry(e, entryNum) return } entryNum++ } fatal("position %d not found in tile (only %d entries)", position, entryNum) } func dumpEntry(e *sunlight.LogEntry, entryNum int) { fmt.Printf("=== Entry %d ===\n", entryNum) fmt.Printf("Leaf Index: %d\n", e.LeafIndex) fmt.Printf("Timestamp: %d\n", e.Timestamp) fmt.Printf("Is Precert: %v\n", e.IsPrecert) if e.IsPrecert { fmt.Printf("Issuer Key Hash: %x\n", e.IssuerKeyHash) } fmt.Printf("Certificate: %d bytes\n", len(e.Certificate)) if e.PreCertificate != nil { fmt.Printf("PreCertificate: %d bytes\n", len(e.PreCertificate)) } fmt.Printf("Chain Fingerprints: %d entries\n", len(e.ChainFingerprints)) for i, fp := range e.ChainFingerprints { fmt.Printf(" [%d]: %x\n", i, fp) } // Try to extract parsed certificate info if trimmed, err := e.TrimmedEntry(); err == nil { if data, err := json.MarshalIndent(trimmed, " ", " "); err == nil { fmt.Printf("Parsed Certificate Info:\n %s\n", data) } } } const maxCompressRatio = 100 func decompress(data []byte) ([]byte, error) { r, err := gzip.NewReader(bytes.NewReader(data)) if err != nil { // Not gzipped, return as-is return data, nil } maxSize := int64(len(data)) * maxCompressRatio return io.ReadAll(io.LimitReader(r, maxSize)) } func fatal(format string, args ...any) { fmt.Fprintf(os.Stderr, "Error: "+format+"\n", args...) os.Exit(1) }