diff --git a/op-signer/app.go b/op-signer/app.go index 074bf941..8f495ac4 100644 --- a/op-signer/app.go +++ b/op-signer/app.go @@ -149,7 +149,10 @@ func (s *SignerApp) initRPC(cfg *Config) error { if err != nil { return fmt.Errorf("failed to read service config: %w", err) } - s.signer = service.NewSignerService(s.log, serviceCfg) + s.signer, err = service.NewSignerService(s.log, serviceCfg) + if err != nil { + return fmt.Errorf("failed to create signer service: %w", err) + } s.signer.RegisterAPIs(s.rpc) if err := s.rpc.Start(); err != nil { diff --git a/op-signer/go.mod b/op-signer/go.mod index 5629bc77..e6b72328 100644 --- a/op-signer/go.mod +++ b/op-signer/go.mod @@ -4,6 +4,7 @@ go 1.22.0 require ( cloud.google.com/go/kms v1.12.1 + github.com/aws/aws-sdk-go-v2/config v1.28.11 github.com/ethereum-optimism/optimism v0.0.0-20241213111354-8bf7ff60f34a github.com/ethereum/go-ethereum v1.14.11 github.com/golang/mock v1.6.0 @@ -17,12 +18,28 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require ( + github.com/aws/aws-sdk-go-v2 v1.32.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.52 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.33.7 // indirect + github.com/aws/smithy-go v1.22.1 // indirect +) + require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect cloud.google.com/go/iam v1.1.1 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/DataDog/zstd v1.5.6-0.20230824185856-869dae002e5e // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/aws/aws-sdk-go-v2/service/kms v1.37.11 github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.4 // indirect diff --git a/op-signer/go.sum b/op-signer/go.sum index 3b58e1fe..831208c0 100644 --- a/op-signer/go.sum +++ b/op-signer/go.sum @@ -18,6 +18,34 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/aws/aws-sdk-go-v2 v1.32.8 h1:cZV+NUS/eGxKXMtmyhtYPJ7Z4YLoI/V8bkTdRZfYhGo= +github.com/aws/aws-sdk-go-v2 v1.32.8/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= +github.com/aws/aws-sdk-go-v2/config v1.28.11 h1:7Ekru0IkRHRnSRWGQLnLN6i0o1Jncd0rHo2T130+tEQ= +github.com/aws/aws-sdk-go-v2/config v1.28.11/go.mod h1:x78TpPvBfHH16hi5tE3OCWQ0pzNfyXA349p5/Wp82Yo= +github.com/aws/aws-sdk-go-v2/credentials v1.17.52 h1:I4ymSk35LHogx2Re2Wu6LOHNTRaRWkLVoJgWS5Wd40M= +github.com/aws/aws-sdk-go-v2/credentials v1.17.52/go.mod h1:vAkqKbMNUcher8fDXP2Ge2qFXKMkcD74qvk1lJRMemM= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23 h1:IBAoD/1d8A8/1aA8g4MBVtTRHhXRiNAgwdbo/xRM2DI= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.23/go.mod h1:vfENuCM7dofkgKpYzuzf1VT1UKkA/YL3qanfBn7HCaA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27 h1:jSJjSBzw8VDIbWv+mmvBSP8ezsztMYJGH+eKqi9AmNs= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.27/go.mod h1:/DAhLbFRgwhmvJdOfSm+WwikZrCuUJiA4WgJG0fTNSw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27 h1:l+X4K77Dui85pIj5foXDhPlnqcNRG2QUyvca300lXh8= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.27/go.mod h1:KvZXSFEXm6x84yE8qffKvT3x8J5clWnVFXphpohhzJ8= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8 h1:cWno7lefSH6Pp+mSznagKCgfDGeZRin66UvYUqAkyeA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.8/go.mod h1:tPD+VjU3ABTBoEJ3nctu5Nyg4P4yjqSH5bJGGkY4+XE= +github.com/aws/aws-sdk-go-v2/service/kms v1.37.11 h1:49cjX6w3sLuMk0PBBXzUsgzF6v4eEB1teKchdDQ4HFo= +github.com/aws/aws-sdk-go-v2/service/kms v1.37.11/go.mod h1:wHYtyttsH+A6d2MzXYl8cIf4O2Kw1Kg0qzromSX/wOs= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.9 h1:YqtxripbjWb2QLyzRK9pByfEDvgg95gpC2AyDq4hFE8= +github.com/aws/aws-sdk-go-v2/service/sso v1.24.9/go.mod h1:lV8iQpg6OLOfBnqbGMBKYjilBlf633qwHnBEiMSPoHY= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 h1:6dBT1Lz8fK11m22R+AqfRsFn8320K0T5DTGxxOQBSMw= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8/go.mod h1:/kiBvRQXBc6xeJTYzhSdGvJ5vm1tjaDEjH+MSeRJnlY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.7 h1:qwGa9MA8G7mBq2YphHFaygdPe5t9OA7SvaJdwWTlEds= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.7/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc= +github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= +github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= diff --git a/op-signer/service/config.go b/op-signer/service/config.go index a51993d6..a8ef1249 100644 --- a/op-signer/service/config.go +++ b/op-signer/service/config.go @@ -6,6 +6,7 @@ import ( "math/big" "os" + "github.com/ethereum-optimism/infra/op-signer/service/provider" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "gopkg.in/yaml.v3" @@ -29,7 +30,8 @@ func (c AuthConfig) MaxValueToInt() *big.Int { } type SignerServiceConfig struct { - Auth []AuthConfig `yaml:"auth"` + ProviderType provider.ProviderType `yaml:"provider"` + Auth []AuthConfig `yaml:"auth"` } func ReadConfig(path string) (SignerServiceConfig, error) { @@ -41,6 +43,16 @@ func ReadConfig(path string) (SignerServiceConfig, error) { if err := yaml.Unmarshal(data, &config); err != nil { return config, err } + + // Default to GCP if Provider is empty to avoid breaking changes + if config.ProviderType == "" { + config.ProviderType = provider.KeyProviderGCP + } + + if !config.ProviderType.IsValid() { + return config, fmt.Errorf("invalid provider '%s' in config. Must be 'AWS' or 'GCP'", config.ProviderType) + } + for _, authConfig := range config.Auth { for _, toAddress := range authConfig.ToAddresses { if _, err := hexutil.Decode(toAddress); err != nil { diff --git a/op-signer/service/config_test.go b/op-signer/service/config_test.go new file mode 100644 index 00000000..6e94aed1 --- /dev/null +++ b/op-signer/service/config_test.go @@ -0,0 +1,78 @@ +package service + +import ( + "os" + "testing" + + "github.com/ethereum-optimism/infra/op-signer/service/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadConfig_DefaultKeyProvider(t *testing.T) { + tmpFile, err := os.CreateTemp("", "config-*.yaml") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + configData := ` +auth: + - name: "test-client" + key: "test-key" + chainID: 10 + fromAddress: "0x1234567890123456789012345678901234567890" + toAddresses: ["0x1234567890123456789012345678901234567890"] + maxValue: "0x1234" +` + err = os.WriteFile(tmpFile.Name(), []byte(configData), 0644) + require.NoError(t, err) + + config, err := ReadConfig(tmpFile.Name()) + require.NoError(t, err) + assert.Equal(t, provider.KeyProviderGCP, config.ProviderType) +} + +func TestReadConfig_ExplicitKeyProvider(t *testing.T) { + tmpFile, err := os.CreateTemp("", "config-*.yaml") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + configData := ` +provider: "AWS" +auth: + - name: "test-client" + key: "test-key" + chainID: 10 + fromAddress: "0x1234567890123456789012345678901234567890" + toAddresses: ["0x1234567890123456789012345678901234567890"] + maxValue: "0x1234" +` + err = os.WriteFile(tmpFile.Name(), []byte(configData), 0644) + require.NoError(t, err) + + config, err := ReadConfig(tmpFile.Name()) + require.NoError(t, err) + assert.Equal(t, provider.KeyProviderAWS, config.ProviderType) +} + +func TestReadConfig_InvalidKeyProvider(t *testing.T) { + tmpFile, err := os.CreateTemp("", "config-*.yaml") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + configData := ` +provider: "INVALID" +auth: + - name: "test-client" + key: "test-key" + chainID: 10 + fromAddress: "0x1234567890123456789012345678901234567890" + toAddresses: ["0x1234567890123456789012345678901234567890"] + maxValue: "0x1234" +` + err = os.WriteFile(tmpFile.Name(), []byte(configData), 0644) + require.NoError(t, err) + + _, err = ReadConfig(tmpFile.Name()) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid provider") +} diff --git a/op-signer/service/provider/aws_kms.go b/op-signer/service/provider/aws_kms.go new file mode 100644 index 00000000..e9986ac1 --- /dev/null +++ b/op-signer/service/provider/aws_kms.go @@ -0,0 +1,87 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/ethereum/go-ethereum/log" +) + +// AWSKMSClient is the minimal interface for the AWS KMS client required by the AWSKMSSignatureProvider. These functions +// are already implemented by the AWS SDK, but we define our own type to allow us to mock the client in tests. +type AWSKMSClient interface { + // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/kms#Client.Sign + Sign(ctx context.Context, params *kms.SignInput, optFns ...func(*kms.Options)) (*kms.SignOutput, error) + // https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/kms#Client.GetPublicKey + GetPublicKey(ctx context.Context, params *kms.GetPublicKeyInput, optFns ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) +} + +type AWSKMSSignatureProvider struct { + logger log.Logger + client AWSKMSClient +} + +func NewAWSKMSSignatureProvider(logger log.Logger) (SignatureProvider, error) { + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + client := kms.NewFromConfig(cfg) + return &AWSKMSSignatureProvider{logger: logger, client: client}, nil +} + +func NewAWSKMSSignatureProviderWithClient(logger log.Logger, client AWSKMSClient) SignatureProvider { + return &AWSKMSSignatureProvider{logger: logger, client: client} +} + +// SignDigest signs the digest with a given AWS KMS keyname and returns a compact recoverable signature. +// The key must be an ECC_SECG_P256K1 key type. +func (a *AWSKMSSignatureProvider) SignDigest( + ctx context.Context, + keyName string, + digest []byte, +) ([]byte, error) { + publicKey, err := a.GetPublicKey(ctx, keyName) + if err != nil { + return nil, fmt.Errorf("failed to get public key: %w", err) + } + + signInput := &kms.SignInput{ + KeyId: &keyName, + Message: digest, + MessageType: types.MessageTypeDigest, + SigningAlgorithm: types.SigningAlgorithmSpecEcdsaSha256, + } + + result, err := a.client.Sign(ctx, signInput) + if err != nil { + return nil, fmt.Errorf("aws kms sign request failed: %w", err) + } + + a.logger.Debug(fmt.Sprintf("der signature: %x", result.Signature)) + + return convertToCompactRecoverableSignature(result.Signature, digest, publicKey) +} + +// GetPublicKey returns a decoded secp256k1 public key. +func (a *AWSKMSSignatureProvider) GetPublicKey( + ctx context.Context, + keyName string, +) ([]byte, error) { + input := &kms.GetPublicKeyInput{ + KeyId: &keyName, + } + + result, err := a.client.GetPublicKey(ctx, input) + if err != nil { + return nil, fmt.Errorf("aws kms get public key request failed: %w", err) + } + + // AWS KMS returns the public key in DER format + return x509ParseECDSAPublicKey(result.PublicKey) +} diff --git a/op-signer/service/provider/aws_kms_test.go b/op-signer/service/provider/aws_kms_test.go new file mode 100644 index 00000000..dc4da144 --- /dev/null +++ b/op-signer/service/provider/aws_kms_test.go @@ -0,0 +1,103 @@ +package provider + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/crypto/secp256k1" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockAWSKMSClient implements AWSKMSClient interface for testing +type mockAWSKMSClient struct { + signOutput *kms.SignOutput + getPublicKeyOutput *kms.GetPublicKeyOutput +} + +func (m *mockAWSKMSClient) Sign(ctx context.Context, params *kms.SignInput, optFns ...func(*kms.Options)) (*kms.SignOutput, error) { + return m.signOutput, nil +} + +func (m *mockAWSKMSClient) GetPublicKey(ctx context.Context, params *kms.GetPublicKeyInput, optFns ...func(*kms.Options)) (*kms.GetPublicKeyOutput, error) { + return m.getPublicKeyOutput, nil +} + +func TestAWSKMSSignatureProvider_GetPublicKey(t *testing.T) { + // Generate a secp256k1 key pair + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + publicKey := privateKey.PublicKey + publicKeyDER, err := marshalECDSAPublicKey(&publicKey) + require.NoError(t, err) + + mockClient := &mockAWSKMSClient{ + getPublicKeyOutput: &kms.GetPublicKeyOutput{ + PublicKey: publicKeyDER, + }, + } + + provider := NewAWSKMSSignatureProviderWithClient(log.New(), mockClient) + + _, err = provider.GetPublicKey(context.Background(), "test-key") + require.NoError(t, err) +} + +func TestAWSKMSSignatureProvider_SignDigest(t *testing.T) { + // Generate a secp256k1 key pair + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + publicKey := privateKey.PublicKey + publicKeyDER, err := marshalECDSAPublicKey(&publicKey) + if err != nil { + t.Fatalf("failed to marshal public key: %v", err) + } + + // Sign a message using the private key so we can later verify the signature + message := []byte("test message to sign") + digest := crypto.Keccak256Hash(message).Bytes() + + compactRecoverableSig, err := crypto.Sign(digest, privateKey) + require.NoError(t, err) + + derSignature, err := convertCompactRecoverableSignatureToDER(compactRecoverableSig) + require.NoError(t, err) + + // Create mock AWS KMS client that returns our test signature + mockClient := &mockAWSKMSClient{ + getPublicKeyOutput: &kms.GetPublicKeyOutput{ + PublicKey: publicKeyDER, + }, + signOutput: &kms.SignOutput{ + Signature: derSignature, + }, + } + + // Create provider with mock client + provider := NewAWSKMSSignatureProviderWithClient(log.New(), mockClient) + + // Test signing + signature, err := provider.SignDigest(context.Background(), "test-key", digest) + require.NoError(t, err) + + // Verify signature length (65 bytes: r[32] || s[32] || v[1]) + assert.Equal(t, 65, len(signature)) + + // Verify signature is recoverable + recoveredPub, err := secp256k1.RecoverPubkey(digest, signature) + require.NoError(t, err) + + // Verify recovered public key matches original + publicKeyBytes, err := x509ParseECDSAPublicKey(publicKeyDER) + require.NoError(t, err) + + assert.Equal(t, publicKeyBytes, recoveredPub) + + // Verify signature using go-ethereum's crypto implementation + assert.True(t, crypto.VerifySignature(publicKeyBytes, digest, signature[:64])) +} diff --git a/op-signer/service/provider/cloudkms.go b/op-signer/service/provider/cloudkms.go index 7b108ac0..ab0aa960 100644 --- a/op-signer/service/provider/cloudkms.go +++ b/op-signer/service/provider/cloudkms.go @@ -42,14 +42,13 @@ type CloudKMSSignatureProvider struct { client CloudKMSClient } -func NewCloudKMSSignatureProvider(logger log.Logger) SignatureProvider { +func NewCloudKMSSignatureProvider(logger log.Logger) (SignatureProvider, error) { ctx := context.Background() client, err := kms.NewKeyManagementClient(ctx) if err != nil { - logger.Error("failed to initialize kms client", "error", err) - panic(err) + return nil, fmt.Errorf("failed to initialize kms client: %w", err) } - return &CloudKMSSignatureProvider{logger, client} + return &CloudKMSSignatureProvider{logger, client}, nil } func NewCloudKMSSignatureProviderWithClient(logger log.Logger, client CloudKMSClient) SignatureProvider { diff --git a/op-signer/service/provider/provider.go b/op-signer/service/provider/provider.go index 33158579..0947a0c5 100644 --- a/op-signer/service/provider/provider.go +++ b/op-signer/service/provider/provider.go @@ -1,9 +1,44 @@ //go:generate mockgen -destination=mock_provider.go -package=provider github.com/ethereum-optimism/infra/op-signer/service/provider SignatureProvider package provider -import "context" +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/log" +) type SignatureProvider interface { SignDigest(ctx context.Context, keyName string, digest []byte) ([]byte, error) GetPublicKey(ctx context.Context, keyName string) ([]byte, error) } + +// ProviderType represents the cloud provider for the key management service +type ProviderType string + +const ( + KeyProviderAWS ProviderType = "AWS" + KeyProviderGCP ProviderType = "GCP" +) + +// IsValid checks if the KeyProvider value is valid +func (k ProviderType) IsValid() bool { + switch k { + case KeyProviderAWS, KeyProviderGCP: + return true + default: + return false + } +} + +// NewSignatureProvider creates a new SignatureProvider based on the provider type +func NewSignatureProvider(logger log.Logger, providerType ProviderType) (SignatureProvider, error) { + switch providerType { + case KeyProviderGCP: + return NewCloudKMSSignatureProvider(logger) + case KeyProviderAWS: + return NewAWSKMSSignatureProvider(logger) + default: + return nil, fmt.Errorf("unsupported provider type: %s", providerType) + } +} diff --git a/op-signer/service/provider/utils.go b/op-signer/service/provider/utils.go new file mode 100644 index 00000000..058756de --- /dev/null +++ b/op-signer/service/provider/utils.go @@ -0,0 +1,110 @@ +package provider + +import ( + "crypto/ecdsa" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/crypto/secp256k1" +) + +// marshalECDSAPublicKey marshals a secp256k1 public key into DER format. This +// is needed because the Golang standard crypto/esdsa and crypto/elliptic libs +// do not support secp256k1. +func marshalECDSAPublicKey(pub *ecdsa.PublicKey) ([]byte, error) { + algoFullBytes, _ := asn1.Marshal(oidNamedCurveSECP256K1) + + // Encode AlgorithmIdentifier with OID for ECDSA and named curve + publicKeyAlgorithm := pkix.AlgorithmIdentifier{ + Algorithm: oidPublicKeyECDSA, + Parameters: asn1.RawValue{ + Tag: asn1.TagOID, + FullBytes: algoFullBytes, + Class: asn1.ClassUniversal, + IsCompound: false, + }, + } + + // Marshal the public key point (X, Y) + publicKeyBytes := secp256k1.S256().Marshal(pub.X, pub.Y) + + // Construct the publicKeyInfo structure + pkix := publicKeyInfo{ + Algorithm: publicKeyAlgorithm, + PublicKey: asn1.BitString{ + Bytes: publicKeyBytes, + BitLength: len(publicKeyBytes) * 8, + }, + } + + // Encode the structure into DER + return asn1.Marshal(pkix) +} + +// unmarshalECDSAPublicKey parses a secp256k1 public key from DER format. +func unmarshalECDSAPublicKey(derBytes []byte) (*ecdsa.PublicKey, error) { + var pkInfo publicKeyInfo + + // Decode DER bytes into publicKeyInfo structure + _, err := asn1.Unmarshal(derBytes, &pkInfo) + if err != nil { + return nil, err + } + + namedCurveOID := new(asn1.ObjectIdentifier) + _, err = asn1.Unmarshal(pkInfo.Algorithm.Parameters.FullBytes, namedCurveOID) + if err != nil { + return nil, fmt.Errorf("x509: failed to parse ECDSA parameters as named curve: %w", err) + } + + if !namedCurveOID.Equal(oidNamedCurveSECP256K1) { + return nil, errors.New("x509: unsupported elliptic curve") + } + + asn1Data := pkInfo.PublicKey.RightAlign() + if asn1Data[0] != 4 { // uncompressed form + return nil, errors.New("x509: only uncompressed keys are supported") + } + + // Decode the public key point + curve := secp256k1.S256() + x, y := curve.Unmarshal(pkInfo.PublicKey.Bytes) + if x == nil || y == nil { + return nil, fmt.Errorf("invalid public key") + } + + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil +} + +// ConvertCompactRecoverableSignatureToDER converts an Ethereum signature in +// [R || S || V] format to DER format. +func convertCompactRecoverableSignatureToDER(sig []byte) ([]byte, error) { + // Ensure the signature is the correct length (65 bytes: R=32, S=32, V=1) + if len(sig) != 65 { + return nil, fmt.Errorf("invalid signature length: expected 65 bytes, got %d", len(sig)) + } + + // Extract R and S components + r := new(big.Int).SetBytes(sig[:32]) // First 32 bytes + s := new(big.Int).SetBytes(sig[32:64]) // Next 32 bytes + + // Create a struct representing the DER sequence + derSignature := struct { + R *big.Int + S *big.Int + }{ + R: r, + S: s, + } + + // Marshal the struct into DER + derBytes, err := asn1.Marshal(derSignature) + if err != nil { + return nil, fmt.Errorf("failed to marshal DER: %w", err) + } + + return derBytes, nil +} diff --git a/op-signer/service/provider/utils_test.go b/op-signer/service/provider/utils_test.go new file mode 100644 index 00000000..ab9c05b9 --- /dev/null +++ b/op-signer/service/provider/utils_test.go @@ -0,0 +1,88 @@ +package provider + +import ( + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMarshalAndUnmarshalECDSAPublicKey tests that a secp256k1 public key can be +// marshaled and unmarshaled correctly. +func TestMarshalAndUnmarshalECDSAPublicKey(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + publicKeyDER, err := marshalECDSAPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + + unmarshalledPublicKey, err := unmarshalECDSAPublicKey(publicKeyDER) + require.NoError(t, err) + + assert.Equal(t, privateKey.PublicKey, *unmarshalledPublicKey) +} + +// TestMarshalAndParseECDSAPublicKey tests that a secp256k1 public key can be +// marshaled and parsed using the existing x509ParseECDSAPublicKey function. This +// ensures that the public key is in the correct format for the AWS KMS client. +func TestMarshalAndParseECDSAPublicKey(t *testing.T) { + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + publicKeyDER, err := marshalECDSAPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + + _, err = x509ParseECDSAPublicKey(publicKeyDER) + require.NoError(t, err) +} + +// TestConvertEthereumSignatureToDER tests that an Ethereum signature can be +// converted to DER format. This test also ensures that our DER encoding is +// correct by converting from DER back to a compact sig using the existing +// convertToCompactSignature function. +func TestConvertEthereumSignatureToDER(t *testing.T) { + privateKey, _ := crypto.GenerateKey() + + // Sign a message using the private key so we can later verify the signature + message := []byte("test message to sign") + digest := crypto.Keccak256Hash(message).Bytes() + + signature, err := crypto.Sign(digest, privateKey) + require.NoError(t, err) + + derSignature, err := convertCompactRecoverableSignatureToDER(signature) + require.NoError(t, err) + + convertedSignature, err := convertToCompactSignature(derSignature) + require.NoError(t, err) + assert.Equal(t, signature[:len(signature)-1], convertedSignature) +} + +// TestConvertToEthereumSignatureRecoverable tests that an Ethereum signature +// can be converted to a recoverable signature. This test also ensures that +// our DER encoding is correct by converting from DER back to a compact +// verifiable sig using our convertToCompactRecoverableSignature function. +func TestConvertToEthereumSignatureRecoverable(t *testing.T) { + privateKey, _ := crypto.GenerateKey() + + // Sign a message using the private key so we can later verify the signature + message := []byte("test message to sign") + digest := crypto.Keccak256Hash(message).Bytes() + + signature, err := crypto.Sign(digest, privateKey) + require.NoError(t, err) + + derSignature, err := convertCompactRecoverableSignatureToDER(signature) + require.NoError(t, err) + + publicKeyDER, err := marshalECDSAPublicKey(&privateKey.PublicKey) + require.NoError(t, err) + + publicKeyBytes, err := x509ParseECDSAPublicKey(publicKeyDER) + require.NoError(t, err) + + convertedSignature, err := convertToCompactRecoverableSignature(derSignature, digest, publicKeyBytes) + require.NoError(t, err) + assert.Equal(t, signature, convertedSignature) +} diff --git a/op-signer/service/service.go b/op-signer/service/service.go index fd4951ee..0c3b47fd 100644 --- a/op-signer/service/service.go +++ b/op-signer/service/service.go @@ -33,8 +33,12 @@ type OpsignerSerivce struct { provider provider.SignatureProvider } -func NewSignerService(logger log.Logger, config SignerServiceConfig) *SignerService { - return NewSignerServiceWithProvider(logger, config, provider.NewCloudKMSSignatureProvider(logger)) +func NewSignerService(logger log.Logger, config SignerServiceConfig) (*SignerService, error) { + provider, err := provider.NewSignatureProvider(logger, config.ProviderType) + if err != nil { + return nil, fmt.Errorf("failed to create signature provider: %w", err) + } + return NewSignerServiceWithProvider(logger, config, provider), nil } func NewSignerServiceWithProvider(