From 48a8eb00320cd30d47479e3cb25154114a7ed066 Mon Sep 17 00:00:00 2001 From: devon1209 Date: Mon, 23 Feb 2026 18:29:59 +0900 Subject: [PATCH 1/3] feat: add Poseidon2 SpongeHash and FieldHasher for bn254 --- ecc/bn254/fr/poseidon2/hash.go | 107 +++++++++++++++++ ecc/bn254/fr/poseidon2/poseidon2_test.go | 102 ++++++++++++++++ .../hash/poseidon2/template/hash.go.tmpl | 112 ++++++++++++++++++ .../poseidon2/template/poseidon2.test.go.tmpl | 102 ++++++++++++++++ 4 files changed, 423 insertions(+) diff --git a/ecc/bn254/fr/poseidon2/hash.go b/ecc/bn254/fr/poseidon2/hash.go index 9bc2a6ec82..75a36effd4 100644 --- a/ecc/bn254/fr/poseidon2/hash.go +++ b/ecc/bn254/fr/poseidon2/hash.go @@ -6,6 +6,7 @@ package poseidon2 import ( + "encoding/binary" "hash" "sync" @@ -34,3 +35,109 @@ func init() { return NewMerkleDamgardHasher() }) } + +// spongeHasher implements a field-element-based sponge hash using Poseidon2 +// with t>=4 (HorizenLabs constants). It mirrors gnark's in-circuit +// hash.FieldHasher interface, producing identical outputs to the in-circuit +// Poseidon2 sponge hash. +type spongeHasher struct { + inputs []fr.Element +} + +// NewFieldHasher returns a Poseidon2 sponge-based field-element hasher. +// This produces outputs compatible with gnark's in-circuit Poseidon2 hash +// (std/hash/poseidon2). +func NewFieldHasher() *spongeHasher { + return &spongeHasher{} +} + +// Write adds field elements to the hash state. +func (h *spongeHasher) Write(data ...fr.Element) { + h.inputs = append(h.inputs, data...) +} + +// Sum computes the sponge hash of the accumulated inputs. +func (h *spongeHasher) Sum() fr.Element { + return SpongeHash(h.inputs) +} + +// Reset clears the accumulated inputs. +func (h *spongeHasher) Reset() { + h.inputs = h.inputs[:0] +} + +// spongeParams returns (width, fullRounds, partialRounds) for the given +// number of inputs, matching the HorizenLabs BN254 Poseidon2 parameters. +func spongeParams(n int) (int, int, int) { + switch { + case n <= 3: + return 4, 8, 56 + case n <= 7: + return 8, 8, 57 + case n <= 11: + return 12, 8, 57 + default: + return 16, 8, 57 + } +} + +// SpongeHash computes a Poseidon2 sponge hash over field elements. +// For 2 inputs, it uses a direct t=3 permutation. For ≥3 inputs, it uses +// the sponge construction with automatic width selection (t=4,8,12,16) and +// HorizenLabs constants. The IV is len(inputs)<<64 placed in the capacity slot. +// Inputs longer than rate are absorbed in multiple blocks automatically. +// +// This matches the in-circuit Poseidon2 sponge hash used by gnark. +func SpongeHash(inputs []fr.Element) fr.Element { + n := len(inputs) + if n < 2 { + panic("poseidon2: SpongeHash requires at least 2 inputs") + } + + // 2 inputs: direct t=3 permutation (no sponge overhead) + if n == 2 { + perm := NewPermutation(3, 8, 56) + state := []fr.Element{inputs[0], inputs[1], {}} + perm.Permutation(state[:]) + return state[0] + } + + // >=3 inputs: sponge with auto-selected width + width, rf, rp := spongeParams(n) + perm := NewPermutation(width, rf, rp) + + state := make([]fr.Element, width) + // IV = len(inputs) << 64 in capacity slot (last element) + var ivBuf [fr.Bytes]byte + binary.BigEndian.PutUint64(ivBuf[fr.Bytes-16:fr.Bytes-8], uint64(n)) + state[width-1].SetBytes(ivBuf[:]) + + rate := width - 1 + cache := make([]fr.Element, rate) + cacheSize := 0 + + for i := 0; i < n; i++ { + if cacheSize == rate { + for j := 0; j < rate; j++ { + state[j].Add(&state[j], &cache[j]) + } + perm.Permutation(state) + cache[0].Set(&inputs[i]) + cacheSize = 1 + } else { + cache[cacheSize].Set(&inputs[i]) + cacheSize++ + } + } + + // Final block: pad remaining cache with zeros and absorb + for j := cacheSize; j < rate; j++ { + cache[j].SetZero() + } + for j := 0; j < rate; j++ { + state[j].Add(&state[j], &cache[j]) + } + perm.Permutation(state) + + return state[0] +} diff --git a/ecc/bn254/fr/poseidon2/poseidon2_test.go b/ecc/bn254/fr/poseidon2/poseidon2_test.go index 601667b09d..82cc43155a 100644 --- a/ecc/bn254/fr/poseidon2/poseidon2_test.go +++ b/ecc/bn254/fr/poseidon2/poseidon2_test.go @@ -142,6 +142,108 @@ func TestNewPermutationWithSeedRejectsSpecWidths(t *testing.T) { }) } +func TestSpongeHashDeterministic(t *testing.T) { + // SpongeHash must produce the same output for the same inputs + var a, b fr.Element + a.SetUint64(42) + b.SetUint64(43) + h1 := SpongeHash([]fr.Element{a, b}) + h2 := SpongeHash([]fr.Element{a, b}) + require.Equal(t, h1, h2, "SpongeHash should be deterministic") +} + +func TestSpongeHashDistinct(t *testing.T) { + // Different inputs must produce different outputs + var a, b, c fr.Element + a.SetUint64(1) + b.SetUint64(2) + c.SetUint64(3) + h1 := SpongeHash([]fr.Element{a, b}) + h2 := SpongeHash([]fr.Element{a, c}) + require.NotEqual(t, h1, h2, "different inputs should produce different hashes") +} + +func TestSpongeHashOrderMatters(t *testing.T) { + var a, b fr.Element + a.SetUint64(1) + b.SetUint64(2) + h1 := SpongeHash([]fr.Element{a, b}) + h2 := SpongeHash([]fr.Element{b, a}) + require.NotEqual(t, h1, h2, "input order should matter") +} + +func TestSpongeHashVariousWidths(t *testing.T) { + // Test that SpongeHash works for input sizes spanning all width selections + // n=2 → t=3 (direct), n=3 → t=4, n=5 → t=8, n=10 → t=12, n=14 → t=16 + testCases := []int{2, 3, 5, 7, 8, 10, 11, 12, 14, 15} + for _, n := range testCases { + inputs := make([]fr.Element, n) + for i := range inputs { + inputs[i].SetUint64(uint64(i + 1)) + } + result := SpongeHash(inputs) + // Must not be zero (with overwhelming probability) + require.False(t, result.IsZero(), "SpongeHash with %d inputs should not be zero", n) + // Must be deterministic + result2 := SpongeHash(inputs) + require.Equal(t, result, result2, "SpongeHash with %d inputs should be deterministic", n) + } +} + +func TestSpongeHashMultiBlock(t *testing.T) { + // Test multi-block absorption: n > rate for all width selections + // n=20 with t=16 (rate=15) → needs 2 absorption rounds + inputs := make([]fr.Element, 20) + for i := range inputs { + inputs[i].SetUint64(uint64(i + 1)) + } + result := SpongeHash(inputs) + require.False(t, result.IsZero(), "multi-block SpongeHash should not be zero") + + // Adding one more element should change the result + inputs2 := make([]fr.Element, 21) + copy(inputs2, inputs) + inputs2[20].SetUint64(99) + result2 := SpongeHash(inputs2) + require.NotEqual(t, result, result2, "different length inputs should produce different hashes") +} + +func TestSpongeHashPanicsOnTooFewInputs(t *testing.T) { + require.Panics(t, func() { + SpongeHash([]fr.Element{}) + }) + var a fr.Element + a.SetUint64(1) + require.Panics(t, func() { + SpongeHash([]fr.Element{a}) + }) +} + +func TestFieldHasherConsistency(t *testing.T) { + // FieldHasher wrapper must produce the same result as direct SpongeHash call + var a, b, c, d, e fr.Element + a.SetUint64(10) + b.SetUint64(20) + c.SetUint64(30) + d.SetUint64(40) + e.SetUint64(50) + inputs := []fr.Element{a, b, c, d, e} + + direct := SpongeHash(inputs) + + h := NewFieldHasher() + h.Write(a, b, c, d, e) + wrapped := h.Sum() + require.Equal(t, direct, wrapped, "FieldHasher.Sum should match SpongeHash") + + // Test Reset + h.Reset() + h.Write(a, b) + h.Write(c, d, e) + split := h.Sum() + require.Equal(t, direct, split, "split Write calls should match single Write") +} + func BenchmarkPoseidon2(b *testing.B) { h := NewPermutation(3, 8, 56) var tmp [3]fr.Element diff --git a/internal/generator/crypto/hash/poseidon2/template/hash.go.tmpl b/internal/generator/crypto/hash/poseidon2/template/hash.go.tmpl index d25d8229f7..0e07642ea5 100644 --- a/internal/generator/crypto/hash/poseidon2/template/hash.go.tmpl +++ b/internal/generator/crypto/hash/poseidon2/template/hash.go.tmpl @@ -1,4 +1,7 @@ import ( + {{- if eq .Name "bn254" }} + "encoding/binary" + {{- end }} "hash" "sync" "github.com/consensys/gnark-crypto/ecc/{{ .Name }}/fr" @@ -40,3 +43,112 @@ func init() { return NewMerkleDamgardHasher() }) } + +{{- if eq .Name "bn254" }} + +// spongeHasher implements a field-element-based sponge hash using Poseidon2 +// with t>=4 (HorizenLabs constants). It mirrors gnark's in-circuit +// hash.FieldHasher interface, producing identical outputs to the in-circuit +// Poseidon2 sponge hash. +type spongeHasher struct { + inputs []fr.Element +} + +// NewFieldHasher returns a Poseidon2 sponge-based field-element hasher. +// This produces outputs compatible with gnark's in-circuit Poseidon2 hash +// (std/hash/poseidon2). +func NewFieldHasher() *spongeHasher { + return &spongeHasher{} +} + +// Write adds field elements to the hash state. +func (h *spongeHasher) Write(data ...fr.Element) { + h.inputs = append(h.inputs, data...) +} + +// Sum computes the sponge hash of the accumulated inputs. +func (h *spongeHasher) Sum() fr.Element { + return SpongeHash(h.inputs) +} + +// Reset clears the accumulated inputs. +func (h *spongeHasher) Reset() { + h.inputs = h.inputs[:0] +} + +// spongeParams returns (width, fullRounds, partialRounds) for the given +// number of inputs, matching the HorizenLabs BN254 Poseidon2 parameters. +func spongeParams(n int) (int, int, int) { + switch { + case n <= 3: + return 4, 8, 56 + case n <= 7: + return 8, 8, 57 + case n <= 11: + return 12, 8, 57 + default: + return 16, 8, 57 + } +} + +// SpongeHash computes a Poseidon2 sponge hash over field elements. +// For 2 inputs, it uses a direct t=3 permutation. For ≥3 inputs, it uses +// the sponge construction with automatic width selection (t=4,8,12,16) and +// HorizenLabs constants. The IV is len(inputs)<<64 placed in the capacity slot. +// Inputs longer than rate are absorbed in multiple blocks automatically. +// +// This matches the in-circuit Poseidon2 sponge hash used by gnark. +func SpongeHash(inputs []fr.Element) fr.Element { + n := len(inputs) + if n < 2 { + panic("poseidon2: SpongeHash requires at least 2 inputs") + } + + // 2 inputs: direct t=3 permutation (no sponge overhead) + if n == 2 { + perm := NewPermutation(3, 8, 56) + state := []fr.Element{inputs[0], inputs[1], {}} + perm.Permutation(state[:]) + return state[0] + } + + // >=3 inputs: sponge with auto-selected width + width, rf, rp := spongeParams(n) + perm := NewPermutation(width, rf, rp) + + state := make([]fr.Element, width) + // IV = len(inputs) << 64 in capacity slot (last element) + var ivBuf [fr.Bytes]byte + binary.BigEndian.PutUint64(ivBuf[fr.Bytes-16:fr.Bytes-8], uint64(n)) + state[width-1].SetBytes(ivBuf[:]) + + rate := width - 1 + cache := make([]fr.Element, rate) + cacheSize := 0 + + for i := 0; i < n; i++ { + if cacheSize == rate { + for j := 0; j < rate; j++ { + state[j].Add(&state[j], &cache[j]) + } + perm.Permutation(state) + cache[0].Set(&inputs[i]) + cacheSize = 1 + } else { + cache[cacheSize].Set(&inputs[i]) + cacheSize++ + } + } + + // Final block: pad remaining cache with zeros and absorb + for j := cacheSize; j < rate; j++ { + cache[j].SetZero() + } + for j := 0; j < rate; j++ { + state[j].Add(&state[j], &cache[j]) + } + perm.Permutation(state) + + return state[0] +} +{{- end }} diff --git a/internal/generator/crypto/hash/poseidon2/template/poseidon2.test.go.tmpl b/internal/generator/crypto/hash/poseidon2/template/poseidon2.test.go.tmpl index 8d8c5038aa..ad1732a172 100644 --- a/internal/generator/crypto/hash/poseidon2/template/poseidon2.test.go.tmpl +++ b/internal/generator/crypto/hash/poseidon2/template/poseidon2.test.go.tmpl @@ -135,6 +135,108 @@ func TestNewPermutationWithSeedRejectsSpecWidths(t *testing.T) { _ = NewPermutationWithSeed(16, 8, 57, "seed") }) } + +func TestSpongeHashDeterministic(t *testing.T) { + // SpongeHash must produce the same output for the same inputs + var a, b fr.Element + a.SetUint64(42) + b.SetUint64(43) + h1 := SpongeHash([]fr.Element{a, b}) + h2 := SpongeHash([]fr.Element{a, b}) + require.Equal(t, h1, h2, "SpongeHash should be deterministic") +} + +func TestSpongeHashDistinct(t *testing.T) { + // Different inputs must produce different outputs + var a, b, c fr.Element + a.SetUint64(1) + b.SetUint64(2) + c.SetUint64(3) + h1 := SpongeHash([]fr.Element{a, b}) + h2 := SpongeHash([]fr.Element{a, c}) + require.NotEqual(t, h1, h2, "different inputs should produce different hashes") +} + +func TestSpongeHashOrderMatters(t *testing.T) { + var a, b fr.Element + a.SetUint64(1) + b.SetUint64(2) + h1 := SpongeHash([]fr.Element{a, b}) + h2 := SpongeHash([]fr.Element{b, a}) + require.NotEqual(t, h1, h2, "input order should matter") +} + +func TestSpongeHashVariousWidths(t *testing.T) { + // Test that SpongeHash works for input sizes spanning all width selections + // n=2 → t=3 (direct), n=3 → t=4, n=5 → t=8, n=10 → t=12, n=14 → t=16 + testCases := []int{2, 3, 5, 7, 8, 10, 11, 12, 14, 15} + for _, n := range testCases { + inputs := make([]fr.Element, n) + for i := range inputs { + inputs[i].SetUint64(uint64(i + 1)) + } + result := SpongeHash(inputs) + // Must not be zero (with overwhelming probability) + require.False(t, result.IsZero(), "SpongeHash with %d inputs should not be zero", n) + // Must be deterministic + result2 := SpongeHash(inputs) + require.Equal(t, result, result2, "SpongeHash with %d inputs should be deterministic", n) + } +} + +func TestSpongeHashMultiBlock(t *testing.T) { + // Test multi-block absorption: n > rate for all width selections + // n=20 with t=16 (rate=15) → needs 2 absorption rounds + inputs := make([]fr.Element, 20) + for i := range inputs { + inputs[i].SetUint64(uint64(i + 1)) + } + result := SpongeHash(inputs) + require.False(t, result.IsZero(), "multi-block SpongeHash should not be zero") + + // Adding one more element should change the result + inputs2 := make([]fr.Element, 21) + copy(inputs2, inputs) + inputs2[20].SetUint64(99) + result2 := SpongeHash(inputs2) + require.NotEqual(t, result, result2, "different length inputs should produce different hashes") +} + +func TestSpongeHashPanicsOnTooFewInputs(t *testing.T) { + require.Panics(t, func() { + SpongeHash([]fr.Element{}) + }) + var a fr.Element + a.SetUint64(1) + require.Panics(t, func() { + SpongeHash([]fr.Element{a}) + }) +} + +func TestFieldHasherConsistency(t *testing.T) { + // FieldHasher wrapper must produce the same result as direct SpongeHash call + var a, b, c, d, e fr.Element + a.SetUint64(10) + b.SetUint64(20) + c.SetUint64(30) + d.SetUint64(40) + e.SetUint64(50) + inputs := []fr.Element{a, b, c, d, e} + + direct := SpongeHash(inputs) + + h := NewFieldHasher() + h.Write(a, b, c, d, e) + wrapped := h.Sum() + require.Equal(t, direct, wrapped, "FieldHasher.Sum should match SpongeHash") + + // Test Reset + h.Reset() + h.Write(a, b) + h.Write(c, d, e) + split := h.Sum() + require.Equal(t, direct, split, "split Write calls should match single Write") +} {{- else }} func TestExternalMatrix(t *testing.T) { t.Skip("skipping test - it is initialized for width=4 for which we don't have the diagonal matrix") From a3c88a9adcbdad2bb48b1a03534b8746f3e3ab95 Mon Sep 17 00:00:00 2001 From: devon1209 Date: Mon, 23 Feb 2026 18:35:45 +0900 Subject: [PATCH 2/3] feat: add field-element EdDSA (SignField/VerifyField) for bn254 --- ecc/bn254/twistededwards/eddsa/eddsa.go | 133 +++++++++++++++++ ecc/bn254/twistededwards/eddsa/eddsa_test.go | 44 ++++++ .../edwards/eddsa/template/eddsa.go.tmpl | 139 ++++++++++++++++++ .../edwards/eddsa/template/eddsa.test.go.tmpl | 49 ++++++ 4 files changed, 365 insertions(+) diff --git a/ecc/bn254/twistededwards/eddsa/eddsa.go b/ecc/bn254/twistededwards/eddsa/eddsa.go index 08329fe774..890e11dff8 100644 --- a/ecc/bn254/twistededwards/eddsa/eddsa.go +++ b/ecc/bn254/twistededwards/eddsa/eddsa.go @@ -21,6 +21,18 @@ import ( var errNotOnCurve = errors.New("point not on curve") var errHashNeeded = errors.New("hFunc cannot be nil. We need a hash for Fiat-Shamir") +// FieldHasher hashes field elements into a short digest. +// This mirrors gnark's std/hash.FieldHasher interface for off-chain use, +// enabling EdDSA signatures that match the in-circuit hash domain exactly. +type FieldHasher interface { + // Sum computes the hash of the internal state of the hash function. + Sum() fr.Element + // Write adds field elements to the internal state. + Write(data ...fr.Element) + // Reset empties the internal state. + Reset() +} + const ( sizeFr = fr.Bytes sizePublicKey = sizeFr @@ -244,3 +256,124 @@ func (pub *PublicKey) Verify(sigBin, message []byte, hFunc hash.Hash) (bool, err return true, nil } + +// SignField signs a field element message using a FieldHasher. +// This produces signatures compatible with in-circuit EdDSA verification +// (e.g., gnark's std/signature/eddsa) when the same field-based hash is used. +// +// The hash is computed as: H(R.X, R.Y, A.X, A.Y, msg) over field elements, +// matching the in-circuit hash domain exactly. +func (privKey *PrivateKey) SignField(msg fr.Element, hFunc FieldHasher) ([]byte, error) { + + if hFunc == nil { + return nil, errHashNeeded + } + + curveParams := twistededwards.GetEdwardsCurve() + + var res Signature + + // blinding factor for the private key + // blindingFactorBigInt = h(randomness_source || msg_bytes)[:sizeFr] + var blindingFactorBigInt big.Int + + msgBytes := msg.Bytes() + randSrc := make([]byte, 32+sizeFr) + copy(randSrc, privKey.randSrc[:]) + copy(randSrc[32:], msgBytes[:]) + + // randBytes = H(randSrc) + blindingFactorBytes := blake2b.Sum512(randSrc[:]) // deterministic nonce + blindingFactorBigInt.SetBytes(blindingFactorBytes[:sizeFr]) + + // compute R = randScalar*Base + res.R.ScalarMultiplication(&curveParams.Base, &blindingFactorBigInt) + if !res.R.IsOnCurve() { + return nil, errNotOnCurve + } + + // compute H(R, A, M) using field-element based hash + hFunc.Reset() + hFunc.Write(res.R.X, res.R.Y, privKey.PublicKey.A.X, privKey.PublicKey.A.Y, msg) + + var hramInt big.Int + hramFr := hFunc.Sum() + hramFr.BigInt(&hramInt) + + // Compute s = randScalarInt + H(R,A,M)*S + // going with big int to do ops mod curve order + var bscalar, bs big.Int + bscalar.SetBytes(privKey.scalar[:]) + bs.Mul(&hramInt, &bscalar). + Add(&bs, &blindingFactorBigInt). + Mod(&bs, &curveParams.Order) + sb := bs.Bytes() + if len(sb) < sizeFr { + offset := make([]byte, sizeFr-len(sb)) + sb = append(offset, sb...) + } + copy(res.S[:], sb[:]) + + return res.Bytes(), nil +} + +// VerifyField verifies an EdDSA signature against a field element message +// using a FieldHasher. This is the off-chain counterpart of in-circuit +// EdDSA verification (e.g., gnark's std/signature/eddsa). +// +// The hash is computed as: H(R.X, R.Y, A.X, A.Y, msg) over field elements. +func (pub *PublicKey) VerifyField(sigBin []byte, msg fr.Element, hFunc FieldHasher) (bool, error) { + + if hFunc == nil { + return false, errHashNeeded + } + + curveParams := twistededwards.GetEdwardsCurve() + + // verify that pubKey is on the curve + if !pub.A.IsOnCurve() { + return false, errNotOnCurve + } + + // Deserialize the signature + var sig Signature + if _, err := sig.SetBytes(sigBin); err != nil { + return false, err + } + + // compute H(R, A, M) using field-element based hash + hFunc.Reset() + hFunc.Write(sig.R.X, sig.R.Y, pub.A.X, pub.A.Y, msg) + + var hramInt big.Int + hramFr := hFunc.Sum() + hramFr.BigInt(&hramInt) + + // lhs = cofactor*S*Base + var lhs twistededwards.PointAffine + var bCofactor, bs big.Int + curveParams.Cofactor.BigInt(&bCofactor) + bs.SetBytes(sig.S[:]) + lhs.ScalarMultiplication(&curveParams.Base, &bs). + ScalarMultiplication(&lhs, &bCofactor) + + if !lhs.IsOnCurve() { + return false, errNotOnCurve + } + + // rhs = cofactor*(R + H(R,A,M)*A) + var rhs twistededwards.PointAffine + rhs.ScalarMultiplication(&pub.A, &hramInt). + Add(&rhs, &sig.R). + ScalarMultiplication(&rhs, &bCofactor) + if !rhs.IsOnCurve() { + return false, errNotOnCurve + } + + // verifies that cofactor*S*Base=cofactor*(R + H(R,A,M)*A) + if !lhs.X.Equal(&rhs.X) || !lhs.Y.Equal(&rhs.Y) { + return false, nil + } + + return true, nil +} diff --git a/ecc/bn254/twistededwards/eddsa/eddsa_test.go b/ecc/bn254/twistededwards/eddsa/eddsa_test.go index 189f0f7465..b56d3596fe 100644 --- a/ecc/bn254/twistededwards/eddsa/eddsa_test.go +++ b/ecc/bn254/twistededwards/eddsa/eddsa_test.go @@ -17,6 +17,7 @@ import ( "github.com/consensys/gnark-crypto/ecc/bn254/fr" "github.com/consensys/gnark-crypto/ecc/bn254/fr/mimc" + "github.com/consensys/gnark-crypto/ecc/bn254/fr/poseidon2" "github.com/consensys/gnark-crypto/ecc/bn254/twistededwards" "github.com/consensys/gnark-crypto/hash" ) @@ -212,6 +213,49 @@ func TestEddsaMIMC(t *testing.T) { } } +func TestEddsaFieldPoseidon2(t *testing.T) { + + src := rand.NewSource(0) + r := rand.New(src) //#nosec G404 weak rng is fine here + + // create eddsa key pair + privKey, err := GenerateKey(r) + if err != nil { + t.Fatal(err) + } + pubKey := privKey.PublicKey + + hFunc := poseidon2.NewFieldHasher() + + var msg fr.Element + msg.SetString("44717650746155748460101257525078853138837311576962212923649547644148297035978") + + // sign the message using field-element based hash + signature, err := privKey.SignField(msg, hFunc) + if err != nil { + t.Fatal(err) + } + + // verifies correct msg + res, err := pubKey.VerifyField(signature, msg, hFunc) + if err != nil { + t.Fatal(err) + } + if !res { + t.Fatal("VerifyField correct signature should return true") + } + + // verifies wrong msg + var wrongMsg fr.Element + wrongMsg.SetString("44717650746155748460101257525078853138837311576962212923649547644148297035979") + res, err = pubKey.VerifyField(signature, wrongMsg, hFunc) + if err != nil { + t.Fatal(err) + } + if res { + t.Fatal("VerifyField wrong message should return false") + } +} func TestEddsaSHA256(t *testing.T) { diff --git a/internal/generator/edwards/eddsa/template/eddsa.go.tmpl b/internal/generator/edwards/eddsa/template/eddsa.go.tmpl index fb77e694a2..eee74a4bc8 100644 --- a/internal/generator/edwards/eddsa/template/eddsa.go.tmpl +++ b/internal/generator/edwards/eddsa/template/eddsa.go.tmpl @@ -14,6 +14,21 @@ import ( var errNotOnCurve = errors.New("point not on curve") var errHashNeeded = errors.New("hFunc cannot be nil. We need a hash for Fiat-Shamir") +{{- if eq .Name "bn254" }} + +// FieldHasher hashes field elements into a short digest. +// This mirrors gnark's std/hash.FieldHasher interface for off-chain use, +// enabling EdDSA signatures that match the in-circuit hash domain exactly. +type FieldHasher interface { + // Sum computes the hash of the internal state of the hash function. + Sum() fr.Element + // Write adds field elements to the internal state. + Write(data ...fr.Element) + // Reset empties the internal state. + Reset() +} +{{- end }} + const ( sizeFr = fr.Bytes sizePublicKey = sizeFr @@ -272,3 +287,127 @@ func (pub *PublicKey) Verify(sigBin, message []byte, hFunc hash.Hash) (bool, err return true, nil } + +{{- if eq .Name "bn254" }} + +// SignField signs a field element message using a FieldHasher. +// This produces signatures compatible with in-circuit EdDSA verification +// (e.g., gnark's std/signature/eddsa) when the same field-based hash is used. +// +// The hash is computed as: H(R.X, R.Y, A.X, A.Y, msg) over field elements, +// matching the in-circuit hash domain exactly. +func (privKey *PrivateKey) SignField(msg fr.Element, hFunc FieldHasher) ([]byte, error) { + + if hFunc == nil { + return nil, errHashNeeded + } + + curveParams := twistededwards.GetEdwardsCurve() + + var res Signature + + // blinding factor for the private key + // blindingFactorBigInt = h(randomness_source || msg_bytes)[:sizeFr] + var blindingFactorBigInt big.Int + + msgBytes := msg.Bytes() + randSrc := make([]byte, 32+sizeFr) + copy(randSrc, privKey.randSrc[:]) + copy(randSrc[32:], msgBytes[:]) + + // randBytes = H(randSrc) + blindingFactorBytes := blake2b.Sum512(randSrc[:]) // deterministic nonce + blindingFactorBigInt.SetBytes(blindingFactorBytes[:sizeFr]) + + // compute R = randScalar*Base + res.R.ScalarMultiplication(&curveParams.Base, &blindingFactorBigInt) + if !res.R.IsOnCurve() { + return nil, errNotOnCurve + } + + // compute H(R, A, M) using field-element based hash + hFunc.Reset() + hFunc.Write(res.R.X, res.R.Y, privKey.PublicKey.A.X, privKey.PublicKey.A.Y, msg) + + var hramInt big.Int + hramFr := hFunc.Sum() + hramFr.BigInt(&hramInt) + + // Compute s = randScalarInt + H(R,A,M)*S + // going with big int to do ops mod curve order + var bscalar, bs big.Int + bscalar.SetBytes(privKey.scalar[:]) + bs.Mul(&hramInt, &bscalar). + Add(&bs, &blindingFactorBigInt). + Mod(&bs, &curveParams.Order) + sb := bs.Bytes() + if len(sb) < sizeFr { + offset := make([]byte, sizeFr-len(sb)) + sb = append(offset, sb...) + } + copy(res.S[:], sb[:]) + + return res.Bytes(), nil +} + +// VerifyField verifies an EdDSA signature against a field element message +// using a FieldHasher. This is the off-chain counterpart of in-circuit +// EdDSA verification (e.g., gnark's std/signature/eddsa). +// +// The hash is computed as: H(R.X, R.Y, A.X, A.Y, msg) over field elements. +func (pub *PublicKey) VerifyField(sigBin []byte, msg fr.Element, hFunc FieldHasher) (bool, error) { + + if hFunc == nil { + return false, errHashNeeded + } + + curveParams := twistededwards.GetEdwardsCurve() + + // verify that pubKey is on the curve + if !pub.A.IsOnCurve() { + return false, errNotOnCurve + } + + // Deserialize the signature + var sig Signature + if _, err := sig.SetBytes(sigBin); err != nil { + return false, err + } + + // compute H(R, A, M) using field-element based hash + hFunc.Reset() + hFunc.Write(sig.R.X, sig.R.Y, pub.A.X, pub.A.Y, msg) + + var hramInt big.Int + hramFr := hFunc.Sum() + hramFr.BigInt(&hramInt) + + // lhs = cofactor*S*Base + var lhs twistededwards.PointAffine + var bCofactor, bs big.Int + curveParams.Cofactor.BigInt(&bCofactor) + bs.SetBytes(sig.S[:]) + lhs.ScalarMultiplication(&curveParams.Base, &bs). + ScalarMultiplication(&lhs, &bCofactor) + + if !lhs.IsOnCurve() { + return false, errNotOnCurve + } + + // rhs = cofactor*(R + H(R,A,M)*A) + var rhs twistededwards.PointAffine + rhs.ScalarMultiplication(&pub.A, &hramInt). + Add(&rhs, &sig.R). + ScalarMultiplication(&rhs, &bCofactor) + if !rhs.IsOnCurve() { + return false, errNotOnCurve + } + + // verifies that cofactor*S*Base=cofactor*(R + H(R,A,M)*A) + if !lhs.X.Equal(&rhs.X) || !lhs.Y.Equal(&rhs.Y) { + return false, nil + } + + return true, nil +} +{{- end }} \ No newline at end of file diff --git a/internal/generator/edwards/eddsa/template/eddsa.test.go.tmpl b/internal/generator/edwards/eddsa/template/eddsa.test.go.tmpl index 180af3987e..eb9451f626 100644 --- a/internal/generator/edwards/eddsa/template/eddsa.test.go.tmpl +++ b/internal/generator/edwards/eddsa/template/eddsa.test.go.tmpl @@ -12,6 +12,9 @@ import ( "github.com/consensys/gnark-crypto/ecc/{{.Name}}/twistededwards" "github.com/consensys/gnark-crypto/ecc/{{.Name}}/fr" "github.com/consensys/gnark-crypto/ecc/{{.Name}}/fr/mimc" + {{- if eq .Name "bn254" }} + "github.com/consensys/gnark-crypto/ecc/{{.Name}}/fr/poseidon2" + {{- end }} ) @@ -207,6 +210,52 @@ func TestEddsaMIMC(t *testing.T) { } +{{- if eq .Name "bn254" }} +func TestEddsaFieldPoseidon2(t *testing.T) { + + src := rand.NewSource(0) + r := rand.New(src) //#nosec G404 weak rng is fine here + + // create eddsa key pair + privKey, err := GenerateKey(r) + if err != nil { + t.Fatal(err) + } + pubKey := privKey.PublicKey + + hFunc := poseidon2.NewFieldHasher() + + var msg fr.Element + msg.SetString("44717650746155748460101257525078853138837311576962212923649547644148297035978") + + // sign the message using field-element based hash + signature, err := privKey.SignField(msg, hFunc) + if err != nil { + t.Fatal(err) + } + + // verifies correct msg + res, err := pubKey.VerifyField(signature, msg, hFunc) + if err != nil { + t.Fatal(err) + } + if !res { + t.Fatal("VerifyField correct signature should return true") + } + + // verifies wrong msg + var wrongMsg fr.Element + wrongMsg.SetString("44717650746155748460101257525078853138837311576962212923649547644148297035979") + res, err = pubKey.VerifyField(signature, wrongMsg, hFunc) + if err != nil { + t.Fatal(err) + } + if res { + t.Fatal("VerifyField wrong message should return false") + } +} +{{- end }} + func TestEddsaSHA256(t *testing.T) { src := rand.NewSource(0) From 425c29788cbe4ca0241e6e7da3e45b776b87ff68 Mon Sep 17 00:00:00 2001 From: devon1209 Date: Mon, 23 Feb 2026 22:55:10 +0900 Subject: [PATCH 3/3] fix: add domain separation to SignField nonce to prevent cross-API key recovery --- ecc/bn254/twistededwards/eddsa/eddsa.go | 9 +++++++-- internal/generator/edwards/eddsa/template/eddsa.go.tmpl | 9 +++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/ecc/bn254/twistededwards/eddsa/eddsa.go b/ecc/bn254/twistededwards/eddsa/eddsa.go index 890e11dff8..35bb3467e5 100644 --- a/ecc/bn254/twistededwards/eddsa/eddsa.go +++ b/ecc/bn254/twistededwards/eddsa/eddsa.go @@ -278,9 +278,14 @@ func (privKey *PrivateKey) SignField(msg fr.Element, hFunc FieldHasher) ([]byte, var blindingFactorBigInt big.Int msgBytes := msg.Bytes() - randSrc := make([]byte, 32+sizeFr) + // Domain-separated nonce: prepend 0x01 to prevent nonce reuse with Sign(). + // Sign() uses Blake2b(randSrc || msg_bytes) without a domain tag (implicitly 0x00). + // Without this, Sign(msg.Bytes(), h1) and SignField(msg, h2) would share the + // same nonce R but produce different S values, enabling private key recovery. + randSrc := make([]byte, 32+1+sizeFr) copy(randSrc, privKey.randSrc[:]) - copy(randSrc[32:], msgBytes[:]) + randSrc[32] = 0x01 // domain separation tag for SignField + copy(randSrc[33:], msgBytes[:]) // randBytes = H(randSrc) blindingFactorBytes := blake2b.Sum512(randSrc[:]) // deterministic nonce diff --git a/internal/generator/edwards/eddsa/template/eddsa.go.tmpl b/internal/generator/edwards/eddsa/template/eddsa.go.tmpl index eee74a4bc8..3ac41c85eb 100644 --- a/internal/generator/edwards/eddsa/template/eddsa.go.tmpl +++ b/internal/generator/edwards/eddsa/template/eddsa.go.tmpl @@ -311,9 +311,14 @@ func (privKey *PrivateKey) SignField(msg fr.Element, hFunc FieldHasher) ([]byte, var blindingFactorBigInt big.Int msgBytes := msg.Bytes() - randSrc := make([]byte, 32+sizeFr) + // Domain-separated nonce: prepend 0x01 to prevent nonce reuse with Sign(). + // Sign() uses Blake2b(randSrc || msg_bytes) without a domain tag (implicitly 0x00). + // Without this, Sign(msg.Bytes(), h1) and SignField(msg, h2) would share the + // same nonce R but produce different S values, enabling private key recovery. + randSrc := make([]byte, 32+1+sizeFr) copy(randSrc, privKey.randSrc[:]) - copy(randSrc[32:], msgBytes[:]) + randSrc[32] = 0x01 // domain separation tag for SignField + copy(randSrc[33:], msgBytes[:]) // randBytes = H(randSrc) blindingFactorBytes := blake2b.Sum512(randSrc[:]) // deterministic nonce