Fold tiledump into ctfetch. Add +sct, +issuer and +ctlog flags to print additional info
This commit is contained in:
64
README.md
64
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user