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>
This commit is contained in:
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user