Skip to content

Commit bb19756

Browse files
authored
feat: support KMS key signing for evm provider (#190)
This commit introduces support for KMS key signing in the EVM provider. It includes a TransactorGenerator which can generate a transactor which signs transactions using a KMS key.
1 parent ce04f0e commit bb19756

File tree

13 files changed

+1369
-15
lines changed

13 files changed

+1369
-15
lines changed

.mockery.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,12 @@ packages:
1616
all: false
1717
interfaces:
1818
ContractCaller:
19+
github.com/smartcontractkit/chainlink-deployments-framework/chain/internal/kms:
20+
config:
21+
all: false
22+
pkgname: "kmsmocks"
23+
dir: '{{.InterfaceDir}}/mocks'
24+
filename: "mock_{{.InterfaceName | snakecase}}.go"
25+
interfaces:
26+
Client:
1927

chain/evm/provider/kms_signer.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
package provider
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"crypto/ecdsa"
7+
"encoding/asn1"
8+
"encoding/hex"
9+
"errors"
10+
"fmt"
11+
"math/big"
12+
13+
"github.com/aws/aws-sdk-go/aws"
14+
kmslib "github.com/aws/aws-sdk-go/service/kms"
15+
"github.com/ethereum/go-ethereum/accounts/abi/bind"
16+
"github.com/ethereum/go-ethereum/common"
17+
"github.com/ethereum/go-ethereum/core/types"
18+
"github.com/ethereum/go-ethereum/crypto"
19+
"github.com/ethereum/go-ethereum/crypto/secp256k1"
20+
21+
"github.com/smartcontractkit/chainlink-deployments-framework/chain/internal/kms"
22+
)
23+
24+
// KMSSigner provides a signer for EVM transactions using a KMS key. It provides methods to
25+
// convert KMS keys to EVM-compatible public keys, signatures, and geth bindings.
26+
type KMSSigner struct {
27+
// client is the underlying KMS client used to sign transactions.
28+
client kms.Client
29+
// kmsKeyID is the ID of the KMS key used for signing. Required to store it on the struct
30+
// so we can use it later to sign transactions.
31+
kmsKeyID string
32+
}
33+
34+
// NewKMSSigner creates a new KMSSigner instance using the provided KMS key ID, region, and
35+
// AWS profile. If you prefer to use environment variables to define the AWS profile, you may
36+
// set the awsProfile as an empty string.
37+
func NewKMSSigner(keyID, keyRegion string, awsProfile string) (*KMSSigner, error) {
38+
client, err := kms.NewClient(kms.ClientConfig{
39+
KeyID: keyID,
40+
KeyRegion: keyRegion,
41+
AWSProfile: awsProfile,
42+
})
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to initialize KMS Client: %w", err)
45+
}
46+
47+
return &KMSSigner{
48+
client: client,
49+
kmsKeyID: keyID,
50+
}, nil
51+
}
52+
53+
// GetECDSAPublicKey retrieves the public key from KMS and converts it to its ECDSA representation.
54+
func (s *KMSSigner) GetECDSAPublicKey() (*ecdsa.PublicKey, error) {
55+
out, err := s.client.GetPublicKey(&kmslib.GetPublicKeyInput{
56+
KeyId: aws.String(s.kmsKeyID),
57+
})
58+
if err != nil {
59+
return nil, fmt.Errorf("cannot get public key from KMS for KeyId=%s: %w", s.kmsKeyID, err)
60+
}
61+
62+
// The public key is returned in ASN.1 format, which we need to decode into an SPKI structure.
63+
var spki kms.SPKI
64+
if _, err = asn1.Unmarshal(out.PublicKey, &spki); err != nil {
65+
return nil, fmt.Errorf("cannot parse asn1 public key for KeyId=%s: %w", s.kmsKeyID, err)
66+
}
67+
68+
// Unmarshal the KMS public key bytes into an ECDSA public key.
69+
pubKey, err := crypto.UnmarshalPubkey(spki.SubjectPublicKey.Bytes)
70+
if err != nil {
71+
return nil, fmt.Errorf("cannot unmarshal public key bytes: %w", err)
72+
}
73+
74+
return pubKey, nil
75+
}
76+
77+
// GetAddress returns the Ethereum address corresponding to the public key managed by KMS.
78+
func (s *KMSSigner) GetAddress() (common.Address, error) {
79+
pubKey, err := s.GetECDSAPublicKey()
80+
if err != nil {
81+
return common.Address{}, fmt.Errorf("failed to get public key: %w", err)
82+
}
83+
84+
return crypto.PubkeyToAddress(*pubKey), nil
85+
}
86+
87+
// GetTransactOpts returns a *bind.TransactOpts configured to sign Ethereum transactions using the
88+
// KMS-backed key.
89+
//
90+
// The returned TransactOpts uses the KMS key for signing and sets the correct sender address
91+
// derived from the KMS public key.
92+
func (s *KMSSigner) GetTransactOpts(
93+
ctx context.Context, chainID *big.Int,
94+
) (*bind.TransactOpts, error) {
95+
if chainID == nil {
96+
return nil, errors.New("chainID is required")
97+
}
98+
99+
// Construct the key's EVM Address from the public key
100+
pubKey, err := s.GetECDSAPublicKey()
101+
if err != nil {
102+
return nil, err
103+
}
104+
105+
return &bind.TransactOpts{
106+
From: crypto.PubkeyToAddress(*pubKey),
107+
Signer: s.signerFunc(pubKey, chainID),
108+
Context: ctx,
109+
}, nil
110+
}
111+
112+
// signerFunc returns a function that signs transactions using KMS. The returned function
113+
// calls the KMS API to sign the transaction hash, converts the KMS signature to an
114+
// Ethereum-compatible format, and applies the signature to the transaction.
115+
func (s *KMSSigner) signerFunc(
116+
pubKey *ecdsa.PublicKey, chainID *big.Int,
117+
) func(address common.Address, tx *types.Transaction) (*types.Transaction, error) {
118+
// Convert the public key to bytes and derive the EVM address from it.
119+
pubKeyBytes := secp256k1.S256().Marshal(pubKey.X, pubKey.Y)
120+
keyAddr := crypto.PubkeyToAddress(*pubKey)
121+
122+
// Construct the EVM signer
123+
signer := types.LatestSignerForChainID(chainID)
124+
125+
return func(address common.Address, tx *types.Transaction) (*types.Transaction, error) {
126+
if address != keyAddr {
127+
return nil, bind.ErrNotAuthorized
128+
}
129+
130+
var (
131+
txHash = signer.Hash(tx).Bytes()
132+
mType = kmslib.MessageTypeDigest
133+
algo = kmslib.SigningAlgorithmSpecEcdsaSha256
134+
)
135+
136+
// Sign the transaction hash using KMS.
137+
out, err := s.client.Sign(&kmslib.SignInput{
138+
KeyId: &s.kmsKeyID,
139+
SigningAlgorithm: &algo,
140+
MessageType: &mType,
141+
Message: txHash,
142+
})
143+
if err != nil {
144+
return nil, fmt.Errorf("call to kms.Sign() failed on transaction: %w", err)
145+
}
146+
147+
evmSig, err := kmsToEVMSig(out.Signature, pubKeyBytes, txHash)
148+
if err != nil {
149+
return nil, fmt.Errorf("failed to convert KMS signature to Ethereum signature: %w", err)
150+
}
151+
152+
return tx.WithSignature(signer, evmSig)
153+
}
154+
}
155+
156+
var (
157+
// secp256k1N is the N value of the secp256k1 curve, used to adjust the S value in signatures.
158+
secp256k1N = crypto.S256().Params().N
159+
// secp256k1HalfN is half of the secp256k1 N value, used to adjust the S value in signatures.
160+
secp256k1HalfN = new(big.Int).Div(secp256k1N, big.NewInt(2))
161+
)
162+
163+
// kmsToEVMSig converts a KMS signature to an Ethereum-compatible signature. This follows this
164+
// example provided by AWS Guides.
165+
//
166+
// [AWS Guides]: https://aws.amazon.com/blogs/database/part2-use-aws-kms-to-securely-manage-ethereum-accounts/
167+
func kmsToEVMSig(kmsSig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) {
168+
var ecdsaSig kms.ECDSASig
169+
if _, err := asn1.Unmarshal(kmsSig, &ecdsaSig); err != nil {
170+
return nil, fmt.Errorf("failed to unmarshal KMS signature: %w", err)
171+
}
172+
173+
rBytes := ecdsaSig.R.Bytes
174+
sBytes := ecdsaSig.S.Bytes
175+
176+
// Adjust S value from signature to match EVM standard.
177+
//
178+
// After we extract r and s successfully, we have to test if the value of s is greater than
179+
// secp256k1n/2 as specified in EIP-2 and flip it if required.
180+
sBigInt := new(big.Int).SetBytes(sBytes)
181+
if sBigInt.Cmp(secp256k1HalfN) > 0 {
182+
sBytes = new(big.Int).Sub(secp256k1N, sBigInt).Bytes()
183+
}
184+
185+
return recoverEVMSignature(ecdsaPubKeyBytes, hash, rBytes, sBytes)
186+
}
187+
188+
// recoverEVMSignature attempts to reconstruct the EVM signature by trying both possible recovery
189+
// IDs (v = 0 and v = 1). It compares the recovered public key with the expected public key bytes
190+
// to determine the correct signature.
191+
//
192+
// Returns the valid EVM signature if successful, or an error if neither recovery ID matches.
193+
func recoverEVMSignature(expectedPublicKey, txHash, r, s []byte) ([]byte, error) {
194+
// Ethereum signatures require r and s to be exactly 32 bytes each.
195+
rsSig := append(padTo32Bytes(r), padTo32Bytes(s)...)
196+
// Ethereum signatures have a 65th byte called the recovery ID (v), which can be 0 or 1.
197+
// Here we append 0 to the signature to start with for the first recovery attempt.
198+
evmSig := append(rsSig, []byte{0}...)
199+
200+
recoveredPublicKey, err := crypto.Ecrecover(txHash, evmSig)
201+
if err != nil {
202+
return nil, fmt.Errorf("failed to recover signature with v=0: %w", err)
203+
}
204+
205+
if hex.EncodeToString(recoveredPublicKey) != hex.EncodeToString(expectedPublicKey) {
206+
// If the first recovery attempt failed, we try with v=1.
207+
evmSig = append(rsSig, []byte{1}...)
208+
recoveredPublicKey, err = crypto.Ecrecover(txHash, evmSig)
209+
if err != nil {
210+
return nil, fmt.Errorf("failed to recover signature with v=1: %w", err)
211+
}
212+
213+
if hex.EncodeToString(recoveredPublicKey) != hex.EncodeToString(expectedPublicKey) {
214+
return nil, errors.New("cannot reconstruct public key from sig")
215+
}
216+
}
217+
218+
return evmSig, nil
219+
}
220+
221+
// padTo32Bytes pads the given byte slice to 32 bytes by trimming leading zeros and prepending
222+
// zeros.
223+
func padTo32Bytes(buffer []byte) []byte {
224+
buffer = bytes.TrimLeft(buffer, "\x00")
225+
for len(buffer) < 32 {
226+
zeroBuf := []byte{0}
227+
buffer = append(zeroBuf, buffer...)
228+
}
229+
230+
return buffer
231+
}

0 commit comments

Comments
 (0)