Initial checkin
This commit is contained in:
200
main.go
Normal file
200
main.go
Normal file
@@ -0,0 +1,200 @@
|
||||
// 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 <pim@ipng.ch>
|
||||
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] <log-url> <leaf-index>\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)
|
||||
}
|
||||
Reference in New Issue
Block a user