diff --git a/Makefile b/Makefile index 67067fc5..2bc44676 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ PACKAGE=github.com/symbioticfi/relay +# Pinned: abigen output changes between releases, and CI verifies that +# `make generate` leaves no diff. Bump deliberately and commit the regenerated +# bindings in the same change. +ABIGEN_VERSION ?= v1.17.5 IMAGE_REPO ?= relay_sidecar BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) @@ -108,27 +112,27 @@ e2e-test: .PHONY: gen-abi gen-abi: - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi symbiotic/client/evm/abi/ValSetDriver.abi.json \ --type ValSetDriver \ --pkg gen \ --out symbiotic/client/evm/gen/valsetDriver.go - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi symbiotic/client/evm/abi/Settlement.abi.json \ --type Settlement \ --pkg gen \ --out symbiotic/client/evm/gen/settlement.go - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi symbiotic/client/evm/abi/KeyRegistry.abi.json \ --type KeyRegistry \ --pkg gen \ --out symbiotic/client/evm/gen/keyRegistry.go - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi symbiotic/client/evm/abi/VotingPowerProvider.abi.json \ --type VotingPowerProvider \ --pkg gen \ --out symbiotic/client/evm/gen/votingPowerProvider.go - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi symbiotic/client/evm/abi/OperatorRegistry.abi.json \ --type OperatorRegistry \ --pkg gen \ @@ -136,22 +140,22 @@ gen-abi: .PHONY: gen-abi-test gen-abi-test: - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi e2e/tests/evm/abi/MockERC20.abi.json \ --type MockERC20 \ --pkg gen \ --out e2e/tests/evm/gen/mockERC20.go - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi e2e/tests/evm/abi/IOptInService.abi.json \ --type OptInService \ --pkg gen \ --out e2e/tests/evm/gen/optInService.go - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi e2e/tests/evm/abi/OpNetVaultAutoDeployLogic.abi.json \ --type OpNetVaultAutoDeployLogic \ --pkg gen \ --out e2e/tests/evm/gen/opNetVaultAutoDeployLogic.go - go run github.com/ethereum/go-ethereum/cmd/abigen@latest \ + go run github.com/ethereum/go-ethereum/cmd/abigen@$(ABIGEN_VERSION) \ --abi e2e/tests/evm/abi/Vault.abi.json \ --type Vault \ --pkg gen \ diff --git a/internal/client/repository/codec/codec_test.go b/internal/client/repository/codec/codec_test.go new file mode 100644 index 00000000..5e8718bc --- /dev/null +++ b/internal/client/repository/codec/codec_test.go @@ -0,0 +1,94 @@ +package codec + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + symbiotic "github.com/symbioticfi/relay/symbiotic/entity" +) + +// Committer indices serialize as a bitmap, so the round-trip returns the set in +// ascending order rather than any insertion order (see the deriver, which sorts +// them for exactly this reason). Slot rotation indexes into that slice, so a +// dropped or spurious member would hand a slot to the wrong validator. +func TestValidatorSetHeaderRoundTrip_PreservesCommitterSet(t *testing.T) { + t.Parallel() + + const slotDuration = uint64(100) + keyTag := symbiotic.KeyTag(15) + + publicKey1 := []byte("committer1_key") + publicKey2 := []byte("committer2_key") + publicKey3 := []byte("committer3_key") + + valset := symbiotic.ValidatorSet{ + RequiredKeyTag: keyTag, + CaptureTimestamp: 1000, + Validators: symbiotic.Validators{ + { + Operator: common.HexToAddress("0x1111111111111111111111111111111111111111"), + VotingPower: symbiotic.ToVotingPower(big.NewInt(100)), + IsActive: true, + Keys: []symbiotic.ValidatorKey{ + {Tag: keyTag, Payload: publicKey1}, + }, + }, + { + Operator: common.HexToAddress("0x2222222222222222222222222222222222222222"), + VotingPower: symbiotic.ToVotingPower(big.NewInt(200)), + IsActive: true, + Keys: []symbiotic.ValidatorKey{ + {Tag: keyTag, Payload: publicKey2}, + }, + }, + { + Operator: common.HexToAddress("0x3333333333333333333333333333333333333333"), + VotingPower: symbiotic.ToVotingPower(big.NewInt(150)), + IsActive: true, + Keys: []symbiotic.ValidatorKey{ + {Tag: keyTag, Payload: publicKey3}, + }, + }, + }, + // A strict subset, ascending, as the deriver emits it. + CommitterIndices: []uint32{0, 2}, + } + + require.True( + t, + valset.IsActiveCommitter(context.Background(), slotDuration, 1050, 0, publicKey1), + "sanity check: validator 0 should own the first slot before storage round-trip", + ) + + headerBytes, err := ValidatorSetHeaderToBytes(valset) + require.NoError(t, err) + + _, committerIndices, err := ExtractAdditionalInfoFromHeaderData(headerBytes) + require.NoError(t, err) + + assert.Equal( + t, + valset.CommitterIndices, + committerIndices, + "header round-trip should preserve the committer set in ascending order", + ) + + roundTripped := valset + roundTripped.CommitterIndices = committerIndices + + assert.True( + t, + roundTripped.IsActiveCommitter(context.Background(), slotDuration, 1050, 0, publicKey1), + "the same validator should still own the first slot after header round-trip", + ) + assert.False( + t, + roundTripped.IsActiveCommitter(context.Background(), slotDuration, 1050, 0, publicKey2), + "a non-committer should not gain a slot through the round-trip", + ) +} diff --git a/internal/usecase/entity-processor/entity_processor_key_binding_test.go b/internal/usecase/entity-processor/entity_processor_key_binding_test.go new file mode 100644 index 00000000..75fadf47 --- /dev/null +++ b/internal/usecase/entity-processor/entity_processor_key_binding_test.go @@ -0,0 +1,85 @@ +package entity_processor + +import ( + "math/big" + "testing" + + "github.com/consensys/gnark-crypto/ecc/bn254" + "github.com/stretchr/testify/require" + + symbiotic "github.com/symbioticfi/relay/symbiotic/entity" + "github.com/symbioticfi/relay/symbiotic/usecase/crypto" + "github.com/symbioticfi/relay/symbiotic/usecase/crypto/blsBn254" +) + +// Validators are looked up by the on-chain G1 key, so a gossiped signature that +// keeps a victim's G1 but carries an attacker's G2 must not verify. Otherwise +// any peer could sign under any validator's identity, and the per-index dedup +// would then censor that validator's genuine signature. +func TestEntityProcessor_ProcessSignature_RejectsUnboundG2(t *testing.T) { + t.Parallel() + + for name, newRepo := range backends() { + t.Run(name, func(t *testing.T) { + t.Parallel() + + repo := newRepo(t) + epoch := symbiotic.Epoch(700) + req := randomSignatureRequest(t, epoch) + + _, privateKeys := setupValidatorSetHeader(t, repo, epoch, big.NewInt(1000)) + + processor, err := NewEntityProcessor(Config{ + Repo: repo, + Aggregator: createMockAggregator(t), + AggProofSignal: createMockAggProofSignal(t), + SignatureProcessedSignal: createMockSignatureProcessedSignal(t), + Metrics: doNothingMetrics{}, + }) + require.NoError(t, err) + + // The attacker knows only the victim's public G1 identity. + victimPub := privateKeys[0][req.KeyTag].PublicKey() + victimG1Compressed := victimPub.Raw()[:32] + + messageHash, err := crypto.HashMessage(req.KeyTag.Type(), req.Message) + require.NoError(t, err) + + // Sign with an arbitrary scalar and publish the matching G2. + attackerScalar := big.NewInt(0xDEADBEEF) + _, _, _, g2Gen := bn254.Generators() + var attackerG2 bn254.G2Affine + attackerG2.ScalarMultiplication(&g2Gen, attackerScalar) + + g1Hash, err := blsBn254.HashToG1(messageHash) + require.NoError(t, err) + var sig bn254.G1Affine + sig.ScalarMultiplication(g1Hash, attackerScalar) + + attackerG2Compressed := attackerG2.Bytes() + forgedRaw := append(append([]byte{}, victimG1Compressed...), attackerG2Compressed[:]...) + forgedPub, err := crypto.NewPublicKey(symbiotic.KeyTypeBlsBn254, forgedRaw) + require.NoError(t, err) + require.Equal(t, victimPub.OnChain(), forgedPub.OnChain(), + "forgery resolves to the victim's on-chain identity") + + forged := symbiotic.Signature{ + KeyTag: req.KeyTag, + Epoch: epoch, + MessageHash: messageHash, + Signature: sig.Marshal(), + PublicKey: forgedPub, + } + + require.Error(t, processor.ProcessSignature(t.Context(), forged, false), + "forged signature must not be accepted under the victim's identity") + + _, err = repo.GetSignatureMap(t.Context(), forged.RequestID()) + require.Error(t, err, "nothing recorded for the victim") + + // The victim's genuine signature is still accepted afterwards. + genuine := signatureExtendedForRequest(t, privateKeys[0][req.KeyTag], req) + require.NoError(t, processor.ProcessSignature(t.Context(), genuine, false)) + }) + } +} diff --git a/symbiotic/client/evm/gen/keyRegistry.go b/symbiotic/client/evm/gen/keyRegistry.go index 1cf19d15..da78dcab 100644 --- a/symbiotic/client/evm/gen/keyRegistry.go +++ b/symbiotic/client/evm/gen/keyRegistry.go @@ -4,9 +4,11 @@ package gen import ( + "context" "errors" "math/big" "strings" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -27,6 +29,8 @@ var ( _ = types.BloomLookup _ = event.NewSubscription _ = abi.ConvertType + _ = time.Tick + _ = context.Background ) // IKeyRegistryKey is an auto generated low-level Go binding around an user-defined struct. diff --git a/symbiotic/client/evm/gen/operatorRegistry.go b/symbiotic/client/evm/gen/operatorRegistry.go index 68ca72f8..e206cb7b 100644 --- a/symbiotic/client/evm/gen/operatorRegistry.go +++ b/symbiotic/client/evm/gen/operatorRegistry.go @@ -4,9 +4,11 @@ package gen import ( + "context" "errors" "math/big" "strings" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -27,6 +29,8 @@ var ( _ = types.BloomLookup _ = event.NewSubscription _ = abi.ConvertType + _ = time.Tick + _ = context.Background ) // OperatorRegistryMetaData contains all meta data concerning the OperatorRegistry contract. diff --git a/symbiotic/client/evm/gen/settlement.go b/symbiotic/client/evm/gen/settlement.go index 6eb0f2ad..10266df8 100644 --- a/symbiotic/client/evm/gen/settlement.go +++ b/symbiotic/client/evm/gen/settlement.go @@ -4,9 +4,11 @@ package gen import ( + "context" "errors" "math/big" "strings" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -27,6 +29,8 @@ var ( _ = types.BloomLookup _ = event.NewSubscription _ = abi.ConvertType + _ = time.Tick + _ = context.Background ) // ISettlementExtraData is an auto generated low-level Go binding around an user-defined struct. diff --git a/symbiotic/client/evm/gen/valsetDriver.go b/symbiotic/client/evm/gen/valsetDriver.go index 2b55cbc4..e1eeee38 100644 --- a/symbiotic/client/evm/gen/valsetDriver.go +++ b/symbiotic/client/evm/gen/valsetDriver.go @@ -4,9 +4,11 @@ package gen import ( + "context" "errors" "math/big" "strings" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -27,6 +29,8 @@ var ( _ = types.BloomLookup _ = event.NewSubscription _ = abi.ConvertType + _ = time.Tick + _ = context.Background ) // IValSetDriverConfig is an auto generated low-level Go binding around an user-defined struct. diff --git a/symbiotic/client/evm/gen/votingPowerProvider.go b/symbiotic/client/evm/gen/votingPowerProvider.go index 191a8c48..a9ef9bbe 100644 --- a/symbiotic/client/evm/gen/votingPowerProvider.go +++ b/symbiotic/client/evm/gen/votingPowerProvider.go @@ -4,9 +4,11 @@ package gen import ( + "context" "errors" "math/big" "strings" + "time" ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" @@ -27,6 +29,8 @@ var ( _ = types.BloomLookup _ = event.NewSubscription _ = abi.ConvertType + _ = time.Tick + _ = context.Background ) // IVotingPowerProviderOperatorVotingPower is an auto generated low-level Go binding around an user-defined struct. diff --git a/symbiotic/usecase/crypto/bls12381/key.go b/symbiotic/usecase/crypto/bls12381/key.go index d26e31b3..db84a27d 100644 --- a/symbiotic/usecase/crypto/bls12381/key.go +++ b/symbiotic/usecase/crypto/bls12381/key.go @@ -113,12 +113,28 @@ func (k *PublicKey) VerifyWithHash(msgHash MessageHash, sig Signature) error { return errors.Errorf("bls12381: failed to set big into G1: %w", err) } - _, _, _, g2Gen := bls12381.Generators() - var negSig bls12381.G1Affine - negSig.Neg(&g1Sig) + // A zero key would make the keypair binding below vacuous. + if k.g1PubKey.IsInfinity() { + return errors.Errorf("bls12381: zero public key") + } + + _, _, g1Gen, g2Gen := bls12381.Generators() + var negG2Gen bls12381.G2Affine + negG2Gen.Neg(&g2Gen) + + // Folds two equations under a Fiat-Shamir challenge: the signature check + // e(H(m), G2) == e(sig, g2Gen), and the keypair binding + // e(G1, g2Gen) == e(g1Gen, G2). Without the binding, a G2 taken from an + // untrusted message is never tied to the G1 a validator is identified by, + // so anyone could sign under someone else's identity. + alpha := keypairChallenge(&g1Sig, &k.g1PubKey, &k.g2PubKey, g1Hash) - g1P := [2]bls12381.G1Affine{*g1Hash, negSig} - g1Q := [2]bls12381.G2Affine{k.g2PubKey, g2Gen} + var sigTerm, msgTerm bls12381.G1Affine + sigTerm.ScalarMultiplication(&k.g1PubKey, alpha).Add(&sigTerm, &g1Sig) + msgTerm.ScalarMultiplication(&g1Gen, alpha).Add(&msgTerm, g1Hash) + + g1P := [2]bls12381.G1Affine{sigTerm, msgTerm} + g1Q := [2]bls12381.G2Affine{negG2Gen, k.g2PubKey} ok, err := bls12381.PairingCheck(g1P[:], g1Q[:]) if err != nil { @@ -130,6 +146,18 @@ func (k *PublicKey) VerifyWithHash(msgHash MessageHash, sig Signature) error { return nil } +// keypairChallenge commits to every point so the two folded equations cannot be +// made to cancel each other out. +func keypairChallenge(sig, g1PubKey *bls12381.G1Affine, g2PubKey *bls12381.G2Affine, msg *bls12381.G1Affine) *big.Int { + sigBytes := sig.Marshal() + g1Bytes := g1PubKey.Marshal() + g2Bytes := g2PubKey.Marshal() + msgBytes := msg.Marshal() + + h := crypto.Keccak256(sigBytes, g1Bytes, g2Bytes, msgBytes) + return new(big.Int).Mod(new(big.Int).SetBytes(h), fr.Modulus()) +} + // OnChain might be one way operation, meaning that it's impossible to reconstruct PublicKey from compact func (k *PublicKey) OnChain() CompactPublicKey { // DEV: g1PubKey Marshalled is 96 bytes in total, x and y each 48bytes diff --git a/symbiotic/usecase/crypto/bls12381/key_test.go b/symbiotic/usecase/crypto/bls12381/key_test.go index 9b611be1..406a3340 100644 --- a/symbiotic/usecase/crypto/bls12381/key_test.go +++ b/symbiotic/usecase/crypto/bls12381/key_test.go @@ -5,6 +5,7 @@ import ( "math/big" "testing" + bls12381 "github.com/consensys/gnark-crypto/ecc/bls12-381" "github.com/stretchr/testify/require" ) @@ -116,6 +117,36 @@ func TestFromRaw(t *testing.T) { require.EqualError(t, err, "bls12381: failed to unmarshal G1 pubkey: short buffer") } +// A key is a (G1, G2) pair sharing one secret scalar, but validators are +// identified by G1 alone. Verification must therefore prove the supplied G2 +// belongs to that same G1, or anyone can pair a victim's G1 with their own G2 +// and sign under the victim's identity. +func TestBLSKeysRejectUnboundG2(t *testing.T) { + victim, err := GenerateKey() + require.NoError(t, err) + victimPub, ok := victim.PublicKey().(*PublicKey) + require.True(t, ok) + + msg := randData(t) + msgHash := HashMessage(msg) + + attackerScalar := big.NewInt(0xDEADBEEF) + _, _, _, g2Gen := bls12381.Generators() + var attackerG2 bls12381.G2Affine + attackerG2.ScalarMultiplication(&g2Gen, attackerScalar) + + g1Hash, err := HashToG1(msgHash) + require.NoError(t, err) + var sig bls12381.G1Affine + sig.ScalarMultiplication(g1Hash, attackerScalar) + + forged := NewPublicKey(victimPub.g1PubKey, attackerG2) + require.Equal(t, victimPub.OnChain(), forged.OnChain(), "forgery keeps the victim's identity") + + require.Error(t, forged.VerifyWithHash(msgHash, sig.Marshal())) + require.Error(t, forged.Verify(msg, sig.Marshal())) +} + func randData(t *testing.T) []byte { t.Helper() data := make([]byte, 32) diff --git a/symbiotic/usecase/crypto/blsBn254/key.go b/symbiotic/usecase/crypto/blsBn254/key.go index 087a88ba..627256b4 100644 --- a/symbiotic/usecase/crypto/blsBn254/key.go +++ b/symbiotic/usecase/crypto/blsBn254/key.go @@ -158,37 +158,7 @@ func NewPublicKey(g1PubKey bn254.G1Affine, g2PubKey bn254.G2Affine) *PublicKey { } func (k *PublicKey) Verify(msg Message, sig Signature) error { - msgHash := HashMessage(msg) - - // Hash the message to a point on G1 - g1Hash, err := HashToG1(msgHash) - if err != nil { - return errors.Errorf("blsBn254: failed to hash message to G1: %w", err) - } - - g1Sig := bn254.G1Affine{} - _, err = g1Sig.SetBytes(sig) - if err != nil { - return errors.Errorf("blsBn254: failed to set big into G1: %w", err) - } - - // Get the G2 generator - _, _, _, g2Gen := bn254.Generators() - - var negSig bn254.G1Affine - negSig.Neg(&g1Sig) - - g1P := [2]bn254.G1Affine{*g1Hash, negSig} - g1Q := [2]bn254.G2Affine{k.g2PubKey, g2Gen} - - ok, err := bn254.PairingCheck(g1P[:], g1Q[:]) - if err != nil { - return errors.Errorf("blsBn254: pairing check failed: %w", err) - } - if !ok { - return errors.Errorf("blsBn254: invalid signature") - } - return nil + return k.VerifyWithHash(HashMessage(msg), sig) } func (k *PublicKey) VerifyWithHash(msgHash MessageHash, sig Signature) error { @@ -208,14 +178,29 @@ func (k *PublicKey) VerifyWithHash(msgHash MessageHash, sig Signature) error { return errors.Errorf("blsBn254: failed to set big into G1: %w", err) } - // Get the G2 generator - _, _, _, g2Gen := bn254.Generators() + // A zero key would make the keypair binding below vacuous. + if k.g1PubKey.IsInfinity() { + return errors.Errorf("blsBn254: zero public key") + } + + _, _, g1Gen, g2Gen := bn254.Generators() + var negG2Gen bn254.G2Affine + negG2Gen.Neg(&g2Gen) + + // Folds two equations under a Fiat-Shamir challenge: the signature check + // e(H(m), G2) == e(sig, g2Gen), and the keypair binding + // e(G1, g2Gen) == e(g1Gen, G2). Without the binding, a G2 taken from an + // untrusted message is never tied to the G1 a validator is identified by, + // so anyone could sign under someone else's identity. Mirrors + // SigBlsBn254.verify in the relay contracts. + alpha := keypairChallenge(&g1Sig, &k.g1PubKey, &k.g2PubKey, g1Hash) - var negSig bn254.G1Affine - negSig.Neg(&g1Sig) + var sigTerm, msgTerm bn254.G1Affine + sigTerm.ScalarMultiplication(&k.g1PubKey, alpha).Add(&sigTerm, &g1Sig) + msgTerm.ScalarMultiplication(&g1Gen, alpha).Add(&msgTerm, g1Hash) - g1P := [2]bn254.G1Affine{*g1Hash, negSig} - g1Q := [2]bn254.G2Affine{k.g2PubKey, g2Gen} + g1P := [2]bn254.G1Affine{sigTerm, msgTerm} + g1Q := [2]bn254.G2Affine{negG2Gen, k.g2PubKey} ok, err := bn254.PairingCheck(g1P[:], g1Q[:]) if err != nil { @@ -227,6 +212,18 @@ func (k *PublicKey) VerifyWithHash(msgHash MessageHash, sig Signature) error { return nil } +// keypairChallenge commits to every point so the two folded equations cannot be +// made to cancel each other out. +func keypairChallenge(sig, g1PubKey *bn254.G1Affine, g2PubKey *bn254.G2Affine, msg *bn254.G1Affine) *big.Int { + sigBytes := sig.Marshal() + g1Bytes := g1PubKey.Marshal() + g2Bytes := g2PubKey.Marshal() + msgBytes := msg.Marshal() + + h := crypto.Keccak256(sigBytes, g1Bytes, g2Bytes, msgBytes) + return new(big.Int).Mod(new(big.Int).SetBytes(h), fr.Modulus()) +} + // OnChain might be one way operation, meaning that it's impossible to reconstruct PublicKey from compact func (k *PublicKey) OnChain() CompactPublicKey { return k.g1PubKey.Marshal() diff --git a/symbiotic/usecase/crypto/blsBn254/key_test.go b/symbiotic/usecase/crypto/blsBn254/key_test.go index d6f372b8..b181a39e 100644 --- a/symbiotic/usecase/crypto/blsBn254/key_test.go +++ b/symbiotic/usecase/crypto/blsBn254/key_test.go @@ -5,6 +5,7 @@ import ( "math/big" "testing" + "github.com/consensys/gnark-crypto/ecc/bn254" "github.com/stretchr/testify/require" ) @@ -117,6 +118,36 @@ func TestFromRaw(t *testing.T) { require.EqualError(t, err, "blsBn254: failed to unmarshal G1 pubkey: short buffer") } +// A key is a (G1, G2) pair sharing one secret scalar, but validators are +// identified by G1 alone. Verification must therefore prove the supplied G2 +// belongs to that same G1, or anyone can pair a victim's G1 with their own G2 +// and sign under the victim's identity. +func TestBLSKeysRejectUnboundG2(t *testing.T) { + victim, err := GenerateKey() + require.NoError(t, err) + victimPub, ok := victim.PublicKey().(*PublicKey) + require.True(t, ok) + + msg := randData(t) + msgHash := HashMessage(msg) + + attackerScalar := big.NewInt(0xDEADBEEF) + _, _, _, g2Gen := bn254.Generators() + var attackerG2 bn254.G2Affine + attackerG2.ScalarMultiplication(&g2Gen, attackerScalar) + + g1Hash, err := HashToG1(msgHash) + require.NoError(t, err) + var sig bn254.G1Affine + sig.ScalarMultiplication(g1Hash, attackerScalar) + + forged := NewPublicKey(victimPub.g1PubKey, attackerG2) + require.Equal(t, victimPub.OnChain(), forged.OnChain(), "forgery keeps the victim's identity") + + require.Error(t, forged.VerifyWithHash(msgHash, sig.Marshal())) + require.Error(t, forged.Verify(msg, sig.Marshal())) +} + func randData(t *testing.T) []byte { t.Helper() data := make([]byte, 32)