diff --git a/keystore/README.md b/keystore/README.md index eeab23ce21..14924d70da 100644 --- a/keystore/README.md +++ b/keystore/README.md @@ -1,3 +1,5 @@ +WARNING: In development do not use in production. + # Keystore Design principles: - Use structs for typed extensibility of the interfaces. Easy diff --git a/keystore/admin.go b/keystore/admin.go index e87d8ce9c3..d71db52824 100644 --- a/keystore/admin.go +++ b/keystore/admin.go @@ -2,6 +2,7 @@ package keystore import ( "context" + "crypto/ecdh" "crypto/ecdsa" "crypto/ed25519" "crypto/rand" @@ -128,6 +129,8 @@ func ValidKeyName(name string) error { return nil } +// CreateKeys creates multiple keys in a single operation. The response preserves the order of the request. +// It's atomic - either all keys are created or none are created. func (ks *keystore) CreateKeys(ctx context.Context, req CreateKeysRequest) (CreateKeysResponse, error) { ks.mu.Lock() defer ks.mu.Unlock() @@ -152,16 +155,19 @@ func (ks *keystore) CreateKeys(ctx context.Context, req CreateKeysRequest) (Crea return CreateKeysResponse{}, fmt.Errorf("failed to get public key from private key: %w", err) } ksCopy[keyReq.KeyName] = newKey(keyReq.KeyType, internal.NewRaw(privateKey), publicKey, time.Now(), []byte{}) - case EcdsaSecp256k1: + case ECDSA_S256: privateKey, err := ecdsa.GenerateKey(crypto.S256(), rand.Reader) if err != nil { - return CreateKeysResponse{}, fmt.Errorf("failed to generate EcdsaSecp256k1 key: %w", err) + return CreateKeysResponse{}, fmt.Errorf("failed to generate ECDSA_S256 key: %w", err) } - publicKey, err := publicKeyFromPrivateKey(internal.NewRaw(privateKey.D.Bytes()), keyReq.KeyType) + // Must copy the private key into 32 byte slice because leading zeros are stripped. + privateKeyBytes := make([]byte, 32) + copy(privateKeyBytes, privateKey.D.Bytes()) + publicKey, err := publicKeyFromPrivateKey(internal.NewRaw(privateKeyBytes), keyReq.KeyType) if err != nil { return CreateKeysResponse{}, fmt.Errorf("failed to get public key from private key: %w", err) } - ksCopy[keyReq.KeyName] = newKey(keyReq.KeyType, internal.NewRaw(privateKey.D.Bytes()), publicKey, time.Now(), []byte{}) + ksCopy[keyReq.KeyName] = newKey(keyReq.KeyType, internal.NewRaw(privateKeyBytes), publicKey, time.Now(), []byte{}) case X25519: privateKey := [curve25519.ScalarSize]byte{} _, err := rand.Read(privateKey[:]) @@ -173,6 +179,16 @@ func (ks *keystore) CreateKeys(ctx context.Context, req CreateKeysRequest) (Crea return CreateKeysResponse{}, fmt.Errorf("failed to get public key from private key: %w", err) } ksCopy[keyReq.KeyName] = newKey(keyReq.KeyType, internal.NewRaw(privateKey[:]), publicKey, time.Now(), []byte{}) + case ECDH_P256: + privateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return CreateKeysResponse{}, fmt.Errorf("failed to generate ECDH_P256 key: %w", err) + } + publicKey, err := publicKeyFromPrivateKey(internal.NewRaw(privateKey.Bytes()), keyReq.KeyType) + if err != nil { + return CreateKeysResponse{}, fmt.Errorf("failed to get public key from private key: %w", err) + } + ksCopy[keyReq.KeyName] = newKey(keyReq.KeyType, internal.NewRaw(privateKey.Bytes()), publicKey, time.Now(), []byte{}) default: return CreateKeysResponse{}, fmt.Errorf("%w: %s", ErrUnsupportedKeyType, keyReq.KeyType) } diff --git a/keystore/admin_test.go b/keystore/admin_test.go index 27d7ad9cc9..02a7c2321b 100644 --- a/keystore/admin_test.go +++ b/keystore/admin_test.go @@ -1,7 +1,6 @@ package keystore_test import ( - "context" "fmt" "sort" "sync" @@ -14,7 +13,7 @@ import ( ) func TestKeystore_CreateDeleteReadKeys(t *testing.T) { - ctx := context.Background() + ctx := t.Context() type key struct { name string metadata []byte @@ -161,7 +160,7 @@ func TestKeystore_CreateDeleteReadKeys(t *testing.T) { func TestKeystore_ConcurrentCreateAndRead(t *testing.T) { t.Parallel() - ctx := context.Background() + ctx := t.Context() st := storage.NewMemoryStorage() ks, err := keystore.LoadKeystore(ctx, st, keystore.EncryptionParams{ Password: "test", diff --git a/keystore/encryptor.go b/keystore/encryptor.go index 590f439bff..891421c9c7 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -2,12 +2,33 @@ package keystore import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "errors" "fmt" + "io" + + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/hkdf" + "golang.org/x/crypto/nacl/box" + + "github.com/smartcontractkit/chainlink-common/keystore/internal" +) + +// Opaque error messages to prevent information leakage +var ( + ErrSharedSecretFailed = errors.New("shared secret derivation failed") + ErrEncryptionFailed = errors.New("encryption operation failed") + ErrDecryptionFailed = errors.New("decryption operation failed") ) type EncryptRequest struct { - KeyName string - Data []byte + RemoteKeyType KeyType + RemotePubKey []byte + Data []byte } type EncryptResponse struct { @@ -24,25 +45,43 @@ type DecryptResponse struct { } type DeriveSharedSecretRequest struct { - LocalKeyName string - RemotePubKey []byte // Maybe this naming is confusing? + KeyName string + RemotePubKey []byte } type DeriveSharedSecretResponse struct { SharedSecret []byte } +const ( + // Maximum payload size for encrypt/decrypt operations (100kb) + // Note just an initial limit, we may want to increase this in the future. + MaxEncryptionPayloadSize = 100 * 1024 +) + +const ( + nonceSizeECDHP256 = 12 + ephPubSizeECDHP256 = 65 +) + +var ( + // Domain separation for HKDF-SHA256 based AES-GCM keys. + infoAESGCM = []byte("keystore:ecdh-p256:aes-gcm:hkdf-sha256:v1") +) + // Encryptor is an interfaces for hybrid encryption (key exchange + encryption) operations. -// WARNING: Using the shared secret should only be used directly in -// cases where very custom encryption schemes are needed and you know -// exactly what you are doing. type Encryptor interface { Encrypt(ctx context.Context, req EncryptRequest) (EncryptResponse, error) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResponse, error) + // DeriveSharedSecret: Derives a shared secret between the key specified + // and the remote public key. WARNING: Using the shared secret should only be used directly in + // cases where very custom encryption schemes are needed and you know + // exactly what you are doing. DeriveSharedSecret(ctx context.Context, req DeriveSharedSecretRequest) (DeriveSharedSecretResponse, error) } // UnimplementedEncryptor returns ErrUnimplemented for all Encryptor methods. +// Clients should embed this struct to ensure forward compatibility with changes to the Encryptor interface. type UnimplementedEncryptor struct{} func (UnimplementedEncryptor) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResponse, error) { @@ -57,15 +96,287 @@ func (UnimplementedEncryptor) DeriveSharedSecret(ctx context.Context, req Derive return DeriveSharedSecretResponse{}, fmt.Errorf("Encryptor.DeriveSharedSecret: %w", ErrUnimplemented) } -// TODO: Encryptor implementation. func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResponse, error) { - return EncryptResponse{}, nil + if len(req.Data) > MaxEncryptionPayloadSize { + return EncryptResponse{}, ErrEncryptionFailed + } + + switch req.RemoteKeyType { + case X25519: + encrypted, err := k.encryptX25519Anonymous(req.Data, req.RemotePubKey) + if err != nil { + return EncryptResponse{}, err + } + return EncryptResponse{ + EncryptedData: encrypted, + }, nil + case ECDH_P256: + encrypted, err := k.encryptECDHP256Anonymous(req.Data, req.RemotePubKey) + if err != nil { + return EncryptResponse{}, err + } + return EncryptResponse{EncryptedData: encrypted}, nil + default: + return EncryptResponse{}, ErrEncryptionFailed + } } func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResponse, error) { - return DecryptResponse{}, nil + k.mu.RLock() + defer k.mu.RUnlock() + + if len(req.EncryptedData) == 0 || len(req.EncryptedData) > MaxEncryptionPayloadSize*2 { + return DecryptResponse{}, ErrDecryptionFailed + } + + key, ok := k.keystore[req.KeyName] + if !ok { + return DecryptResponse{}, ErrDecryptionFailed + } + + switch key.keyType { + case X25519: + decrypted, err := k.decryptX25519Anonymous(req.EncryptedData, key.privateKey, key.publicKey) + if err != nil { + return DecryptResponse{}, err + } + return DecryptResponse{Data: decrypted}, nil + case ECDH_P256: + decrypted, err := k.decryptECDHP256Anonymous(req.EncryptedData, key.privateKey) + if err != nil { + return DecryptResponse{}, err + } + return DecryptResponse{Data: decrypted}, nil + default: + return DecryptResponse{}, ErrDecryptionFailed + } } func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecretRequest) (DeriveSharedSecretResponse, error) { - return DeriveSharedSecretResponse{}, nil + k.mu.RLock() + defer k.mu.RUnlock() + + key, ok := k.keystore[req.KeyName] + if !ok { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } + + switch key.keyType { + case X25519: + if len(req.RemotePubKey) != 32 { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } + sharedSecret, err := curve25519.X25519(internal.Bytes(key.privateKey), req.RemotePubKey) + if err != nil { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } + return DeriveSharedSecretResponse{ + SharedSecret: sharedSecret, + }, nil + case ECDH_P256: + // P-256 uncompressed public keys are 65 bytes (0x04 || x || y) + if len(req.RemotePubKey) != 65 { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } + curve := ecdh.P256() + priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) + if err != nil { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } + remotePub, err := curve.NewPublicKey(req.RemotePubKey) + if err != nil { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } + shared, err := priv.ECDH(remotePub) + if err != nil { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } + return DeriveSharedSecretResponse{SharedSecret: shared}, nil + default: + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed + } +} + +// encryptX25519Anonymous performs X25519 anonymous encryption using NaCl box +func (k *keystore) encryptX25519Anonymous(data []byte, remotePubKey []byte) ([]byte, error) { + if len(remotePubKey) != 32 { + return nil, ErrEncryptionFailed + } + + // Use SealAnonymous for anonymous encryption + encrypted, err := box.SealAnonymous(nil, data, (*[32]byte)(remotePubKey), rand.Reader) + if err != nil { + return nil, ErrEncryptionFailed + } + return encrypted, nil +} + +// decryptX25519Anonymous performs X25519 anonymous decryption using NaCl box +func (k *keystore) decryptX25519Anonymous(encryptedData []byte, privateKey internal.Raw, publicKey []byte) ([]byte, error) { + if len(publicKey) != 32 { + return nil, ErrDecryptionFailed + } + + // Use OpenAnonymous for anonymous decryption + decrypted, ok := box.OpenAnonymous(nil, encryptedData, (*[32]byte)(publicKey), (*[32]byte)(internal.Bytes(privateKey))) + if !ok { + return nil, ErrDecryptionFailed + } + if len(decrypted) == 0 { + // box.OpenAnonymous will return a nil slice if the ciphertext is empty + return []byte{}, nil + } + return decrypted, nil +} + +// encryptECDHP256Anonymous performs ECDH-P256 anonymous encryption +// We follow the same general idea as box: +// 1. Generate an ephemeral key pair +// 2. Derive a shared secret using the ephemeral private key + recipient public key +// 3. Derive a nonce from the ephemeral public key + recipient public key: SHA-256(ephPubKey || recipientPubKey)[:12] +// 4. Derive an AES-256-GCM key from the shared secret and nonce +// 5. Encrypt the data with AES-GCM, including both nonce and ephemeral public key in AAD for complete authentication +// 6. Embed ephemeral public key and nonce in the result +func (k *keystore) encryptECDHP256Anonymous(data []byte, remotePubKey []byte) ([]byte, error) { + curve := ecdh.P256() + if len(remotePubKey) != 65 { + return nil, ErrEncryptionFailed + } + + // Generate ephemeral key pair for this encryption + ephPriv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return nil, ErrEncryptionFailed + } + + // Remote public key must be on the P256 curve for the shared secret to work + recipientPub, err := curve.NewPublicKey(remotePubKey) + if err != nil { + return nil, ErrEncryptionFailed + } + + // Derive shared secret using ephemeral private key + recipient public key + shared, err := ephPriv.ECDH(recipientPub) + if err != nil { + return nil, ErrEncryptionFailed + } + + // Derive deterministic nonce from ephemeral public key + recipient public key + // This is the same approach taken by box.SealAnonymous. + nonce := deriveNonce(ephPriv.PublicKey().Bytes(), remotePubKey) + + // Derive AES-256-GCM key from shared secret and nonce + derivedKey, err := deriveAESKeyFromSharedSecret(shared, nonce, infoAESGCM) + if err != nil { + return nil, ErrEncryptionFailed + } + + // Encrypt with AES-GCM + block, err := aes.NewCipher(derivedKey) + if err != nil { + return nil, ErrEncryptionFailed + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, ErrEncryptionFailed + } + + // Include both nonce and ephemeral public key in AAD for complete authentication + // AAD is [12 byte nonce] [65 byte ephemeral public key] + ciphertext := gcm.Seal(nil, nonce, data, append(nonce[:], ephPriv.PublicKey().Bytes()...)) + + // Embed ephemeral public key and nonce in the result + // [12 byte nonce] [65 byte ephemeral public key] [AES-GCM ciphertext] + result := encodeECDHP256Anonymous(nonce[:], ephPriv.PublicKey().Bytes(), ciphertext) + return result, nil +} + +func encodeECDHP256Anonymous(nonce []byte, ephPub []byte, ciphertext []byte) []byte { + var result []byte + result = append(result, nonce[:]...) + result = append(result, ephPub...) + result = append(result, ciphertext...) + return result +} + +func decodeECDHP256Anonymous(encryptedData []byte) ([]byte, []byte, []byte, error) { + if len(encryptedData) < ephPubSizeECDHP256+nonceSizeECDHP256 { + return nil, nil, nil, ErrDecryptionFailed + } + nonceBytes := encryptedData[:nonceSizeECDHP256] + ephPubBytes := encryptedData[nonceSizeECDHP256 : nonceSizeECDHP256+ephPubSizeECDHP256] + ciphertext := encryptedData[nonceSizeECDHP256+ephPubSizeECDHP256:] + return nonceBytes, ephPubBytes, ciphertext, nil +} + +// decryptECDHP256Anonymous performs ECDH-P256 anonymous decryption +func (k *keystore) decryptECDHP256Anonymous(encryptedData []byte, privateKey internal.Raw) ([]byte, error) { + if len(encryptedData) < ephPubSizeECDHP256+nonceSizeECDHP256 { + return nil, ErrDecryptionFailed + } + + nonce, ephPubBytes, ciphertext, err := decodeECDHP256Anonymous(encryptedData) + if err != nil { + return nil, err + } + + curve := ecdh.P256() + ephPub, err := curve.NewPublicKey(ephPubBytes) + if err != nil { + return nil, ErrDecryptionFailed + } + + priv, err := curve.NewPrivateKey(internal.Bytes(privateKey)) + if err != nil { + return nil, ErrDecryptionFailed + } + + shared, err := priv.ECDH(ephPub) + if err != nil { + return nil, ErrDecryptionFailed + } + + derivedKey, err := deriveAESKeyFromSharedSecret(shared, nonce[:], infoAESGCM) + if err != nil { + return nil, ErrDecryptionFailed + } + + block, err := aes.NewCipher(derivedKey) + if err != nil { + return nil, ErrDecryptionFailed + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, ErrDecryptionFailed + } + + // Include both nonce and ephemeral public key in AAD for complete authentication + aad := append(nonce[:], ephPubBytes...) + pt, err := gcm.Open(nil, nonce[:], ciphertext, aad) + if err != nil { + return nil, ErrDecryptionFailed + } + + if len(pt) == 0 { + // Return empty slice instead of nil for consistency + return []byte{}, nil + } + return pt, nil +} + +// deriveNonce creates a deterministic nonce from two public keys +func deriveNonce(pub1, pub2 []byte) []byte { + h := sha256.New() + h.Write(pub1) + h.Write(pub2) + return h.Sum(nil)[:nonceSizeECDHP256] +} + +func deriveAESKeyFromSharedSecret(sharedSecret []byte, salt []byte, info []byte) ([]byte, error) { + r := hkdf.New(sha256.New, sharedSecret, salt, info) + key := make([]byte, 32) + if _, err := io.ReadFull(r, key); err != nil { + return nil, fmt.Errorf("hkdf: %w", err) + } + return key, nil } diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go new file mode 100644 index 0000000000..78db216f70 --- /dev/null +++ b/keystore/encryptor_test.go @@ -0,0 +1,225 @@ +package keystore_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/stretchr/testify/require" +) + +func TestEncryptDecrypt(t *testing.T) { + ctx := t.Context() + th := NewKeystoreTH(t) + th.CreateTestKeys(t) + + type testCase struct { + name string + remoteKeyType keystore.KeyType + remotePubKey []byte + decryptKey string + payload []byte + expectedEncryptError error + expectedDecryptError error + } + + var tt = []testCase{ + { + name: "Non-existent encrypt key", + remoteKeyType: "blah", + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, + decryptKey: th.KeyName(keystore.X25519, 0), + payload: []byte("hello world"), + expectedEncryptError: keystore.ErrEncryptionFailed, + }, + { + name: "Empty payload x25519", + remoteKeyType: keystore.X25519, + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, + decryptKey: th.KeyName(keystore.X25519, 0), + payload: []byte{}, + }, + { + name: "Empty payload ecdh p256", + remoteKeyType: keystore.ECDH_P256, + remotePubKey: th.KeysByType()[keystore.ECDH_P256][0].PublicKey, + decryptKey: th.KeyName(keystore.ECDH_P256, 0), + payload: []byte{}, + }, + { + name: "Non-existent decrypt key", + remoteKeyType: keystore.X25519, + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, + decryptKey: "blah", + payload: []byte("hello world"), + expectedDecryptError: keystore.ErrDecryptionFailed, + }, + { + name: "Max payload", + remoteKeyType: keystore.X25519, + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, + decryptKey: th.KeyName(keystore.X25519, 0), + payload: make([]byte, keystore.MaxEncryptionPayloadSize), + }, + { + name: "Payload too large", + remoteKeyType: keystore.X25519, + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, + decryptKey: th.KeyName(keystore.X25519, 0), + payload: make([]byte, keystore.MaxEncryptionPayloadSize+1), + expectedEncryptError: keystore.ErrEncryptionFailed, + }} + + for encName, encKey := range th.KeysByName() { + testName := fmt.Sprintf("Encrypt to %s", encName) + var expectedEncryptError error + if encKey.KeyType.IsEncryptionKeyType() { + // Same key types should succeed + expectedEncryptError = nil + } else { + // Different key types or non-encryption key types should fail + expectedEncryptError = keystore.ErrEncryptionFailed + } + + tt = append(tt, testCase{ + name: testName, + remoteKeyType: encKey.KeyType, + remotePubKey: encKey.PublicKey, + decryptKey: encName, + expectedEncryptError: expectedEncryptError, + payload: []byte("hello world"), + }) + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + encryptResp, err := th.Keystore.Encrypt(ctx, keystore.EncryptRequest{ + RemoteKeyType: tt.remoteKeyType, + RemotePubKey: tt.remotePubKey, + Data: tt.payload, + }) + if tt.expectedEncryptError != nil { + require.Error(t, err) + require.True(t, errors.Is(err, tt.expectedEncryptError)) + return + } + require.NoError(t, err) + decryptResp, err := th.Keystore.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: tt.decryptKey, + EncryptedData: encryptResp.EncryptedData, + }) + if tt.expectedDecryptError != nil { + require.Error(t, err) + require.True(t, errors.Is(err, tt.expectedDecryptError)) + return + } + require.NoError(t, err) + require.Equal(t, tt.payload, decryptResp.Data) + }) + } +} + +func TestEncryptDecrypt_SharedSecret(t *testing.T) { + ctx := t.Context() + th := NewKeystoreTH(t) + th.CreateTestKeys(t) + + type testCase struct { + name string + keyName string + keyType keystore.KeyType + expectedError error + } + var tt = []testCase{ + { + name: "Non-existent key", + keyName: "blah", + keyType: keystore.X25519, + expectedError: keystore.ErrSharedSecretFailed, + }, + } + + for keyType := range th.KeysByType() { + var expectedError error + if !keyType.IsEncryptionKeyType() { + expectedError = keystore.ErrSharedSecretFailed + } + tt = append(tt, testCase{ + keyName: th.KeyName(keyType, 0), + name: fmt.Sprintf("keyType_%s", keyType), + keyType: keyType, + expectedError: expectedError, + }) + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + _, err := th.Keystore.DeriveSharedSecret(ctx, keystore.DeriveSharedSecretRequest{ + KeyName: tt.keyName, + RemotePubKey: th.KeysByType()[tt.keyType][0].PublicKey, + }) + if tt.expectedError != nil { + require.Error(t, err) + require.True(t, errors.Is(err, tt.expectedError)) + return + } + require.NoError(t, err) + }) + } +} + +func FuzzEncryptDecryptRoundtrip(f *testing.F) { + // Add seed corpus with various input sizes and patterns + seedCorpus := [][]byte{ + {0x00}, // Single null byte + {0xFF}, // Single 0xFF byte + {0x00, 0xFF}, // Two bytes + []byte("hello"), // Short string + []byte("hello world"), // Medium string + []byte("The quick brown fox jumps over the lazy dog"), // Longer string + make([]byte, 100), // 100 null bytes + make([]byte, 1000), // 1000 null bytes + make([]byte, 10000), // 10KB of null bytes + make([]byte, 100000), // 100KB of null bytes + make([]byte, 1024*1024), // Exactly 1MB (at limit) + } + + for _, seed := range seedCorpus { + f.Add(seed) + } + + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > keystore.MaxEncryptionPayloadSize || len(data) == 0 { + t.Skip("Invalid data size for fuzz test") + } + + ctx := t.Context() + th := NewKeystoreTH(t) + th.CreateTestKeys(t) + // Test each encryption key type + for _, keyType := range keystore.AllEncryptionKeyTypes { + t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { + // Encrypt data using sender key to receiver's public key + encryptResp, err := th.Keystore.Encrypt(ctx, keystore.EncryptRequest{ + RemoteKeyType: keyType, + RemotePubKey: th.KeysByType()[keyType][1].PublicKey, // receiver's public key + Data: data, + }) + require.NoError(t, err, "Encryption should succeed for keyType %s with data length %d", keyType, len(data)) + + // Decrypt using receiver key + decryptResp, err := th.Keystore.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: th.KeyName(keyType, 1), + EncryptedData: encryptResp.EncryptedData, + }) + require.NoError(t, err, "Decryption should succeed for keyType %s with data length %d", keyType, len(data)) + + // Verify roundtrip integrity + require.Equal(t, data, decryptResp.Data, + "Roundtrip failed for keyType %s: original data length %d, decrypted data length %d", + keyType, len(data), len(decryptResp.Data)) + }) + } + }) +} diff --git a/keystore/helpers_test.go b/keystore/helpers_test.go new file mode 100644 index 0000000000..f67c733396 --- /dev/null +++ b/keystore/helpers_test.go @@ -0,0 +1,75 @@ +package keystore_test + +import ( + "fmt" + "sync" + "testing" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/smartcontractkit/chainlink-common/keystore/storage" + "github.com/stretchr/testify/require" +) + +type Key struct { + KeyType keystore.KeyType + PublicKey []byte +} + +type KeystoreTH struct { + mu sync.RWMutex + Keystore keystore.Keystore + keysByName map[string]Key + keysByType map[keystore.KeyType][]Key +} + +func NewKeystoreTH(t *testing.T) *KeystoreTH { + ctx := t.Context() + st := storage.NewMemoryStorage() + ks, err := keystore.LoadKeystore(ctx, st, keystore.EncryptionParams{ + Password: "test", + ScryptParams: keystore.FastScryptParams, + }) + require.NoError(t, err) + return &KeystoreTH{ + Keystore: ks, + keysByName: make(map[string]Key), + keysByType: make(map[keystore.KeyType][]Key), + } +} + +func (th *KeystoreTH) KeysByName() map[string]Key { + th.mu.RLock() + defer th.mu.RUnlock() + return th.keysByName +} + +func (th *KeystoreTH) KeysByType() map[keystore.KeyType][]Key { + th.mu.RLock() + defer th.mu.RUnlock() + return th.keysByType +} + +func (th *KeystoreTH) KeyName(keyType keystore.KeyType, index int) string { + return fmt.Sprintf("test-key-%s-%d", keyType, index) +} + +// CreateTestKeys creates 2 keys of each type in the keystore. +func (th *KeystoreTH) CreateTestKeys(t *testing.T) { + th.mu.Lock() + defer th.mu.Unlock() + ctx := t.Context() + for _, keyType := range keystore.AllKeyTypes { + keys, err := th.Keystore.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + {KeyName: th.KeyName(keyType, 0), KeyType: keyType}, + {KeyName: th.KeyName(keyType, 1), KeyType: keyType}, + }, + }) + require.NoError(t, err) + th.keysByName[keys.Keys[0].KeyInfo.Name] = Key{KeyType: keys.Keys[0].KeyInfo.KeyType, PublicKey: keys.Keys[0].KeyInfo.PublicKey} + th.keysByType[keyType] = append(th.keysByType[keyType], Key{KeyType: keys.Keys[0].KeyInfo.KeyType, PublicKey: keys.Keys[0].KeyInfo.PublicKey}) + + th.keysByName[keys.Keys[1].KeyInfo.Name] = Key{KeyType: keys.Keys[1].KeyInfo.KeyType, PublicKey: keys.Keys[1].KeyInfo.PublicKey} + th.keysByType[keyType] = append(th.keysByType[keyType], Key{KeyType: keys.Keys[1].KeyInfo.KeyType, PublicKey: keys.Keys[1].KeyInfo.PublicKey}) + } +} diff --git a/keystore/keystore.go b/keystore/keystore.go index bbb808e76e..a4466330e6 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -2,10 +2,12 @@ package keystore import ( "context" + "crypto/ecdh" "crypto/ed25519" "encoding/json" "errors" "fmt" + "slices" "sync" "time" @@ -21,6 +23,18 @@ import ( type KeyType string +func (k KeyType) String() string { + return string(k) +} + +func (k KeyType) IsEncryptionKeyType() bool { + return slices.Contains(AllEncryptionKeyTypes, k) +} + +func (k KeyType) IsDigitalSignatureKeyType() bool { + return slices.Contains(AllDigitalSignatureKeyTypes, k) +} + const ( // Hybrid encryption (key exchange + encryption) key types. // Naming schema is generally . @@ -31,20 +45,25 @@ const ( // - X25519 for ECDH key exchange. // - Box for encryption (ChaCha20Poly1305) X25519 KeyType = "X25519" - // TODO: EcdhP256: + // ECDH_P256: // - ECDH on P-256 - // - Encryption with AES-GCM. + // - Encryption with AES-GCM and HKDF-SHA256 + ECDH_P256 KeyType = "ecdh-p256" // Digital signature key types. // Ed25519: // - Ed25519 for digital signatures. + // - Supports arbitrary messages sizes, no hashing required. Ed25519 KeyType = "ed25519" - // EcdsaSecp256k1: + // ECDSA_S256: // - ECDSA on secp256k1 for digital signatures. - EcdsaSecp256k1 KeyType = "ecdsa-secp256k1" + // - Only signs 32 byte digests. Caller must hash the data before signing. + ECDSA_S256 KeyType = "ecdsa-secp256k1" ) -var AllKeyTypes = []KeyType{X25519, Ed25519, EcdsaSecp256k1} +var AllKeyTypes = []KeyType{X25519, ECDH_P256, Ed25519, ECDSA_S256} +var AllEncryptionKeyTypes = []KeyType{X25519, ECDH_P256} +var AllDigitalSignatureKeyTypes = []KeyType{Ed25519, ECDSA_S256} type ScryptParams struct { N int @@ -135,8 +154,10 @@ type EncryptionParams struct { func publicKeyFromPrivateKey(privateKeyBytes internal.Raw, keyType KeyType) ([]byte, error) { switch keyType { case Ed25519: - return ed25519.PublicKey(internal.Bytes(privateKeyBytes)), nil - case EcdsaSecp256k1: + privateKey := ed25519.PrivateKey(internal.Bytes(privateKeyBytes)) + publicKey := privateKey.Public().(ed25519.PublicKey) + return publicKey, nil + case ECDSA_S256: // Here we use SEC1 (uncompressed) format for ECDSA public keys. // Its commonly used and EVM addresses are derived from this format. // We use the geth crypto library for secp256k1 support @@ -153,6 +174,13 @@ func publicKeyFromPrivateKey(privateKeyBytes internal.Raw, keyType KeyType) ([]b return nil, fmt.Errorf("failed to derive shared secret: %w", err) } return pubKey, nil + case ECDH_P256: + curve := ecdh.P256() + priv, err := curve.NewPrivateKey(internal.Bytes(privateKeyBytes)) + if err != nil { + return nil, fmt.Errorf("invalid P-256 private key: %w", err) + } + return priv.PublicKey().Bytes(), nil default: // Some types may not have a public key. return []byte{}, nil @@ -185,7 +213,7 @@ func (k *keystore) load(ctx context.Context) error { } // If no data exists, return empty keystore - if encryptedKeystore == nil || len(encryptedKeystore) == 0 { + if len(encryptedKeystore) == 0 { k.keystore = make(map[string]key) return nil } diff --git a/keystore/keystore_internal_test.go b/keystore/keystore_internal_test.go index c8c16b4701..c118925896 100644 --- a/keystore/keystore_internal_test.go +++ b/keystore/keystore_internal_test.go @@ -1,6 +1,8 @@ package keystore import ( + "crypto/ecdh" + "crypto/rand" "testing" gethcrypto "github.com/ethereum/go-ethereum/crypto" @@ -13,8 +15,18 @@ func TestPublicKeyFromPrivateKey(t *testing.T) { // (in particular for secp2561k1 since the stdlib doesn't support it) pk, err := gethcrypto.GenerateKey() require.NoError(t, err) - pubKey, err := publicKeyFromPrivateKey(internal.NewRaw(pk.D.Bytes()), EcdsaSecp256k1) + pubKey, err := publicKeyFromPrivateKey(internal.NewRaw(pk.D.Bytes()), ECDSA_S256) require.NoError(t, err) pubKeyGeth := gethcrypto.FromECDSAPub(&pk.PublicKey) require.Equal(t, pubKeyGeth, pubKey) + // We use SEC1 (uncompressed) format for ECDSA public keys. + require.Equal(t, 65, len(pubKey)) + + ecdhPriv, err := ecdh.P256().GenerateKey(rand.Reader) + require.NoError(t, err) + pubKey, err = publicKeyFromPrivateKey(internal.NewRaw(ecdhPriv.Bytes()), ECDH_P256) + require.NoError(t, err) + require.Equal(t, ecdhPriv.PublicKey().Bytes(), pubKey) + // We use SEC1 (uncompressed) format for ECDH public keys. + require.Equal(t, 65, len(pubKey)) } diff --git a/keystore/reader.go b/keystore/reader.go index 4eb6b7c3a0..f05c1509ca 100644 --- a/keystore/reader.go +++ b/keystore/reader.go @@ -6,12 +6,6 @@ import ( "sort" ) -type ListKeysRequest struct{} - -type ListKeysResponse struct { - Keys []KeyInfo -} - type GetKeysRequest struct { KeyNames []string } diff --git a/keystore/signer.go b/keystore/signer.go index 5969d37d22..948e9f8777 100644 --- a/keystore/signer.go +++ b/keystore/signer.go @@ -2,7 +2,17 @@ package keystore import ( "context" + "crypto/ed25519" + "errors" "fmt" + + gethcrypto "github.com/ethereum/go-ethereum/crypto" + "github.com/smartcontractkit/chainlink-common/keystore/internal" +) + +var ( + ErrInvalidSignRequest = errors.New("invalid sign request") + ErrInvalidVerifyRequest = errors.New("invalid verify request") ) type SignRequest struct { @@ -15,7 +25,8 @@ type SignResponse struct { } type VerifyRequest struct { - KeyName string + KeyType KeyType + PublicKey []byte Data []byte Signature []byte } @@ -40,11 +51,74 @@ func (UnimplementedSigner) Verify(ctx context.Context, req VerifyRequest) (Verif return VerifyResponse{}, fmt.Errorf("Signer.Verify: %w", ErrUnimplemented) } -// TODO: Signer implementation. func (k *keystore) Sign(ctx context.Context, req SignRequest) (SignResponse, error) { - return SignResponse{}, nil + k.mu.RLock() + defer k.mu.RUnlock() + + key, ok := k.keystore[req.KeyName] + if !ok { + return SignResponse{}, fmt.Errorf("%s: %w", req.KeyName, ErrKeyNotFound) + } + switch key.keyType { + case Ed25519: + privateKey := ed25519.PrivateKey(internal.Bytes(key.privateKey)) + signature := ed25519.Sign(privateKey, req.Data) + return SignResponse{ + Signature: signature, + }, nil + case ECDSA_S256: + if len(req.Data) != 32 { + return SignResponse{}, fmt.Errorf("data must be 32 bytes for ECDSA_S256, got %d: %w", len(req.Data), ErrInvalidSignRequest) + } + privateKey, err := gethcrypto.ToECDSA(internal.Bytes(key.privateKey)) + if err != nil { + return SignResponse{}, fmt.Errorf("failed to convert private key to ECDSA private key: %w", err) + } + signature, err := gethcrypto.Sign(req.Data, privateKey) + if err != nil { + return SignResponse{}, err + } + return SignResponse{ + Signature: signature, + }, nil + default: + return SignResponse{}, fmt.Errorf("unsupported key type: %s", key.keyType) + } } func (k *keystore) Verify(ctx context.Context, req VerifyRequest) (VerifyResponse, error) { - return VerifyResponse{}, nil + // Note don't need the lock since this is a pure function. + switch req.KeyType { + case Ed25519: + if len(req.Signature) != 64 { + return VerifyResponse{}, fmt.Errorf("signature must be 64 bytes for Ed25519, got %d: %w", len(req.Signature), ErrInvalidVerifyRequest) + } + if len(req.PublicKey) != 32 { + return VerifyResponse{}, fmt.Errorf("public key must be 32 bytes for Ed25519, got %d: %w", len(req.PublicKey), ErrInvalidVerifyRequest) + } + publicKey := ed25519.PublicKey(req.PublicKey) + signature := ed25519.Verify(publicKey, req.Data, req.Signature) + return VerifyResponse{ + Valid: signature, + }, nil + case ECDSA_S256: + if len(req.Data) != 32 { + return VerifyResponse{}, fmt.Errorf("data must be 32 bytes for ECDSA_S256, got %d: %w", len(req.Data), ErrInvalidVerifyRequest) + } + // ECDSA_S256 public keys are in SEC1 (uncompressed) format + if len(req.PublicKey) != 65 { + return VerifyResponse{}, fmt.Errorf("public key must be 65 bytes for ECDSA_S256, got %d: %w", len(req.PublicKey), ErrInvalidVerifyRequest) + } + if len(req.Signature) != 65 { + return VerifyResponse{}, fmt.Errorf("signature must be 65 bytes for ECDSA_S256, got %d: %w", len(req.Signature), ErrInvalidVerifyRequest) + } + // VerifySignature expects 64 bytes [R || S] without the V byte + // Strip the V byte (last byte) from the 65-byte signature + valid := gethcrypto.VerifySignature(req.PublicKey, req.Data, req.Signature[:64]) + return VerifyResponse{ + Valid: valid, + }, nil + default: + return VerifyResponse{}, fmt.Errorf("unsupported key type: %s", req.KeyType) + } } diff --git a/keystore/signer_test.go b/keystore/signer_test.go new file mode 100644 index 0000000000..ab5d9adb09 --- /dev/null +++ b/keystore/signer_test.go @@ -0,0 +1,70 @@ +package keystore_test + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/stretchr/testify/require" +) + +func TestSigner(t *testing.T) { + ks := NewKeystoreTH(t) + ks.CreateTestKeys(t) + ctx := t.Context() + + var tt = []struct { + name string + keyName string + data []byte + signature []byte + expectedError error + }{ + { + name: "ECDSA_S256 sign/verify", + keyName: ks.KeyName(keystore.ECDSA_S256, 0), + data: make([]byte, 32), // 32 byte digest + }, + { + name: "ECDSA_S256 sign/verify no such key", + keyName: "no-such-key", + data: make([]byte, 32), // 32 byte digest + expectedError: keystore.ErrKeyNotFound, + }, + { + name: "ECDSA_S256 sign/verify wrong data length", + keyName: ks.KeyName(keystore.ECDSA_S256, 0), + data: make([]byte, 31), + expectedError: keystore.ErrInvalidSignRequest, + }, + { + name: "Ed25519 sign/verify", + keyName: ks.KeyName(keystore.Ed25519, 0), + data: []byte("test_data"), + }, + { + name: "Ed25519 sign/verify no such key", + keyName: "no-such-key", + data: make([]byte, 2), + expectedError: keystore.ErrKeyNotFound, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + signature, err := ks.Keystore.Sign(ctx, keystore.SignRequest{KeyName: tc.keyName, Data: tc.data}) + if tc.expectedError != nil { + require.Error(t, err) + require.ErrorIs(t, err, tc.expectedError) + return + } + require.NoError(t, err) + valid, err := ks.Keystore.Verify(ctx, keystore.VerifyRequest{ + KeyType: ks.KeysByName()[tc.keyName].KeyType, + PublicKey: ks.KeysByName()[tc.keyName].PublicKey, + Data: tc.data, + Signature: signature.Signature, + }) + require.NoError(t, err) + require.True(t, valid.Valid) + }) + } +} diff --git a/keystore/storage/memory_test.go b/keystore/storage/memory_test.go index e83fba60bf..1e2b1d59f7 100644 --- a/keystore/storage/memory_test.go +++ b/keystore/storage/memory_test.go @@ -1,7 +1,6 @@ package storage_test import ( - "context" "testing" "github.com/smartcontractkit/chainlink-common/keystore/storage" @@ -10,8 +9,8 @@ import ( func TestMemoryStorage(t *testing.T) { storage := storage.NewMemoryStorage() - require.NoError(t, storage.PutEncryptedKeystore(context.Background(), []byte("test"))) - got, err := storage.GetEncryptedKeystore(context.Background()) + require.NoError(t, storage.PutEncryptedKeystore(t.Context(), []byte("test"))) + got, err := storage.GetEncryptedKeystore(t.Context()) require.NoError(t, err) require.Equal(t, []byte("test"), got) }