From bbc40a820db1db07b4cadba66757473d03f3072e Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 14:17:57 -0500 Subject: [PATCH 01/15] Wip --- keystore/go.mod | 2 + keystore/go.sum | 7 + keystore/keystore.go | 11 +- keystore/kms/internal/asn1.go | 34 +++++ keystore/kms/internal/kms.go | 85 ++++++++++++ keystore/kms/keystore.go | 250 ++++++++++++++++++++++++++++++++++ 6 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 keystore/kms/internal/asn1.go create mode 100644 keystore/kms/internal/kms.go create mode 100644 keystore/kms/keystore.go diff --git a/keystore/go.mod b/keystore/go.mod index 584165782b..c1a119cf86 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 @@ -59,6 +60,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 a191c9fab8..432756dd31 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= @@ -507,6 +513,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/keystore.go b/keystore/keystore.go index 15306cda22..769a727754 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -150,11 +150,20 @@ func newKeyInfo(name string, keyType KeyType, createdAt time.Time, publicKey []b type Keystore interface { Admin - Reader Signer + Reader Encryptor } +// KeystoreSignerReader is useful for +// services which just need to sign using pre-established keys. +// Useful for TXM only / non-OCR services. +// Add more narrow interface as needed. +type KeystoreSignerReader interface { + Reader + Signer +} + var ErrUnimplemented = errors.New("unimplemented") // UnimplementedKeystore provides a no-op implementation of Keystore. diff --git a/keystore/kms/internal/asn1.go b/keystore/kms/internal/asn1.go new file mode 100644 index 0000000000..c3b4dd7431 --- /dev/null +++ b/keystore/kms/internal/asn1.go @@ -0,0 +1,34 @@ +package kms + +import "encoding/asn1" + +// 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 +} diff --git a/keystore/kms/internal/kms.go b/keystore/kms/internal/kms.go new file mode 100644 index 0000000000..c5bc5ec2ec --- /dev/null +++ b/keystore/kms/internal/kms.go @@ -0,0 +1,85 @@ +package kms + +import ( + "errors" + "fmt" + + "github.com/aws/aws-sdk-go/aws" + "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. +type Client interface { + // Duck Typed from: + // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.GetPublicKey + GetPublicKey(input *kmslib.GetPublicKeyInput) (*kmslib.GetPublicKeyOutput, error) + // Duck Typed from: + // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.Sign + Sign(input *kmslib.SignInput) (*kmslib.SignOutput, error) + // Duck Typed from: + // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.DescribeKey + DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.DescribeKeyOutput, error) +} + +// ClientConfig holds the configuration for the AWS KMS client. +type ClientConfig struct { + // Required: KeyRegion is the AWS region where the KMS key is located. + KeyRegion string + // Optional: AWSProfile is the name of the AWS profile to use for authentication. + // If not provided, environment variables will be used to determine the AWS profile. + AWSProfile string +} + +// validate checks if the ClientConfig has the required fields set. +func (c ClientConfig) validate() error { + if c.KeyRegion == "" { + return errors.New("KMS key region is required") + } + + return nil +} + +// NewClient constructs a new kmslib.KMS instance using the provided configuration. This adheres to +// the KMSClient interface, allowing for signing and public key retrieval using AWS KMS. +func NewClient(config ClientConfig) (Client, error) { + if err := config.validate(); err != nil { + return nil, fmt.Errorf("invalid KMS config: %w", err) + } + + // Create a new AWS session using the provided region and profile name if specified. Defaults + // to using environment variables. + session := sessionFromEnvVars(config.KeyRegion) + if config.AWSProfile != "" { + session = sessionFromProfile(config.KeyRegion, config.AWSProfile) + } + + return kmslib.New(session), nil +} + +// sessionFromEnvVars creates a new AWS session using environment variables to load the profile. +func sessionFromEnvVars(region string) *session.Session { + return session.Must( + session.NewSession(&aws.Config{ + Region: aws.String(region), + CredentialsChainVerboseErrors: aws.Bool(true), + }), + ) +} + +// sessionFromProfile creates a new AWS session using a specific profile name and region. +func sessionFromProfile(region string, profile string) *session.Session { + return session.Must( + session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, + Profile: profile, + Config: aws.Config{ + Region: aws.String(region), + CredentialsChainVerboseErrors: aws.Bool(true), + }, + }), + ) +} diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go new file mode 100644 index 0000000000..ef511999b3 --- /dev/null +++ b/keystore/kms/keystore.go @@ -0,0 +1,250 @@ +package kms + +import ( + "bytes" + "context" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "math/big" + "time" + + "github.com/aws/aws-sdk-go/aws" + kmslib "github.com/aws/aws-sdk-go/service/kms" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" + "github.com/smartcontractkit/chainlink-common/keystore" + kms "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" + kmsinternal "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" +) + +type KeystoreConfig struct { + AWSProfile string + KeyIDs []string + KeyRegion string +} + +type kmsKeystoreSignerReader struct { + client kmsinternal.Client + config KeystoreConfig +} + +func NewKMSKeystore(config KeystoreConfig) (keystore.KeystoreSignerReader, error) { + client, err := kmsinternal.NewClient(kmsinternal.ClientConfig{ + KeyRegion: config.KeyRegion, + AWSProfile: config.AWSProfile, + }) + if err != nil { + return nil, fmt.Errorf("failed to create KMS client: %w", err) + } + return &kmsKeystoreSignerReader{ + client: client, + config: config, + }, nil +} + +// keySpecToKeyType converts an AWS KMS KeySpec to a keystore KeyType. +// AWS KMS supports: +// - ECC_SECG_P256K1 (secp256k1) -> ECDSA_S256 +// - ECC_NIST_P256, ECC_NIST_P384, ECC_NIST_P521 -> not supported (different curves) +// - RSA_* -> not supported +// - SYMMETRIC_DEFAULT -> not supported +// +// Note: AWS KMS does not support Ed25519 keys. +func keySpecToKeyType(keySpec string) (keystore.KeyType, error) { + switch keySpec { + case "ECC_SECG_P256K1": + return keystore.ECDSA_S256, nil + default: + return "", fmt.Errorf("unsupported KMS key spec: %s (only ECC_SECG_P256K1 is supported)", keySpec) + } +} + +func (k *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetKeysRequest) (keystore.GetKeysResponse, error) { + keys := make([]keystore.GetKeyResponse, 0, len(k.config.KeyIDs)) + for _, keyID := range k.config.KeyIDs { + // 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) + } + + // 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) + } + + // Get creation date from metadata + createdAt := time.Now() // fallback + if describeKey.KeyMetadata.CreationDate != nil { + createdAt = *describeKey.KeyMetadata.CreationDate + } + + keys = append(keys, keystore.GetKeyResponse{ + KeyInfo: keystore.KeyInfo{ + Name: keyID, + KeyType: keyType, + PublicKey: key.PublicKey, + CreatedAt: createdAt, + }, + }) + } + return keystore.GetKeysResponse{Keys: keys}, nil +} + +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)) +) + +// Sign signs data using the KMS key specified by the key name. +func (k *kmsKeystoreSignerReader) Sign(ctx context.Context, req keystore.SignRequest) (keystore.SignResponse, error) { + // TODO: Handle other key types + 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) + } + 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) + } + var spki kms.SPKI + if _, err = asn1.Unmarshal(key.PublicKey, &spki); err != nil { + return keystore.SignResponse{}, fmt.Errorf("cannot parse asn1 public key for KeyId=%s: %w", req.KeyName, err) + } + pubKey, err := crypto.UnmarshalPubkey(spki.SubjectPublicKey.Bytes) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("failed to unmarshal public key: %w", err) + } + pubKeyBytes := secp256k1.S256().Marshal(pubKey.X, pubKey.Y) + + // 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 := kmsToEVMSig(sig.Signature, pubKeyBytes, req.Data) + if err != nil { + return keystore.SignResponse{}, fmt.Errorf("failed to convert KMS signature to EVM signature: %w", err) + } + return keystore.SignResponse{ + Signature: signature, + }, nil +} + +// kmsToEVMSig converts a KMS signature to an Ethereum-compatible signature. This follows this +// example provided by AWS Guides. +// +// [AWS Guides]: https://aws.amazon.com/blogs/database/part2-use-aws-kms-to-securely-manage-ethereum-accounts/ +func kmsToEVMSig(kmsSig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) { + var ecdsaSig kms.ECDSASig + if _, err := asn1.Unmarshal(kmsSig, &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 EVM 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 recoverEVMSignature(ecdsaPubKeyBytes, hash, rBytes, sBytes) +} + +// recoverEVMSignature attempts to reconstruct the EVM 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 EVM signature if successful, or an error if neither recovery ID matches. +func recoverEVMSignature(expectedPublicKey, txHash, r, s []byte) ([]byte, error) { + // Ethereum signatures require r and s to be exactly 32 bytes each. + rsSig := append(padTo32Bytes(r), padTo32Bytes(s)...) + // Ethereum 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. + evmSig := append(rsSig, []byte{0}...) + + recoveredPublicKey, err := crypto.Ecrecover(txHash, evmSig) + 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. + evmSig = append(rsSig, []byte{1}...) + recoveredPublicKey, err = crypto.Ecrecover(txHash, evmSig) + 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 evmSig, 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 +} + +func (k *kmsKeystoreSignerReader) Verify(ctx context.Context, req keystore.VerifyRequest) (keystore.VerifyResponse, error) { + if req.KeyType != keystore.ECDSA_S256 { + return keystore.VerifyResponse{}, fmt.Errorf("KMS keystore only supports ECDSA_S256, got %s: %w", req.KeyType, keystore.ErrInvalidVerifyRequest) + } + + if len(req.Data) != 32 { + return keystore.VerifyResponse{}, fmt.Errorf("data must be 32 bytes for ECDSA_S256, got %d: %w", len(req.Data), keystore.ErrInvalidVerifyRequest) + } + + // ECDSA_S256 public keys are in SEC1 (uncompressed) format + if len(req.PublicKey) != 65 { + return keystore.VerifyResponse{}, fmt.Errorf("public key must be 65 bytes for ECDSA_S256, got %d: %w", len(req.PublicKey), keystore.ErrInvalidVerifyRequest) + } + + if len(req.Signature) != 65 { + return keystore.VerifyResponse{}, fmt.Errorf("signature must be 65 bytes for ECDSA_S256, got %d: %w", len(req.Signature), keystore.ErrInvalidVerifyRequest) + } + + // VerifySignature expects 64 bytes [R || S] without the V byte + // Strip the V byte (last byte) from the 65-byte signature + valid := crypto.VerifySignature(req.PublicKey, req.Data, req.Signature[:64]) + return keystore.VerifyResponse{ + Valid: valid, + }, nil +} From 4246278009a6ae57f4380f34ccb67460eb755f64 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 14:50:03 -0500 Subject: [PATCH 02/15] Simplify --- keystore/cli/cli.go | 112 +++++++++++++++++++++++++++++------ keystore/kms/internal/kms.go | 63 ++++++-------------- keystore/kms/keystore.go | 18 ++++-- 3 files changed, 126 insertions(+), 67 deletions(-) diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index 912301eaff..c441d61376 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -16,6 +16,7 @@ import ( "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: "./keystore ", 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("keystore-file-path", "", "Overrides KEYSTORE_FILE_PATH environment variable") - cmd.PersistentFlags().String("keystore-db-url", "", "Overrides KEYSTORE_DB_URL environment variable") - cmd.PersistentFlags().String("keystore-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,7 @@ 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 runKeystoreCommandWithSigner[ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.GetKeysRequest) (ks.GetKeysResponse, error) { return k.GetKeys(ctx, req) }) }, @@ -197,8 +226,38 @@ 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 { +func runKeystoreCommandWithSigner[Req any, Resp any](cmd *cobra.Command, args []string, fn func(ctx context.Context, k ks.KeystoreSignerReader, req Req) (Resp, error)) error { + jsonBytes, err := readJSONInput(cmd) + if err != nil { + return err + } + var req Req + err = json.Unmarshal(jsonBytes, &req) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), KeystoreLoadTimeout) + defer cancel() + k, err := loadKeystoreSignerReader(ctx, cmd) + if err != nil { + return err + } + resp, err := fn(ctx, k, req) + if err != nil { + return err + } + jsonBytesOut, err := json.Marshal(resp) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(jsonBytesOut) + if err != nil { + return err + } + return nil +} + +func runKeystoreCommand[Req any, Resp any](cmd *cobra.Command, args []string, fn func(ctx context.Context, k ks.Keystore, req Req) (Resp, error)) error { jsonBytes, err := readJSONInput(cmd) if err != nil { return err @@ -233,7 +292,7 @@ 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 runKeystoreCommandWithSigner[ks.SignRequest, ks.SignResponse](cmd, args, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.SignRequest) (ks.SignResponse, error) { return k.Sign(ctx, req) }) }, @@ -272,7 +331,7 @@ 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 runKeystoreCommandWithSigner[ks.VerifyRequest, ks.VerifyResponse](cmd, args, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.VerifyRequest) (ks.VerifyResponse, error) { return k.Verify(ctx, req) }) }, @@ -335,6 +394,25 @@ func NewDecryptCmd() *cobra.Command { return &cmd } +func loadKeystoreSignerReader(ctx context.Context, cmd *cobra.Command) (ks.KeystoreSignerReader, error) { + // Check if KMS mode is enabled + kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") + if kmsProfile != "" { + return loadKMSKeystore(ctx) + } + return loadKeystore(ctx, cmd) +} + +func loadKMSKeystore(ctx context.Context) (ks.KeystoreSignerReader, error) { + kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") + if kmsProfile == "" { + return nil, errors.New("KEYSTORE_KMS_PROFILE is required for KMS keystore") + } + return kms.NewKMSKeystore(kms.KeystoreConfig{ + AWSProfile: kmsProfile, + }) +} + func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) { root := cmd.Root() filePath, err := root.Flags().GetString("keystore-file-path") @@ -345,7 +423,7 @@ func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) if err != nil { return nil, err } - password, err := cmd.Flags().GetString("keystore-password") + password, err := root.Flags().GetString("keystore-password") if err != nil { return nil, err } diff --git a/keystore/kms/internal/kms.go b/keystore/kms/internal/kms.go index c5bc5ec2ec..4d7d4320d2 100644 --- a/keystore/kms/internal/kms.go +++ b/keystore/kms/internal/kms.go @@ -1,9 +1,6 @@ package kms import ( - "errors" - "fmt" - "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" kmslib "github.com/aws/aws-sdk-go/service/kms" @@ -23,63 +20,39 @@ type Client interface { // Duck Typed from: // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.DescribeKey DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.DescribeKeyOutput, error) + // Duck Typed from: + // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.ListKeys + ListKeys(input *kmslib.ListKeysInput) (*kmslib.ListKeysOutput, error) } // ClientConfig holds the configuration for the AWS KMS client. type ClientConfig struct { - // Required: KeyRegion is the AWS region where the KMS key is located. - KeyRegion string // Optional: AWSProfile is the name of the AWS profile to use for authentication. // If not provided, environment variables will be used to determine the AWS profile. AWSProfile string } -// validate checks if the ClientConfig has the required fields set. -func (c ClientConfig) validate() error { - if c.KeyRegion == "" { - return errors.New("KMS key region is required") - } - - return nil -} - // NewClient constructs a new kmslib.KMS instance using the provided configuration. This adheres to // the KMSClient interface, allowing for signing and public key retrieval using AWS KMS. func NewClient(config ClientConfig) (Client, error) { - if err := config.validate(); err != nil { - return nil, fmt.Errorf("invalid KMS config: %w", err) - } - - // Create a new AWS session using the provided region and profile name if specified. Defaults - // to using environment variables. - session := sessionFromEnvVars(config.KeyRegion) + var sess *session.Session if config.AWSProfile != "" { - session = sessionFromProfile(config.KeyRegion, config.AWSProfile) - } - - return kmslib.New(session), nil -} - -// sessionFromEnvVars creates a new AWS session using environment variables to load the profile. -func sessionFromEnvVars(region string) *session.Session { - return session.Must( - session.NewSession(&aws.Config{ - Region: aws.String(region), - CredentialsChainVerboseErrors: aws.Bool(true), - }), - ) -} - -// sessionFromProfile creates a new AWS session using a specific profile name and region. -func sessionFromProfile(region string, profile string) *session.Session { - return session.Must( - session.NewSessionWithOptions(session.Options{ + // Use profile - region will come from profile config or can be overridden + opts := session.Options{ SharedConfigState: session.SharedConfigEnable, - Profile: profile, + Profile: config.AWSProfile, Config: aws.Config{ - Region: aws.String(region), CredentialsChainVerboseErrors: aws.Bool(true), }, - }), - ) + } + sess = session.Must(session.NewSessionWithOptions(opts)) + } else { + // Use environment variables + cfg := &aws.Config{ + CredentialsChainVerboseErrors: aws.Bool(true), + } + sess = session.Must(session.NewSession(cfg)) + } + + return kmslib.New(sess), nil } diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go index ef511999b3..691db67649 100644 --- a/keystore/kms/keystore.go +++ b/keystore/kms/keystore.go @@ -21,8 +21,6 @@ import ( type KeystoreConfig struct { AWSProfile string - KeyIDs []string - KeyRegion string } type kmsKeystoreSignerReader struct { @@ -32,7 +30,6 @@ type kmsKeystoreSignerReader struct { func NewKMSKeystore(config KeystoreConfig) (keystore.KeystoreSignerReader, error) { client, err := kmsinternal.NewClient(kmsinternal.ClientConfig{ - KeyRegion: config.KeyRegion, AWSProfile: config.AWSProfile, }) if err != nil { @@ -62,8 +59,19 @@ func keySpecToKeyType(keySpec string) (keystore.KeyType, error) { } func (k *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetKeysRequest) (keystore.GetKeysResponse, error) { - keys := make([]keystore.GetKeyResponse, 0, len(k.config.KeyIDs)) - for _, keyID := range k.config.KeyIDs { + 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), From 06974e6ff7a269908b653a8e5d26129fc0d212aa Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 15:29:10 -0500 Subject: [PATCH 03/15] Simpler --- keystore/kms/internal/asn1.go | 110 +++++++++++++++++++- keystore/kms/internal/client.go | 38 +++++++ keystore/kms/internal/kms.go | 58 ----------- keystore/kms/keystore.go | 176 ++++++++------------------------ keystore/signer.go | 4 + 5 files changed, 192 insertions(+), 194 deletions(-) create mode 100644 keystore/kms/internal/client.go delete mode 100644 keystore/kms/internal/kms.go diff --git a/keystore/kms/internal/asn1.go b/keystore/kms/internal/asn1.go index c3b4dd7431..bdfccbde0c 100644 --- a/keystore/kms/internal/asn1.go +++ b/keystore/kms/internal/asn1.go @@ -1,6 +1,16 @@ package kms -import "encoding/asn1" +import ( + "bytes" + "encoding/asn1" + "encoding/hex" + "errors" + "fmt" + "math/big" + + "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. // @@ -32,3 +42,101 @@ 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)) +) + +// KMSToSEC1Sig converts a KMS 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 KMSToSEC1Sig(kmsSig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) { + var ecdsaSig ECDSASig + if _, err := asn1.Unmarshal(kmsSig, &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 +} + +// 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. +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) + } + + pubKey, err := crypto.UnmarshalPubkey(spki.SubjectPublicKey.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal public key: %w", err) + } + + // SEC1 uncompressed format: 0x04 || X || Y (65 bytes) + pubKeyBytes := secp256k1.S256().Marshal(pubKey.X, pubKey.Y) + return pubKeyBytes, 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/internal/client.go b/keystore/kms/internal/client.go new file mode 100644 index 0000000000..167e621122 --- /dev/null +++ b/keystore/kms/internal/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/internal/kms.go b/keystore/kms/internal/kms.go deleted file mode 100644 index 4d7d4320d2..0000000000 --- a/keystore/kms/internal/kms.go +++ /dev/null @@ -1,58 +0,0 @@ -package kms - -import ( - "github.com/aws/aws-sdk-go/aws" - "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. -type Client interface { - // Duck Typed from: - // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.GetPublicKey - GetPublicKey(input *kmslib.GetPublicKeyInput) (*kmslib.GetPublicKeyOutput, error) - // Duck Typed from: - // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.Sign - Sign(input *kmslib.SignInput) (*kmslib.SignOutput, error) - // Duck Typed from: - // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.DescribeKey - DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.DescribeKeyOutput, error) - // Duck Typed from: - // https://pkg.go.dev/github.com/aws/aws-sdk-go@v1.55.7/service/kms#KMS.ListKeys - ListKeys(input *kmslib.ListKeysInput) (*kmslib.ListKeysOutput, error) -} - -// ClientConfig holds the configuration for the AWS KMS client. -type ClientConfig struct { - // Optional: AWSProfile is the name of the AWS profile to use for authentication. - // If not provided, environment variables will be used to determine the AWS profile. - AWSProfile string -} - -// NewClient constructs a new kmslib.KMS instance using the provided configuration. This adheres to -// the KMSClient interface, allowing for signing and public key retrieval using AWS KMS. -func NewClient(config ClientConfig) (Client, error) { - var sess *session.Session - if config.AWSProfile != "" { - // Use profile - region will come from profile config or can be overridden - opts := session.Options{ - SharedConfigState: session.SharedConfigEnable, - Profile: config.AWSProfile, - Config: aws.Config{ - CredentialsChainVerboseErrors: aws.Bool(true), - }, - } - sess = session.Must(session.NewSessionWithOptions(opts)) - } else { - // Use environment variables - cfg := &aws.Config{ - CredentialsChainVerboseErrors: aws.Bool(true), - } - sess = session.Must(session.NewSession(cfg)) - } - - return kmslib.New(sess), nil -} diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go index 691db67649..370b78c6ae 100644 --- a/keystore/kms/keystore.go +++ b/keystore/kms/keystore.go @@ -1,24 +1,20 @@ package kms import ( - "bytes" "context" - "encoding/asn1" - "encoding/hex" - "errors" "fmt" - "math/big" "time" "github.com/aws/aws-sdk-go/aws" kmslib "github.com/aws/aws-sdk-go/service/kms" - "github.com/ethereum/go-ethereum/crypto" - "github.com/ethereum/go-ethereum/crypto/secp256k1" "github.com/smartcontractkit/chainlink-common/keystore" kms "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" kmsinternal "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" ) +// KeystoreConfig is the configuration for the KMS keystore. +// Struct for extensibility in the future. +// Uses the default region from the AWS profile. type KeystoreConfig struct { AWSProfile string } @@ -29,9 +25,7 @@ type kmsKeystoreSignerReader struct { } func NewKMSKeystore(config KeystoreConfig) (keystore.KeystoreSignerReader, error) { - client, err := kmsinternal.NewClient(kmsinternal.ClientConfig{ - AWSProfile: config.AWSProfile, - }) + client, err := kmsinternal.NewClient(config.AWSProfile) if err != nil { return nil, fmt.Errorf("failed to create KMS client: %w", err) } @@ -44,20 +38,19 @@ func NewKMSKeystore(config KeystoreConfig) (keystore.KeystoreSignerReader, error // keySpecToKeyType converts an AWS KMS KeySpec to a keystore KeyType. // AWS KMS supports: // - ECC_SECG_P256K1 (secp256k1) -> ECDSA_S256 -// - ECC_NIST_P256, ECC_NIST_P384, ECC_NIST_P521 -> not supported (different curves) -// - RSA_* -> not supported -// - SYMMETRIC_DEFAULT -> not supported -// -// Note: AWS KMS does not support Ed25519 keys. func keySpecToKeyType(keySpec string) (keystore.KeyType, error) { switch keySpec { - case "ECC_SECG_P256K1": + 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 *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetKeysRequest) (keystore.GetKeysResponse, error) { if len(req.KeyNames) == 0 { listResp, err := k.client.ListKeys(&kmslib.ListKeysInput{}) @@ -113,146 +106,59 @@ func (k *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetK return keystore.GetKeysResponse{Keys: keys}, nil } -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)) -) - // Sign signs data using the KMS key specified by the key name. func (k *kmsKeystoreSignerReader) Sign(ctx context.Context, req keystore.SignRequest) (keystore.SignResponse, error) { - // TODO: Handle other key types - 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) - } 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) } - var spki kms.SPKI - if _, err = asn1.Unmarshal(key.PublicKey, &spki); err != nil { - return keystore.SignResponse{}, fmt.Errorf("cannot parse asn1 public key for KeyId=%s: %w", req.KeyName, err) - } - pubKey, err := crypto.UnmarshalPubkey(spki.SubjectPublicKey.Bytes) - if err != nil { - return keystore.SignResponse{}, fmt.Errorf("failed to unmarshal public key: %w", err) - } - pubKeyBytes := secp256k1.S256().Marshal(pubKey.X, pubKey.Y) - - // 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)), + describeKey, err := k.client.DescribeKey(&kmslib.DescribeKeyInput{ + KeyId: aws.String(req.KeyName), }) if err != nil { - return keystore.SignResponse{}, fmt.Errorf("failed to sign data: %w", err) + return keystore.SignResponse{}, fmt.Errorf("failed to describe key %s: %w", req.KeyName, err) } - signature, err := kmsToEVMSig(sig.Signature, pubKeyBytes, req.Data) + keySpec := aws.StringValue(describeKey.KeyMetadata.KeySpec) + keyType, err := keySpecToKeyType(keySpec) if err != nil { - return keystore.SignResponse{}, fmt.Errorf("failed to convert KMS signature to EVM signature: %w", err) + return keystore.SignResponse{}, fmt.Errorf("key %s: %w", req.KeyName, err) } - return keystore.SignResponse{ - Signature: signature, - }, nil -} -// kmsToEVMSig converts a KMS signature to an Ethereum-compatible signature. This follows this -// example provided by AWS Guides. -// -// [AWS Guides]: https://aws.amazon.com/blogs/database/part2-use-aws-kms-to-securely-manage-ethereum-accounts/ -func kmsToEVMSig(kmsSig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) { - var ecdsaSig kms.ECDSASig - if _, err := asn1.Unmarshal(kmsSig, &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 EVM 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 recoverEVMSignature(ecdsaPubKeyBytes, hash, rBytes, sBytes) -} - -// recoverEVMSignature attempts to reconstruct the EVM 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 EVM signature if successful, or an error if neither recovery ID matches. -func recoverEVMSignature(expectedPublicKey, txHash, r, s []byte) ([]byte, error) { - // Ethereum signatures require r and s to be exactly 32 bytes each. - rsSig := append(padTo32Bytes(r), padTo32Bytes(s)...) - // Ethereum 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. - evmSig := append(rsSig, []byte{0}...) - - recoveredPublicKey, err := crypto.Ecrecover(txHash, evmSig) - 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. - evmSig = append(rsSig, []byte{1}...) - recoveredPublicKey, err = crypto.Ecrecover(txHash, evmSig) + 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 := kms.ASN1ToSEC1PublicKey(key.PublicKey) if err != nil { - return nil, fmt.Errorf("failed to recover signature with v=1: %w", err) + return keystore.SignResponse{}, fmt.Errorf("failed to convert public key for KeyId=%s: %w", req.KeyName, err) } - if hex.EncodeToString(recoveredPublicKey) != hex.EncodeToString(expectedPublicKey) { - return nil, errors.New("cannot reconstruct public key from sig") + // 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 := kms.KMSToSEC1Sig(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) } - return evmSig, 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 } func (k *kmsKeystoreSignerReader) Verify(ctx context.Context, req keystore.VerifyRequest) (keystore.VerifyResponse, error) { - if req.KeyType != keystore.ECDSA_S256 { - return keystore.VerifyResponse{}, fmt.Errorf("KMS keystore only supports ECDSA_S256, got %s: %w", req.KeyType, keystore.ErrInvalidVerifyRequest) - } - - if len(req.Data) != 32 { - return keystore.VerifyResponse{}, fmt.Errorf("data must be 32 bytes for ECDSA_S256, got %d: %w", len(req.Data), keystore.ErrInvalidVerifyRequest) - } - - // ECDSA_S256 public keys are in SEC1 (uncompressed) format - if len(req.PublicKey) != 65 { - return keystore.VerifyResponse{}, fmt.Errorf("public key must be 65 bytes for ECDSA_S256, got %d: %w", len(req.PublicKey), keystore.ErrInvalidVerifyRequest) - } - - if len(req.Signature) != 65 { - return keystore.VerifyResponse{}, fmt.Errorf("signature must be 65 bytes for ECDSA_S256, got %d: %w", len(req.Signature), keystore.ErrInvalidVerifyRequest) - } - - // VerifySignature expects 64 bytes [R || S] without the V byte - // Strip the V byte (last byte) from the 65-byte signature - valid := crypto.VerifySignature(req.PublicKey, req.Data, req.Signature[:64]) - return keystore.VerifyResponse{ - Valid: valid, - }, nil + return keystore.Verify(ctx, req) } 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 { From c57dde64101fb92413c0b885254a384a0222a4ea Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 15:32:42 -0500 Subject: [PATCH 04/15] Fix pubkey --- keystore/kms/keystore.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go index 370b78c6ae..117cd4c417 100644 --- a/keystore/kms/keystore.go +++ b/keystore/kms/keystore.go @@ -80,6 +80,7 @@ func (k *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetK 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) @@ -87,18 +88,22 @@ func (k *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetK if err != nil { return keystore.GetKeysResponse{}, fmt.Errorf("key %s: %w", keyID, err) } - - // Get creation date from metadata - createdAt := time.Now() // fallback - if describeKey.KeyMetadata.CreationDate != nil { - createdAt = *describeKey.KeyMetadata.CreationDate + var publicKeyBytes []byte + switch keyType { + case keystore.ECDSA_S256: + publicKeyBytes, err = kms.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: key.PublicKey, + PublicKey: publicKeyBytes, CreatedAt: createdAt, }, }) From 3f331761227b535efd55255d8793643d104ab9fb Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 16:42:26 -0500 Subject: [PATCH 05/15] Testing --- keystore/cli/cli.go | 8 +- keystore/kms/{internal => }/client.go | 0 keystore/kms/internal/asn1.go | 148 +++++++++++++++++++++----- keystore/kms/internal/asn1_test.go | 53 +++++++++ keystore/kms/internal/fake_client.go | 94 ++++++++++++++++ keystore/kms/keystore.go | 30 ++---- keystore/kms/keystore_test.go | 91 ++++++++++++++++ 7 files changed, 375 insertions(+), 49 deletions(-) rename keystore/kms/{internal => }/client.go (100%) create mode 100644 keystore/kms/internal/asn1_test.go create mode 100644 keystore/kms/internal/fake_client.go create mode 100644 keystore/kms/keystore_test.go diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index acd148cbc3..a8f0e65d6b 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -408,9 +408,11 @@ func loadKMSKeystore(ctx context.Context) (ks.KeystoreSignerReader, error) { if kmsProfile == "" { return nil, errors.New("KEYSTORE_KMS_PROFILE is required for KMS keystore") } - return kms.NewKMSKeystore(kms.KeystoreConfig{ - AWSProfile: kmsProfile, - }) + client, err := kms.NewClient(kmsProfile) + if err != nil { + return nil, fmt.Errorf("create KMS client: %w", err) + } + return kms.NewKeystore(client) } func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) { diff --git a/keystore/kms/internal/client.go b/keystore/kms/client.go similarity index 100% rename from keystore/kms/internal/client.go rename to keystore/kms/client.go diff --git a/keystore/kms/internal/asn1.go b/keystore/kms/internal/asn1.go index bdfccbde0c..9ceb8b6870 100644 --- a/keystore/kms/internal/asn1.go +++ b/keystore/kms/internal/asn1.go @@ -50,13 +50,134 @@ var ( secp256k1HalfN = new(big.Int).Div(secp256k1N, big.NewInt(2)) ) -// KMSToSEC1Sig converts a KMS signature (ASN.1 format) to SEC1 format (R || S || V). This follows this +// 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 { + // BitString already has 0x04 prefix (KMS format), use as-is + } else { + return nil, fmt.Errorf("invalid public key length in BitString: expected 64 or 65 bytes, got %d", len(pubKeyBytes)) + } + + 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 := append([]byte{0x04}, append(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 KMSToSEC1Sig(kmsSig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) { +func ASN1ToSEC1Sig(asn1Sig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) { var ecdsaSig ECDSASig - if _, err := asn1.Unmarshal(kmsSig, &ecdsaSig); err != nil { + if _, err := asn1.Unmarshal(asn1Sig, &ecdsaSig); err != nil { return nil, fmt.Errorf("failed to unmarshal KMS signature: %w", err) } @@ -108,27 +229,6 @@ func recoverSEC1Signature(expectedPublicKey, txHash, r, s []byte) ([]byte, error return sec1Sig, nil } -// 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. -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) - } - - pubKey, err := crypto.UnmarshalPubkey(spki.SubjectPublicKey.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to unmarshal public key: %w", err) - } - - // SEC1 uncompressed format: 0x04 || X || Y (65 bytes) - pubKeyBytes := secp256k1.S256().Marshal(pubKey.X, pubKey.Y) - return pubKeyBytes, nil -} - // padTo32Bytes pads the given byte slice to 32 bytes by trimming leading zeros and prepending // zeros. func padTo32Bytes(buffer []byte) []byte { diff --git a/keystore/kms/internal/asn1_test.go b/keystore/kms/internal/asn1_test.go new file mode 100644 index 0000000000..b6f26240b5 --- /dev/null +++ b/keystore/kms/internal/asn1_test.go @@ -0,0 +1,53 @@ +package kms_test + +import ( + "log" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + kmsinternal "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" + "github.com/stretchr/testify/require" +) + +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 := kmsinternal.SEC1ToASN1PublicKey(sec1PubKey) + require.NoError(t, err) + log.Println("asn1PubKey", len(asn1PubKey)) + + // Convert back to SEC1 + sec1PubKey2, err := kmsinternal.ASN1ToSEC1PublicKey(asn1PubKey) + require.NoError(t, err) + require.Len(t, sec1PubKey2, 65) + require.Equal(t, byte(0x04), sec1PubKey2[0]) + require.Equal(t, privateKey.PublicKey.X.Bytes(), sec1PubKey2[1:33]) + require.Equal(t, privateKey.PublicKey.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 := kmsinternal.SEC1ToASN1Sig(sig) + require.NoError(t, err) + + // We pass the expected SEC1 public key for verification. + sec1Sig, err := kmsinternal.ASN1ToSEC1Sig(asn1Sig, sec1PubKey, hash[:]) + require.NoError(t, err) + require.Len(t, sec1Sig, 65) + require.Equal(t, sig, sec1Sig) +} diff --git a/keystore/kms/internal/fake_client.go b/keystore/kms/internal/fake_client.go new file mode 100644 index 0000000000..1512fa1f76 --- /dev/null +++ b/keystore/kms/internal/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 MockKMSClient struct { + keys []Key + createdAt time.Time +} + +type Key struct { + PrivateKey *ecdsa.PrivateKey + KeyID string +} + +func NewMockKMSClient(keys []Key) (*MockKMSClient, error) { + return &MockKMSClient{ + keys: keys, + createdAt: time.Now(), + }, nil +} + +func (m *MockKMSClient) 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 *MockKMSClient) 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 *MockKMSClient) 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 *MockKMSClient) ListKeys(input *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 index 117cd4c417..80d0a8c014 100644 --- a/keystore/kms/keystore.go +++ b/keystore/kms/keystore.go @@ -9,29 +9,15 @@ import ( kmslib "github.com/aws/aws-sdk-go/service/kms" "github.com/smartcontractkit/chainlink-common/keystore" kms "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" - kmsinternal "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" ) -// KeystoreConfig is the configuration for the KMS keystore. -// Struct for extensibility in the future. -// Uses the default region from the AWS profile. -type KeystoreConfig struct { - AWSProfile string +type keystoreSignerReader struct { + client Client } -type kmsKeystoreSignerReader struct { - client kmsinternal.Client - config KeystoreConfig -} - -func NewKMSKeystore(config KeystoreConfig) (keystore.KeystoreSignerReader, error) { - client, err := kmsinternal.NewClient(config.AWSProfile) - if err != nil { - return nil, fmt.Errorf("failed to create KMS client: %w", err) - } - return &kmsKeystoreSignerReader{ +func NewKeystore(client Client) (keystore.KeystoreSignerReader, error) { + return &keystoreSignerReader{ client: client, - config: config, }, nil } @@ -51,7 +37,7 @@ func keySpecToKeyType(keySpec string) (keystore.KeyType, error) { // 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 *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetKeysRequest) (keystore.GetKeysResponse, error) { +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 { @@ -112,7 +98,7 @@ func (k *kmsKeystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetK } // Sign signs data using the KMS key specified by the key name. -func (k *kmsKeystoreSignerReader) Sign(ctx context.Context, req keystore.SignRequest) (keystore.SignResponse, error) { +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), }) @@ -151,7 +137,7 @@ func (k *kmsKeystoreSignerReader) Sign(ctx context.Context, req keystore.SignReq if err != nil { return keystore.SignResponse{}, fmt.Errorf("failed to sign data: %w", err) } - signature, err := kms.KMSToSEC1Sig(sig.Signature, pubKeyBytes, req.Data) + signature, err := kms.ASN1ToSEC1Sig(sig.Signature, pubKeyBytes, req.Data) if err != nil { return keystore.SignResponse{}, fmt.Errorf("failed to convert KMS signature to SEC1 signature: %w", err) } @@ -164,6 +150,6 @@ func (k *kmsKeystoreSignerReader) Sign(ctx context.Context, req keystore.SignReq } -func (k *kmsKeystoreSignerReader) Verify(ctx context.Context, req keystore.VerifyRequest) (keystore.VerifyResponse, error) { +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..2bd537eca3 --- /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/smartcontractkit/chainlink-common/keystore" + kms "github.com/smartcontractkit/chainlink-common/keystore/kms" + kmsinternal "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" + "github.com/stretchr/testify/require" +) + +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) + mockClient, err := kmsinternal.NewMockKMSClient([]kmsinternal.Key{ + { + KeyID: keyID, + PrivateKey: key, + }, + { + KeyID: keyID2, + PrivateKey: key2, + }, + }) + require.NoError(t, err) + ks, err := kms.NewKMSKeystore(mockClient) + 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) + }) + }) +} From f9769741c602b6a7803aeaeb13a614af221778c0 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 16:43:15 -0500 Subject: [PATCH 06/15] test --- keystore/kms/keystore_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keystore/kms/keystore_test.go b/keystore/kms/keystore_test.go index 2bd537eca3..4f4d7e6916 100644 --- a/keystore/kms/keystore_test.go +++ b/keystore/kms/keystore_test.go @@ -27,7 +27,7 @@ func TestKMSKeystore(t *testing.T) { }, }) require.NoError(t, err) - ks, err := kms.NewKMSKeystore(mockClient) + ks, err := kms.NewKeystore(mockClient) require.NoError(t, err) ctx := t.Context() From a1fb5461a61f41f7cce0691f8f9e44a33ffd574b Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 16:53:02 -0500 Subject: [PATCH 07/15] Lint --- keystore/cli/cli.go | 12 ++++++++++++ keystore/keystore.go | 4 ++-- keystore/kms/internal/asn1_test.go | 2 -- keystore/kms/internal/fake_client.go | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index a8f0e65d6b..aead1fb008 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -408,6 +408,18 @@ func loadKMSKeystore(ctx context.Context) (ks.KeystoreSignerReader, error) { if kmsProfile == "" { return nil, errors.New("KEYSTORE_KMS_PROFILE is required for KMS keystore") } + if kmsProfile == "xxx_test_profile" { + + key, err := crypto.GenerateKey() + if err != nil { + return nil, fmt.Errorf("generate key: %w", err) + } + return kms.NewKeystore(kmsinternal.NewMockKMSClient([]kmsinternal.Key{ + { + KeyID: "test-key-id", + PrivateKey: key, + })) + } client, err := kms.NewClient(kmsProfile) if err != nil { return nil, fmt.Errorf("create KMS client: %w", err) diff --git a/keystore/keystore.go b/keystore/keystore.go index 769a727754..5802a42d89 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -150,15 +150,15 @@ func newKeyInfo(name string, keyType KeyType, createdAt time.Time, publicKey []b type Keystore interface { Admin - Signer Reader + Signer Encryptor } // KeystoreSignerReader is useful for // services which just need to sign using pre-established keys. // Useful for TXM only / non-OCR services. -// Add more narrow interface as needed. +// Add more narrow interfaces as needed. type KeystoreSignerReader interface { Reader Signer diff --git a/keystore/kms/internal/asn1_test.go b/keystore/kms/internal/asn1_test.go index b6f26240b5..36817df3fc 100644 --- a/keystore/kms/internal/asn1_test.go +++ b/keystore/kms/internal/asn1_test.go @@ -1,7 +1,6 @@ package kms_test import ( - "log" "testing" "github.com/ethereum/go-ethereum/crypto" @@ -21,7 +20,6 @@ func TestSEC1ToASN1PublicKey(t *testing.T) { // Convert to ASN.1 asn1PubKey, err := kmsinternal.SEC1ToASN1PublicKey(sec1PubKey) require.NoError(t, err) - log.Println("asn1PubKey", len(asn1PubKey)) // Convert back to SEC1 sec1PubKey2, err := kmsinternal.ASN1ToSEC1PublicKey(asn1PubKey) diff --git a/keystore/kms/internal/fake_client.go b/keystore/kms/internal/fake_client.go index 1512fa1f76..ac527d6c10 100644 --- a/keystore/kms/internal/fake_client.go +++ b/keystore/kms/internal/fake_client.go @@ -81,7 +81,7 @@ func (m *MockKMSClient) DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.Des } // ListKeys returns a list of key IDs. -func (m *MockKMSClient) ListKeys(input *kmslib.ListKeysInput) (*kmslib.ListKeysOutput, error) { +func (m *MockKMSClient) ListKeys(_ *kmslib.ListKeysInput) (*kmslib.ListKeysOutput, error) { keys := make([]*kmslib.KeyListEntry, 0, len(m.keys)) for _, key := range m.keys { keys = append(keys, &kmslib.KeyListEntry{ From 9ce4cbd0484b8d8739e17af49e6c2068aa7776e1 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 16:58:16 -0500 Subject: [PATCH 08/15] Expore ASN utilities --- keystore/kms/{internal => }/asn1.go | 0 keystore/kms/{internal => }/asn1_test.go | 10 +++++----- keystore/kms/{internal => }/fake_client.go | 14 +++++++------- keystore/kms/keystore.go | 7 +++---- keystore/kms/keystore_test.go | 9 ++++----- 5 files changed, 19 insertions(+), 21 deletions(-) rename keystore/kms/{internal => }/asn1.go (100%) rename keystore/kms/{internal => }/asn1_test.go (78%) rename keystore/kms/{internal => }/fake_client.go (85%) diff --git a/keystore/kms/internal/asn1.go b/keystore/kms/asn1.go similarity index 100% rename from keystore/kms/internal/asn1.go rename to keystore/kms/asn1.go diff --git a/keystore/kms/internal/asn1_test.go b/keystore/kms/asn1_test.go similarity index 78% rename from keystore/kms/internal/asn1_test.go rename to keystore/kms/asn1_test.go index 36817df3fc..2bce24ff7b 100644 --- a/keystore/kms/internal/asn1_test.go +++ b/keystore/kms/asn1_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/ethereum/go-ethereum/crypto" - kmsinternal "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" + kms "github.com/smartcontractkit/chainlink-common/keystore/kms" "github.com/stretchr/testify/require" ) @@ -18,11 +18,11 @@ func TestSEC1ToASN1PublicKey(t *testing.T) { require.Equal(t, byte(0x04), sec1PubKey[0]) // Convert to ASN.1 - asn1PubKey, err := kmsinternal.SEC1ToASN1PublicKey(sec1PubKey) + asn1PubKey, err := kms.SEC1ToASN1PublicKey(sec1PubKey) require.NoError(t, err) // Convert back to SEC1 - sec1PubKey2, err := kmsinternal.ASN1ToSEC1PublicKey(asn1PubKey) + sec1PubKey2, err := kms.ASN1ToSEC1PublicKey(asn1PubKey) require.NoError(t, err) require.Len(t, sec1PubKey2, 65) require.Equal(t, byte(0x04), sec1PubKey2[0]) @@ -40,11 +40,11 @@ func TestASN1SignatureToSEC1Signature(t *testing.T) { sig, err := crypto.Sign(hash[:], privateKey) require.NoError(t, err) - asn1Sig, err := kmsinternal.SEC1ToASN1Sig(sig) + asn1Sig, err := kms.SEC1ToASN1Sig(sig) require.NoError(t, err) // We pass the expected SEC1 public key for verification. - sec1Sig, err := kmsinternal.ASN1ToSEC1Sig(asn1Sig, sec1PubKey, hash[:]) + 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/internal/fake_client.go b/keystore/kms/fake_client.go similarity index 85% rename from keystore/kms/internal/fake_client.go rename to keystore/kms/fake_client.go index ac527d6c10..9c9c9b8912 100644 --- a/keystore/kms/internal/fake_client.go +++ b/keystore/kms/fake_client.go @@ -11,7 +11,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" ) -type MockKMSClient struct { +type FakeKMSClient struct { keys []Key createdAt time.Time } @@ -21,14 +21,14 @@ type Key struct { KeyID string } -func NewMockKMSClient(keys []Key) (*MockKMSClient, error) { - return &MockKMSClient{ +func NewFakeKMSClient(keys []Key) (*FakeKMSClient, error) { + return &FakeKMSClient{ keys: keys, createdAt: time.Now(), }, nil } -func (m *MockKMSClient) GetPublicKey(input *kmslib.GetPublicKeyInput) (*kmslib.GetPublicKeyOutput, error) { +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)) @@ -44,7 +44,7 @@ func (m *MockKMSClient) GetPublicKey(input *kmslib.GetPublicKeyInput) (*kmslib.G return nil, awserr.New(kmslib.ErrCodeNotFoundException, "key not found", errors.New("key not found")) } -func (m *MockKMSClient) Sign(input *kmslib.SignInput) (*kmslib.SignOutput, error) { +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) @@ -65,7 +65,7 @@ func (m *MockKMSClient) Sign(input *kmslib.SignInput) (*kmslib.SignOutput, error } // DescribeKey returns metadata about the key. -func (m *MockKMSClient) DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.DescribeKeyOutput, error) { +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{ @@ -81,7 +81,7 @@ func (m *MockKMSClient) DescribeKey(input *kmslib.DescribeKeyInput) (*kmslib.Des } // ListKeys returns a list of key IDs. -func (m *MockKMSClient) ListKeys(_ *kmslib.ListKeysInput) (*kmslib.ListKeysOutput, error) { +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{ diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go index 80d0a8c014..b7526d40fa 100644 --- a/keystore/kms/keystore.go +++ b/keystore/kms/keystore.go @@ -8,7 +8,6 @@ import ( "github.com/aws/aws-sdk-go/aws" kmslib "github.com/aws/aws-sdk-go/service/kms" "github.com/smartcontractkit/chainlink-common/keystore" - kms "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" ) type keystoreSignerReader struct { @@ -77,7 +76,7 @@ func (k *keystoreSignerReader) GetKeys(ctx context.Context, req keystore.GetKeys var publicKeyBytes []byte switch keyType { case keystore.ECDSA_S256: - publicKeyBytes, err = kms.ASN1ToSEC1PublicKey(key.PublicKey) + publicKeyBytes, err = ASN1ToSEC1PublicKey(key.PublicKey) if err != nil { return keystore.GetKeysResponse{}, fmt.Errorf("failed to convert public key for key %s: %w", keyID, err) } @@ -122,7 +121,7 @@ func (k *keystoreSignerReader) Sign(ctx context.Context, req keystore.SignReques 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 := kms.ASN1ToSEC1PublicKey(key.PublicKey) + 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) } @@ -137,7 +136,7 @@ func (k *keystoreSignerReader) Sign(ctx context.Context, req keystore.SignReques if err != nil { return keystore.SignResponse{}, fmt.Errorf("failed to sign data: %w", err) } - signature, err := kms.ASN1ToSEC1Sig(sig.Signature, pubKeyBytes, req.Data) + 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) } diff --git a/keystore/kms/keystore_test.go b/keystore/kms/keystore_test.go index 4f4d7e6916..a317abdb07 100644 --- a/keystore/kms/keystore_test.go +++ b/keystore/kms/keystore_test.go @@ -6,7 +6,6 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/smartcontractkit/chainlink-common/keystore" kms "github.com/smartcontractkit/chainlink-common/keystore/kms" - kmsinternal "github.com/smartcontractkit/chainlink-common/keystore/kms/internal" "github.com/stretchr/testify/require" ) @@ -16,18 +15,18 @@ func TestKMSKeystore(t *testing.T) { require.NoError(t, err) key2, err := crypto.GenerateKey() require.NoError(t, err) - mockClient, err := kmsinternal.NewMockKMSClient([]kmsinternal.Key{ + fakeClient, err := kms.NewFakeKMSClient([]kms.Key{ { - KeyID: keyID, PrivateKey: key, + KeyID: keyID, }, { - KeyID: keyID2, PrivateKey: key2, + KeyID: keyID2, }, }) require.NoError(t, err) - ks, err := kms.NewKeystore(mockClient) + ks, err := kms.NewKeystore(fakeClient) require.NoError(t, err) ctx := t.Context() From de36f947f906a56b19859bf23686e618d9f0b7f3 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 17:09:44 -0500 Subject: [PATCH 09/15] Linter --- keystore/kms/asn1.go | 5 ++--- keystore/kms/asn1_test.go | 8 +++++--- keystore/kms/keystore.go | 2 +- keystore/kms/keystore_test.go | 3 ++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/keystore/kms/asn1.go b/keystore/kms/asn1.go index 9ceb8b6870..24c3ed9050 100644 --- a/keystore/kms/asn1.go +++ b/keystore/kms/asn1.go @@ -71,11 +71,10 @@ func ASN1ToSEC1PublicKey(asn1PublicKey []byte) ([]byte, error) { 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 { - // BitString already has 0x04 prefix (KMS format), use as-is - } else { + } 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 { diff --git a/keystore/kms/asn1_test.go b/keystore/kms/asn1_test.go index 2bce24ff7b..f0407a6e70 100644 --- a/keystore/kms/asn1_test.go +++ b/keystore/kms/asn1_test.go @@ -4,8 +4,9 @@ import ( "testing" "github.com/ethereum/go-ethereum/crypto" - kms "github.com/smartcontractkit/chainlink-common/keystore/kms" "github.com/stretchr/testify/require" + + kms "github.com/smartcontractkit/chainlink-common/keystore/kms" ) func TestSEC1ToASN1PublicKey(t *testing.T) { @@ -26,8 +27,9 @@ func TestSEC1ToASN1PublicKey(t *testing.T) { require.NoError(t, err) require.Len(t, sec1PubKey2, 65) require.Equal(t, byte(0x04), sec1PubKey2[0]) - require.Equal(t, privateKey.PublicKey.X.Bytes(), sec1PubKey2[1:33]) - require.Equal(t, privateKey.PublicKey.Y.Bytes(), sec1PubKey2[33:65]) + 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) { diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go index b7526d40fa..b6678e3168 100644 --- a/keystore/kms/keystore.go +++ b/keystore/kms/keystore.go @@ -7,6 +7,7 @@ import ( "github.com/aws/aws-sdk-go/aws" kmslib "github.com/aws/aws-sdk-go/service/kms" + "github.com/smartcontractkit/chainlink-common/keystore" ) @@ -146,7 +147,6 @@ func (k *keystoreSignerReader) Sign(ctx context.Context, req keystore.SignReques 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) { diff --git a/keystore/kms/keystore_test.go b/keystore/kms/keystore_test.go index a317abdb07..d6c3210449 100644 --- a/keystore/kms/keystore_test.go +++ b/keystore/kms/keystore_test.go @@ -4,9 +4,10 @@ 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" - "github.com/stretchr/testify/require" ) func TestKMSKeystore(t *testing.T) { From 01bfee6de615e63123a9d3e79d98f7a7ed05b216 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 17:10:27 -0500 Subject: [PATCH 10/15] Old cli thing --- keystore/cli/cli.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index aead1fb008..a8f0e65d6b 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -408,18 +408,6 @@ func loadKMSKeystore(ctx context.Context) (ks.KeystoreSignerReader, error) { if kmsProfile == "" { return nil, errors.New("KEYSTORE_KMS_PROFILE is required for KMS keystore") } - if kmsProfile == "xxx_test_profile" { - - key, err := crypto.GenerateKey() - if err != nil { - return nil, fmt.Errorf("generate key: %w", err) - } - return kms.NewKeystore(kmsinternal.NewMockKMSClient([]kmsinternal.Key{ - { - KeyID: "test-key-id", - PrivateKey: key, - })) - } client, err := kms.NewClient(kmsProfile) if err != nil { return nil, fmt.Errorf("create KMS client: %w", err) From 6ebc06a5eb07c1e2ecc0e113be7e74ab1d9fc6a0 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 17:21:18 -0500 Subject: [PATCH 11/15] Simpler cli --- keystore/cli/cli.go | 82 +++++++++++++++------------------------------ 1 file changed, 27 insertions(+), 55 deletions(-) diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index a8f0e65d6b..7b6ba0d441 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -15,6 +15,7 @@ import ( _ "github.com/lib/pq" "github.com/spf13/cobra" + "github.com/smartcontractkit/chainlink-common/keystore" ks "github.com/smartcontractkit/chainlink-common/keystore" "github.com/smartcontractkit/chainlink-common/keystore/kms" "github.com/smartcontractkit/chainlink-common/keystore/pgstore" @@ -114,7 +115,7 @@ func NewGetCmd() *cobra.Command { cmd := cobra.Command{ Use: "get", Short: "Get keys", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommandWithSigner[ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.GetKeysRequest) (ks.GetKeysResponse, error) { + return runKeystoreCommand[ks.KeystoreSignerReader, ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.GetKeysRequest) (ks.GetKeysResponse, error) { return k.GetKeys(ctx, req) }) }, @@ -128,7 +129,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 keystore.Keystore, req ks.CreateKeysRequest) (ks.CreateKeysResponse, error) { return k.CreateKeys(ctx, req) }) }, @@ -188,7 +189,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) }) }, @@ -202,7 +203,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) }) }, @@ -216,7 +217,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) }) }, @@ -226,7 +227,13 @@ func NewSetMetadataCmd() *cobra.Command { return &cmd } -func runKeystoreCommandWithSigner[Req any, Resp any](cmd *cobra.Command, args []string, fn func(ctx context.Context, k ks.KeystoreSignerReader, 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 @@ -238,38 +245,7 @@ func runKeystoreCommandWithSigner[Req any, Resp any](cmd *cobra.Command, args [] } ctx, cancel := context.WithTimeout(cmd.Context(), KeystoreLoadTimeout) defer cancel() - k, err := loadKeystoreSignerReader(ctx, cmd) - if err != nil { - return err - } - resp, err := fn(ctx, k, req) - if err != nil { - return err - } - jsonBytesOut, err := json.Marshal(resp) - if err != nil { - return err - } - _, err = cmd.OutOrStdout().Write(jsonBytesOut) - if err != nil { - return err - } - return nil -} - -func runKeystoreCommand[Req any, Resp any](cmd *cobra.Command, args []string, fn func(ctx context.Context, k ks.Keystore, req Req) (Resp, error)) error { - jsonBytes, err := readJSONInput(cmd) - if err != nil { - return err - } - var req Req - err = json.Unmarshal(jsonBytes, &req) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(cmd.Context(), KeystoreLoadTimeout) - defer cancel() - k, err := loadKeystore(ctx, cmd) + k, err := loader(ctx, cmd) if err != nil { return err } @@ -292,7 +268,7 @@ func NewSignCmd() *cobra.Command { cmd := cobra.Command{ Use: "sign", Short: "Sign data with a key", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommandWithSigner[ks.SignRequest, ks.SignResponse](cmd, args, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.SignRequest) (ks.SignResponse, error) { + return runKeystoreCommand[ks.KeystoreSignerReader, ks.SignRequest, ks.SignResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.SignRequest) (ks.SignResponse, error) { return k.Sign(ctx, req) }) }, @@ -331,7 +307,7 @@ func NewVerifyCmd() *cobra.Command { cmd := cobra.Command{ Use: "verify", Short: "Verify a signature", RunE: func(cmd *cobra.Command, args []string) error { - return runKeystoreCommandWithSigner[ks.VerifyRequest, ks.VerifyResponse](cmd, args, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.VerifyRequest) (ks.VerifyResponse, error) { + return runKeystoreCommand[ks.KeystoreSignerReader, ks.VerifyRequest, ks.VerifyResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k ks.KeystoreSignerReader, req ks.VerifyRequest) (ks.VerifyResponse, error) { return k.Verify(ctx, req) }) }, @@ -345,7 +321,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) }) }, @@ -384,7 +360,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) }) }, @@ -398,23 +374,19 @@ func loadKeystoreSignerReader(ctx context.Context, cmd *cobra.Command) (ks.Keyst // Check if KMS mode is enabled kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") if kmsProfile != "" { - return loadKMSKeystore(ctx) + kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") + if kmsProfile == "" { + return nil, errors.New("KEYSTORE_KMS_PROFILE is required for KMS keystore") + } + 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 loadKMSKeystore(ctx context.Context) (ks.KeystoreSignerReader, error) { - kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") - if kmsProfile == "" { - return nil, errors.New("KEYSTORE_KMS_PROFILE is required for KMS keystore") - } - client, err := kms.NewClient(kmsProfile) - if err != nil { - return nil, fmt.Errorf("create KMS client: %w", err) - } - return kms.NewKeystore(client) -} - func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) { // Read from environment variables only filePath := os.Getenv("KEYSTORE_FILE_PATH") From 13ddee987cb2ab19e32e3f475813470890b630af Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 17:27:01 -0500 Subject: [PATCH 12/15] Lint --- keystore/cli/cli.go | 7 +++---- keystore/cli/cli_test.go | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index 7b6ba0d441..3eeebb436f 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -12,10 +12,9 @@ import ( "time" "github.com/jmoiron/sqlx" - _ "github.com/lib/pq" + _ "github.com/lib/pq" // Register postgres driver "github.com/spf13/cobra" - "github.com/smartcontractkit/chainlink-common/keystore" ks "github.com/smartcontractkit/chainlink-common/keystore" "github.com/smartcontractkit/chainlink-common/keystore/kms" "github.com/smartcontractkit/chainlink-common/keystore/pgstore" @@ -129,7 +128,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.Keystore, ks.CreateKeysRequest, ks.CreateKeysResponse](cmd, args, loadKeystore, func(ctx context.Context, k keystore.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) }) }, @@ -158,7 +157,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 } 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) From dfc1d96ead034dec0ac4b18ccbca69626f7bad16 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 17:35:00 -0500 Subject: [PATCH 13/15] More lint --- keystore/cli/cli.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index 3eeebb436f..23b8ffe572 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -373,10 +373,6 @@ func loadKeystoreSignerReader(ctx context.Context, cmd *cobra.Command) (ks.Keyst // Check if KMS mode is enabled kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") if kmsProfile != "" { - kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE") - if kmsProfile == "" { - return nil, errors.New("KEYSTORE_KMS_PROFILE is required for KMS keystore") - } client, err := kms.NewClient(kmsProfile) if err != nil { return nil, fmt.Errorf("create KMS client: %w", err) From 77e44656aa3740cdc6724e316db0384f007a86dd Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 12 Jan 2026 18:02:04 -0500 Subject: [PATCH 14/15] Lint and simpler interface comp --- keystore/cli/cli.go | 29 +++++++++++++++++++++++++---- keystore/keystore.go | 9 --------- keystore/kms/keystore.go | 5 ++++- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/keystore/cli/cli.go b/keystore/cli/cli.go index 23b8ffe572..1aebfa8864 100644 --- a/keystore/cli/cli.go +++ b/keystore/cli/cli.go @@ -114,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.KeystoreSignerReader, ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k ks.KeystoreSignerReader, 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) }) }, @@ -267,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.KeystoreSignerReader, ks.SignRequest, ks.SignResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k ks.KeystoreSignerReader, 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) }) }, @@ -306,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.KeystoreSignerReader, ks.VerifyRequest, ks.VerifyResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k ks.KeystoreSignerReader, 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) }) }, @@ -369,7 +387,10 @@ func NewDecryptCmd() *cobra.Command { return &cmd } -func loadKeystoreSignerReader(ctx context.Context, cmd *cobra.Command) (ks.KeystoreSignerReader, error) { +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 != "" { diff --git a/keystore/keystore.go b/keystore/keystore.go index 5802a42d89..15306cda22 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -155,15 +155,6 @@ type Keystore interface { Encryptor } -// KeystoreSignerReader is useful for -// services which just need to sign using pre-established keys. -// Useful for TXM only / non-OCR services. -// Add more narrow interfaces as needed. -type KeystoreSignerReader interface { - Reader - Signer -} - var ErrUnimplemented = errors.New("unimplemented") // UnimplementedKeystore provides a no-op implementation of Keystore. diff --git a/keystore/kms/keystore.go b/keystore/kms/keystore.go index b6678e3168..cb00b15021 100644 --- a/keystore/kms/keystore.go +++ b/keystore/kms/keystore.go @@ -15,7 +15,10 @@ type keystoreSignerReader struct { client Client } -func NewKeystore(client Client) (keystore.KeystoreSignerReader, error) { +func NewKeystore(client Client) (interface { + keystore.Reader + keystore.Signer +}, error) { return &keystoreSignerReader{ client: client, }, nil From 22b739d72e61a43ccd9e7eede78fbd3230e00770 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Tue, 13 Jan 2026 11:14:49 -0500 Subject: [PATCH 15/15] Comments --- keystore/kms/asn1.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/keystore/kms/asn1.go b/keystore/kms/asn1.go index 24c3ed9050..0d15919e85 100644 --- a/keystore/kms/asn1.go +++ b/keystore/kms/asn1.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "math/big" + "slices" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/crypto/secp256k1" @@ -110,7 +111,7 @@ func SEC1ToASN1PublicKey(sec1PubKey []byte) ([]byte, error) { // 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 := append([]byte{0x04}, append(x, y...)...) + 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}