diff --git a/.gitignore b/.gitignore index 4711883..8f46747 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Go binaries -/cmd +/tlog-verify /proxy /client /ct diff --git a/cmd/tlog-verify/README.md b/cmd/tlog-verify/README.md new file mode 100644 index 0000000..bdd5878 --- /dev/null +++ b/cmd/tlog-verify/README.md @@ -0,0 +1,73 @@ +# tlog-verify + +`tlog-verify` is a command-line tool that performs offline verification of transparency log proofs formatted according to the [c2sp.org/tlog-proof](https://c2sp.org/tlog-proof) specification. + +## Motivation + +Transparency logs allow verifiers to ensure that data has been publicly logged. A `tlog-proof` file bundles all the information needed to verify that a specific leaf exists in the log at a specific checkpoint: +1. The log checkpoint (signed by the log). +2. The index of the entry. +3. The Merkle inclusion proof. + +This tool allows verifying these proofs offline, given the log's public verifier key and the expected leaf data (or its hash). + +## Usage + +```shell +go run ./cmd/tlog-verify --log-key [flags] [proof-file] +``` + +If `proof-file` is omitted, the proof is read from standard input. + +### Flags + +* `--log-key`: (Required) Log verifier key (format: `name+hash+key`). +* `--origin`: (Optional) Expected log origin in checkpoint. Defaults to the name in the log key. +* `--leaf-hash`: Pre-computed leaf hash (hex or base64 encoded). +* `--leaf`: Raw leaf data string. +* `--leaf-file`: Path to file containing raw leaf data. + +Exactly one of `--leaf-hash`, `--leaf`, or `--leaf-file` must be specified. +If raw leaf data is provided (`--leaf` or `--leaf-file`), it is hashed using the RFC6962 leaf hashing strategy: `SHA256(0x00 || data)`. + +### Example: Go Checksum DB (SumDB) + +The Go Checksum DB (`sum.golang.org`) is a transparency log. You can use the [Woodpecker Web](../../woodpecker-web) viewer to browse the log and export proof and leaf files. + +For example, for entry `#43930254` (which contains `github.com/transparency-dev/tessera@v1.0.0`), you can export: +1. The proof bundle: `go.sum-database-tree-43930254.tlog-proof` +2. The raw leaf file: `go.sum-database-tree-43930254.raw` + +For convenience, these files are checked into the repository in the `testdata` directory. + +The public key for `sum.golang.org` is: +`sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8` + +Since Go SumDB uses `go.sum database tree` as the origin name in its checkpoints (which differs from the key name `sum.golang.org`), we must pass the `--origin` flag. + +#### Verification using raw leaf file (Recommended) + +This verifies that the actual content of the leaf matches what is in the log: + +```shell +go run ./cmd/tlog-verify \ + --log-key "sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8" \ + --origin "go.sum database tree" \ + --leaf-file cmd/tlog-verify/testdata/go.sum-database-tree-43930254.raw \ + cmd/tlog-verify/testdata/go.sum-database-tree-43930254.tlog-proof +``` + +#### Verification using pre-computed leaf hash + +This only verifies that the hash is present in the log (it does not verify the leaf content): + +The RFC6962 leaf hash for this Tessera entry is `D6V8URw7L/zAoFrWMXJWjSZar5hE6bY2oJeHlTlvKrE=` (base64). + +```shell +go run ./cmd/tlog-verify \ + --log-key "sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8" \ + --origin "go.sum database tree" \ + --leaf-hash "D6V8URw7L/zAoFrWMXJWjSZar5hE6bY2oJeHlTlvKrE=" \ + cmd/tlog-verify/testdata/go.sum-database-tree-43930254.tlog-proof +``` + diff --git a/cmd/tlog-verify/main.go b/cmd/tlog-verify/main.go new file mode 100644 index 0000000..2a2dea5 --- /dev/null +++ b/cmd/tlog-verify/main.go @@ -0,0 +1,186 @@ +// Copyright 2026 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/transparency-dev/formats/log" + "github.com/transparency-dev/formats/proof" + merkleproof "github.com/transparency-dev/merkle/proof" + "github.com/transparency-dev/merkle/rfc6962" + "golang.org/x/mod/sumdb/note" +) + +var ( + logKey = flag.String("log-key", "", "Log verifier key (required).") + origin = flag.String("origin", "", "Expected log origin in checkpoint (optional, defaults to name in log-key).") + leafHash = flag.String("leaf-hash", "", "Pre-computed leaf hash (hex or base64).") + leaf = flag.String("leaf", "", "Raw leaf data string.") + leafFile = flag.String("leaf-file", "", "Path to file containing raw leaf data.") +) + +func main() { + flag.Parse() + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Println("OK") +} + +func run() error { + if *logKey == "" { + return fmt.Errorf("--log-key is required") + } + + // Validate leaf inputs + leafInputs := 0 + if *leafHash != "" { + leafInputs++ + } + if *leaf != "" { + leafInputs++ + } + if *leafFile != "" { + leafInputs++ + } + + if leafInputs != 1 { + return fmt.Errorf("exactly one of --leaf-hash, --leaf, or --leaf-file must be provided") + } + + // Determine leaf hash + var computedLeafHash []byte + var err error + + if *leafHash != "" { + computedLeafHash, err = decodeLeafHash(*leafHash) + if err != nil { + return fmt.Errorf("invalid --leaf-hash: %w", err) + } + } else { + var leafData []byte + if *leaf != "" { + leafData = []byte(*leaf) + } else { + leafData, err = os.ReadFile(*leafFile) + if err != nil { + return fmt.Errorf("failed to read leaf file: %w", err) + } + } + // Apply RFC6962 leaf hashing: SHA256(0x00 || data) + h := rfc6962.DefaultHasher.HashLeaf(leafData) + computedLeafHash = h + } + + // Read proof + var proofBytes []byte + args := flag.Args() + if len(args) > 1 { + return fmt.Errorf("too many arguments; expected at most one proof file") + } + + if len(args) == 1 { + proofBytes, err = os.ReadFile(args[0]) + if err != nil { + return fmt.Errorf("failed to read proof file: %w", err) + } + } else { + proofBytes, err = io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read proof from stdin: %w", err) + } + } + + return verifyProof(*logKey, *origin, proofBytes, computedLeafHash) +} + +func decodeLeafHash(s string) ([]byte, error) { + // Try hex first (64 chars for SHA256) + if len(s) == 64 { + h, err := hex.DecodeString(s) + if err == nil && len(h) == 32 { + return h, nil + } + } + + // Try base64 + h, err := base64.StdEncoding.DecodeString(s) + if err == nil && len(h) == 32 { + return h, nil + } + + // Try base64 raw (unpadded) + h, err = base64.RawStdEncoding.DecodeString(s) + if err == nil && len(h) == 32 { + return h, nil + } + + return nil, fmt.Errorf("must be 32-byte hex or base64 encoded string") +} + +func verifyProof(logKeyStr, originStr string, proofBytes []byte, leafHash []byte) error { + // 1. Parse log key + verifier, err := note.NewVerifier(strings.TrimSpace(logKeyStr)) + if err != nil { + return fmt.Errorf("failed to parse log key: %w", err) + } + + // 2. Unmarshal proof + var p proof.TLogProof + if err := p.Unmarshal(proofBytes); err != nil { + return fmt.Errorf("failed to unmarshal proof: %w", err) + } + + expectedOrigin := originStr + if expectedOrigin == "" { + expectedOrigin = verifier.Name() + } + + // 3. Verify checkpoint signature + // note.Open is strict about trailing newlines. Clean them up. + cpBytes := bytes.TrimRight(p.Checkpoint, "\r\n ") + cpBytes = append(cpBytes, '\n') + + checkpoint, _, _, err := log.ParseCheckpoint(cpBytes, expectedOrigin, verifier) + if err != nil { + return fmt.Errorf("failed to verify checkpoint: %w", err) + } + + // 4. Verify inclusion proof + hashes := make([][]byte, len(p.Hashes)) + for i, h := range p.Hashes { + hashes[i] = h[:] + } + + // Convert root hash to slice + rootHash := checkpoint.Hash + + err = merkleproof.VerifyInclusion(rfc6962.DefaultHasher, p.Index, checkpoint.Size, leafHash, hashes, rootHash) + if err != nil { + return fmt.Errorf("inclusion proof verification failed: %w", err) + } + + return nil +} diff --git a/cmd/tlog-verify/main_test.go b/cmd/tlog-verify/main_test.go new file mode 100644 index 0000000..aa77376 --- /dev/null +++ b/cmd/tlog-verify/main_test.go @@ -0,0 +1,186 @@ +// Copyright 2026 Google LLC. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/transparency-dev/merkle/rfc6962" +) + +const ( + testLogKey = "sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8" + testOrigin = "go.sum database tree" + // RFC6962 leaf hash for tessera@v1.0.0 + testLeafHashHex = "0fa57c511c3b2ffcc0a05ad63172568d265aaf9844e9b636a0978795396f2ab1" +) + +func TestVerifyProof(t *testing.T) { + proofPath := filepath.Join("testdata", "go.sum-database-tree-43930254.tlog-proof") + proofBytes, err := os.ReadFile(proofPath) + if err != nil { + t.Fatalf("failed to read test proof file: %v", err) + } + + leafHash, err := decodeLeafHash(testLeafHashHex) + if err != nil { + t.Fatalf("failed to decode leaf hash: %v", err) + } + + wrongLeafHash := make([]byte, len(leafHash)) + copy(wrongLeafHash, leafHash) + wrongLeafHash[0] ^= 0xFF + + wrongLogKey := "sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn9" + + corruptedProof := make([]byte, len(proofBytes)) + copy(corruptedProof, proofBytes) + corruptedProof = corruptedProof[:len(corruptedProof)-50] + + proofWithNewlines := append(proofBytes, []byte("\n\n\n")...) + + tests := []struct { + name string + logKey string + origin string + proof []byte + leafHash []byte + wantErr bool + }{ + { + name: "success", + logKey: testLogKey, + origin: testOrigin, + proof: proofBytes, + leafHash: leafHash, + wantErr: false, + }, + { + name: "wrong leaf hash", + logKey: testLogKey, + origin: testOrigin, + proof: proofBytes, + leafHash: wrongLeafHash, + wantErr: true, + }, + { + name: "wrong log key", + logKey: wrongLogKey, + origin: testOrigin, + proof: proofBytes, + leafHash: leafHash, + wantErr: true, + }, + { + name: "corrupted proof", + logKey: testLogKey, + origin: testOrigin, + proof: corruptedProof, + leafHash: leafHash, + wantErr: true, + }, + { + name: "success with trailing newlines", + logKey: testLogKey, + origin: testOrigin, + proof: proofWithNewlines, + leafHash: leafHash, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := verifyProof(tt.logKey, tt.origin, tt.proof, tt.leafHash) + if (err != nil) != tt.wantErr { + t.Errorf("verifyProof() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDecodeLeafHash(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid hex", + input: testLeafHashHex, + wantErr: false, + }, + { + name: "valid base64 padded", + input: "D6V8URw7L/zAoFrWMXJWjSZar5hE6bY2oJeHlTlvKrE=", + wantErr: false, + }, + { + name: "valid base64 unpadded", + input: "D6V8URw7L/zAoFrWMXJWjSZar5hE6bY2oJeHlTlvKrE", + wantErr: false, + }, + { + name: "invalid length hex", + input: testLeafHashHex + "00", + wantErr: true, + }, + { + name: "invalid chars hex", + input: testLeafHashHex[:63] + "g", + wantErr: true, + }, + { + name: "invalid base64", + input: "invalid-base64-string!!!", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := decodeLeafHash(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("decodeLeafHash() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) != 32 { + t.Errorf("decodeLeafHash() got length = %d, want 32", len(got)) + } + }) + } +} + +func TestVerifyProofWithRawLeaf(t *testing.T) { + rawLeafPath := filepath.Join("testdata", "go.sum-database-tree-43930254.raw") + rawLeaf, err := os.ReadFile(rawLeafPath) + if err != nil { + t.Fatalf("failed to read test raw leaf file: %v", err) + } + computedHash := rfc6962.DefaultHasher.HashLeaf(rawLeaf) + + proofPath := filepath.Join("testdata", "go.sum-database-tree-43930254.tlog-proof") + proofBytes, err := os.ReadFile(proofPath) + if err != nil { + t.Fatalf("failed to read test proof file: %v", err) + } + + err = verifyProof(testLogKey, testOrigin, proofBytes, computedHash) + if err != nil { + t.Errorf("expected verification with computed leaf hash to succeed, got: %v", err) + } +} diff --git a/cmd/tlog-verify/testdata/go.sum-database-tree-43930254.raw b/cmd/tlog-verify/testdata/go.sum-database-tree-43930254.raw new file mode 100644 index 0000000..e61bf4e --- /dev/null +++ b/cmd/tlog-verify/testdata/go.sum-database-tree-43930254.raw @@ -0,0 +1,2 @@ +github.com/transparency-dev/tessera v1.0.0 h1:4OT1V9xJLa5NnYlFWWlCdZkCm18/o12rdd+bCTje7XE= +github.com/transparency-dev/tessera v1.0.0/go.mod h1:TLvfjlkbmsmKVEJUtzO2eb9Q2IBnK3EJ0dI4G0oxEOU= diff --git a/cmd/tlog-verify/testdata/go.sum-database-tree-43930254.tlog-proof b/cmd/tlog-verify/testdata/go.sum-database-tree-43930254.tlog-proof new file mode 100644 index 0000000..00f5c57 --- /dev/null +++ b/cmd/tlog-verify/testdata/go.sum-database-tree-43930254.tlog-proof @@ -0,0 +1,34 @@ +c2sp.org/tlog-proof@v1 +index 43930254 +Q2cuq1e21q4UC1RQ6wtmXmCxk2q3Wqz/yFvD12P2KSM= +VdgHwjNYSv9fZVxvYzn8kySGvrjqeCnzdJqJILieWf4= +ARfBpZjsj3LSP42V4xBVdCKbHVa4WXSbuFAa0rUSCVQ= +0VYgz0Vmgz6jOM8H6edZBWHtO6+R7tPvN4EOwwRRSws= +vq+ifloynXE+ORFC1wymQT0cpNIJA1tqg2mQpA55+kc= +SCRewH6vHE1/Vwkt5yEW8xT7il0WbWltXjFIVqlGtQQ= +p//fIG8jgZie4MMwjHF7quCIJ4hErr7U4at0HV34MZQ= +D3K5YdBlxQcklrgF8vSbDyT05J5/mA9lzuhhp72uSOk= +jeYlYyH9FhYGwu+ZJmfPr8GvSNdgrydwqmJgHsygoR0= +p6RFgfGhu8Uuy0LyyZdI4MrSPOZkMe+ycwWJFfr8zTI= +AMZsxrVKBPSe8nSNVVCTyTxWns0CiNbChG/FoiSJWuM= +e+rtSmK5Pd1K5LgFwj7VSo5XJXOcq7OOcqPVZn1hqeM= +expmv1e6XiGr5NMUjMRkSZijlcBx+fZcpqQmUnawhOY= ++Wuic0YARH84k80/Q+KJIbhRCldxG8npjLFiHMLtEpo= +NOMeJqlyUxFXUKYRI/1YqAF4WJBfXJr8UM/SlWGqxgY= +Lm6+TAlLb2lvUs/3unZ2t6RSmJw8RO+WuEuUbAw3QEc= +Jhh2EDG15gDBoLmLiF88lVTgd+uvx3L0xczb8Y3kSq8= +taenaiKqtYMXS3gDzIF3q8VZzqAXqftHqL9BC39njaw= +kZNKypGwEANnt1+msLINnqrIrwX+0MGZUTTKXTfbloI= +A4NJSymjVJ5dvGPaGC3Gj04x8qVRKYfh0pP24mwd6Yk= +KQq808D9W4NUgr4ip+rF/RCyAUERYKKCwKiiAJfaFOc= +I3EVN3zM2rDjs5Q06Zr5gxhg+ksnX2h77E75uL+h3aI= +7iNSYSDhMeRkeV7r9krKbKelNPiqDCJU1i6xAn/Fbuw= +MYqdo6zSjxaAn+29EEjNRbPYm0myB3bheu+HTZIAbVY= +S7olT9TPLXk+bbawbV3sAzWMmhcpWrtjbw++9ZTiyn8= +xJ20xXM4QItt5vxFOtolZ5l18p7ej1+RimNkgWmoRFM= + +go.sum database tree +58253265 +lIyLAEe4zJUQAda6pFFg3yPOf7j1SgfMEDrpgoX39JU= + +— sum.golang.org Az3grkvBHbFcpEFQ3BhGlktgB7pcswIW+ez91eWY6rNPtWfAey8bJO2rVoG2yO5zXvgiaikWVHqon+e14JhA+WXvpAY= diff --git a/sumdb/proxy.go b/sumdb/proxy.go index 98c0bb0..38a3483 100644 --- a/sumdb/proxy.go +++ b/sumdb/proxy.go @@ -87,6 +87,8 @@ func newReverseProxy(opts ProxyOpts) *httputil.ReverseProxy { } else if after, ok := strings.CutPrefix(inPath, tlogEntriesPrefix); ok { o := after r.Out.URL.Path = fmt.Sprintf("%s%s", sumDBTileDataPrefix, o) + } else if strings.HasPrefix(inPath, "/tile/8/") { + r.Out.URL.Path = inPath } else if after, ok := strings.CutPrefix(inPath, tlogTilePrefix); ok { o := after r.Out.URL.Path = fmt.Sprintf("%s%s", sumDBTilePrefix, o) diff --git a/woodpecker-web/README.md b/woodpecker-web/README.md index 0e1f1b6..2af7b82 100644 --- a/woodpecker-web/README.md +++ b/woodpecker-web/README.md @@ -26,9 +26,20 @@ The tiles are expected to be formatted as a stream of Length-Value Payloads (LVP - **Entry Browser**: Lists entries with their index and size. - **Jump to Index**: Quickly navigate to a specific entry by index. - **Detail Modal**: Inspect entries in both interpreted (text/JSON) and raw hex formats. +- **Proof Exporter**: Generate and export a `tlog-proof` bundle for any entry, with a copyable verification command for `tlog-verify`. ### For Log Operators - **Customizable**: Contains a `LOG_CUSTOMIZER` object in the script to allow custom rendering of log entries to fit your specific log schema. +## Offline Proof Verification + +While Woodpecker Web is a convenient tool for browsing log entries, it runs in the browser and does not perform cryptographic verification of the log's integrity or the inclusion of entries. + +For security-sensitive operations, you can export a `tlog-proof` bundle for any entry and verify it offline. + +![Screenshot of Woodpecker Web Proof Export](./woodpecker-web-proof.png) + +The export dialog allows you to download both the `tlog-proof` bundle and the raw leaf data file. The proof can then be verified offline using the [tlog-verify](../cmd/tlog-verify) command-line tool. The dialog provides the exact command-line invocation to verify the proof using either the downloaded leaf file (recommended, as it verifies the actual content) or the pre-computed leaf hash. + ## Usage Copy `index.html` to the root of your `tlog-tiles` directory (next to `./checkpoint`, `./tile`, etc.). Customize the leaf renderer if viewing the bytes as a string isn't what you want. diff --git a/woodpecker-web/index.html b/woodpecker-web/index.html index cb5a2b4..8b4092e 100644 --- a/woodpecker-web/index.html +++ b/woodpecker-web/index.html @@ -12,7 +12,7 @@ .custom-scrollbar::-webkit-scrollbar { width: 6px; } .custom-scrollbar::-webkit-scrollbar-track { background: transparent; } .custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; border-radius: 10px; } - + @keyframes pulse-soft { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } @@ -26,7 +26,7 @@ .jump-highlight { animation: highlight-fade 2s ease-out forwards; } - + #sidebar { transition: transform 0.3s ease-in-out; } @media (max-width: 768px) { #sidebar.hidden-mobile { transform: translateX(-100%); } @@ -168,7 +168,7 @@

