Files
attesto-go/zk/pedersen.go
T
CodexandClaude Opus 5 34b61b1c09 fix(security): close every fixable advisory and make the unfixable one unreachable
The dependency scan reported 14 high/critical findings across the backend and
the marketplace frontend. All of them are now closed, and the scan is green for
the first time.

**Bumped, with the suite as the check.** aiohttp 3.14.1 -> 3.14.3, pyasn1 0.6.3
-> 0.6.4, pydantic-settings 2.14.1 -> 2.15.0, and cryptography 48.0.1 -> 50.0.0.
That last one crosses two majors, which is why it was flagged as blast radius
rather than a routine bump; the full backend suite passes unchanged. nanoid and
postcss in the marketplace frontend are patched and the frontend still builds.

**ecdsa has no fix and never will.** CVE-2024-23342 is a Minerva timing attack on
P-256, and the project considers side channels out of scope. It arrives through
python-jose, and only signing, key generation and ECDH are affected —
verification is not. The backend signs tenant tokens with the symmetric
JWT_SECRET, so only HMAC families are coherent there anyway.

That was true by habit, not by construction: `jwt_algorithm` had no validation at
all, so JWT_ALGORITHM=ES256 would have signed through the vulnerable path with
nothing to say so. app/core/security.py now refuses any algorithm outside
HS256/HS384/HS512, on both the encode and decode paths, and
tests/test_jwt_algorithm_guard.py fails if that control is removed. `none` is
refused alongside ES*: an unsigned token is not a lesser problem than a badly
signed one.

The advisory is accepted by exact ID with that control named, using a mechanism
added here rather than by silencing the tool. A new advisory on ecdsa still
fails, and a package whose every finding is accepted stops being listed as
vulnerable so the field keeps meaning something.

Backend 1419 passed.

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

100 lines
3.7 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")
}
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
}