diff --git a/eth2util/signing/signing.go b/eth2util/signing/signing.go index 8e81abf53..ab5440bec 100644 --- a/eth2util/signing/signing.go +++ b/eth2util/signing/signing.go @@ -22,6 +22,10 @@ import ( eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" "github.com/obolnetwork/charon/app/errors" + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" ) // DomainName as defined in eth2 spec. @@ -42,14 +46,15 @@ const ( // DomainContributionAndProof DomainName = "DOMAIN_CONTRIBUTION_AND_PROOF". ) -// Eth2DomainProvider is the subset of eth2 beacon api provider required to get a signing domain. -type Eth2DomainProvider interface { - eth2client.SpecProvider +// Eth2Provider is the subset of eth2 beacon api provider required to get a signing domain. +type Eth2Provider interface { eth2client.DomainProvider + eth2client.SlotsPerEpochProvider + eth2client.SpecProvider } // GetDomain returns the beacon domain for the provided type. -func GetDomain(ctx context.Context, eth2Cl Eth2DomainProvider, name DomainName, epoch eth2p0.Epoch) (eth2p0.Domain, error) { +func GetDomain(ctx context.Context, eth2Cl Eth2Provider, name DomainName, epoch eth2p0.Epoch) (eth2p0.Domain, error) { // TODO(corver): Remove once https://github.com/attestantio/go-eth2-client/pull/23 is released if name == DomainApplicationBuilder { // See https://github.com/ethereum/builder-specs/blob/main/specs/builder.md#domain-types @@ -76,7 +81,7 @@ func GetDomain(ctx context.Context, eth2Cl Eth2DomainProvider, name DomainName, // GetDataRoot wraps the signing root with the domain and returns signing data hash tree root. // The result should be identical to what was signed by the VC. -func GetDataRoot(ctx context.Context, eth2Cl Eth2DomainProvider, name DomainName, epoch eth2p0.Epoch, root eth2p0.Root) ([32]byte, error) { +func GetDataRoot(ctx context.Context, eth2Cl Eth2Provider, name DomainName, epoch eth2p0.Epoch, root eth2p0.Root) ([32]byte, error) { domain, err := GetDomain(ctx, eth2Cl, name, epoch) if err != nil { return [32]byte{}, err @@ -89,3 +94,129 @@ func GetDataRoot(ctx context.Context, eth2Cl Eth2DomainProvider, name DomainName return msg, nil } + +// NewVerifyFunc returns partial signature verification function which verifies given ParSignedData according to Duty Type. +//nolint:gocognit +func NewVerifyFunc(eth2Cl Eth2Provider) func(context.Context, core.Duty, core.PubKey, core.ParSignedData) error { + return func(ctx context.Context, duty core.Duty, pubkey core.PubKey, parSig core.ParSignedData) error { + switch duty.Type { + case core.DutyAttester: + att, ok := parSig.SignedData.(core.Attestation) + if !ok { + return errors.New("invalid attestation") + } + + sigRoot, err := att.Data.HashTreeRoot() + if err != nil { + return errors.Wrap(err, "hash attestation data") + } + + return verify(ctx, eth2Cl, DomainBeaconAttester, att.Data.Target.Epoch, sigRoot, att.Attestation.Signature, pubkey) + case core.DutyProposer: + block, ok := parSig.SignedData.(core.VersionedSignedBeaconBlock) + if !ok { + return errors.New("invalid block") + } + + // Calculate slot epoch + epoch, err := epochFromSlot(ctx, eth2Cl, eth2p0.Slot(duty.Slot)) + if err != nil { + return err + } + + sigRoot, err := block.Root() + if err != nil { + return err + } + + return verify(ctx, eth2Cl, DomainBeaconProposer, epoch, sigRoot, block.Signature().ToETH2(), pubkey) + case core.DutyBuilderProposer: + blindedBlock, ok := parSig.SignedData.(core.VersionedSignedBlindedBeaconBlock) + if !ok { + return errors.New("invalid blinded block") + } + + // Calculate slot epoch + epoch, err := epochFromSlot(ctx, eth2Cl, eth2p0.Slot(duty.Slot)) + if err != nil { + return err + } + + sigRoot, err := blindedBlock.Root() + if err != nil { + return err + } + + return verify(ctx, eth2Cl, DomainBeaconProposer, epoch, sigRoot, blindedBlock.Signature().ToETH2(), pubkey) + case core.DutyRandao: + randao, ok := parSig.SignedData.(core.Signature) + if !ok { + return errors.New("invalid randao") + } + + // Calculate slot epoch + epoch, err := epochFromSlot(ctx, eth2Cl, eth2p0.Slot(duty.Slot)) + if err != nil { + return err + } + + sigRoot, err := eth2util.EpochHashRoot(epoch) + if err != nil { + return err + } + + return verify(ctx, eth2Cl, DomainRandao, epoch, sigRoot, randao.ToETH2(), pubkey) + case core.DutyExit: + exit, ok := parSig.SignedData.(core.SignedVoluntaryExit) + if !ok { + return errors.New("invalid voluntary exit") + } + + sigRoot, err := exit.Message.HashTreeRoot() + if err != nil { + return err + } + + return verify(ctx, eth2Cl, DomainExit, exit.Message.Epoch, sigRoot, exit.SignedVoluntaryExit.Signature, pubkey) + default: + return errors.New("invalid duty type") + } + } +} + +func verify(ctx context.Context, eth2Cl Eth2Provider, domain DomainName, epoch eth2p0.Epoch, sigRoot [32]byte, sig eth2p0.BLSSignature, pubkey core.PubKey) error { + sigData, err := GetDataRoot(ctx, eth2Cl, domain, epoch, sigRoot) + if err != nil { + return err + } + + // Convert the signature + s, err := tblsconv.SigFromETH2(sig) + if err != nil { + return errors.Wrap(err, "convert signature") + } + + // Verify using public share + pubshare, err := tblsconv.KeyFromCore(pubkey) + if err != nil { + return err + } + + ok, err := tbls.Verify(pubshare, sigData[:], s) + if err != nil { + return err + } else if !ok { + return errors.New("invalid signature") + } + + return nil +} + +func epochFromSlot(ctx context.Context, eth2Cl Eth2Provider, slot eth2p0.Slot) (eth2p0.Epoch, error) { + slotsPerEpoch, err := eth2Cl.SlotsPerEpoch(ctx) + if err != nil { + return 0, errors.Wrap(err, "getting slots per epoch") + } + + return eth2p0.Epoch(uint64(slot) / slotsPerEpoch), nil +} diff --git a/eth2util/signing/signing_test.go b/eth2util/signing/signing_test.go new file mode 100644 index 000000000..0dcb30027 --- /dev/null +++ b/eth2util/signing/signing_test.go @@ -0,0 +1,182 @@ +// Copyright © 2022 Obol Labs Inc. +// +// This program is free software: you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation, either version 3 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +package signing_test + +import ( + "context" + "testing" + + eth2p0 "github.com/attestantio/go-eth2-client/spec/phase0" + "github.com/coinbase/kryptology/pkg/signatures/bls/bls_sig" + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/core" + "github.com/obolnetwork/charon/eth2util" + "github.com/obolnetwork/charon/eth2util/signing" + "github.com/obolnetwork/charon/tbls" + "github.com/obolnetwork/charon/tbls/tblsconv" + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/beaconmock" +) + +func TestVerifyAttestation(t *testing.T) { + att := testutil.RandomAttestation() + duty := core.NewAttesterDuty(int64(att.Data.Slot)) + + bmock, err := beaconmock.New() + require.NoError(t, err) + + root, err := att.Data.HashTreeRoot() + require.NoError(t, err) + + sigData, err := signing.GetDataRoot(context.Background(), bmock, signing.DomainBeaconAttester, att.Data.Target.Epoch, root) + require.NoError(t, err) + + sig, pubkey := sign(t, sigData[:]) + att.Signature = tblsconv.SigToETH2(sig) + psig := core.NewPartialAttestation(att, 0) + + verifyFunc := signing.NewVerifyFunc(bmock) + require.NoError(t, verifyFunc(context.Background(), duty, pubkey, psig)) +} + +func TestVerifyBeaconBlock(t *testing.T) { + block := testutil.RandomCoreVersionSignedBeaconBlock(t) + + slot, err := block.Slot() + require.NoError(t, err) + + duty := core.NewProposerDuty(int64(slot)) + + bmock, err := beaconmock.New() + require.NoError(t, err) + + root, err := block.Root() + require.NoError(t, err) + + slotsPerEpoch, err := bmock.SlotsPerEpoch(context.Background()) + require.NoError(t, err) + epoch := eth2p0.Epoch(uint64(slot) / slotsPerEpoch) + + sigData, err := signing.GetDataRoot(context.Background(), bmock, signing.DomainBeaconProposer, epoch, root) + require.NoError(t, err) + + sig, pubkey := sign(t, sigData[:]) + data, err := block.SetSignature(tblsconv.SigToCore(sig)) + require.NoError(t, err) + + psig := core.ParSignedData{SignedData: data, ShareIdx: 0} + verifyFunc := signing.NewVerifyFunc(bmock) + require.NoError(t, verifyFunc(context.Background(), duty, pubkey, psig)) +} + +func TestVerifyDutyRandao(t *testing.T) { + duty := core.NewRandaoDuty(123) + + bmock, err := beaconmock.New() + require.NoError(t, err) + + slotsPerEpoch, err := bmock.SlotsPerEpoch(context.Background()) + require.NoError(t, err) + + epoch := eth2p0.Epoch(uint64(duty.Slot) / slotsPerEpoch) + sigRoot, err := eth2util.EpochHashRoot(epoch) + require.NoError(t, err) + + sigData, err := signing.GetDataRoot(context.Background(), bmock, signing.DomainRandao, epoch, sigRoot) + require.NoError(t, err) + + sig, pubkey := sign(t, sigData[:]) + psig := core.NewPartialSignature(tblsconv.SigToCore(sig), 0) + + verifyFunc := signing.NewVerifyFunc(bmock) + require.NoError(t, verifyFunc(context.Background(), duty, pubkey, psig)) +} + +func TestVerifyVoluntaryExit(t *testing.T) { + duty := core.NewVoluntaryExit(123) + exit := testutil.RandomExit() + + bmock, err := beaconmock.New() + require.NoError(t, err) + + sigRoot, err := exit.Message.HashTreeRoot() + require.NoError(t, err) + + sigData, err := signing.GetDataRoot(context.Background(), bmock, signing.DomainExit, exit.Message.Epoch, sigRoot) + require.NoError(t, err) + + sig, pubkey := sign(t, sigData[:]) + exit.Signature = tblsconv.SigToETH2(sig) + psig := core.NewPartialSignedVoluntaryExit(exit, 0) + + verifyFunc := signing.NewVerifyFunc(bmock) + require.NoError(t, verifyFunc(context.Background(), duty, pubkey, psig)) +} + +func TestVerifyBlindedBeaconBlock(t *testing.T) { + duty := core.NewBuilderProposerDuty(123) + blindedBlock := testutil.RandomCoreVersionSignedBlindedBeaconBlock(t) + + bmock, err := beaconmock.New() + require.NoError(t, err) + + sigRoot, err := blindedBlock.Root() + require.NoError(t, err) + + slotsPerEpoch, err := bmock.SlotsPerEpoch(context.Background()) + require.NoError(t, err) + + epoch := eth2p0.Epoch(uint64(duty.Slot) / slotsPerEpoch) + sigData, err := signing.GetDataRoot(context.Background(), bmock, signing.DomainBeaconProposer, epoch, sigRoot) + require.NoError(t, err) + + sig, pubkey := sign(t, sigData[:]) + data, err := blindedBlock.SetSignature(tblsconv.SigToCore(sig)) + require.NoError(t, err) + + psig := core.ParSignedData{SignedData: data, ShareIdx: 0} + verifyFunc := signing.NewVerifyFunc(bmock) + require.NoError(t, verifyFunc(context.Background(), duty, pubkey, psig)) +} + +func TestVerifyInvalidDuty(t *testing.T) { + duty := core.NewBuilderRegistrationDuty(123) + + bmock, err := beaconmock.New() + require.NoError(t, err) + + pubkey := testutil.RandomCorePubKey(t) + psig := core.ParSignedData{} + + verifyFunc := signing.NewVerifyFunc(bmock) + require.EqualError(t, verifyFunc(context.Background(), duty, pubkey, psig), "invalid duty type") +} + +func sign(t *testing.T, data []byte) (*bls_sig.Signature, core.PubKey) { + t.Helper() + + blsPubkey, secret, err := tbls.Keygen() + require.NoError(t, err) + + sig, err := tbls.Sign(secret, data) + require.NoError(t, err) + + pk, err := tblsconv.KeyToCore(blsPubkey) + require.NoError(t, err) + + return sig, pk +}