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/Makefile b/Makefile index 936749638..452a3ba79 100644 --- a/Makefile +++ b/Makefile @@ -516,6 +516,17 @@ 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 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" + start: start-postgres start-devnet ## Start the anvil devnet and PostgreSQL 16 docker containers stop-devnet: ## Stop the anvil devnet docker container @@ -524,7 +535,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 @@ -571,7 +585,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 +593,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. @@ -600,7 +615,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))) @@ -624,6 +639,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". @@ -671,6 +689,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 @@ -681,6 +700,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 @@ -714,6 +734,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/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/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/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 diff --git a/internal/config/auth/auth.go b/internal/config/auth/auth.go index 408f7b13a..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 @@ -60,20 +64,20 @@ 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() + awsCfg, err := aws_cfg.LoadDefaultConfig(ctx) 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/auth/auth_test.go b/internal/config/auth/auth_test.go new file mode 100644 index 000000000..3fb54c58f --- /dev/null +++ b/internal/config/auth/auth_test.go @@ -0,0 +1,180 @@ +// (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/accounts/abi/bind" + "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 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() + 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") + + // 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") +} + +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)) +} diff --git a/internal/config/generate/Config.toml b/internal/config/generate/Config.toml index c5d298a78..c77bdd183 100644 --- a/internal/config/generate/Config.toml +++ b/internal/config/generate/Config.toml @@ -360,18 +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"] +If set, the node will use the AWS KMS service with this key to sign transactions. -[auth.CARTESI_AUTH_AWS_KMS_REGION] -go-type = "RedactedString" -description = """ -An AWS KMS Region. +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. -Must be set alongside `CARTESI_AUTH_AWS_KMS_KEY_ID`.""" +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 cab15bdc8..a891a79f6 100644 --- a/internal/config/generated.go +++ b/internal/config/generated.go @@ -23,7 +23,6 @@ func init() { const ( 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" @@ -102,8 +101,6 @@ func SetDefaults() { // 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 @@ -1699,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/internal/kms/signtx.go b/internal/kms/signtx.go index 26446dae6..aad5d5c52 100644 --- a/internal/kms/signtx.go +++ b/internal/kms/signtx.go @@ -10,12 +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" @@ -34,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 @@ -71,23 +74,27 @@ 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) > 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 { - 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. @@ -139,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 a4d7d422d..094a117d0 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -8,11 +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" @@ -24,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 */ @@ -33,18 +36,99 @@ 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 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)) + 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.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( - value *big.Int, - SignTx SignTxFn, ctx context.Context, + client *ethclient.Client, + value *big.Int, + signTx SignTxFn, 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) @@ -55,12 +139,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) } @@ -74,8 +160,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 { @@ -94,10 +185,10 @@ func TestSignTx(t *testing.T) { panic(err) } - sendFunds(value20, CreateSignTxFnFromPrivateKey(anvilPrivateKey), - context.Background(), anvilAddress, KMSAddress) - sendFunds(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) { @@ -105,16 +196,17 @@ 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, client, &arn, - ethtypes.NewEIP155Signer(big.NewInt(1)), + ethtypes.LatestSignerForChainID(big.NewInt(1)), ) 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") @@ -128,14 +220,214 @@ 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) +} + +func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + arn := testKeyID + factory, err := CreateAWSTransactOptsFactory( + context.Background(), client, &arn, ethtypes.LatestSignerForChainID(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 := testKeyID + tx := ethtypes.NewTransaction(0, common.Address{0x01}, big.NewInt(1), 21000, big.NewInt(1), nil) + signer := ethtypes.LatestSignerForChainID(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 { + 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))) //nolint:gosec // Length is bounded above. + content = append(content, r...) + 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...) //nolint:gosec // Length is bounded above. +} + 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 { @@ -151,10 +443,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( @@ -162,6 +454,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 } @@ -170,7 +465,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/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 diff --git a/test/compose/compose.integration.yaml b/test/compose/compose.integration.yaml index 3d305f338..20e846e03 100644 --- a/test/compose/compose.integration.yaml +++ b/test/compose/compose.integration.yaml @@ -83,6 +83,14 @@ services: <<: *env restart: "no" + localstack: + image: localstack/localstack:4.14.0 + profiles: [awskms] + networks: + - devnet + 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 +107,9 @@ services: condition: service_healthy dapp-builder: 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 @@ -125,6 +136,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..df4e6cea6 --- /dev/null +++ b/test/integration/localstack_integration_test.go @@ -0,0 +1,235 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +//go:build endtoendtests + +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" + "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 + ethClient *ethclient.Client + kmsClient *awskms.Client + kmsKeyID string + txOpts *bind.TransactOpts +} + +func (s *AwsKmsIntegrationSuite) SetupSuite() { + t := s.T() + ctx := t.Context() + + region := os.Getenv("AWS_REGION") + 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, + 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 required { + t.Fatalf("%s: %v", message, err) + } + t.Skipf("%s: %v", message, err) + } + 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() + viper.AutomaticEnv() + 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) + t.Setenv("AWS_REGION", region) + t.Setenv("AWS_ENDPOINT_URL_KMS", 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.ethClient = ethClient + s.kmsClient = client + s.kmsKeyID = *created.KeyMetadata.KeyId + s.txOpts = opts +} + +func (s *AwsKmsIntegrationSuite) sendFunds( + value *big.Int, + signTx bind.SignerFn, + sender common.Address, + recipient common.Address, +) { + ctx := s.T().Context() + + nonce, err := s.ethClient.PendingNonceAt(ctx, sender) + s.Require().NoError(err) + gasLimit := uint64(21000) + gasTipCap, err := s.ethClient.SuggestGasTipCap(ctx) + s.Require().NoError(err) + header, err := s.ethClient.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 = s.ethClient.SendTransaction(ctx, signedTx) + s.Require().NoError(err) + receipt, err := bind.WaitMined(ctx, s.ethClient, signedTx.Hash()) + s.Require().NoError(err) + s.Require().Equal(types.ReceiptStatusSuccessful, receipt.Status) +} + +func (s *AwsKmsIntegrationSuite) TestLocalStackAWSSignedTransaction() { + // 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) + 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) + 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 := []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 _, 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) + s.Require().Equal(s.txOpts.From, sender) + }) + } +} + +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)) +}