From 4502af14b45cfb5b33e3cc410767aba820a68146 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/19] 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 | 163 ++++++++++++++++++ 7 files changed, 263 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..32adc3297 --- /dev/null +++ b/test/integration/localstack_integration_test.go @@ -0,0 +1,163 @@ +// (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, err = ethClient.ChainID(ctx) + s.Require().NoError(err) + 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 7c06bd085ceb8dc005924811d9742c0c0cb65e1b 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 02/19] 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 38ad26512143948236efb0a59f78b8f99735fffc 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 03/19] test(integration): add make target to run AWS LocalStack --- Makefile | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b47816841..ec80bb8a0 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,7 +533,10 @@ stop-devnet: ## Stop the anvil devnet docker container stop-postgres: ## Stop the PostgreSQL 16 docker container @docker stop postgres || true -stop: stop-devnet stop-postgres ## Stop all running docker containers +stop-awslocalstack: ## Stop the AWS LocalStack docker container + @docker stop awslocalstack || true + +stop: stop-devnet stop-postgres ## Stop the anvil devnet and PostgreSQL 16 docker containers restart-devnet: ## Restart the anvil devnet docker container @$(MAKE) stop-devnet From 3abc81aa3e6e394ad4d0492dd84fa74106ff4f07 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:48:47 -0300 Subject: [PATCH 04/19] test(integration): fix AWS KMS subtest execution - Replaced nondeterministic map iteration with an ordered test-case slice. - Replaced s.T().Run with s.Run, ensuring suite assertions target the correct subtest. - Legacy and dynamic-fee cases now run predictably and report failures independently. --- .../localstack_integration_test.go | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 32adc3297..46b884493 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -138,18 +138,27 @@ func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { 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), - }), + tests := []struct { + name string + tx *types.Transaction + }{ + { + name: "legacy", + tx: types.NewTx(&types.LegacyTx{ + Nonce: 1, GasPrice: big.NewInt(2), Gas: 21000, To: &to, Value: big.NewInt(3), + }), + }, + { + name: "dynamic fee", + tx: 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) + for _, test := range tests { + s.Run(test.name, func() { + signed, err := s.txOpts.Signer(s.txOpts.From, test.tx) s.Require().NoError(err) sender, err := types.Sender(types.LatestSignerForChainID(s.chainID), signed) s.Require().NoError(err) From 186055b45575faacfe97a2bfc7d5d158c9ce1487 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:07:38 -0300 Subject: [PATCH 05/19] test(integration): use new style for Ethereum transactions for better test coverage - Funding transfers now use EIP-1559 `DynamicFeeTx`. - Gas tip and fee caps are derived from the current chain state. - `sendFunds` waits for transaction mining before returning. - Receipt success is asserted, removing the funding race. - Ethereum clients are closed after use. --- .../localstack_integration_test.go | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 46b884493..50f45afd2 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -107,18 +107,36 @@ func (s *AwsKmsIntegrationSuite) sendFunds( s.Require().NoError(err) client, err := ethclient.Dial(ethEndpoint.Raw()) // anvil s.Require().NoError(err) + defer client.Close() nonce, err := client.PendingNonceAt(ctx, sender) s.Require().NoError(err) gasLimit := uint64(21000) - gasPrice, err := client.SuggestGasPrice(ctx) + gasTipCap, err := client.SuggestGasTipCap(ctx) s.Require().NoError(err) - var data []byte - tx := types.NewTransaction(nonce, recipient, value, gasLimit, gasPrice, data) + header, err := client.HeaderByNumber(ctx, nil) + s.Require().NoError(err) + s.Require().NotNil(header.BaseFee) + gasFeeCap := new(big.Int).Add( + new(big.Int).Mul(header.BaseFee, big.NewInt(2)), //nolint:mnd // EIP-1559 base-fee headroom. + gasTipCap, + ) + tx := types.NewTx(&types.DynamicFeeTx{ + ChainID: s.chainID, + Nonce: nonce, + GasTipCap: gasTipCap, + GasFeeCap: gasFeeCap, + Gas: gasLimit, + To: &recipient, + Value: value, + }) signedTx, err := signTx(sender, tx) s.Require().NoError(err) err = client.SendTransaction(ctx, signedTx) s.Require().NoError(err) + receipt, err := bind.WaitMined(ctx, client, signedTx.Hash()) + s.Require().NoError(err) + s.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status) } func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { From 48c2a3ab73b70bea8a4cf725f509230e4eafcfaf Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:26:58 -0300 Subject: [PATCH 06/19] test(integration): only run AWS Local Stack for required tests - LocalStack is now behind the awskms Compose profile. - Its dependency is optional when that profile is inactive. - Compose runners activate the profile only when the selected shards include awskms. - The awskms shard is excluded from the multiprocess topology. --- Makefile | 7 ++++++- test/compose/compose.integration.yaml | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ec80bb8a0..401f5ecc1 100644 --- a/Makefile +++ b/Makefile @@ -613,7 +613,7 @@ INTEGRATION_TOPOLOGIES := standalone multiprocess NODE_TOPOLOGY ?= standalone INTEGRATION_SHARDS_standalone := $(INTEGRATION_SHARDS) -INTEGRATION_SHARDS_multiprocess := $(INTEGRATION_SHARDS) +INTEGRATION_SHARDS_multiprocess := $(filter-out awskms,$(INTEGRATION_SHARDS)) # The CI matrix is the set of (shard, topology) cells, encoded "shard:topology". INTEGRATION_CELLS := $(foreach t,$(INTEGRATION_TOPOLOGIES),$(foreach s,$(INTEGRATION_SHARDS_$(t)),$(s):$(t))) @@ -637,6 +637,9 @@ TOPOLOGIES_SELECTED = $(if $(filter all,$(NODE_TOPOLOGY)),$(INTEGRATION_TOPOLOGI shards_for = $(filter $(if $(strip $(SHARD)),$(SHARD),$(INTEGRATION_SHARDS_$(1))),$(INTEGRATION_SHARDS_$(1))) # run_pattern(topology): the selected shards' -run regexes as one alternation. run_pattern = $(subst $(space),|,$(strip $(foreach s,$(call shards_for,$(1)),$(INTEGRATION_SHARD_$(s))))) +# compose_profiles(topology): activate optional infrastructure required by the +# selected shards for this topology. +compose_profiles = $(if $(filter awskms,$(call shards_for,$(1))),awskms,) # Selected (shard:topology) cells, for PARALLEL fan-out. SELECTED_CELLS = $(foreach t,$(TOPOLOGIES_SELECTED),$(foreach s,$(call shards_for,$(t)),$(s):$(t))) # Label for project/log names: the SHARD filter joined by '-', or "all". @@ -684,6 +687,7 @@ _compose-topology-%: COMPOSE_PROJECT='$(if $(filter rollups-node-integration,$(COMPOSE_PROJECT)),rollups-node-integration-$(SUITE_LABEL)-$*,$(COMPOSE_PROJECT))' \ INTEGRATION_LOGS='integration-logs-$(SUITE_LABEL)-$*.txt' \ TEST_PATTERN="$$pattern" SHARD_NAME='$(SUITE_LABEL)-$*' NODE_TOPOLOGY='$*' \ + COMPOSE_PROFILES='$(call compose_profiles,$*)' \ GOTESTSUM_FORMAT='$(COMPOSE_TOPOLOGY_GOTESTSUM_FORMAT)' \ scripts/compose-integration-run.sh @@ -694,6 +698,7 @@ _compose-cell-%: TEST_PATTERN='$(INTEGRATION_SHARD_$(firstword $(subst :, ,$*)))' \ SHARD_NAME='$(firstword $(subst :, ,$*))' \ NODE_TOPOLOGY='$(lastword $(subst :, ,$*))' \ + COMPOSE_PROFILES='$(if $(filter awskms,$(firstword $(subst :, ,$*))),awskms,)' \ GOTESTSUM_FORMAT='$(GOTESTSUM_FORMAT)' \ scripts/compose-integration-run.sh diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index c74340642..32e1ae532 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -85,6 +85,7 @@ services: localstack: image: localstack/localstack:4.14.0 + profiles: [awskms] networks: - devnet healthcheck: @@ -113,6 +114,7 @@ services: condition: service_completed_successfully localstack: condition: service_healthy + required: false volumes: - dapp_images:/var/lib/cartesi-rollups-node/dapps:ro - node_logs:/var/lib/cartesi-rollups-node/logs From 7b1d009f903bdb430df6d9ff75abf975a9c2784f Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:53:34 -0300 Subject: [PATCH 07/19] test(integration): fix clean up of configurations used in tests - Replaced `TearDownSuite` cleanup with eagerly registered `t.Cleanup` callbacks. - KMS key deletion now uses a fresh background context with a 10-second timeout. - Viper is fully reset and configuration defaults are restored. - Removed suite fields that existed only for deferred teardown. --- .../localstack_integration_test.go | 33 +++++++++---------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 50f45afd2..fc7122ac9 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -6,10 +6,12 @@ package integration import ( + "context" "crypto/ecdsa" "math/big" "os" "testing" + "time" "github.com/cartesi/rollups-node/internal/config" "github.com/cartesi/rollups-node/internal/config/auth" @@ -30,10 +32,8 @@ import ( type AwsKmsIntegrationSuite struct { suite.Suite - chainID *big.Int - kmsClient *awskms.Client - kmsRegisteredKey *awskms.CreateKeyOutput - txOpts *bind.TransactOpts + chainID *big.Int + txOpts *bind.TransactOpts } func (s *AwsKmsIntegrationSuite) SetupSuite() { @@ -65,7 +65,19 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { } s.Require().NotNil(created.KeyMetadata) s.Require().NotNil(created.KeyMetadata.KeyId) + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = client.ScheduleKeyDeletion(cleanupCtx, &awskms.ScheduleKeyDeletionInput{ + KeyId: created.KeyMetadata.KeyId, + PendingWindowInDays: aws.Int32(1), + }) + }) + t.Cleanup(func() { + viper.Reset() + config.SetDefaults() + }) 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) @@ -79,22 +91,9 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { 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, From 914dace4e5643042f47eda3bd615a5f873904df8 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:02:36 -0300 Subject: [PATCH 08/19] test(integration): isolate AWS KMS funding account Use a dedicated Foundry account to avoid nonce collisions with the node submitter and other integration-test actors. --- test/integration/localstack_integration_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index fc7122ac9..cb4e24dfd 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -139,7 +139,10 @@ func (s *AwsKmsIntegrationSuite) sendFunds( } func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { - anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, 0) + // Keep funding transactions isolated from the node submitter (index 0) and + // the guardian/quorum accounts used by the other integration suites. + const fundingAccountIndex uint32 = 9 + anvilPrivateKey, err := ethutil.MnemonicToPrivateKey(ethutil.FoundryMnemonic, fundingAccountIndex) s.Require().NoError(err) anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) From 78d2ed4e2074b109905189848c1544a6445fbc31 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:11:15 -0300 Subject: [PATCH 09/19] test(integration): allow built-in readness check of AWS LocalStack image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed the custom gateway-only LocalStack healthcheck, allowing the image’s built-in per-service readiness check and start period to apply. --- test/compose/compose.integration.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 32e1ae532..20e846e03 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -88,11 +88,6 @@ services: profiles: [awskms] networks: - devnet - healthcheck: - test: ["CMD", "curl", "-fsS", "http://localhost:4566/_localstack/health"] - interval: 2s - timeout: 2s - retries: 30 environment: SERVICES: kms From 588fec0cc7619fc69bd20f5e1fdb921cbc885456 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:43:06 -0300 Subject: [PATCH 10/19] test(integration): clean up AWS KMS test resources - Hoisted the Ethereum client into SetupSuite and registered cleanup once. - Reused the client for both funding transfers. - Read AWS_REGION from the environment, defaulting to us-east-1. - Enforced the bind.SignerFn address contract with bind.ErrNotAuthorized. --- .../localstack_integration_test.go | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index cb4e24dfd..4dc497102 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -32,15 +32,19 @@ import ( type AwsKmsIntegrationSuite struct { suite.Suite - chainID *big.Int - txOpts *bind.TransactOpts + chainID *big.Int + ethClient *ethclient.Client + txOpts *bind.TransactOpts } func (s *AwsKmsIntegrationSuite) SetupSuite() { t := s.T() ctx := t.Context() - const region = "us-east-1" + region := os.Getenv("AWS_REGION") + if region == "" { + 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") @@ -78,6 +82,12 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { viper.Reset() config.SetDefaults() }) + ethEndpoint, err := config.GetBlockchainHttpEndpoint() + s.Require().NoError(err) + ethClient, err := ethclient.DialContext(ctx, ethEndpoint.Raw()) + s.Require().NoError(err) + t.Cleanup(ethClient.Close) + 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) @@ -91,6 +101,7 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { opts, err := factory.NewTransactOpts(ctx) s.Require().NoError(err) + s.ethClient = ethClient s.txOpts = opts } @@ -102,18 +113,12 @@ func (s *AwsKmsIntegrationSuite) sendFunds( ) { ctx := s.T().Context() - ethEndpoint, err := config.GetBlockchainHttpEndpoint() - s.Require().NoError(err) - client, err := ethclient.Dial(ethEndpoint.Raw()) // anvil - s.Require().NoError(err) - defer client.Close() - - nonce, err := client.PendingNonceAt(ctx, sender) + nonce, err := s.ethClient.PendingNonceAt(ctx, sender) s.Require().NoError(err) gasLimit := uint64(21000) - gasTipCap, err := client.SuggestGasTipCap(ctx) + gasTipCap, err := s.ethClient.SuggestGasTipCap(ctx) s.Require().NoError(err) - header, err := client.HeaderByNumber(ctx, nil) + header, err := s.ethClient.HeaderByNumber(ctx, nil) s.Require().NoError(err) s.Require().NotNil(header.BaseFee) gasFeeCap := new(big.Int).Add( @@ -131,9 +136,9 @@ func (s *AwsKmsIntegrationSuite) sendFunds( }) signedTx, err := signTx(sender, tx) s.Require().NoError(err) - err = client.SendTransaction(ctx, signedTx) + err = s.ethClient.SendTransaction(ctx, signedTx) s.Require().NoError(err) - receipt, err := bind.WaitMined(ctx, client, signedTx.Hash()) + receipt, err := bind.WaitMined(ctx, s.ethClient, signedTx.Hash()) s.Require().NoError(err) s.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status) } @@ -148,6 +153,9 @@ func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { anvilPublicKey := anvilPrivateKey.Public().(*ecdsa.PublicKey) anvilAddress := crypto.PubkeyToAddress(*anvilPublicKey) anvilSignTx := func(address common.Address, tx *types.Transaction) (*types.Transaction, error) { + if address != anvilAddress { + return nil, bind.ErrNotAuthorized + } return types.SignTx(tx, types.LatestSignerForChainID(s.chainID), anvilPrivateKey) } value20 := big.NewInt(2000000000000000000) // in wei (2 eth) From 8eac70dc4c95f050f4fd3888186236634a0b0d82 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:39:01 -0300 Subject: [PATCH 11/19] test(integration): fail AWS KMS test when misconfigured --- Makefile | 8 ++++++++ test/integration/localstack_integration_test.go | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 401f5ecc1..1df750b83 100644 --- a/Makefile +++ b/Makefile @@ -732,6 +732,14 @@ integration-test-local: build cartesi-rollups-machine-tool echo-dapp reject-loop _local-topology-%: @pattern='$(call run_pattern,$*)'; \ if [ -z "$$pattern" ]; then echo "skip: no applicable shards for topology '$*' (SHARD filter excludes all)"; exit 0; fi; \ + if [ -n "$(filter awskms,$(SHARD))" ]; then \ + if [ -z "$$LOCALSTACK_KMS_ENDPOINT" ]; then \ + echo "ERROR: LOCALSTACK_KMS_ENDPOINT is required when SHARD includes awskms." >&2; \ + echo "Run 'make start-awslocalstack' and export the variables it prints." >&2; \ + exit 1; \ + fi; \ + export LOCALSTACK_KMS_REQUIRED=true; \ + fi; \ cartesi-rollups-cli db init; \ test_ports="10000 10001 10002 10003 10004 10005 10006 10011 10012"; \ busy_pids="$$(for p in $$test_ports; do lsof -tiTCP:$$p -sTCP:LISTEN 2>/dev/null || true; done | sort -u | tr '\n' ' ')"; \ diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 4dc497102..5fd52f091 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -45,8 +45,12 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { if region == "" { region = "us-east-1" } + required := os.Getenv("LOCALSTACK_KMS_REQUIRED") == "true" endpoint := os.Getenv("LOCALSTACK_KMS_ENDPOINT") if endpoint == "" { + if required { + t.Fatal("LOCALSTACK_KMS_ENDPOINT is required for this shard") + } t.Skip("LOCALSTACK_KMS_ENDPOINT is not set; skipping LocalStack KMS integration test") } cfg, err := awscfg.LoadDefaultConfig(ctx, @@ -62,7 +66,7 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { }) if err != nil { message := "unable to create key on LocalStack" - if os.Getenv("LOCALSTACK_KMS_REQUIRED") == "true" { + if required { t.Fatalf("%s: %v", message, err) } t.Skipf("%s: %v", message, err) From 2531d77b80bd3a01cc7457ded4c89611dfd9033e 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 12/19] 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 ------------------- .../localstack_integration_test.go | 4 +-- 7 files changed, 31 insertions(+), 72 deletions(-) diff --git a/Makefile b/Makefile index 1df750b83..452a3ba79 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) diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index 5fd52f091..aa56d8367 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -94,8 +94,8 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { 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) + t.Setenv("AWS_REGION", region) + t.Setenv("AWS_ENDPOINT_URL_KMS", endpoint) s.chainID, err = ethClient.ChainID(ctx) s.Require().NoError(err) From b0057fbeb88d0203b5c3d55ce75df06891420edf 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 13/19] 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 | 105 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 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 d26975fb0..414d0dbcf 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -11,15 +11,120 @@ 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 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, + 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) From d2b092f92de07bff6c512a0aa7573494a3da1ea4 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 14/19] 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 | 242 +++++++++++++++++- .../localstack_integration_test.go | 30 +++ 2 files changed, 266 insertions(+), 6 deletions(-) diff --git a/internal/kms/signtx_test.go b/internal/kms/signtx_test.go index 414d0dbcf..e56c2c70a 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -8,10 +8,12 @@ import ( "crypto/ecdsa" "crypto/rand" "encoding/asn1" + "errors" "math/big" "testing" "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" @@ -58,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, @@ -140,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") @@ -184,14 +240,178 @@ func TestAWSTransactOptsFactorySignsDynamicFeeTransaction(t *testing.T) { require.Equal(t, crypto.PubkeyToAddress(privateKey.PublicKey), sender) } +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 TestAWSSignTxPropagatesKMSSignFailure(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + signErr := errors.New("KMSInternalException") + client.signErr = signErr + keyID := testKeyID + signTx, _, _, err := CreateAWSSignTxFn(t.Context(), client, &keyID) + require.NoError(t, err) + + chainID := big.NewInt(31337) + tx := ethtypes.NewTx(ðtypes.DynamicFeeTx{ + ChainID: chainID, GasTipCap: big.NewInt(1), GasFeeCap: big.NewInt(2), Gas: 21000, + }) + signed, err := signTx(t.Context(), tx, ethtypes.LatestSignerForChainID(chainID)) + + require.Nil(t, signed) + require.ErrorIs(t, err, signErr) +} + +func TestAWSSignTxRejectsMalformedKMSSignature(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + keyID := testKeyID + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + signer := ethtypes.LatestSignerForChainID(big.NewInt(1)) + + for _, test := range []struct { + name string + signature []byte + }{ + {name: "not DER", signature: []byte{0xff, 0xff, 0xff}}, + {name: "truncated sequence", signature: []byte{0x30, 0x03, 0x02, 0x01, 0x01}}, + } { + t.Run(test.name, func(t *testing.T) { + client := newFakeKMSClient(t, privateKey) + client.signature = test.signature + signTx, _, _, err := CreateAWSSignTxFn(t.Context(), client, &keyID) + require.NoError(t, err) + + signed, err := signTx(t.Context(), tx, signer) + require.Nil(t, signed) + require.Error(t, err) + }) + } +} + +func TestCreateAWSTransactOptsFactoryPropagatesGetPublicKeyFailure(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + getPublicKeyErr := errors.New("KeyUnavailableException") + client.getPublicKeyErr = getPublicKeyErr + keyID := testKeyID + + factory, err := CreateAWSTransactOptsFactory( + t.Context(), client, &keyID, ethtypes.LatestSignerForChainID(big.NewInt(1)), + ) + + require.Nil(t, factory) + require.ErrorIs(t, err, getPublicKeyErr) +} + +func TestCreateAWSTransactOptsFactoryRejectsMalformedPublicKeys(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + keyID := testKeyID + tests := []struct { + name string + publicKey []byte + }{ + {name: "malformed DER", publicKey: []byte{0xff, 0xff}}, + {name: "invalid secp256k1 point", publicKey: marshalSPKI(t, []byte{0x04, 0x01, 0x02})}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + client := newFakeKMSClient(t, privateKey) + client.publicKey = test.publicKey + factory, err := CreateAWSTransactOptsFactory( + t.Context(), client, &keyID, ethtypes.LatestSignerForChainID(big.NewInt(1)), + ) + require.Nil(t, factory) + require.Error(t, err) + }) + } +} + +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 + t *testing.T + privateKey *ecdsa.PrivateKey + publicKey []byte + signContext context.Context + signCalls int + signature []byte + getPublicKeyErr error + signErr error } func newFakeKMSClient(t *testing.T, privateKey *ecdsa.PrivateKey) *fakeKMSClient { + t.Helper() + return &fakeKMSClient{ + t: t, privateKey: privateKey, publicKey: marshalSPKI(t, crypto.FromECDSAPub(&privateKey.PublicKey)), + } +} + +func marshalSPKI(t *testing.T, publicKeyBytes []byte) []byte { t.Helper() publicKey, err := asn1.Marshal(struct { Algorithm struct { @@ -207,10 +427,10 @@ func newFakeKMSClient(t *testing.T, privateKey *ecdsa.PrivateKey) *fakeKMSClient 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)}, + SubjectPublicKey: asn1.BitString{Bytes: publicKeyBytes}, }) require.NoError(t, err) - return &fakeKMSClient{t: t, privateKey: privateKey, publicKey: publicKey} + return publicKey } func (f *fakeKMSClient) GetPublicKey( @@ -218,6 +438,9 @@ func (f *fakeKMSClient) GetPublicKey( *awskms.GetPublicKeyInput, ...func(*awskms.Options), ) (*awskms.GetPublicKeyOutput, error) { + if f.getPublicKeyErr != nil { + return nil, f.getPublicKeyErr + } return &awskms.GetPublicKeyOutput{PublicKey: f.publicKey}, nil } @@ -226,7 +449,14 @@ func (f *fakeKMSClient) Sign( input *awskms.SignInput, _ ...func(*awskms.Options), ) (*awskms.SignOutput, error) { + f.signCalls++ f.signContext = ctx + if f.signErr != nil { + return nil, f.signErr + } + 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 { diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index aa56d8367..bfc22b950 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -34,6 +34,8 @@ type AwsKmsIntegrationSuite struct { suite.Suite chainID *big.Int ethClient *ethclient.Client + kmsClient *awskms.Client + kmsKeyID string txOpts *bind.TransactOpts } @@ -106,6 +108,8 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { s.Require().NoError(err) s.ethClient = ethClient + s.kmsClient = client + s.kmsKeyID = *created.KeyMetadata.KeyId s.txOpts = opts } @@ -199,6 +203,32 @@ func (s *AwsKmsIntegrationSuite) TestLocalStackAWSTransactionOptsFactory() { } } +func (s *AwsKmsIntegrationSuite) TestWrongKeySpecIsNotTreatedAsTransient() { + ctx := s.T().Context() + created, err := s.kmsClient.CreateKey(ctx, &awskms.CreateKeyInput{ + KeyUsage: kmstypes.KeyUsageTypeSignVerify, + KeySpec: kmstypes.KeySpecRsa2048, + }) + s.Require().NoError(err) + s.Require().NotNil(created.KeyMetadata) + s.Require().NotNil(created.KeyMetadata.KeyId) + s.T().Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = s.kmsClient.ScheduleKeyDeletion(cleanupCtx, &awskms.ScheduleKeyDeletionInput{ + KeyId: created.KeyMetadata.KeyId, + PendingWindowInDays: aws.Int32(1), + }) + viper.Set(config.AUTH_AWS_KMS_KEY_ID, s.kmsKeyID) + }) + + viper.Set(config.AUTH_AWS_KMS_KEY_ID, *created.KeyMetadata.KeyId) + factory, err := auth.GetTransactOptsFactory(ctx, s.chainID) + + s.Require().Nil(factory) + s.Require().Error(err) +} + func TestLocalStackAWSIntegration(t *testing.T) { suite.Run(t, new(AwsKmsIntegrationSuite)) } From 7c354c8171e8eef6d9bb72a609aee3b9f1023479 Mon Sep 17 00:00:00 2001 From: Renato Maia <1887792+renatomaia@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:56:40 -0300 Subject: [PATCH 15/19] test(kms): fix test environment config cleanup --- internal/config/auth/auth_test.go | 7 ++++++- test/integration/localstack_integration_test.go | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go index 318605f78..fabddaea2 100644 --- a/internal/config/auth/auth_test.go +++ b/internal/config/auth/auth_test.go @@ -77,7 +77,12 @@ func TestGetTransactOptsFactoryAWSSignsDynamicFeeTransaction(t *testing.T) { func setupAWSAuth(t *testing.T, endpoint string) { t.Helper() viper.Reset() - t.Cleanup(viper.Reset) + viper.AutomaticEnv() + t.Cleanup(func() { + viper.Reset() + viper.AutomaticEnv() + SetDefaults() + }) viper.Set(AUTH_KIND, "aws") viper.Set(AUTH_AWS_KMS_KEY_ID, "alias/test-key") diff --git a/test/integration/localstack_integration_test.go b/test/integration/localstack_integration_test.go index bfc22b950..df4e6cea6 100644 --- a/test/integration/localstack_integration_test.go +++ b/test/integration/localstack_integration_test.go @@ -86,6 +86,7 @@ func (s *AwsKmsIntegrationSuite) SetupSuite() { t.Cleanup(func() { viper.Reset() + viper.AutomaticEnv() config.SetDefaults() }) ethEndpoint, err := config.GetBlockchainHttpEndpoint() From 1ea8e25d3874073aeed19b441ebf67dcaa748ff5 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 16/19] 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 fabddaea2..3fb54c58f 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 e56c2c70a..084de96e5 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() @@ -246,7 +255,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()) @@ -264,7 +273,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 763847da48d37dc0dd518e40f93ec036c4f8341b 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 17/19] 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 084de96e5..094a117d0 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, @@ -253,7 +255,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)), ) @@ -271,7 +273,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)) @@ -394,12 +396,17 @@ func TestCreateAWSTransactOptsFactoryRejectsMalformedPublicKeys(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 68f345aa63c0beed599593052c689dcfbbd0cdff 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 18/19] 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 550b16497731bd3dc399ae941678483e1edad080 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 19/19] 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