Entry Inspector

- +
@@ -179,7 +179,7 @@

Entry Inspector

--
- + @@ -187,7 +187,70 @@

Entry Inspector

Payload: 0 bytes
- +
+ + +
+
+
+ + + + @@ -198,6 +261,13 @@

Entry Inspector

* Edit this object to tune Woodpecker Web for your specific log's leaf data. ******************************************************************************/ const LOG_CUSTOMIZER = { + /** + * logKey + * The public verifier key for the log (format: name+hash+key). + * If provided, it will be pre-populated in the export verification command. + */ + logKey: "", + /** * renderLogEntry * @param {Uint8Array} bytes - The raw binary payload for a single log entry. @@ -312,7 +382,7 @@

Entry Inspector

async fetchTile(idx, targetIndex = null) { const encodedPath = this.formatTileIndex(idx); let url = `${this.config.tilePath}${encodedPath}`; - + const isFullTile = (idx + 1) * this.config.tileSize <= this.state.treeSize; if (!isFullTile) { const leavesCount = this.state.treeSize % this.config.tileSize; @@ -326,7 +396,7 @@

Entry Inspector

const res = await fetch(url); if (!res.ok) throw new Error(`Tile ${idx} not found at ${url}`); const buffer = await res.arrayBuffer(); - + this.log(`Received buffer: ${buffer.byteLength} bytes`); if (buffer.byteLength === 0) { this.log(`Warning: Received empty buffer for tile ${idx}`, true); @@ -334,7 +404,7 @@

Entry Inspector

const entries = this.parseLVP(new Uint8Array(buffer)); this.log(`Parsed ${entries.length} entries from uint16 stream`); - + this.renderEntries(entries, idx, targetIndex); } catch (e) { this.log(`Tile loading error: ${e.message}`, true); @@ -350,7 +420,7 @@

Entry Inspector

try { const length = dataView.getUint16(pointer, false); const start = pointer + 2; - + if (start + length > uint8Array.length) { this.log(`LVP Truncation: Expected ${length} bytes, but only ${uint8Array.length - start} remain.`, true); break; @@ -363,20 +433,20 @@

Entry Inspector

break; } } - + if (pointer < uint8Array.length && uint8Array.length - pointer > 0) { this.log(`Trailing data: ${uint8Array.length - pointer} bytes remain unparsed.`); } - + return records; }, renderEntries(records, tileIdx, targetIndex = null) { const container = document.getElementById('entries-container'); container.innerHTML = ''; - + document.getElementById('entries-subtitle').textContent = `Binary Stream Viewer | Tile #${tileIdx}`; - + if (records.length === 0) { container.innerHTML = `
@@ -396,11 +466,11 @@

Entry Inspector

const div = document.createElement('div'); div.id = `entry-${globalIdx}`; div.className = "group flex items-center justify-between p-3 bg-slate-900 border border-slate-800 rounded-xl hover:border-orange-500/50 cursor-pointer transition-all shadow-sm"; - + const interpreted = LOG_CUSTOMIZER.renderLogEntry(record); - + div.onclick = () => this.showDetail(globalIdx, record); - + if (globalIdx === targetIndex) { div.classList.add('jump-highlight'); targetElement = div; @@ -434,6 +504,7 @@

Entry Inspector

showDetail(idx, buffer) { this.state.currentEntryBytes = buffer; + this.state.currentIndex = idx; this.setInspectorTab('interpreted'); document.getElementById('detail-index').textContent = `#${idx}`; document.getElementById('detail-data').textContent = LOG_CUSTOMIZER.renderLogEntry(buffer); @@ -442,7 +513,7 @@

Entry Inspector

document.getElementById('detail-offset').textContent = buffer.byteLength; document.getElementById('detail-overlay').classList.remove('hidden'); }, - + showSignatureDetail(identity, sig) { document.getElementById('sig-identity').textContent = identity; document.getElementById('sig-data').textContent = sig; @@ -493,6 +564,161 @@

Local Origin Missing

`; }, + async exportProof() { + const idx = this.state.currentIndex; + + document.getElementById('proof-overlay').classList.remove('hidden'); + document.getElementById('proof-loading').classList.remove('hidden'); + document.getElementById('proof-content').classList.add('hidden'); + document.getElementById('proof-status-message').classList.remove('hidden'); + document.getElementById('proof-status-message').textContent = "Initializing proof generation..."; + + try { + const cpResponse = await fetch(this.config.checkpointPath); + if (!cpResponse.ok) throw new Error("Failed to fetch checkpoint"); + const cpText = await cpResponse.text(); + + const cpLines = cpText.trim().split('\n'); + if (cpLines.length < 2) { + throw new Error("Malformed checkpoint fetched for proof"); + } + const treeSize = parseInt(cpLines[1], 10); + + const h = Math.round(Math.log2(this.config.tileSize)); + + let logRoot = ""; + const slashIdx = this.config.checkpointPath.lastIndexOf('/'); + if (slashIdx !== -1) { + logRoot = this.config.checkpointPath.substring(0, slashIdx + 1); + } + + const tileCache = {}; + const fetchTileBytes = async (path) => { + if (tileCache[path]) { + return tileCache[path]; + } + this.log(`Proof Gen: Fetching tile ${path}`); + document.getElementById('proof-status-message').textContent = `Fetching tile ${path}...`; + const res = await fetch(logRoot + path); + if (!res.ok) throw new Error(`Failed to fetch tile ${path}`); + const buf = await res.arrayBuffer(); + const bytes = new Uint8Array(buf); + tileCache[path] = bytes; + return bytes; + }; + + document.getElementById('proof-status-message').textContent = "Calculating proof path..."; + + const proofHashes = await ProofGenerator.generateProof(treeSize, idx, fetchTileBytes, h); + + const leafHashBytes = await computeLeafHash(this.state.currentEntryBytes); + const leafHashB64 = base64Encode(leafHashBytes); + + let proofBundle = "c2sp.org/tlog-proof@v1\n"; + proofBundle += `index ${idx}\n`; + for (const hash of proofHashes) { + proofBundle += base64Encode(hash) + "\n"; + } + proofBundle += "\n"; + proofBundle += cpText; + if (!cpText.endsWith('\n')) { + proofBundle += "\n"; + } + + this.state.currentProofBundle = proofBundle; + this.state.currentLeafHashB64 = leafHashB64; + + document.getElementById('proof-raw').textContent = proofBundle; + + let logKeyVal = LOG_CUSTOMIZER.logKey || ""; + if (!LOG_CUSTOMIZER.logKey) { + try { + const keyRes = await fetch(logRoot + 'key'); + if (keyRes.ok) { + const keyText = await keyRes.text(); + logKeyVal = keyText.trim(); + } + } catch (e) { + // ignore + } + } + + const originName = cpText.split('\n')[0]; + const safeOrigin = originName.replace(/[^a-zA-Z0-9.-]/g, '-').replace(/-+/g, '-'); + const filename = `${safeOrigin}-${idx}.tlog-proof`; + this.state.currentProofFilename = filename; + + const leafFilename = `${safeOrigin}-${idx}.raw`; + this.state.currentLeafFilename = leafFilename; + + const commandFile = `go run github.com/transparency-dev/incubator/cmd/tlog-verify@latest \\\n --log-key "${logKeyVal}" \\\n --origin "${originName}" \\\n --leaf-file "${leafFilename}" \\\n ${filename}`; + + const commandHash = `go run github.com/transparency-dev/incubator/cmd/tlog-verify@latest \\\n --log-key "${logKeyVal}" \\\n --origin "${originName}" \\\n --leaf-hash "${leafHashB64}" \\\n ${filename}`; + + this.state.currentVerifyCommandFile = commandFile; + this.state.currentVerifyCommandHash = commandHash; + + document.getElementById('proof-command-file').textContent = commandFile; + document.getElementById('proof-command-hash').textContent = commandHash; + + document.getElementById('proof-loading').classList.add('hidden'); + document.getElementById('proof-status-message').classList.add('hidden'); + document.getElementById('proof-content').classList.remove('hidden'); + + } catch (e) { + this.log(`Proof Gen Failed: ${e.message}`, true); + document.getElementById('proof-loading').classList.add('hidden'); + document.getElementById('proof-status-message').textContent = `Failed: ${e.message}`; + document.getElementById('proof-status-message').classList.remove('text-slate-400'); + document.getElementById('proof-status-message').classList.add('text-red-400', 'font-bold'); + } + }, + + closeProof() { + document.getElementById('proof-overlay').classList.add('hidden'); + }, + + copyProof() { + navigator.clipboard.writeText(this.state.currentProofBundle); + this.log("Proof copied to clipboard"); + }, + + downloadProof() { + const blob = new Blob([this.state.currentProofBundle], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = this.state.currentProofFilename || `proof-${this.state.currentIndex}.tlog-proof`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this.log("Proof downloaded"); + }, + + downloadLeaf() { + const blob = new Blob([this.state.currentEntryBytes], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = this.state.currentLeafFilename || `leaf-${this.state.currentIndex}.raw`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + this.log("Leaf downloaded"); + }, + + copyCommandFile() { + navigator.clipboard.writeText(this.state.currentVerifyCommandFile); + this.log("File verification command copied to clipboard"); + }, + + copyCommandHash() { + navigator.clipboard.writeText(this.state.currentVerifyCommandHash); + this.log("Hash verification command copied to clipboard"); + }, + setupListeners() { document.getElementById('search-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') { @@ -506,6 +732,279 @@

Local Origin Missing

} }; + /******************************************************************************* + * 🧮 Merkle Proof Generation Helpers (ported from golang.org/x/mod/sumdb/tlog) + ******************************************************************************/ + function maxpow2(n) { + let l = 0; + let k = 1n; + while (k * 2n < n) { + k *= 2n; + l++; + } + return { k, l }; + } + + function storedHashIndex(level, n) { + level = BigInt(level); + n = BigInt(n); + for (let l = level; l > 0n; l--) { + n = 2n * n + 1n; + } + let i = 0n; + while (n > 0n) { + i += n; + n >>= 1n; + } + return i + level; + } + + function splitStoredHashIndex(index) { + index = BigInt(index); + let n = index / 2n; + let indexN = storedHashIndex(0, n); + if (indexN > index) { + throw new Error("bad math"); + } + while (true) { + let tz = BigInt(trailingZeros64(n + 1n)); + let x = indexN + 1n + tz; + if (x > index) { + break; + } + n++; + indexN = x; + } + let level = Number(index - indexN); + return { level, n: n >> BigInt(level) }; + } + + function trailingZeros64(n) { + if (n === 0n) return 64; + let count = 0; + while ((n & 1n) === 0n) { + count++; + n >>= 1n; + } + return count; + } + + function tileForIndex(h, index) { + h = BigInt(h); + index = BigInt(index); + let { level, n } = splitStoredHashIndex(index); + level = BigInt(level); + let L = level / h; + let levelWithinTile = level - L * h; + let N = (n << levelWithinTile) >> h; + let nWithinTile = n - ((N << h) >> levelWithinTile); + let W = (nWithinTile + 1n) << levelWithinTile; + + const HashSize = 32n; + let start = (nWithinTile << levelWithinTile) * HashSize; + let end = ((nWithinTile + 1n) << levelWithinTile) * HashSize; + + return { + tile: { H: Number(h), L: Number(L), N: N, W: Number(W) }, + start: Number(start), + end: Number(end) + }; + } + + function tilePath(tile) { + let n = BigInt(tile.N); + const pathBase = 1000n; + let nStr = (n % pathBase).toString().padStart(3, '0'); + while (n >= pathBase) { + n /= pathBase; + nStr = (n % pathBase).toString().padStart(3, '0') + '/' + nStr; + } + const parts = nStr.split('/'); + for (let i = 0; i < parts.length - 1; i++) { + parts[i] = 'x' + parts[i]; + } + nStr = parts.join('/'); + + let pStr = ""; + let wFull = 2n ** BigInt(tile.H); + if (BigInt(tile.W) !== wFull) { + pStr = `.p/${tile.W}`; + } + let L = tile.L === -1 ? "data" : tile.L.toString(); + return `tile/${tile.H}/${L}/${nStr}${pStr}`; + } + + function tileParent(t, k, n) { + let tile = { ...t }; + tile.L += k; + tile.N = BigInt(tile.N) >> BigInt(k * tile.H); + tile.W = 1 << tile.H; + + let max = BigInt(n) >> BigInt(tile.L * tile.H); + let wBig = BigInt(tile.W); + let nShift = BigInt(tile.N) << BigInt(tile.H); + if (nShift + wBig >= max) { + if (nShift >= max) { + return null; + } + tile.W = Number(max - nShift); + } + return tile; + } + + async function nodeHash(left, right) { + const buf = new Uint8Array(1 + 32 + 32); + buf[0] = 0x01; + buf.set(left, 1); + buf.set(right, 1 + 32); + const hashBuffer = await crypto.subtle.digest('SHA-256', buf); + return new Uint8Array(hashBuffer); + } + + async function subTreeHash(lo, hi, hashes) { + lo = BigInt(lo); + hi = BigInt(hi); + let numTree = 0; + let tempLo = lo; + while (tempLo < hi) { + let { k } = maxpow2(hi - tempLo + 1n); + tempLo += k; + numTree++; + } + + if (hashes.length < numTree) { + throw new Error("bad index math in subTreeHash"); + } + + let subHashes = hashes.slice(0, numTree); + let remainingHashes = hashes.slice(numTree); + + let h = subHashes[numTree - 1]; + for (let i = numTree - 2; i >= 0; i--) { + h = await nodeHash(subHashes[i], h); + } + return { hash: h, remaining: remainingHashes }; + } + + async function leafProof(lo, hi, n, hashes) { + lo = BigInt(lo); + hi = BigInt(hi); + n = BigInt(n); + + if (lo + 1n === hi) { + return { proof: [], remaining: hashes }; + } + + let { k } = maxpow2(hi - lo); + let p, th, res; + if (n < lo + k) { + res = await leafProof(lo, lo + k, n, hashes); + p = res.proof; + res = await subTreeHash(lo + k, hi, res.remaining); + th = res.hash; + hashes = res.remaining; + } else { + res = await subTreeHash(lo, lo + k, hashes); + th = res.hash; + res = await leafProof(lo + k, hi, n, res.remaining); + p = res.proof; + hashes = res.remaining; + } + p.push(th); + return { proof: p, remaining: hashes }; + } + + async function computeLeafHash(dataBytes) { + const prefixed = new Uint8Array(1 + dataBytes.length); + prefixed[0] = 0x00; + prefixed.set(dataBytes, 1); + const hashBuffer = await crypto.subtle.digest('SHA-256', prefixed); + return new Uint8Array(hashBuffer); + } + + function base64Encode(bytes) { + let binary = ''; + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + async function tileHash(data) { + if (data.length === 0) { + throw new Error("bad math in tileHash"); + } + const HashSize = 32; + if (data.length === HashSize) { + return data; + } + const n = data.length / 2; + const left = await tileHash(data.slice(0, n)); + const right = await tileHash(data.slice(n)); + return await nodeHash(left, right); + } + + const ProofGenerator = { + async generateProof(treeSize, index, fetchTileBytes, h) { + const indexes = leafProofIndex(0, treeSize, index); + const hashes = []; + for (const idx of indexes) { + const { tile, start, end } = tileForIndex(h, idx); + const actualTile = tileParent(tile, 0, treeSize); + if (!actualTile) { + throw new Error(`Index ${idx} not in tree of size ${treeSize}`); + } + const path = tilePath(actualTile); + const tileData = await fetchTileBytes(path); + if (tileData.length < end) { + throw new Error(`Tile data too short for index ${idx}`); + } + const rawHashData = tileData.slice(start, end); + const singleHash = await tileHash(rawHashData); + hashes.push(singleHash); + } + const { proof } = await leafProof(0, treeSize, index, hashes); + return proof; + } + }; + + function leafProofIndex(lo, hi, n, need = []) { + lo = BigInt(lo); + hi = BigInt(hi); + n = BigInt(n); + if (!(lo <= n && n < hi)) { + throw new Error("bad math in leafProofIndex"); + } + if (lo + 1n === hi) { + return need; + } + let { k } = maxpow2(hi - lo); + if (n < lo + k) { + need = leafProofIndex(lo, lo + k, n, need); + need = subTreeIndex(lo + k, hi, need); + } else { + need = subTreeIndex(lo, lo + k, need); + need = leafProofIndex(lo + k, hi, n, need); + } + return need; + } + + function subTreeIndex(lo, hi, need = []) { + lo = BigInt(lo); + hi = BigInt(hi); + while (lo < hi) { + let { k, l } = maxpow2(hi - lo + 1n); + if ((lo & (k - 1n)) !== 0n) { + throw new Error("bad math in subTreeIndex"); + } + let level = l; + need.push(storedHashIndex(level, lo >> BigInt(level))); + lo += k; + } + return need; + } + window.onload = () => app.init(); diff --git a/woodpecker-web/woodpecker-web-proof.png b/woodpecker-web/woodpecker-web-proof.png new file mode 100644 index 0000000..ae8a263 Binary files /dev/null and b/woodpecker-web/woodpecker-web-proof.png differ