From 7b34da7c788ddf23e6a088bc1ffae5989c216327 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 22 Aug 2026 12:52:22 +0200 Subject: [PATCH] feat(sdk): verify disclosures in Go and TypeScript against the same bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three clients now verify a presentation the Local Vault really built through the real edge core. The fixture is checked in rather than written to satisfy the verifiers: three implementations agreeing with each other proves less than three agreeing with the producer. The fixture is generated once and not regenerated on every run — a disclosure carries fresh randomizers and a fresh signature, so comparing regenerated bytes would fail by design. Drift is caught the other way round: the Local Vault's own verifier checks the checked-in fixture, so a format change makes the producer reject its own past output. CI runs that. `bytesForSubtle` and `hexToBytes` move from private to exported in the TypeScript proofstream module rather than being duplicated. Two hex decoders that could disagree is a worse outcome than one shared internal helper. Drift testing found that **nothing tested inclusion at all**. Removing the two-hop check left every disclosure test passing in all three languages: a tampered value was caught by the commitment check, a tampered signature by the signature check, but a leaf belonging to an entirely different capsule would have been accepted. That is the one thing a disclosure is for. Each SDK now has a test that corrupts a sibling in the subtree path and another in the top path, leaving value and randomizer untouched so only the fold can catch it. Corpus coverage 26/26 and 12/12 in all three languages. Co-Authored-By: Claude Opus 5 (1M context) --- disclosure_verification_test.go | 218 +++++++++++++++++++++++++ provenance.go | 273 ++++++++++++++++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 disclosure_verification_test.go diff --git a/disclosure_verification_test.go b/disclosure_verification_test.go new file mode 100644 index 0000000..50abe49 --- /dev/null +++ b/disclosure_verification_test.go @@ -0,0 +1,218 @@ +package attesto + +// Go parity on offline disclosure verification. +// +// The fixture is a presentation the Local Vault really built through the real +// edge core. Three implementations agreeing with each other proves less than +// three agreeing with the producer, which is why it is not written to satisfy +// the verifiers. + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func loadPresentation(t *testing.T) map[string]any { + t.Helper() + path := filepath.Join( + "..", "..", "golden-vectors", "provenance-v0.1-dev", + "provenance-disclosure-presentation-valid.json", + ) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read fixture: %v", err) + } + var vector map[string]any + if err := json.Unmarshal(raw, &vector); err != nil { + t.Fatalf("parse fixture: %v", err) + } + return vector +} + +func presentationCopy(t *testing.T, vector map[string]any) map[string]any { + t.Helper() + raw, err := json.Marshal(vector["presentation"]) + if err != nil { + t.Fatalf("copy: %v", err) + } + var copied map[string]any + if err := json.Unmarshal(raw, &copied); err != nil { + t.Fatalf("copy: %v", err) + } + return copied +} + +func TestARealDisclosureVerifiesAcrossLanguages(t *testing.T) { + vector := loadPresentation(t) + report := VerifyDisclosure( + vector["presentation"].(map[string]any), + WithExpectedNonce(vector["nonce"].(string)), + ) + if !report.Ok { + t.Fatalf("a real disclosure did not verify: %v", report.Problems) + } + expected := vector["expected"].(map[string]any) + if float64(len(report.VerifiedLeaves)) != expected["verified_leaf_count"].(float64) { + t.Fatalf("verified %d leaves, expected %v", len(report.VerifiedLeaves), expected["verified_leaf_count"]) + } + if report.Freshness != "challenge" { + t.Fatalf("freshness: got %q want challenge", report.Freshness) + } +} + +func TestWithoutAChallengeTheReportSaysSo(t *testing.T) { + // Weaker evidence, reported as weaker rather than presented as the same. + vector := loadPresentation(t) + report := VerifyDisclosure(vector["presentation"].(map[string]any)) + if !report.Ok { + t.Fatalf("expiry-bounded verification failed: %v", report.Problems) + } + if report.Freshness != "bounded_lifetime" { + t.Fatalf("freshness: got %q want bounded_lifetime", report.Freshness) + } +} + +func TestASwappedValueDoesNotOpenItsLeaf(t *testing.T) { + vector := loadPresentation(t) + tampered := presentationCopy(t, vector) + revealed := tampered["revealed"].([]any) + entry := revealed[0].(map[string]any) + entry["value"].(map[string]any)["value"] = map[string]any{"manifest_count": "9"} + + report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string))) + if report.Ok { + t.Fatal("a swapped value still verified") + } + if !hasDisclosureProblem(report.Problems, "does not open") { + t.Fatalf("expected an opening failure, got %v", report.Problems) + } +} + +func TestALeafThatDoesNotFoldToTheRootIsRefused(t *testing.T) { + // A leaf can open its own commitment perfectly and still belong to a + // different capsule. Corrupting a sibling leaves the value and randomizer + // untouched, so only the two-hop fold can catch it — which is what + // distinguishes a verifier from a value checker. + vector := loadPresentation(t) + tampered := presentationCopy(t, vector) + proofs := tampered["inclusion_proofs"].([]any) + steps := proofs[0].(map[string]any)["subtree_steps"].([]any) + steps[0].(map[string]any)["sibling"] = strings.Repeat("0", 64) + + report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string))) + if report.Ok || !hasDisclosureProblem(report.Problems, "not included") { + t.Fatalf("a leaf outside the capsule was accepted: %v", report.Problems) + } +} + +func TestACorruptedTopPathIsRefused(t *testing.T) { + vector := loadPresentation(t) + tampered := presentationCopy(t, vector) + proofs := tampered["inclusion_proofs"].([]any) + steps := proofs[0].(map[string]any)["top_steps"].([]any) + steps[0].(map[string]any)["sibling"] = strings.Repeat("0", 64) + + report := VerifyDisclosure(tampered, WithExpectedNonce(vector["nonce"].(string))) + if report.Ok || !hasDisclosureProblem(report.Problems, "not included") { + t.Fatalf("a corrupted top path was accepted: %v", report.Problems) + } +} + +func TestAReplayedNonceIsRefused(t *testing.T) { + vector := loadPresentation(t) + report := VerifyDisclosure( + vector["presentation"].(map[string]any), + WithExpectedNonce(strings.Repeat("ff", 32)), + ) + if report.Ok || !hasDisclosureProblem(report.Problems, "nonce") { + t.Fatalf("a replayed nonce was accepted: %v", report.Problems) + } +} + +func TestAnExpiredDisclosureIsRefused(t *testing.T) { + vector := loadPresentation(t) + report := VerifyDisclosure( + vector["presentation"].(map[string]any), + WithVerificationTime(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)), + ) + if report.Ok || !hasDisclosureProblem(report.Problems, "expired") { + t.Fatalf("an expired disclosure was accepted: %v", report.Problems) + } +} + +func TestADisclosureForAnotherAssetIsRefused(t *testing.T) { + vector := loadPresentation(t) + report := VerifyDisclosure( + vector["presentation"].(map[string]any), + WithSubjectCommitment(strings.Repeat("aa", 32)), + ) + if report.Ok || report.SubjectChecked { + t.Fatalf("a foreign subject was accepted: %v", report.Problems) + } +} + +func TestTheMatchingSubjectIsReportedAsChecked(t *testing.T) { + vector := loadPresentation(t) + report := VerifyDisclosure( + vector["presentation"].(map[string]any), + WithSubjectCommitment(vector["subject_commitment"].(string)), + ) + if !report.Ok || !report.SubjectChecked { + t.Fatalf("the matching subject was not reported: %v", report.Problems) + } +} + +func TestATamperedSignatureIsCaught(t *testing.T) { + vector := loadPresentation(t) + tampered := presentationCopy(t, vector) + tampered["nonce"] = strings.Repeat("cd", 32) + report := VerifyDisclosure(tampered) + if report.Ok || !hasDisclosureProblem(report.Problems, "signature") { + t.Fatalf("a tampered presentation was accepted: %v", report.Problems) + } +} + +func TestEveryProblemIsCollected(t *testing.T) { + // A caller should see everything wrong with a presentation at once. + vector := loadPresentation(t) + tampered := presentationCopy(t, vector) + revealed := tampered["revealed"].([]any) + revealed[0].(map[string]any)["value"].(map[string]any)["value"] = map[string]any{"x": "y"} + + report := VerifyDisclosure( + tampered, + WithExpectedNonce(strings.Repeat("ff", 32)), + WithVerificationTime(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC)), + ) + if len(report.Problems) < 3 { + t.Fatalf("expected several problems, got %v", report.Problems) + } +} + +func TestTheReportSaysWhatItDoesNotClaim(t *testing.T) { + vector := loadPresentation(t) + report := VerifyDisclosure(vector["presentation"].(map[string]any)) + if len(report.NotClaimed) != 1 || report.NotClaimed[0]["id"] != "undisclosed_facts_absent" { + t.Fatalf("the non-claim is missing: %v", report.NotClaimed) + } +} + +func TestAnUnknownProtocolIsRefusedFirst(t *testing.T) { + report := VerifyDisclosure(map[string]any{"protocol": "SOMETHING-ELSE"}) + if report.Ok || len(report.Problems) != 1 { + t.Fatalf("expected a single protocol refusal, got %v", report.Problems) + } +} + +func hasDisclosureProblem(problems []string, needle string) bool { + for _, problem := range problems { + if strings.Contains(problem, needle) { + return true + } + } + return false +} diff --git a/provenance.go b/provenance.go index ba46b51..5bf652b 100644 --- a/provenance.go +++ b/provenance.go @@ -14,11 +14,14 @@ package attesto // Conformance is defined by golden-vectors/provenance-v0.1-dev/. import ( + "crypto/ed25519" "crypto/subtle" "encoding/hex" + "encoding/json" "fmt" "sort" "strings" + "time" ) const ( @@ -661,3 +664,273 @@ func ValidateRangeStatement(statement map[string]any) (uint, error) { } return ZKRangeWidth(lower, upper) } + +// ------------------------------------------------- disclosure verification + +const ( + DisclosureProtocol = "ATTESTO-DISCLOSURE-001" + DisclosureProtocolVersion = "0.1" + disclosureDomain = "attesto.provenance.v1.disclosure" +) + +// subtreeLeafDomain is the domain a leaf's own commitment was made under, as +// opposed to the domain its subtree folds in. Verifying a disclosure needs both: +// one opens the leaf, the other proves it belongs to the tree. +var subtreeLeafDomain = map[string]string{ + "claims": "attesto.provenance.v1.claim", + "evidence": "attesto.provenance.v1.evidence", + "policy_results": "attesto.provenance.v1.policy_result", +} + +// DisclosureNotClaimed is the seventh non-claim, on top of TM-05's six. A +// verifier that reported only what it checked would leave a reader to assume the +// picture is complete. +var DisclosureNotClaimed = map[string]string{ + "id": "undisclosed_facts_absent", + "statement": "This disclosure proves the revealed leaves are in the capsule. " + + "It is not a statement that the capsule holds nothing else.", +} + +// VerifiedLeaf is one leaf a presentation proved. +type VerifiedLeaf struct { + Subtree string `json:"subtree"` + LeafRole string `json:"leaf_role"` + Value any `json:"value"` +} + +// DisclosureReport is what a presentation established, and what it did not. +// +// Ok is true only when every revealed leaf opened its commitment and every proof +// folded to the presented capsule root. Freshness and SubjectChecked are reported +// separately rather than folded in: a caller that issued no challenge got weaker +// evidence than one that did, and saying so is what separates a verifier from a +// rubber stamp. +type DisclosureReport struct { + Ok bool + CapsuleRoot string + VerifiedLeaves []VerifiedLeaf + Freshness string + SubjectChecked bool + Problems []string + NotClaimed []map[string]string +} + +type disclosureOptions struct { + expectedNonce *string + subjectCommitment *string + now *time.Time +} + +// DisclosureOption configures a verification. +type DisclosureOption func(*disclosureOptions) + +// WithExpectedNonce turns on interactive mode: supply the challenge issued to +// the holder. Without it the presentation is only bounded by its expiry, which +// is weaker evidence, and the report says so. +func WithExpectedNonce(nonce string) DisclosureOption { + return func(o *disclosureOptions) { o.expectedNonce = &nonce } +} + +// WithSubjectCommitment checks the presentation is bound to the asset held. +func WithSubjectCommitment(commitment string) DisclosureOption { + return func(o *disclosureOptions) { o.subjectCommitment = &commitment } +} + +// WithVerificationTime overrides the clock, for testing expiry without waiting. +func WithVerificationTime(at time.Time) DisclosureOption { + return func(o *disclosureOptions) { o.now = &at } +} + +func disclosureSigningPayload(presentation map[string]any) map[string]any { + payload := make(map[string]any, len(presentation)) + for key, value := range presentation { + if key != "signature" { + payload[key] = value + } + } + return payload +} + +// VerifyDisclosure verifies a selective disclosure offline. +// +// It needs no network and no platform: the presentation carries its own +// signature, the revealed values with their randomizers, and two-hop proofs to +// the capsule root. +// +// Every problem is collected rather than returned on the first one, so a caller +// sees all of what is wrong with a presentation instead of only the earliest. +func VerifyDisclosure(presentation map[string]any, options ...DisclosureOption) DisclosureReport { + opts := &disclosureOptions{} + for _, option := range options { + option(opts) + } + + notClaimed := []map[string]string{DisclosureNotClaimed} + if presentation["protocol"] != DisclosureProtocol || + presentation["protocol_version"] != DisclosureProtocolVersion { + return DisclosureReport{ + Freshness: "unknown", + Problems: []string{"unsupported disclosure protocol"}, + NotClaimed: notClaimed, + } + } + + problems := []string{} + moment := time.Now().UTC() + if opts.now != nil { + moment = *opts.now + } + if raw, ok := presentation["expires_at"].(string); ok { + if expires, err := time.Parse(time.RFC3339Nano, raw); err != nil { + problems = append(problems, "expiry is malformed") + } else if !moment.Before(expires) { + problems = append(problems, "disclosure has expired") + } + } else { + problems = append(problems, "expiry is malformed") + } + + if opts.expectedNonce != nil && presentation["nonce"] != *opts.expectedNonce { + problems = append(problems, "nonce is not the one issued") + } + + problems = append(problems, verifyDisclosureSignature(presentation)...) + + revealed := map[string]map[string]any{} + for _, raw := range asSlice(presentation["revealed"]) { + if entry, ok := raw.(map[string]any); ok { + revealed[toString(entry["leaf_id"])] = entry + } + } + proofs := map[string]map[string]any{} + for _, raw := range asSlice(presentation["inclusion_proofs"]) { + if proof, ok := raw.(map[string]any); ok { + proofs[toString(proof["leaf_id"])] = proof + } + } + if len(revealed) == 0 || len(revealed) != len(proofs) { + problems = append(problems, "every revealed leaf needs its proof and every proof its leaf") + } + + capsuleRoot := toString(presentation["capsule_root"]) + verified := []VerifiedLeaf{} + for leafID, entry := range revealed { + proof, ok := proofs[leafID] + if !ok { + problems = append(problems, "every revealed leaf needs its proof and every proof its leaf") + continue + } + subtree := toString(entry["subtree"]) + if toString(proof["capsule_root"]) != capsuleRoot || toString(proof["subtree"]) != subtree { + problems = append(problems, "proof does not match its revealed leaf: "+leafID) + continue + } + domain, known := subtreeLeafDomain[subtree] + if !known { + problems = append(problems, "unknown subtree: "+subtree) + continue + } + opened, err := VerifyProvenanceCommitment( + domain, entry["value"], toString(entry["randomizer"]), toString(proof["leaf"]), + ) + if err != nil || !opened { + problems = append(problems, "revealed value does not open its leaf commitment: "+leafID) + continue + } + included, err := verifyTwoHopFromMap(proof) + if err != nil || !included { + problems = append(problems, "leaf is not included under the presented capsule root: "+leafID) + continue + } + leafValue := entry["value"] + if wrapped, ok := leafValue.(map[string]any); ok { + leafValue = wrapped["value"] + } + verified = append(verified, VerifiedLeaf{ + Subtree: subtree, LeafRole: toString(entry["leaf_role"]), Value: leafValue, + }) + } + + subjectChecked := false + if opts.subjectCommitment != nil { + if toString(presentation["subject_binding"]) != *opts.subjectCommitment { + problems = append(problems, "disclosure is bound to a different asset") + } else { + subjectChecked = true + } + } + + freshness := "bounded_lifetime" + if opts.expectedNonce != nil { + freshness = "challenge" + } + return DisclosureReport{ + Ok: len(problems) == 0, + CapsuleRoot: capsuleRoot, + VerifiedLeaves: verified, + Freshness: freshness, + SubjectChecked: subjectChecked, + Problems: problems, + NotClaimed: notClaimed, + } +} + +func verifyDisclosureSignature(presentation map[string]any) []string { + signature, _ := presentation["signature"].(map[string]any) + issuer, _ := presentation["issuer"].(map[string]any) + if signature == nil || issuer == nil || + toString(signature["algorithm"]) != "ed25519" || + toString(signature["domain"]) != disclosureDomain { + return []string{"signature is not a v1 disclosure signature"} + } + publicKey, err := hex.DecodeString(toString(issuer["public_key"])) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return []string{"signature did not verify"} + } + sig, err := hex.DecodeString(toString(signature["value"])) + if err != nil || len(sig) != ed25519.SignatureSize { + return []string{"signature did not verify"} + } + payload, err := CanonicalJSON(disclosureSigningPayload(presentation)) + if err != nil { + return []string{"signature did not verify"} + } + message := append([]byte(disclosureDomain), 0) + message = append(message, payload...) + if !ed25519.Verify(publicKey, message, sig) { + return []string{"signature did not verify"} + } + return nil +} + +func verifyTwoHopFromMap(proof map[string]any) (bool, error) { + scrubbed := make(map[string]any, len(proof)) + for key, value := range proof { + if key != "leaf_id" { + scrubbed[key] = value + } + } + encoded, err := json.Marshal(scrubbed) + if err != nil { + return false, err + } + var typed TwoHopProof + if err := json.Unmarshal(encoded, &typed); err != nil { + return false, err + } + return VerifyTwoHop(typed) +} + +func asSlice(value any) []any { + if typed, ok := value.([]any); ok { + return typed + } + return nil +} + +func toString(value any) string { + if typed, ok := value.(string); ok { + return typed + } + return "" +}