From 2ad9c86ecb2cbd4bb540b101f3a8827efb48cafd Mon Sep 17 00:00:00 2001 From: Cedric Cordenier Date: Wed, 21 Jan 2026 14:01:52 +0000 Subject: [PATCH 1/2] Add corekeys package with keystorelib implementations for CSA and OCRKeyBundle --- keystore/corekeys/csa.go | 134 +++++++++++++++++++++++ keystore/corekeys/csa_test.go | 78 ++++++++++++++ keystore/corekeys/ocrkeybundle.go | 142 +++++++++++++++++++++++++ keystore/corekeys/ocrkeybundle_test.go | 132 +++++++++++++++++++++++ 4 files changed, 486 insertions(+) create mode 100644 keystore/corekeys/csa.go create mode 100644 keystore/corekeys/csa_test.go create mode 100644 keystore/corekeys/ocrkeybundle.go create mode 100644 keystore/corekeys/ocrkeybundle_test.go diff --git a/keystore/corekeys/csa.go b/keystore/corekeys/csa.go new file mode 100644 index 000000000..2df114dde --- /dev/null +++ b/keystore/corekeys/csa.go @@ -0,0 +1,134 @@ +// `corekeys` provides utilities to generate keys that are compatible with the core node +// and can be imported by it. +package corekeys + +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 ( + TypeCSA = "csa" + nameDefault = "default" + exportFormat = "github.com/smartcontractkit/chainlink-common/keystore/corekeys" +) + +type Store struct { + keystore.Keystore +} + +type Envelope struct { + Type string + Keys []keystore.ExportKeyResponse + ExportFormat string +} + +func NewStore(ks keystore.Keystore) *Store { + return &Store{ + 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 *Store) GenerateEncryptedCSAKey(ctx context.Context, password string) ([]byte, error) { + path := keystore.NewKeyPath(TypeCSA, nameDefault) + _, 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: TypeCSA, + 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 != TypeCSA { + return nil, fmt.Errorf("invalid key type: expected %s, got %s", TypeCSA, 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/corekeys/csa_test.go b/keystore/corekeys/csa_test.go new file mode 100644 index 000000000..2ab94720c --- /dev/null +++ b/keystore/corekeys/csa_test.go @@ -0,0 +1,78 @@ +package corekeys + +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 := NewStore(ks) + + encryptedKey, err := coreshimKs.GenerateEncryptedCSAKey(ctx, password) + require.NoError(t, err) + require.NotEmpty(t, encryptedKey) + + csaKeyPath := keystore.NewKeyPath(TypeCSA, nameDefault) + 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.Len(t, privateKey, 64) + + 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 := NewStore(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/corekeys/ocrkeybundle.go b/keystore/corekeys/ocrkeybundle.go new file mode 100644 index 000000000..811f8f6b5 --- /dev/null +++ b/keystore/corekeys/ocrkeybundle.go @@ -0,0 +1,142 @@ +package corekeys + +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 ( + TypeOCR = "ocr" + PrefixOCR2Onchain = "ocr2_onchain" +) + +type OCRKeyBundle struct { + ChainType ChainType + OffchainSigningKey []byte + OffchainEncryptionKey []byte + OnchainSigningKey []byte +} + +func (ks *Store) GenerateEncryptedOCRKeyBundle(ctx context.Context, chainType ChainType, password string) ([]byte, error) { + _, err := ocr2offchain.CreateOCR2OffchainKeyring(ctx, ks.Keystore, nameDefault) + if err != nil { + return nil, err + } + + var onchainKeyPath keystore.KeyPath + switch chainType { + case chainTypeEVM: + path := keystore.NewKeyPath(PrefixOCR2Onchain, nameDefault, string(chainType)) + _, ierr := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + { + KeyName: path.String(), + KeyType: keystore.ECDSA_S256, + }, + }, + }) + if ierr != nil { + return nil, fmt.Errorf("failed to generate exportable key: %w", ierr) + } + + 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, nameDefault, ocr2offchain.OCR2OffchainSigning).String(), + Enc: keystore.EncryptionParams{ + Password: password, + ScryptParams: keystore.DefaultScryptParams, + }, + }, + { + KeyName: keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, nameDefault, 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: TypeOCR, + 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 != TypeOCR { + return nil, fmt.Errorf("invalid key type: expected %s, got %s", TypeOCR, 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/corekeys/ocrkeybundle_test.go b/keystore/corekeys/ocrkeybundle_test.go new file mode 100644 index 000000000..2052768ac --- /dev/null +++ b/keystore/corekeys/ocrkeybundle_test.go @@ -0,0 +1,132 @@ +package corekeys + +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 := NewStore(ks) + + encryptedBundle, err := coreshimKs.GenerateEncryptedOCRKeyBundle(ctx, chainType, password) + require.NoError(t, err) + require.NotEmpty(t, encryptedBundle) + + signingKeyPath := keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, nameDefault, ocr2offchain.OCR2OffchainSigning) + encryptionKeyPath := keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, nameDefault, ocr2offchain.OCR2OffchainEncryption) + onchainKeyPath := keystore.NewKeyPath(PrefixOCR2Onchain, nameDefault, 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 := NewStore(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 := NewStore(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") +} From 1488eae7b15f55fef1f9112be1dae600f2e084b5 Mon Sep 17 00:00:00 2001 From: Cedric Cordenier Date: Wed, 21 Jan 2026 14:01:52 +0000 Subject: [PATCH 2/2] Add corekeys package with keystorelib implementations for CSA and OCRKeyBundle --- keystore/corekeys/ocrkeybundle.go | 18 ++++++------------ keystore/corekeys/ocrkeybundle_test.go | 7 ++++--- keystore/go.mod | 2 +- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/keystore/corekeys/ocrkeybundle.go b/keystore/corekeys/ocrkeybundle.go index 811f8f6b5..060f85245 100644 --- a/keystore/corekeys/ocrkeybundle.go +++ b/keystore/corekeys/ocrkeybundle.go @@ -6,30 +6,24 @@ import ( "fmt" "strings" + chainselectors "github.com/smartcontractkit/chain-selectors" "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 ( TypeOCR = "ocr" PrefixOCR2Onchain = "ocr2_onchain" ) type OCRKeyBundle struct { - ChainType ChainType + ChainType string OffchainSigningKey []byte OffchainEncryptionKey []byte OnchainSigningKey []byte } -func (ks *Store) GenerateEncryptedOCRKeyBundle(ctx context.Context, chainType ChainType, password string) ([]byte, error) { +func (ks *Store) GenerateEncryptedOCRKeyBundle(ctx context.Context, chainType string, password string) ([]byte, error) { _, err := ocr2offchain.CreateOCR2OffchainKeyring(ctx, ks.Keystore, nameDefault) if err != nil { return nil, err @@ -37,8 +31,8 @@ func (ks *Store) GenerateEncryptedOCRKeyBundle(ctx context.Context, chainType Ch var onchainKeyPath keystore.KeyPath switch chainType { - case chainTypeEVM: - path := keystore.NewKeyPath(PrefixOCR2Onchain, nameDefault, string(chainType)) + case chainselectors.FamilyEVM: + path := keystore.NewKeyPath(PrefixOCR2Onchain, nameDefault, chainType) _, ierr := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ Keys: []keystore.CreateKeyRequest{ { @@ -134,7 +128,7 @@ func FromEncryptedOCRKeyBundle(data []byte, password string) (*OCRKeyBundle, err bundle.OnchainSigningKey = keypb.PrivateKey // Extract chain type from the key path keyPath := keystore.NewKeyPathFromString(key.KeyName) - bundle.ChainType = ChainType(strings.ToLower(keyPath.Base())) + bundle.ChainType = strings.ToLower(keyPath.Base()) } } diff --git a/keystore/corekeys/ocrkeybundle_test.go b/keystore/corekeys/ocrkeybundle_test.go index 2052768ac..c6ee5a028 100644 --- a/keystore/corekeys/ocrkeybundle_test.go +++ b/keystore/corekeys/ocrkeybundle_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/curve25519" + chainselectors "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/chainlink-common/keystore" "github.com/smartcontractkit/chainlink-common/keystore/ocr2offchain" ) @@ -16,7 +17,7 @@ func TestOCRKeyBundleRoundTrip(t *testing.T) { t.Parallel() ctx := t.Context() password := "test-password" - chainType := ChainType("evm") + chainType := chainselectors.FamilyEVM st := keystore.NewMemoryStorage() ks, err := keystore.LoadKeystore(ctx, st, "test", @@ -32,7 +33,7 @@ func TestOCRKeyBundleRoundTrip(t *testing.T) { signingKeyPath := keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, nameDefault, ocr2offchain.OCR2OffchainSigning) encryptionKeyPath := keystore.NewKeyPath(ocr2offchain.PrefixOCR2Offchain, nameDefault, ocr2offchain.OCR2OffchainEncryption) - onchainKeyPath := keystore.NewKeyPath(PrefixOCR2Onchain, nameDefault, string(chainType)) + onchainKeyPath := keystore.NewKeyPath(PrefixOCR2Onchain, nameDefault, chainType) getKeysResp, err := ks.GetKeys(ctx, keystore.GetKeysRequest{ KeyNames: []string{ @@ -83,7 +84,7 @@ func TestOCRKeyBundleImportWithWrongPassword(t *testing.T) { ctx := t.Context() password := "test-password" wrongPassword := "wrong-password" - chainType := ChainType("evm") + chainType := chainselectors.FamilyEVM st := keystore.NewMemoryStorage() ks, err := keystore.LoadKeystore(ctx, st, "test", diff --git a/keystore/go.mod b/keystore/go.mod index c9af6285c..e1ed3ceaf 100644 --- a/keystore/go.mod +++ b/keystore/go.mod @@ -9,6 +9,7 @@ require ( github.com/ethereum/go-ethereum v1.16.2 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 + github.com/smartcontractkit/chain-selectors v1.0.67 github.com/smartcontractkit/chainlink-common v0.9.6-0.20251107154219-ec6d8370ebbf github.com/smartcontractkit/libocr v0.0.0-20250912173940-f3ab0246e23d github.com/spf13/cobra v1.8.1 @@ -96,7 +97,6 @@ require ( github.com/scylladb/go-reflectx v1.0.1 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/smartcontractkit/chain-selectors v1.0.67 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 // indirect github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20251124151448-0448aefdaab9 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect