carlos / platform Public

ledger: Verify — re-hash the chain from HEAD to genesis, store or HTTP

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9a5fd0dfe8ba9f9611033bd8a88d3590d7e160bb parent 8b8734a
2 files changed, +164 −0
  • internal/ledger/verify.go +83 −0
  • internal/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)
+ }
+}