From 8283b0c5e4e063a3a5f140d6384c4c773bd3caa6 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 6 Oct 2025 11:48:42 -0400 Subject: [PATCH 01/15] Wip encryptor --- keystore/admin.go | 11 ++ keystore/encryptor.go | 249 +++++++++++++++++++++++++++++++++++-- keystore/encryptor_test.go | 68 ++++++++++ keystore/keystore.go | 13 +- 4 files changed, 332 insertions(+), 9 deletions(-) create mode 100644 keystore/encryptor_test.go diff --git a/keystore/admin.go b/keystore/admin.go index e87d8ce9c3..1772b30f43 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" @@ -173,6 +174,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 EcdhP256: + privateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return CreateKeysResponse{}, fmt.Errorf("failed to generate EcdhP256 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/encryptor.go b/keystore/encryptor.go index 590f439bff..b5b88d1c29 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -2,12 +2,26 @@ package keystore import ( "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/json" "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" ) type EncryptRequest struct { - KeyName string - Data []byte + KeyName string + RemotePubKey []byte + Data []byte } type EncryptResponse struct { @@ -25,13 +39,50 @@ type DecryptResponse struct { type DeriveSharedSecretRequest struct { LocalKeyName string - RemotePubKey []byte // Maybe this naming is confusing? + RemotePubKey []byte } type DeriveSharedSecretResponse struct { SharedSecret []byte } +const ( + aesGCMNonceSize = 12 + hkdfSaltSize = 16 + + // ciphertext framing for EcdhP256 + HKDF-SHA256 + AES-GCM + // [1B version=1][2B ephLen][ephPub][1B saltLen][salt][1B nonceLen][nonce][ciphertext] + encVersionV1 byte = 1 + + algP256HKDFAESGCM = "ecdh-p256+hkdf-sha256+aes-256-gcm" +) + +func hkdfAESGCMKey(sharedSecret, salt, info []byte, keyLen int) ([]byte, error) { + r := hkdf.New(sha256.New, sharedSecret, salt, info) + key := make([]byte, keyLen) + if _, err := io.ReadFull(r, key); err != nil { + return nil, fmt.Errorf("hkdf: %w", err) + } + return key, nil +} + +type encAAD struct { + V byte `json:"v"` + Alg string `json:"alg"` + EPK []byte `json:"epk"` + Salt []byte `json:"salt"` + Nonce []byte `json:"nonce"` +} + +type encEnvelope struct { + V byte `json:"v"` + Alg string `json:"alg"` + EPK []byte `json:"epk"` + Salt []byte `json:"salt"` + Nonce []byte `json:"nonce"` + CT []byte `json:"ct"` +} + // 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 @@ -57,15 +108,199 @@ 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 + k.mu.RLock() + defer k.mu.RUnlock() + + key, ok := k.keystore[req.KeyName] + if !ok { + return EncryptResponse{}, fmt.Errorf("key not found: %s", req.KeyName) + } + switch key.keyType { + case X25519: + if len(req.RemotePubKey) != 32 { + return EncryptResponse{}, fmt.Errorf("remote public key must be 32 bytes for X25519") + } + encrypted, err := box.SealAnonymous(nil, req.Data, (*[32]byte)(req.RemotePubKey), rand.Reader) + if err != nil { + return EncryptResponse{}, fmt.Errorf("failed to encrypt data: %w", err) + } + return EncryptResponse{ + EncryptedData: encrypted, + }, nil + case EcdhP256: + curve := ecdh.P256() + if len(req.RemotePubKey) == 0 { + return EncryptResponse{}, fmt.Errorf("remote public key required for EcdhP256") + } + recipientPub, err := curve.NewPublicKey(req.RemotePubKey) + if err != nil { + return EncryptResponse{}, fmt.Errorf("invalid P-256 public key: %w", err) + } + // Ephemeral key pair + ephPriv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return EncryptResponse{}, fmt.Errorf("failed to generate ephemeral key: %w", err) + } + shared, err := ephPriv.ECDH(recipientPub) + if err != nil { + return EncryptResponse{}, fmt.Errorf("ecdh failed: %w", err) + } + // Derive AES-256-GCM key + salt := make([]byte, hkdfSaltSize) + if _, err := rand.Read(salt); err != nil { + return EncryptResponse{}, fmt.Errorf("salt generation failed: %w", err) + } + info := []byte("keystore:ecdh-p256:aes-gcm:hkdf-sha256:v1") + aeadKey, err := hkdfAESGCMKey(shared, salt, info, 32) + if err != nil { + return EncryptResponse{}, err + } + block, err := aes.NewCipher(aeadKey) + if err != nil { + return EncryptResponse{}, fmt.Errorf("aes: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return EncryptResponse{}, fmt.Errorf("gcm: %w", err) + } + nonce := make([]byte, aesGCMNonceSize) + if _, err := rand.Read(nonce); err != nil { + return EncryptResponse{}, fmt.Errorf("nonce generation failed: %w", err) + } + ephPub := ephPriv.PublicKey().Bytes() + head := encAAD{ + V: encVersionV1, + Alg: algP256HKDFAESGCM, + EPK: ephPub, + Salt: salt, + Nonce: nonce, + } + aadBytes, err := json.Marshal(head) + if err != nil { + return EncryptResponse{}, fmt.Errorf("aad marshal: %w", err) + } + ciphertext := gcm.Seal(nil, nonce, req.Data, aadBytes) + env := encEnvelope{ + V: encVersionV1, + Alg: algP256HKDFAESGCM, + EPK: ephPub, + Salt: salt, + Nonce: nonce, + CT: ciphertext, + } + out, err := json.Marshal(env) + if err != nil { + return EncryptResponse{}, fmt.Errorf("envelope marshal: %w", err) + } + return EncryptResponse{EncryptedData: out}, nil + default: + return EncryptResponse{}, fmt.Errorf("unsupported key type: %s", key.keyType) + } } func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResponse, error) { - return DecryptResponse{}, nil + k.mu.RLock() + defer k.mu.RUnlock() + + key, ok := k.keystore[req.KeyName] + if !ok { + return DecryptResponse{}, fmt.Errorf("key not found: %s", req.KeyName) + } + switch key.keyType { + case X25519: + decrypted, ok := box.OpenAnonymous(nil, req.EncryptedData, (*[32]byte)(key.publicKey), (*[32]byte)(internal.Bytes(key.privateKey))) + if !ok { + return DecryptResponse{}, fmt.Errorf("failed to decrypt data") + } + return DecryptResponse{ + Data: decrypted, + }, nil + case EcdhP256: + var env encEnvelope + if err := json.Unmarshal(req.EncryptedData, &env); err != nil { + return DecryptResponse{}, fmt.Errorf("envelope unmarshal: %w", err) + } + if env.V != encVersionV1 || env.Alg != algP256HKDFAESGCM { + return DecryptResponse{}, fmt.Errorf("unsupported envelope version/alg") + } + curve := ecdh.P256() + priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) + if err != nil { + return DecryptResponse{}, fmt.Errorf("invalid P-256 private key: %w", err) + } + ephPub, err := curve.NewPublicKey(env.EPK) + if err != nil { + return DecryptResponse{}, fmt.Errorf("invalid P-256 ephemeral public key: %w", err) + } + shared, err := priv.ECDH(ephPub) + if err != nil { + return DecryptResponse{}, fmt.Errorf("ecdh failed: %w", err) + } + info := []byte("keystore:ecdh-p256:aes-gcm:hkdf-sha256:v1") + aeadKey, err := hkdfAESGCMKey(shared, env.Salt, info, 32) + if err != nil { + return DecryptResponse{}, err + } + block, err := aes.NewCipher(aeadKey) + if err != nil { + return DecryptResponse{}, fmt.Errorf("aes: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return DecryptResponse{}, fmt.Errorf("gcm: %w", err) + } + aad := encAAD{V: env.V, Alg: env.Alg, EPK: env.EPK, Salt: env.Salt, Nonce: env.Nonce} + aadBytes, err := json.Marshal(aad) + if err != nil { + return DecryptResponse{}, fmt.Errorf("aad marshal: %w", err) + } + pt, err := gcm.Open(nil, env.Nonce, env.CT, aadBytes) + if err != nil { + return DecryptResponse{}, fmt.Errorf("gcm open: %w", err) + } + return DecryptResponse{Data: pt}, nil + default: + return DecryptResponse{}, fmt.Errorf("unsupported key type: %s", key.keyType) + } } 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.LocalKeyName] + if !ok { + return DeriveSharedSecretResponse{}, fmt.Errorf("key not found: %s", req.LocalKeyName) + } + switch key.keyType { + case X25519: + if len(req.RemotePubKey) != 32 { + return DeriveSharedSecretResponse{}, fmt.Errorf("remote public key must be 32 bytes") + } + sharedSecret, err := curve25519.X25519(internal.Bytes(key.privateKey), req.RemotePubKey) + if err != nil { + return DeriveSharedSecretResponse{}, fmt.Errorf("failed to derive shared secret: %w", err) + } + return DeriveSharedSecretResponse{ + SharedSecret: sharedSecret, + }, nil + case EcdhP256: + curve := ecdh.P256() + priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) + if err != nil { + return DeriveSharedSecretResponse{}, fmt.Errorf("invalid P-256 private key: %w", err) + } + remotePub, err := curve.NewPublicKey(req.RemotePubKey) + if err != nil { + return DeriveSharedSecretResponse{}, fmt.Errorf("invalid P-256 public key: %w", err) + } + shared, err := priv.ECDH(remotePub) + if err != nil { + return DeriveSharedSecretResponse{}, fmt.Errorf("ecdh failed: %w", err) + } + return DeriveSharedSecretResponse{SharedSecret: shared}, nil + default: + return DeriveSharedSecretResponse{}, fmt.Errorf("unsupported key type: %s", key.keyType) + } } diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go new file mode 100644 index 0000000000..6400ec248b --- /dev/null +++ b/keystore/encryptor_test.go @@ -0,0 +1,68 @@ +package keystore_test + +import ( + "context" + "testing" + + "github.com/smartcontractkit/chainlink-common/keystore" + "github.com/smartcontractkit/chainlink-common/keystore/storage" + "github.com/stretchr/testify/require" +) + +func TestEncryptor_X25519(t *testing.T) { + ctx := context.Background() + ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ + Password: "test-password", + ScryptParams: keystore.FastScryptParams, + }) + require.NoError(t, err) + + keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + {KeyName: "A", KeyType: keystore.X25519}, + {KeyName: "B", KeyType: keystore.X25519}, + }, + }) + require.NoError(t, err) + encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: "A", + // Encrypt to B + RemotePubKey: keys.Keys[1].KeyInfo.PublicKey, + Data: []byte("hello world"), + }) + require.NoError(t, err) + decryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: "B", + EncryptedData: encryptResp.EncryptedData, + }) + require.NoError(t, err) + require.Equal(t, []byte("hello world"), decryptResp.Data) +} + +func TestEncryptor_EcdhP256(t *testing.T) { + ctx := context.Background() + ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ + Password: "test-password", + ScryptParams: keystore.FastScryptParams, + }) + require.NoError(t, err) + keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + {KeyName: "A", KeyType: keystore.EcdhP256}, + {KeyName: "B", KeyType: keystore.EcdhP256}, + }, + }) + require.NoError(t, err) + encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: "A", + RemotePubKey: keys.Keys[1].KeyInfo.PublicKey, + Data: []byte("hello world"), + }) + require.NoError(t, err) + decryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: "B", + EncryptedData: encryptResp.EncryptedData, + }) + require.NoError(t, err) + require.Equal(t, []byte("hello world"), decryptResp.Data) +} diff --git a/keystore/keystore.go b/keystore/keystore.go index bbb808e76e..e948fe1cfb 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -2,6 +2,7 @@ package keystore import ( "context" + "crypto/ecdh" "crypto/ed25519" "encoding/json" "errors" @@ -31,9 +32,10 @@ const ( // - X25519 for ECDH key exchange. // - Box for encryption (ChaCha20Poly1305) X25519 KeyType = "X25519" - // TODO: EcdhP256: + // EcdhP256: // - ECDH on P-256 // - Encryption with AES-GCM. + EcdhP256 KeyType = "ecdh-p256" // Digital signature key types. // Ed25519: @@ -44,7 +46,7 @@ const ( EcdsaSecp256k1 KeyType = "ecdsa-secp256k1" ) -var AllKeyTypes = []KeyType{X25519, Ed25519, EcdsaSecp256k1} +var AllKeyTypes = []KeyType{X25519, EcdhP256, Ed25519, EcdsaSecp256k1} type ScryptParams struct { N int @@ -153,6 +155,13 @@ func publicKeyFromPrivateKey(privateKeyBytes internal.Raw, keyType KeyType) ([]b return nil, fmt.Errorf("failed to derive shared secret: %w", err) } return pubKey, nil + case EcdhP256: + 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 From 0875ec03d43176b61fbcdeb603b90d95cabb43ba Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 6 Oct 2025 16:56:54 -0400 Subject: [PATCH 02/15] Add a test and more documentation for encryption --- keystore/encryptor.go | 203 +++++++++++++++++++++---------------- keystore/encryptor_test.go | 108 +++++++++++--------- keystore/keystore.go | 8 +- keystore/reader.go | 6 -- 4 files changed, 185 insertions(+), 140 deletions(-) diff --git a/keystore/encryptor.go b/keystore/encryptor.go index b5b88d1c29..92506878b6 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -18,6 +18,12 @@ import ( "github.com/smartcontractkit/chainlink-common/keystore/internal" ) +// Opaque error messages to prevent information leakage +var ( + ErrEncryptionFailed = fmt.Errorf("encryption operation failed") + ErrDecryptionFailed = fmt.Errorf("decryption operation failed") +) + type EncryptRequest struct { KeyName string RemotePubKey []byte @@ -47,40 +53,28 @@ type DeriveSharedSecretResponse struct { } const ( - aesGCMNonceSize = 12 - hkdfSaltSize = 16 - - // ciphertext framing for EcdhP256 + HKDF-SHA256 + AES-GCM - // [1B version=1][2B ephLen][ephPub][1B saltLen][salt][1B nonceLen][nonce][ciphertext] + // 16 byte is the standard NIST recommended salt size for HKDF. + hkdfSaltSize = 16 + // 1 byte is the version for the encryption envelope. encVersionV1 byte = 1 - - algP256HKDFAESGCM = "ecdh-p256+hkdf-sha256+aes-256-gcm" ) -func hkdfAESGCMKey(sharedSecret, salt, info []byte, keyLen int) ([]byte, error) { - r := hkdf.New(sha256.New, sharedSecret, salt, info) - key := make([]byte, keyLen) - if _, err := io.ReadFull(r, key); err != nil { - return nil, fmt.Errorf("hkdf: %w", err) - } - return key, nil -} +var ( + // Domain separation for HKDF-SHA256 based AES-GCM keys. + infoAESGCM = []byte("keystore:ecdh-p256:aes-gcm:hkdf-sha256:v1") +) -type encAAD struct { - V byte `json:"v"` - Alg string `json:"alg"` - EPK []byte `json:"epk"` - Salt []byte `json:"salt"` - Nonce []byte `json:"nonce"` +type encHeader struct { + Version byte `json:"version"` + Alg string `json:"alg"` + EphemeralPublicKey []byte `json:"ephemeral_public_key"` + Salt []byte `json:"salt"` + Nonce []byte `json:"nonce"` } type encEnvelope struct { - V byte `json:"v"` - Alg string `json:"alg"` - EPK []byte `json:"epk"` - Salt []byte `json:"salt"` - Nonce []byte `json:"nonce"` - CT []byte `json:"ct"` + encHeader + CipherText []byte `json:"ciphertext"` } // Encryptor is an interfaces for hybrid encryption (key exchange + encryption) operations. @@ -94,6 +88,7 @@ type Encryptor interface { } // 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) { @@ -112,18 +107,25 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp k.mu.RLock() defer k.mu.RUnlock() + // Validate request parameters without leaking information + if req.KeyName == "" || len(req.Data) == 0 { + return EncryptResponse{}, ErrEncryptionFailed + } + key, ok := k.keystore[req.KeyName] if !ok { - return EncryptResponse{}, fmt.Errorf("key not found: %s", req.KeyName) + // Don't leak key existence - return same error as other failures + return EncryptResponse{}, ErrEncryptionFailed } + switch key.keyType { case X25519: if len(req.RemotePubKey) != 32 { - return EncryptResponse{}, fmt.Errorf("remote public key must be 32 bytes for X25519") + return EncryptResponse{}, ErrEncryptionFailed } encrypted, err := box.SealAnonymous(nil, req.Data, (*[32]byte)(req.RemotePubKey), rand.Reader) if err != nil { - return EncryptResponse{}, fmt.Errorf("failed to encrypt data: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } return EncryptResponse{ EncryptedData: encrypted, @@ -131,71 +133,77 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp case EcdhP256: curve := ecdh.P256() if len(req.RemotePubKey) == 0 { - return EncryptResponse{}, fmt.Errorf("remote public key required for EcdhP256") + return EncryptResponse{}, ErrEncryptionFailed } + // Remote public key must be on the P256 curve for the shared secret to work. recipientPub, err := curve.NewPublicKey(req.RemotePubKey) if err != nil { - return EncryptResponse{}, fmt.Errorf("invalid P-256 public key: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } - // Ephemeral key pair + // Create an ephemeral keypair on the P256 curve used for encryption. ephPriv, err := curve.GenerateKey(rand.Reader) if err != nil { - return EncryptResponse{}, fmt.Errorf("failed to generate ephemeral key: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } + // The magic here is the the receipient can compute the same + // shared secret because ephPriv*G*recipientPriv = ephPub*G. + // This lets them derive the same ephemeral key used for encryption + // so they can decrypt the ciphertext. shared, err := ephPriv.ECDH(recipientPub) if err != nil { - return EncryptResponse{}, fmt.Errorf("ecdh failed: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } // Derive AES-256-GCM key + // The reason we do this is so that we can use symmetric encryption (more efficient) + // This is part of any standard hybrid encryption scheme. + // We include random salt to prevent rainbow table attacks (i.e. preventing + // attackers from tracking a mapping of encryption data to plaintext) salt := make([]byte, hkdfSaltSize) if _, err := rand.Read(salt); err != nil { - return EncryptResponse{}, fmt.Errorf("salt generation failed: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } - info := []byte("keystore:ecdh-p256:aes-gcm:hkdf-sha256:v1") - aeadKey, err := hkdfAESGCMKey(shared, salt, info, 32) + derivedKey, err := deriveAESKeyFromSharedSecret(shared, salt, infoAESGCM) if err != nil { - return EncryptResponse{}, err + return EncryptResponse{}, ErrEncryptionFailed } - block, err := aes.NewCipher(aeadKey) + block, err := aes.NewCipher(derivedKey) if err != nil { - return EncryptResponse{}, fmt.Errorf("aes: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } gcm, err := cipher.NewGCM(block) if err != nil { - return EncryptResponse{}, fmt.Errorf("gcm: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } - nonce := make([]byte, aesGCMNonceSize) + nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { - return EncryptResponse{}, fmt.Errorf("nonce generation failed: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } ephPub := ephPriv.PublicKey().Bytes() - head := encAAD{ - V: encVersionV1, - Alg: algP256HKDFAESGCM, - EPK: ephPub, - Salt: salt, - Nonce: nonce, + head := encHeader{ + Version: encVersionV1, + Alg: EcdhP256.String(), + EphemeralPublicKey: ephPub, + Salt: salt, + Nonce: nonce, } aadBytes, err := json.Marshal(head) if err != nil { - return EncryptResponse{}, fmt.Errorf("aad marshal: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } + // Critical to include the header parameters as additional authenticated data. + // Prevents a MITM from changing the header. ciphertext := gcm.Seal(nil, nonce, req.Data, aadBytes) env := encEnvelope{ - V: encVersionV1, - Alg: algP256HKDFAESGCM, - EPK: ephPub, - Salt: salt, - Nonce: nonce, - CT: ciphertext, + encHeader: head, + CipherText: ciphertext, } out, err := json.Marshal(env) if err != nil { - return EncryptResponse{}, fmt.Errorf("envelope marshal: %w", err) + return EncryptResponse{}, ErrEncryptionFailed } return EncryptResponse{EncryptedData: out}, nil default: - return EncryptResponse{}, fmt.Errorf("unsupported key type: %s", key.keyType) + return EncryptResponse{}, ErrEncryptionFailed } } @@ -203,15 +211,23 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp k.mu.RLock() defer k.mu.RUnlock() + // Validate request parameters without leaking information + + if req.KeyName == "" || len(req.EncryptedData) == 0 { + return DecryptResponse{}, ErrDecryptionFailed + } + key, ok := k.keystore[req.KeyName] if !ok { - return DecryptResponse{}, fmt.Errorf("key not found: %s", req.KeyName) + // Don't leak key existence - return same error as other failures + return DecryptResponse{}, ErrDecryptionFailed } + switch key.keyType { case X25519: decrypted, ok := box.OpenAnonymous(nil, req.EncryptedData, (*[32]byte)(key.publicKey), (*[32]byte)(internal.Bytes(key.privateKey))) if !ok { - return DecryptResponse{}, fmt.Errorf("failed to decrypt data") + return DecryptResponse{}, ErrDecryptionFailed } return DecryptResponse{ Data: decrypted, @@ -219,49 +235,48 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp case EcdhP256: var env encEnvelope if err := json.Unmarshal(req.EncryptedData, &env); err != nil { - return DecryptResponse{}, fmt.Errorf("envelope unmarshal: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } - if env.V != encVersionV1 || env.Alg != algP256HKDFAESGCM { - return DecryptResponse{}, fmt.Errorf("unsupported envelope version/alg") + if env.Version != encVersionV1 || env.Alg != string(EcdhP256) { + return DecryptResponse{}, ErrDecryptionFailed } curve := ecdh.P256() priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) if err != nil { - return DecryptResponse{}, fmt.Errorf("invalid P-256 private key: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } - ephPub, err := curve.NewPublicKey(env.EPK) + ephPub, err := curve.NewPublicKey(env.EphemeralPublicKey) if err != nil { - return DecryptResponse{}, fmt.Errorf("invalid P-256 ephemeral public key: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } shared, err := priv.ECDH(ephPub) if err != nil { - return DecryptResponse{}, fmt.Errorf("ecdh failed: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } - info := []byte("keystore:ecdh-p256:aes-gcm:hkdf-sha256:v1") - aeadKey, err := hkdfAESGCMKey(shared, env.Salt, info, 32) + derivedKey, err := deriveAESKeyFromSharedSecret(shared, env.Salt, infoAESGCM) if err != nil { - return DecryptResponse{}, err + return DecryptResponse{}, ErrDecryptionFailed } - block, err := aes.NewCipher(aeadKey) + block, err := aes.NewCipher(derivedKey) if err != nil { - return DecryptResponse{}, fmt.Errorf("aes: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } gcm, err := cipher.NewGCM(block) if err != nil { - return DecryptResponse{}, fmt.Errorf("gcm: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } - aad := encAAD{V: env.V, Alg: env.Alg, EPK: env.EPK, Salt: env.Salt, Nonce: env.Nonce} + aad := encHeader{Version: env.Version, Alg: env.Alg, EphemeralPublicKey: env.EphemeralPublicKey, Salt: env.Salt, Nonce: env.Nonce} aadBytes, err := json.Marshal(aad) if err != nil { - return DecryptResponse{}, fmt.Errorf("aad marshal: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } - pt, err := gcm.Open(nil, env.Nonce, env.CT, aadBytes) + pt, err := gcm.Open(nil, env.Nonce, env.CipherText, aadBytes) if err != nil { - return DecryptResponse{}, fmt.Errorf("gcm open: %w", err) + return DecryptResponse{}, ErrDecryptionFailed } return DecryptResponse{Data: pt}, nil default: - return DecryptResponse{}, fmt.Errorf("unsupported key type: %s", key.keyType) + return DecryptResponse{}, ErrDecryptionFailed } } @@ -269,18 +284,25 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre k.mu.RLock() defer k.mu.RUnlock() + // Validate request parameters without leaking information + if req.LocalKeyName == "" || len(req.RemotePubKey) == 0 { + return DeriveSharedSecretResponse{}, ErrEncryptionFailed + } + key, ok := k.keystore[req.LocalKeyName] if !ok { - return DeriveSharedSecretResponse{}, fmt.Errorf("key not found: %s", req.LocalKeyName) + // Don't leak key existence - return same error as other failures + return DeriveSharedSecretResponse{}, ErrEncryptionFailed } + switch key.keyType { case X25519: if len(req.RemotePubKey) != 32 { - return DeriveSharedSecretResponse{}, fmt.Errorf("remote public key must be 32 bytes") + return DeriveSharedSecretResponse{}, ErrEncryptionFailed } sharedSecret, err := curve25519.X25519(internal.Bytes(key.privateKey), req.RemotePubKey) if err != nil { - return DeriveSharedSecretResponse{}, fmt.Errorf("failed to derive shared secret: %w", err) + return DeriveSharedSecretResponse{}, ErrEncryptionFailed } return DeriveSharedSecretResponse{ SharedSecret: sharedSecret, @@ -289,18 +311,27 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre curve := ecdh.P256() priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) if err != nil { - return DeriveSharedSecretResponse{}, fmt.Errorf("invalid P-256 private key: %w", err) + return DeriveSharedSecretResponse{}, ErrEncryptionFailed } remotePub, err := curve.NewPublicKey(req.RemotePubKey) if err != nil { - return DeriveSharedSecretResponse{}, fmt.Errorf("invalid P-256 public key: %w", err) + return DeriveSharedSecretResponse{}, ErrEncryptionFailed } shared, err := priv.ECDH(remotePub) if err != nil { - return DeriveSharedSecretResponse{}, fmt.Errorf("ecdh failed: %w", err) + return DeriveSharedSecretResponse{}, ErrEncryptionFailed } return DeriveSharedSecretResponse{SharedSecret: shared}, nil default: - return DeriveSharedSecretResponse{}, fmt.Errorf("unsupported key type: %s", key.keyType) + return DeriveSharedSecretResponse{}, ErrEncryptionFailed } } + +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 index 6400ec248b..9c352616d8 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -2,6 +2,8 @@ package keystore_test import ( "context" + "errors" + "fmt" "testing" "github.com/smartcontractkit/chainlink-common/keystore" @@ -9,7 +11,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestEncryptor_X25519(t *testing.T) { +func TestEncryptDecrypt(t *testing.T) { ctx := context.Background() ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ Password: "test-password", @@ -17,52 +19,64 @@ func TestEncryptor_X25519(t *testing.T) { }) require.NoError(t, err) - keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ - Keys: []keystore.CreateKeyRequest{ - {KeyName: "A", KeyType: keystore.X25519}, - {KeyName: "B", KeyType: keystore.X25519}, - }, + // Create 2 keys of each key type. + testKeysByType := make(map[string]struct { + keyType keystore.KeyType + publicKey []byte }) - require.NoError(t, err) - encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ - KeyName: "A", - // Encrypt to B - RemotePubKey: keys.Keys[1].KeyInfo.PublicKey, - Data: []byte("hello world"), - }) - require.NoError(t, err) - decryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ - KeyName: "B", - EncryptedData: encryptResp.EncryptedData, - }) - require.NoError(t, err) - require.Equal(t, []byte("hello world"), decryptResp.Data) -} + keyName := func(keyType keystore.KeyType, index int) string { + return fmt.Sprintf("key-%s-%d", keyType, index) + } + for _, keyType := range keystore.AllEncryptionKeyTypes { + keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + {KeyName: keyName(keyType, 0), KeyType: keyType}, + {KeyName: keyName(keyType, 1), KeyType: keyType}, + }, + }) + require.NoError(t, err) + testKeysByType[keys.Keys[0].KeyInfo.Name] = struct { + keyType keystore.KeyType + publicKey []byte + }{keyType: keys.Keys[0].KeyInfo.KeyType, publicKey: keys.Keys[0].KeyInfo.PublicKey} + testKeysByType[keys.Keys[1].KeyInfo.Name] = struct { + keyType keystore.KeyType + publicKey []byte + }{keyType: keys.Keys[1].KeyInfo.KeyType, publicKey: keys.Keys[1].KeyInfo.PublicKey} + } -func TestEncryptor_EcdhP256(t *testing.T) { - ctx := context.Background() - ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ - Password: "test-password", - ScryptParams: keystore.FastScryptParams, - }) - require.NoError(t, err) - keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ - Keys: []keystore.CreateKeyRequest{ - {KeyName: "A", KeyType: keystore.EcdhP256}, - {KeyName: "B", KeyType: keystore.EcdhP256}, - }, - }) - require.NoError(t, err) - encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ - KeyName: "A", - RemotePubKey: keys.Keys[1].KeyInfo.PublicKey, - Data: []byte("hello world"), - }) - require.NoError(t, err) - decryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ - KeyName: "B", - EncryptedData: encryptResp.EncryptedData, - }) - require.NoError(t, err) - require.Equal(t, []byte("hello world"), decryptResp.Data) + var tt = []struct { + name string + fromKey string + toKey string + expectedError error + }{ + {name: "Encrypt to self x25519", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.X25519, 0), expectedError: nil}, + {name: "Encrypt to other x25519", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.X25519, 1), expectedError: nil}, + {name: "Encrypt to self ecdh-p256", fromKey: keyName(keystore.EcdhP256, 0), toKey: keyName(keystore.EcdhP256, 0), expectedError: nil}, + {name: "Encrypt to other ecdh-p256", fromKey: keyName(keystore.EcdhP256, 0), toKey: keyName(keystore.EcdhP256, 1), expectedError: nil}, + {name: "Encrypt x25519 to ecdh-p256 should fail", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.EcdhP256, 0), expectedError: keystore.ErrEncryptionFailed}, + {name: "Encrypt ecdh-p256 to x25519 should fail", fromKey: keyName(keystore.EcdhP256, 0), toKey: keyName(keystore.X25519, 0), expectedError: keystore.ErrEncryptionFailed}, + } + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: tt.fromKey, + RemotePubKey: testKeysByType[tt.toKey].publicKey, + Data: []byte("hello world"), + }) + if tt.expectedError != nil { + require.Error(t, err) + require.True(t, errors.Is(err, tt.expectedError)) + return + } + require.NoError(t, err) + decryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: tt.toKey, + EncryptedData: encryptResp.EncryptedData, + }) + require.NoError(t, err) + require.Equal(t, []byte("hello world"), decryptResp.Data) + }) + } } diff --git a/keystore/keystore.go b/keystore/keystore.go index e948fe1cfb..7467700f05 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -22,6 +22,10 @@ import ( type KeyType string +func (k KeyType) String() string { + return string(k) +} + const ( // Hybrid encryption (key exchange + encryption) key types. // Naming schema is generally . @@ -34,7 +38,7 @@ const ( X25519 KeyType = "X25519" // EcdhP256: // - ECDH on P-256 - // - Encryption with AES-GCM. + // - Encryption with AES-GCM and HKDF-SHA256 EcdhP256 KeyType = "ecdh-p256" // Digital signature key types. @@ -47,6 +51,8 @@ const ( ) var AllKeyTypes = []KeyType{X25519, EcdhP256, Ed25519, EcdsaSecp256k1} +var AllEncryptionKeyTypes = []KeyType{X25519, EcdhP256} +var AllDigitalSignatureKeyTypes = []KeyType{Ed25519, EcdsaSecp256k1} type ScryptParams struct { N int 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 } From 232ceb62b0e55f293972c063767d763119eebae3 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 6 Oct 2025 17:55:02 -0400 Subject: [PATCH 03/15] Switch to protobuf to trim the ciphertext size --- keystore/admin.go | 10 ++ keystore/encryptor.go | 85 ++++++++--------- keystore/encryptor_test.go | 122 +++++++++++++++++++++++++ keystore/serialization/keystore.pb.go | 127 ++++++++++++++++++++++++-- keystore/serialization/keystore.proto | 10 ++ 5 files changed, 305 insertions(+), 49 deletions(-) diff --git a/keystore/admin.go b/keystore/admin.go index 1772b30f43..240c99c5ea 100644 --- a/keystore/admin.go +++ b/keystore/admin.go @@ -23,6 +23,9 @@ var ( ErrUnsupportedKeyType = fmt.Errorf("unsupported key type") ) +// CreateKeysRequest represents a request to create multiple keys. +// The Keys slice will be processed in order, and the response will preserve this order. +// It's atomic in that all keys are created or none are created. type CreateKeysRequest struct { Keys []CreateKeyRequest } @@ -32,6 +35,9 @@ type CreateKeyRequest struct { KeyType KeyType } +// CreateKeysResponse contains the created keys in the same order as they were +// requested in CreateKeysRequest.Keys. This ordering guarantee allows clients +// to rely on consistent indexing when processing the response. type CreateKeysResponse struct { Keys []CreateKeyResponse } @@ -129,6 +135,10 @@ func ValidKeyName(name string) error { return nil } +// CreateKeys creates multiple keys in a single operation. The keys are processed +// in the order they appear in req.Keys, and the response preserves this exact order. +// This ordering guarantee allows clients to rely on consistent indexing when +// processing the response (e.g., keys[0] corresponds to req.Keys[0]). func (ks *keystore) CreateKeys(ctx context.Context, req CreateKeysRequest) (CreateKeysResponse, error) { ks.mu.Lock() defer ks.mu.Unlock() diff --git a/keystore/encryptor.go b/keystore/encryptor.go index 92506878b6..66f9030567 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -7,15 +7,16 @@ import ( "crypto/ecdh" "crypto/rand" "crypto/sha256" - "encoding/json" "fmt" "io" "golang.org/x/crypto/curve25519" "golang.org/x/crypto/hkdf" "golang.org/x/crypto/nacl/box" + "google.golang.org/protobuf/proto" "github.com/smartcontractkit/chainlink-common/keystore/internal" + "github.com/smartcontractkit/chainlink-common/keystore/serialization" ) // Opaque error messages to prevent information leakage @@ -55,8 +56,13 @@ type DeriveSharedSecretResponse struct { const ( // 16 byte is the standard NIST recommended salt size for HKDF. hkdfSaltSize = 16 - // 1 byte is the version for the encryption envelope. - encVersionV1 byte = 1 + // Version for the encryption envelope. + encVersionV1 uint32 = 1 + // 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 + // Overhead size for the encryption envelope. + overHeadSize = 1024 ) var ( @@ -64,19 +70,6 @@ var ( infoAESGCM = []byte("keystore:ecdh-p256:aes-gcm:hkdf-sha256:v1") ) -type encHeader struct { - Version byte `json:"version"` - Alg string `json:"alg"` - EphemeralPublicKey []byte `json:"ephemeral_public_key"` - Salt []byte `json:"salt"` - Nonce []byte `json:"nonce"` -} - -type encEnvelope struct { - encHeader - CipherText []byte `json:"ciphertext"` -} - // 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 @@ -107,14 +100,11 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp k.mu.RLock() defer k.mu.RUnlock() - // Validate request parameters without leaking information - if req.KeyName == "" || len(req.Data) == 0 { + if len(req.Data) == 0 || len(req.Data) > MaxEncryptionPayloadSize { return EncryptResponse{}, ErrEncryptionFailed } - key, ok := k.keystore[req.KeyName] if !ok { - // Don't leak key existence - return same error as other failures return EncryptResponse{}, ErrEncryptionFailed } @@ -179,25 +169,31 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp return EncryptResponse{}, ErrEncryptionFailed } ephPub := ephPriv.PublicKey().Bytes() - head := encHeader{ + + // Create the protobuf envelope + envelope := &serialization.EcdhEnvelope{ Version: encVersionV1, - Alg: EcdhP256.String(), + Algorithm: EcdhP256.String(), EphemeralPublicKey: ephPub, Salt: salt, Nonce: nonce, } - aadBytes, err := json.Marshal(head) + + // Marshal the envelope for AAD (without ciphertext) + aadBytes, err := proto.Marshal(envelope) if err != nil { return EncryptResponse{}, ErrEncryptionFailed } + // Critical to include the header parameters as additional authenticated data. // Prevents a MITM from changing the header. ciphertext := gcm.Seal(nil, nonce, req.Data, aadBytes) - env := encEnvelope{ - encHeader: head, - CipherText: ciphertext, - } - out, err := json.Marshal(env) + + // Add ciphertext to envelope + envelope.Ciphertext = ciphertext + + // Marshal the complete envelope + out, err := proto.Marshal(envelope) if err != nil { return EncryptResponse{}, ErrEncryptionFailed } @@ -211,15 +207,12 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp k.mu.RLock() defer k.mu.RUnlock() - // Validate request parameters without leaking information - - if req.KeyName == "" || len(req.EncryptedData) == 0 { + if len(req.EncryptedData) == 0 || len(req.EncryptedData) > (MaxEncryptionPayloadSize+overHeadSize) { return DecryptResponse{}, ErrDecryptionFailed } key, ok := k.keystore[req.KeyName] if !ok { - // Don't leak key existence - return same error as other failures return DecryptResponse{}, ErrDecryptionFailed } @@ -233,11 +226,11 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp Data: decrypted, }, nil case EcdhP256: - var env encEnvelope - if err := json.Unmarshal(req.EncryptedData, &env); err != nil { + var envelope serialization.EcdhEnvelope + if err := proto.Unmarshal(req.EncryptedData, &envelope); err != nil { return DecryptResponse{}, ErrDecryptionFailed } - if env.Version != encVersionV1 || env.Alg != string(EcdhP256) { + if envelope.Version != encVersionV1 || envelope.Algorithm != string(EcdhP256) { return DecryptResponse{}, ErrDecryptionFailed } curve := ecdh.P256() @@ -245,7 +238,7 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp if err != nil { return DecryptResponse{}, ErrDecryptionFailed } - ephPub, err := curve.NewPublicKey(env.EphemeralPublicKey) + ephPub, err := curve.NewPublicKey(envelope.EphemeralPublicKey) if err != nil { return DecryptResponse{}, ErrDecryptionFailed } @@ -253,7 +246,7 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp if err != nil { return DecryptResponse{}, ErrDecryptionFailed } - derivedKey, err := deriveAESKeyFromSharedSecret(shared, env.Salt, infoAESGCM) + derivedKey, err := deriveAESKeyFromSharedSecret(shared, envelope.Salt, infoAESGCM) if err != nil { return DecryptResponse{}, ErrDecryptionFailed } @@ -265,12 +258,22 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp if err != nil { return DecryptResponse{}, ErrDecryptionFailed } - aad := encHeader{Version: env.Version, Alg: env.Alg, EphemeralPublicKey: env.EphemeralPublicKey, Salt: env.Salt, Nonce: env.Nonce} - aadBytes, err := json.Marshal(aad) + + // Recreate the envelope for AAD (without ciphertext) + aadEnvelope := &serialization.EcdhEnvelope{ + Version: envelope.Version, + Algorithm: envelope.Algorithm, + EphemeralPublicKey: envelope.EphemeralPublicKey, + Salt: envelope.Salt, + Nonce: envelope.Nonce, + // Ciphertext is intentionally omitted for AAD + } + aadBytes, err := proto.Marshal(aadEnvelope) if err != nil { return DecryptResponse{}, ErrDecryptionFailed } - pt, err := gcm.Open(nil, env.Nonce, env.CipherText, aadBytes) + + pt, err := gcm.Open(nil, envelope.Nonce, envelope.Ciphertext, aadBytes) if err != nil { return DecryptResponse{}, ErrDecryptionFailed } @@ -284,14 +287,12 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre k.mu.RLock() defer k.mu.RUnlock() - // Validate request parameters without leaking information if req.LocalKeyName == "" || len(req.RemotePubKey) == 0 { return DeriveSharedSecretResponse{}, ErrEncryptionFailed } key, ok := k.keystore[req.LocalKeyName] if !ok { - // Don't leak key existence - return same error as other failures return DeriveSharedSecretResponse{}, ErrEncryptionFailed } diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index 9c352616d8..1a5775312c 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -80,3 +80,125 @@ func TestEncryptDecrypt(t *testing.T) { }) } } + +func TestEncryptDecrypt_PayloadSizeLimit(t *testing.T) { + ctx := context.Background() + ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ + Password: "test-password", + ScryptParams: keystore.FastScryptParams, + }) + require.NoError(t, err) + + for _, keyType := range []keystore.KeyType{keystore.EcdhP256} { + t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { + keyName := fmt.Sprintf("test-key-%s", keyType) + keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + {KeyName: keyName, KeyType: keyType}, + }, + }) + require.NoError(t, err) + // Test encrypting at the limit + maxPayload := make([]byte, keystore.MaxEncryptionPayloadSize) + maxEncryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: keyName, + RemotePubKey: keys.Keys[0].KeyInfo.PublicKey, + Data: maxPayload, + }) + require.NoError(t, err) + + // Test decrypting at max (confirm overhead sufficient) + maxDecryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: keyName, + EncryptedData: maxEncryptResp.EncryptedData, + }) + require.NoError(t, err) + require.Equal(t, len(maxDecryptResp.Data), len(maxPayload)) + + // Test encrypting above the limit + _, err = ks.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: keyName, + RemotePubKey: keys.Keys[0].KeyInfo.PublicKey, + Data: make([]byte, keystore.MaxEncryptionPayloadSize+1), + }) + require.Error(t, err) + + // Test decrypting above the limit + _, err = ks.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: keyName, + EncryptedData: make([]byte, keystore.MaxEncryptionPayloadSize+1025), + }) + require.Error(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 := context.Background() + ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ + Password: "test-password", + ScryptParams: keystore.FastScryptParams, + }) + require.NoError(t, err) + + // Test each encryption key type + for i, keyType := range keystore.AllEncryptionKeyTypes { + t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { + // Create two keys of the same type for encryption/decryption + senderName := fmt.Sprintf("sender-%d", i) + receiverName := fmt.Sprintf("receiver-%d", i) + keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + {KeyName: senderName, KeyType: keyType}, + {KeyName: receiverName, KeyType: keyType}, + }, + }) + require.NoError(t, err) + + // Encrypt data using sender key to receiver's public key + encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: senderName, + RemotePubKey: keys.Keys[1].KeyInfo.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 := ks.Decrypt(ctx, keystore.DecryptRequest{ + KeyName: receiverName, + 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/serialization/keystore.pb.go b/keystore/serialization/keystore.pb.go index 19bf3222f9..475dfa531e 100644 --- a/keystore/serialization/keystore.pb.go +++ b/keystore/serialization/keystore.pb.go @@ -148,6 +148,94 @@ func (x *Key) GetMetadata() []byte { return nil } +// ECDH encryption envelope for hybrid encryption +type EcdhEnvelope struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` + Algorithm string `protobuf:"bytes,2,opt,name=algorithm,proto3" json:"algorithm,omitempty"` + EphemeralPublicKey []byte `protobuf:"bytes,3,opt,name=ephemeral_public_key,json=ephemeralPublicKey,proto3" json:"ephemeral_public_key,omitempty"` + Salt []byte `protobuf:"bytes,4,opt,name=salt,proto3" json:"salt,omitempty"` + Nonce []byte `protobuf:"bytes,5,opt,name=nonce,proto3" json:"nonce,omitempty"` + Ciphertext []byte `protobuf:"bytes,6,opt,name=ciphertext,proto3" json:"ciphertext,omitempty"` +} + +func (x *EcdhEnvelope) Reset() { + *x = EcdhEnvelope{} + if protoimpl.UnsafeEnabled { + mi := &file_keystore_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *EcdhEnvelope) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EcdhEnvelope) ProtoMessage() {} + +func (x *EcdhEnvelope) ProtoReflect() protoreflect.Message { + mi := &file_keystore_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EcdhEnvelope.ProtoReflect.Descriptor instead. +func (*EcdhEnvelope) Descriptor() ([]byte, []int) { + return file_keystore_proto_rawDescGZIP(), []int{2} +} + +func (x *EcdhEnvelope) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *EcdhEnvelope) GetAlgorithm() string { + if x != nil { + return x.Algorithm + } + return "" +} + +func (x *EcdhEnvelope) GetEphemeralPublicKey() []byte { + if x != nil { + return x.EphemeralPublicKey + } + return nil +} + +func (x *EcdhEnvelope) GetSalt() []byte { + if x != nil { + return x.Salt + } + return nil +} + +func (x *EcdhEnvelope) GetNonce() []byte { + if x != nil { + return x.Nonce + } + return nil +} + +func (x *EcdhEnvelope) GetCiphertext() []byte { + if x != nil { + return x.Ciphertext + } + return nil +} + var File_keystore_proto protoreflect.FileDescriptor var file_keystore_proto_rawDesc = []byte{ @@ -165,9 +253,21 @@ var file_keystore_proto_rawDesc = []byte{ 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x6d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x42, 0x12, 0x5a, 0x10, 0x2e, 0x2f, 0x3b, 0x73, 0x65, 0x72, - 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x33, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xc2, 0x01, 0x0a, 0x0c, 0x45, 0x63, 0x64, 0x68, 0x45, + 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, + 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x12, + 0x30, 0x0a, 0x14, 0x65, 0x70, 0x68, 0x65, 0x6d, 0x65, 0x72, 0x61, 0x6c, 0x5f, 0x70, 0x75, 0x62, + 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x65, + 0x70, 0x68, 0x65, 0x6d, 0x65, 0x72, 0x61, 0x6c, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, + 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x04, 0x73, 0x61, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x05, + 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x63, + 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x42, 0x12, 0x5a, 0x10, 0x2e, + 0x2f, 0x3b, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x62, + 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -182,10 +282,11 @@ func file_keystore_proto_rawDescGZIP() []byte { return file_keystore_proto_rawDescData } -var file_keystore_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_keystore_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_keystore_proto_goTypes = []interface{}{ - (*Keystore)(nil), // 0: serialization.Keystore - (*Key)(nil), // 1: serialization.Key + (*Keystore)(nil), // 0: serialization.Keystore + (*Key)(nil), // 1: serialization.Key + (*EcdhEnvelope)(nil), // 2: serialization.EcdhEnvelope } var file_keystore_proto_depIdxs = []int32{ 1, // 0: serialization.Keystore.keys:type_name -> serialization.Key @@ -226,6 +327,18 @@ func file_keystore_proto_init() { return nil } } + file_keystore_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*EcdhEnvelope); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -233,7 +346,7 @@ func file_keystore_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_keystore_proto_rawDesc, NumEnums: 0, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/keystore/serialization/keystore.proto b/keystore/serialization/keystore.proto index a388934e73..994142c862 100644 --- a/keystore/serialization/keystore.proto +++ b/keystore/serialization/keystore.proto @@ -15,4 +15,14 @@ message Key { int64 created_at = 3; string key_type = 4; bytes metadata = 5; +} + +// ECDH encryption envelope for hybrid encryption +message EcdhEnvelope { + uint32 version = 1; + string algorithm = 2; + bytes ephemeral_public_key = 3; + bytes salt = 4; + bytes nonce = 5; + bytes ciphertext = 6; } \ No newline at end of file From 7d07720b7b6f728911fcd90d5a052e77a63e8eb5 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Mon, 6 Oct 2025 18:00:26 -0400 Subject: [PATCH 04/15] More test coverage --- keystore/admin.go | 12 ++---------- keystore/encryptor.go | 7 +++---- keystore/encryptor_test.go | 28 +++++++++++++++++++++++++++- 3 files changed, 32 insertions(+), 15 deletions(-) diff --git a/keystore/admin.go b/keystore/admin.go index 240c99c5ea..5907303e90 100644 --- a/keystore/admin.go +++ b/keystore/admin.go @@ -23,9 +23,6 @@ var ( ErrUnsupportedKeyType = fmt.Errorf("unsupported key type") ) -// CreateKeysRequest represents a request to create multiple keys. -// The Keys slice will be processed in order, and the response will preserve this order. -// It's atomic in that all keys are created or none are created. type CreateKeysRequest struct { Keys []CreateKeyRequest } @@ -35,9 +32,6 @@ type CreateKeyRequest struct { KeyType KeyType } -// CreateKeysResponse contains the created keys in the same order as they were -// requested in CreateKeysRequest.Keys. This ordering guarantee allows clients -// to rely on consistent indexing when processing the response. type CreateKeysResponse struct { Keys []CreateKeyResponse } @@ -135,10 +129,8 @@ func ValidKeyName(name string) error { return nil } -// CreateKeys creates multiple keys in a single operation. The keys are processed -// in the order they appear in req.Keys, and the response preserves this exact order. -// This ordering guarantee allows clients to rely on consistent indexing when -// processing the response (e.g., keys[0] corresponds to req.Keys[0]). +// 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() diff --git a/keystore/encryptor.go b/keystore/encryptor.go index 66f9030567..545cf2ad04 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -287,10 +287,6 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre k.mu.RLock() defer k.mu.RUnlock() - if req.LocalKeyName == "" || len(req.RemotePubKey) == 0 { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed - } - key, ok := k.keystore[req.LocalKeyName] if !ok { return DeriveSharedSecretResponse{}, ErrEncryptionFailed @@ -314,6 +310,9 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre if err != nil { return DeriveSharedSecretResponse{}, ErrEncryptionFailed } + if len(req.RemotePubKey) == 32 { + return DeriveSharedSecretResponse{}, ErrEncryptionFailed + } remotePub, err := curve.NewPublicKey(req.RemotePubKey) if err != nil { return DeriveSharedSecretResponse{}, ErrEncryptionFailed diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index 1a5775312c..c214d800b9 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -81,6 +81,32 @@ func TestEncryptDecrypt(t *testing.T) { } } +func TestEncryptDecrypt_SharedSecret(t *testing.T) { + ctx := context.Background() + ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ + Password: "test-password", + ScryptParams: keystore.FastScryptParams, + }) + require.NoError(t, err) + + for _, keyType := range keystore.AllEncryptionKeyTypes { + t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { + keyName := fmt.Sprintf("test-key-%s", keyType) + keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ + Keys: []keystore.CreateKeyRequest{ + {KeyName: keyName, KeyType: keyType}, + }, + }) + require.NoError(t, err) + _, err = ks.DeriveSharedSecret(ctx, keystore.DeriveSharedSecretRequest{ + LocalKeyName: keyName, + RemotePubKey: keys.Keys[0].KeyInfo.PublicKey, + }) + require.NoError(t, err) + }) + } +} + func TestEncryptDecrypt_PayloadSizeLimit(t *testing.T) { ctx := context.Background() ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ @@ -89,7 +115,7 @@ func TestEncryptDecrypt_PayloadSizeLimit(t *testing.T) { }) require.NoError(t, err) - for _, keyType := range []keystore.KeyType{keystore.EcdhP256} { + for _, keyType := range keystore.AllEncryptionKeyTypes { t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { keyName := fmt.Sprintf("test-key-%s", keyType) keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ From b6404fa3add38edc98a7b769cb078100b78efda3 Mon Sep 17 00:00:00 2001 From: Awbrey Hughlett Date: Mon, 6 Oct 2025 12:04:33 -0500 Subject: [PATCH 05/15] =?UTF-8?q?add=20field=20to=20capability=20response?= =?UTF-8?q?=20metadata=20to=20convey=20the=20total=20number=20=E2=80=A6=20?= =?UTF-8?q?(#1574)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add field to capability response metadata to convey the total number of don participants * update property for better clarity and add to helpers --- pkg/capabilities/capabilities.go | 1 + pkg/capabilities/pb/capabilities.pb.go | 16 +++++++++++++--- pkg/capabilities/pb/capabilities.proto | 2 ++ pkg/capabilities/pb/capabilities_helpers.go | 2 ++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/capabilities/capabilities.go b/pkg/capabilities/capabilities.go index 86565d5990..a06cf8fce0 100644 --- a/pkg/capabilities/capabilities.go +++ b/pkg/capabilities/capabilities.go @@ -74,6 +74,7 @@ type CapabilityResponse struct { type ResponseMetadata struct { Metering []MeteringNodeDetail + CapDON_N uint32 } type MeteringNodeDetail struct { diff --git a/pkg/capabilities/pb/capabilities.pb.go b/pkg/capabilities/pb/capabilities.pb.go index d82ad26e7e..f53a95ecbe 100644 --- a/pkg/capabilities/pb/capabilities.pb.go +++ b/pkg/capabilities/pb/capabilities.pb.go @@ -796,7 +796,9 @@ type ResponseMetadata struct { // // If you are working with this in a capability, you should not emit // more than one metering report per node. - Metering []*pb1.MeteringReportNodeDetail `protobuf:"bytes,1,rep,name=metering,proto3" json:"metering,omitempty"` + Metering []*pb1.MeteringReportNodeDetail `protobuf:"bytes,1,rep,name=metering,proto3" json:"metering,omitempty"` + // capdon_n represents the total number of nodes in a capability don. + CapdonN uint32 `protobuf:"varint,2,opt,name=capdon_n,json=capdonN,proto3" json:"capdon_n,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -838,6 +840,13 @@ func (x *ResponseMetadata) GetMetering() []*pb1.MeteringReportNodeDetail { return nil } +func (x *ResponseMetadata) GetCapdonN() uint32 { + if x != nil { + return x.CapdonN + } + return 0 +} + type RegistrationMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` WorkflowId string `protobuf:"bytes,1,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` @@ -1223,9 +1232,10 @@ const file_capabilities_proto_rawDesc = "" + "\x05value\x18\x01 \x01(\v2\x0e.values.v1.MapR\x05value\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12:\n" + "\bmetadata\x18\x03 \x01(\v2\x1e.capabilities.ResponseMetadataR\bmetadata\x12.\n" + - "\apayload\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\apayload\"R\n" + + "\apayload\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\apayload\"m\n" + "\x10ResponseMetadata\x12>\n" + - "\bmetering\x18\x01 \x03(\v2\".metering.MeteringReportNodeDetailR\bmetering\"\x81\x01\n" + + "\bmetering\x18\x01 \x03(\v2\".metering.MeteringReportNodeDetailR\bmetering\x12\x19\n" + + "\bcapdon_n\x18\x02 \x01(\rR\acapdonN\"\x81\x01\n" + "\x14RegistrationMetadata\x12\x1f\n" + "\vworkflow_id\x18\x01 \x01(\tR\n" + "workflowId\x12!\n" + diff --git a/pkg/capabilities/pb/capabilities.proto b/pkg/capabilities/pb/capabilities.proto index b18ca5e5a5..bb491b3729 100644 --- a/pkg/capabilities/pb/capabilities.proto +++ b/pkg/capabilities/pb/capabilities.proto @@ -127,6 +127,8 @@ message ResponseMetadata { // If you are working with this in a capability, you should not emit // more than one metering report per node. repeated metering.MeteringReportNodeDetail metering = 1; + // capdon_n represents the total number of nodes in a capability don. + uint32 capdon_n = 2; } message RegistrationMetadata { diff --git a/pkg/capabilities/pb/capabilities_helpers.go b/pkg/capabilities/pb/capabilities_helpers.go index 4bf2e85835..8c03be56d7 100644 --- a/pkg/capabilities/pb/capabilities_helpers.go +++ b/pkg/capabilities/pb/capabilities_helpers.go @@ -90,6 +90,7 @@ func CapabilityResponseToProto(resp capabilities.CapabilityResponse) *Capability Value: values.ProtoMap(resp.Value), Metadata: &ResponseMetadata{ Metering: metering, + CapdonN: resp.Metadata.CapDON_N, }, Payload: resp.Payload, } @@ -166,6 +167,7 @@ func CapabilityResponseFromProto(pr *CapabilityResponse) (capabilities.Capabilit Value: val, Metadata: capabilities.ResponseMetadata{ Metering: metering, + CapDON_N: pr.Metadata.GetCapdonN(), }, Payload: pr.Payload, } From 746a336126259790ce53a641ea3084b541bac30a Mon Sep 17 00:00:00 2001 From: connorwstein Date: Tue, 7 Oct 2025 14:43:36 -0400 Subject: [PATCH 06/15] Comply with go naming conventions --- keystore/admin.go | 8 +++---- keystore/encryptor.go | 20 ++++++++-------- keystore/encryptor_test.go | 8 +++---- keystore/keystore.go | 18 +++++++------- keystore/keystore_internal_test.go | 2 +- keystore/serialization/keystore.pb.go | 34 +++++++++++++-------------- keystore/serialization/keystore.proto | 2 +- 7 files changed, 46 insertions(+), 46 deletions(-) diff --git a/keystore/admin.go b/keystore/admin.go index 5907303e90..614e81f7f2 100644 --- a/keystore/admin.go +++ b/keystore/admin.go @@ -155,10 +155,10 @@ 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) if err != nil { @@ -176,10 +176,10 @@ 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 EcdhP256: + case ECDH_P256: privateKey, err := ecdh.P256().GenerateKey(rand.Reader) if err != nil { - return CreateKeysResponse{}, fmt.Errorf("failed to generate EcdhP256 key: %w", err) + return CreateKeysResponse{}, fmt.Errorf("failed to generate ECDH_P256 key: %w", err) } publicKey, err := publicKeyFromPrivateKey(internal.NewRaw(privateKey.Bytes()), keyReq.KeyType) if err != nil { diff --git a/keystore/encryptor.go b/keystore/encryptor.go index 545cf2ad04..3b730c7bec 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -62,7 +62,7 @@ const ( // Note just an initial limit, we may want to increase this in the future. MaxEncryptionPayloadSize = 100 * 1024 // Overhead size for the encryption envelope. - overHeadSize = 1024 + overheadSize = 1024 ) var ( @@ -120,7 +120,7 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp return EncryptResponse{ EncryptedData: encrypted, }, nil - case EcdhP256: + case ECDH_P256: curve := ecdh.P256() if len(req.RemotePubKey) == 0 { return EncryptResponse{}, ErrEncryptionFailed @@ -171,9 +171,9 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp ephPub := ephPriv.PublicKey().Bytes() // Create the protobuf envelope - envelope := &serialization.EcdhEnvelope{ + envelope := &serialization.ECDHEnvelope{ Version: encVersionV1, - Algorithm: EcdhP256.String(), + Algorithm: ECDH_P256.String(), EphemeralPublicKey: ephPub, Salt: salt, Nonce: nonce, @@ -207,7 +207,7 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp k.mu.RLock() defer k.mu.RUnlock() - if len(req.EncryptedData) == 0 || len(req.EncryptedData) > (MaxEncryptionPayloadSize+overHeadSize) { + if len(req.EncryptedData) == 0 || len(req.EncryptedData) > (MaxEncryptionPayloadSize+overheadSize) { return DecryptResponse{}, ErrDecryptionFailed } @@ -225,12 +225,12 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp return DecryptResponse{ Data: decrypted, }, nil - case EcdhP256: - var envelope serialization.EcdhEnvelope + case ECDH_P256: + var envelope serialization.ECDHEnvelope if err := proto.Unmarshal(req.EncryptedData, &envelope); err != nil { return DecryptResponse{}, ErrDecryptionFailed } - if envelope.Version != encVersionV1 || envelope.Algorithm != string(EcdhP256) { + if envelope.Version != encVersionV1 || envelope.Algorithm != string(ECDH_P256) { return DecryptResponse{}, ErrDecryptionFailed } curve := ecdh.P256() @@ -260,7 +260,7 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp } // Recreate the envelope for AAD (without ciphertext) - aadEnvelope := &serialization.EcdhEnvelope{ + aadEnvelope := &serialization.ECDHEnvelope{ Version: envelope.Version, Algorithm: envelope.Algorithm, EphemeralPublicKey: envelope.EphemeralPublicKey, @@ -304,7 +304,7 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre return DeriveSharedSecretResponse{ SharedSecret: sharedSecret, }, nil - case EcdhP256: + case ECDH_P256: curve := ecdh.P256() priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) if err != nil { diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index c214d800b9..9e9a1ea0fb 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -53,10 +53,10 @@ func TestEncryptDecrypt(t *testing.T) { }{ {name: "Encrypt to self x25519", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.X25519, 0), expectedError: nil}, {name: "Encrypt to other x25519", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.X25519, 1), expectedError: nil}, - {name: "Encrypt to self ecdh-p256", fromKey: keyName(keystore.EcdhP256, 0), toKey: keyName(keystore.EcdhP256, 0), expectedError: nil}, - {name: "Encrypt to other ecdh-p256", fromKey: keyName(keystore.EcdhP256, 0), toKey: keyName(keystore.EcdhP256, 1), expectedError: nil}, - {name: "Encrypt x25519 to ecdh-p256 should fail", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.EcdhP256, 0), expectedError: keystore.ErrEncryptionFailed}, - {name: "Encrypt ecdh-p256 to x25519 should fail", fromKey: keyName(keystore.EcdhP256, 0), toKey: keyName(keystore.X25519, 0), expectedError: keystore.ErrEncryptionFailed}, + {name: "Encrypt to self ecdh-p256", fromKey: keyName(keystore.ECDH_P256, 0), toKey: keyName(keystore.ECDH_P256, 0), expectedError: nil}, + {name: "Encrypt to other ecdh-p256", fromKey: keyName(keystore.ECDH_P256, 0), toKey: keyName(keystore.ECDH_P256, 1), expectedError: nil}, + {name: "Encrypt x25519 to ecdh-p256 should fail", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.ECDH_P256, 0), expectedError: keystore.ErrEncryptionFailed}, + {name: "Encrypt ecdh-p256 to x25519 should fail", fromKey: keyName(keystore.ECDH_P256, 0), toKey: keyName(keystore.X25519, 0), expectedError: keystore.ErrEncryptionFailed}, } for _, tt := range tt { t.Run(tt.name, func(t *testing.T) { diff --git a/keystore/keystore.go b/keystore/keystore.go index 7467700f05..86cf200ccc 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -36,23 +36,23 @@ const ( // - X25519 for ECDH key exchange. // - Box for encryption (ChaCha20Poly1305) X25519 KeyType = "X25519" - // EcdhP256: + // ECDH_P256: // - ECDH on P-256 // - Encryption with AES-GCM and HKDF-SHA256 - EcdhP256 KeyType = "ecdh-p256" + ECDH_P256 KeyType = "ecdh-p256" // Digital signature key types. // Ed25519: // - Ed25519 for digital signatures. Ed25519 KeyType = "ed25519" - // EcdsaSecp256k1: + // ECDSA_S256: // - ECDSA on secp256k1 for digital signatures. - EcdsaSecp256k1 KeyType = "ecdsa-secp256k1" + ECDSA_S256 KeyType = "ecdsa-secp256k1" ) -var AllKeyTypes = []KeyType{X25519, EcdhP256, Ed25519, EcdsaSecp256k1} -var AllEncryptionKeyTypes = []KeyType{X25519, EcdhP256} -var AllDigitalSignatureKeyTypes = []KeyType{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 @@ -144,7 +144,7 @@ func publicKeyFromPrivateKey(privateKeyBytes internal.Raw, keyType KeyType) ([]b switch keyType { case Ed25519: return ed25519.PublicKey(internal.Bytes(privateKeyBytes)), nil - case EcdsaSecp256k1: + 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 @@ -161,7 +161,7 @@ func publicKeyFromPrivateKey(privateKeyBytes internal.Raw, keyType KeyType) ([]b return nil, fmt.Errorf("failed to derive shared secret: %w", err) } return pubKey, nil - case EcdhP256: + case ECDH_P256: curve := ecdh.P256() priv, err := curve.NewPrivateKey(internal.Bytes(privateKeyBytes)) if err != nil { diff --git a/keystore/keystore_internal_test.go b/keystore/keystore_internal_test.go index c8c16b4701..8144f9074d 100644 --- a/keystore/keystore_internal_test.go +++ b/keystore/keystore_internal_test.go @@ -13,7 +13,7 @@ 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) diff --git a/keystore/serialization/keystore.pb.go b/keystore/serialization/keystore.pb.go index 475dfa531e..f03581febc 100644 --- a/keystore/serialization/keystore.pb.go +++ b/keystore/serialization/keystore.pb.go @@ -149,7 +149,7 @@ func (x *Key) GetMetadata() []byte { } // ECDH encryption envelope for hybrid encryption -type EcdhEnvelope struct { +type ECDHEnvelope struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields @@ -162,8 +162,8 @@ type EcdhEnvelope struct { Ciphertext []byte `protobuf:"bytes,6,opt,name=ciphertext,proto3" json:"ciphertext,omitempty"` } -func (x *EcdhEnvelope) Reset() { - *x = EcdhEnvelope{} +func (x *ECDHEnvelope) Reset() { + *x = ECDHEnvelope{} if protoimpl.UnsafeEnabled { mi := &file_keystore_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -171,13 +171,13 @@ func (x *EcdhEnvelope) Reset() { } } -func (x *EcdhEnvelope) String() string { +func (x *ECDHEnvelope) String() string { return protoimpl.X.MessageStringOf(x) } -func (*EcdhEnvelope) ProtoMessage() {} +func (*ECDHEnvelope) ProtoMessage() {} -func (x *EcdhEnvelope) ProtoReflect() protoreflect.Message { +func (x *ECDHEnvelope) ProtoReflect() protoreflect.Message { mi := &file_keystore_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -189,47 +189,47 @@ func (x *EcdhEnvelope) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use EcdhEnvelope.ProtoReflect.Descriptor instead. -func (*EcdhEnvelope) Descriptor() ([]byte, []int) { +// Deprecated: Use ECDHEnvelope.ProtoReflect.Descriptor instead. +func (*ECDHEnvelope) Descriptor() ([]byte, []int) { return file_keystore_proto_rawDescGZIP(), []int{2} } -func (x *EcdhEnvelope) GetVersion() uint32 { +func (x *ECDHEnvelope) GetVersion() uint32 { if x != nil { return x.Version } return 0 } -func (x *EcdhEnvelope) GetAlgorithm() string { +func (x *ECDHEnvelope) GetAlgorithm() string { if x != nil { return x.Algorithm } return "" } -func (x *EcdhEnvelope) GetEphemeralPublicKey() []byte { +func (x *ECDHEnvelope) GetEphemeralPublicKey() []byte { if x != nil { return x.EphemeralPublicKey } return nil } -func (x *EcdhEnvelope) GetSalt() []byte { +func (x *ECDHEnvelope) GetSalt() []byte { if x != nil { return x.Salt } return nil } -func (x *EcdhEnvelope) GetNonce() []byte { +func (x *ECDHEnvelope) GetNonce() []byte { if x != nil { return x.Nonce } return nil } -func (x *EcdhEnvelope) GetCiphertext() []byte { +func (x *ECDHEnvelope) GetCiphertext() []byte { if x != nil { return x.Ciphertext } @@ -253,7 +253,7 @@ var file_keystore_proto_rawDesc = []byte{ 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x6d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xc2, 0x01, 0x0a, 0x0c, 0x45, 0x63, 0x64, 0x68, 0x45, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xc2, 0x01, 0x0a, 0x0c, 0x45, 0x43, 0x44, 0x48, 0x45, 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x02, @@ -286,7 +286,7 @@ var file_keystore_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_keystore_proto_goTypes = []interface{}{ (*Keystore)(nil), // 0: serialization.Keystore (*Key)(nil), // 1: serialization.Key - (*EcdhEnvelope)(nil), // 2: serialization.EcdhEnvelope + (*ECDHEnvelope)(nil), // 2: serialization.ECDHEnvelope } var file_keystore_proto_depIdxs = []int32{ 1, // 0: serialization.Keystore.keys:type_name -> serialization.Key @@ -328,7 +328,7 @@ func file_keystore_proto_init() { } } file_keystore_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*EcdhEnvelope); i { + switch v := v.(*ECDHEnvelope); i { case 0: return &v.state case 1: diff --git a/keystore/serialization/keystore.proto b/keystore/serialization/keystore.proto index 994142c862..3a15656ea7 100644 --- a/keystore/serialization/keystore.proto +++ b/keystore/serialization/keystore.proto @@ -18,7 +18,7 @@ message Key { } // ECDH encryption envelope for hybrid encryption -message EcdhEnvelope { +message ECDHEnvelope { uint32 version = 1; string algorithm = 2; bytes ephemeral_public_key = 3; From d78be609fd5dd70a7a4cdb25b23a4aefa74b1fa8 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Tue, 7 Oct 2025 14:57:16 -0400 Subject: [PATCH 07/15] Improve encrypt test coverage --- keystore/encryptor_test.go | 43 ++++++++++++++++++++++++++++++-------- keystore/keystore.go | 10 +++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index 9e9a1ea0fb..a3a0219e2b 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -27,7 +27,7 @@ func TestEncryptDecrypt(t *testing.T) { keyName := func(keyType keystore.KeyType, index int) string { return fmt.Sprintf("key-%s-%d", keyType, index) } - for _, keyType := range keystore.AllEncryptionKeyTypes { + for _, keyType := range keystore.AllKeyTypes { keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ Keys: []keystore.CreateKeyRequest{ {KeyName: keyName(keyType, 0), KeyType: keyType}, @@ -45,18 +45,43 @@ func TestEncryptDecrypt(t *testing.T) { }{keyType: keys.Keys[1].KeyInfo.KeyType, publicKey: keys.Keys[1].KeyInfo.PublicKey} } - var tt = []struct { + var tt []struct { name string fromKey string toKey string expectedError error - }{ - {name: "Encrypt to self x25519", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.X25519, 0), expectedError: nil}, - {name: "Encrypt to other x25519", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.X25519, 1), expectedError: nil}, - {name: "Encrypt to self ecdh-p256", fromKey: keyName(keystore.ECDH_P256, 0), toKey: keyName(keystore.ECDH_P256, 0), expectedError: nil}, - {name: "Encrypt to other ecdh-p256", fromKey: keyName(keystore.ECDH_P256, 0), toKey: keyName(keystore.ECDH_P256, 1), expectedError: nil}, - {name: "Encrypt x25519 to ecdh-p256 should fail", fromKey: keyName(keystore.X25519, 0), toKey: keyName(keystore.ECDH_P256, 0), expectedError: keystore.ErrEncryptionFailed}, - {name: "Encrypt ecdh-p256 to x25519 should fail", fromKey: keyName(keystore.ECDH_P256, 0), toKey: keyName(keystore.X25519, 0), expectedError: keystore.ErrEncryptionFailed}, + } + + for _, fromType := range keystore.AllKeyTypes { + for _, toType := range keystore.AllKeyTypes { + // Test both same key (index 0) and different key (index 1) scenarios + for keyIndex := 0; keyIndex < 2; keyIndex++ { + testName := fmt.Sprintf("Encrypt %s to %s (key %d)", fromType, toType, keyIndex) + fromKey := keyName(fromType, 0) // Always use key 0 as source + toKey := keyName(toType, keyIndex) + + var expectedError error + if fromType == toType && fromType.IsEncryptionKeyType() { + // Same key types should succeed + expectedError = nil + } else { + // Different key types or non-encryption key types should fail + expectedError = keystore.ErrEncryptionFailed + } + + tt = append(tt, struct { + name string + fromKey string + toKey string + expectedError error + }{ + name: testName, + fromKey: fromKey, + toKey: toKey, + expectedError: expectedError, + }) + } + } } for _, tt := range tt { t.Run(tt.name, func(t *testing.T) { diff --git a/keystore/keystore.go b/keystore/keystore.go index 86cf200ccc..ba8ddd7dff 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -10,6 +10,8 @@ import ( "sync" "time" + "slices" + "golang.org/x/crypto/curve25519" gethkeystore "github.com/ethereum/go-ethereum/accounts/keystore" @@ -26,6 +28,14 @@ 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 . From 95ea1d72f743206465f748982ee983534e014a26 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Tue, 7 Oct 2025 15:17:08 -0400 Subject: [PATCH 08/15] Add a test harness, fix a few bugs --- keystore/admin.go | 7 +- keystore/encryptor.go | 39 ++-- keystore/encryptor_test.go | 303 +++++++++++++---------------- keystore/helpers_test.go | 76 ++++++++ keystore/keystore_internal_test.go | 12 ++ 5 files changed, 256 insertions(+), 181 deletions(-) create mode 100644 keystore/helpers_test.go diff --git a/keystore/admin.go b/keystore/admin.go index 614e81f7f2..d71db52824 100644 --- a/keystore/admin.go +++ b/keystore/admin.go @@ -160,11 +160,14 @@ func (ks *keystore) CreateKeys(ctx context.Context, req CreateKeysRequest) (Crea if err != nil { 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[:]) diff --git a/keystore/encryptor.go b/keystore/encryptor.go index 3b730c7bec..c80d9b9eed 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -21,8 +21,9 @@ import ( // Opaque error messages to prevent information leakage var ( - ErrEncryptionFailed = fmt.Errorf("encryption operation failed") - ErrDecryptionFailed = fmt.Errorf("decryption operation failed") + ErrSharedSecretFailed = fmt.Errorf("shared secret derivation failed") + ErrEncryptionFailed = fmt.Errorf("encryption operation failed") + ErrDecryptionFailed = fmt.Errorf("decryption operation failed") ) type EncryptRequest struct { @@ -100,9 +101,10 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp k.mu.RLock() defer k.mu.RUnlock() - if len(req.Data) == 0 || len(req.Data) > MaxEncryptionPayloadSize { + if len(req.Data) > MaxEncryptionPayloadSize { return EncryptResponse{}, ErrEncryptionFailed } + key, ok := k.keystore[req.KeyName] if !ok { return EncryptResponse{}, ErrEncryptionFailed @@ -122,7 +124,7 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp }, nil case ECDH_P256: curve := ecdh.P256() - if len(req.RemotePubKey) == 0 { + if len(req.RemotePubKey) != 65 { return EncryptResponse{}, ErrEncryptionFailed } // Remote public key must be on the P256 curve for the shared secret to work. @@ -135,7 +137,7 @@ func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResp if err != nil { return EncryptResponse{}, ErrEncryptionFailed } - // The magic here is the the receipient can compute the same + // The magic here is that the receipient can compute the same // shared secret because ephPriv*G*recipientPriv = ephPub*G. // This lets them derive the same ephemeral key used for encryption // so they can decrypt the ciphertext. @@ -222,6 +224,10 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp if !ok { return DecryptResponse{}, ErrDecryptionFailed } + if len(decrypted) == 0 { + // box.OpenAnonymous will return a nil slice if the ciphertext is empty + return DecryptResponse{Data: []byte{}}, nil + } return DecryptResponse{ Data: decrypted, }, nil @@ -277,6 +283,10 @@ func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResp if err != nil { return DecryptResponse{}, ErrDecryptionFailed } + if len(pt) == 0 { + // gcm.Open will return a nil slice if the ciphertext is empty + return DecryptResponse{Data: []byte{}}, nil + } return DecryptResponse{Data: pt}, nil default: return DecryptResponse{}, ErrDecryptionFailed @@ -289,17 +299,17 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre key, ok := k.keystore[req.LocalKeyName] if !ok { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } switch key.keyType { case X25519: if len(req.RemotePubKey) != 32 { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } sharedSecret, err := curve25519.X25519(internal.Bytes(key.privateKey), req.RemotePubKey) if err != nil { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } return DeriveSharedSecretResponse{ SharedSecret: sharedSecret, @@ -308,22 +318,23 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre curve := ecdh.P256() priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) if err != nil { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } - if len(req.RemotePubKey) == 32 { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + // P-256 uncompressed public keys are 65 bytes (0x04 || x || y) + if len(req.RemotePubKey) != 65 { + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } remotePub, err := curve.NewPublicKey(req.RemotePubKey) if err != nil { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } shared, err := priv.ECDH(remotePub) if err != nil { - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } return DeriveSharedSecretResponse{SharedSecret: shared}, nil default: - return DeriveSharedSecretResponse{}, ErrEncryptionFailed + return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } } diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index a3a0219e2b..248d9c2872 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -7,179 +7,167 @@ import ( "testing" "github.com/smartcontractkit/chainlink-common/keystore" - "github.com/smartcontractkit/chainlink-common/keystore/storage" "github.com/stretchr/testify/require" ) func TestEncryptDecrypt(t *testing.T) { ctx := context.Background() - ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ - Password: "test-password", - ScryptParams: keystore.FastScryptParams, - }) - require.NoError(t, err) - - // Create 2 keys of each key type. - testKeysByType := make(map[string]struct { - keyType keystore.KeyType - publicKey []byte - }) - keyName := func(keyType keystore.KeyType, index int) string { - return fmt.Sprintf("key-%s-%d", keyType, index) - } - for _, keyType := range keystore.AllKeyTypes { - keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ - Keys: []keystore.CreateKeyRequest{ - {KeyName: keyName(keyType, 0), KeyType: keyType}, - {KeyName: keyName(keyType, 1), KeyType: keyType}, - }, - }) - require.NoError(t, err) - testKeysByType[keys.Keys[0].KeyInfo.Name] = struct { - keyType keystore.KeyType - publicKey []byte - }{keyType: keys.Keys[0].KeyInfo.KeyType, publicKey: keys.Keys[0].KeyInfo.PublicKey} - testKeysByType[keys.Keys[1].KeyInfo.Name] = struct { - keyType keystore.KeyType - publicKey []byte - }{keyType: keys.Keys[1].KeyInfo.KeyType, publicKey: keys.Keys[1].KeyInfo.PublicKey} - } - - var tt []struct { - name string - fromKey string - toKey string - expectedError error + th := NewKeystoreTH(t) + th.CreateTestKeys(t) + + type testCase struct { + name string + encryptKey string + remotePubKey []byte + decryptKey string + payload []byte + expectedEncryptError error + expectedDecryptError error } - for _, fromType := range keystore.AllKeyTypes { - for _, toType := range keystore.AllKeyTypes { - // Test both same key (index 0) and different key (index 1) scenarios - for keyIndex := 0; keyIndex < 2; keyIndex++ { - testName := fmt.Sprintf("Encrypt %s to %s (key %d)", fromType, toType, keyIndex) - fromKey := keyName(fromType, 0) // Always use key 0 as source - toKey := keyName(toType, keyIndex) - - var expectedError error - if fromType == toType && fromType.IsEncryptionKeyType() { - // Same key types should succeed - expectedError = nil - } else { - // Different key types or non-encryption key types should fail - expectedError = keystore.ErrEncryptionFailed - } - - tt = append(tt, struct { - name string - fromKey string - toKey string - expectedError error - }{ - name: testName, - fromKey: fromKey, - toKey: toKey, - expectedError: expectedError, - }) + var tt = []testCase{ + { + name: "Non-existent encrypt key", + encryptKey: "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", + encryptKey: th.KeyName(keystore.X25519, 0), + remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, + decryptKey: th.KeyName(keystore.X25519, 0), + payload: []byte{}, + }, + { + name: "Empty payload ecdh p256", + encryptKey: th.KeyName(keystore.ECDH_P256, 0), + remotePubKey: th.KeysByType()[keystore.ECDH_P256][0].publicKey, + decryptKey: th.KeyName(keystore.ECDH_P256, 0), + payload: []byte{}, + }, + { + name: "Non-existent decrypt key", + encryptKey: th.KeyName(keystore.X25519, 0), + remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, + decryptKey: "blah", + payload: []byte("hello world"), + expectedDecryptError: keystore.ErrDecryptionFailed, + }, + { + name: "Max payload", + encryptKey: th.KeyName(keystore.X25519, 0), + remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, + decryptKey: th.KeyName(keystore.X25519, 0), + payload: make([]byte, keystore.MaxEncryptionPayloadSize), + }, + { + name: "Payload too large", + encryptKey: th.KeyName(keystore.X25519, 0), + remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, + decryptKey: th.KeyName(keystore.X25519, 0), + payload: make([]byte, keystore.MaxEncryptionPayloadSize+1), + expectedEncryptError: keystore.ErrEncryptionFailed, + }} + + for fromKeyName, fromKey := range th.KeysByName() { + for toKeyName, toKey := range th.KeysByName() { + testName := fmt.Sprintf("Encrypt %s to %s", fromKeyName, toKeyName) + var expectedEncryptError error + if fromKey.keyType == toKey.keyType && fromKey.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, + encryptKey: fromKeyName, + remotePubKey: toKey.publicKey, + decryptKey: toKeyName, + expectedEncryptError: expectedEncryptError, + payload: []byte("hello world"), + }) } } + for _, tt := range tt { t.Run(tt.name, func(t *testing.T) { - encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ - KeyName: tt.fromKey, - RemotePubKey: testKeysByType[tt.toKey].publicKey, - Data: []byte("hello world"), + encryptResp, err := th.Keystore.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: tt.encryptKey, + RemotePubKey: tt.remotePubKey, + Data: tt.payload, }) - if tt.expectedError != nil { + if tt.expectedEncryptError != nil { require.Error(t, err) - require.True(t, errors.Is(err, tt.expectedError)) + require.True(t, errors.Is(err, tt.expectedEncryptError)) return } require.NoError(t, err) - decryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ - KeyName: tt.toKey, + 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, []byte("hello world"), decryptResp.Data) + require.Equal(t, tt.payload, decryptResp.Data) }) } } func TestEncryptDecrypt_SharedSecret(t *testing.T) { ctx := context.Background() - ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ - Password: "test-password", - ScryptParams: keystore.FastScryptParams, - }) - require.NoError(t, err) - - for _, keyType := range keystore.AllEncryptionKeyTypes { - t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { - keyName := fmt.Sprintf("test-key-%s", keyType) - keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ - Keys: []keystore.CreateKeyRequest{ - {KeyName: keyName, KeyType: keyType}, - }, - }) - require.NoError(t, err) - _, err = ks.DeriveSharedSecret(ctx, keystore.DeriveSharedSecretRequest{ - LocalKeyName: keyName, - RemotePubKey: keys.Keys[0].KeyInfo.PublicKey, - }) - require.NoError(t, err) - }) + 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, + }, } -} -func TestEncryptDecrypt_PayloadSizeLimit(t *testing.T) { - ctx := context.Background() - ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ - Password: "test-password", - ScryptParams: keystore.FastScryptParams, - }) - require.NoError(t, err) - - for _, keyType := range keystore.AllEncryptionKeyTypes { - t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { - keyName := fmt.Sprintf("test-key-%s", keyType) - keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ - Keys: []keystore.CreateKeyRequest{ - {KeyName: keyName, KeyType: keyType}, - }, - }) - require.NoError(t, err) - // Test encrypting at the limit - maxPayload := make([]byte, keystore.MaxEncryptionPayloadSize) - maxEncryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ - KeyName: keyName, - RemotePubKey: keys.Keys[0].KeyInfo.PublicKey, - Data: maxPayload, - }) - require.NoError(t, err) + 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, + }) + } - // Test decrypting at max (confirm overhead sufficient) - maxDecryptResp, err := ks.Decrypt(ctx, keystore.DecryptRequest{ - KeyName: keyName, - EncryptedData: maxEncryptResp.EncryptedData, + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + _, err := th.Keystore.DeriveSharedSecret(ctx, keystore.DeriveSharedSecretRequest{ + LocalKeyName: 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) - require.Equal(t, len(maxDecryptResp.Data), len(maxPayload)) - - // Test encrypting above the limit - _, err = ks.Encrypt(ctx, keystore.EncryptRequest{ - KeyName: keyName, - RemotePubKey: keys.Keys[0].KeyInfo.PublicKey, - Data: make([]byte, keystore.MaxEncryptionPayloadSize+1), - }) - require.Error(t, err) - - // Test decrypting above the limit - _, err = ks.Decrypt(ctx, keystore.DecryptRequest{ - KeyName: keyName, - EncryptedData: make([]byte, keystore.MaxEncryptionPayloadSize+1025), - }) - require.Error(t, err) }) } } @@ -210,37 +198,22 @@ func FuzzEncryptDecryptRoundtrip(f *testing.F) { } ctx := context.Background() - ks, err := keystore.LoadKeystore(ctx, storage.NewMemoryStorage(), keystore.EncryptionParams{ - Password: "test-password", - ScryptParams: keystore.FastScryptParams, - }) - require.NoError(t, err) - + th := NewKeystoreTH(t) + th.CreateTestKeys(t) // Test each encryption key type - for i, keyType := range keystore.AllEncryptionKeyTypes { + for _, keyType := range keystore.AllEncryptionKeyTypes { t.Run(fmt.Sprintf("keyType_%s", keyType), func(t *testing.T) { - // Create two keys of the same type for encryption/decryption - senderName := fmt.Sprintf("sender-%d", i) - receiverName := fmt.Sprintf("receiver-%d", i) - keys, err := ks.CreateKeys(ctx, keystore.CreateKeysRequest{ - Keys: []keystore.CreateKeyRequest{ - {KeyName: senderName, KeyType: keyType}, - {KeyName: receiverName, KeyType: keyType}, - }, - }) - require.NoError(t, err) - // Encrypt data using sender key to receiver's public key - encryptResp, err := ks.Encrypt(ctx, keystore.EncryptRequest{ - KeyName: senderName, - RemotePubKey: keys.Keys[1].KeyInfo.PublicKey, // receiver's public key + encryptResp, err := th.Keystore.Encrypt(ctx, keystore.EncryptRequest{ + KeyName: th.KeyName(keyType, 0), + 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 := ks.Decrypt(ctx, keystore.DecryptRequest{ - KeyName: receiverName, + 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)) diff --git a/keystore/helpers_test.go b/keystore/helpers_test.go new file mode 100644 index 0000000000..aaeb7bf28b --- /dev/null +++ b/keystore/helpers_test.go @@ -0,0 +1,76 @@ +package keystore_test + +import ( + "context" + "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 := context.Background() + 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 := context.Background() + 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_internal_test.go b/keystore/keystore_internal_test.go index 8144f9074d..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" @@ -17,4 +19,14 @@ func TestPublicKeyFromPrivateKey(t *testing.T) { 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)) } From a5c9b8f4ab062ee0b5683add8f7c566edae68d2d Mon Sep 17 00:00:00 2001 From: connorwstein Date: Tue, 7 Oct 2025 16:50:41 -0400 Subject: [PATCH 09/15] Added a readme warning --- keystore/README.md | 2 ++ keystore/encryptor.go | 8 ++++---- 2 files changed, 6 insertions(+), 4 deletions(-) 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/encryptor.go b/keystore/encryptor.go index c80d9b9eed..5085d5ba27 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -315,15 +315,15 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre 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 } - // P-256 uncompressed public keys are 65 bytes (0x04 || x || y) - if len(req.RemotePubKey) != 65 { - return DeriveSharedSecretResponse{}, ErrSharedSecretFailed - } remotePub, err := curve.NewPublicKey(req.RemotePubKey) if err != nil { return DeriveSharedSecretResponse{}, ErrSharedSecretFailed From 3bfb012d4b56d46d507d16c5dee0db041e5f0a6d Mon Sep 17 00:00:00 2001 From: connorwstein Date: Wed, 8 Oct 2025 11:15:23 -0400 Subject: [PATCH 10/15] Comments and re-orient API around anonymous encryption --- keystore/admin_test.go | 5 +- keystore/encryptor.go | 408 ++++++++++++++------------ keystore/encryptor_test.go | 69 +++-- keystore/go.mod | 4 +- keystore/helpers_test.go | 5 +- keystore/serialization/keystore.pb.go | 127 +------- keystore/serialization/keystore.proto | 10 - keystore/storage/memory_test.go | 5 +- 8 files changed, 270 insertions(+), 363 deletions(-) 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 5085d5ba27..8dee5e3211 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -13,10 +13,8 @@ import ( "golang.org/x/crypto/curve25519" "golang.org/x/crypto/hkdf" "golang.org/x/crypto/nacl/box" - "google.golang.org/protobuf/proto" "github.com/smartcontractkit/chainlink-common/keystore/internal" - "github.com/smartcontractkit/chainlink-common/keystore/serialization" ) // Opaque error messages to prevent information leakage @@ -26,27 +24,27 @@ var ( ErrDecryptionFailed = fmt.Errorf("decryption operation failed") ) -type EncryptRequest struct { - KeyName string - RemotePubKey []byte - Data []byte +type EncryptAnonymousRequest struct { + RemoteKeyType KeyType + RemotePubKey []byte + Data []byte } -type EncryptResponse struct { +type EncryptAnonymousResponse struct { EncryptedData []byte } -type DecryptRequest struct { +type DecryptAnonymousRequest struct { KeyName string EncryptedData []byte } -type DecryptResponse struct { +type DecryptAnonymousResponse struct { Data []byte } type DeriveSharedSecretRequest struct { - LocalKeyName string + KeyName string RemotePubKey []byte } @@ -55,15 +53,9 @@ type DeriveSharedSecretResponse struct { } const ( - // 16 byte is the standard NIST recommended salt size for HKDF. - hkdfSaltSize = 16 - // Version for the encryption envelope. - encVersionV1 uint32 = 1 // 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 - // Overhead size for the encryption envelope. - overheadSize = 1024 ) var ( @@ -72,12 +64,13 @@ var ( ) // 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) + EncryptAnonymous(ctx context.Context, req EncryptAnonymousRequest) (EncryptAnonymousResponse, error) + DecryptAnonymous(ctx context.Context, req DecryptAnonymousRequest) (DecryptAnonymousResponse, 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) } @@ -85,211 +78,71 @@ type Encryptor interface { // 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) { - return EncryptResponse{}, fmt.Errorf("Encryptor.Encrypt: %w", ErrUnimplemented) +func (UnimplementedEncryptor) EncryptAnonymous(ctx context.Context, req EncryptAnonymousRequest) (EncryptAnonymousResponse, error) { + return EncryptAnonymousResponse{}, fmt.Errorf("Encryptor.EncryptAnonymous: %w", ErrUnimplemented) } -func (UnimplementedEncryptor) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResponse, error) { - return DecryptResponse{}, fmt.Errorf("Encryptor.Decrypt: %w", ErrUnimplemented) +func (UnimplementedEncryptor) DecryptAnonymous(ctx context.Context, req DecryptAnonymousRequest) (DecryptAnonymousResponse, error) { + return DecryptAnonymousResponse{}, fmt.Errorf("Encryptor.DecryptAnonymous: %w", ErrUnimplemented) } func (UnimplementedEncryptor) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecretRequest) (DeriveSharedSecretResponse, error) { return DeriveSharedSecretResponse{}, fmt.Errorf("Encryptor.DeriveSharedSecret: %w", ErrUnimplemented) } -func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResponse, error) { - k.mu.RLock() - defer k.mu.RUnlock() - +func (k *keystore) EncryptAnonymous(ctx context.Context, req EncryptAnonymousRequest) (EncryptAnonymousResponse, error) { if len(req.Data) > MaxEncryptionPayloadSize { - return EncryptResponse{}, ErrEncryptionFailed + return EncryptAnonymousResponse{}, ErrEncryptionFailed } - key, ok := k.keystore[req.KeyName] - if !ok { - return EncryptResponse{}, ErrEncryptionFailed - } - - switch key.keyType { + switch req.RemoteKeyType { case X25519: - if len(req.RemotePubKey) != 32 { - return EncryptResponse{}, ErrEncryptionFailed - } - encrypted, err := box.SealAnonymous(nil, req.Data, (*[32]byte)(req.RemotePubKey), rand.Reader) + encrypted, err := k.encryptX25519Anonymous(req.Data, req.RemotePubKey) if err != nil { - return EncryptResponse{}, ErrEncryptionFailed + return EncryptAnonymousResponse{}, err } - return EncryptResponse{ + return EncryptAnonymousResponse{ EncryptedData: encrypted, }, nil case ECDH_P256: - curve := ecdh.P256() - if len(req.RemotePubKey) != 65 { - return EncryptResponse{}, ErrEncryptionFailed - } - // Remote public key must be on the P256 curve for the shared secret to work. - recipientPub, err := curve.NewPublicKey(req.RemotePubKey) - if err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - // Create an ephemeral keypair on the P256 curve used for encryption. - ephPriv, err := curve.GenerateKey(rand.Reader) - if err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - // The magic here is that the receipient can compute the same - // shared secret because ephPriv*G*recipientPriv = ephPub*G. - // This lets them derive the same ephemeral key used for encryption - // so they can decrypt the ciphertext. - shared, err := ephPriv.ECDH(recipientPub) - if err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - // Derive AES-256-GCM key - // The reason we do this is so that we can use symmetric encryption (more efficient) - // This is part of any standard hybrid encryption scheme. - // We include random salt to prevent rainbow table attacks (i.e. preventing - // attackers from tracking a mapping of encryption data to plaintext) - salt := make([]byte, hkdfSaltSize) - if _, err := rand.Read(salt); err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - derivedKey, err := deriveAESKeyFromSharedSecret(shared, salt, infoAESGCM) - if err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - block, err := aes.NewCipher(derivedKey) - if err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - nonce := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(nonce); err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - ephPub := ephPriv.PublicKey().Bytes() - - // Create the protobuf envelope - envelope := &serialization.ECDHEnvelope{ - Version: encVersionV1, - Algorithm: ECDH_P256.String(), - EphemeralPublicKey: ephPub, - Salt: salt, - Nonce: nonce, - } - - // Marshal the envelope for AAD (without ciphertext) - aadBytes, err := proto.Marshal(envelope) - if err != nil { - return EncryptResponse{}, ErrEncryptionFailed - } - - // Critical to include the header parameters as additional authenticated data. - // Prevents a MITM from changing the header. - ciphertext := gcm.Seal(nil, nonce, req.Data, aadBytes) - - // Add ciphertext to envelope - envelope.Ciphertext = ciphertext - - // Marshal the complete envelope - out, err := proto.Marshal(envelope) + encrypted, err := k.encryptECDHP256Anonymous(req.Data, req.RemotePubKey) if err != nil { - return EncryptResponse{}, ErrEncryptionFailed + return EncryptAnonymousResponse{}, err } - return EncryptResponse{EncryptedData: out}, nil + return EncryptAnonymousResponse{EncryptedData: encrypted}, nil default: - return EncryptResponse{}, ErrEncryptionFailed + return EncryptAnonymousResponse{}, ErrEncryptionFailed } } -func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResponse, error) { +func (k *keystore) DecryptAnonymous(ctx context.Context, req DecryptAnonymousRequest) (DecryptAnonymousResponse, error) { k.mu.RLock() defer k.mu.RUnlock() - if len(req.EncryptedData) == 0 || len(req.EncryptedData) > (MaxEncryptionPayloadSize+overheadSize) { - return DecryptResponse{}, ErrDecryptionFailed + if len(req.EncryptedData) == 0 || len(req.EncryptedData) > MaxEncryptionPayloadSize*2 { + return DecryptAnonymousResponse{}, ErrDecryptionFailed } key, ok := k.keystore[req.KeyName] if !ok { - return DecryptResponse{}, ErrDecryptionFailed + return DecryptAnonymousResponse{}, ErrDecryptionFailed } switch key.keyType { case X25519: - decrypted, ok := box.OpenAnonymous(nil, req.EncryptedData, (*[32]byte)(key.publicKey), (*[32]byte)(internal.Bytes(key.privateKey))) - if !ok { - return DecryptResponse{}, ErrDecryptionFailed - } - if len(decrypted) == 0 { - // box.OpenAnonymous will return a nil slice if the ciphertext is empty - return DecryptResponse{Data: []byte{}}, nil - } - return DecryptResponse{ - Data: decrypted, - }, nil - case ECDH_P256: - var envelope serialization.ECDHEnvelope - if err := proto.Unmarshal(req.EncryptedData, &envelope); err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - if envelope.Version != encVersionV1 || envelope.Algorithm != string(ECDH_P256) { - return DecryptResponse{}, ErrDecryptionFailed - } - curve := ecdh.P256() - priv, err := curve.NewPrivateKey(internal.Bytes(key.privateKey)) + decrypted, err := k.decryptX25519Anonymous(req.EncryptedData, key.privateKey, key.publicKey) if err != nil { - return DecryptResponse{}, ErrDecryptionFailed + return DecryptAnonymousResponse{}, err } - ephPub, err := curve.NewPublicKey(envelope.EphemeralPublicKey) - if err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - shared, err := priv.ECDH(ephPub) - if err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - derivedKey, err := deriveAESKeyFromSharedSecret(shared, envelope.Salt, infoAESGCM) - if err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - block, err := aes.NewCipher(derivedKey) - if err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - - // Recreate the envelope for AAD (without ciphertext) - aadEnvelope := &serialization.ECDHEnvelope{ - Version: envelope.Version, - Algorithm: envelope.Algorithm, - EphemeralPublicKey: envelope.EphemeralPublicKey, - Salt: envelope.Salt, - Nonce: envelope.Nonce, - // Ciphertext is intentionally omitted for AAD - } - aadBytes, err := proto.Marshal(aadEnvelope) - if err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - - pt, err := gcm.Open(nil, envelope.Nonce, envelope.Ciphertext, aadBytes) + return DecryptAnonymousResponse{Data: decrypted}, nil + case ECDH_P256: + decrypted, err := k.decryptECDHP256Anonymous(req.EncryptedData, key.privateKey) if err != nil { - return DecryptResponse{}, ErrDecryptionFailed - } - if len(pt) == 0 { - // gcm.Open will return a nil slice if the ciphertext is empty - return DecryptResponse{Data: []byte{}}, nil + return DecryptAnonymousResponse{}, err } - return DecryptResponse{Data: pt}, nil + return DecryptAnonymousResponse{Data: decrypted}, nil default: - return DecryptResponse{}, ErrDecryptionFailed + return DecryptAnonymousResponse{}, ErrDecryptionFailed } } @@ -297,7 +150,7 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre k.mu.RLock() defer k.mu.RUnlock() - key, ok := k.keystore[req.LocalKeyName] + key, ok := k.keystore[req.KeyName] if !ok { return DeriveSharedSecretResponse{}, ErrSharedSecretFailed } @@ -338,6 +191,185 @@ func (k *keystore) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecre } } +// 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[:]...) // 12 bytes: nonce + result = append(result, ephPub...) // 65 bytes: ephemeral public key + result = append(result, ciphertext...) // AES-GCM ciphertext + return result +} + +func decodeECDHP256Anonymous(encryptedData []byte) ([]byte, []byte, []byte, error) { + if len(encryptedData) < 65+12 { + return nil, nil, nil, ErrDecryptionFailed + } + nonceBytes := encryptedData[:12] // 12 bytes: nonce + ephPubBytes := encryptedData[12 : 12+65] // 65 bytes: ephemeral public key + ciphertext := encryptedData[12+65:] // AES-GCM ciphertext + 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) < 65+12 { + 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 + } + + // Get local private key + priv, err := curve.NewPrivateKey(internal.Bytes(privateKey)) + if err != nil { + return nil, ErrDecryptionFailed + } + + // Derive shared secret using local private key + ephemeral public key + shared, err := priv.ECDH(ephPub) + if err != nil { + return nil, ErrDecryptionFailed + } + + // Derive the same AES key + derivedKey, err := deriveAESKeyFromSharedSecret(shared, nonce[:], infoAESGCM) + if err != nil { + return nil, ErrDecryptionFailed + } + + // Decrypt with AES-GCM + 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)[:12] // 12 bytes for AES-GCM nonce +} + func deriveAESKeyFromSharedSecret(sharedSecret []byte, salt []byte, info []byte) ([]byte, error) { r := hkdf.New(sha256.New, sharedSecret, salt, info) key := make([]byte, 32) diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index 248d9c2872..0f62c272d5 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -1,7 +1,6 @@ package keystore_test import ( - "context" "errors" "fmt" "testing" @@ -11,13 +10,13 @@ import ( ) func TestEncryptDecrypt(t *testing.T) { - ctx := context.Background() + ctx := t.Context() th := NewKeystoreTH(t) th.CreateTestKeys(t) type testCase struct { name string - encryptKey string + remoteKeyType keystore.KeyType remotePubKey []byte decryptKey string payload []byte @@ -28,44 +27,44 @@ func TestEncryptDecrypt(t *testing.T) { var tt = []testCase{ { name: "Non-existent encrypt key", - encryptKey: "blah", + 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", - encryptKey: th.KeyName(keystore.X25519, 0), - remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, - decryptKey: th.KeyName(keystore.X25519, 0), - payload: []byte{}, + 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", - encryptKey: th.KeyName(keystore.ECDH_P256, 0), - remotePubKey: th.KeysByType()[keystore.ECDH_P256][0].publicKey, - decryptKey: th.KeyName(keystore.ECDH_P256, 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", - encryptKey: th.KeyName(keystore.X25519, 0), + remoteKeyType: keystore.X25519, remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, decryptKey: "blah", payload: []byte("hello world"), expectedDecryptError: keystore.ErrDecryptionFailed, }, { - name: "Max payload", - encryptKey: th.KeyName(keystore.X25519, 0), - remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, - decryptKey: th.KeyName(keystore.X25519, 0), - payload: make([]byte, keystore.MaxEncryptionPayloadSize), + 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", - encryptKey: th.KeyName(keystore.X25519, 0), + remoteKeyType: keystore.X25519, remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, decryptKey: th.KeyName(keystore.X25519, 0), payload: make([]byte, keystore.MaxEncryptionPayloadSize+1), @@ -86,7 +85,7 @@ func TestEncryptDecrypt(t *testing.T) { tt = append(tt, testCase{ name: testName, - encryptKey: fromKeyName, + remoteKeyType: fromKey.keyType, remotePubKey: toKey.publicKey, decryptKey: toKeyName, expectedEncryptError: expectedEncryptError, @@ -97,10 +96,10 @@ func TestEncryptDecrypt(t *testing.T) { for _, tt := range tt { t.Run(tt.name, func(t *testing.T) { - encryptResp, err := th.Keystore.Encrypt(ctx, keystore.EncryptRequest{ - KeyName: tt.encryptKey, - RemotePubKey: tt.remotePubKey, - Data: tt.payload, + encryptResp, err := th.Keystore.EncryptAnonymous(ctx, keystore.EncryptAnonymousRequest{ + RemoteKeyType: tt.remoteKeyType, + RemotePubKey: tt.remotePubKey, + Data: tt.payload, }) if tt.expectedEncryptError != nil { require.Error(t, err) @@ -108,7 +107,7 @@ func TestEncryptDecrypt(t *testing.T) { return } require.NoError(t, err) - decryptResp, err := th.Keystore.Decrypt(ctx, keystore.DecryptRequest{ + decryptResp, err := th.Keystore.DecryptAnonymous(ctx, keystore.DecryptAnonymousRequest{ KeyName: tt.decryptKey, EncryptedData: encryptResp.EncryptedData, }) @@ -124,7 +123,7 @@ func TestEncryptDecrypt(t *testing.T) { } func TestEncryptDecrypt_SharedSecret(t *testing.T) { - ctx := context.Background() + ctx := t.Context() th := NewKeystoreTH(t) th.CreateTestKeys(t) @@ -159,7 +158,7 @@ func TestEncryptDecrypt_SharedSecret(t *testing.T) { for _, tt := range tt { t.Run(tt.name, func(t *testing.T) { _, err := th.Keystore.DeriveSharedSecret(ctx, keystore.DeriveSharedSecretRequest{ - LocalKeyName: tt.keyName, + KeyName: tt.keyName, RemotePubKey: th.KeysByType()[tt.keyType][0].publicKey, }) if tt.expectedError != nil { @@ -197,22 +196,22 @@ func FuzzEncryptDecryptRoundtrip(f *testing.F) { t.Skip("Invalid data size for fuzz test") } - ctx := context.Background() + 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{ - KeyName: th.KeyName(keyType, 0), - RemotePubKey: th.KeysByType()[keyType][1].publicKey, // receiver's public key - Data: data, + encryptResp, err := th.Keystore.EncryptAnonymous(ctx, keystore.EncryptAnonymousRequest{ + 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{ + decryptResp, err := th.Keystore.DecryptAnonymous(ctx, keystore.DecryptAnonymousRequest{ KeyName: th.KeyName(keyType, 1), EncryptedData: encryptResp.EncryptedData, }) diff --git a/keystore/go.mod b/keystore/go.mod index 4d3f6446df..8e42efc79f 100644 --- a/keystore/go.mod +++ b/keystore/go.mod @@ -1,6 +1,8 @@ module github.com/smartcontractkit/chainlink-common/keystore -go 1.24.5 +go 1.24.0 + +toolchain go1.24.5 require ( github.com/ethereum/go-ethereum v1.16.2 diff --git a/keystore/helpers_test.go b/keystore/helpers_test.go index aaeb7bf28b..99c2b94ecf 100644 --- a/keystore/helpers_test.go +++ b/keystore/helpers_test.go @@ -1,7 +1,6 @@ package keystore_test import ( - "context" "fmt" "sync" "testing" @@ -24,7 +23,7 @@ type KeystoreTH struct { } func NewKeystoreTH(t *testing.T) *KeystoreTH { - ctx := context.Background() + ctx := t.Context() st := storage.NewMemoryStorage() ks, err := keystore.LoadKeystore(ctx, st, keystore.EncryptionParams{ Password: "test", @@ -58,7 +57,7 @@ func (th *KeystoreTH) KeyName(keyType keystore.KeyType, index int) string { func (th *KeystoreTH) CreateTestKeys(t *testing.T) { th.mu.Lock() defer th.mu.Unlock() - ctx := context.Background() + ctx := t.Context() for _, keyType := range keystore.AllKeyTypes { keys, err := th.Keystore.CreateKeys(ctx, keystore.CreateKeysRequest{ Keys: []keystore.CreateKeyRequest{ diff --git a/keystore/serialization/keystore.pb.go b/keystore/serialization/keystore.pb.go index f03581febc..19bf3222f9 100644 --- a/keystore/serialization/keystore.pb.go +++ b/keystore/serialization/keystore.pb.go @@ -148,94 +148,6 @@ func (x *Key) GetMetadata() []byte { return nil } -// ECDH encryption envelope for hybrid encryption -type ECDHEnvelope struct { - state protoimpl.MessageState - sizeCache protoimpl.SizeCache - unknownFields protoimpl.UnknownFields - - Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"` - Algorithm string `protobuf:"bytes,2,opt,name=algorithm,proto3" json:"algorithm,omitempty"` - EphemeralPublicKey []byte `protobuf:"bytes,3,opt,name=ephemeral_public_key,json=ephemeralPublicKey,proto3" json:"ephemeral_public_key,omitempty"` - Salt []byte `protobuf:"bytes,4,opt,name=salt,proto3" json:"salt,omitempty"` - Nonce []byte `protobuf:"bytes,5,opt,name=nonce,proto3" json:"nonce,omitempty"` - Ciphertext []byte `protobuf:"bytes,6,opt,name=ciphertext,proto3" json:"ciphertext,omitempty"` -} - -func (x *ECDHEnvelope) Reset() { - *x = ECDHEnvelope{} - if protoimpl.UnsafeEnabled { - mi := &file_keystore_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) - } -} - -func (x *ECDHEnvelope) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*ECDHEnvelope) ProtoMessage() {} - -func (x *ECDHEnvelope) ProtoReflect() protoreflect.Message { - mi := &file_keystore_proto_msgTypes[2] - if protoimpl.UnsafeEnabled && x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use ECDHEnvelope.ProtoReflect.Descriptor instead. -func (*ECDHEnvelope) Descriptor() ([]byte, []int) { - return file_keystore_proto_rawDescGZIP(), []int{2} -} - -func (x *ECDHEnvelope) GetVersion() uint32 { - if x != nil { - return x.Version - } - return 0 -} - -func (x *ECDHEnvelope) GetAlgorithm() string { - if x != nil { - return x.Algorithm - } - return "" -} - -func (x *ECDHEnvelope) GetEphemeralPublicKey() []byte { - if x != nil { - return x.EphemeralPublicKey - } - return nil -} - -func (x *ECDHEnvelope) GetSalt() []byte { - if x != nil { - return x.Salt - } - return nil -} - -func (x *ECDHEnvelope) GetNonce() []byte { - if x != nil { - return x.Nonce - } - return nil -} - -func (x *ECDHEnvelope) GetCiphertext() []byte { - if x != nil { - return x.Ciphertext - } - return nil -} - var File_keystore_proto protoreflect.FileDescriptor var file_keystore_proto_rawDesc = []byte{ @@ -253,21 +165,9 @@ var file_keystore_proto_rawDesc = []byte{ 0x19, 0x0a, 0x08, 0x6b, 0x65, 0x79, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6b, 0x65, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x08, 0x6d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xc2, 0x01, 0x0a, 0x0c, 0x45, 0x43, 0x44, 0x48, 0x45, - 0x6e, 0x76, 0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, - 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x12, - 0x30, 0x0a, 0x14, 0x65, 0x70, 0x68, 0x65, 0x6d, 0x65, 0x72, 0x61, 0x6c, 0x5f, 0x70, 0x75, 0x62, - 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x12, 0x65, - 0x70, 0x68, 0x65, 0x6d, 0x65, 0x72, 0x61, 0x6c, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, - 0x79, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x04, 0x73, 0x61, 0x6c, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x05, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x6e, 0x6f, 0x6e, 0x63, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x63, - 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, - 0x0a, 0x63, 0x69, 0x70, 0x68, 0x65, 0x72, 0x74, 0x65, 0x78, 0x74, 0x42, 0x12, 0x5a, 0x10, 0x2e, - 0x2f, 0x3b, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x42, 0x12, 0x5a, 0x10, 0x2e, 0x2f, 0x3b, 0x73, 0x65, 0x72, + 0x69, 0x61, 0x6c, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -282,11 +182,10 @@ func file_keystore_proto_rawDescGZIP() []byte { return file_keystore_proto_rawDescData } -var file_keystore_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_keystore_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_keystore_proto_goTypes = []interface{}{ - (*Keystore)(nil), // 0: serialization.Keystore - (*Key)(nil), // 1: serialization.Key - (*ECDHEnvelope)(nil), // 2: serialization.ECDHEnvelope + (*Keystore)(nil), // 0: serialization.Keystore + (*Key)(nil), // 1: serialization.Key } var file_keystore_proto_depIdxs = []int32{ 1, // 0: serialization.Keystore.keys:type_name -> serialization.Key @@ -327,18 +226,6 @@ func file_keystore_proto_init() { return nil } } - file_keystore_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*ECDHEnvelope); i { - case 0: - return &v.state - case 1: - return &v.sizeCache - case 2: - return &v.unknownFields - default: - return nil - } - } } type x struct{} out := protoimpl.TypeBuilder{ @@ -346,7 +233,7 @@ func file_keystore_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_keystore_proto_rawDesc, NumEnums: 0, - NumMessages: 3, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/keystore/serialization/keystore.proto b/keystore/serialization/keystore.proto index 3a15656ea7..a388934e73 100644 --- a/keystore/serialization/keystore.proto +++ b/keystore/serialization/keystore.proto @@ -15,14 +15,4 @@ message Key { int64 created_at = 3; string key_type = 4; bytes metadata = 5; -} - -// ECDH encryption envelope for hybrid encryption -message ECDHEnvelope { - uint32 version = 1; - string algorithm = 2; - bytes ephemeral_public_key = 3; - bytes salt = 4; - bytes nonce = 5; - bytes ciphertext = 6; } \ No newline at end of file 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) } From 8d9d70f9b8c79b1e5d7bf92051583c174a58e95b Mon Sep 17 00:00:00 2001 From: connorwstein Date: Thu, 9 Oct 2025 10:57:29 -0400 Subject: [PATCH 11/15] Rename --- keystore/encryptor.go | 54 ++++++++++++++++++-------------------- keystore/encryptor_test.go | 8 +++--- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/keystore/encryptor.go b/keystore/encryptor.go index 8dee5e3211..d981d40351 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -24,22 +24,22 @@ var ( ErrDecryptionFailed = fmt.Errorf("decryption operation failed") ) -type EncryptAnonymousRequest struct { +type EncryptRequest struct { RemoteKeyType KeyType RemotePubKey []byte Data []byte } -type EncryptAnonymousResponse struct { +type EncryptResponse struct { EncryptedData []byte } -type DecryptAnonymousRequest struct { +type DecryptRequest struct { KeyName string EncryptedData []byte } -type DecryptAnonymousResponse struct { +type DecryptResponse struct { Data []byte } @@ -65,8 +65,8 @@ var ( // Encryptor is an interfaces for hybrid encryption (key exchange + encryption) operations. type Encryptor interface { - EncryptAnonymous(ctx context.Context, req EncryptAnonymousRequest) (EncryptAnonymousResponse, error) - DecryptAnonymous(ctx context.Context, req DecryptAnonymousRequest) (DecryptAnonymousResponse, error) + 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 @@ -78,71 +78,71 @@ type Encryptor interface { // Clients should embed this struct to ensure forward compatibility with changes to the Encryptor interface. type UnimplementedEncryptor struct{} -func (UnimplementedEncryptor) EncryptAnonymous(ctx context.Context, req EncryptAnonymousRequest) (EncryptAnonymousResponse, error) { - return EncryptAnonymousResponse{}, fmt.Errorf("Encryptor.EncryptAnonymous: %w", ErrUnimplemented) +func (UnimplementedEncryptor) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResponse, error) { + return EncryptResponse{}, fmt.Errorf("Encryptor.Encrypt: %w", ErrUnimplemented) } -func (UnimplementedEncryptor) DecryptAnonymous(ctx context.Context, req DecryptAnonymousRequest) (DecryptAnonymousResponse, error) { - return DecryptAnonymousResponse{}, fmt.Errorf("Encryptor.DecryptAnonymous: %w", ErrUnimplemented) +func (UnimplementedEncryptor) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResponse, error) { + return DecryptResponse{}, fmt.Errorf("Encryptor.Decrypt: %w", ErrUnimplemented) } func (UnimplementedEncryptor) DeriveSharedSecret(ctx context.Context, req DeriveSharedSecretRequest) (DeriveSharedSecretResponse, error) { return DeriveSharedSecretResponse{}, fmt.Errorf("Encryptor.DeriveSharedSecret: %w", ErrUnimplemented) } -func (k *keystore) EncryptAnonymous(ctx context.Context, req EncryptAnonymousRequest) (EncryptAnonymousResponse, error) { +func (k *keystore) Encrypt(ctx context.Context, req EncryptRequest) (EncryptResponse, error) { if len(req.Data) > MaxEncryptionPayloadSize { - return EncryptAnonymousResponse{}, ErrEncryptionFailed + return EncryptResponse{}, ErrEncryptionFailed } switch req.RemoteKeyType { case X25519: encrypted, err := k.encryptX25519Anonymous(req.Data, req.RemotePubKey) if err != nil { - return EncryptAnonymousResponse{}, err + return EncryptResponse{}, err } - return EncryptAnonymousResponse{ + return EncryptResponse{ EncryptedData: encrypted, }, nil case ECDH_P256: encrypted, err := k.encryptECDHP256Anonymous(req.Data, req.RemotePubKey) if err != nil { - return EncryptAnonymousResponse{}, err + return EncryptResponse{}, err } - return EncryptAnonymousResponse{EncryptedData: encrypted}, nil + return EncryptResponse{EncryptedData: encrypted}, nil default: - return EncryptAnonymousResponse{}, ErrEncryptionFailed + return EncryptResponse{}, ErrEncryptionFailed } } -func (k *keystore) DecryptAnonymous(ctx context.Context, req DecryptAnonymousRequest) (DecryptAnonymousResponse, error) { +func (k *keystore) Decrypt(ctx context.Context, req DecryptRequest) (DecryptResponse, error) { k.mu.RLock() defer k.mu.RUnlock() if len(req.EncryptedData) == 0 || len(req.EncryptedData) > MaxEncryptionPayloadSize*2 { - return DecryptAnonymousResponse{}, ErrDecryptionFailed + return DecryptResponse{}, ErrDecryptionFailed } key, ok := k.keystore[req.KeyName] if !ok { - return DecryptAnonymousResponse{}, ErrDecryptionFailed + return DecryptResponse{}, ErrDecryptionFailed } switch key.keyType { case X25519: decrypted, err := k.decryptX25519Anonymous(req.EncryptedData, key.privateKey, key.publicKey) if err != nil { - return DecryptAnonymousResponse{}, err + return DecryptResponse{}, err } - return DecryptAnonymousResponse{Data: decrypted}, nil + return DecryptResponse{Data: decrypted}, nil case ECDH_P256: decrypted, err := k.decryptECDHP256Anonymous(req.EncryptedData, key.privateKey) if err != nil { - return DecryptAnonymousResponse{}, err + return DecryptResponse{}, err } - return DecryptAnonymousResponse{Data: decrypted}, nil + return DecryptResponse{Data: decrypted}, nil default: - return DecryptAnonymousResponse{}, ErrDecryptionFailed + return DecryptResponse{}, ErrDecryptionFailed } } @@ -320,25 +320,21 @@ func (k *keystore) decryptECDHP256Anonymous(encryptedData []byte, privateKey int return nil, ErrDecryptionFailed } - // Get local private key priv, err := curve.NewPrivateKey(internal.Bytes(privateKey)) if err != nil { return nil, ErrDecryptionFailed } - // Derive shared secret using local private key + ephemeral public key shared, err := priv.ECDH(ephPub) if err != nil { return nil, ErrDecryptionFailed } - // Derive the same AES key derivedKey, err := deriveAESKeyFromSharedSecret(shared, nonce[:], infoAESGCM) if err != nil { return nil, ErrDecryptionFailed } - // Decrypt with AES-GCM block, err := aes.NewCipher(derivedKey) if err != nil { return nil, ErrDecryptionFailed diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index 0f62c272d5..ac69b79368 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -96,7 +96,7 @@ func TestEncryptDecrypt(t *testing.T) { for _, tt := range tt { t.Run(tt.name, func(t *testing.T) { - encryptResp, err := th.Keystore.EncryptAnonymous(ctx, keystore.EncryptAnonymousRequest{ + encryptResp, err := th.Keystore.Encrypt(ctx, keystore.EncryptRequest{ RemoteKeyType: tt.remoteKeyType, RemotePubKey: tt.remotePubKey, Data: tt.payload, @@ -107,7 +107,7 @@ func TestEncryptDecrypt(t *testing.T) { return } require.NoError(t, err) - decryptResp, err := th.Keystore.DecryptAnonymous(ctx, keystore.DecryptAnonymousRequest{ + decryptResp, err := th.Keystore.Decrypt(ctx, keystore.DecryptRequest{ KeyName: tt.decryptKey, EncryptedData: encryptResp.EncryptedData, }) @@ -203,7 +203,7 @@ func FuzzEncryptDecryptRoundtrip(f *testing.F) { 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.EncryptAnonymous(ctx, keystore.EncryptAnonymousRequest{ + encryptResp, err := th.Keystore.Encrypt(ctx, keystore.EncryptRequest{ RemoteKeyType: keyType, RemotePubKey: th.KeysByType()[keyType][1].publicKey, // receiver's public key Data: data, @@ -211,7 +211,7 @@ func FuzzEncryptDecryptRoundtrip(f *testing.F) { 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.DecryptAnonymous(ctx, keystore.DecryptAnonymousRequest{ + decryptResp, err := th.Keystore.Decrypt(ctx, keystore.DecryptRequest{ KeyName: th.KeyName(keyType, 1), EncryptedData: encryptResp.EncryptedData, }) From ee722e1c64e0e2c4417f76f6baaedbef7b65db00 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Thu, 9 Oct 2025 13:30:22 -0400 Subject: [PATCH 12/15] PR comments + port a critical fix --- keystore/encryptor.go | 23 ++++++++++++++--------- keystore/go.mod | 4 +--- keystore/keystore.go | 11 +++++++---- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/keystore/encryptor.go b/keystore/encryptor.go index d981d40351..346da2c3b8 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -58,6 +58,11 @@ const ( 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") @@ -287,25 +292,25 @@ func (k *keystore) encryptECDHP256Anonymous(data []byte, remotePubKey []byte) ([ func encodeECDHP256Anonymous(nonce []byte, ephPub []byte, ciphertext []byte) []byte { var result []byte - result = append(result, nonce[:]...) // 12 bytes: nonce - result = append(result, ephPub...) // 65 bytes: ephemeral public key - result = append(result, ciphertext...) // AES-GCM ciphertext + 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) < 65+12 { + if len(encryptedData) < ephPubSizeECDHP256+nonceSizeECDHP256 { return nil, nil, nil, ErrDecryptionFailed } - nonceBytes := encryptedData[:12] // 12 bytes: nonce - ephPubBytes := encryptedData[12 : 12+65] // 65 bytes: ephemeral public key - ciphertext := encryptedData[12+65:] // AES-GCM ciphertext + 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) < 65+12 { + if len(encryptedData) < ephPubSizeECDHP256+nonceSizeECDHP256 { return nil, ErrDecryptionFailed } @@ -363,7 +368,7 @@ func deriveNonce(pub1, pub2 []byte) []byte { h := sha256.New() h.Write(pub1) h.Write(pub2) - return h.Sum(nil)[:12] // 12 bytes for AES-GCM nonce + return h.Sum(nil)[:nonceSizeECDHP256] } func deriveAESKeyFromSharedSecret(sharedSecret []byte, salt []byte, info []byte) ([]byte, error) { diff --git a/keystore/go.mod b/keystore/go.mod index 8e42efc79f..4d3f6446df 100644 --- a/keystore/go.mod +++ b/keystore/go.mod @@ -1,8 +1,6 @@ module github.com/smartcontractkit/chainlink-common/keystore -go 1.24.0 - -toolchain go1.24.5 +go 1.24.5 require ( github.com/ethereum/go-ethereum v1.16.2 diff --git a/keystore/keystore.go b/keystore/keystore.go index ba8ddd7dff..a4466330e6 100644 --- a/keystore/keystore.go +++ b/keystore/keystore.go @@ -7,11 +7,10 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sync" "time" - "slices" - "golang.org/x/crypto/curve25519" gethkeystore "github.com/ethereum/go-ethereum/accounts/keystore" @@ -54,9 +53,11 @@ const ( // Digital signature key types. // Ed25519: // - Ed25519 for digital signatures. + // - Supports arbitrary messages sizes, no hashing required. Ed25519 KeyType = "ed25519" // ECDSA_S256: // - ECDSA on secp256k1 for digital signatures. + // - Only signs 32 byte digests. Caller must hash the data before signing. ECDSA_S256 KeyType = "ecdsa-secp256k1" ) @@ -153,7 +154,9 @@ type EncryptionParams struct { func publicKeyFromPrivateKey(privateKeyBytes internal.Raw, keyType KeyType) ([]byte, error) { switch keyType { case Ed25519: - return ed25519.PublicKey(internal.Bytes(privateKeyBytes)), nil + 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. @@ -210,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 } From 5096a9995884c1b6b820cdbff2c29cb1ef5541a9 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Thu, 9 Oct 2025 16:51:43 -0400 Subject: [PATCH 13/15] Fix test --- keystore/encryptor_test.go | 38 ++++++++++++++++++-------------------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index ac69b79368..775bb270a9 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -71,27 +71,25 @@ func TestEncryptDecrypt(t *testing.T) { expectedEncryptError: keystore.ErrEncryptionFailed, }} - for fromKeyName, fromKey := range th.KeysByName() { - for toKeyName, toKey := range th.KeysByName() { - testName := fmt.Sprintf("Encrypt %s to %s", fromKeyName, toKeyName) - var expectedEncryptError error - if fromKey.keyType == toKey.keyType && fromKey.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: fromKey.keyType, - remotePubKey: toKey.publicKey, - decryptKey: toKeyName, - expectedEncryptError: expectedEncryptError, - payload: []byte("hello world"), - }) + 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 { From 2f7258610217bbd84a2261424034c5c55f66ea86 Mon Sep 17 00:00:00 2001 From: connorwstein Date: Thu, 9 Oct 2025 16:53:58 -0400 Subject: [PATCH 14/15] Use errors.New --- keystore/encryptor.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/keystore/encryptor.go b/keystore/encryptor.go index 346da2c3b8..891421c9c7 100644 --- a/keystore/encryptor.go +++ b/keystore/encryptor.go @@ -7,6 +7,7 @@ import ( "crypto/ecdh" "crypto/rand" "crypto/sha256" + "errors" "fmt" "io" @@ -19,9 +20,9 @@ import ( // Opaque error messages to prevent information leakage var ( - ErrSharedSecretFailed = fmt.Errorf("shared secret derivation failed") - ErrEncryptionFailed = fmt.Errorf("encryption operation failed") - ErrDecryptionFailed = fmt.Errorf("decryption operation failed") + ErrSharedSecretFailed = errors.New("shared secret derivation failed") + ErrEncryptionFailed = errors.New("encryption operation failed") + ErrDecryptionFailed = errors.New("decryption operation failed") ) type EncryptRequest struct { From ac08a7904b554bd83d4bd62126c02384503d181f Mon Sep 17 00:00:00 2001 From: Connor Stein Date: Fri, 10 Oct 2025 12:38:30 -0400 Subject: [PATCH 15/15] Keystore signer impl (#1598) * Wip * Basic signer test * Errors new * Fix merge * Remove unnecessary lock --- keystore/encryptor_test.go | 22 +++++----- keystore/helpers_test.go | 12 +++--- keystore/signer.go | 82 ++++++++++++++++++++++++++++++++++++-- keystore/signer_test.go | 70 ++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 21 deletions(-) create mode 100644 keystore/signer_test.go diff --git a/keystore/encryptor_test.go b/keystore/encryptor_test.go index 775bb270a9..78db216f70 100644 --- a/keystore/encryptor_test.go +++ b/keystore/encryptor_test.go @@ -28,7 +28,7 @@ func TestEncryptDecrypt(t *testing.T) { { name: "Non-existent encrypt key", remoteKeyType: "blah", - remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, decryptKey: th.KeyName(keystore.X25519, 0), payload: []byte("hello world"), expectedEncryptError: keystore.ErrEncryptionFailed, @@ -36,21 +36,21 @@ func TestEncryptDecrypt(t *testing.T) { { name: "Empty payload x25519", remoteKeyType: keystore.X25519, - remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, + 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, + 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, + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, decryptKey: "blah", payload: []byte("hello world"), expectedDecryptError: keystore.ErrDecryptionFailed, @@ -58,14 +58,14 @@ func TestEncryptDecrypt(t *testing.T) { { name: "Max payload", remoteKeyType: keystore.X25519, - remotePubKey: th.KeysByType()[keystore.X25519][0].publicKey, + 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, + remotePubKey: th.KeysByType()[keystore.X25519][0].PublicKey, decryptKey: th.KeyName(keystore.X25519, 0), payload: make([]byte, keystore.MaxEncryptionPayloadSize+1), expectedEncryptError: keystore.ErrEncryptionFailed, @@ -74,7 +74,7 @@ func TestEncryptDecrypt(t *testing.T) { for encName, encKey := range th.KeysByName() { testName := fmt.Sprintf("Encrypt to %s", encName) var expectedEncryptError error - if encKey.keyType.IsEncryptionKeyType() { + if encKey.KeyType.IsEncryptionKeyType() { // Same key types should succeed expectedEncryptError = nil } else { @@ -84,8 +84,8 @@ func TestEncryptDecrypt(t *testing.T) { tt = append(tt, testCase{ name: testName, - remoteKeyType: encKey.keyType, - remotePubKey: encKey.publicKey, + remoteKeyType: encKey.KeyType, + remotePubKey: encKey.PublicKey, decryptKey: encName, expectedEncryptError: expectedEncryptError, payload: []byte("hello world"), @@ -157,7 +157,7 @@ func TestEncryptDecrypt_SharedSecret(t *testing.T) { 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, + RemotePubKey: th.KeysByType()[tt.keyType][0].PublicKey, }) if tt.expectedError != nil { require.Error(t, err) @@ -203,7 +203,7 @@ func FuzzEncryptDecryptRoundtrip(f *testing.F) { // 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 + 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)) diff --git a/keystore/helpers_test.go b/keystore/helpers_test.go index 99c2b94ecf..f67c733396 100644 --- a/keystore/helpers_test.go +++ b/keystore/helpers_test.go @@ -11,8 +11,8 @@ import ( ) type Key struct { - keyType keystore.KeyType - publicKey []byte + KeyType keystore.KeyType + PublicKey []byte } type KeystoreTH struct { @@ -66,10 +66,10 @@ func (th *KeystoreTH) CreateTestKeys(t *testing.T) { }, }) 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[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}) + 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/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) + }) + } +}