From a6a14e5fbb7729c32d89708ffea5db961d2cf31e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 11 Jun 2026 14:29:48 +0200 Subject: [PATCH] sdk(P1.3): inclusion, checkpoint-chain, and completeness verification Completes the offline verification stack (P1.2 -> P1.1 -> P1.3) in all three SDKs, each a faithful port of the backend windows.py / checkpoints.py math on top of the frozen canonical/domain-hash primitives: - verify_inclusion_proof: fold a window inclusion proof to the window root (domain attesto.v2.window; left sibling -> node(sibling,current), right -> node(current,sibling)). - verify_checkpoint_root: recompute a checkpoint root from window hashes (domain attesto.v2.checkpoint), with an odd node at any level **promoted unchanged** rather than duplicated/hashed with itself (the place a naive Merkle port silently diverges). - verify_checkpoint_extension: current.from_seq_no == previous.to_seq_no + 1 and current.previous_checkpoint_hash == previous.checkpoint_hash. - verify_completeness: proves no events were omitted in a range -- gap-free seq_no coverage plus prev_event_hash chaining to the previous event_hash. New corpus golden-vectors/sdk-parity/inclusion.json (5-leaf window exercising the promoted odd node, 3-window checkpoint root, extension + completeness negatives), exported from the backend functions. Proven: Python = TypeScript = Go = backend agree on every case. READMEs updated per SDK. Co-Authored-By: Claude Fable 5 --- README.md | 13 ++++ proofstream.go | 142 +++++++++++++++++++++++++++++++++++++ proofstream_parity_test.go | 71 +++++++++++++++++++ 3 files changed, 226 insertions(+) diff --git a/README.md b/README.md index 5bb1cde..1868b66 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,19 @@ if !report.OK { } ``` +The offline trust model extends across the whole proof chain — all client-side: + +```go +ok, _ := attesto.VerifyInclusionProof(leafHash, proof, windowRoot) // event in a window root +ok, _ = attesto.VerifyCheckpointRoot(windowHashes, checkpointRoot) // windows fold to checkpoint root +ext := attesto.VerifyCheckpointExtension(previous, current) // one checkpoint continues the previous +comp := attesto.VerifyCompleteness(events, 5, 8) // no events omitted in [5, 8] +``` + +`VerifyCompleteness` proves **no events were omitted** in a range: the sequence +numbers must be gap-free and each event's `prev_event_hash` must chain to the +previous event's `event_hash`. + ## Operator and Admin Endpoints System-key clients are created with `attesto.NewClient`. Tenant/operator diff --git a/proofstream.go b/proofstream.go index 1f78def..de87d59 100644 --- a/proofstream.go +++ b/proofstream.go @@ -297,6 +297,148 @@ func VerifyMetadataCommitment(metadata any, event map[string]any) (bool, error) return commitment["canonical_metadata_hash"] == stored, nil } +func windowNodeHash(left, right string) (string, error) { + return DomainHashHex(ProofstreamDomains["window"], map[string]any{ + "kind": "node", "left_hash": left, "right_hash": right, + }) +} + +func checkpointNodeHash(left, right string) (string, error) { + return DomainHashHex(ProofstreamDomains["checkpoint"], map[string]any{ + "kind": "node", "left_hash": left, "right_hash": right, + }) +} + +// InclusionStep is one node of a window inclusion proof. +type InclusionStep struct { + Side string `json:"side"` + Hash string `json:"hash"` +} + +// VerifyInclusionProof folds a window inclusion proof from a leaf up to the +// window root. Mirrors verify_inclusion_proof in the backend windows.py: a left +// sibling hashes as node(sibling, current), a right sibling as node(current, sibling). +func VerifyInclusionProof(leafHash string, proof []InclusionStep, rootHash string) (bool, error) { + current := leafHash + for _, step := range proof { + var err error + switch step.Side { + case "left": + if step.Hash == "" { + return false, nil + } + current, err = windowNodeHash(step.Hash, current) + case "right": + if step.Hash == "" { + return false, nil + } + current, err = windowNodeHash(current, step.Hash) + default: + return false, nil + } + if err != nil { + return false, err + } + } + return current == rootHash, nil +} + +// VerifyCheckpointRoot recomputes a checkpoint root from its window hashes and +// compares. Mirrors checkpoint_root_hash in the backend checkpoints.py: an odd +// node at any level is promoted unchanged (never duplicated/hashed with itself). +func VerifyCheckpointRoot(windowHashes []string, expectedRoot string) (bool, error) { + if len(windowHashes) == 0 { + return false, nil + } + level := append([]string{}, windowHashes...) + for len(level) > 1 { + next := make([]string, 0, (len(level)+1)/2) + for offset := 0; offset < len(level); offset += 2 { + if offset+1 >= len(level) { + next = append(next, level[offset]) // promote, do not duplicate + } else { + h, err := checkpointNodeHash(level[offset], level[offset+1]) + if err != nil { + return false, err + } + next = append(next, h) + } + } + level = next + } + return level[0] == expectedRoot, nil +} + +// VerifyCheckpointExtension checks that current continues previous: contiguous +// sequence (current.from_seq_no == previous.to_seq_no + 1) and back-link +// (current.previous_checkpoint_hash == previous.checkpoint_hash). +func VerifyCheckpointExtension(previous, current map[string]any) VerifyReport { + problems := make([]string, 0) + if asFloat(current["from_seq_no"]) != asFloat(previous["to_seq_no"])+1 { + problems = append(problems, "checkpoint does not extend previous (sequence gap)") + } + if !sameString(current["previous_checkpoint_hash"], previous["checkpoint_hash"]) { + problems = append(problems, "checkpoint previous_checkpoint_hash does not match previous") + } + return VerifyReport{Kind: "checkpoint-extension", OK: len(problems) == 0, Problems: problems} +} + +// VerifyCompleteness proves no events were omitted in [fromSeqNo, toSeqNo]: the +// sequence numbers must be gap-free and every event's prev_event_hash must equal +// the previous event's event_hash (the per-stream hash chain). +func VerifyCompleteness(events []map[string]any, fromSeqNo, toSeqNo int) VerifyReport { + problems := make([]string, 0) + ordered := append([]map[string]any{}, events...) + sort.Slice(ordered, func(i, j int) bool { + return asFloat(ordered[i]["seq_no"]) < asFloat(ordered[j]["seq_no"]) + }) + gapFree := len(ordered) == toSeqNo-fromSeqNo+1 + if gapFree { + for i, event := range ordered { + if int(asFloat(event["seq_no"])) != fromSeqNo+i { + gapFree = false + break + } + } + } + if !gapFree { + problems = append(problems, "sequence range is not gap-free") + } else { + for i := 1; i < len(ordered); i++ { + if !sameString(ordered[i]["prev_event_hash"], ordered[i-1]["event_hash"]) { + problems = append(problems, fmt.Sprintf( + "event chain broken at seq_no %d", int(asFloat(ordered[i]["seq_no"])))) + break + } + } + } + return VerifyReport{Kind: "completeness", OK: len(problems) == 0, Problems: problems} +} + +func asFloat(v any) float64 { + switch n := v.(type) { + case float64: + return n + case int: + return float64(n) + case int64: + return float64(n) + case json.Number: + f, _ := n.Float64() + return f + } + return 0 +} + +func sameString(a, b any) bool { + as, aok := a.(string) + bs, bok := b.(string) + if aok && bok { + return as == bs + } + return a == nil && b == nil +} + func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) { if timestamp == 0 { timestamp = time.Now().Unix() diff --git a/proofstream_parity_test.go b/proofstream_parity_test.go index 630670a..61511d5 100644 --- a/proofstream_parity_test.go +++ b/proofstream_parity_test.go @@ -109,6 +109,77 @@ func TestParityReceiptVerification(t *testing.T) { } } +type inclusionParityVectors struct { + Inclusion []struct { + ID string `json:"id"` + LeafHash string `json:"leaf_hash"` + Proof []InclusionStep `json:"proof"` + RootHash string `json:"root_hash"` + ExpectOK bool `json:"expect_ok"` + } `json:"inclusion"` + CheckpointRoot []struct { + ID string `json:"id"` + WindowHashes []string `json:"window_hashes"` + ExpectedRoot string `json:"expected_root"` + ExpectOK bool `json:"expect_ok"` + } `json:"checkpoint_root"` + CheckpointExtension []struct { + ID string `json:"id"` + Previous map[string]any `json:"previous"` + Current map[string]any `json:"current"` + ExpectOK bool `json:"expect_ok"` + } `json:"checkpoint_extension"` + Completeness []struct { + ID string `json:"id"` + Events []map[string]any `json:"events"` + FromSeqNo int `json:"from_seq_no"` + ToSeqNo int `json:"to_seq_no"` + ExpectOK bool `json:"expect_ok"` + } `json:"completeness"` +} + +func TestParityInclusionAndCheckpoint(t *testing.T) { + path := filepath.Join("..", "..", "golden-vectors", "sdk-parity", "inclusion.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read inclusion vectors: %v", err) + } + var v inclusionParityVectors + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatalf("decode inclusion vectors: %v", err) + } + for _, c := range v.Inclusion { + ok, err := VerifyInclusionProof(c.LeafHash, c.Proof, c.RootHash) + if err != nil { + t.Fatalf("%s: %v", c.ID, err) + } + if ok != c.ExpectOK { + t.Errorf("inclusion %s: ok=%v want %v", c.ID, ok, c.ExpectOK) + } + } + for _, c := range v.CheckpointRoot { + ok, err := VerifyCheckpointRoot(c.WindowHashes, c.ExpectedRoot) + if err != nil { + t.Fatalf("%s: %v", c.ID, err) + } + if ok != c.ExpectOK { + t.Errorf("checkpoint_root %s: ok=%v want %v", c.ID, ok, c.ExpectOK) + } + } + for _, c := range v.CheckpointExtension { + report := VerifyCheckpointExtension(c.Previous, c.Current) + if report.OK != c.ExpectOK { + t.Errorf("extension %s: ok=%v want %v (%v)", c.ID, report.OK, c.ExpectOK, report.Problems) + } + } + for _, c := range v.Completeness { + report := VerifyCompleteness(c.Events, c.FromSeqNo, c.ToSeqNo) + if report.OK != c.ExpectOK { + t.Errorf("completeness %s: ok=%v want %v (%v)", c.ID, report.OK, c.ExpectOK, report.Problems) + } + } +} + func TestParityVerifyPayloadCommitment(t *testing.T) { for _, c := range loadParityVectors(t).Accept { commitment, err := PayloadCommitment(c.Payload)