From bd42c43732dc5956861bf50e95291c4956fed2e8 Mon Sep 17 00:00:00 2001 From: Victor Yves Crispim Date: Tue, 12 Mar 2024 18:30:35 -0300 Subject: [PATCH] feat: validate cartesi machine hash --- CHANGELOG.md | 1 + build/compose-devnet.yaml | 1 + build/compose-host.yaml | 1 + internal/node/machinehash.go | 84 ++++++++++++++++ internal/node/machinehash_test.go | 158 ++++++++++++++++++++++++++++++ internal/node/node.go | 11 +++ 6 files changed, 256 insertions(+) create mode 100644 internal/node/machinehash.go create mode 100644 internal/node/machinehash_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d913d275..9c47ce7f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added verification to ensure `CARTESI_BLOCKCHAIN_ID` matches the id returned from the Ethereum node +- Added verification to ensure the Cartesi Machine snapshot hash matches the template hash from the CartesiDApp contract - Added support for `CARTESI_AUTH_PRIVATE_KEY` and `CARTESI_AUTH_PRIVATE_KEY_FILE` - Added `CARTESI_AUTH_KIND` environment variable to select the blockchain authetication method - Added structured logging with slog. Colored logs can now be enabled with `CARTESI_LOG_PRETTY` environment variable. diff --git a/build/compose-devnet.yaml b/build/compose-devnet.yaml index 7f72571e8..a06e87edd 100644 --- a/build/compose-devnet.yaml +++ b/build/compose-devnet.yaml @@ -26,5 +26,6 @@ services: CARTESI_CONTRACTS_INPUT_BOX_ADDRESS: "0x59b22D57D4f067708AB0c00552767405926dc768" CARTESI_CONTRACTS_INPUT_BOX_DEPLOYMENT_BLOCK_NUMBER: "20" CARTESI_EPOCH_DURATION: "120" + CARTESI_FEATURE_DISABLE_MACHINE_HASH_CHECK: "true" CARTESI_AUTH_KIND: "mnemonic" CARTESI_AUTH_MNEMONIC: "test test test test test test test test test test test junk" diff --git a/build/compose-host.yaml b/build/compose-host.yaml index d1f782332..f48bad3e3 100644 --- a/build/compose-host.yaml +++ b/build/compose-host.yaml @@ -9,3 +9,4 @@ services: - "10007:10007" # Host Runner Rollup API environment: CARTESI_FEATURE_HOST_MODE: "true" + CARTESI_FEATURE_DISABLE_MACHINE_HASH_CHECK: "true" diff --git a/internal/node/machinehash.go b/internal/node/machinehash.go new file mode 100644 index 000000000..3d7d05418 --- /dev/null +++ b/internal/node/machinehash.go @@ -0,0 +1,84 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package node + +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( + "validate machine hash: 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 "", fmt.Errorf("read hash: %w", err) + } else if len(hash) != common.HashLength { + return "", fmt.Errorf( + "read hash: wrong size; expected %v bytes but read %v", + common.HashLength, + 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 "", fmt.Errorf("get template hash: %w", err) + } + cartesiApplication, err := contracts.NewCartesiDAppCaller( + common.HexToAddress(applicationAddress), + client, + ) + if err != nil { + return "", fmt.Errorf("get template hash: %w", err) + } + hash, err := cartesiApplication.GetTemplateHash(&bind.CallOpts{Context: ctx}) + if err != nil { + return "", fmt.Errorf("get template hash: %w", err) + } + return common.Bytes2Hex(hash[:]), nil +} diff --git a/internal/node/machinehash_test.go b/internal/node/machinehash_test.go new file mode 100644 index 000000000..51791e136 --- /dev/null +++ b/internal/node/machinehash_test.go @@ -0,0 +1,158 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package node + +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" +) + +const BlockchainHttpEndpoint = "http://0.0.0.0:" + deps.DefaultDevnetPort + +type ValidateMachineHashSuite struct { + suite.Suite +} + +func TestValidateMachineHash(t *testing.T) { + suite.Run(t, new(ValidateMachineHashSuite)) +} + +func (s *ValidateMachineHashSuite) TestItFailsWhenSnapshotHasNoHash() { + machineDir, err := os.MkdirTemp("", "") + defer os.RemoveAll(machineDir) + if err != nil { + s.FailNow(err.Error()) + } + + err = validateMachineHash(context.Background(), machineDir, "", "") + s.ErrorContains(err, "no such file or directory") +} + +func (s *ValidateMachineHashSuite) TestItFailsWhenHashHasWrongSize() { + machineDir, err := mockMachineDir("deadbeef") + defer os.RemoveAll(machineDir) + if err != nil { + s.FailNow(err.Error()) + } + + err = validateMachineHash(context.Background(), machineDir, "", "") + s.ErrorContains(err, "wrong size") +} + +func (s *ValidateMachineHashSuite) TestItFailsWhenContextIsCanceled() { + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + machineDir, err := createMachineSnapshot() + defer os.RemoveAll(machineDir) + if err != nil { + s.FailNow(err.Error()) + } + devnet, err := startDevnet() + if err != nil { + s.Fail(err.Error()) + if err = deps.Terminate(context.Background(), devnet); err != nil { + s.Fail(err.Error()) + } + } + + err = validateMachineHash( + ctx, + machineDir, + addresses.GetTestBook().CartesiDApp.String(), + BlockchainHttpEndpoint, + ) + s.NotNil(err) + s.ErrorIs(err, context.DeadlineExceeded) + + if err = deps.Terminate(context.Background(), devnet); err != nil { + s.Fail(err.Error()) + } +} + +func (s *ValidateMachineHashSuite) TestItSucceedsWhenHashesAreEqual() { + ctx := context.Background() + machineDir, err := createMachineSnapshot() + defer os.RemoveAll(machineDir) + if err != nil { + s.FailNow(err.Error()) + } + devnet, err := startDevnet() + if err != nil { + s.Fail(err.Error()) + if err = deps.Terminate(context.Background(), devnet); err != nil { + s.Fail(err.Error()) + } + } + + if err := validateMachineHash( + ctx, + machineDir, + addresses.GetTestBook().CartesiDApp.String(), + BlockchainHttpEndpoint, + ); err != nil { + s.Fail(err.Error()) + } + + if err = deps.Terminate(context.Background(), devnet); err != nil { + s.Fail(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 returns +// 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/internal/node/node.go b/internal/node/node.go index 86eeb80a9..be613d10f 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -18,6 +18,17 @@ func Setup(ctx context.Context, c config.NodeConfig) (services.Service, error) { return nil, err } + if !c.FeatureDisableMachineHashCheck { + if err := validateMachineHash( + ctx, + c.SnapshotDir, + c.ContractsApplicationAddress, + c.BlockchainHttpEndpoint.Value, + ); err != nil { + return nil, err + } + } + // create service return newSupervisorService(c), nil }