diff --git a/keystore/coreshim/csa.go b/keystore/coreshim/csa.go new file mode 100644 index 000000000..544337141 --- /dev/null +++ b/keystore/coreshim/csa.go @@ -0,0 +1,139 @@ +// `coreshim` provides utilities to generate keys that are compatible with the core node +// and can be imported by it. +package coreshim + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + gethkeystore "github.com/ethereum/go-ethereum/accounts/keystore" + "google.golang.org/protobuf/proto" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/smartcontractkit/chainlink-common/keystore/serialization" +) + +var ( + ErrInvalidExportFormat = errors.New("invalid export format") +) + +const ( + KeyTypeCSA = "csa" + keyNameDefault = "default" + exportFormat = "github.com/smartcontractkit/chainlink-common/keystore/coreshim" +) + +type Keystore struct { + keystore.Keystore +} + +type Key struct { + KeyName string + Data json.RawMessage +} + +type Envelope struct { + Type string + Keys []keystore.ExportKeyResponse + ExportFormat string +} + +func NewKeystore(ks keystore.Keystore) *Keystore { + return &Keystore{ + Keystore: ks, + } +} + +// decryptKey decrypts an encrypted key using the provided password and returns the deserialized key. +func decryptKey(encryptedData []byte, password string) (*serialization.Key, error) { + encData := gethkeystore.CryptoJSON{} + err := json.Unmarshal(encryptedData, &encData) + if err != nil { + return nil, fmt.Errorf("could not unmarshal key material into CryptoJSON: %w", err) + } + + decData, err := gethkeystore.DecryptDataV3(encData, password) + if err != nil { + return nil, fmt.Errorf("could not decrypt data: %w", err) + } + + keypb := &serialization.Key{} + err = proto.Unmarshal(decData, keypb) + if err != nil { + return nil, fmt.Errorf("could not unmarshal key into serialization.Key: %w", err) + } + + return keypb, nil +} + +func (ks *Keystore) GenerateEncryptedCSAKey(ctx context.Context, password string) ([]byte, error) { + path := keystore.NewKeyPath(KeyTypeCSA, keyNameDefault) + _, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + { + KeyName: path.String(), + KeyType: keystore.Ed25519, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate exportable key: %w", err) + } + + er, err := ks.ExportKeys(ctx, keystore.ExportKeysRequest{ + Keys: []keystore.ExportKeyParam{ + { + KeyName: path.String(), + Enc: keystore.EncryptionParams{ + Password: password, + ScryptParams: keystore.DefaultScryptParams, + }, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to export key: %w", err) + } + + envelope := Envelope{ + Type: KeyTypeCSA, + Keys: er.Keys, + ExportFormat: exportFormat, + } + + data, err := json.Marshal(&envelope) + if err != nil { + return nil, fmt.Errorf("failed to marshal envelope: %w", err) + } + + return data, nil +} + +func FromEncryptedCSAKey(data []byte, password string) ([]byte, error) { + envelope := Envelope{} + err := json.Unmarshal(data, &envelope) + if err != nil { + return nil, fmt.Errorf("could not unmarshal import data into envelope: %w", err) + } + + if envelope.ExportFormat != exportFormat { + return nil, fmt.Errorf("invalid export format: %w", ErrInvalidExportFormat) + } + + if envelope.Type != KeyTypeCSA { + return nil, fmt.Errorf("invalid key type: expected %s, got %s", KeyTypeCSA, envelope.Type) + } + + if len(envelope.Keys) != 1 { + return nil, fmt.Errorf("expected exactly one key in envelope, got %d", len(envelope.Keys)) + } + + keypb, err := decryptKey(envelope.Keys[0].Data, password) + if err != nil { + return nil, err + } + + return keypb.PrivateKey, nil +} diff --git a/keystore/coreshim/csa_test.go b/keystore/coreshim/csa_test.go new file mode 100644 index 000000000..cc1a66baa --- /dev/null +++ b/keystore/coreshim/csa_test.go @@ -0,0 +1,78 @@ +package coreshim + +import ( + "crypto/ed25519" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/keystore" +) + +func TestCSAKeyRoundTrip(t *testing.T) { + t.Parallel() + ctx := t.Context() + password := "test-password" + + st := keystore.NewMemoryStorage() + ks, err := keystore.LoadKeystore(ctx, st, "test", + keystore.WithScryptParams(keystore.FastScryptParams), + ) + require.NoError(t, err) + + coreshimKs := NewKeystore(ks) + + encryptedKey, err := coreshimKs.GenerateEncryptedCSAKey(ctx, password) + require.NoError(t, err) + require.NotEmpty(t, encryptedKey) + + csaKeyPath := keystore.NewKeyPath(KeyTypeCSA, keyNameDefault) + getKeysResp, err := ks.GetKeys(ctx, keystore.GetKeysRequest{ + KeyNames: []string{csaKeyPath.String()}, + }) + require.NoError(t, err) + require.Len(t, getKeysResp.Keys, 1) + + storedPublicKey := getKeysResp.Keys[0].KeyInfo.PublicKey + require.NotEmpty(t, storedPublicKey) + + privateKey, err := FromEncryptedCSAKey(encryptedKey, password) + require.NoError(t, err) + require.NotEmpty(t, privateKey) + + require.Equal(t, 64, len(privateKey)) + + derivedPublicKey := ed25519.PrivateKey(privateKey).Public().(ed25519.PublicKey) + require.Equal(t, storedPublicKey, []byte(derivedPublicKey)) +} + +func TestCSAKeyImportWithWrongPassword(t *testing.T) { + t.Parallel() + ctx := t.Context() + password := "test-password" + wrongPassword := "wrong-password" + + st := keystore.NewMemoryStorage() + ks, err := keystore.LoadKeystore(ctx, st, "test", + keystore.WithScryptParams(keystore.FastScryptParams), + ) + require.NoError(t, err) + + coreshimKs := NewKeystore(ks) + + encryptedKey, err := coreshimKs.GenerateEncryptedCSAKey(ctx, password) + require.NoError(t, err) + require.NotNil(t, encryptedKey) + + _, err = FromEncryptedCSAKey(encryptedKey, wrongPassword) + require.Error(t, err) + require.Contains(t, err.Error(), "could not decrypt data") +} + +func TestCSAKeyImportInvalidFormat(t *testing.T) { + t.Parallel() + + _, err := FromEncryptedCSAKey([]byte("invalid json"), "password") + require.Error(t, err) + require.Contains(t, err.Error(), "could not unmarshal import data") +} diff --git a/keystore/coreshim/ocrkeybundle.go b/keystore/coreshim/ocrkeybundle.go new file mode 100644 index 000000000..b1aea56b5 --- /dev/null +++ b/keystore/coreshim/ocrkeybundle.go @@ -0,0 +1,142 @@ +package coreshim + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/smartcontractkit/chainlink-common/keystore/ocr2offchain" +) + +type ChainType string + +const ( + // Must match ChainType in core. + chainTypeEVM ChainType = "evm" +) + +const ( + KeyTypeOCR = "OCR" + PrefixOCR2Onchain = "ocr2_onchain" +) + +type OCRKeyBundle struct { + ChainType ChainType + OffchainSigningKey []byte + OffchainEncryptionKey []byte + OnchainSigningKey []byte +} + +func (ks *Keystore) GenerateEncryptedOCRKeyBundle(ctx context.Context, chainType ChainType, password string) ([]byte, error) { + _, err := ocr2offchain.CreateOCR2OffchainKeyring(ctx, ks.Keystore, keyNameDefault) + if err != nil { + return nil, err + } + + var onchainKeyPath keystore.KeyPath + switch chainType { + case chainTypeEVM: + path := keystore.NewKeyPath(PrefixOCR2Onchain, keyNameDefault, string(chainType)) + _, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + { + KeyName: path.String(), + KeyType: keystore.ECDSA_S256, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate exportable key: %w", err) + } + + onchainKeyPath = path + default: + return nil, fmt.Errorf("unsupported chain type: %s", chainType) + } + + er, err := ks.ExportKeys(ctx, keystore.ExportKeysRequest{ + Keys: []keystore.ExportKeyParam{ + { + KeyName: keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, keyNameDefault, ocr2offchain.OCR2OffchainSigning).String(), + Enc: keystore.EncryptionParams{ + Password: password, + ScryptParams: keystore.DefaultScryptParams, + }, + }, + { + KeyName: keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, keyNameDefault, ocr2offchain.OCR2OffchainEncryption).String(), + Enc: keystore.EncryptionParams{ + Password: password, + ScryptParams: keystore.DefaultScryptParams, + }, + }, + { + KeyName: onchainKeyPath.String(), + Enc: keystore.EncryptionParams{ + Password: password, + ScryptParams: keystore.DefaultScryptParams, + }, + }, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to export OCR key bundle: %w", err) + } + + envelope := Envelope{ + Type: KeyTypeOCR, + Keys: er.Keys, + ExportFormat: exportFormat, + } + + data, err := json.Marshal(&envelope) + if err != nil { + return nil, fmt.Errorf("failed to marshal OCR key bundle envelope: %w", err) + } + + return data, nil +} + +func FromEncryptedOCRKeyBundle(data []byte, password string) (*OCRKeyBundle, error) { + envelope := Envelope{} + err := json.Unmarshal(data, &envelope) + if err != nil { + return nil, fmt.Errorf("could not unmarshal import data into envelope: %w", err) + } + + if envelope.ExportFormat != exportFormat { + return nil, fmt.Errorf("invalid export format: %w", ErrInvalidExportFormat) + } + + if envelope.Type != KeyTypeOCR { + return nil, fmt.Errorf("invalid key type: expected %s, got %s", KeyTypeOCR, envelope.Type) + } + + if len(envelope.Keys) != 3 { + return nil, fmt.Errorf("expected exactly three keys in envelope, got %d", len(envelope.Keys)) + } + + bundle := &OCRKeyBundle{} + + for _, key := range envelope.Keys { + keypb, err := decryptKey(key.Data, password) + if err != nil { + return nil, err + } + + if strings.Contains(key.KeyName, ocr2offchain.OCR2OffchainSigning) { + bundle.OffchainSigningKey = keypb.PrivateKey + } else if strings.Contains(key.KeyName, ocr2offchain.OCR2OffchainEncryption) { + bundle.OffchainEncryptionKey = keypb.PrivateKey + } else if strings.Contains(key.KeyName, PrefixOCR2Onchain) { + bundle.OnchainSigningKey = keypb.PrivateKey + // Extract chain type from the key path + keyPath := keystore.NewKeyPathFromString(key.KeyName) + bundle.ChainType = ChainType(strings.ToLower(keyPath.Base())) + } + } + + return bundle, nil +} diff --git a/keystore/coreshim/ocrkeybundle_test.go b/keystore/coreshim/ocrkeybundle_test.go new file mode 100644 index 000000000..1f8e42854 --- /dev/null +++ b/keystore/coreshim/ocrkeybundle_test.go @@ -0,0 +1,132 @@ +package coreshim + +import ( + "crypto/ed25519" + "testing" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/curve25519" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/smartcontractkit/chainlink-common/keystore/ocr2offchain" +) + +func TestOCRKeyBundleRoundTrip(t *testing.T) { + t.Parallel() + ctx := t.Context() + password := "test-password" + chainType := ChainType("EVM") + + st := keystore.NewMemoryStorage() + ks, err := keystore.LoadKeystore(ctx, st, "test", + keystore.WithScryptParams(keystore.FastScryptParams), + ) + require.NoError(t, err) + + coreshimKs := NewKeystore(ks) + + encryptedBundle, err := coreshimKs.GenerateEncryptedOCRKeyBundle(ctx, chainType, password) + require.NoError(t, err) + require.NotEmpty(t, encryptedBundle) + + signingKeyPath := keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, keyNameDefault, ocr2offchain.OCR2OffchainSigning) + encryptionKeyPath := keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, keyNameDefault, ocr2offchain.OCR2OffchainEncryption) + onchainKeyPath := keystore.NewKeyPath(PrefixOCR2Onchain, keyNameDefault, string(chainType)) + + getKeysResp, err := ks.GetKeys(ctx, keystore.GetKeysRequest{ + KeyNames: []string{ + signingKeyPath.String(), + encryptionKeyPath.String(), + onchainKeyPath.String(), + }, + }) + require.NoError(t, err) + require.Len(t, getKeysResp.Keys, 3) + + var storedSigningPubKey, storedEncryptionPubKey, storedOnchainPubKey []byte + for _, key := range getKeysResp.Keys { + switch key.KeyInfo.Name { + case signingKeyPath.String(): + storedSigningPubKey = key.KeyInfo.PublicKey + case encryptionKeyPath.String(): + storedEncryptionPubKey = key.KeyInfo.PublicKey + case onchainKeyPath.String(): + storedOnchainPubKey = key.KeyInfo.PublicKey + } + } + + require.NotEmpty(t, storedSigningPubKey) + require.NotEmpty(t, storedEncryptionPubKey) + require.NotEmpty(t, storedOnchainPubKey) + + bundle, err := FromEncryptedOCRKeyBundle(encryptedBundle, password) + require.NoError(t, err) + require.NotNil(t, bundle) + + require.NotEmpty(t, bundle.OffchainSigningKey) + derivedSigningPubKey := ed25519.PrivateKey(bundle.OffchainSigningKey).Public().(ed25519.PublicKey) + require.Equal(t, storedSigningPubKey, []byte(derivedSigningPubKey)) + + var derivedEncryptionPubKey [32]byte + curve25519.ScalarBaseMult(&derivedEncryptionPubKey, (*[32]byte)(bundle.OffchainEncryptionKey)) + require.Equal(t, storedEncryptionPubKey, derivedEncryptionPubKey[:]) + + onchainPrivKey, err := crypto.ToECDSA(bundle.OnchainSigningKey) + require.NoError(t, err) + derivedOnchainPubKey := crypto.FromECDSAPub(&onchainPrivKey.PublicKey) + require.Equal(t, storedOnchainPubKey, derivedOnchainPubKey) +} + +func TestOCRKeyBundleImportWithWrongPassword(t *testing.T) { + t.Parallel() + ctx := t.Context() + password := "test-password" + wrongPassword := "wrong-password" + chainType := ChainType("EVM") + + st := keystore.NewMemoryStorage() + ks, err := keystore.LoadKeystore(ctx, st, "test", + keystore.WithScryptParams(keystore.FastScryptParams), + ) + require.NoError(t, err) + + coreshimKs := NewKeystore(ks) + + encryptedBundle, err := coreshimKs.GenerateEncryptedOCRKeyBundle(ctx, chainType, password) + require.NoError(t, err) + require.NotNil(t, encryptedBundle) + + _, err = FromEncryptedOCRKeyBundle(encryptedBundle, wrongPassword) + require.Error(t, err) + require.Contains(t, err.Error(), "could not decrypt data") +} + +func TestOCRKeyBundleImportInvalidFormat(t *testing.T) { + t.Parallel() + + _, err := FromEncryptedOCRKeyBundle([]byte("invalid json"), "password") + require.Error(t, err) + require.Contains(t, err.Error(), "could not unmarshal import data") +} + +func TestOCRKeyBundleInvalidKeyType(t *testing.T) { + t.Parallel() + ctx := t.Context() + password := "test-password" + + st := keystore.NewMemoryStorage() + ks, err := keystore.LoadKeystore(ctx, st, "test", + keystore.WithScryptParams(keystore.FastScryptParams), + ) + require.NoError(t, err) + + coreshimKs := NewKeystore(ks) + + encryptedCSAKey, err := coreshimKs.GenerateEncryptedCSAKey(ctx, password) + require.NoError(t, err) + + _, err = FromEncryptedOCRKeyBundle(encryptedCSAKey, password) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid key type") +}