pedersen: add deterministic NewGeneratorsFromSeed for cross-language KATs

NewGeneratorsFromSeed(seed [32]byte) derives (G, H) deterministically via
RFC 9380 hash-to-curve (SVDW) on BN254 G1, with msg = seed || u64_le(index)
and DST = "LUX_PEDERSEN_SEEDED_GEN_V1". BN254 G1 has cofactor 1, so outputs
are subgroup-correct without clearing.

Existing crypto/rand-backed NewGenerators is unchanged.

Tests: 5 same-seed determinism cases, 5 cross-seed isolation cases, 1
homomorphism check on the seeded basis, 1 frozen golden vector for the
incrementing seed {0..31} so future C++/Rust KAT generators can be cross-
checked against Go byte-for-byte.
This commit is contained in:
Hanzo AI
2025-12-28 08:57:44 -08:00
parent bebcd1cb1a
commit 8e28780bbf
2 changed files with 245 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pedersen
import (
bn254 "github.com/consensys/gnark-crypto/ecc/bn254"
)
// SeededGenDST is the domain separation tag used by NewGeneratorsFromSeed.
// It is exported so cross-language KAT generators (C++, Rust) can replicate
// the derivation byte-for-byte.
const SeededGenDST = "LUX_PEDERSEN_SEEDED_GEN_V1"
// NewGeneratorsFromSeed deterministically derives the Pedersen generators
// (G, H) from a 32-byte seed using RFC 9380 hash-to-curve (SVDW map) on
// BN254 G1.
//
// Derivation algorithm (replicate exactly in C++/Rust for KAT parity):
//
// 1. msg_i = seed (32 bytes) || u64_le(i) for i in {0, 1}
// 2. P_i = bn254.HashToG1(msg_i, dst=SeededGenDST)
// 3. G = P_0, H = P_1
//
// where u64_le(i) is the 8-byte little-endian encoding of i. Because BN254
// G1 has cofactor 1, every output of HashToG1 is in the prime-order subgroup,
// so no clearing is needed.
//
// Same seed → identical (G, H) on every machine, every run.
//
// Use this constructor for cross-language Known-Answer-Test (KAT) generation
// or for any setting that requires reproducibility. For production where a
// fresh, non-reproducible basis is desired, use NewGenerators(nil) instead.
func NewGeneratorsFromSeed(seed [32]byte) (*Generators, error) {
g, err := hashIndexedG1(seed, 0)
if err != nil {
return nil, err
}
h, err := hashIndexedG1(seed, 1)
if err != nil {
return nil, err
}
if g.Equal(&h) {
return nil, ErrIdenticalGenerators
}
gen := &Generators{}
gen.G.FromAffine(&g)
gen.H.FromAffine(&h)
return gen, nil
}
// hashIndexedG1 returns HashToG1(seed || u64_le(index), dst=SeededGenDST).
func hashIndexedG1(seed [32]byte, index uint64) (bn254.G1Affine, error) {
var msg [40]byte
copy(msg[:32], seed[:])
// little-endian u64
for i := uint64(0); i < 8; i++ {
msg[32+i] = byte(index >> (8 * i))
}
return bn254.HashToG1(msg[:], []byte(SeededGenDST))
}
+184
View File
@@ -0,0 +1,184 @@
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package pedersen
import (
"encoding/hex"
"testing"
bn254 "github.com/consensys/gnark-crypto/ecc/bn254"
"github.com/consensys/gnark-crypto/ecc/bn254/fr"
)
func affineHex(p *bn254.G1Affine) string {
b := p.Bytes() // gnark-crypto compressed form: 32 bytes (X with Y-parity flags)
return hex.EncodeToString(b[:])
}
func toAffine(g *Generators) (bn254.G1Affine, bn254.G1Affine) {
var gA, hA bn254.G1Affine
gA.FromJacobian(&g.G)
hA.FromJacobian(&g.H)
return gA, hA
}
// TestNewGeneratorsFromSeed_Deterministic: same seed → same generators.
func TestNewGeneratorsFromSeed_Deterministic(t *testing.T) {
cases := [][32]byte{
zeroSeed(),
oneSeed(),
incrementingSeed(),
highBitSeed(),
mixedSeed(),
}
for i, seed := range cases {
a, err := NewGeneratorsFromSeed(seed)
if err != nil {
t.Fatalf("case %d: a: %v", i, err)
}
b, err := NewGeneratorsFromSeed(seed)
if err != nil {
t.Fatalf("case %d: b: %v", i, err)
}
ag, ah := toAffine(a)
bg, bh := toAffine(b)
if !ag.Equal(&bg) || !ah.Equal(&bh) {
t.Fatalf("case %d: non-deterministic output for identical seed", i)
}
if !ag.IsInSubGroup() || !ah.IsInSubGroup() {
t.Fatalf("case %d: derived generator not in prime-order subgroup", i)
}
if ag.Equal(&ah) {
t.Fatalf("case %d: G and H must be independent", i)
}
}
}
// TestNewGeneratorsFromSeed_DistinctSeeds: different seeds → different generators.
func TestNewGeneratorsFromSeed_DistinctSeeds(t *testing.T) {
pairs := [][2][32]byte{
{zeroSeed(), oneSeed()},
{zeroSeed(), incrementingSeed()},
{oneSeed(), highBitSeed()},
{incrementingSeed(), mixedSeed()},
{highBitSeed(), mixedSeed()},
}
for i, p := range pairs {
a, err := NewGeneratorsFromSeed(p[0])
if err != nil {
t.Fatalf("case %d: a: %v", i, err)
}
b, err := NewGeneratorsFromSeed(p[1])
if err != nil {
t.Fatalf("case %d: b: %v", i, err)
}
ag, ah := toAffine(a)
bg, bh := toAffine(b)
if ag.Equal(&bg) {
t.Fatalf("case %d: distinct seeds produced identical G", i)
}
if ah.Equal(&bh) {
t.Fatalf("case %d: distinct seeds produced identical H", i)
}
}
}
// TestNewGeneratorsFromSeed_CommitsHomomorphic: derived basis still satisfies
// the Pedersen homomorphism: Commit(m1+m2, r1+r2) == Commit(m1, r1) + Commit(m2, r2).
func TestNewGeneratorsFromSeed_CommitsHomomorphic(t *testing.T) {
gens, err := NewGeneratorsFromSeed(incrementingSeed())
if err != nil {
t.Fatal(err)
}
var m1, r1, m2, r2 fr.Element
m1.SetUint64(7)
r1.SetUint64(13)
m2.SetUint64(5)
r2.SetUint64(11)
c1 := gens.Commit(&m1, &r1)
c2 := gens.Commit(&m2, &r2)
var mSum, rSum fr.Element
mSum.Add(&m1, &m2)
rSum.Add(&r1, &r2)
cSum := gens.Commit(&mSum, &rSum)
var aJ, bJ bn254.G1Jac
aJ.FromAffine(&c1)
bJ.FromAffine(&c2)
aJ.AddAssign(&bJ)
var got bn254.G1Affine
got.FromJacobian(&aJ)
if !got.Equal(&cSum) {
t.Fatalf("homomorphism violated for seeded generators")
}
}
// TestNewGeneratorsFromSeed_GoldenVector freezes a hard-coded expected output
// for seed = {0,1,...,31}. Any future change to the derivation algorithm will
// trip this test, forcing a coordinated update to the C++/Rust KAT generators.
//
// Encoding: gnark-crypto bn254.G1Affine.Bytes() is 32 bytes, compressed
// big-endian (X coord with Y-parity in top bits, per the gnark-crypto
// internal compressed form).
func TestNewGeneratorsFromSeed_GoldenVector(t *testing.T) {
gens, err := NewGeneratorsFromSeed(incrementingSeed())
if err != nil {
t.Fatal(err)
}
gA, hA := toAffine(gens)
gotG := affineHex(&gA)
gotH := affineHex(&hA)
const wantG = "afba7c7a97100c5eb0ec96758698779b5d8d38d228bcdb7c85a4c1626ea5247a"
const wantH = "abc19b5bad508d8e7b944a37812a342cdbaa5946f0b3fd854805820c006c6110"
if gotG != wantG {
t.Fatalf("golden G mismatch (derivation regression):\n got=%s\nwant=%s", gotG, wantG)
}
if gotH != wantH {
t.Fatalf("golden H mismatch (derivation regression):\n got=%s\nwant=%s", gotH, wantH)
}
}
// --- helpers ---
func zeroSeed() [32]byte {
var s [32]byte
return s
}
func oneSeed() [32]byte {
var s [32]byte
for i := range s {
s[i] = 1
}
return s
}
func incrementingSeed() [32]byte {
var s [32]byte
for i := range s {
s[i] = byte(i)
}
return s
}
func highBitSeed() [32]byte {
var s [32]byte
for i := range s {
s[i] = 0x80
}
return s
}
func mixedSeed() [32]byte {
var s [32]byte
for i := range s {
s[i] = byte(0xA5 ^ i)
}
return s
}