diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index 3d4c5b4c43..1aebfa8864 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -12,10 +12,11 @@ import ( "time" "github.com/jmoiron/sqlx" - _ "github.com/lib/pq" + _ "github.com/lib/pq" // Register postgres driver "github.com/spf13/cobra" ks "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/smartcontractkit/chainlink-common/keystore/kms" "github.com/smartcontractkit/chainlink-common/keystore/pgstore" ) @@ -27,12 +28,15 @@ func NewRootCmd() *cobra.Command { cmd := &cobra.Command{ Use: "keys", Long: ` -CLI for managing keystore keys. Must specify KEYSTORE_FILE_PATH or KEYSTORE_DB_URL -and KEYSTORE_PASSWORD in order to load the keystore. +CLI for managing keystore keys. -KEYSTORE_FILE_PATH: is the path to the keystore file, can be empty for a new keystore. -File must already exist. Example to create a new keystore file: touch ./keystore.json +If KEYSTORE_KMS_PROFILE is set, will load the keystore from KMS. +KEYSTORE_KMS_PROFILE: is the AWS profile to use for KMS (region will be taken from the profile). +Otherwise, will load the keystore from a file or database. +KEYSTORE_PASSWORD: password used to encrypt the key material before storage, must be provided. +KEYSTORE_FILE_PATH: is the path to the keystore file, can be empty for a new keystore. +File must already exist. Example to create a new keystore file: touch ./keystore.json. Either KEYSTORE_FILE_PATH or KEYSTORE_DB_URL must be set. KEYSTORE_DB_URL: is the postgres connection URL. Only use this if your keystore is stored in a pg database. Requires a pg database with a 'encrypted_keystore' table with the following schema: CREATE TABLE IF NOT EXISTS encrypted_keystore ( @@ -42,17 +46,42 @@ CREATE TABLE IF NOT EXISTS encrypted_keystore ( updated_at timestamptz NOT NULL DEFAULT NOW(), encrypted_data BYTEA NOT NULL ); - -KEYSTORE_PASSWORD is the password used to encrypt the key material before storage. `, Short: "CLI for managing keystore keys", SilenceUsage: true, } - cmd.PersistentFlags().String("file-path", "", "Overrides KEYSTORE_FILE_PATH environment variable") - cmd.PersistentFlags().String("db-url", "", "Overrides KEYSTORE_DB_URL environment variable") - cmd.PersistentFlags().String("password", "", "Overrides KEYSTORE_PASSWORD environment variable. Not recommended as will leave shell traces.") - cmd.AddCommand(NewListCmd(), NewGetCmd(), NewCreateCmd(), NewDeleteCmd(), NewExportCmd(), NewImportCmd(), NewSetMetadataCmd(), NewSignCmd(), NewVerifyCmd(), NewEncryptCmd(), NewDecryptCmd()) + // Check if KMS profile is set - if so, hide commands that don't work with KMS + isKMSMode := os.Getenv("KEYSTORE_KMS_PROFILE") != "" + + // Commands that work with both regular keystore and KMS + listCmd := NewListCmd() + getCmd := NewGetCmd() + signCmd := NewSignCmd() + verifyCmd := NewVerifyCmd() + + // Commands that only work with regular keystore (not KMS) + createCmd := NewCreateCmd() + deleteCmd := NewDeleteCmd() + exportCmd := NewExportCmd() + importCmd := NewImportCmd() + setMetadataCmd := NewSetMetadataCmd() + // Note these could potentially be supported with KMS, but not yet implemented. + encryptCmd := NewEncryptCmd() + decryptCmd := NewDecryptCmd() + + // Hide admin/encryption commands when using KMS (keys are managed externally) + if isKMSMode { + createCmd.Hidden = true + deleteCmd.Hidden = true + exportCmd.Hidden = true + importCmd.Hidden = true + setMetadataCmd.Hidden = true + encryptCmd.Hidden = true + decryptCmd.Hidden = true + } + + cmd.AddCommand(listCmd, getCmd, createCmd, deleteCmd, exportCmd, importCmd, setMetadataCmd, signCmd, verifyCmd, encryptCmd, decryptCmd) return cmd } @@ -62,7 +91,7 @@ func NewListCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithTimeout(cmd.Context(), KeystoreLoadTimeout) defer cancel() - k, err := loadKeystore(ctx, cmd) + k, err := loadKeystoreSignerReader(ctx, cmd) if err != nil { return err } @@ -85,7 +114,13 @@ func NewGetCmd() *cobra.Command { cmd := cobra.Command{ Use: "get", Short: "Get keys", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.GetKeysRequest) (ks.GetKeysResponse, error) { + return runKeystoreCommand[interface { + ks.Reader + ks.Signer + }, ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k interface { + ks.Reader + ks.Signer + }, req ks.GetKeysRequest) (ks.GetKeysResponse, error) { return k.GetKeys(ctx, req) }) }, @@ -99,7 +134,7 @@ func NewCreateCmd() *cobra.Command { cmd := cobra.Command{ Use: "create", Short: "Create a key", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.CreateKeysRequest, ks.CreateKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.CreateKeysRequest) (ks.CreateKeysResponse, error) { + return runKeystoreCommand[ks.Keystore, ks.CreateKeysRequest, ks.CreateKeysResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.CreateKeysRequest) (ks.CreateKeysResponse, error) { return k.CreateKeys(ctx, req) }) }, @@ -128,7 +163,7 @@ func NewDeleteCmd() *cobra.Command { } if !confirmYes { // Prompt for confirmation on stdin - _, err = cmd.OutOrStderr().Write([]byte(fmt.Sprintf("This will permanently delete keys: %v. Type 'yes' to confirm: ", strings.Join(req.KeyNames, ", ")))) + _, err = fmt.Fprintf(cmd.OutOrStderr(), "This will permanently delete keys: %v. Type 'yes' to confirm: ", strings.Join(req.KeyNames, ", ")) if err != nil { return err } @@ -159,7 +194,7 @@ func NewExportCmd() *cobra.Command { cmd := cobra.Command{ Use: "export", Short: "Export a key to an encrypted JSON file", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.ExportKeysRequest, ks.ExportKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.ExportKeysRequest) (ks.ExportKeysResponse, error) { + return runKeystoreCommand[ks.Keystore, ks.ExportKeysRequest, ks.ExportKeysResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.ExportKeysRequest) (ks.ExportKeysResponse, error) { return k.ExportKeys(ctx, req) }) }, @@ -173,7 +208,7 @@ func NewImportCmd() *cobra.Command { cmd := cobra.Command{ Use: "import", Short: "Import an encrypted key JSON file", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.ImportKeysRequest, ks.ImportKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.ImportKeysRequest) (ks.ImportKeysResponse, error) { + return runKeystoreCommand[ks.Keystore, ks.ImportKeysRequest, ks.ImportKeysResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.ImportKeysRequest) (ks.ImportKeysResponse, error) { return k.ImportKeys(ctx, req) }) }, @@ -187,7 +222,7 @@ func NewSetMetadataCmd() *cobra.Command { cmd := cobra.Command{ Use: "set-metadata", Short: "Set metadata for keys", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.SetMetadataRequest, ks.SetMetadataResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.SetMetadataRequest) (ks.SetMetadataResponse, error) { + return runKeystoreCommand[ks.Keystore, ks.SetMetadataRequest, ks.SetMetadataResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.SetMetadataRequest) (ks.SetMetadataResponse, error) { return k.SetMetadata(ctx, req) }) }, @@ -197,8 +232,13 @@ func NewSetMetadataCmd() *cobra.Command { return &cmd } -func runKeystoreCommand[Req any, Resp any](cmd *cobra.Command, args []string, fn func(ctx context.Context, k ks.Keystore, - req Req) (Resp, error)) error { +// runKeystoreCommandGeneric is a generic helper that runs a keystore command with a custom loader function. +func runKeystoreCommand[K any, Req any, Resp any]( + cmd *cobra.Command, + args []string, + loader func(ctx context.Context, cmd *cobra.Command) (K, error), + fn func(ctx context.Context, k K, req Req) (Resp, error), +) error { jsonBytes, err := readJSONInput(cmd) if err != nil { return err @@ -210,7 +250,7 @@ func runKeystoreCommand[Req any, Resp any](cmd *cobra.Command, args []string, fn } ctx, cancel := context.WithTimeout(cmd.Context(), KeystoreLoadTimeout) defer cancel() - k, err := loadKeystore(ctx, cmd) + k, err := loader(ctx, cmd) if err != nil { return err } @@ -233,7 +273,13 @@ func NewSignCmd() *cobra.Command { cmd := cobra.Command{ Use: "sign", Short: "Sign data with a key", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.SignRequest, ks.SignResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.SignRequest) (ks.SignResponse, error) { + return runKeystoreCommand[interface { + ks.Reader + ks.Signer + }, ks.SignRequest, ks.SignResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k interface { + ks.Reader + ks.Signer + }, req ks.SignRequest) (ks.SignResponse, error) { return k.Sign(ctx, req) }) }, @@ -272,7 +318,13 @@ func NewVerifyCmd() *cobra.Command { cmd := cobra.Command{ Use: "verify", Short: "Verify a signature", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.VerifyRequest, ks.VerifyResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.VerifyRequest) (ks.VerifyResponse, error) { + return runKeystoreCommand[interface { + ks.Reader + ks.Signer + }, ks.VerifyRequest, ks.VerifyResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k interface { + ks.Reader + ks.Signer + }, req ks.VerifyRequest) (ks.VerifyResponse, error) { return k.Verify(ctx, req) }) }, @@ -286,7 +338,7 @@ func NewEncryptCmd() *cobra.Command { cmd := cobra.Command{ Use: "encrypt", Short: "Encrypt data to a remote public key", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.EncryptRequest, ks.EncryptResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.EncryptRequest) (ks.EncryptResponse, error) { + return runKeystoreCommand[ks.Keystore, ks.EncryptRequest, ks.EncryptResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.EncryptRequest) (ks.EncryptResponse, error) { return k.Encrypt(ctx, req) }) }, @@ -325,7 +377,7 @@ func NewDecryptCmd() *cobra.Command { cmd := cobra.Command{ Use: "decrypt", Short: "Decrypt data with a key", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommand[ks.DecryptRequest, ks.DecryptResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.DecryptRequest) (ks.DecryptResponse, error) { + return runKeystoreCommand[ks.Keystore, ks.DecryptRequest, ks.DecryptResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.DecryptRequest) (ks.DecryptResponse, error) { return k.Decrypt(ctx, req) }) }, @@ -335,38 +387,27 @@ func NewDecryptCmd() *cobra.Command { return &cmd } -func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) { - // Use parent command which has the persistent flags. - // This works whether keystore CLI is standalone or embedded as a subcommand. - parent := cmd.Parent() - filePath, err := parent.Flags().GetString("file-path") - if err != nil { - return nil, err - } - dbURL, err := parent.Flags().GetString("db-url") - if err != nil { - return nil, err - } - password, err := parent.Flags().GetString("password") - if err != nil { - return nil, err - } - // Env var fallbacks (flags override) - if filePath == "" { - if v := os.Getenv("KEYSTORE_FILE_PATH"); v != "" { - filePath = v - } - } - if dbURL == "" { - if v := os.Getenv("KEYSTORE_DB_URL"); v != "" { - dbURL = v - } - } - if password == "" { - if v := os.Getenv("KEYSTORE_PASSWORD"); v != "" { - password = v +func loadKeystoreSignerReader(ctx context.Context, cmd *cobra.Command) (interface { + ks.Reader + ks.Signer +}, error) { + // Check if KMS mode is enabled + kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") + if kmsProfile != "" { + client, err := kms.NewClient(kmsProfile) + if err != nil { + return nil, fmt.Errorf("create KMS client: %w", err) } + return kms.NewKeystore(client) } + return loadKeystore(ctx, cmd) +} + +func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) { + // Read from environment variables only + filePath := os.Getenv("KEYSTORE_FILE_PATH") + dbURL := os.Getenv("KEYSTORE_DB_URL") + password := os.Getenv("KEYSTORE_PASSWORD") if password == "" { return nil, errors.New("keystore password is required") } diff --git a/keystore/cli/cli_test.go b/keystore/cli/cli_test.go index c7adc1b111..d11bd5d7e8 100644 --- a/keystore/cli/cli_test.go +++ b/keystore/cli/cli_test.go @@ -22,6 +22,8 @@ func setupKeystore(t *testing.T) func(t *testing.T) { require.NoError(t, err) t.Setenv("KEYSTORE_FILE_PATH", keystoreFile) t.Setenv("KEYSTORE_PASSWORD", "testpassword") + // Set to empty string to test regular keystore mode. + t.Setenv("KEYSTORE_KMS_PROFILE", "") return func(t *testing.T) { f.Close() os.RemoveAll(tempDir) diff --git a/keystore/go.mod b/keystore/go.mod index e8cf4168bb..3c7b3258ff 100644 --- a/keystore/go.mod +++ b/keystore/go.mod @@ -3,6 +3,7 @@ module github.com/smartcontractkit/chainlink-common/keystore go 1.25.3 require ( + github.com/aws/aws-sdk-go v1.55.5 github.com/ethereum/go-ethereum v1.16.2 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 @@ -58,6 +59,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgtype v1.14.4 // indirect github.com/jackc/pgx/v4 v4.18.3 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect diff --git a/keystore/go.sum b/keystore/go.sum index 157ad9061c..1598b02fff 100644 --- a/keystore/go.sum +++ b/keystore/go.sum @@ -14,6 +14,8 @@ github.com/apache/arrow-go/v18 v18.3.1 h1:oYZT8FqONiK74JhlH3WKVv+2NKYoyZ7C2ioD4D github.com/apache/arrow-go/v18 v18.3.1/go.mod h1:12QBya5JZT6PnBihi5NJTzbACrDGXYkrgjujz3MRQXU= github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE= github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= +github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= +github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -171,6 +173,10 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -505,6 +511,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keystore/kms/asn1.go b/keystore/kms/asn1.go new file mode 100644 index 0000000000..0d15919e85 --- /dev/null +++ b/keystore/kms/asn1.go @@ -0,0 +1,242 @@ +package kms + +import ( + "bytes" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "math/big" + "slices" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" +) + +// SPKI represents the SubjectPublicKeyInfo structure as defined in [RFC 5280] in ASN.1 format. +// +// The public key that AWS KMS returns is a DER-encoded X.509 public key, also known as +// SubjectPublicKeyInfo (SPKI). This structure is used to unpack the public key returned by the KMS +// GetPublicKey API call. +// +// For more details: see the AWS KMS documentation on [GetPublicKey response syntax]. +// +// [RFC 5280]: https://datatracker.ietf.org/doc/html/rfc5280 +// [GetPublicKey response syntax]: https://docs.aws.amazon.com/kms/latest/APIReference/API_GetPublicKey.html#API_GetPublicKey_ResponseSyntax +type SPKI struct { + AlgorithmIdentifier SPKIAlgorithmIdentifier + SubjectPublicKey asn1.BitString +} + +// SPKIAlgorithmIdentifier represents the AlgorithmIdentifier structure for the +// SubjectPublicKeyInfo (SPKI) in ASN.1 format. +type SPKIAlgorithmIdentifier struct { + Algorithm asn1.ObjectIdentifier + Parameters asn1.ObjectIdentifier +} + +// ECDSASig represents the ECDSA signature structure as defined in [RFC 3279] in ASN.1 format. +// This structure is used to unpack the ECDSA signature returned by AWS KMS when signing data. +// +// [RFC 3279] https://datatracker.ietf.org/doc/html/rfc3279#section-2.2.3 +type ECDSASig struct { + R asn1.RawValue + S asn1.RawValue +} + +var ( + // secp256k1N is the N value of the secp256k1 curve, used to adjust the S value in signatures. + secp256k1N = crypto.S256().Params().N + // secp256k1HalfN is half of the secp256k1 N value, used to adjust the S value in signatures. + secp256k1HalfN = new(big.Int).Div(secp256k1N, big.NewInt(2)) +) + +// ASN1ToSEC1PublicKey converts a KMS public key (ASN.1 DER-encoded SPKI format) to SEC1 format +// (uncompressed: 0x04 || X || Y, 65 bytes). +// +// KMS returns public keys in ASN.1 DER-encoded SubjectPublicKeyInfo (SPKI) format as defined in +// RFC 5280. This function extracts the public key and converts it to SEC1 uncompressed format. +// +// This matches the implementation in chainlink-deployments-framework: +// https://github.com/smartcontractkit/chainlink-deployments-framework/blob/main/chain/evm/provider/kms_signer.go#L78 +func ASN1ToSEC1PublicKey(asn1PublicKey []byte) ([]byte, error) { + var spki SPKI + if _, err := asn1.Unmarshal(asn1PublicKey, &spki); err != nil { + return nil, fmt.Errorf("failed to unmarshal ASN.1 public key: %w", err) + } + + // Unmarshal the KMS public key bytes into an ECDSA public key. + // KMS includes the 0x04 prefix in the BitString (65 bytes: 0x04 || X || Y). + // If the BitString is 64 bytes (X || Y), we need to prepend 0x04. + pubKeyBytes := spki.SubjectPublicKey.Bytes + if len(pubKeyBytes) == 64 { + // BitString is 64 bytes (X || Y), prepend 0x04 + pubKeyBytes = append([]byte{0x04}, pubKeyBytes...) + } else if len(pubKeyBytes) != 65 || pubKeyBytes[0] != 0x04 { + return nil, fmt.Errorf("invalid public key length in BitString: expected 64 or 65 bytes, got %d", len(pubKeyBytes)) + } + // BitString already has 0x04 prefix (KMS format), use as-is + + pubKey, err := crypto.UnmarshalPubkey(pubKeyBytes) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal public key: %w", err) + } + + // SEC1 uncompressed format: 0x04 || X || Y (65 bytes) + sec1PubKeyBytes := secp256k1.S256().Marshal(pubKey.X, pubKey.Y) + return sec1PubKeyBytes, nil +} + +// SEC1ToASN1PublicKey converts a SEC1 uncompressed public key (0x04 || X || Y, 65 bytes) +// to ASN.1 DER-encoded SubjectPublicKeyInfo format. +// +// This is the reverse operation of ASN1ToSEC1PublicKey. +func SEC1ToASN1PublicKey(sec1PubKey []byte) ([]byte, error) { + if len(sec1PubKey) != 65 || sec1PubKey[0] != 0x04 { + return nil, fmt.Errorf("invalid SEC1 public key format: expected 65 bytes starting with 0x04, got %d bytes", len(sec1PubKey)) + } + + // Unmarshal the SEC1 public key to get the ECDSA public key + // This ensures proper handling and validates the key + pubKey, err := crypto.UnmarshalPubkey(sec1PubKey) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal SEC1 public key: %w", err) + } + + x := make([]byte, 32) + y := make([]byte, 32) + pubKey.X.FillBytes(x) + pubKey.Y.FillBytes(y) + + // Create the public key as a BitString + // KMS includes the 0x04 prefix in the BitString (65 bytes: 0x04 || X || Y) + // This matches the SEC1 uncompressed format + pubKeyBytes := slices.Concat([]byte{0x04}, x, y) + + // OID for ecPublicKey: 1.2.840.10045.2.1 + ecPublicKeyOID := asn1.ObjectIdentifier{1, 2, 840, 10045, 2, 1} + // OID for secp256k1: 1.3.132.0.10 + secp256k1OID := asn1.ObjectIdentifier{1, 3, 132, 0, 10} + + // Create SPKI structure + spki := SPKI{ + AlgorithmIdentifier: SPKIAlgorithmIdentifier{ + Algorithm: ecPublicKeyOID, + Parameters: secp256k1OID, + }, + SubjectPublicKey: asn1.BitString{ + Bytes: pubKeyBytes, + BitLength: len(pubKeyBytes) * 8, + }, + } + + // Marshal to ASN.1 DER + return asn1.Marshal(spki) +} + +// SEC1ToASN1Sig converts a SEC1 signature (R || S || V, 65 bytes) to ASN.1 DER format. +// +// The SEC1 signature format is: [32 bytes R][32 bytes S][1 byte V] +// The ASN.1 format is a SEQUENCE of two INTEGERs: { R, S } +// The recovery ID (V) is not included in ASN.1 format as it's only used for public key recovery. +// +// This is the reverse operation of KMSToSEC1Sig, but note that the recovery ID (V) is lost +// in the conversion since ASN.1 format doesn't include it. +func SEC1ToASN1Sig(sec1Sig []byte) ([]byte, error) { + if len(sec1Sig) != 65 { + return nil, fmt.Errorf("invalid SEC1 signature format: expected 65 bytes, got %d", len(sec1Sig)) + } + + // Extract R and S (first 32 bytes are R, next 32 bytes are S) + rBytes := sec1Sig[0:32] + sBytes := sec1Sig[32:64] + // V (recovery ID) is in sec1Sig[64], but we don't need it for ASN.1 + + // Convert bytes to big.Int, removing leading zeros + r := new(big.Int).SetBytes(rBytes) + s := new(big.Int).SetBytes(sBytes) + + // ASN.1 ECDSA signature is a SEQUENCE of two INTEGERs (R and S) + type ecdsaSignature struct { + R *big.Int + S *big.Int + } + + sig := ecdsaSignature{ + R: r, + S: s, + } + + // Marshal to ASN.1 DER + return asn1.Marshal(sig) +} + +// ASN1ToSEC1Sig converts a ASN.1 signature (ASN.1 format) to SEC1 format (R || S || V). This follows this +// example provided by AWS Guides. Notably Ethereum and most blockchain systems use the SEC1 format for signatures. +// +// [AWS Guides]: https://aws.amazon.com/blogs/database/part2-use-aws-kms-to-securely-manage-ethereum-accounts/ +func ASN1ToSEC1Sig(asn1Sig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) { + var ecdsaSig ECDSASig + if _, err := asn1.Unmarshal(asn1Sig, &ecdsaSig); err != nil { + return nil, fmt.Errorf("failed to unmarshal KMS signature: %w", err) + } + + rBytes := ecdsaSig.R.Bytes + sBytes := ecdsaSig.S.Bytes + + // Adjust S value from signature to match SEC1 standard. + // + // After we extract r and s successfully, we have to test if the value of s is greater than + // secp256k1n/2 as specified in EIP-2 and flip it if required. + sBigInt := new(big.Int).SetBytes(sBytes) + if sBigInt.Cmp(secp256k1HalfN) > 0 { + sBytes = new(big.Int).Sub(secp256k1N, sBigInt).Bytes() + } + + return recoverSEC1Signature(ecdsaPubKeyBytes, hash, rBytes, sBytes) +} + +// recoverSEC1Signature attempts to reconstruct the SEC1 signature by trying both possible recovery +// IDs (v = 0 and v = 1). It compares the recovered public key with the expected public key bytes +// to determine the correct signature. +// +// Returns the valid SEC1 signature if successful, or an error if neither recovery ID matches. +func recoverSEC1Signature(expectedPublicKey, txHash, r, s []byte) ([]byte, error) { + // SEC1 signatures require r and s to be exactly 32 bytes each. + rsSig := append(padTo32Bytes(r), padTo32Bytes(s)...) + // SEC1 signatures have a 65th byte called the recovery ID (v), which can be 0 or 1. + // Here we append 0 to the signature to start with for the first recovery attempt. + sec1Sig := append(rsSig, []byte{0}...) + + recoveredPublicKey, err := crypto.Ecrecover(txHash, sec1Sig) + if err != nil { + return nil, fmt.Errorf("failed to recover signature with v=0: %w", err) + } + + if hex.EncodeToString(recoveredPublicKey) != hex.EncodeToString(expectedPublicKey) { + // If the first recovery attempt failed, we try with v=1. + sec1Sig = append(rsSig, []byte{1}...) + recoveredPublicKey, err = crypto.Ecrecover(txHash, sec1Sig) + if err != nil { + return nil, fmt.Errorf("failed to recover signature with v=1: %w", err) + } + + if hex.EncodeToString(recoveredPublicKey) != hex.EncodeToString(expectedPublicKey) { + return nil, errors.New("cannot reconstruct public key from sig") + } + } + + return sec1Sig, nil +} + +// padTo32Bytes pads the given byte slice to 32 bytes by trimming leading zeros and prepending +// zeros. +func padTo32Bytes(buffer []byte) []byte { + buffer = bytes.TrimLeft(buffer, "\x00") + for len(buffer) < 32 { + zeroBuf := []byte{0} + buffer = append(zeroBuf, buffer...) + } + + return buffer +} diff --git a/keystore/kms/asn1_test.go b/keystore/kms/asn1_test.go new file mode 100644 index 0000000000..f0407a6e70 --- /dev/null +++ b/keystore/kms/asn1_test.go @@ -0,0 +1,53 @@ +package kms_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + kms "github.com/smartcontractkit/chainlink-common/keystore/kms" +) + +func TestSEC1ToASN1PublicKey(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + // Geth library uses SEC1 format. + sec1PubKey := crypto.FromECDSAPub(&privateKey.PublicKey) + require.Len(t, sec1PubKey, 65) + require.Equal(t, byte(0x04), sec1PubKey[0]) + + // Convert to ASN.1 + asn1PubKey, err := kms.SEC1ToASN1PublicKey(sec1PubKey) + require.NoError(t, err) + + // Convert back to SEC1 + sec1PubKey2, err := kms.ASN1ToSEC1PublicKey(asn1PubKey) + require.NoError(t, err) + require.Len(t, sec1PubKey2, 65) + require.Equal(t, byte(0x04), sec1PubKey2[0]) + pubKey := privateKey.PublicKey + require.Equal(t, pubKey.X.Bytes(), sec1PubKey2[1:33]) + require.Equal(t, pubKey.Y.Bytes(), sec1PubKey2[33:65]) +} + +func TestASN1SignatureToSEC1Signature(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + sec1PubKey := crypto.FromECDSAPub(&privateKey.PublicKey) + + hash := crypto.Keccak256Hash([]byte("test")) + + sig, err := crypto.Sign(hash[:], privateKey) + require.NoError(t, err) + + asn1Sig, err := kms.SEC1ToASN1Sig(sig) + require.NoError(t, err) + + // We pass the expected SEC1 public key for verification. + sec1Sig, err := kms.ASN1ToSEC1Sig(asn1Sig, sec1PubKey, hash[:]) + require.NoError(t, err) + require.Len(t, sec1Sig, 65) + require.Equal(t, sig, sec1Sig) +} diff --git a/keystore/kms/client.go b/keystore/kms/client.go new file mode 100644 index 0000000000..167e621122 --- /dev/null +++ b/keystore/kms/client.go @@ -0,0 +1,38 @@ +package kms + +import ( + "errors" + "fmt" + + "github.com/aws/aws-sdk-go/aws/session" + kmslib "github.com/aws/aws-sdk-go/service/kms" +) + +// Client is an interface that defines the methods for interacting with AWS KMS. We only expose +// the methods that are needed for our use case, which is to get a public key and sign data. +// +// These methods are directly copied from the kms.Client interface in the AWS SDK for Go v1. +// // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms +type Client interface { + GetPublicKey(input *kmslib.GetPublicKeyInput) (*kmslib.GetPublicKeyOutput, error) + Sign(input *kmslib.SignInput) (*kmslib.SignOutput, error) + DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.DescribeKeyOutput, error) + ListKeys(input *kmslib.ListKeysInput) (*kmslib.ListKeysOutput, error) +} + +// NewClient constructs a new kmslib.KMS instance using the provided AWS profile. This adheres to +// the KMSClient interface, allowing for signing and public key retrieval using AWS KMS. +// The region is automatically read from the AWS profile configuration. +func NewClient(awsProfile string) (Client, error) { + if awsProfile == "" { + return nil, errors.New("AWSProfile is required") + } + sess, err := session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, + Profile: awsProfile, + }) + if err != nil { + return nil, fmt.Errorf("failed to create AWS session: %w", err) + } + return kmslib.New(sess), nil +} diff --git a/keystore/kms/fake_client.go b/keystore/kms/fake_client.go new file mode 100644 index 0000000000..9c9c9b8912 --- /dev/null +++ b/keystore/kms/fake_client.go @@ -0,0 +1,94 @@ +package kms + +import ( + "crypto/ecdsa" + "errors" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + kmslib "github.com/aws/aws-sdk-go/service/kms" + "github.com/ethereum/go-ethereum/crypto" +) + +type FakeKMSClient struct { + keys []Key + createdAt time.Time +} + +type Key struct { + PrivateKey *ecdsa.PrivateKey + KeyID string +} + +func NewFakeKMSClient(keys []Key) (*FakeKMSClient, error) { + return &FakeKMSClient{ + keys: keys, + createdAt: time.Now(), + }, nil +} + +func (m *FakeKMSClient) GetPublicKey(input *kmslib.GetPublicKeyInput) (*kmslib.GetPublicKeyOutput, error) { + for _, key := range m.keys { + if aws.StringValue(input.KeyId) == key.KeyID { + asn1PubKey, err := SEC1ToASN1PublicKey(crypto.FromECDSAPub(&key.PrivateKey.PublicKey)) + if err != nil { + return nil, err + } + return &kmslib.GetPublicKeyOutput{ + KeyId: aws.String(key.KeyID), + PublicKey: asn1PubKey, + }, nil + } + } + return nil, awserr.New(kmslib.ErrCodeNotFoundException, "key not found", errors.New("key not found")) +} + +func (m *FakeKMSClient) Sign(input *kmslib.SignInput) (*kmslib.SignOutput, error) { + for _, key := range m.keys { + if aws.StringValue(input.KeyId) == key.KeyID { + sig, err := crypto.Sign(input.Message, key.PrivateKey) + if err != nil { + return nil, err + } + asn1Sig, err := SEC1ToASN1Sig(sig) + if err != nil { + return nil, err + } + return &kmslib.SignOutput{ + KeyId: aws.String(key.KeyID), + Signature: asn1Sig, + }, nil + } + } + return nil, awserr.New(kmslib.ErrCodeNotFoundException, "key not found", errors.New("key not found")) +} + +// DescribeKey returns metadata about the key. +func (m *FakeKMSClient) DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.DescribeKeyOutput, error) { + for _, key := range m.keys { + if aws.StringValue(input.KeyId) == key.KeyID { + return &kmslib.DescribeKeyOutput{ + KeyMetadata: &kmslib.KeyMetadata{ + KeyId: aws.String(key.KeyID), + KeySpec: aws.String(kmslib.KeySpecEccSecgP256k1), + CreationDate: aws.Time(m.createdAt), + }, + }, nil + } + } + return nil, awserr.New(kmslib.ErrCodeNotFoundException, "key not found", errors.New("key not found")) +} + +// ListKeys returns a list of key IDs. +func (m *FakeKMSClient) ListKeys(_ *kmslib.ListKeysInput) (*kmslib.ListKeysOutput, error) { + keys := make([]*kmslib.KeyListEntry, 0, len(m.keys)) + for _, key := range m.keys { + keys = append(keys, &kmslib.KeyListEntry{ + KeyId: aws.String(key.KeyID), + }) + } + return &kmslib.ListKeysOutput{ + Keys: keys, + }, nil +} diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go new file mode 100644 index 0000000000..cb00b15021 --- /dev/null +++ b/keystore/kms/keystore.go @@ -0,0 +1,157 @@ +package kms + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go/aws" + kmslib "github.com/aws/aws-sdk-go/service/kms" + + "github.com/smartcontractkit/chainlink-common/keystore" +) + +type keystoreSignerReader struct { + client Client +} + +func NewKeystore(client Client) (interface { + keystore.Reader + keystore.Signer +}, error) { + return &keystoreSignerReader{ + client: client, + }, nil +} + +// keySpecToKeyType converts an AWS KMS KeySpec to a keystore KeyType. +// AWS KMS supports: +// - ECC_SECG_P256K1 (secp256k1) -> ECDSA_S256 +func keySpecToKeyType(keySpec string) (keystore.KeyType, error) { + switch keySpec { + case kmslib.KeySpecEccSecgP256k1: + return keystore.ECDSA_S256, nil + default: + return "", fmt.Errorf("unsupported KMS key spec: %s (only ECC_SECG_P256K1 is supported)", keySpec) + } +} + +// GetKeys lists all keys in the KMS keystore. +// Note: possible that ListKeys is not supported for a given AWS role +// but specific GetPublicKey/DescribeKey are supported. We transparently +// let the user use whatever they are permitted to given their AWS perms. +func (k *keystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetKeysRequest) (keystore.GetKeysResponse, error) { + if len(req.KeyNames) == 0 { + listResp, err := k.client.ListKeys(&kmslib.ListKeysInput{}) + if err != nil { + return keystore.GetKeysResponse{}, fmt.Errorf("failed to list KMS keys: %w", err) + } + req.KeyNames = make([]string, 0, len(listResp.Keys)) + for _, key := range listResp.Keys { + req.KeyNames = append(req.KeyNames, aws.StringValue(key.KeyId)) + } + } + + keys := make([]keystore.GetKeyResponse, 0, len(req.KeyNames)) + for _, keyID := range req.KeyNames { + // Get public key + key, err := k.client.GetPublicKey(&kmslib.GetPublicKeyInput{ + KeyId: aws.String(keyID), + }) + if err != nil { + return keystore.GetKeysResponse{}, fmt.Errorf("failed to get public key for key %s: %w", keyID, err) + } + + // Get key metadata to determine key type and creation date + describeKey, err := k.client.DescribeKey(&kmslib.DescribeKeyInput{ + KeyId: aws.String(keyID), + }) + if err != nil { + return keystore.GetKeysResponse{}, fmt.Errorf("failed to describe key %s: %w", keyID, err) + } + createdAt := time.Unix(describeKey.KeyMetadata.CreationDate.Unix(), 0) + + // Convert KMS KeySpec to keystore KeyType + keySpec := aws.StringValue(describeKey.KeyMetadata.KeySpec) + keyType, err := keySpecToKeyType(keySpec) + if err != nil { + return keystore.GetKeysResponse{}, fmt.Errorf("key %s: %w", keyID, err) + } + var publicKeyBytes []byte + switch keyType { + case keystore.ECDSA_S256: + publicKeyBytes, err = ASN1ToSEC1PublicKey(key.PublicKey) + if err != nil { + return keystore.GetKeysResponse{}, fmt.Errorf("failed to convert public key for key %s: %w", keyID, err) + } + default: + return keystore.GetKeysResponse{}, fmt.Errorf("unsupported key type: %s", keyType) + } + + keys = append(keys, keystore.GetKeyResponse{ + KeyInfo: keystore.KeyInfo{ + Name: keyID, + KeyType: keyType, + PublicKey: publicKeyBytes, + CreatedAt: createdAt, + }, + }) + } + return keystore.GetKeysResponse{Keys: keys}, nil +} + +// Sign signs data using the KMS key specified by the key name. +func (k *keystoreSignerReader) Sign(ctx context.Context, req keystore.SignRequest) (keystore.SignResponse, error) { + key, err := k.client.GetPublicKey(&kmslib.GetPublicKeyInput{ + KeyId: aws.String(req.KeyName), + }) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("failed to get public key for key %s: %w", req.KeyName, err) + } + describeKey, err := k.client.DescribeKey(&kmslib.DescribeKeyInput{ + KeyId: aws.String(req.KeyName), + }) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("failed to describe key %s: %w", req.KeyName, err) + } + keySpec := aws.StringValue(describeKey.KeyMetadata.KeySpec) + keyType, err := keySpecToKeyType(keySpec) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("key %s: %w", req.KeyName, err) + } + + switch keyType { + case keystore.ECDSA_S256: + if len(req.Data) != 32 { + return keystore.SignResponse{}, fmt.Errorf("data must be 32 bytes for ECDSA_S256, got %d: %w", len(req.Data), keystore.ErrInvalidSignRequest) + } + pubKeyBytes, err := ASN1ToSEC1PublicKey(key.PublicKey) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("failed to convert public key for KeyId=%s: %w", req.KeyName, err) + } + + // MessageType is digest because its prehashed. + sig, err := k.client.Sign(&kmslib.SignInput{ + KeyId: aws.String(req.KeyName), + Message: req.Data, + SigningAlgorithm: aws.String(string(kmslib.SigningAlgorithmSpecEcdsaSha256)), + MessageType: aws.String(string(kmslib.MessageTypeDigest)), + }) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("failed to sign data: %w", err) + } + signature, err := ASN1ToSEC1Sig(sig.Signature, pubKeyBytes, req.Data) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("failed to convert KMS signature to SEC1 signature: %w", err) + } + return keystore.SignResponse{ + Signature: signature, + }, nil + default: + return keystore.SignResponse{}, fmt.Errorf("key %s: %w", req.KeyName, keystore.ErrInvalidSignRequest) + } +} + +func (k *keystoreSignerReader) Verify(ctx context.Context, req keystore.VerifyRequest) (keystore.VerifyResponse, error) { + return keystore.Verify(ctx, req) +} diff --git a/keystore/kms/keystore_test.go b/keystore/kms/keystore_test.go new file mode 100644 index 0000000000..d6c3210449 --- /dev/null +++ b/keystore/kms/keystore_test.go @@ -0,0 +1,91 @@ +package kms_test + +import ( + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/keystore" + kms "github.com/smartcontractkit/chainlink-common/keystore/kms" +) + +func TestKMSKeystore(t *testing.T) { + keyID, keyID2 := "test-key-id", "test-key-id-2" + key, err := crypto.GenerateKey() + require.NoError(t, err) + key2, err := crypto.GenerateKey() + require.NoError(t, err) + fakeClient, err := kms.NewFakeKMSClient([]kms.Key{ + { + PrivateKey: key, + KeyID: keyID, + }, + { + PrivateKey: key2, + KeyID: keyID2, + }, + }) + require.NoError(t, err) + ks, err := kms.NewKeystore(fakeClient) + require.NoError(t, err) + ctx := t.Context() + + t.Run("GetKeys", func(t *testing.T) { + t.Run("success", func(t *testing.T) { + resp, err := ks.GetKeys(ctx, keystore.GetKeysRequest{}) + require.NoError(t, err) + require.Len(t, resp.Keys, 2) + require.Equal(t, keyID, resp.Keys[0].KeyInfo.Name) + require.Equal(t, keyID2, resp.Keys[1].KeyInfo.Name) + }) + t.Run("no such key", func(t *testing.T) { + _, err := ks.GetKeys(ctx, keystore.GetKeysRequest{ + KeyNames: []string{"no-such-key"}, + }) + require.Error(t, err) + }) + t.Run("specific keys", func(t *testing.T) { + resp, err := ks.GetKeys(ctx, keystore.GetKeysRequest{ + KeyNames: []string{keyID}, + }) + require.NoError(t, err) + require.Len(t, resp.Keys, 1) + require.Equal(t, keyID, resp.Keys[0].KeyInfo.Name) + }) + }) + + t.Run("SignVerify", func(t *testing.T) { + t.Run("invalid sign request", func(t *testing.T) { + _, err := ks.Sign(ctx, keystore.SignRequest{ + KeyName: keyID, + Data: make([]byte, 31), // 31 byte digest + }) + require.Error(t, err) + require.ErrorIs(t, err, keystore.ErrInvalidSignRequest) + }) + t.Run("no such key", func(t *testing.T) { + _, err := ks.Sign(ctx, keystore.SignRequest{ + KeyName: "no-such-key", + Data: make([]byte, 32), // 32 byte digest + }) + require.Error(t, err) + }) + t.Run("success", func(t *testing.T) { + signResp, err := ks.Sign(ctx, keystore.SignRequest{ + KeyName: keyID, + Data: make([]byte, 32), // 32 byte digest + }) + require.NoError(t, err) + require.NotNil(t, signResp.Signature) + verifyResp, err := ks.Verify(ctx, keystore.VerifyRequest{ + KeyType: keystore.ECDSA_S256, + PublicKey: crypto.FromECDSAPub(&key.PublicKey), + Data: make([]byte, 32), // 32 byte digest + Signature: signResp.Signature, + }) + require.NoError(t, err) + require.True(t, verifyResp.Valid) + }) + }) +} diff --git a/keystore/signer.go b/keystore/signer.go index c909b73388..214afe7947 100644 --- a/keystore/signer.go +++ b/keystore/signer.go @@ -88,6 +88,10 @@ func (k *keystore) Sign(ctx context.Context, req SignRequest) (SignResponse, err func (k *keystore) Verify(ctx context.Context, req VerifyRequest) (VerifyResponse, error) { // Note don't need the lock since this is a pure function. + return Verify(ctx, req) +} + +func Verify(ctx context.Context, req VerifyRequest) (VerifyResponse, error) { switch req.KeyType { case Ed25519: if len(req.Signature) != 64 {