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>
This commit is contained in:
Codex
2026-08-21 19:15:20 +02:00
co-authored by Claude Opus 5
parent 34b61b1c09
commit 0b881e1a74
2 changed files with 28 additions and 0 deletions
+14
View File
@@ -72,6 +72,20 @@ func VerifyOpening(descriptor map[string]any, encodedValue uint64, blindingScala
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")
+14
View File
@@ -130,6 +130,20 @@ func TestAWidenedEncodingDomainIsNotTheCurveChecksBusiness(t *testing.T) {
}
}
func TestAValueOutsideTheDeclaredDomainCannotOpen(t *testing.T) {
// "invalid", not "rejected": the check ran and answered no. The commitment
// would fail to match anyway, but for the wrong reason.
vector := loadVector(t, "provenance-private-numeric-semantic-bound-invalid")
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 TestAMalformedOpeningIsRefused(t *testing.T) {
vector := loadVector(t, "provenance-private-numeric-opening-valid")
descriptor, value, _ := openingFrom(t, vector)