diff --git a/Makefile b/Makefile index ef5157b11..ef9583466 100644 --- a/Makefile +++ b/Makefile @@ -498,6 +498,15 @@ start-postgres: ## Run the PostgreSQL 16 docker container @docker run --rm --name postgres -p 5432:5432 -d -e POSTGRES_PASSWORD=password -e POSTGRES_DB=rollupsdb -v $(CURDIR)/test/postgres/init-test-db.sh:/docker-entrypoint-initdb.d/init-test-db.sh postgres:18-alpine @$(MAKE) migrate +start-aws: ## Run the AWS LocakStack docker container + @echo "Starting AWS localstack" + @docker run --rm --name awslocalstack -p 127.0.0.1:4566:4566 -d -e SERVICES=kms localstack/localstack:4.14.0 + @echo "Add the following variables to run integration test with AWS services:" + @echo " export AWS_ACCESS_KEY_ID=test" + @echo " export AWS_SECRET_ACCESS_KEY=test" + @echo " export LOCALSTACK_KMS_ENDPOINT=http://localhost:4566" + @echo " export LOCALSTACK_KMS_REQUIRED=true" + start: start-postgres start-devnet ## Start the anvil devnet and PostgreSQL 16 docker containers stop-devnet: ## Stop the anvil devnet docker container @@ -506,6 +515,9 @@ stop-devnet: ## Stop the anvil devnet docker container stop-postgres: ## Stop the PostgreSQL 16 docker container @docker stop postgres || true +stop-aws: ## Stop the AWS LocalStack docker container + @docker stop awslocalstack || true + stop: stop-devnet stop-postgres ## Stop all running docker containers restart-devnet: ## Restart the anvil devnet docker container @@ -516,6 +528,10 @@ restart-postgres: ## Restart the PostgreSQL 16 docker container and migrate it @$(MAKE) stop-postgres @$(MAKE) start-postgres +restart-aws: ## Restart the AWS LocalStack docker container and migrate it + @$(MAKE) stop-aws + @$(MAKE) start-aws + restart: ## Restart all running docker containers @$(MAKE) stop-devnet @$(MAKE) stop-postgres @@ -553,7 +569,7 @@ check-license: ## Verify license headers on Go source files # Discovery (integration-test-shard-check) lists tests with a plain Go # toolchain, so the integration package must stay free of the Cartesi CGo # dependency for the check to build on the CI setup runner. -INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal +INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal awskms INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|MultiApp|EchoAuthorityStaging)$$ INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ @@ -561,6 +577,7 @@ INTEGRATION_SHARD_prt := ^Test(EchoPrt|RejectExceptionPrt|ForeclosePrt)$$ INTEGRATION_SHARD_replay := ^Test(Foreclose|ForecloseReplay|DivergentClaim)$$ INTEGRATION_SHARD_restart := ^Test(Restart|SnapshotPolicy)$$ INTEGRATION_SHARD_withdrawal := ^TestWithdrawalLifecycle$$ +INTEGRATION_SHARD_awskms := ^TestLocalStackAWSIntegration$$ # ----------------------------------------------------------------------------- # Node topology axis — orthogonal to shards. diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index 408f7b13a..663d66742 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -5,6 +5,7 @@ package auth import ( "context" + "errors" "fmt" "math/big" @@ -16,6 +17,7 @@ import ( aws_cfg "github.com/aws/aws-sdk-go-v2/config" aws_kms "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/cartesi/rollups-node/internal/config" . "github.com/cartesi/rollups-node/internal/config" signtx "github.com/cartesi/rollups-node/internal/kms" "github.com/cartesi/rollups-node/pkg/ethutil" @@ -60,20 +62,35 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran } return ethutil.NewStaticTransactOptsFactory(txOpts), nil case AuthKindAWS: - awsc, err := aws_cfg.LoadDefaultConfig(ctx) + keyId, err := GetAuthAwsKmsKeyId() if err != nil { return nil, err } - kmsConfig := aws_kms.NewFromConfig(awsc) - authAwsKmsKeyId, err := GetAuthAwsKmsKeyId() + awsOpts := make([]func (*aws_cfg.LoadOptions) error, 0, 2) + kmsRegion, err := GetAuthAwsKmsRegion() + if !errors.Is(err, config.ErrNotDefined) { + if err != nil { + return nil, err + } + awsOpts = append(awsOpts, aws_cfg.WithRegion(kmsRegion.Value)) + } + kmsEndpoint, err := GetAuthAwsKmsEndpoint() + if !errors.Is(err, config.ErrNotDefined) { + if err != nil { + return nil, err + } + awsOpts = append(awsOpts, aws_cfg.WithBaseEndpoint(kmsEndpoint.Value)) + } + awsCfg, err := aws_cfg.LoadDefaultConfig(ctx, awsOpts...) if err != nil { return nil, err } + kmsClient := aws_kms.NewFromConfig(awsCfg) return signtx.CreateAWSTransactOptsFactory( ctx, - kmsConfig, - aws.String(authAwsKmsKeyId.Value), - types.NewEIP155Signer(chainId), + kmsClient, + aws.String(keyId.Value), + types.LatestSignerForChainID(chainId), ) default: return nil, fmt.Errorf("no valid authentication method found") diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index c5d298a78..39d787b06 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -375,6 +375,15 @@ Must be set alongside `CARTESI_AUTH_AWS_KMS_KEY_ID`.""" omit = true used-by = ["claimer", "node", "cli", "prt"] +[auth.CARTESI_AUTH_AWS_KMS_ENDPOINT] +go-type = "RedactedString" +description = """ +An AWS KMS Endpoint. + +When not provided, the default endpoint for the AWS region defined by `CARTESI_AUTH_AWS_KMS_REGION` is automatically used.""" +omit = true +used-by = ["claimer", "node", "cli", "prt"] + # # Database # diff --git a/internal/config/generated.go b/internal/config/generated.go index cab15bdc8..929b12818 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -22,6 +22,7 @@ func init() { } const ( + AUTH_AWS_KMS_ENDPOINT = "CARTESI_AUTH_AWS_KMS_ENDPOINT" AUTH_AWS_KMS_KEY_ID = "CARTESI_AUTH_AWS_KMS_KEY_ID" AUTH_AWS_KMS_REGION = "CARTESI_AUTH_AWS_KMS_REGION" AUTH_KIND = "CARTESI_AUTH_KIND" @@ -100,6 +101,8 @@ const ( func SetDefaults() { // Set defaults based on the TOML definitions. + // no default for CARTESI_AUTH_AWS_KMS_ENDPOINT + // no default for CARTESI_AUTH_AWS_KMS_KEY_ID // no default for CARTESI_AUTH_AWS_KMS_REGION @@ -1686,6 +1689,19 @@ func (c *NodeConfig) ToValidatorConfig() *ValidatorConfig { } } +// GetAuthAwsKmsEndpoint returns the value for the environment variable CARTESI_AUTH_AWS_KMS_ENDPOINT. +func GetAuthAwsKmsEndpoint() (RedactedString, error) { + s := viper.GetString(AUTH_AWS_KMS_ENDPOINT) + if s != "" { + v, err := toRedactedString(s) + if err != nil { + return v, fmt.Errorf("failed to parse %s: %w", AUTH_AWS_KMS_ENDPOINT, err) + } + return v, nil + } + return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_ENDPOINT, ErrNotDefined) +} + // GetAuthAwsKmsKeyId returns the value for the environment variable CARTESI_AUTH_AWS_KMS_KEY_ID. func GetAuthAwsKmsKeyId() (RedactedString, error) { s := viper.GetString(AUTH_AWS_KMS_KEY_ID) diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index a4d7d422d..d26975fb0 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -11,95 +11,15 @@ import ( "math/big" "testing" - "github.com/cartesi/rollups-node/pkg/ethutil" - "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/ethclient" - awscfg "github.com/aws/aws-sdk-go-v2/config" awskms "github.com/aws/aws-sdk-go-v2/service/kms" kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types" "github.com/stretchr/testify/require" ) -var ARN = "" - -/* Create a SignTxFn from a private key. Useful for testing */ -func CreateSignTxFnFromPrivateKey(privateKey *ecdsa.PrivateKey) SignTxFn { - return func(_ context.Context, tx *ethtypes.Transaction, s ethtypes.Signer) (*ethtypes.Transaction, error) { - return ethtypes.SignTx(tx, s, privateKey) - } -} - -func sendFunds( - value *big.Int, - SignTx SignTxFn, - ctx context.Context, - sender common.Address, - recipient common.Address, -) { - client, err := ethclient.Dial("http://127.0.0.1:8545") // anvil - if err != nil { - panic(err) - } - - nonce, err := client.PendingNonceAt(context.Background(), sender) - if err != nil { - panic(err) - } - gasLimit := uint64(21000) - gasPrice, err := client.SuggestGasPrice(ctx) - if err != nil { - panic(err) - } - var data []byte - tx := ethtypes.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) - chainID, err := client.NetworkID(context.Background()) - if err != nil { - panic(err) - } - signedTx, err := SignTx(ctx, tx, ethtypes.NewEIP155Signer(chainID)) - if err != nil { - panic(err) - } - err = client.SendTransaction(context.Background(), signedTx) - if err != nil { - panic(err) - } -} - -func TestSignTx(t *testing.T) { - if len(ARN) == 0 { - t.Skip("Skipping test, ARN for KMS key is unset") - } - value20 := big.NewInt(2000000000000000000) // in wei (2 eth) - value10 := big.NewInt(1000000000000000000) // in wei (1 eth) - - anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) - if err != nil { - panic(err) - } - anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) - anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) - - config, err := awscfg.LoadDefaultConfig(context.Background()) - if err != nil { - panic(err) - } - kms := awskms.NewFromConfig(config) - SignTx, _, KMSAddress, err := CreateAWSSignTxFn(context.Background(), kms, &ARN) - if err != nil { - panic(err) - } - - sendFunds(value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), - context.Background(), anvilAddress, KMSAddress) - sendFunds(value10, SignTx, - context.Background(), KMSAddress, anvilAddress) -} - func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { privateKey, err := crypto.GenerateKey() require.NoError(t, err) @@ -128,6 +48,37 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { require.NoError(t, client.signContext.Err()) } +func TestAWSTransactOptsFactorySignsDynamicFeeTransaction(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + chainID := big.NewInt(31337) + client := newFakeKMSClient(t, privateKey) + keyID := "alias/test-key" + factory, err := CreateAWSTransactOptsFactory( + context.Background(), client, &keyID, ethtypes.LatestSignerForChainID(chainID), + ) + require.NoError(t, err) + + opts, err := factory.NewTransactOpts(context.Background()) + require.NoError(t, err) + tx := ethtypes.NewTx(ðtypes.DynamicFeeTx{ + ChainID: chainID, + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 21000, + To: &common.Address{0x01}, + Value: big.NewInt(3), + }) + signed, err := opts.Signer(opts.From, tx) + require.NoError(t, err) + + sender, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), sender) +} + type fakeKMSClient struct { t *testing.T privateKey *ecdsa.PrivateKey diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 3d305f338..c74340642 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -83,6 +83,18 @@ services: <<: *env restart: "no" + localstack: + image: localstack/localstack:4.14.0 + networks: + - devnet + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:4566/_localstack/health"] + interval: 2s + timeout: 2s + retries: 30 + environment: + SERVICES: kms + # The node is started and managed by TestMain inside the test process. # This ensures all tests (including restart and snapshot policy tests) # run with the same infrastructure in both local and CI environments. @@ -99,6 +111,8 @@ services: condition: service_healthy dapp-builder: condition: service_completed_successfully + localstack: + condition: service_healthy volumes: - dapp_images:/var/lib/cartesi-rollups-node/dapps:ro - node_logs:/var/lib/cartesi-rollups-node/logs @@ -125,6 +139,12 @@ services: CARTESI_TEST_ERC20_WITHDRAWAL_DAPP_PATH: /var/lib/cartesi-rollups-node/dapps/erc20-withdrawal-dapp CARTESI_TEST_NODE_LOG_FILE: /var/lib/cartesi-rollups-node/logs/node.log CARTESI_INSPECT_URL: http://localhost:10012/ + # test/integration/localstack_integration_test.go + AWS_ACCESS_KEY_ID: test + AWS_SECRET_ACCESS_KEY: test + AWS_REGION: us-east-1 + LOCALSTACK_KMS_ENDPOINT: http://localstack:4566 + LOCALSTACK_KMS_REQUIRED: "true" volumes: dapp_images: diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go new file mode 100644 index 000000000..9c18542d1 --- /dev/null +++ b/test/integration/localstack_integration_test.go @@ -0,0 +1,162 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +package integration + +import ( + "crypto/ecdsa" + "math/big" + "os" + "testing" + + "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/pkg/ethutil" + + "github.com/aws/aws-sdk-go-v2/aws" + awscfg "github.com/aws/aws-sdk-go-v2/config" + awskms "github.com/aws/aws-sdk-go-v2/service/kms" + kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/ethereum/go-ethereum/accounts/abi/bind/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/spf13/viper" + "github.com/stretchr/testify/suite" +) + +type AwsKmsIntegrationSuite struct { + suite.Suite + chainID *big.Int + kmsClient *awskms.Client + kmsRegisteredKey *awskms.CreateKeyOutput + txOpts *bind.TransactOpts +} + +func (s *AwsKmsIntegrationSuite) SetupSuite() { + t := s.T() + ctx := t.Context() + + const region = "us-east-1" + endpoint := os.Getenv("LOCALSTACK_KMS_ENDPOINT") + if endpoint == "" { + t.Skip("LOCALSTACK_KMS_ENDPOINT is not set; skipping LocalStack KMS integration test") + } + cfg, err := awscfg.LoadDefaultConfig(ctx, + awscfg.WithRegion(region), + awscfg.WithBaseEndpoint(endpoint), + ) + s.Require().NoError(err) + client := awskms.NewFromConfig(cfg) + + created, err := client.CreateKey(ctx, &awskms.CreateKeyInput{ + KeyUsage: kmstypes.KeyUsageTypeSignVerify, + KeySpec: kmstypes.KeySpecEccSecgP256k1, + }) + if err != nil { + message := "unable to create key on LocalStack" + if os.Getenv("LOCALSTACK_KMS_REQUIRED") == "true" { + t.Fatalf("%s: %v", message, err) + } + t.Skipf("%s: %v", message, err) + } + s.Require().NotNil(created.KeyMetadata) + s.Require().NotNil(created.KeyMetadata.KeyId) + + viper.Set(config.AUTH_KIND, "aws") + viper.Set(config.AUTH_AWS_KMS_KEY_ID, created.KeyMetadata.KeyId) + viper.Set(config.AUTH_AWS_KMS_REGION, region) + viper.Set(config.AUTH_AWS_KMS_ENDPOINT, endpoint) + + s.chainID = big.NewInt(31337) + factory, err := auth.GetTransactOptsFactory(ctx, s.chainID) + s.Require().NoError(err) + s.Require().NotEqual(common.Address{}, factory.From()) + opts, err := factory.NewTransactOpts(ctx) + s.Require().NoError(err) + + s.kmsClient = client + s.kmsRegisteredKey = created + s.txOpts = opts +} + +func (s *AwsKmsIntegrationSuite) TearDownSuite() { + _, _ = s.kmsClient.ScheduleKeyDeletion(s.T().Context(), &awskms.ScheduleKeyDeletionInput{ + KeyId: s.kmsRegisteredKey.KeyMetadata.KeyId, + PendingWindowInDays: aws.Int32(1), //nolint:mnd + }) + viper.Set(config.AUTH_KIND, nil) + viper.Set(config.AUTH_AWS_KMS_KEY_ID, nil) + viper.Set(config.AUTH_AWS_KMS_REGION, nil) + viper.Set(config.AUTH_AWS_KMS_ENDPOINT, nil) +} + +func (s *AwsKmsIntegrationSuite) sendFunds( + value *big.Int, + signTx bind.SignerFn, + sender common.Address, + recipient common.Address, +) { + ctx := s.T().Context() + + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + s.Require().NoError(err) + client, err := ethclient.Dial(ethEndpoint.Raw()) // anvil + s.Require().NoError(err) + + nonce, err := client.PendingNonceAt(ctx, sender) + s.Require().NoError(err) + gasLimit := uint64(21000) + gasPrice, err := client.SuggestGasPrice(ctx) + s.Require().NoError(err) + var data []byte + tx := types.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + signedTx, err := signTx(sender, tx) + s.Require().NoError(err) + err = client.SendTransaction(ctx, signedTx) + s.Require().NoError(err) +} + +func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { + anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + s.Require().NoError(err) + + anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) + anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) + anvilSignTx := func(address common.Address, tx *types.Transaction) (*types.Transaction, error) { + return types.SignTx(tx, types.LatestSignerForChainID(s.chainID), anvilPrivateKey) + } + value20 := big.NewInt(2000000000000000000) // in wei (2 eth) + value10 := big.NewInt(1000000000000000000) // in wei (1 eth) + s.sendFunds(value20, anvilSignTx, anvilAddress, s.txOpts.From) + s.sendFunds(value10, s.txOpts.Signer, s.txOpts.From, anvilAddress) +} + +func (s *AwsKmsIntegrationSuite) TestLocalStackAWSTransactionOptsFactory() { + to := common.Address{0x01} + tests := map[string]*types.Transaction{ + "legacy": types.NewTx(&types.LegacyTx{ + Nonce: 1, GasPrice: big.NewInt(2), Gas: 21000, To: &to, Value: big.NewInt(3), + }), + "dynamic fee": types.NewTx(&types.DynamicFeeTx{ + ChainID: s.chainID, Nonce: 2, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), + Gas: 21000, To: &to, Value: big.NewInt(3), + }), + } + for name, tx := range tests { + s.T().Run(name, func(*testing.T) { + signed, err := s.txOpts.Signer(s.txOpts.From, tx) + s.Require().NoError(err) + sender, err := types.Sender(types.LatestSignerForChainID(s.chainID), signed) + s.Require().NoError(err) + s.Require().Equal(s.txOpts.From, sender) + }) + } +} + +func TestLocalStackAWSIntegration(t *testing.T) { + suite.Run(t, new(AwsKmsIntegrationSuite)) +}