Skip to content

Commit

Permalink
Bring KMS client and multiclient over to chainlink (#14484)
Browse files Browse the repository at this point in the history
* Bring KMS client and multiclient over to chainlink

* Pull seth client into multiclient

* remove toml tags

* Keep multiclient simple

* Config validation for kms

* Rename to ws, fix test

* Changeset

* Downgrade seth

* Lint

* More lint

---------

Co-authored-by: AnieeG <[email protected]>
Co-authored-by: connorwstein <[email protected]>
  • Loading branch information
3 people authored Sep 19, 2024
1 parent 0aa5985 commit d2a01ca
Show file tree
Hide file tree
Showing 7 changed files with 426 additions and 3 deletions.
5 changes: 5 additions & 0 deletions .changeset/sour-ears-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink": patch
---

#internal KMS client for deployment
2 changes: 0 additions & 2 deletions core/capabilities/ccip/ccip_integration_tests/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,6 @@ func (h *homeChain) AddNodes(
p2pIDs [][32]byte,
capabilityIDs [][32]byte,
) {
// Need to sort, otherwise _checkIsValidUniqueSubset onChain will fail
sortP2PIDS(p2pIDs)
var nodeParams []kcr.CapabilitiesRegistryNodeParams
for _, p2pID := range p2pIDs {
Expand All @@ -430,7 +429,6 @@ func AddChainConfig(
p2pIDs [][32]byte,
f uint8,
) ccip_config.CCIPConfigTypesChainConfigInfo {
// Need to sort, otherwise _checkIsValidUniqueSubset onChain will fail
sortP2PIDS(p2pIDs)
// First Add ChainConfig that includes all p2pIDs as readers
encodedExtraChainConfig, err := chainconfig.EncodeChainConfig(chainconfig.ChainConfig{
Expand Down
233 changes: 233 additions & 0 deletions integration-tests/deployment/evm_kmsclient.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
package deployment

import (
"bytes"
"context"
"crypto/ecdsa"
"encoding/asn1"
"encoding/hex"
"fmt"
"math/big"

"github.com/aws/aws-sdk-go/aws/session"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/kms"
"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/crypto/secp256k1"
)

var (
secp256k1N = crypto.S256().Params().N
secp256k1HalfN = new(big.Int).Div(secp256k1N, big.NewInt(2))
)

// See https://docs.aws.amazon.com/kms/latest/APIReference/API_GetPublicKey.html#API_GetPublicKey_ResponseSyntax
// and https://datatracker.ietf.org/doc/html/rfc5280 for why we need to unpack the KMS public key.
type asn1SubjectPublicKeyInfo struct {
AlgorithmIdentifier asn1AlgorithmIdentifier
SubjectPublicKey asn1.BitString
}

type asn1AlgorithmIdentifier struct {
Algorithm asn1.ObjectIdentifier
Parameters asn1.ObjectIdentifier
}

// See https://aws.amazon.com/blogs/database/part2-use-aws-kms-to-securely-manage-ethereum-accounts/ for why we
// need to manually prep the signature for Ethereum.
type asn1ECDSASig struct {
R asn1.RawValue
S asn1.RawValue
}

// TODO: Mockery gen then test with a regular eth key behind the interface.
type KMSClient interface {
GetPublicKey(input *kms.GetPublicKeyInput) (*kms.GetPublicKeyOutput, error)
Sign(input *kms.SignInput) (*kms.SignOutput, error)
}

type KMS struct {
KmsDeployerKeyId string
KmsDeployerKeyRegion string
AwsProfileName string
}

func NewKMSClient(config KMS) (KMSClient, error) {
if config.KmsDeployerKeyId == "" {
return nil, fmt.Errorf("KMS key ID is required")
}
if config.KmsDeployerKeyRegion == "" {
return nil, fmt.Errorf("KMS key region is required")
}
var awsSessionFn AwsSessionFn
if config.AwsProfileName != "" {
awsSessionFn = awsSessionFromProfileFn
} else {
awsSessionFn = awsSessionFromEnvVarsFn
}
return kms.New(awsSessionFn(config)), nil
}

type EVMKMSClient struct {
Client KMSClient
KeyID string
}

func NewEVMKMSClient(client KMSClient, keyID string) *EVMKMSClient {
return &EVMKMSClient{
Client: client,
KeyID: keyID,
}
}

func (c *EVMKMSClient) GetKMSTransactOpts(ctx context.Context, chainID *big.Int) (*bind.TransactOpts, error) {
ecdsaPublicKey, err := c.GetECDSAPublicKey()
if err != nil {
return nil, err
}

pubKeyBytes := secp256k1.S256().Marshal(ecdsaPublicKey.X, ecdsaPublicKey.Y)
keyAddr := crypto.PubkeyToAddress(*ecdsaPublicKey)
if chainID == nil {
return nil, fmt.Errorf("chainID is required")
}
signer := types.LatestSignerForChainID(chainID)

signerFn := func(address common.Address, tx *types.Transaction) (*types.Transaction, error) {
if address != keyAddr {
return nil, bind.ErrNotAuthorized
}

txHashBytes := signer.Hash(tx).Bytes()

mType := kms.MessageTypeDigest
algo := kms.SigningAlgorithmSpecEcdsaSha256
signOutput, err := c.Client.Sign(
&kms.SignInput{
KeyId: &c.KeyID,
SigningAlgorithm: &algo,
MessageType: &mType,
Message: txHashBytes,
})
if err != nil {
return nil, fmt.Errorf("failed to call kms.Sign() on transaction: %w", err)
}

ethSig, err := kmsToEthSig(signOutput.Signature, pubKeyBytes, txHashBytes)
if err != nil {
return nil, fmt.Errorf("failed to convert KMS signature to Ethereum signature: %w", err)
}

return tx.WithSignature(signer, ethSig)
}

return &bind.TransactOpts{
From: keyAddr,
Signer: signerFn,
Context: ctx,
}, nil
}

// GetECDSAPublicKey retrieves the public key from KMS and converts it to its ECDSA representation.
func (c *EVMKMSClient) GetECDSAPublicKey() (*ecdsa.PublicKey, error) {
getPubKeyOutput, err := c.Client.GetPublicKey(&kms.GetPublicKeyInput{
KeyId: aws.String(c.KeyID),
})
if err != nil {
return nil, fmt.Errorf("can not get public key from KMS for KeyId=%s: %w", c.KeyID, err)
}

var asn1pubKeyInfo asn1SubjectPublicKeyInfo
_, err = asn1.Unmarshal(getPubKeyOutput.PublicKey, &asn1pubKeyInfo)
if err != nil {
return nil, fmt.Errorf("can not parse asn1 public key for KeyId=%s: %w", c.KeyID, err)
}

pubKey, err := crypto.UnmarshalPubkey(asn1pubKeyInfo.SubjectPublicKey.Bytes)
if err != nil {
return nil, fmt.Errorf("can not unmarshal public key bytes: %w", err)
}
return pubKey, nil
}

func kmsToEthSig(kmsSig, ecdsaPubKeyBytes, hash []byte) ([]byte, error) {
var asn1Sig asn1ECDSASig
_, err := asn1.Unmarshal(kmsSig, &asn1Sig)
if err != nil {
return nil, err
}

rBytes := asn1Sig.R.Bytes
sBytes := asn1Sig.S.Bytes

// Adjust S value from signature to match Eth standard.
// See: https://aws.amazon.com/blogs/database/part2-use-aws-kms-to-securely-manage-ethereum-accounts/
// "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 recoverEthSignature(ecdsaPubKeyBytes, hash, rBytes, sBytes)
}

// See: https://aws.amazon.com/blogs/database/part2-use-aws-kms-to-securely-manage-ethereum-accounts/
func recoverEthSignature(expectedPublicKeyBytes, txHash, r, s []byte) ([]byte, error) {
rsSig := append(padTo32Bytes(r), padTo32Bytes(s)...)
ethSig := append(rsSig, []byte{0}...)

recoveredPublicKeyBytes, err := crypto.Ecrecover(txHash, ethSig)
if err != nil {
return nil, fmt.Errorf("failing to call Ecrecover: %w", err)
}

if hex.EncodeToString(recoveredPublicKeyBytes) != hex.EncodeToString(expectedPublicKeyBytes) {
ethSig = append(rsSig, []byte{1}...)
recoveredPublicKeyBytes, err = crypto.Ecrecover(txHash, ethSig)
if err != nil {
return nil, fmt.Errorf("failing to call Ecrecover: %w", err)
}

if hex.EncodeToString(recoveredPublicKeyBytes) != hex.EncodeToString(expectedPublicKeyBytes) {
return nil, fmt.Errorf("can not reconstruct public key from sig")
}
}

return ethSig, nil
}

func padTo32Bytes(buffer []byte) []byte {
buffer = bytes.TrimLeft(buffer, "\x00")
for len(buffer) < 32 {
zeroBuf := []byte{0}
buffer = append(zeroBuf, buffer...)
}
return buffer
}

type AwsSessionFn func(config KMS) *session.Session

var awsSessionFromEnvVarsFn = func(config KMS) *session.Session {
return session.Must(
session.NewSession(&aws.Config{
Region: aws.String(config.KmsDeployerKeyRegion),
CredentialsChainVerboseErrors: aws.Bool(true),
}))
}

var awsSessionFromProfileFn = func(config KMS) *session.Session {
return session.Must(
session.NewSessionWithOptions(session.Options{
SharedConfigState: session.SharedConfigEnable,
Profile: config.AwsProfileName,
Config: aws.Config{
Region: aws.String(config.KmsDeployerKeyRegion),
CredentialsChainVerboseErrors: aws.Bool(true),
},
}))
}
27 changes: 27 additions & 0 deletions integration-tests/deployment/evm_kmsclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package deployment

import (
"encoding/hex"
"testing"

"github.com/test-go/testify/require"
)

func TestKMSToEthSigConversion(t *testing.T) {
kmsSigBytes, err := hex.DecodeString("304402206168865941bafcae3a8cf8b26edbb5693d62222b2e54d962c1aabbeaddf33b6802205edc7f597d2bf2d1eaa14fc514a6202bafcffe52b13ae3fec00674d92a874b73")
require.NoError(t, err)
ecdsaPublicKeyBytes, err := hex.DecodeString("04a735e9e3cb526f83be23b03f1f5ae7788a8654e3f0fcfb4f978290de07ebd47da30eeb72e904fdd4a81b46e320908ff4345e119148f89c1f04674c14a506e24b")
require.NoError(t, err)
txHashBytes, err := hex.DecodeString("a2f037301e90f58c084fe4bec2eef14b26e620d6b6cb46051037d03b29ab7d9a")
require.NoError(t, err)
expectedEthSignBytes, err := hex.DecodeString("6168865941bafcae3a8cf8b26edbb5693d62222b2e54d962c1aabbeaddf33b685edc7f597d2bf2d1eaa14fc514a6202bafcffe52b13ae3fec00674d92a874b7300")
require.NoError(t, err)

actualEthSig, err := kmsToEthSig(
kmsSigBytes,
ecdsaPublicKeyBytes,
txHashBytes,
)
require.NoError(t, err)
require.Equal(t, expectedEthSignBytes, actualEthSig)
}
Loading

0 comments on commit d2a01ca

Please sign in to comment.