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..36560b222 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,6 +535,9 @@ stop-devnet: ## Stop the anvil devnet docker container stop-postgres: ## Stop the PostgreSQL 16 docker container @docker stop postgres || true +stop-awslocalstack: ## Stop the AWS LocalStack docker container + @docker stop awslocalstack || true + stop: stop-devnet stop-postgres ## Stop all running docker containers restart-devnet: ## Restart the anvil devnet docker container 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..9e4fc4df9 100644 --- a/internal/claimer/service.go +++ b/internal/claimer/service.go @@ -77,8 +77,7 @@ type PersistentConfig struct { ChainID uint64 } -func Create(ctx context.Context, c *CreateInfo) (*Service, error) { - var err error +func Create(ctx context.Context, c *CreateInfo) (_ *Service, err error) { if c == nil { return nil, errors.New("invalid CreateInfo is nil") @@ -101,6 +100,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, fmt.Errorf("creating base service: %w", err) } + defer func() { + if err != nil && s.Ticker != nil { + s.Ticker.Stop() + } + }() nodeConfig, err := setupPersistentConfig(ctx, s.Logger, c.Repository, &c.Config) if err != nil { @@ -138,6 +142,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..5bfa995df 100644 --- a/internal/config/auth/auth.go +++ b/internal/config/auth/auth.go @@ -5,6 +5,7 @@ package auth import ( "context" + "errors" "fmt" "math/big" @@ -21,7 +22,17 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" ) +// ErrSignerUnavailable identifies transient failures while acquiring the AWS +// KMS-backed signer. Callers may use this to degrade and retry signing services +// without treating unrelated configuration or service creation errors as +// recoverable. +var ErrSignerUnavailable = errors.New("signer unavailable") + func GetTransactOptsFactory(ctx context.Context, chainId *big.Int) (ethutil.TransactOptsFactory, error) { + if chainId == nil || chainId.Sign() <= 0 { + return nil, bind.ErrNoChainID + } + authKind, err := GetAuthKind() if err != nil { return nil, err @@ -60,21 +71,25 @@ 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 } - return signtx.CreateAWSTransactOptsFactory( + kmsClient := aws_kms.NewFromConfig(awsCfg) + factory, err := signtx.CreateAWSTransactOptsFactory( ctx, - kmsConfig, - aws.String(authAwsKmsKeyId.Value), - types.NewEIP155Signer(chainId), + kmsClient, + aws.String(keyId.Value), + types.LatestSignerForChainID(chainId), ) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrSignerUnavailable, err) + } + return factory, nil default: return nil, fmt.Errorf("no valid authentication method found") } diff --git a/internal/config/auth/auth_test.go b/internal/config/auth/auth_test.go new file mode 100644 index 000000000..ad5a57d6d --- /dev/null +++ b/internal/config/auth/auth_test.go @@ -0,0 +1,175 @@ +// (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() + t.Cleanup(viper.Reset) + 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..bfa974d33 100644 --- a/internal/kms/signtx_test.go +++ b/internal/kms/signtx_test.go @@ -13,6 +13,7 @@ import ( "github.com/cartesi/rollups-node/pkg/ethutil" + "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -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,11 +220,83 @@ func TestAWSTransactOptsFactorySignsWithSubmitContext(t *testing.T) { require.NoError(t, client.signContext.Err()) } +func TestAWSTransactOptsFactoryRejectsUnauthorizedAddress(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + client := newFakeKMSClient(t, privateKey) + arn := 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 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 + signCalls int + signature []byte } func newFakeKMSClient(t *testing.T, privateKey *ecdsa.PrivateKey) *fakeKMSClient { @@ -170,7 +334,11 @@ func (f *fakeKMSClient) Sign( input *awskms.SignInput, _ ...func(*awskms.Options), ) (*awskms.SignOutput, error) { + f.signCalls++ f.signContext = ctx + if f.signature != nil { + return &awskms.SignOutput{Signature: f.signature}, nil + } r, s, err := ecdsa.Sign(rand.Reader, f.privateKey, input.Message) require.NoError(f.t, err) signature, err := asn1.Marshal(struct { diff --git a/internal/node/node.go b/internal/node/node.go index 872ca8c26..9954bd1c8 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -5,13 +5,18 @@ package node import ( "context" + "errors" "fmt" + "log/slog" + "sync" + "time" "github.com/cartesi/rollups-node/pkg/service" "github.com/cartesi/rollups-node/internal/advancer" "github.com/cartesi/rollups-node/internal/claimer" "github.com/cartesi/rollups-node/internal/config" + "github.com/cartesi/rollups-node/internal/config/auth" "github.com/cartesi/rollups-node/internal/evmreader" "github.com/cartesi/rollups-node/internal/jsonrpc" "github.com/cartesi/rollups-node/internal/prt" @@ -26,8 +31,11 @@ import ( type serviceResult struct { service service.IService err error + create serviceCreator } +var degradedServiceRetryInterval = 5 * time.Second + type CreateInfo struct { service.CreateInfo @@ -87,7 +95,7 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { for _, create := range creators { go func() { svc, err := create(ctx, c, s) - ch <- serviceResult{service: svc, err: err} + ch <- serviceResult{service: svc, err: err, create: create} }() } @@ -95,6 +103,15 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { select { case result := <-ch: if result.err != nil { + if errors.Is(result.err, auth.ErrSignerUnavailable) { + s.Logger.Error("Signing service started in degraded state; retrying until the signer is available", + "error", result.err) + s.Children = append(s.Children, newDegradedService(ctx, s.Logger, + func(retryCtx context.Context) (service.IService, error) { + return result.create(retryCtx, c, s) + })) + continue + } stopAndDrain(s.Children, ch, len(creators)-len(s.Children)-1) return fmt.Errorf("failed to create service: %w", result.err) } @@ -107,6 +124,103 @@ func createServices(ctx context.Context, c *CreateInfo, s *Service) error { return nil } +// degradedService keeps a transiently unavailable signing service visible to +// readiness checks while recreating it in the background. It becomes a thin +// proxy once creation succeeds. +type degradedService struct { + ctx context.Context + cancel context.CancelFunc + logger *slog.Logger + create func(context.Context) (service.IService, error) + + mu sync.RWMutex + service service.IService +} + +func newDegradedService( + ctx context.Context, + logger *slog.Logger, + create func(context.Context) (service.IService, error), +) *degradedService { + retryCtx, cancel := context.WithCancel(ctx) + return °radedService{ctx: retryCtx, cancel: cancel, logger: logger, create: create} +} + +func (s *degradedService) current() service.IService { + s.mu.RLock() + defer s.mu.RUnlock() + return s.service +} + +func (s *degradedService) Alive() bool { + if child := s.current(); child != nil { + return child.Alive() + } + return true +} + +func (s *degradedService) Ready() bool { + if child := s.current(); child != nil { + return child.Ready() + } + return false +} + +func (s *degradedService) Reload() []error { + if child := s.current(); child != nil { + return child.Reload() + } + return nil +} + +func (s *degradedService) Tick() []error { return nil } + +func (s *degradedService) Stop(force bool) []error { + s.cancel() + if child := s.current(); child != nil { + return child.Stop(force) + } + return nil +} + +func (s *degradedService) String() string { + if child := s.current(); child != nil { + return child.String() + } + return "degraded signing service" +} + +func (s *degradedService) Serve() error { + ticker := time.NewTicker(degradedServiceRetryInterval) + defer ticker.Stop() + + for { + select { + case <-s.ctx.Done(): + return nil + case <-ticker.C: + child, err := s.create(s.ctx) + if err != nil { + if s.ctx.Err() != nil { + return nil + } + s.logger.Error("Signing service remains degraded; creation retry failed", "error", err) + continue + } + if s.ctx.Err() != nil { + child.Stop(true) + return nil + } + + s.mu.Lock() + s.service = child + s.mu.Unlock() + s.logger.Info("Signing service recovered") + return child.Serve() + } + } +} + // stopAndDrain stops already-created children and drains remaining results // from the channel, stopping any successful services to prevent resource leaks. func stopAndDrain(children []service.IService, ch <-chan serviceResult, remaining int) { diff --git a/internal/node/node_test.go b/internal/node/node_test.go new file mode 100644 index 000000000..2e2c5ca0c --- /dev/null +++ b/internal/node/node_test.go @@ -0,0 +1,64 @@ +// (c) Cartesi and individual authors (see AUTHORS) +// SPDX-License-Identifier: Apache-2.0 (see LICENSE) + +package node + +import ( + "context" + "errors" + "io" + "log/slog" + "sync/atomic" + "testing" + "time" + + "github.com/cartesi/rollups-node/pkg/service" + "github.com/stretchr/testify/require" +) + +type recoveredService struct { + served chan struct{} + stopped atomic.Bool +} + +func (s *recoveredService) Alive() bool { return true } +func (s *recoveredService) Ready() bool { return true } +func (s *recoveredService) Reload() []error { return nil } +func (s *recoveredService) Tick() []error { return nil } +func (s *recoveredService) String() string { return "recovered" } +func (s *recoveredService) Stop(bool) []error { s.stopped.Store(true); return nil } +func (s *recoveredService) Serve() error { close(s.served); return nil } + +func TestDegradedServiceRetriesUntilRecovery(t *testing.T) { + oldInterval := degradedServiceRetryInterval + degradedServiceRetryInterval = time.Millisecond + t.Cleanup(func() { degradedServiceRetryInterval = oldInterval }) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + recovered := &recoveredService{served: make(chan struct{})} + var attempts atomic.Int32 + degraded := newDegradedService(ctx, logger, func(context.Context) (service.IService, error) { + if attempts.Add(1) < 2 { + return nil, errors.New("KMS unavailable") + } + return recovered, nil + }) + + require.True(t, degraded.Alive()) + require.False(t, degraded.Ready()) + + done := make(chan error, 1) + go func() { done <- degraded.Serve() }() + select { + case <-recovered.served: + case <-time.After(time.Second): + t.Fatal("degraded service did not recover") + } + + require.True(t, degraded.Ready()) + require.NoError(t, <-done) + require.Empty(t, degraded.Stop(true)) + require.True(t, recovered.stopped.Load()) +} diff --git a/internal/prt/service.go b/internal/prt/service.go index 9d9878886..64f228b28 100644 --- a/internal/prt/service.go +++ b/internal/prt/service.go @@ -50,8 +50,7 @@ type PersistentConfig struct { ChainID uint64 } -func Create(ctx context.Context, c *CreateInfo) (*Service, error) { - var err error +func Create(ctx context.Context, c *CreateInfo) (_ *Service, err error) { if err = ctx.Err(); err != nil { return nil, err // This returns context.Canceled or context.DeadlineExceeded. } @@ -63,6 +62,11 @@ func Create(ctx context.Context, c *CreateInfo) (*Service, error) { if err != nil { return nil, err } + defer func() { + if err != nil && s.Ticker != nil { + s.Ticker.Stop() + } + }() if c.EthClient == nil { return nil, fmt.Errorf("EthClient on prt service Create is nil") @@ -121,6 +125,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