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 <noreply@anthropic.com>
This commit is contained in:
13
README.md
13
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
|
## Operator and Admin Endpoints
|
||||||
|
|
||||||
System-key clients are created with `attesto.NewClient`. Tenant/operator
|
System-key clients are created with `attesto.NewClient`. Tenant/operator
|
||||||
|
|||||||
142
proofstream.go
142
proofstream.go
@@ -297,6 +297,148 @@ func VerifyMetadataCommitment(metadata any, event map[string]any) (bool, error)
|
|||||||
return commitment["canonical_metadata_hash"] == stored, nil
|
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) {
|
func SignConnectorWebhookPayload(secret string, body []byte, timestamp int64) (string, string) {
|
||||||
if timestamp == 0 {
|
if timestamp == 0 {
|
||||||
timestamp = time.Now().Unix()
|
timestamp = time.Now().Unix()
|
||||||
|
|||||||
@@ -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) {
|
func TestParityVerifyPayloadCommitment(t *testing.T) {
|
||||||
for _, c := range loadParityVectors(t).Accept {
|
for _, c := range loadParityVectors(t).Accept {
|
||||||
commitment, err := PayloadCommitment(c.Payload)
|
commitment, err := PayloadCommitment(c.Payload)
|
||||||
|
|||||||
Reference in New Issue
Block a user