diff --git a/cmd/cartesi-rollups-node/machinehash.go b/cmd/cartesi-rollups-node/machinehash.go new file mode 100644 index 000000000..9b392576a --- /dev/null +++ b/cmd/cartesi-rollups-node/machinehash.go @@ -0,0 +1,104 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "context" + "fmt" + "os" + "path" + + "github.com/cartesi/rollups-node/pkg/contracts" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" +) + +// Validates if the hash from the Cartesi Machine at machineDir matches the template hash onchain. +// It returns an error if it doesn't. +func validateMachineHash( + ctx context.Context, + machineDir string, + applicationAddress string, + ethereumNodeAddr string, +) error { + offchainHash, err := readHash(machineDir) + if err != nil { + return err + } + onchainHash, err := getTemplateHash(ctx, applicationAddress, ethereumNodeAddr) + if err != nil { + return err + } + if offchainHash != onchainHash { + return fmt.Errorf( + "cartesi machine hash mismatch; expected %v but got %v", + onchainHash, + offchainHash, + ) + } + return nil +} + +// Reads the Cartesi Machine hash from machineDir. Returns it as a hex string or +// an error +func readHash(machineDir string) (string, error) { + path := path.Join(machineDir, "hash") + hash, err := os.ReadFile(path) + if err != nil { + return "", ReadHashError{Err: err} + } else if len(hash) != common.HashLength { + return "", MalformedHashError{ReadBytes: len(hash)} + } + return common.Bytes2Hex(hash), nil +} + +// Retrieves the template hash from the application contract. Returns it as a +// hex string or an error +func getTemplateHash( + ctx context.Context, + applicationAddress string, + ethereumNodeAddr string, +) (string, error) { + client, err := ethclient.DialContext(ctx, ethereumNodeAddr) + if err != nil { + return "", err + } + cartesiApplication, err := contracts.NewCartesiDAppCaller( + common.HexToAddress(applicationAddress), + client, + ) + if err != nil { + return "", err + } + hash, err := cartesiApplication.GetTemplateHash(&bind.CallOpts{Context: ctx}) + if err != nil { + return "", err + } + return common.Bytes2Hex(hash[:]), nil +} + +// ------------------------------------------------------------------------------------------------ +// Custom errors +// ------------------------------------------------------------------------------------------------ + +type ReadHashError struct { + Err error +} + +func (e ReadHashError) Error() string { + return "failed to read hash file: " + e.Err.Error() +} + +type MalformedHashError struct { + ReadBytes int +} + +func (e MalformedHashError) Error() string { + return fmt.Sprintf( + "malformed hash: expected %v bytes, read %v", + common.HashLength, + e.ReadBytes, + ) +} diff --git a/cmd/cartesi-rollups-node/machinehash_test.go b/cmd/cartesi-rollups-node/machinehash_test.go new file mode 100644 index 000000000..0dc2094ea --- /dev/null +++ b/cmd/cartesi-rollups-node/machinehash_test.go @@ -0,0 +1,156 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package main + +import ( + "context" + "os" + "testing" + "time" + + "github.com/cartesi/rollups-node/internal/deps" + "github.com/cartesi/rollups-node/internal/machine" + "github.com/cartesi/rollups-node/pkg/addresses" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/suite" +) + +type ValidateMachineHashSuite struct { + suite.Suite +} + +func TestValidateMachineHash(t *testing.T) { + suite.Run(t, new(ValidateMachineHashSuite)) +} + +func (s *ValidateMachineHashSuite) TestItFailsWhenSnapshotHasNoHash() { + machineDir, err := os.MkdirTemp("", "") + if err != nil { + s.FailNow(err.Error()) + } + + err = validateMachineHash(context.Background(), machineDir, "", "") + s.NotNil(err) + s.IsType(ReadHashError{}, err) + + os.RemoveAll(machineDir) +} + +func (s *ValidateMachineHashSuite) TestItFailsWhenHashHasWrongSize() { + machineDir, err := mockMachineDir("deadbeef") + if err != nil { + s.FailNow(err.Error()) + } + + err = validateMachineHash(context.Background(), machineDir, "", "") + s.NotNil(err) + s.IsType(MalformedHashError{}, err) + + os.RemoveAll(machineDir) +} + +func (s *ValidateMachineHashSuite) TestItFailsWhenContextIsCanceled() { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + machineDir, err := createMachineSnapshot() + if err != nil { + s.FailNow(err.Error()) + } + devnet, err := startDevnet() + if err != nil { + s.FailNow(err.Error()) + } + + err = validateMachineHash( + ctx, + machineDir, + addresses.GetTestBook().CartesiDApp.String(), + "http://0.0.0.0:"+deps.DefaultDevnetPort, + ) + s.NotNil(err) + s.ErrorIs(err, context.DeadlineExceeded) + + os.RemoveAll(machineDir) + err = deps.Terminate(context.Background(), devnet) + if err != nil { + s.FailNow(err.Error()) + } +} + +func (s *ValidateMachineHashSuite) TestItSucceedsWhenHashesAreEqual() { + ctx := context.Background() + machineDir, err := createMachineSnapshot() + if err != nil { + s.FailNow(err.Error()) + } + devnet, err := startDevnet() + if err != nil { + s.FailNow(err.Error()) + } + + if err := validateMachineHash( + ctx, + machineDir, + addresses.GetTestBook().CartesiDApp.String(), + "http://0.0.0.0:"+deps.DefaultDevnetPort, + ); err != nil { + s.FailNow(err.Error()) + } + + os.RemoveAll(machineDir) + err = deps.Terminate(ctx, devnet) + if err != nil { + s.FailNow(err.Error()) + } +} + +// ------------------------------------------------------------------------------------------------ +// Auxiliary functions +// ------------------------------------------------------------------------------------------------ + +// Mocks the Cartesi Machine directory by creating a temporary directory with +// a single file named "hash" with the contents of `hash`, a hexadecimal string +func mockMachineDir(hash string) (string, error) { + temp, err := os.MkdirTemp("", "") + if err != nil { + return "", err + } + hashFile := temp + "/hash" + err = os.WriteFile(hashFile, common.FromHex(hash), os.ModePerm) + if err != nil { + return "", err + } + return temp, nil +} + +// Generates a new Cartesi Machine snapshot in a temporary directory and return +// its path +func createMachineSnapshot() (string, error) { + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + return "", err + } + if err = machine.Save( + "cartesi/rollups-node-snapshot:devel", + tmpDir, + "snapshotTemp", + ); err != nil { + return "", err + } + return tmpDir, nil +} + +// Starts a devnet in a Docker container with the default parameters +func startDevnet() (*deps.DepsContainers, error) { + container, err := deps.Run(context.Background(), deps.DepsConfig{ + Devnet: &deps.DevnetConfig{ + DockerImage: deps.DefaultDevnetDockerImage, + Port: deps.DefaultDevnetPort, + }, + }) + if err != nil { + return nil, err + } + return container, nil +} diff --git a/cmd/cartesi-rollups-node/main.go b/cmd/cartesi-rollups-node/main.go index c8ef42e36..e560cc5c0 100644 --- a/cmd/cartesi-rollups-node/main.go +++ b/cmd/cartesi-rollups-node/main.go @@ -27,6 +27,16 @@ func main() { ); err != nil { config.ErrorLogger.Fatal(err) } + if !config.GetCartesiFeatureDisableMachineHashCheck() { + if err := validateMachineHash( + ctx, + config.GetCartesiSnapshotDir(), + config.GetCartesiContractsApplicationAddress(), + config.GetCartesiBlockchainHttpEndpoint(), + ); err != nil { + config.ErrorLogger.Fatal(err) + } + } sunodoValidatorEnabled := config.GetCartesiExperimentalSunodoValidatorEnabled() if !sunodoValidatorEnabled {