diff --git a/zk/go.mod b/zk/go.mod new file mode 100644 index 0000000..ae2774b --- /dev/null +++ b/zk/go.mod @@ -0,0 +1,8 @@ +// A separate module on purpose: go.attesto.eu/sdk has no dependencies at all, +// and a consumer who never opens a private numeric should not inherit a curve +// library to keep it that way. +module go.attesto.eu/sdk/zk + +go 1.24 + +require github.com/gtank/ristretto255 v0.1.2 diff --git a/zk/go.sum b/zk/go.sum new file mode 100644 index 0000000..dbcb494 --- /dev/null +++ b/zk/go.sum @@ -0,0 +1,2 @@ +github.com/gtank/ristretto255 v0.1.2 h1:JEqUCPA1NvLq5DwYtuzigd7ss8fwbYay9fi4/5uMzcc= +github.com/gtank/ristretto255 v0.1.2/go.mod h1:Ph5OpO6c7xKUGROZfWVLiJf9icMDwUeIvY4OmlYW69o= diff --git a/zk/pedersen.go b/zk/pedersen.go new file mode 100644 index 0000000..379bcb0 --- /dev/null +++ b/zk/pedersen.go @@ -0,0 +1,99 @@ +// Package zk verifies Pedersen openings for Attesto private numeric claims. +// +// It is a separate module from go.attesto.eu/sdk because that package carries no +// dependencies at all, and most verification is SHA-256 and Merkle work. A +// consumer who never opens a private numeric should not inherit a curve library. +// +// This package does not verify range proofs. That needs a full bulletproofs +// implementation, not curve arithmetic, and remains the Rust core's job. +package zk + +import ( + "encoding/hex" + "fmt" + "math/big" + + "github.com/gtank/ristretto255" +) + +// The frozen v1 generator pair, from docs/protocol/zk-generator-registry.md. The +// registry defines what Attesto means by these; a dependency's word "default" is +// not the protocol definition, so they are pinned here. +const ( + GeneratorSetID = "attesto-ristretto255-pedersen-v1" + GeneratorBHex = "e2f2ae0a6abc4e71a884a961c500515f58e30b6aa582dd8db6a65945e08d2d76" + GeneratorHHex = "8c9240b456a9e6dc65c377a1048d745f94a08cdb7f44cbcd7b46f34048871134" +) + +func decodePoint(value string) (*ristretto255.Element, error) { + raw, err := hex.DecodeString(value) + if err != nil { + return nil, fmt.Errorf("point is not hex") + } + element := ristretto255.NewElement() + if err := element.Decode(raw); err != nil { + return nil, fmt.Errorf("point is not a valid ristretto element") + } + return element, nil +} + +func scalarFromUint(value uint64) *ristretto255.Scalar { + // Canonical 32-byte little-endian, which is what the core commits under. + var wide [64]byte + big.NewInt(0).SetUint64(value).FillBytes(wide[:8]) + // FillBytes writes big-endian into the slice; reverse into little-endian. + var canonical [32]byte + for index := 0; index < 8; index++ { + canonical[index] = wide[7-index] + } + scalar := ristretto255.NewScalar() + // SetCanonicalBytes cannot fail for a value below 2^64. + if err := scalar.Decode(canonical[:]); err != nil { + panic("a value below 2^64 is always a canonical scalar: " + err.Error()) + } + return scalar +} + +// VerifyOpening recomputes v·B + r·H and requires it to equal the committed C, +// byte for byte. +// +// This is the last link of the exact-opening chain: v + r -> C -> claim leaf -> +// capsule root. The descriptor must already have opened its claim leaf; only +// then is the commitment checked here the committed one. Verifying against a +// descriptor a holder merely supplied would let a matching pair be fabricated +// whole. +func VerifyOpening(descriptor map[string]any, encodedValue uint64, blindingScalar string) (bool, error) { + pedersen, _ := descriptor["pedersen"].(map[string]any) + if pedersen["generator_set_id"] != GeneratorSetID { + return false, fmt.Errorf("descriptor names a different generator set") + } + committed, ok := pedersen["commitment"].(string) + if !ok || len(committed) != 64 { + return false, fmt.Errorf("descriptor carries no commitment to open") + } + + raw, err := hex.DecodeString(blindingScalar) + if err != nil || len(raw) != 32 { + return false, fmt.Errorf("opening blinding is not 32 hex-encoded bytes") + } + blinding := ristretto255.NewScalar() + if err := blinding.Decode(raw); err != nil { + // A non-canonical encoding decodes to the same scalar as a canonical one + // and would let two opening records open the same commitment. + return false, fmt.Errorf("opening blinding is not a canonical scalar") + } + + base, err := decodePoint(GeneratorBHex) + if err != nil { + return false, err + } + blindingBase, err := decodePoint(GeneratorHHex) + if err != nil { + return false, err + } + + value := ristretto255.NewElement().ScalarMult(scalarFromUint(encodedValue), base) + mask := ristretto255.NewElement().ScalarMult(blinding, blindingBase) + recomputed := ristretto255.NewElement().Add(value, mask) + return hex.EncodeToString(recomputed.Encode(nil)) == committed, nil +} diff --git a/zk/pedersen_test.go b/zk/pedersen_test.go new file mode 100644 index 0000000..59a5a6a --- /dev/null +++ b/zk/pedersen_test.go @@ -0,0 +1,141 @@ +package zk + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// Go parity on the Pedersen opening vectors. +// +// These were a declared boundary: verifying them needs ristretto255 scalar +// arithmetic the dependency-free SDK does not carry. This module carries it, so +// a consumer who needs exact-opening verification can have it without imposing a +// curve library on everyone else. + +func loadVector(t *testing.T, name string) map[string]any { + t.Helper() + path := filepath.Join("..", "..", "..", "golden-vectors", "provenance-v0.1-dev", name+".json") + raw, err := os.ReadFile(path) + 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 openingFrom(t *testing.T, vector map[string]any) (map[string]any, uint64, string) { + t.Helper() + descriptor := vector["descriptor"].(map[string]any) + value := uint64(vector["encoded_value"].(float64)) + return descriptor, value, vector["blinding_scalar"].(string) +} + +func TestGeneratorPairMatchesTheFrozenRegistry(t *testing.T) { + // Constants that drifted would commit under a different pair than the + // protocol declares, and every commitment would be unopenable elsewhere. + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "protocol", "zk-generator-registry.md")) + if err != nil { + t.Fatalf("read registry: %v", err) + } + registry := string(raw) + for _, pinned := range []string{GeneratorSetID, GeneratorBHex, GeneratorHHex} { + if !strings.Contains(registry, pinned) { + t.Fatalf("registry no longer carries %s", pinned) + } + } +} + +func TestAValidOpeningReproducesTheRustCommitment(t *testing.T) { + vector := loadVector(t, "provenance-private-numeric-opening-valid") + descriptor, value, blinding := openingFrom(t, vector) + ok, err := VerifyOpening(descriptor, value, blinding) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ok != vector["expected_valid"].(bool) { + t.Fatalf("got %v want %v", ok, vector["expected_valid"]) + } +} + +func TestAWrongOpeningDoesNotReproduceTheCommitment(t *testing.T) { + for _, name := range []string{ + "provenance-private-numeric-opening-wrong-value", + "provenance-private-numeric-opening-wrong-blinding", + } { + vector := loadVector(t, name) + descriptor, value, blinding := openingFrom(t, vector) + ok, err := VerifyOpening(descriptor, value, blinding) + if err != nil { + t.Fatalf("%s: verify: %v", name, err) + } + if ok != vector["expected_valid"].(bool) { + t.Fatalf("%s: got %v want %v", name, ok, vector["expected_valid"]) + } + } +} + +func TestTheCommittedCommitmentIsWhatAValidOpeningProduces(t *testing.T) { + // Reads the commitment back out, so a check that always returned true fails. + vector := loadVector(t, "provenance-private-numeric-commitment-valid") + descriptor := vector["descriptor"].(map[string]any) + value := uint64(vector["expected_encoded_value"].(float64)) + blinding := vector["blinding_scalar"].(string) + + ok, err := VerifyOpening(descriptor, value, blinding) + if err != nil || !ok { + t.Fatalf("a valid opening did not verify: ok=%v err=%v", ok, err) + } + + pedersen := descriptor["pedersen"].(map[string]any) + pedersen["commitment"] = strings.Repeat("00", 32) + ok, err = VerifyOpening(descriptor, value, blinding) + if err != nil { + t.Fatalf("verify: %v", err) + } + if ok { + t.Fatal("a tampered commitment still verified") + } +} + +func TestTheSameMeasurementUnderTwoBlindingsIsUnlinkable(t *testing.T) { + vector := loadVector(t, "provenance-private-numeric-commitment-randomized") + if vector["first_commitment"] == vector["second_commitment"] { + t.Fatal("two blindings produced the same commitment") + } +} + +func TestADescriptorNamingAnotherGeneratorSetIsRefused(t *testing.T) { + // "rejected" is not "invalid": the input is refused before any check runs. + vector := loadVector(t, "provenance-private-numeric-wrong-generator-set") + descriptor, value, blinding := openingFrom(t, vector) + if _, err := VerifyOpening(descriptor, value, blinding); err == nil { + t.Fatal("a foreign generator set was accepted") + } +} + +func TestAWidenedEncodingDomainIsNotTheCurveChecksBusiness(t *testing.T) { + // A claim about semantics is refused by whoever validates the profile. The + // commitment still opens, and saying otherwise would blame the wrong layer. + vector := loadVector(t, "provenance-private-numeric-encoding-mismatch") + descriptor, value, blinding := openingFrom(t, vector) + ok, err := VerifyOpening(descriptor, value, blinding) + if err != nil || !ok { + t.Fatalf("the commitment should still open: ok=%v err=%v", ok, err) + } +} + +func TestAMalformedOpeningIsRefused(t *testing.T) { + vector := loadVector(t, "provenance-private-numeric-opening-valid") + descriptor, value, _ := openingFrom(t, vector) + for _, blinding := range []string{"nothex", strings.Repeat("ab", 31), "", strings.Repeat("ff", 32)} { + if _, err := VerifyOpening(descriptor, value, blinding); err == nil { + t.Fatalf("a malformed blinding %q was accepted", blinding) + } + } +}