Skip to content

Commit d621874

Browse files
committed
cmd/{photocamera-archiver,vanity-mirror}: add RFC 6962 archival tools
1 parent da56c47 commit d621874

File tree

6 files changed

+806
-2
lines changed

6 files changed

+806
-2
lines changed
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// Command photocamera-archiver creates an archive of a Static Certificate
2+
// Transparency log, by compressing tiles into zip files, each containing a
3+
// subtree 16,777,216 entries wide (65,536 level -1 and 0 tiles, 256 level 1
4+
// tiles, and 1 level 2 tile). The checkpoint, JSON metadata, and level 3+ tiles
5+
// are left uncompressed. The zip files are stored at tile/zip/<N>.zip.
6+
// Unnecessary partial tiles at levels 3+ are also removed.
7+
//
8+
// After running this tool, archive the following files and directories:
9+
//
10+
// - checkpoint
11+
// - log.v3.json
12+
// - tile/zip/
13+
// - tile/3/
14+
// - tile/4/ (if present)
15+
// - issuer/
16+
package main
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"crypto/x509"
22+
"encoding/json"
23+
"fmt"
24+
"io/fs"
25+
"log/slog"
26+
"os"
27+
"os/signal"
28+
"slices"
29+
"strings"
30+
31+
"filippo.io/sunlight"
32+
"filippo.io/sunlight/internal/stdlog"
33+
"filippo.io/torchwood"
34+
"github.com/klauspost/compress/zip"
35+
"github.com/schollz/progressbar/v3"
36+
"golang.org/x/mod/sumdb/note"
37+
"golang.org/x/mod/sumdb/tlog"
38+
)
39+
40+
func main() {
41+
logger := slog.New(stdlog.Handler)
42+
43+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
44+
defer stop()
45+
46+
root, err := os.OpenRoot(".")
47+
if err != nil {
48+
fatalError(logger, "failed to open local directory", "err", err)
49+
}
50+
if err := root.MkdirAll("tile/zip", 0o755); err != nil {
51+
fatalError(logger, "failed to create zip directory", "err", err)
52+
}
53+
tr := localTileReader{root.FS()}
54+
55+
var logInfo struct {
56+
Description string `json:"description"`
57+
Key []byte `json:"key"`
58+
URL string `json:"url"`
59+
}
60+
logBytes, err := root.ReadFile("log.v3.json")
61+
if err != nil {
62+
fatalError(logger, "failed to read log info", "err", err)
63+
}
64+
if err := json.Unmarshal(logBytes, &logInfo); err != nil {
65+
fatalError(logger, "failed to parse log info", "err", err)
66+
}
67+
key, err := x509.ParsePKIXPublicKey(logInfo.Key)
68+
if err != nil {
69+
fatalError(logger, "failed to parse log public key", "err", err)
70+
}
71+
origin := strings.TrimPrefix(logInfo.URL, "https://")
72+
origin = strings.TrimSuffix(origin, "/")
73+
v, err := sunlight.NewRFC6962Verifier(origin, key)
74+
if err != nil {
75+
fatalError(logger, "failed to construct log verifier", "err", err)
76+
}
77+
checkpointBytes, err := root.ReadFile("checkpoint")
78+
if err != nil {
79+
fatalError(logger, "failed to read checkpoint", "err", err)
80+
}
81+
n, err := note.Open(checkpointBytes, note.VerifierList(v))
82+
if err != nil {
83+
fatalError(logger, "failed to verify checkpoint", "err", err)
84+
}
85+
c, err := torchwood.ParseCheckpoint(n.Text)
86+
if err != nil {
87+
fatalError(logger, "failed to parse checkpoint", "err", err)
88+
}
89+
logger.Info("loaded checkpoint", "tree_size", c.N, "root_hash", c.Hash)
90+
91+
hr := torchwood.TileHashReaderWithContext(ctx, c.Tree, tr)
92+
93+
for n := int64(0); n < c.N; n += 256 * 256 * 256 {
94+
i := n / (256 * 256 * 256)
95+
if i >= 1000 {
96+
fatalError(logger, "cannot archive more than 1000 zip files")
97+
}
98+
name := fmt.Sprintf("tile/zip/%03d.zip", i)
99+
subtree := min(256*256*256, c.N-n)
100+
logger.Info("processing subtree", "name", name, "start", n, "end", n+subtree)
101+
f, err := root.Create(name)
102+
if err != nil {
103+
fatalError(logger, "failed to create zip file", "name", name, "err", err)
104+
}
105+
w := zip.NewWriter(f)
106+
comment := fmt.Sprintf("%s %03d", c.Origin, i)
107+
if err := w.SetComment(comment); err != nil {
108+
fatalError(logger, "failed to set zip comment", "name", name, "err", err)
109+
}
110+
tiles := tlog.NewTiles(torchwood.TileHeight, n, n+subtree)
111+
pb := progressbar.Default(int64(len(tiles)), name)
112+
// Sort tiles in the zip files so that higher-level tiles come first.
113+
slices.SortStableFunc(tiles, func(a, b tlog.Tile) int {
114+
switch {
115+
case a.L < b.L:
116+
return 1
117+
case a.L > b.L:
118+
return -1
119+
default:
120+
return 0
121+
}
122+
})
123+
for _, tile := range tiles {
124+
if tile.L >= 3 {
125+
pb.Add(1)
126+
continue
127+
}
128+
path := sunlight.TilePath(tile)
129+
// Pull the hashes through TileHashReader instead of reading them
130+
// directly, so that their inclusion in the tree is verified.
131+
data, err := tlog.ReadTileData(tile, hr)
132+
if err != nil {
133+
fatalError(logger, "failed to read tile data", "tile", path, "err", err)
134+
}
135+
zf, err := w.CreateHeader(&zip.FileHeader{
136+
Name: path,
137+
Method: zip.Store, // hashes don't compress!
138+
})
139+
if err != nil {
140+
fatalError(logger, "failed to create zip entry", "tile", path, "err", err)
141+
}
142+
if _, err := zf.Write(data); err != nil {
143+
fatalError(logger, "failed to write zip entry", "tile", path, "err", err)
144+
}
145+
pb.Add(1)
146+
if err := ctx.Err(); err != nil {
147+
fatalError(logger, "interrupted", "err", err)
148+
}
149+
}
150+
pb.Reset()
151+
pb.ChangeMax64((subtree + 255) / 256)
152+
// Store data tiles after the Merkle tree tiles.
153+
for _, tile := range tiles {
154+
if tile.L != 0 {
155+
continue
156+
}
157+
tile.L = -1
158+
path := sunlight.TilePath(tile)
159+
data, err := root.ReadFile(path)
160+
if err != nil {
161+
fatalError(logger, "failed to read tile data", "tile", path, "err", err)
162+
}
163+
if err := verifyTileData(tile, data, hr); err != nil {
164+
fatalError(logger, "failed to verify tile data", "tile", path, "err", err)
165+
}
166+
zf, err := w.CreateHeader(&zip.FileHeader{
167+
Name: path,
168+
Method: zip.Deflate,
169+
})
170+
if err != nil {
171+
fatalError(logger, "failed to create zip entry", "tile", path, "err", err)
172+
}
173+
if _, err := zf.Write(data); err != nil {
174+
fatalError(logger, "failed to write zip entry", "tile", path, "err", err)
175+
}
176+
pb.Add(1)
177+
if err := ctx.Err(); err != nil {
178+
fatalError(logger, "interrupted", "err", err)
179+
}
180+
}
181+
if err := w.Close(); err != nil {
182+
fatalError(logger, "failed to finalize zip file", "name", name, "err", err)
183+
}
184+
if err := f.Close(); err != nil {
185+
fatalError(logger, "failed to close zip file", "name", name, "err", err)
186+
}
187+
pb.Exit()
188+
logger.Info("wrote zip file", "name", name)
189+
}
190+
191+
// Delete unnecessary tiles at level 3+, and verify the rest of them.
192+
for L := 3; L <= 5; L++ {
193+
levelDir := fmt.Sprintf("tile/%d", L)
194+
levelMaxSize := c.N >> (sunlight.TileHeight * L)
195+
if levelMaxSize == 0 {
196+
break
197+
}
198+
if err := fs.WalkDir(root.FS(), levelDir, func(path string, d fs.DirEntry, err error) error {
199+
if err != nil {
200+
return err
201+
}
202+
if d.IsDir() {
203+
return nil
204+
}
205+
t, err := sunlight.ParseTilePath(strings.TrimSuffix(path, ".p"))
206+
if err != nil {
207+
return fmt.Errorf("failed to parse tile path %s: %w", path, err)
208+
}
209+
if t.L != L {
210+
return fmt.Errorf("unexpected tile level %d, want %d", t.L, L)
211+
}
212+
size := t.N*sunlight.TileWidth + int64(t.W)
213+
if t.W != sunlight.TileWidth && size < levelMaxSize {
214+
// Partial tile, can be deleted.
215+
logger.Info("removing unnecessary partial tile", "tile", path,
216+
"size", size, "max", levelMaxSize)
217+
if err := os.Remove(path); err != nil {
218+
return fmt.Errorf("failed to remove tile %s: %w", path, err)
219+
}
220+
return nil
221+
}
222+
data, err := root.ReadFile(path)
223+
if err != nil {
224+
return fmt.Errorf("failed to read tile data %s: %w", path, err)
225+
}
226+
exp, err := tlog.ReadTileData(t, hr)
227+
if err != nil {
228+
return fmt.Errorf("failed to read tile data %s: %w", path, err)
229+
}
230+
if !bytes.Equal(data, exp) {
231+
return fmt.Errorf("tile data mismatch for %s", path)
232+
}
233+
logger.Info("verified tile", "tile", path)
234+
return nil
235+
}); err != nil {
236+
fatalError(logger, "failed to walk tile directory", "level", L, "err", err)
237+
}
238+
}
239+
240+
logger.Info("done")
241+
}
242+
243+
func verifyTileData(tile tlog.Tile, data []byte, hr tlog.HashReader) error {
244+
if tile.L != -1 {
245+
return fmt.Errorf("not a data tile")
246+
}
247+
indexes := make([]int64, 0, tile.W)
248+
for i := range tile.W {
249+
indexes = append(indexes, tlog.StoredHashIndex(0, tile.N*256+int64(i)))
250+
}
251+
hashes, err := hr.ReadHashes(indexes)
252+
if err != nil {
253+
return fmt.Errorf("failed to read record hashes: %w", err)
254+
}
255+
for i, h := range hashes {
256+
var e *sunlight.LogEntry
257+
e, data, err = sunlight.ReadTileLeaf(data)
258+
if err != nil {
259+
return fmt.Errorf("failed to read tile leaf: %w", err)
260+
}
261+
if !e.RFC6962ArchivalLeaf && e.LeafIndex != tile.N*256+int64(i) {
262+
return fmt.Errorf("unexpected leaf index %d, want %d", e.LeafIndex, tile.N*256+int64(i))
263+
}
264+
if rh := tlog.RecordHash(e.MerkleTreeLeaf()); rh != h {
265+
return fmt.Errorf("record hash mismatch at index %d", tile.N*256+int64(i))
266+
}
267+
}
268+
if len(data) != 0 {
269+
return fmt.Errorf("trailing data")
270+
}
271+
return nil
272+
}
273+
274+
type localTileReader struct{ fs.FS }
275+
276+
func (l localTileReader) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
277+
data = make([][]byte, len(tiles))
278+
for i, tile := range tiles {
279+
d, err := fs.ReadFile(l.FS, sunlight.TilePath(tile))
280+
if err != nil {
281+
return nil, err
282+
}
283+
data[i] = d
284+
}
285+
return data, nil
286+
}
287+
288+
func (l localTileReader) SaveTiles(tiles []tlog.Tile, data [][]byte) {}
289+
290+
func fatalError(logger *slog.Logger, msg string, args ...any) {
291+
logger.Error(msg, args...)
292+
os.Exit(1)
293+
}

