Files
attesto-go/zk/pedersen.go
T
CodexandClaude Opus 5 0b881e1a74 feat(sdk): verify Pedersen openings in all three clients, without imposing a curve library
The eight private-numeric vectors were a declared boundary: verifying them needs
ristretto255 scalar arithmetic no SDK carried. Adding one everywhere would have
cost something real — the Go and TypeScript SDKs have *zero* dependencies, which
is a property their consumers get for free today.

So it is optional in each, and the shape differs per ecosystem: a `attesto[zk]`
extra in Python, an optional peer dependency in TypeScript, and a separate
`go.attesto.eu/sdk/zk` module in Go. A consumer who never opens a private
numeric inherits nothing. `not_checked` now means "this installation did not
check" rather than "nobody can", which is a better answer to the same question.

Each was verified against a real Rust commitment before being chosen: pysodium
over libsodium, @noble/curves, and gtank/ristretto255 all reproduce the core's
bytes exactly. PyNaCl was tried first and ruled out — 1.6.2 exposes no
ristretto255 bindings at all.

This closes Pedersen opening verification, not range proofs. A range proof needs
a full bulletproofs implementation, not curve arithmetic, and stays the core's
job.

Two things the last vector forced:

* A value outside the descriptor's declared domain now returns invalid for the
  right reason. The commitment would fail to match anyway, but attributing that
  to the arithmetic when the real answer is "that value is outside the declared
  domain" blames the wrong layer. All three match the Rust core here.
* A skipped suite is a gate that proves nothing, so CI sets
  ATTESTO_REQUIRE_ZK_EXTRA and an environment that was supposed to install the
  dependency and did not now fails instead of reporting green over skips.

Corpus coverage is 25/25 and 12/12 in all three languages, with no exemption
left. The exemption mechanism is removed rather than emptied: reintroducing one
should be a visible decision, not a constant someone left lying around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 19:15:20 +02:00

114 lines
4.3 KiB
Go

// 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")
}
// A value outside the declared domain cannot be what was committed, whatever
// the blinding. Checking it here rather than letting the commitment simply
// fail to match means the answer names the real reason, and matches the Rust
// core.
if encoding, ok := descriptor["encoding"].(map[string]any); ok {
minimum, hasMin := encoding["semantic_min_encoded"].(float64)
maximum, hasMax := encoding["semantic_max_encoded"].(float64)
if hasMin && hasMax {
if float64(encodedValue) < minimum || float64(encodedValue) > maximum {
return false, nil
}
}
}
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
}