feat(attesto3): pin the range statement and its width across all three SDKs
The statement is what gets folded into the proof transcript, and the width is derived from its bounds. A client that ordered the fields differently or picked a different width would produce proofs nobody else could verify — and the symptom would read as a broken proof rather than a divergent implementation. Both are pure arithmetic and canonical JSON, so every SDK can check them and now does. Each client gains `zk_range_width` and `validate_range_statement`. The field set is exact rather than a minimum: an extra field would bind to nothing and a missing one would change the challenges. A float bound is refused rather than truncated, which is the encoding registry's whole purpose one layer up. The width table is checked in as a vector and the Rust core asserts against that file directly rather than against a second copy of the table. Changing one now fails the other, which a duplicated constant would not have done. Corpus coverage: 17/17 provenance and 12/12 zk-range in Python, Go and TypeScript. The cross-SDK vector item is closed; Sprint 12 is down to three open items, all of which need something local work cannot supply — other architectures, a curve-library decision, and a UI. Python 107, Go ok, TypeScript 119, Local Vault 375, edge 117. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -573,3 +573,91 @@ func InspectPredicateResult(result map[string]any, capsuleInclusion *bool) (*Pre
|
|||||||
NotClaimed: notClaimed,
|
NotClaimed: notClaimed,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ZKRangeWidths are the proof widths ATTESTO-ZK-RANGE-001 permits. Pinned rather
|
||||||
|
// than derived: a client offering a width outside this set would build
|
||||||
|
// statements the proving library refuses after the transcript is already bound.
|
||||||
|
var ZKRangeWidths = [4]uint{8, 16, 32, 64}
|
||||||
|
|
||||||
|
// zkRangeStatementFields is exactly what a range statement carries. Every field
|
||||||
|
// is folded into the proof transcript, so an extra one would bind to nothing and
|
||||||
|
// a missing one would change the challenges.
|
||||||
|
var zkRangeStatementFields = map[string]struct{}{
|
||||||
|
"capsule_root": {}, "claim_id": {}, "claim_descriptor_version": {},
|
||||||
|
"provider_id": {}, "provider_version": {}, "commitment_c": {},
|
||||||
|
"predicate_type": {}, "lower_bound": {}, "upper_bound": {},
|
||||||
|
"encoding_version": {}, "proof_scheme_version": {}, "verifier_nonce": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZKRangeWidth returns the smallest permitted width covering the whole interval.
|
||||||
|
//
|
||||||
|
// Both proved differences are bounded by upper-lower, so one width serves both.
|
||||||
|
// It is derived from the public bounds and never chosen by the prover: a prover
|
||||||
|
// who picked it could prove a wider range than the statement says.
|
||||||
|
func ZKRangeWidth(lowerBound, upperBound uint64) (uint, error) {
|
||||||
|
if upperBound < lowerBound {
|
||||||
|
return 0, fmt.Errorf("upper bound is below its lower bound")
|
||||||
|
}
|
||||||
|
span := upperBound - lowerBound
|
||||||
|
for _, width := range ZKRangeWidths {
|
||||||
|
if width == 64 || span < (uint64(1)<<width) {
|
||||||
|
return width, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("interval is wider than the largest permitted proof")
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodedBound reads a JSON number as the encoded integer it must be. A float
|
||||||
|
// bound is refused rather than truncated: the encoding registry exists so no
|
||||||
|
// rounding step is left for three SDKs to disagree about.
|
||||||
|
func encodedBound(value any, name string) (uint64, error) {
|
||||||
|
number, ok := value.(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0, fmt.Errorf("%s must be an encoded integer, never a float", name)
|
||||||
|
}
|
||||||
|
if number < 0 || number != float64(uint64(number)) {
|
||||||
|
return 0, fmt.Errorf("%s must be an encoded integer, never a float", name)
|
||||||
|
}
|
||||||
|
return uint64(number), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRangeStatement checks a statement is well-formed and returns the width
|
||||||
|
// its bounds imply.
|
||||||
|
//
|
||||||
|
// This does not verify the proof — that needs curve arithmetic no SDK carries.
|
||||||
|
// It refuses the statements no honest prover produced, which is a check a
|
||||||
|
// verification client can make on its own.
|
||||||
|
func ValidateRangeStatement(statement map[string]any) (uint, error) {
|
||||||
|
for field := range zkRangeStatementFields {
|
||||||
|
if _, ok := statement[field]; !ok {
|
||||||
|
return 0, fmt.Errorf("range statement is missing %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for field := range statement {
|
||||||
|
if _, ok := zkRangeStatementFields[field]; !ok {
|
||||||
|
return 0, fmt.Errorf("range statement carries an unexpected field: %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if statement["predicate_type"] != "inclusive_range" {
|
||||||
|
return 0, fmt.Errorf("statement predicate is not the v1 inclusive range")
|
||||||
|
}
|
||||||
|
for _, field := range []string{
|
||||||
|
"capsule_root", "claim_id", "claim_descriptor_version", "provider_id",
|
||||||
|
"provider_version", "commitment_c", "encoding_version",
|
||||||
|
"proof_scheme_version", "verifier_nonce",
|
||||||
|
} {
|
||||||
|
value, ok := statement[field].(string)
|
||||||
|
if !ok || value == "" {
|
||||||
|
return 0, fmt.Errorf("range statement %s is empty or not a string", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lower, err := encodedBound(statement["lower_bound"], "lower_bound")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
upper, err := encodedBound(statement["upper_bound"], "upper_bound")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return ZKRangeWidth(lower, upper)
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,3 +150,73 @@ func TestZKRangeRefusalsAreNotBlanket(t *testing.T) {
|
|||||||
t.Fatalf("a well-formed result was refused: %v", err)
|
t.Fatalf("a well-formed result was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- statement and width
|
||||||
|
|
||||||
|
func TestZKRangeStatementCarriesExactlyTheTranscriptBoundFields(t *testing.T) {
|
||||||
|
// Every field is folded into the proof transcript. An extra one would bind to
|
||||||
|
// nothing; a missing one would change the challenges. So the set is exact.
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-statement-valid")
|
||||||
|
statement := vector["statement"].(map[string]any)
|
||||||
|
|
||||||
|
expected := vector["expected_fields"].([]any)
|
||||||
|
if len(statement) != len(expected) {
|
||||||
|
t.Fatalf("statement carries %d fields, corpus lists %d", len(statement), len(expected))
|
||||||
|
}
|
||||||
|
for _, field := range expected {
|
||||||
|
if _, ok := statement[field.(string)]; !ok {
|
||||||
|
t.Fatalf("statement is missing %v", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
width, err := ValidateRangeStatement(statement)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validate: %v", err)
|
||||||
|
}
|
||||||
|
if float64(width) != vector["expected_width"].(float64) {
|
||||||
|
t.Fatalf("width: got %d want %v", width, vector["expected_width"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeWidthIsDerivedFromThePublicBounds(t *testing.T) {
|
||||||
|
// Pinned across languages because a divergent width is invisible: a client
|
||||||
|
// picking a different one produces proofs nobody else can verify, and the
|
||||||
|
// symptom looks like a broken proof rather than a divergent rule.
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-width-selection")
|
||||||
|
for _, raw := range vector["cases"].([]any) {
|
||||||
|
testCase := raw.(map[string]any)
|
||||||
|
lower := uint64(testCase["lower_bound"].(float64))
|
||||||
|
upper := uint64(testCase["upper_bound"].(float64))
|
||||||
|
width, err := ZKRangeWidth(lower, upper)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("[%d, %d]: %v", lower, upper, err)
|
||||||
|
}
|
||||||
|
if float64(width) != testCase["expected_width"].(float64) {
|
||||||
|
t.Fatalf("[%d, %d]: got %d want %v", lower, upper, width, testCase["expected_width"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeDishonestStatementsAreRefused(t *testing.T) {
|
||||||
|
for _, name := range []string{
|
||||||
|
"zk-range-statement-float-bound",
|
||||||
|
"zk-range-statement-inverted",
|
||||||
|
"zk-range-statement-unknown-field",
|
||||||
|
"zk-range-statement-empty-nonce",
|
||||||
|
} {
|
||||||
|
vector := loadZKRangeVector(t, name)
|
||||||
|
if vector["expectation"] != "rejected" {
|
||||||
|
t.Fatalf("%s: expected a rejected vector", name)
|
||||||
|
}
|
||||||
|
if _, err := ValidateRangeStatement(vector["statement"].(map[string]any)); err == nil {
|
||||||
|
t.Fatalf("%s was accepted", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZKRangeStatementRefusalsAreNotBlanket(t *testing.T) {
|
||||||
|
vector := loadZKRangeVector(t, "zk-range-statement-valid")
|
||||||
|
if _, err := ValidateRangeStatement(vector["statement"].(map[string]any)); err != nil {
|
||||||
|
t.Fatalf("a well-formed statement was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user