Files
ctfetch/internal/utils/utils.go
2026-01-12 22:48:15 +01:00

186 lines
5.0 KiB
Go

// Package utils provides shared functionality for dumping CT log tile entries.
// (C) Copyright 2026 Pim van Pelt <pim@ipng.ch>
package utils
import (
"bytes"
"compress/gzip"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"filippo.io/sunlight"
)
const maxCompressRatio = 100
// Entry represents a CT log entry in JSON format.
type Entry struct {
EntryNumber int `json:"entry_number"`
LeafIndex int64 `json:"leaf_index"`
Timestamp int64 `json:"timestamp"`
IsPrecert bool `json:"is_precert"`
IssuerKeyHash string `json:"issuer_key_hash,omitempty"`
CertificateSize int `json:"certificate_size"`
PreCertificateSize *int `json:"precertificate_size,omitempty"`
ChainFingerprints []string `json:"chain_fingerprints"`
ParsedCertInfo json.RawMessage `json:"parsed_cert_info,omitempty"`
}
// HashTileOutput represents a hash tile in JSON format.
type HashTileOutput struct {
NumHashes int `json:"num_hashes"`
Hashes []string `json:"hashes"`
}
// 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"`
}
// FetchURL fetches data from a URL.
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)
}
// 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))
if err != nil {
// Not gzipped, return as-is
return data, nil
}
maxSize := int64(len(data)) * maxCompressRatio
return io.ReadAll(io.LimitReader(r, maxSize))
}
// 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) {
// Try to read as data tile first
result, err := dumpDataTile(tileData)
if err != nil {
// If it fails, try as hash tile
fmt.Fprintf(os.Stderr, "Not a data tile, trying as hash tile...\n")
return dumpHashTile(tileData)
}
return result, nil
}
func dumpDataTile(tileData []byte) (*DumpResult, error) {
entryNum := 0
var entries []Entry
for len(tileData) > 0 {
e, remaining, err := sunlight.ReadTileLeaf(tileData)
if err != nil {
return nil, fmt.Errorf("failed to read entry %d: %w", entryNum, err)
}
tileData = remaining
entry := convertEntry(e, entryNum)
entries = append(entries, entry)
entryNum++
}
return &DumpResult{
Entries: entries,
TotalEntries: entryNum,
}, nil
}
func dumpHashTile(tileData []byte) (*DumpResult, error) {
const hashSize = 32 // SHA-256 hash size
if len(tileData)%hashSize != 0 {
return nil, fmt.Errorf("invalid hash tile: size %d is not a multiple of %d", len(tileData), hashSize)
}
numHashes := len(tileData) / hashSize
hashes := make([]string, numHashes)
for i := 0; i < numHashes; i++ {
hash := tileData[i*hashSize : (i+1)*hashSize]
hashes[i] = hex.EncodeToString(hash)
}
return &DumpResult{
HashTile: &HashTileOutput{
NumHashes: numHashes,
Hashes: hashes,
},
}, nil
}
// DumpEntryAtPosition reads and returns a specific entry at the given position.
func DumpEntryAtPosition(tileData []byte, position int, expectedIndex int64) (*Entry, error) {
entryNum := 0
for len(tileData) > 0 {
e, remaining, err := sunlight.ReadTileLeaf(tileData)
if err != nil {
return nil, fmt.Errorf("failed to read entry %d: %w", 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)
}
entry := convertEntry(e, entryNum)
return &entry, nil
}
entryNum++
}
return nil, fmt.Errorf("position %d not found in tile (only %d entries)", position, entryNum)
}
func convertEntry(e *sunlight.LogEntry, entryNum int) Entry {
entry := Entry{
EntryNumber: entryNum,
LeafIndex: e.LeafIndex,
Timestamp: e.Timestamp,
IsPrecert: e.IsPrecert,
CertificateSize: len(e.Certificate),
}
if e.IsPrecert {
entry.IssuerKeyHash = hex.EncodeToString(e.IssuerKeyHash[:])
}
if e.PreCertificate != nil {
size := len(e.PreCertificate)
entry.PreCertificateSize = &size
}
// Convert chain fingerprints to hex strings
entry.ChainFingerprints = make([]string, len(e.ChainFingerprints))
for i, fp := range e.ChainFingerprints {
entry.ChainFingerprints[i] = hex.EncodeToString(fp[:])
}
// Try to extract parsed certificate info
if trimmed, err := e.TrimmedEntry(); err == nil {
if data, err := json.Marshal(trimmed); err == nil {
entry.ParsedCertInfo = data
}
}
return entry
}