// 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 }