cmd/vanity-mirror/tile.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"io/fs"
6+
7+
"filippo.io/sunlight"
8+
"filippo.io/torchwood"
9+
"golang.org/x/mod/sumdb/tlog"
10+
)
11+
12+
// TODO: consider moving this to torchwood.
13+
14+
func NewTiles(ctx context.Context, tr torchwood.TileReaderWithContext, old tlog.Tree,
15+
entries []tlog.Hash) (new tlog.Tree, tiles []tlog.Tile, data [][]byte, err error) {
16+
hashReader := torchwood.TileHashReaderWithContext(ctx, old, tr)
17+
hashReader = &hashReaderOverlay{
18+
HashReader: hashReader,
19+
overlays: make(map[int64]tlog.Hash),
20+
}
21+
22+
for i, h := range entries {
23+
n := old.N + int64(i)
24+
newHashes, err := tlog.StoredHashesForRecordHash(n, h, hashReader)
25+
if err != nil {
26+
return tlog.Tree{}, nil, nil, err
27+
}
28+
baseIdx := tlog.StoredHashIndex(0, n)
29+
for j, nh := range newHashes {
30+
idx := baseIdx + int64(j)
31+
hashReader.(*hashReaderOverlay).overlays[idx] = nh
32+
}
33+
}
34+
35+
n := old.N + int64(len(entries))
36+
tiles = tlog.NewTiles(torchwood.TileHeight, old.N, n)
37+
data = make([][]byte, len(tiles))
38+
for i, tile := range tiles {
39+
d, err := tlog.ReadTileData(tile, hashReader)
40+
if err != nil {
41+
return tlog.Tree{}, nil, nil, err
42+
}
43+
data[i] = d
44+
}
45+
46+
h, err := tlog.TreeHash(n, hashReader)
47+
if err != nil {
48+
return tlog.Tree{}, nil, nil, err
49+
}
50+
return tlog.Tree{N: n, Hash: h}, tiles, data, nil
51+
}
52+
53+
type hashReaderOverlay struct {
54+
tlog.HashReader
55+
overlays map[int64]tlog.Hash
56+
}
57+
58+
var _ tlog.HashReader = (*hashReaderOverlay)(nil)
59+
60+
func (h *hashReaderOverlay) ReadHashes(indexes []int64) ([]tlog.Hash, error) {
61+
results := make([]tlog.Hash, len(indexes))
62+
remaining := make([]int64, 0, len(indexes))
63+
for i, idx := range indexes {
64+
if v, ok := h.overlays[idx]; ok {
65+
results[i] = v
66+
} else {
67+
remaining = append(remaining, idx)
68+
}
69+
}
70+
if len(remaining) == 0 {
71+
return results, nil
72+
}
73+
fetched, err := h.HashReader.ReadHashes(remaining)
74+
if err != nil {
75+
return nil, err
76+
}
77+
j := 0
78+
for i, idx := range indexes {
79+
if _, ok := h.overlays[idx]; !ok {
80+
results[i] = fetched[j]
81+
j++
82+
}
83+
}
84+
return results, nil
85+
}
86+
87+
type localTileReader struct{ fs.FS }
88+
89+
func (l localTileReader) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
90+
data = make([][]byte, len(tiles))
91+
for i, tile := range tiles {
92+
d, err := fs.ReadFile(l.FS, sunlight.TilePath(tile))
93+
if err != nil {
94+
return nil, err
95+
}
96+
data[i] = d
97+
}
98+
return data, nil
99+
}
100+
101+
func (l localTileReader) SaveTiles(tiles []tlog.Tile, data [][]byte) {}

0 commit comments

Comments
 (0)