From d651b893dea0291db4fbb65357a663610757cd36 Mon Sep 17 00:00:00 2001 From: Ivo Kubjas Date: Thu, 5 Sep 2024 18:07:51 +0200 Subject: [PATCH] feat: allow configurable hash-to-field function for Groth16 Solidity verifier (#1102) * fix: set dynamic default Solidity version pragma * feat: add option for selecting Solidity compatible options * feat: add configurable hash function to exportSolidity * feat: prepend default hash function to allow override * feat: use chosen hash function in Solidity contract * chore: generate * test: add test for the options --- backend/groth16/bn254/solidity.go | 4 +- backend/groth16/bn254/verify.go | 38 +++- backend/plonk/bn254/verify.go | 3 + backend/solidity/solidity.go | 76 +++++++- backend/solidity/solidity_test.go | 176 ++++++++++++++++++ .../zkpschemes/groth16/groth16.verify.go.tmpl | 41 +++- .../zkpschemes/plonk/plonk.verify.go.tmpl | 3 + test/assert_checkcircuit.go | 8 +- test/assert_solidity.go | 5 - 9 files changed, 328 insertions(+), 26 deletions(-) create mode 100644 backend/solidity/solidity_test.go diff --git a/backend/groth16/bn254/solidity.go b/backend/groth16/bn254/solidity.go index a8620efe7..401ecd776 100644 --- a/backend/groth16/bn254/solidity.go +++ b/backend/groth16/bn254/solidity.go @@ -566,7 +566,7 @@ contract Verifier { {{- end }} publicCommitments[{{$i}}] = uint256( - sha256( + {{ hashFnName }}( abi.encodePacked( commitments[{{mul $i 2}}], commitments[{{sum (mul $i 2) 1}}], @@ -713,7 +713,7 @@ contract Verifier { {{- end }} publicCommitments[{{$i}}] = uint256( - sha256( + {{ hashFnName }}( abi.encodePacked( commitments[{{mul $i 2}}], commitments[{{sum (mul $i 2) 1}}], diff --git a/backend/groth16/bn254/verify.go b/backend/groth16/bn254/verify.go index b275d0109..ab9e4c132 100644 --- a/backend/groth16/bn254/verify.go +++ b/backend/groth16/bn254/verify.go @@ -17,9 +17,12 @@ package groth16 import ( + "bytes" + "crypto/sha256" "errors" "fmt" "github.com/consensys/gnark-crypto/ecc/bn254/fp" + "golang.org/x/crypto/sha3" "io" "math/big" "text/template" @@ -151,6 +154,32 @@ func Verify(proof *Proof, vk *VerifyingKey, publicWitness fr.Vector, opts ...bac // // See https://github.com/ConsenSys/gnark-tests for example usage. func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.ExportOption) error { + cfg, err := solidity.NewExportConfig(exportOpts...) + log := logger.Logger() + if err != nil { + return err + } + if cfg.HashToFieldFn == nil { + // set the target hash function to legacy keccak256 as it is the default for `solidity.WithTargetSolidityVerifier`` + cfg.HashToFieldFn = sha3.NewLegacyKeccak256() + log.Debug().Msg("hash to field function not set, using keccak256 as default") + } + // a bit hacky way to understand what hash function is provided. We already + // receive instance of hash function but it is difficult to compare it with + // sha256.New() or sha3.NewLegacyKeccak256() directly. + // + // So, we hash an empty input and compare the outputs. + cfg.HashToFieldFn.Reset() + hashBts := cfg.HashToFieldFn.Sum(nil) + var hashFnName string + if bytes.Equal(hashBts, sha256.New().Sum(nil)) { + hashFnName = "sha256" + } else if bytes.Equal(hashBts, sha3.NewLegacyKeccak256().Sum(nil)) { + hashFnName = "keccak256" + } else { + return fmt.Errorf("unsupported hash function used, only supported sha256 and legacy keccak256") + } + cfg.HashToFieldFn.Reset() helpers := template.FuncMap{ "sum": func(a, b int) int { return a + b @@ -173,9 +202,11 @@ func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.Expor x.BigInt(bv) return bv.String() }, + "hashFnName": func() string { + return hashFnName + }, } - log := logger.Logger() if len(vk.PublicAndCommitmentCommitted) > 1 { log.Warn().Msg("exporting solidity verifier with more than one commitment is not supported") } else if len(vk.PublicAndCommitmentCommitted) == 1 { @@ -195,11 +226,6 @@ func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.Expor vk.G2.Gamma, vk.G2.gammaNeg = vk.G2.gammaNeg, vk.G2.Gamma vk.G2.Delta, vk.G2.deltaNeg = vk.G2.deltaNeg, vk.G2.Delta - cfg, err := solidity.NewExportConfig(exportOpts...) - if err != nil { - return err - } - // execute template err = tmpl.Execute(w, struct { Cfg solidity.ExportConfig diff --git a/backend/plonk/bn254/verify.go b/backend/plonk/bn254/verify.go index 2197228b3..c42e1aeb6 100644 --- a/backend/plonk/bn254/verify.go +++ b/backend/plonk/bn254/verify.go @@ -434,6 +434,9 @@ func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.Expor if err != nil { return err } + if cfg.HashToFieldFn != nil { + return fmt.Errorf("setting hash to field function is not supported for PLONK Solidity export. Hash function is hardcoded to RFC9380") + } return t.Execute(w, struct { Cfg solidity.ExportConfig diff --git a/backend/solidity/solidity.go b/backend/solidity/solidity.go index e5a5a3500..1dbe5c466 100644 --- a/backend/solidity/solidity.go +++ b/backend/solidity/solidity.go @@ -1,5 +1,13 @@ package solidity +import ( + "fmt" + "hash" + + "github.com/consensys/gnark/backend" + "golang.org/x/crypto/sha3" +) + // ExportOption defines option for altering the behavior of the prover in // Prove, ReadAndProve and IsSolved methods. See the descriptions of functions // returning instances of this type for implemented options. @@ -8,13 +16,15 @@ type ExportOption func(*ExportConfig) error // ExportConfig is the configuration for the prover with the options applied. type ExportConfig struct { PragmaVersion string + HashToFieldFn hash.Hash } // NewExportConfig returns a default ExportConfig with given export options opts // applied. func NewExportConfig(opts ...ExportOption) (ExportConfig, error) { config := ExportConfig{ - PragmaVersion: "0.8.24", + // we set default pragma version to 0.8.0+ to avoid needing to sync Solidity CI all the time + PragmaVersion: "^0.8.0", } for _, option := range opts { if err := option(&config); err != nil { @@ -31,3 +41,67 @@ func WithPragmaVersion(version string) ExportOption { return nil } } + +// WithHashToFieldFunction changes the hash function used for hashing +// bytes to field. If not set then the default hash function based on RFC 9380 +// is used. Used mainly for compatibility between different systems and +// efficient recursion. +func WithHashToFieldFunction(hFunc hash.Hash) ExportOption { + return func(cfg *ExportConfig) error { + cfg.HashToFieldFn = hFunc + return nil + } +} + +// WithProverTargetSolidityVerifier returns a prover option that sets all the +// necessary prover options which are suitable for verifying the proofs in the +// Solidity verifier. +// +// For PLONK this is a no-op option as the Solidity verifier is directly +// compatible with the default prover options. Regardless, it is recommended to +// use this option for consistency and possible future changes in the Solidity +// verifier. +// +// For Groth16 this option sets the hash function used for hashing bytes to +// field to [sha3.NewLegacyKeccak256] as the Solidity verifier does not support +// the standard hash-to-field function. We use legacy Keccak256 in Solidity for +// the cheapest gas usage. +func WithProverTargetSolidityVerifier(bid backend.ID) backend.ProverOption { + switch bid { + case backend.GROTH16: + // Solidity verifier does not support standard hash-to-field function. + // Choose efficient one. + return backend.WithProverHashToFieldFunction(sha3.NewLegacyKeccak256()) + case backend.PLONK: + // default hash function works for PLONK. We just have to return a no-op option + return func(*backend.ProverConfig) error { + return nil + } + default: + return func(*backend.ProverConfig) error { + return fmt.Errorf("unsupported backend ID: %s", bid) + } + } +} + +// WithVerifierTargetSolidityVerifier returns a verifier option that sets all +// the necessary verifier options which are suitable for verifying the proofs +// targeted for the Solidity verifier. See the comments in +// [WithProverTargetSolidityVerifier]. +func WithVerifierTargetSolidityVerifier(bid backend.ID) backend.VerifierOption { + switch bid { + case backend.GROTH16: + // Solidity verifier does not support standard hash-to-field function. + // Choose efficient one. + return backend.WithVerifierHashToFieldFunction(sha3.NewLegacyKeccak256()) + case backend.PLONK: + // default hash function works for PLONK. We just have to return a no-op option + return func(*backend.VerifierConfig) error { + return nil + } + default: + return func(*backend.VerifierConfig) error { + return fmt.Errorf("unsupported backend ID: %s", bid) + } + } +} diff --git a/backend/solidity/solidity_test.go b/backend/solidity/solidity_test.go new file mode 100644 index 000000000..aeb2db1ea --- /dev/null +++ b/backend/solidity/solidity_test.go @@ -0,0 +1,176 @@ +package solidity_test + +import ( + "crypto/sha256" + "fmt" + "hash" + "testing" + + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend" + "github.com/consensys/gnark/backend/solidity" + "github.com/consensys/gnark/frontend" + "github.com/consensys/gnark/test" + "golang.org/x/crypto/sha3" +) + +type noCommitCircuit struct { + A, B, Out frontend.Variable `gnark:",public"` +} + +func (c *noCommitCircuit) Define(api frontend.API) error { + res := api.Mul(c.A, c.B) + api.AssertIsEqual(res, c.Out) + return nil +} + +type commitCircuit struct { + A, B, Out frontend.Variable `gnark:",public"` +} + +func (c *commitCircuit) Define(api frontend.API) error { + res := api.Mul(c.A, c.B) + api.AssertIsEqual(res, c.Out) + cmter, ok := api.(frontend.Committer) + if !ok { + return fmt.Errorf("api does not support commitment") + } + cmt1, err := cmter.Commit(res) + if err != nil { + return err + } + api.AssertIsDifferent(cmt1, res) + return nil +} + +type twoCommitCircuit struct { + A, B, Out frontend.Variable `gnark:",public"` +} + +func (c *twoCommitCircuit) Define(api frontend.API) error { + res := api.Mul(c.A, c.B) + api.AssertIsEqual(res, c.Out) + cmter, ok := api.(frontend.Committer) + if !ok { + return fmt.Errorf("api does not support commitment") + } + cmt1, err := cmter.Commit(res) + if err != nil { + return err + } + cmt2, err := cmter.Commit(cmt1) + if err != nil { + return err + } + api.AssertIsDifferent(cmt1, cmt2) + return nil +} + +func TestNoCommitment(t *testing.T) { + // should succeed both with G16 and PLONK: + assert := test.NewAssert(t) + circuit := &noCommitCircuit{} + assignment := &noCommitCircuit{A: 2, B: 3, Out: 6} + defaultOpts := []test.TestingOption{ + test.WithCurves(ecc.BN254), + test.WithValidAssignment(assignment), + } + checkCircuit := func(assert *test.Assert, bid backend.ID) { + opts := append(defaultOpts, + test.WithBackends(bid), + ) + + assert.CheckCircuit(circuit, opts...) + } + assert.Run(func(assert *test.Assert) { + checkCircuit(assert, backend.GROTH16) + }, "Groth16") + assert.Run(func(assert *test.Assert) { + checkCircuit(assert, backend.PLONK) + }, "PLONK") +} + +func TestSingleCommitment(t *testing.T) { + // should succeed both with G16 and PLONK: + // - But for G16 only if the hash-to-field is set to a supported one. + // - but for PLONK only if the hash-to-field is the default one. If not, then it should fail. + assert := test.NewAssert(t) + circuit := &commitCircuit{} + assignment := &commitCircuit{A: 2, B: 3, Out: 6} + defaultOpts := []test.TestingOption{ + test.WithCurves(ecc.BN254), + test.WithValidAssignment(assignment), + } + checkCircuit := func(assert *test.Assert, bid backend.ID, newHash func() hash.Hash) { + opts := append(defaultOpts, + test.WithBackends(bid), + test.WithProverOpts( + backend.WithProverHashToFieldFunction(newHash()), + ), + test.WithVerifierOpts( + backend.WithVerifierHashToFieldFunction(newHash()), + ), + test.WithSolidityExportOptions(solidity.WithHashToFieldFunction(newHash())), + ) + + assert.CheckCircuit(circuit, opts...) + } + // G16 success with explicitly set options + assert.Run(func(assert *test.Assert) { + checkCircuit(assert, backend.GROTH16, sha256.New) + }, "groth16", "sha256") + assert.Run(func(assert *test.Assert) { + checkCircuit(assert, backend.GROTH16, sha3.NewLegacyKeccak256) + }, "groth16", "keccak256") + // G16 success with using TargetSolidityVerifier + assert.Run(func(assert *test.Assert) { + opts := append(defaultOpts, + test.WithBackends(backend.GROTH16), + test.WithProverOpts( + solidity.WithProverTargetSolidityVerifier(backend.GROTH16), + ), + test.WithVerifierOpts( + solidity.WithVerifierTargetSolidityVerifier(backend.GROTH16), + ), + ) + assert.CheckCircuit(circuit, opts...) + }, "groth16", "targetSolidityVerifier") + // G16 success without any options because we set default options already in + // assert.CheckCircuit if they are not set. + assert.Run(func(assert *test.Assert) { + opts := append(defaultOpts, + test.WithBackends(backend.GROTH16), + ) + assert.CheckCircuit(circuit, opts...) + }, "groth16", "no-options") + + // PLONK success with default options + assert.Run(func(assert *test.Assert) { + opts := append(defaultOpts, + test.WithBackends(backend.PLONK), + ) + assert.CheckCircuit(circuit, opts...) + }, "plonk", "default") + // PLONK success with using TargetSolidityVerifier + assert.Run(func(assert *test.Assert) { + opts := append(defaultOpts, + test.WithBackends(backend.PLONK), + test.WithProverOpts( + solidity.WithProverTargetSolidityVerifier(backend.PLONK), + ), + test.WithVerifierOpts( + solidity.WithVerifierTargetSolidityVerifier(backend.PLONK), + ), + ) + assert.CheckCircuit(circuit, opts...) + }, "plonk", "targetSolidityVerifier") +} + +func TestTwoCommitments(t *testing.T) { + // should succeed with PLONK only. + // - but for PLONK only if the hash-to-field is the default one. If not, then it should fail. + assert := test.NewAssert(t) + circuit := &twoCommitCircuit{} + assignment := &twoCommitCircuit{A: 2, B: 3, Out: 6} + assert.CheckCircuit(circuit, test.WithCurves(ecc.BN254), test.WithValidAssignment(assignment), test.WithBackends(backend.PLONK)) +} diff --git a/internal/generator/backend/template/zkpschemes/groth16/groth16.verify.go.tmpl b/internal/generator/backend/template/zkpschemes/groth16/groth16.verify.go.tmpl index e1ec6e6c0..b64fe618d 100644 --- a/internal/generator/backend/template/zkpschemes/groth16/groth16.verify.go.tmpl +++ b/internal/generator/backend/template/zkpschemes/groth16/groth16.verify.go.tmpl @@ -5,6 +5,9 @@ import ( {{- if eq .Curve "BN254"}} "math/big" "text/template" + "bytes" + "crypto/sha256" + "golang.org/x/crypto/sha3" {{- template "import_fp" . }} {{- end}} "time" @@ -137,6 +140,32 @@ func Verify(proof *Proof, vk *VerifyingKey, publicWitness fr.Vector, opts ...bac // // See https://github.com/ConsenSys/gnark-tests for example usage. func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.ExportOption) error { + cfg, err := solidity.NewExportConfig(exportOpts...) + log := logger.Logger() + if err != nil { + return err + } + if cfg.HashToFieldFn == nil { + // set the target hash function to legacy keccak256 as it is the default for `solidity.WithTargetSolidityVerifier`` + cfg.HashToFieldFn = sha3.NewLegacyKeccak256() + log.Debug().Msg("hash to field function not set, using keccak256 as default") + } + // a bit hacky way to understand what hash function is provided. We already + // receive instance of hash function but it is difficult to compare it with + // sha256.New() or sha3.NewLegacyKeccak256() directly. + // + // So, we hash an empty input and compare the outputs. + cfg.HashToFieldFn.Reset() + hashBts := cfg.HashToFieldFn.Sum(nil) + var hashFnName string + if bytes.Equal(hashBts, sha256.New().Sum(nil)) { + hashFnName = "sha256" + } else if bytes.Equal(hashBts, sha3.NewLegacyKeccak256().Sum(nil)) { + hashFnName = "keccak256" + } else { + return fmt.Errorf("unsupported hash function used, only supported sha256 and legacy keccak256") + } + cfg.HashToFieldFn.Reset() helpers := template.FuncMap{ "sum": func(a, b int) int { return a + b @@ -159,9 +188,11 @@ func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.Expor x.BigInt(bv) return bv.String() }, + "hashFnName": func() string { + return hashFnName + }, } - log := logger.Logger() if len(vk.PublicAndCommitmentCommitted) > 1 { log.Warn().Msg("exporting solidity verifier with more than one commitment is not supported") } else if len(vk.PublicAndCommitmentCommitted) == 1 { @@ -181,15 +212,11 @@ func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.Expor vk.G2.Gamma, vk.G2.gammaNeg = vk.G2.gammaNeg, vk.G2.Gamma vk.G2.Delta, vk.G2.deltaNeg = vk.G2.deltaNeg, vk.G2.Delta - cfg, err := solidity.NewExportConfig(exportOpts...) - if err != nil { - return err - } // execute template err = tmpl.Execute(w, struct { - Cfg solidity.ExportConfig - Vk VerifyingKey + Cfg solidity.ExportConfig + Vk VerifyingKey }{ Cfg: cfg, Vk: *vk, diff --git a/internal/generator/backend/template/zkpschemes/plonk/plonk.verify.go.tmpl b/internal/generator/backend/template/zkpschemes/plonk/plonk.verify.go.tmpl index 7ba4a42a4..f7368f472 100644 --- a/internal/generator/backend/template/zkpschemes/plonk/plonk.verify.go.tmpl +++ b/internal/generator/backend/template/zkpschemes/plonk/plonk.verify.go.tmpl @@ -419,6 +419,9 @@ func (vk *VerifyingKey) ExportSolidity(w io.Writer, exportOpts ...solidity.Expor if err != nil { return err } + if cfg.HashToFieldFn != nil { + return fmt.Errorf("setting hash to field function is not supported for PLONK Solidity export. Hash function is hardcoded to RFC9380") + } return t.Execute(w, struct { Cfg solidity.ExportConfig diff --git a/test/assert_checkcircuit.go b/test/assert_checkcircuit.go index d75700513..c631623d7 100644 --- a/test/assert_checkcircuit.go +++ b/test/assert_checkcircuit.go @@ -1,8 +1,6 @@ package test import ( - "crypto/sha256" - "github.com/consensys/gnark-crypto/ecc" "github.com/consensys/gnark/backend" "github.com/consensys/gnark/backend/groth16" @@ -128,9 +126,9 @@ func (assert *Assert) CheckCircuit(circuit frontend.Circuit, opts ...TestingOpti if b == backend.GROTH16 { // currently groth16 Solidity checker only supports circuits with up to 1 commitment checkSolidity = checkSolidity && (len(ccs.GetCommitments().CommitmentIndexes()) <= 1) - // additionally, we use sha256 as hash to field (fixed in Solidity contract) - proverOpts = append(proverOpts, backend.WithProverHashToFieldFunction(sha256.New())) - verifierOpts = append(verifierOpts, backend.WithVerifierHashToFieldFunction(sha256.New())) + // set the default hash function in case of custom hash function not set. This is to ensure that the proof can be verified by gnark-solidity-checker + proverOpts = append([]backend.ProverOption{solidity.WithProverTargetSolidityVerifier(b)}, opt.proverOpts...) + verifierOpts = append([]backend.VerifierOption{solidity.WithVerifierTargetSolidityVerifier(b)}, opt.verifierOpts...) } proof, err := concreteBackend.prove(ccs, pk, w.full, proverOpts...) assert.noError(err, &w) diff --git a/test/assert_solidity.go b/test/assert_solidity.go index f72818aae..8949aa36a 100644 --- a/test/assert_solidity.go +++ b/test/assert_solidity.go @@ -26,11 +26,6 @@ func (assert *Assert) solidityVerification(b backend.ID, vk solidity.VerifyingKe } assert.t.Helper() - // set default options for CI when none are provided - if len(opts) == 0 { - opts = append(opts, solidity.WithPragmaVersion("^0.8.0")) // to avoid needing sync Solidity CI all the time - } - // make temp dir tmpDir, err := os.MkdirTemp("", "gnark-solidity-check*") assert.NoError(err)