diff --git a/provenance.go b/provenance.go index 97c771e..513a006 100644 --- a/provenance.go +++ b/provenance.go @@ -18,6 +18,7 @@ import ( "encoding/hex" "fmt" "sort" + "strings" ) const ( @@ -430,3 +431,145 @@ func EnvelopeCoreCanonicalBytes(envelope map[string]any) ([]byte, error) { } return CanonicalJSON(core) } + +// ---------------------------------------------------------------- predicates + +const ( + ZKRangeProtocol = "ATTESTO-ZK-RANGE-001" + ZKRangeProtocolVersion = "0.1" + PredicateResultSchema = "attesto.provenance.predicate_result" + PredicateResultSchemaVersion = "0.1" +) + +// RequiredNonClaims must appear in every predicate result. A result that dropped +// one would be read as the stronger statement, which is the failure this +// vocabulary prevents. +var RequiredNonClaims = [3]string{ + "detector_correctness_not_proven", + "content_truth_not_proven", + "ai_generation_not_proven", +} + +// forbiddenResultFields would let a consumer render a proven bound as a verdict +// about the content. A range proof says a named detector's measurement fell +// inside an interval and nothing more. +var forbiddenResultFields = map[string]struct{}{ + "ai_generated": {}, "synthetic": {}, "is_fake": {}, "authentic": {}, + "confidence": {}, "score": {}, "probability": {}, +} + +// PredicateReport separates what this client checked from what the issuer claims. +type PredicateReport struct { + Protocol string `json:"protocol"` + CapsuleRoot string `json:"capsule_root"` + ClaimID string `json:"claim_id"` + CommitmentC string `json:"commitment_c"` + Predicate map[string]any `json:"predicate"` + VerifiedHere map[string]string `json:"verified_here"` + ReportedByIssuer map[string]any `json:"reported_by_issuer"` + NotClaimed []map[string]any `json:"not_claimed"` +} + +func rejectVerdictFields(node any, path string) error { + switch typed := node.(type) { + case map[string]any: + for key, value := range typed { + if _, bad := forbiddenResultFields[strings.ToLower(key)]; bad { + return fmt.Errorf("predicate result carries a verdict-shaped field at %s.%s", path, key) + } + if err := rejectVerdictFields(value, path+"."+key); err != nil { + return err + } + } + case []any: + for index, value := range typed { + if err := rejectVerdictFields(value, fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + } + return nil +} + +// InspectPredicateResult reports what this SDK established, kept apart from what +// the issuer claims. +// +// This is a verification client without ristretto255 arithmetic, so it cannot +// check a range proof. It says not_checked rather than passing the issuer's word +// through as though it had verified it: an SDK that reported the issuer's +// "verified" as its own is the failure this construction exists to prevent. +// +// capsuleInclusion is nil when the caller did not check inclusion. +func InspectPredicateResult(result map[string]any, capsuleInclusion *bool) (*PredicateReport, error) { + if result["schema"] != PredicateResultSchema || result["schema_version"] != PredicateResultSchemaVersion { + return nil, fmt.Errorf("unsupported predicate result schema") + } + if result["protocol"] != ZKRangeProtocol || result["protocol_version"] != ZKRangeProtocolVersion { + return nil, fmt.Errorf("unsupported predicate protocol") + } + + rawClaims, _ := result["not_claimed"].([]any) + declared := map[string]struct{}{} + notClaimed := make([]map[string]any, 0, len(rawClaims)) + for _, raw := range rawClaims { + claim, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("predicate result carries a malformed non-claim") + } + if id, ok := claim["id"].(string); ok { + declared[id] = struct{}{} + } + copied := map[string]any{} + for key, value := range claim { + copied[key] = value + } + notClaimed = append(notClaimed, copied) + } + for _, required := range RequiredNonClaims { + if _, ok := declared[required]; !ok { + return nil, fmt.Errorf("predicate result omits required non-claims: %s", required) + } + } + + if err := rejectVerdictFields(result, "result"); err != nil { + return nil, err + } + + bound := map[string]string{} + for _, field := range []string{"capsule_root", "claim_id", "commitment_c"} { + value, ok := result[field].(string) + if !ok || value == "" { + return nil, fmt.Errorf("predicate result has no %s to bind to", field) + } + bound[field] = value + } + + inclusion := "not_checked" + if capsuleInclusion != nil { + if *capsuleInclusion { + inclusion = "verified" + } else { + inclusion = "failed" + } + } + + predicate, _ := result["predicate"].(map[string]any) + issuer, _ := result["verification"].(map[string]any) + return &PredicateReport{ + Protocol: ZKRangeProtocol, + CapsuleRoot: bound["capsule_root"], + ClaimID: bound["claim_id"], + CommitmentC: bound["commitment_c"], + Predicate: predicate, + // zk_predicate is always not_checked: verifying the proof needs curve + // arithmetic this client does not carry. + VerifiedHere: map[string]string{ + "zk_predicate": "not_checked", + "capsule_inclusion": inclusion, + }, + // What the issuer says it checked, kept separate so a reader can tell a + // claim from a check. + ReportedByIssuer: issuer, + NotClaimed: notClaimed, + }, nil +} diff --git a/zk_range_result_parity_test.go b/zk_range_result_parity_test.go new file mode 100644 index 0000000..f124d39 --- /dev/null +++ b/zk_range_result_parity_test.go @@ -0,0 +1,152 @@ +package attesto + +// Go parity against the ATTESTO-ZK-RANGE-001 result corpus. +// +// The cryptography of a range proof is not checked here and cannot be: this SDK +// carries no ristretto255 arithmetic. What is checked is what an SDK can get +// wrong on its own — reporting the issuer's word as its own finding, or handing +// a consumer an object shaped like a verdict. + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func zkRangeVectorDir(t *testing.T) string { + t.Helper() + dir := filepath.Join("..", "..", "golden-vectors", "zk-range-v0.1-dev") + if _, err := os.Stat(dir); err != nil { + t.Fatalf("no zk-range vectors at %s: %v", dir, err) + } + return dir +} + +func loadZKRangeVector(t *testing.T, name string) map[string]any { + t.Helper() + raw, err := os.ReadFile(filepath.Join(zkRangeVectorDir(t), name+".json")) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + var vector map[string]any + if err := json.Unmarshal(raw, &vector); err != nil { + t.Fatalf("parse %s: %v", name, err) + } + return vector +} + +func TestZKRangeCorpusIsPresentAndTyped(t *testing.T) { + entries, err := filepath.Glob(filepath.Join(zkRangeVectorDir(t), "*.json")) + if err != nil || len(entries) == 0 { + t.Fatalf("no zk-range vectors: %v", err) + } + for _, entry := range entries { + raw, err := os.ReadFile(entry) + if err != nil { + t.Fatalf("read %s: %v", entry, err) + } + var vector map[string]any + if err := json.Unmarshal(raw, &vector); err != nil { + t.Fatalf("parse %s: %v", entry, err) + } + if vector["protocol"] != ZKRangeProtocol { + t.Fatalf("%s: wrong protocol %v", entry, vector["protocol"]) + } + if vector["requires"] == nil { + t.Fatalf("%s: does not declare what it requires", entry) + } + switch vector["expectation"] { + case "valid", "invalid", "rejected", "differs": + default: + t.Fatalf("%s: bad expectation %v", entry, vector["expectation"]) + } + } +} + +func TestZKRangeClientWithoutCurveArithmeticSaysSo(t *testing.T) { + // The whole point. This SDK cannot verify the proof and reports that; the + // issuer's own verification block is kept under a separate key so a reader + // can tell a claim apart from a check. + vector := loadZKRangeVector(t, "zk-range-result-valid") + result := vector["result"].(map[string]any) + + report, err := InspectPredicateResult(result, nil) + if err != nil { + t.Fatalf("inspect: %v", err) + } + if report.VerifiedHere["zk_predicate"] != "not_checked" { + t.Fatalf("this client cannot verify a proof but reported %q", report.VerifiedHere["zk_predicate"]) + } + expected := vector["expected_verified_here"].(map[string]any) + for key, want := range expected { + if report.VerifiedHere[key] != want.(string) { + t.Fatalf("verified_here[%s]: got %q want %v", key, report.VerifiedHere[key], want) + } + } + if report.ReportedByIssuer["zk_predicate"] != "verified" { + t.Fatalf("the issuer's own claim was not carried through separately") + } +} + +func TestZKRangeInclusionTheClientCheckedIsReported(t *testing.T) { + // Not everything is out of reach: two-hop inclusion is SHA-256. + vector := loadZKRangeVector(t, "zk-range-result-valid") + result := vector["result"].(map[string]any) + + for _, testCase := range []struct { + checked bool + want string + }{{true, "verified"}, {false, "failed"}} { + checked := testCase.checked + report, err := InspectPredicateResult(result, &checked) + if err != nil { + t.Fatalf("inspect: %v", err) + } + if report.VerifiedHere["capsule_inclusion"] != testCase.want { + t.Fatalf("capsule_inclusion: got %q want %q", report.VerifiedHere["capsule_inclusion"], testCase.want) + } + } +} + +func TestZKRangeResultCarriesTheThreeNonClaims(t *testing.T) { + vector := loadZKRangeVector(t, "zk-range-result-valid") + report, err := InspectPredicateResult(vector["result"].(map[string]any), nil) + if err != nil { + t.Fatalf("inspect: %v", err) + } + if len(report.NotClaimed) != len(RequiredNonClaims) { + t.Fatalf("expected %d non-claims, got %d", len(RequiredNonClaims), len(report.NotClaimed)) + } + for index, required := range RequiredNonClaims { + if report.NotClaimed[index]["id"] != required { + t.Fatalf("non-claim %d: got %v want %s", index, report.NotClaimed[index]["id"], required) + } + } +} + +func TestZKRangeMisleadingResultsAreRefused(t *testing.T) { + for _, name := range []string{ + "zk-range-result-missing-non-claim", + "zk-range-result-verdict-field", + "zk-range-result-nested-verdict-field", + "zk-range-result-unbound", + "zk-range-result-unsupported-version", + } { + vector := loadZKRangeVector(t, name) + if vector["expectation"] != "rejected" { + t.Fatalf("%s: expected a rejected vector", name) + } + if _, err := InspectPredicateResult(vector["result"].(map[string]any), nil); err == nil { + t.Fatalf("%s was accepted", name) + } + } +} + +func TestZKRangeRefusalsAreNotBlanket(t *testing.T) { + // The refusals above must not be a function that refuses everything. + vector := loadZKRangeVector(t, "zk-range-result-valid") + if _, err := InspectPredicateResult(vector["result"].(map[string]any), nil); err != nil { + t.Fatalf("a well-formed result was refused: %v", err) + } +}