From 095bfee74fcf1225b281ebe180ef5a2ceee799ee Mon Sep 17 00:00:00 2001 From: Cedric Cordenier Date: Wed, 21 Jan 2026 14:01:52 +0000 Subject: [PATCH 1/2] Coreshim WIP --- keystore/coreshim/csa.go | 139 +++++++++++++++++++++++++ keystore/coreshim/csa_test.go | 78 ++++++++++++++ keystore/coreshim/ocrkeybundle.go | 133 +++++++++++++++++++++++ keystore/coreshim/ocrkeybundle_test.go | 133 +++++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 keystore/coreshim/csa.go create mode 100644 keystore/coreshim/csa_test.go create mode 100644 keystore/coreshim/ocrkeybundle.go create mode 100644 keystore/coreshim/ocrkeybundle_test.go 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..d4e57c46a --- /dev/null +++ b/keystore/coreshim/ocrkeybundle.go @@ -0,0 +1,133 @@ +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 ( + KeyTypeOCR = "OCR" + PrefixOCR2Onchain = "ocr2_onchain" +) + +type OCRKeyBundle struct { + 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 "EVM": + 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 + } + } + + return bundle, nil +} diff --git a/keystore/coreshim/ocrkeybundle_test.go b/keystore/coreshim/ocrkeybundle_test.go new file mode 100644 index 000000000..227502f48 --- /dev/null +++ b/keystore/coreshim/ocrkeybundle_test.go @@ -0,0 +1,133 @@ +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) + + // Generate a CSA key and try to import it as an OCR key bundle + 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 8054dbab24f2f0f04fca4fb33a943894487b6028 Mon Sep 17 00:00:00 2001 From: Cedric Cordenier Date: Thu, 22 Jan 2026 12:51:57 +0000 Subject: [PATCH 2/2] Add ChainType field to OCRKeyBundle and extract from key path - Added ChainType field to OCRKeyBundle struct - Extract chain type from onchain key path using NewKeyPathFromString - Use chainTypeEVM constant instead of hardcoded "EVM" string - Parse key name and extract chain type from path in FromEncryptedOCRKeyBundle --- keystore/coreshim/ocrkeybundle.go | 11 ++++++++++- keystore/coreshim/ocrkeybundle_test.go | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/keystore/coreshim/ocrkeybundle.go b/keystore/coreshim/ocrkeybundle.go index d4e57c46a..b1aea56b5 100644 --- a/keystore/coreshim/ocrkeybundle.go +++ b/keystore/coreshim/ocrkeybundle.go @@ -12,12 +12,18 @@ import ( 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 @@ -31,7 +37,7 @@ func (ks *Keystore) GenerateEncryptedOCRKeyBundle(ctx context.Context, chainType var onchainKeyPath keystore.KeyPath switch chainType { - case "EVM": + case chainTypeEVM: path := keystore.NewKeyPath(PrefixOCR2Onchain, keyNameDefault, string(chainType)) _, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ Keys: []keystore.CreateKeyRequest{ @@ -126,6 +132,9 @@ func FromEncryptedOCRKeyBundle(data []byte, password string) (*OCRKeyBundle, err 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())) } } diff --git a/keystore/coreshim/ocrkeybundle_test.go b/keystore/coreshim/ocrkeybundle_test.go index 227502f48..1f8e42854 100644 --- a/keystore/coreshim/ocrkeybundle_test.go +++ b/keystore/coreshim/ocrkeybundle_test.go @@ -123,7 +123,6 @@ func TestOCRKeyBundleInvalidKeyType(t *testing.T) { coreshimKs := NewKeystore(ks) - // Generate a CSA key and try to import it as an OCR key bundle encryptedCSAKey, err := coreshimKs.GenerateEncryptedCSAKey(ctx, password) require.NoError(t, err)