From 7a3951d90f5ede932555b206e14901d2779ff956 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:22:54 -0300 Subject: [PATCH 01/12] fix(kms): make the AWS KMS signer support dynamic-fee transactions --- Makefile | 3 +- internal/config/auth/auth.go | 28 ++- internal/config/generate/Config.toml | 9 + internal/config/generated.go | 16 ++ internal/kms/signtx_test.go | 111 ++++-------- test/compose/compose.integration.yaml | 20 +++ .../localstack_integration_test.go | 162 ++++++++++++++++++ 7 files changed, 262 insertions(+), 87 deletions(-) create mode 100644 test/integration/localstack_integration_test.go diff --git a/Makefile b/Makefile index 936749638..b47816841 100644 --- a/Makefile +++ b/Makefile @@ -571,7 +571,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)$$ @@ -579,6 +579,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..d58f86810 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" @@ -60,20 +61,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, ErrNotDefined) { + if err != nil { + return nil, err + } + awsOpts = append(awsOpts, aws_cfg.WithRegion(kmsRegion.Value)) + } + kmsEndpoint, err := GetAuthAwsKmsEndpoint() + if !errors.Is(err, 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..4bacaf7e0 --- /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)) +} From 55c2dd097e13dfa852a63bd7022a80ff1bbbd394 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:35:14 -0300 Subject: [PATCH 02/12] test(integration): add make target to run AWS LocalStack --- Makefile | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Makefile b/Makefile index b47816841..c7d372d84 100644 --- a/Makefile +++ b/Makefile @@ -516,6 +516,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-awslocalstack: ## Run the AWS LocalStack 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 @@ -524,6 +533,9 @@ stop-devnet: ## Stop the anvil devnet docker container stop-postgres: ## Stop the PostgreSQL 16 docker container @docker stop postgres || true +stop-awslocalstack: ## 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 From 8c016b82ab55aa2330077efab9ab6d7804c087b9 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:23:00 -0300 Subject: [PATCH 03/12] test(kms): add unit test for AWS KMS authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exercises the real 'GetTransactOptsFactory' AWS wiring. - Uses an httptest KMS implementation—no Docker or AWS access. - Tests dynamic-fee and legacy transactions. - Verifies the recovered sender matches the KMS key. - Uses isolated dummy AWS credentials and resets Viper state. --- internal/config/auth/auth_test.go | 155 ++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 internal/config/auth/auth_test.go diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go new file mode 100644 index 000000000..01aa5b9ff --- /dev/null +++ b/internal/config/auth/auth_test.go @@ -0,0 +1,155 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package auth + +import ( + "crypto/ecdsa" + "crypto/rand" + "encoding/asn1" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + + . "github.com/cartesi/rollups-node/internal/config" +) + +func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { + server := newFakeKMSServer(t) + t.Cleanup(server.Close) + setupAWSAuth(t, server.URL) + + chainID := big.NewInt(31337) + factory, err := GetTransactOptsFactory(t.Context(), chainID) + require.NoError(t, err) + opts, err := factory.NewTransactOpts(t.Context()) + require.NoError(t, err) + + to := common.Address{0x01} + tests := []struct { + name string + tx *types.Transaction + }{ + { + name: "dynamic fee", + tx: types.NewTx(&types.DynamicFeeTx{ + ChainID: chainID, + Nonce: 1, + GasTipCap: big.NewInt(1), + GasFeeCap: big.NewInt(2), + Gas: 21000, + To: &to, + Value: big.NewInt(3), + }), + }, + { + name: "legacy", + tx: types.NewTx(&types.LegacyTx{ + Nonce: 2, + GasPrice: big.NewInt(1), + Gas: 21000, + To: &to, + Value: big.NewInt(3), + }), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + signed, err := opts.Signer(opts.From, test.tx) + require.NoError(t, err) + sender, err := types.Sender(types.LatestSignerForChainID(chainID), signed) + require.NoError(t, err) + require.Equal(t, opts.From, sender) + }) + } +} + +func setupAWSAuth(t *testing.T, endpoint string) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) + viper.Set(AUTH_KIND, "aws") + viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") + viper.Set(AUTH_AWS_KMS_REGION, "us-east-1") + viper.Set(AUTH_AWS_KMS_ENDPOINT, endpoint) + + // Static dummy credentials keep the AWS SDK hermetic: it never consults + // shared config files, credential services, or EC2 instance metadata. + t.Setenv("AWS_ACCESS_KEY_ID", "test") + t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") +} + +func newFakeKMSServer(t *testing.T) *httptest.Server { + t.Helper() + + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + publicKey, err := asn1.Marshal(struct { + Algorithm struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + } + SubjectPublicKey asn1.BitString + }{ + Algorithm: struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier + }{ + Algorithm: asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1}, + Parameters: asn1.ObjectIdentifier{1, 3, 132, 0, 10}, + }, + SubjectPublicKey: asn1.BitString{Bytes: crypto.FromECDSAPub(&privateKey.PublicKey)}, + }) + require.NoError(t, err) + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-amz-json-1.1") + switch r.Header.Get("X-Amz-Target") { + case "TrentService.GetPublicKey": + writeKMSJSON(t, w, map[string]any{ + "KeyId": "alias/test-key", + "KeySpec": "ECC_SECG_P256K1", + "KeyUsage": "SIGN_VERIFY", + "PublicKey": base64.StdEncoding.EncodeToString(publicKey), + }) + case "TrentService.Sign": + var input struct { + Message string `json:"Message"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&input)) + digest, err := base64.StdEncoding.DecodeString(input.Message) + require.NoError(t, err) + r, s, err := ecdsa.Sign(rand.Reader, privateKey, digest) + require.NoError(t, err) + signature, err := asn1.Marshal(struct { + R *big.Int + S *big.Int + }{R: r, S: s}) + require.NoError(t, err) + writeKMSJSON(t, w, map[string]any{ + "KeyId": "alias/test-key", + "Signature": base64.StdEncoding.EncodeToString(signature), + "SigningAlgorithm": "ECDSA_SHA_256", + }) + default: + http.Error(w, "unexpected KMS operation", http.StatusBadRequest) + } + })) +} + +func writeKMSJSON(t *testing.T, w http.ResponseWriter, value any) { + t.Helper() + require.NoError(t, json.NewEncoder(w).Encode(value)) +} From bd75835ff43bf7d9c606e8fad27a8fa1052bf082 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:47:14 -0300 Subject: [PATCH 04/12] fixup! test(kms): add unit test for AWS KMS authentication --- Makefile | 3 +- internal/kms/signtx_test.go | 111 ++++++++---- test/compose/compose.integration.yaml | 20 --- .../localstack_integration_test.go | 162 ------------------ 4 files changed, 81 insertions(+), 215 deletions(-) delete mode 100644 test/integration/localstack_integration_test.go diff --git a/Makefile b/Makefile index c7d372d84..5c7aa6813 100644 --- a/Makefile +++ b/Makefile @@ -583,7 +583,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 awskms +INTEGRATION_SHARDS := basic quorum prt replay restart withdrawal INTEGRATION_SHARD_basic := ^Test(EchoAuthority|RejectException|MultiApp|EchoAuthorityStaging)$$ INTEGRATION_SHARD_quorum := ^Test(EchoQuorum|SameBlockInputs)$$ @@ -591,7 +591,6 @@ 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/kms/signtx_test.go b/internal/kms/signtx_test.go index d26975fb0..a4d7d422d 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -11,15 +11,95 @@ 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) @@ -48,37 +128,6 @@ 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 c74340642..3d305f338 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -83,18 +83,6 @@ 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. @@ -111,8 +99,6 @@ 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 @@ -139,12 +125,6 @@ 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 deleted file mode 100644 index 4bacaf7e0..000000000 --- a/test/integration/localstack_integration_test.go +++ /dev/null @@ -1,162 +0,0 @@ -// (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)) -} From 5e647384a4521ff5da203ad9a88cb01ae0fdebc3 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:03:20 -0300 Subject: [PATCH 05/12] fix(kms): remove redundant configuration variables for AWS KMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed CARTESI_AUTH_AWS_KMS_REGION and CARTESI_AUTH_AWS_KMS_ENDPOINT. - AWS KMS now uses the SDK’s standard region and endpoint resolution. - Updated tests and LocalStack instructions to use AWS_REGION and AWS_ENDPOINT_URL_KMS. - Documented AWS-provided configuration and the KMS alias risk. --- Makefile | 2 ++ internal/config/auth/auth.go | 18 +--------------- internal/config/auth/auth_test.go | 4 ++-- internal/config/generate/Config.toml | 26 +++++++--------------- internal/config/generate/docs.go | 17 ++++++++++++++- internal/config/generated.go | 32 ---------------------------- 6 files changed, 29 insertions(+), 70 deletions(-) diff --git a/Makefile b/Makefile index 5c7aa6813..36560b222 100644 --- a/Makefile +++ b/Makefile @@ -522,6 +522,8 @@ start-awslocalstack: ## Run the AWS LocalStack docker container @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 AWS_REGION=us-east-1" + @echo " export AWS_ENDPOINT_URL_KMS=http://localhost:4566" @echo " export LOCALSTACK_KMS_ENDPOINT=http://localhost:4566" @echo " export LOCALSTACK_KMS_REQUIRED=true" diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index d58f86810..8edced3e9 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -5,7 +5,6 @@ package auth import ( "context" - "errors" "fmt" "math/big" @@ -65,22 +64,7 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran if err != nil { return nil, err } - awsOpts := make([]func (*aws_cfg.LoadOptions) error, 0, 2) - kmsRegion, err := GetAuthAwsKmsRegion() - if !errors.Is(err, ErrNotDefined) { - if err != nil { - return nil, err - } - awsOpts = append(awsOpts, aws_cfg.WithRegion(kmsRegion.Value)) - } - kmsEndpoint, err := GetAuthAwsKmsEndpoint() - if !errors.Is(err, ErrNotDefined) { - if err != nil { - return nil, err - } - awsOpts = append(awsOpts, aws_cfg.WithBaseEndpoint(kmsEndpoint.Value)) - } - awsCfg, err := aws_cfg.LoadDefaultConfig(ctx, awsOpts...) + awsCfg, err := aws_cfg.LoadDefaultConfig(ctx) if err != nil { return nil, err } diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go index 01aa5b9ff..318605f78 100644 --- a/internal/config/auth/auth_test.go +++ b/internal/config/auth/auth_test.go @@ -80,13 +80,13 @@ func setupAWSAuth(t *testing.T, endpoint string) { t.Cleanup(viper.Reset) viper.Set(AUTH_KIND, "aws") viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") - viper.Set(AUTH_AWS_KMS_REGION, "us-east-1") - viper.Set(AUTH_AWS_KMS_ENDPOINT, endpoint) // Static dummy credentials keep the AWS SDK hermetic: it never consults // shared config files, credential services, or EC2 instance metadata. t.Setenv("AWS_ACCESS_KEY_ID", "test") t.Setenv("AWS_SECRET_ACCESS_KEY", "test") + t.Setenv("AWS_REGION", "us-east-1") + t.Setenv("AWS_ENDPOINT_URL_KMS", endpoint) t.Setenv("AWS_EC2_METADATA_DISABLED", "true") } diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index 39d787b06..c77bdd183 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -360,27 +360,17 @@ used-by = ["claimer", "node", "cli", "prt"] [auth.CARTESI_AUTH_AWS_KMS_KEY_ID] go-type = "RedactedString" description = """ -If set, the node will use the AWS KMS service with this key ID to sign transactions. +An AWS KMS key ID, alias, or ARN. -Must be set alongside `CARTESI_AUTH_AWS_KMS_REGION`.""" -omit = true -used-by = ["claimer", "node", "cli", "prt"] - -[auth.CARTESI_AUTH_AWS_KMS_REGION] -go-type = "RedactedString" -description = """ -An AWS KMS Region. +If set, the node will use the AWS KMS service with this key to sign transactions. -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. +Everything else about the AWS connection — region, endpoint, and credentials — is +resolved by the AWS SDK's standard chain, not by CARTESI_ variables. See the +"Externally-provided configuration" section for the variables involved. -When not provided, the default endpoint for the AWS region defined by `CARTESI_AUTH_AWS_KMS_REGION` is automatically used.""" +Prefer an ARN or a bare key ID over an alias: an alias is resolved per-region, so +the same alias in a different region names a different key and therefore a +different signing address.""" omit = true used-by = ["claimer", "node", "cli", "prt"] diff --git a/internal/config/generate/docs.go b/internal/config/generate/docs.go index 78b44d795..8820686b2 100644 --- a/internal/config/generate/docs.go +++ b/internal/config/generate/docs.go @@ -44,7 +44,8 @@ DO NOT EDIT. # Node Configuration The node is configurable through environment variables. -(There is no other way to configure it.) +Variables prefixed CARTESI_ are listed below. A few subsystems additionally read +standard variables defined by third-party SDKs; those are listed at the end. This file documents the configuration options. @@ -63,4 +64,18 @@ This file documents the configuration options. * **Used by:** {{range $i, $e := .UsedBy}}{{if $i}}, {{end}}{{$e}}{{end}} {{- end}} {{- end}} + +## Externally-provided configuration + +These are read by the AWS SDK, not by the node's own configuration layer, and +apply only when CARTESI_AUTH_KIND=aws. + +* AWS_REGION / AWS_DEFAULT_REGION — region used to resolve the KMS key. +* AWS_ENDPOINT_URL_KMS / AWS_ENDPOINT_URL — override the KMS endpoint + (VPC endpoint, PrivateLink, FIPS, or a local emulator such as LocalStack). +* AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN — static + credentials. They may instead come from the shared config file, an EC2 instance + profile, or IRSA; the node does not require any particular source. + +Full resolution order is documented by AWS; the node applies no overrides. ` diff --git a/internal/config/generated.go b/internal/config/generated.go index 929b12818..a891a79f6 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -22,9 +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" AUTH_MNEMONIC = "CARTESI_AUTH_MNEMONIC" AUTH_MNEMONIC_ACCOUNT_INDEX = "CARTESI_AUTH_MNEMONIC_ACCOUNT_INDEX" @@ -101,12 +99,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 - viper.SetDefault(AUTH_KIND, "mnemonic") // no default for CARTESI_AUTH_MNEMONIC @@ -1689,19 +1683,6 @@ 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) @@ -1715,19 +1696,6 @@ func GetAuthAwsKmsKeyId() (RedactedString, error) { return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_KEY_ID, ErrNotDefined) } -// GetAuthAwsKmsRegion returns the value for the environment variable CARTESI_AUTH_AWS_KMS_REGION. -func GetAuthAwsKmsRegion() (RedactedString, error) { - s := viper.GetString(AUTH_AWS_KMS_REGION) - if s != "" { - v, err := toRedactedString(s) - if err != nil { - return v, fmt.Errorf("failed to parse %s: %w", AUTH_AWS_KMS_REGION, err) - } - return v, nil - } - return notDefinedRedactedString(), fmt.Errorf("%s: %w", AUTH_AWS_KMS_REGION, ErrNotDefined) -} - // GetAuthKind returns the value for the environment variable CARTESI_AUTH_KIND. func GetAuthKind() (AuthKind, error) { s := viper.GetString(AUTH_KIND) From 99928635ea5c8a9bd7bfb628bd42776191b30b63 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:51:46 -0300 Subject: [PATCH 06/12] fix(kms): handle invalid AWS KMS authentication - `assembleSignature` now rejects `r` or `s` components longer than 32 bytes with a descriptive error. - Added regression tests covering overlong `r` and `s`. --- internal/kms/signtx.go | 5 +++++ internal/kms/signtx_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go index 26446dae6..f9bcf9557 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -14,6 +14,7 @@ import ( "crypto/ecdsa" "encoding/asn1" "errors" + "fmt" "math/big" "reflect" @@ -71,6 +72,10 @@ func normalizeS(S []byte) []byte { * of the values of `v` will hold ecrecover(hash, sig) == publicKey, and that * is the one ethereum wants. */ func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, error) { + if len(r) > 32 || len(s) > 32 { + return nil, fmt.Errorf("malformed signature: len(r)=%d len(s)=%d", len(r), len(s)) + } + sig := make([]byte, 65) // align `s` and `r` in case they have less then 32bytes in size diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index a4d7d422d..891501b2c 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -33,6 +33,32 @@ func CreateSignTxFnFromPrivateKey(privateKey *ecdsa.PrivateKey) SignTxFn { } } +func TestAssembleSignatureRejectsOverlongComponents(t *testing.T) { + tests := []struct { + name string + r []byte + s []byte + expected string + }{ + { + name: "r", r: make([]byte, 33), s: make([]byte, 32), + expected: "malformed signature: len(r)=33 len(s)=32", + }, + { + name: "s", r: make([]byte, 32), s: make([]byte, 33), + expected: "malformed signature: len(r)=32 len(s)=33", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + signature, err := assembleSignature(test.r, test.s, nil, nil) + require.Nil(t, signature) + require.EqualError(t, err, test.expected) + }) + } +} + func sendFunds( value *big.Int, SignTx SignTxFn, From 022af2009ab0cdda5648f4a6fa69eeda7c7ea9ae Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:09:07 -0300 Subject: [PATCH 07/12] test(kms): improve test coverage of AWS KMS authentication - `normalizeR` passthrough, zero-padding trim, and malformed-padding rejection. - High-`s` normalization. - Non-canonical DER `r` and `s` handling through a fake KMS client. - Unrecoverable signature failure. - `From()` identity reporting. - Unauthorized signer rejection, verifying KMS is never called. --- internal/kms/signtx_test.go | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index 891501b2c..f0b674c01 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -13,6 +13,7 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -59,6 +60,59 @@ func TestAssembleSignatureRejectsOverlongComponents(t *testing.T) { } } +func TestNormalizeR(t *testing.T) { + t.Run("keeps components up to 32 bytes", func(t *testing.T) { + input := []byte{1, 2, 3} + + r, err := normalizeR(input) + require.NoError(t, err) + require.Equal(t, input, r) + }) + + t.Run("trims leading zero padding", func(t *testing.T) { + padded := append([]byte{0}, make([]byte, 32)...) + padded[len(padded)-1] = 1 + + r, err := normalizeR(padded) + require.NoError(t, err) + require.Len(t, r, 32) + require.Equal(t, byte(1), r[len(r)-1]) + }) + + t.Run("rejects non-padding bytes", func(t *testing.T) { + malformed := append([]byte{1}, make([]byte, 32)...) + + r, err := normalizeR(malformed) + require.Nil(t, r) + require.EqualError(t, err, "malformed `r` component") + }) +} + +func TestNormalizeSConvertsHighSToLowS(t *testing.T) { + n := crypto.S256().Params().N + halfN := new(big.Int).Div(new(big.Int).Set(n), big.NewInt(2)) //nolint:mnd + highS := new(big.Int).Add(halfN, big.NewInt(1)) + expected := new(big.Int).Sub(n, highS).Bytes() + + require.Equal(t, expected, normalizeS(highS.Bytes())) +} + +func TestAssembleSignatureRejectsUnrecoverableKey(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + otherKey, err := crypto.GenerateKey() + require.NoError(t, err) + hash := crypto.Keccak256([]byte("test transaction")) + signature, err := crypto.Sign(hash, privateKey) + require.NoError(t, err) + + assembled, err := assembleSignature( + signature[:32], signature[32:64], hash, crypto.FromECDSAPub(&otherKey.PublicKey), + ) + require.EqualError(t, err, "failed to compute signature") + require.NotNil(t, assembled) +} + func sendFunds( value *big.Int, SignTx SignTxFn, @@ -141,6 +195,7 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { ) require.NoError(t, err) cancelStartup() + require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), factory.From()) type contextKey string submitCtx := context.WithValue(context.Background(), contextKey("phase"), "submit") @@ -154,11 +209,78 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { require.NoError(t, client.signContext.Err()) } +func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + arn := "alias/test-key" + factory, err := CreateAWSTransactOptsFactory( + context.Background(), client, &arn, ethtypes.NewEIP155Signer(big.NewInt(1)), + ) + require.NoError(t, err) + opts, err := factory.NewTransactOpts(context.Background()) + require.NoError(t, err) + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + + signed, err := opts.Signer(common.Address{0xff}, tx) + require.Nil(t, signed) + require.ErrorIs(t, err, bind.ErrNotAuthorized) + require.Zero(t, client.signCalls) +} + +func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + arn := "alias/test-key" + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + signer := ethtypes.NewEIP155Signer(big.NewInt(1)) + + tests := []struct { + name string + r []byte + s []byte + expected string + }{ + { + name: "non-padding byte in overlong r", r: append([]byte{1}, make([]byte, 32)...), s: []byte{1}, + expected: "malformed `r` component", + }, + { + name: "non-minimal overlong s", r: []byte{1}, s: append([]byte{0}, make([]byte, 32)...), + expected: "malformed signature: len(r)=1 len(s)=33", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := newFakeKMSClient(t, privateKey) + client.signature = marshalRawECDSASignature(test.r, test.s) + signTx, _, _, err := CreateAWSSignTxFn(context.Background(), client, &arn) + require.NoError(t, err) + + signed, err := signTx(context.Background(), tx, signer) + require.Nil(t, signed) + require.EqualError(t, err, test.expected) + }) + } +} + +func marshalRawECDSASignature(r, s []byte) []byte { + content := make([]byte, 0, len(r)+len(s)+4) + content = append(content, 0x02, byte(len(r))) + content = append(content, r...) + content = append(content, 0x02, byte(len(s))) + content = append(content, s...) + return append([]byte{0x30, byte(len(content))}, content...) +} + type fakeKMSClient struct { t *testing.T privateKey *ecdsa.PrivateKey publicKey []byte signContext context.Context + signCalls int + signature []byte } func newFakeKMSClient(t *testing.T, privateKey *ecdsa.PrivateKey) *fakeKMSClient { @@ -196,7 +318,11 @@ func (f *fakeKMSClient) Sign( input *awskms.SignInput, _ ...func(*awskms.Options), ) (*awskms.SignOutput, error) { + f.signCalls++ f.signContext = ctx + if f.signature != nil { + return &awskms.SignOutput{Signature: f.signature}, nil + } r, s, err := ecdsa.Sign(rand.Reader, f.privateKey, input.Message) require.NoError(f.t, err) signature, err := asn1.Marshal(struct { From 7c098c34a1a85620c642a5ac35ee7365e5386653 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:23:38 -0300 Subject: [PATCH 08/12] refactor(kms): fix small details in implementation and tests - Added the `endtoendtests` build tag to .`golangci.yml`. - Replaced unused `ethtypes.NewEIP155Signer` with the current `ethtypes.LatestSignerForChainID` in tests. - Replaced `reflect.DeepEqual` with `bytes.Equal`. - Recovery now tries both recovery IDs when `Ecrecover` fails. - Added a regression test for that behavior. - Reused one Ethereum client in the surviving manual test and closed it after use. - Replaced deprecated `types.NewTransaction` with `types.NewTx`. --- .golangci.yml | 3 +++ internal/config/auth/auth.go | 4 ++++ internal/config/auth/auth_test.go | 20 ++++++++++++++++ internal/kms/signtx.go | 8 +++---- internal/kms/signtx_test.go | 39 +++++++++++++++++++------------ 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 7a94ed81d..a7b264e22 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,7 @@ version: "2" +run: + build-tags: + - endtoendtests linters: enable: - exhaustive diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index 8edced3e9..d06166d4a 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -22,6 +22,10 @@ import ( ) func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { + if chainId == nil || chainId.Sign() <= 0 { + return nil, bind.ErrNoChainID + } + authKind, err := GetAuthKind() if err != nil { return nil, err diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go index 318605f78..ad5a57d6d 100644 --- a/internal/config/auth/auth_test.go +++ b/internal/config/auth/auth_test.go @@ -14,6 +14,7 @@ import ( "net/http/httptest" "testing" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -74,6 +75,25 @@ func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { } } +func TestGetTransactOptsFactoryRejectsInvalidChainID(t *testing.T) { + tests := []struct { + name string + chainID *big.Int + }{ + {name: "nil", chainID: nil}, + {name: "zero", chainID: big.NewInt(0)}, + {name: "negative", chainID: big.NewInt(-1)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, err := GetTransactOptsFactory(t.Context(), test.chainID) + require.Nil(t, factory) + require.ErrorIs(t, err, bind.ErrNoChainID) + }) + } +} + func setupAWSAuth(t *testing.T, endpoint string) { t.Helper() viper.Reset() diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go index f9bcf9557..737494136 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -10,13 +10,13 @@ package kms import ( + "bytes" "context" "crypto/ecdsa" "encoding/asn1" "errors" "fmt" "math/big" - "reflect" "github.com/aws/aws-sdk-go-v2/service/kms" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -86,13 +86,13 @@ func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, err sig[64] = i pub, err := crypto.Ecrecover(hash, sig[:]) if err != nil { - return nil, err + continue } - if reflect.DeepEqual(pub, key) { + if bytes.Equal(pub, key) { return sig, nil } } - return sig, errors.New("failed to compute signature") + return nil, errors.New("failed to compute signature") } /* Create a SignTxFn that uses the KMS infrastructure from AWS for signing. diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index f0b674c01..800648da3 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -110,21 +110,23 @@ func TestAssembleSignatureRejectsUnrecoverableKey(t *testing.T) { signature[:32], signature[32:64], hash, crypto.FromECDSAPub(&otherKey.PublicKey), ) require.EqualError(t, err, "failed to compute signature") - require.NotNil(t, assembled) + require.Nil(t, assembled) +} + +func TestAssembleSignatureTriesBothRecoveryIDs(t *testing.T) { + assembled, err := assembleSignature(make([]byte, 32), make([]byte, 32), make([]byte, 32), nil) + require.Nil(t, assembled) + require.EqualError(t, err, "failed to compute signature") } func sendFunds( + client *ethclient.Client, 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) @@ -135,12 +137,14 @@ func sendFunds( panic(err) } var data []byte - tx := ethtypes.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + tx := ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: nonce, To: &recipient, Value: value, Gas: gasLimit, GasPrice: gasPrice, Data: data, + }) chainID, err := client.NetworkID(context.Background()) if err != nil { panic(err) } - signedTx, err := SignTx(ctx, tx, ethtypes.NewEIP155Signer(chainID)) + signedTx, err := SignTx(ctx, tx, ethtypes.LatestSignerForChainID(chainID)) if err != nil { panic(err) } @@ -154,8 +158,13 @@ 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) + value20 := big.NewInt(2000000000000000000) // in wei (2 eth) + value10 := big.NewInt(1000000000000000000) // in wei (1 eth) + client, err := ethclient.Dial("http://127.0.0.1:8545") // anvil + if err != nil { + panic(err) + } + defer client.Close() anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) if err != nil { @@ -174,9 +183,9 @@ func TestSignTx(t *testing.T) { panic(err) } - sendFunds(value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), + sendFunds(client, value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), context.Background(), anvilAddress, KMSAddress) - sendFunds(value10, SignTx, + sendFunds(client, value10, SignTx, context.Background(), KMSAddress, anvilAddress) } @@ -191,7 +200,7 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { startupCtx, client, &arn, - ethtypes.NewEIP155Signer(big.NewInt(1)), + ethtypes.LatestSignerForChainID(big.NewInt(1)), ) require.NoError(t, err) cancelStartup() @@ -215,7 +224,7 @@ func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { client := newFakeKMSClient(t, privateKey) arn := "alias/test-key" factory, err := CreateAWSTransactOptsFactory( - context.Background(), client, &arn, ethtypes.NewEIP155Signer(big.NewInt(1)), + context.Background(), client, &arn, ethtypes.LatestSignerForChainID(big.NewInt(1)), ) require.NoError(t, err) opts, err := factory.NewTransactOpts(context.Background()) @@ -233,7 +242,7 @@ func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { require.NoError(t, err) arn := "alias/test-key" tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) - signer := ethtypes.NewEIP155Signer(big.NewInt(1)) + signer := ethtypes.LatestSignerForChainID(big.NewInt(1)) tests := []struct { name string From 46944387caa20cc82883e32635718f1f4282573f Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 20:43:19 -0300 Subject: [PATCH 09/12] style(kms): avoid lint errors --- internal/kms/signtx.go | 40 +++++++++++++++++++------------------ internal/kms/signtx_test.go | 35 +++++++++++++++++++------------- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/internal/kms/signtx.go b/internal/kms/signtx.go index 737494136..aad5d5c52 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -35,31 +35,33 @@ type Client interface { Sign(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) } +const signatureComponentSize = 32 + /* AWS sometimes reply with a `r` larger than 32bytes padded on the left with * zeros. Trim it down to a total of 32bytes */ -func normalizeR(R []byte) ([]byte, error) { - if len(R) <= 32 { - return R, nil +func normalizeR(r []byte) ([]byte, error) { + if len(r) <= signatureComponentSize { + return r, nil } - for i := 0; i < len(R)-32; i++ { - if R[i] != 0 { // must be padding + for i := 0; i < len(r)-signatureComponentSize; i++ { + if r[i] != 0 { // must be padding return nil, errors.New("malformed `r` component") } } - return R[len(R)-32:], nil + return r[len(r)-signatureComponentSize:], nil } /* normalize `s` to the lower half of N according to EIP-2 * ref. https://eips.ethereum.org/EIPS/eip-2 */ -func normalizeS(S []byte) []byte { - N := crypto.S256().Params().N - halfN := new(big.Int).Div(N, big.NewInt(2)) //nolint:mnd - SBI := new(big.Int).SetBytes(S) +func normalizeS(s []byte) []byte { + n := crypto.S256().Params().N + halfN := new(big.Int).Div(n, big.NewInt(2)) //nolint:mnd + sBigInt := new(big.Int).SetBytes(s) - if SBI.Cmp(halfN) > 0 { - S = new(big.Int).Sub(N, SBI).Bytes() + if sBigInt.Cmp(halfN) > 0 { + s = new(big.Int).Sub(n, sBigInt).Bytes() } - return S + return s } /* Compute the final component `v` of the ethereum signature, one KMS doesn't @@ -72,19 +74,19 @@ func normalizeS(S []byte) []byte { * of the values of `v` will hold ecrecover(hash, sig) == publicKey, and that * is the one ethereum wants. */ func assembleSignature(r []byte, s []byte, hash []byte, key []byte) ([]byte, error) { - if len(r) > 32 || len(s) > 32 { + if len(r) > signatureComponentSize || len(s) > signatureComponentSize { return nil, fmt.Errorf("malformed signature: len(r)=%d len(s)=%d", len(r), len(s)) } sig := make([]byte, 65) // align `s` and `r` in case they have less then 32bytes in size - copy(sig[32-len(r):], r) + copy(sig[signatureComponentSize-len(r):], r) copy(sig[64-len(s):], s) for i := byte(0); i < 2; i++ { sig[64] = i - pub, err := crypto.Ecrecover(hash, sig[:]) + pub, err := crypto.Ecrecover(hash, sig) if err != nil { continue } @@ -144,13 +146,13 @@ func CreateAWSSignTxFn( if err != nil { return nil, err } - return tx.WithSignature(signer, signature[:]) + return tx.WithSignature(signer, signature) }, publicKey, crypto.PubkeyToAddress(*publicKey), nil } -func GetPublicKeyBytes(ctx context.Context, client Client, Arn *string) ([]byte, error) { +func GetPublicKeyBytes(ctx context.Context, client Client, arn *string) ([]byte, error) { publicKeyOutput, err := client.GetPublicKey(ctx, &kms.GetPublicKeyInput{ - KeyId: Arn, + KeyId: arn, }) if err != nil { return nil, err diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index 800648da3..bfa974d33 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -25,6 +25,8 @@ import ( "github.com/stretchr/testify/require" ) +const testKeyID = "alias/test-key" + var ARN = "" /* Create a SignTxFn from a private key. Useful for testing */ @@ -90,7 +92,7 @@ func TestNormalizeR(t *testing.T) { func TestNormalizeSConvertsHighSToLowS(t *testing.T) { n := crypto.S256().Params().N - halfN := new(big.Int).Div(new(big.Int).Set(n), big.NewInt(2)) //nolint:mnd + halfN := new(big.Int).Div(new(big.Int).Set(n), big.NewInt(2)) highS := new(big.Int).Add(halfN, big.NewInt(1)) expected := new(big.Int).Sub(n, highS).Bytes() @@ -120,10 +122,10 @@ func TestAssembleSignatureTriesBothRecoveryIDs(t *testing.T) { } func sendFunds( + ctx context.Context, client *ethclient.Client, value *big.Int, - SignTx SignTxFn, - ctx context.Context, + signTx SignTxFn, sender common.Address, recipient common.Address, ) { @@ -144,7 +146,7 @@ func sendFunds( if err != nil { panic(err) } - signedTx, err := SignTx(ctx, tx, ethtypes.LatestSignerForChainID(chainID)) + signedTx, err := signTx(ctx, tx, ethtypes.LatestSignerForChainID(chainID)) if err != nil { panic(err) } @@ -183,10 +185,10 @@ func TestSignTx(t *testing.T) { panic(err) } - sendFunds(client, value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), - context.Background(), anvilAddress, KMSAddress) - sendFunds(client, value10, SignTx, - context.Background(), KMSAddress, anvilAddress) + sendFunds(context.Background(), client, value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), + anvilAddress, KMSAddress) + sendFunds(context.Background(), client, value10, SignTx, + KMSAddress, anvilAddress) } func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { @@ -194,7 +196,7 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { require.NoError(t, err) client := newFakeKMSClient(t, privateKey) - arn := "alias/test-key" + arn := testKeyID startupCtx, cancelStartup := context.WithCancel(context.Background()) factory, err := CreateAWSTransactOptsFactory( startupCtx, @@ -222,7 +224,7 @@ func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { privateKey, err := crypto.GenerateKey() require.NoError(t, err) client := newFakeKMSClient(t, privateKey) - arn := "alias/test-key" + arn := testKeyID factory, err := CreateAWSTransactOptsFactory( context.Background(), client, &arn, ethtypes.LatestSignerForChainID(big.NewInt(1)), ) @@ -240,7 +242,7 @@ func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { privateKey, err := crypto.GenerateKey() require.NoError(t, err) - arn := "alias/test-key" + arn := testKeyID tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) signer := ethtypes.LatestSignerForChainID(big.NewInt(1)) @@ -275,12 +277,17 @@ func TestAWSSignTxRejectsNonCanonicalDERComponents(t *testing.T) { } func marshalRawECDSASignature(r, s []byte) []byte { + const maxDERLength = 255 + if len(r) > maxDERLength || len(s) > maxDERLength || len(r)+len(s)+4 > maxDERLength { + panic("test DER signature is too large for single-byte length encoding") + } + content := make([]byte, 0, len(r)+len(s)+4) - content = append(content, 0x02, byte(len(r))) + content = append(content, 0x02, byte(len(r))) //nolint:gosec // Length is bounded above. content = append(content, r...) - content = append(content, 0x02, byte(len(s))) + content = append(content, 0x02, byte(len(s))) //nolint:gosec // Length is bounded above. content = append(content, s...) - return append([]byte{0x30, byte(len(content))}, content...) + return append([]byte{0x30, byte(len(content))}, content...) //nolint:gosec // Length is bounded above. } type fakeKMSClient struct { From 7309375b072cacb638537c526ac1ef6ffd44508b Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:11:07 -0300 Subject: [PATCH 10/12] feat(claimer,prt): log submitter identity on service startup --- internal/claimer/service.go | 1 + internal/prt/service.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/claimer/service.go b/internal/claimer/service.go index 1a1399ed3..9f92cab4e 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -138,6 +138,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, fmt.Errorf("getting transaction options: %w", err) } + s.Logger.Info("Claim submitter identity", "address", txOptsFactory.From()) } s.repository = c.Repository diff --git a/internal/prt/service.go b/internal/prt/service.go index 9d9878886..3279dee9c 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -121,6 +121,7 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, err } + s.Logger.Info("PRT submitter identity", "address", s.txOptsFactory.From()) } return s, nil From ff9125a754968404c6fa92229481f5f78152cb07 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:13:07 -0300 Subject: [PATCH 11/12] fix(cli): use a single authentication for all deposit transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ERC-20 deposit now creates one transaction-options factory per command. - The factory’s address is reused for confirmation output. - Fresh transaction options are derived from that same factory for approval and deposit. - Existing CLI callers remain compatible through GetTransactOpts. --- cmd/cartesi-rollups-cli/root/deposit/deposit.go | 9 +++++---- internal/cli/ethereum.go | 7 +++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmd/cartesi-rollups-cli/root/deposit/deposit.go b/cmd/cartesi-rollups-cli/root/deposit/deposit.go index 536717419..5b08cf460 100644 --- a/cmd/cartesi-rollups-cli/root/deposit/deposit.go +++ b/cmd/cartesi-rollups-cli/root/deposit/deposit.go @@ -13,6 +13,7 @@ import ( "github.com/cartesi/rollups-node/cmd/cartesi-rollups-cli/util" "github.com/cartesi/rollups-node/internal/cli" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/pkg/contracts/iapplication" "github.com/cartesi/rollups-node/pkg/contracts/ierc20errors" "github.com/cartesi/rollups-node/pkg/contracts/ierc20metadata" @@ -113,7 +114,7 @@ func runERC20(cmd *cobra.Command, args []string) { cobra.CheckErr(err) chainID, err := client.ChainID(ctx) cobra.CheckErr(err) - txOpts, err := cli.GetTransactOpts(ctx, chainID) + txOptsFactory, err := auth.GetTransactOptsFactory(ctx, chainID) cobra.CheckErr(err) if !skipConfirmation { @@ -124,7 +125,7 @@ func runERC20(cmd *cobra.Command, args []string) { " token: %s\n"+ " amount: %s\n"+ " approve: %t\n", - txOpts.From, appAddr, portalAddr, tokenAddr, amount.String(), approveParam) + txOptsFactory.From(), appAddr, portalAddr, tokenAddr, amount.String(), approveParam) confirmed, promptErr := cli.ConfirmPrompt("Do you want to continue?") cobra.CheckErr(promptErr) if !confirmed { @@ -137,7 +138,7 @@ func runERC20(cmd *cobra.Command, args []string) { if approveParam { token, err := ierc20metadata.NewIERC20Metadata(tokenAddr, client) cobra.CheckErr(err) - approveOpts, err := cli.GetTransactOpts(ctx, chainID) + approveOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) cobra.CheckErr(err) tx, err := token.Approve(approveOpts, portalAddr, amount) cobra.CheckErr(cli.DecorateRevert(err, @@ -153,7 +154,7 @@ func runERC20(cmd *cobra.Command, args []string) { portal, err := ierc20portal.NewIERC20Portal(portalAddr, client) cobra.CheckErr(err) - depositOpts, err := cli.GetTransactOpts(ctx, chainID) + depositOpts, err := cli.GetTransactOptsFromFactory(ctx, txOptsFactory) cobra.CheckErr(err) tx, err := portal.DepositERC20Tokens(depositOpts, tokenAddr, appAddr, amount, execData) // The revert can come from three layers: the portal itself diff --git a/internal/cli/ethereum.go b/internal/cli/ethereum.go index e44018c09..00357d81d 100644 --- a/internal/cli/ethereum.go +++ b/internal/cli/ethereum.go @@ -10,6 +10,7 @@ import ( "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" + "github.com/cartesi/rollups-node/pkg/ethutil" "github.com/ethereum/go-ethereum/accounts/abi/bind" ) @@ -18,7 +19,13 @@ func GetTransactOpts(ctx context.Context, chainId *big.Int) (*bind.TransactOpts, if err != nil { return nil, err } + return GetTransactOptsFromFactory(ctx, factory) +} +func GetTransactOptsFromFactory( + ctx context.Context, + factory ethutil.TransactOptsFactory, +) (*bind.TransactOpts, error) { txOpts, err := factory.NewTransactOpts(ctx) if err != nil { return nil, err From 3b8473dbd6e13becea02420e3508cc06b8432205 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:48:45 -0300 Subject: [PATCH 12/12] feat(node): delay startup of services until able to authenticate --- internal/claimer/service.go | 8 ++- internal/config/auth/auth.go | 13 +++- internal/node/node.go | 116 ++++++++++++++++++++++++++++++++++- internal/node/node_test.go | 64 +++++++++++++++++++ internal/prt/service.go | 8 ++- 5 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 internal/node/node_test.go diff --git a/internal/claimer/service.go b/internal/claimer/service.go index 9f92cab4e..9e4fc4df9 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -77,8 +77,7 @@ type PersistentConfig struct { ChainID uint64 } -func Create(ctx context.Context, c *CreateInfo) (*Service, error) { - var err error +func Create(ctx context.Context, c *CreateInfo) (_ *Service, err error) { if c == nil { return nil, errors.New("invalid CreateInfo is nil") @@ -101,6 +100,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, fmt.Errorf("creating base service: %w", err) } + defer func() { + if err != nil && s.Ticker != nil { + s.Ticker.Stop() + } + }() nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config) if err != nil { diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index d06166d4a..5bfa995df 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" @@ -21,6 +22,12 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" ) +// ErrSignerUnavailable identifies transient failures while acquiring the AWS +// KMS-backed signer. Callers may use this to degrade and retry signing services +// without treating unrelated configuration or service creation errors as +// recoverable. +var ErrSignerUnavailable = errors.New("signer unavailable") + func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { if chainId == nil || chainId.Sign() <= 0 { return nil, bind.ErrNoChainID @@ -73,12 +80,16 @@ func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.Tran return nil, err } kmsClient := aws_kms.NewFromConfig(awsCfg) - return signtx.CreateAWSTransactOptsFactory( + factory, err := signtx.CreateAWSTransactOptsFactory( ctx, kmsClient, aws.String(keyId.Value), types.LatestSignerForChainID(chainId), ) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSignerUnavailable, err) + } + return factory, nil default: return nil, fmt.Errorf("no valid authentication method found") } diff --git a/internal/node/node.go b/internal/node/node.go index 872ca8c26..9954bd1c8 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -5,13 +5,18 @@ package node import ( "context" + "errors" "fmt" + "log/slog" + "sync" + "time" "github.com/cartesi/rollups-node/pkg/service" "github.com/cartesi/rollups-node/internal/advancer" "github.com/cartesi/rollups-node/internal/claimer" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/internal/evmreader" "github.com/cartesi/rollups-node/internal/jsonrpc" "github.com/cartesi/rollups-node/internal/prt" @@ -26,8 +31,11 @@ import ( type serviceResult struct { service service.IService err error + create serviceCreator } +var degradedServiceRetryInterval = 5 * time.Second + type CreateInfo struct { service.CreateInfo @@ -87,7 +95,7 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { for _, create := range creators { go func() { svc, err := create(ctx, c, s) - ch <- serviceResult{service: svc, err: err} + ch <- serviceResult{service: svc, err: err, create: create} }() } @@ -95,6 +103,15 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { select { case result := <-ch: if result.err != nil { + if errors.Is(result.err, auth.ErrSignerUnavailable) { + s.Logger.Error("Signing service started in degraded state; retrying until the signer is available", + "error", result.err) + s.Children = append(s.Children, newDegradedService(ctx, s.Logger, + func(retryCtx context.Context) (service.IService, error) { + return result.create(retryCtx, c, s) + })) + continue + } stopAndDrain(s.Children, ch, len(creators)-len(s.Children)-1) return fmt.Errorf("failed to create service: %w", result.err) } @@ -107,6 +124,103 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { return nil } +// degradedService keeps a transiently unavailable signing service visible to +// readiness checks while recreating it in the background. It becomes a thin +// proxy once creation succeeds. +type degradedService struct { + ctx context.Context + cancel context.CancelFunc + logger *slog.Logger + create func(context.Context) (service.IService, error) + + mu sync.RWMutex + service service.IService +} + +func newDegradedService( + ctx context.Context, + logger *slog.Logger, + create func(context.Context) (service.IService, error), +) *degradedService { + retryCtx, cancel := context.WithCancel(ctx) + return °radedService{ctx: retryCtx, cancel: cancel, logger: logger, create: create} +} + +func (s *degradedService) current() service.IService { + s.mu.RLock() + defer s.mu.RUnlock() + return s.service +} + +func (s *degradedService) Alive() bool { + if child := s.current(); child != nil { + return child.Alive() + } + return true +} + +func (s *degradedService) Ready() bool { + if child := s.current(); child != nil { + return child.Ready() + } + return false +} + +func (s *degradedService) Reload() []error { + if child := s.current(); child != nil { + return child.Reload() + } + return nil +} + +func (s *degradedService) Tick() []error { return nil } + +func (s *degradedService) Stop(force bool) []error { + s.cancel() + if child := s.current(); child != nil { + return child.Stop(force) + } + return nil +} + +func (s *degradedService) String() string { + if child := s.current(); child != nil { + return child.String() + } + return "degraded signing service" +} + +func (s *degradedService) Serve() error { + ticker := time.NewTicker(degradedServiceRetryInterval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return nil + case <-ticker.C: + child, err := s.create(s.ctx) + if err != nil { + if s.ctx.Err() != nil { + return nil + } + s.logger.Error("Signing service remains degraded; creation retry failed", "error", err) + continue + } + if s.ctx.Err() != nil { + child.Stop(true) + return nil + } + + s.mu.Lock() + s.service = child + s.mu.Unlock() + s.logger.Info("Signing service recovered") + return child.Serve() + } + } +} + // stopAndDrain stops already-created children and drains remaining results // from the channel, stopping any successful services to prevent resource leaks. func stopAndDrain(children []service.IService, ch <-chan serviceResult, remaining int) { diff --git a/internal/node/node_test.go b/internal/node/node_test.go new file mode 100644 index 000000000..2e2c5ca0c --- /dev/null +++ b/internal/node/node_test.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package node + +import ( + "context" + "errors" + "io" + "log/slog" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +type recoveredService struct { + served chan struct{} + stopped atomic.Bool +} + +func (s *recoveredService) Alive() bool { return true } +func (s *recoveredService) Ready() bool { return true } +func (s *recoveredService) Reload() []error { return nil } +func (s *recoveredService) Tick() []error { return nil } +func (s *recoveredService) String() string { return "recovered" } +func (s *recoveredService) Stop(bool) []error { s.stopped.Store(true); return nil } +func (s *recoveredService) Serve() error { close(s.served); return nil } + +func TestDegradedServiceRetriesUntilRecovery(t *testing.T) { + oldInterval := degradedServiceRetryInterval + degradedServiceRetryInterval = time.Millisecond + t.Cleanup(func() { degradedServiceRetryInterval = oldInterval }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + recovered := &recoveredService{served: make(chan struct{})} + var attempts atomic.Int32 + degraded := newDegradedService(ctx, logger, func(context.Context) (service.IService, error) { + if attempts.Add(1) < 2 { + return nil, errors.New("KMS unavailable") + } + return recovered, nil + }) + + require.True(t, degraded.Alive()) + require.False(t, degraded.Ready()) + + done := make(chan error, 1) + go func() { done <- degraded.Serve() }() + select { + case <-recovered.served: + case <-time.After(time.Second): + t.Fatal("degraded service did not recover") + } + + require.True(t, degraded.Ready()) + require.NoError(t, <-done) + require.Empty(t, degraded.Stop(true)) + require.True(t, recovered.stopped.Load()) +} diff --git a/internal/prt/service.go b/internal/prt/service.go index 3279dee9c..64f228b28 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -50,8 +50,7 @@ type PersistentConfig struct { ChainID uint64 } -func Create(ctx context.Context, c *CreateInfo) (*Service, error) { - var err error +func Create(ctx context.Context, c *CreateInfo) (_ *Service, err error) { if err = ctx.Err(); err != nil { return nil, err // This returns context.Canceled or context.DeadlineExceeded. } @@ -63,6 +62,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, err } + defer func() { + if err != nil && s.Ticker != nil { + s.Ticker.Stop() + } + }() if c.EthClient == nil { return nil, fmt.Errorf("EthClient on prt service Create is nil")