ledger: Verify — re-hash the chain from HEAD to genesis, store or HTTP
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+164
−0
internal/ledger/verify.go+83 −0internal/ledger/verify_test.go+81 −0
diff --git a/internal/ledger/verify.go b/internal/ledger/verify.go| new file mode 100644 |
| index 000000000..5547ce44c |
| --- /dev/null |
| +++ b/internal/ledger/verify.go |
| @@ -0,0 +1,83 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "io" |
| + "net/http" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type Getter func(ctx context.Context, key string) ([]byte, error) |
| + |
| +func StoreGetter(st release.Store) Getter { |
| + return func(ctx context.Context, key string) ([]byte, error) { |
| + b, _, err := st.Get(ctx, key) |
| + return b, err |
| + } |
| +} |
| + |
| +func HTTPGetter(client *http.Client, base string) Getter { |
| + return func(ctx context.Context, key string) ([]byte, error) { |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/"+key, nil) |
| + if err != nil { |
| + return nil, err |
| + } |
| + resp, err := client.Do(req) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return nil, fmt.Errorf("ledger: GET %s: %s", key, resp.Status) |
| + } |
| + return io.ReadAll(io.LimitReader(resp.Body, MaxBlob+1)) |
| + } |
| +} |
| + |
| +type VerifyResult struct { |
| + Count int |
| + HeadSHA256 string |
| +} |
| + |
| +// Verify walks HEAD back to the genesis entry, re-hashing every stored |
| +// byte. It proves: HEAD's sha matches the head entry's bytes, every |
| +// prev_sha256 matches the previous entry's bytes, the walk terminates at |
| +// prev_key "", and the walked length equals HEAD's count. |
| +func Verify(ctx context.Context, get Getter, sqid, app, name string) (VerifyResult, error) { |
| + hb, err := get(ctx, HeadKey(sqid, app, name)) |
| + if err != nil { |
| + return VerifyResult{}, err |
| + } |
| + var head Head |
| + if err := json.Unmarshal(hb, &head); err != nil { |
| + return VerifyResult{}, fmt.Errorf("ledger: HEAD corrupt: %w", err) |
| + } |
| + |
| + key, wantSHA := head.HeadKey, head.HeadSHA256 |
| + walked := 0 |
| + for key != "" { |
| + if walked >= head.Count { |
| + return VerifyResult{}, fmt.Errorf("ledger: chain longer than HEAD count %d", head.Count) |
| + } |
| + eb, err := get(ctx, key) |
| + if err != nil { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s: %w", key, err) |
| + } |
| + if got := SHA256Hex(eb); got != wantSHA { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s: sha %s, chain says %s — bytes were altered", key, got, wantSHA) |
| + } |
| + var e Entry |
| + if err := json.Unmarshal(eb, &e); err != nil { |
| + return VerifyResult{}, fmt.Errorf("ledger: %s corrupt: %w", key, err) |
| + } |
| + key, wantSHA = e.PrevKey, e.PrevSHA256 |
| + walked++ |
| + } |
| + if walked != head.Count { |
| + return VerifyResult{}, fmt.Errorf("ledger: walked %d entries, HEAD count %d", walked, head.Count) |
| + } |
| + return VerifyResult{Count: walked, HeadSHA256: head.HeadSHA256}, nil |
| +} |
diff --git a/internal/ledger/verify_test.go b/internal/ledger/verify_test.go| new file mode 100644 |
| index 000000000..c3916aa92 |
| --- /dev/null |
| +++ b/internal/ledger/verify_test.go |
| @@ -0,0 +1,81 @@ |
| +package ledger |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func chainOf(t *testing.T, st release.Store, n int) { |
| + t.Helper() |
| + for i := range n { |
| + at := func() time.Time { return fixedNow().Add(time.Duration(i) * time.Minute) } |
| + if _, err := Append(context.Background(), st, "bab", "platform", "carbon", |
| + "w", json.RawMessage(`{"i":`+string(rune('0'+i))+`}`), at); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| +} |
| + |
| +func TestVerifyGoodChain(t *testing.T) { |
| + st := release.NewDirStore(t.TempDir()) |
| + chainOf(t, st, 3) |
| + res, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if res.Count != 3 { |
| + t.Fatalf("count = %d, want 3", res.Count) |
| + } |
| +} |
| + |
| +func TestVerifyDetectsTamper(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + chainOf(t, st, 3) |
| + // Flip one byte in the OLDEST entry's stored file, behind the store's |
| + // back — exactly the mutation a hash chain exists to catch. |
| + keys, err := st.List(context.Background(), Prefix("bab", "platform", "carbon")+"entries/") |
| + if err != nil || len(keys) != 3 { |
| + t.Fatalf("entries: %v %v", keys, err) |
| + } |
| + p := filepath.Join(dir, filepath.FromSlash(keys[0])) |
| + b, err := os.ReadFile(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b[len(b)-2] ^= 1 |
| + if err := os.WriteFile(p, b, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := Verify(context.Background(), StoreGetter(st), "bab", "platform", "carbon"); err == nil { |
| + t.Fatal("tampered chain verified clean") |
| + } |
| +} |
| + |
| +func TestVerifyOverHTTP(t *testing.T) { |
| + dir := t.TempDir() |
| + st := release.NewDirStore(dir) |
| + chainOf(t, st, 2) |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + b, _, err := st.Get(r.Context(), strings.TrimPrefix(r.URL.Path, "/")) |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Write(b) |
| + })) |
| + defer ts.Close() |
| + res, err := Verify(context.Background(), HTTPGetter(ts.Client(), ts.URL), "bab", "platform", "carbon") |
| + if err != nil || res.Count != 2 { |
| + t.Fatalf("res=%+v err=%v", res, err) |
| + } |
| +} |