Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 24 additions & 11 deletions core/capabilities/vault/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import (
"fmt"
"strconv"

"github.com/ethereum/go-ethereum/common"
"github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy"

vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault"
pkgconfig "github.com/smartcontractkit/chainlink-common/pkg/config"
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes"
"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils"
)

type RequestValidator struct {
Expand Down Expand Up @@ -65,7 +65,7 @@ func (r *RequestValidator) validateWriteRequest(publicKey *tdh2easy.PublicKey, i
if err := r.validateCiphertextSize(req.EncryptedValue); err != nil {
return fmt.Errorf("secret encrypted value at index %d is invalid: %w", idx, err)
}
err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, req.Id.Owner)
err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, req.Id.Owner, "")
if err != nil {
return errors.New("Encrypted Secret at index [" + strconv.Itoa(idx) + "] doesn't have owner as the label. Error: " + err.Error())
}
Expand Down Expand Up @@ -159,27 +159,40 @@ func NewRequestValidator(
}
}

func EnsureRightLabelOnSecret(publicKey *tdh2easy.PublicKey, secret, owner string) error {
// EnsureRightLabelOnSecret verifies that the TDH2 ciphertext label matches either the
// workflowOwner (Ethereum address, left-padded) or the orgID (SHA256 hash). Either
// parameter can be empty to skip that check. The function succeeds if the label matches
// at least one non-empty owner.
func EnsureRightLabelOnSecret(publicKey *tdh2easy.PublicKey, secret string, workflowOwner string, orgID string) error {
cipherText := &tdh2easy.Ciphertext{}
cipherBytes, err := hex.DecodeString(secret)
if err != nil {
return errors.New("failed to decode encrypted value:" + err.Error())
}
if publicKey == nil {
// Public key can be nil if gateway cache isn't populated yet(immediately after gateway reboots)
// Ok to not validate in such cases, since this validation also runs on Vault Nodes
// Public key can be nil if gateway cache isn't populated yet (immediately after gateway reboots).
// Ok to not validate in such cases, since this validation also runs on Vault Nodes.
return nil
}
err = cipherText.UnmarshalVerify(cipherBytes, publicKey)
if err != nil {
return errors.New("failed to verify encrypted value:" + err.Error())
}
secretLabel := cipherText.Label()
ownerAddr := common.HexToAddress(owner)
var ownerLabel [32]byte
copy(ownerLabel[12:], ownerAddr.Bytes()) // left-pad with 12 zero
if secretLabel != ownerLabel {
return errors.New("secret label [" + hex.EncodeToString(secretLabel[:]) + "] does not match owner label [" + hex.EncodeToString(ownerLabel[:]) + "]")

if workflowOwner != "" {
expected := vaultutils.WorkflowOwnerToLabel(workflowOwner)
if secretLabel == expected {
return nil
}
}
return nil

if orgID != "" {
expected := vaultutils.OrgIDToLabel(orgID)
if secretLabel == expected {
return nil
}
}

return errors.New("secret label [" + hex.EncodeToString(secretLabel[:]) + "] does not match any of the provided owner labels")
}
198 changes: 198 additions & 0 deletions core/capabilities/vault/validator_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,214 @@
package vault

import (
"crypto/sha256"
"encoding/hex"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault"
pkgconfig "github.com/smartcontractkit/chainlink-common/pkg/config"
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils"
)

func generateTestKeys(t *testing.T) (*tdh2easy.PublicKey, []*tdh2easy.PrivateShare) {
t.Helper()
_, pk, shares, err := tdh2easy.GenerateKeys(1, 3)
require.NoError(t, err)
return pk, shares
}

func encryptWithEthAddressLabel(t *testing.T, pk *tdh2easy.PublicKey, owner string) string {
t.Helper()
encrypted, err := vaultutils.EncryptSecretWithWorkflowOwner("test-secret", pk, common.HexToAddress(owner))
require.NoError(t, err)
return encrypted
}

func encryptWithOrgIDLabel(t *testing.T, pk *tdh2easy.PublicKey, orgID string) string {
t.Helper()
encrypted, err := vaultutils.EncryptSecretWithOrgID("test-secret", pk, orgID)
require.NoError(t, err)
return encrypted
}

func TestWorkflowOwnerToLabel(t *testing.T) {
t.Run("ethereum address with 0x prefix", func(t *testing.T) {
addr := "0x0001020304050607080900010203040506070809"
label := vaultutils.WorkflowOwnerToLabel(addr)

var expected [32]byte
copy(expected[12:], common.HexToAddress(addr).Bytes())
assert.Equal(t, expected, label)
})

t.Run("ethereum address without 0x prefix", func(t *testing.T) {
addr := "0001020304050607080900010203040506070809"
label := vaultutils.WorkflowOwnerToLabel(addr)

var expected [32]byte
copy(expected[12:], common.HexToAddress(addr).Bytes())
assert.Equal(t, expected, label)
})

t.Run("checksummed ethereum address", func(t *testing.T) {
addr := "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B"
label := vaultutils.WorkflowOwnerToLabel(addr)

var expected [32]byte
copy(expected[12:], common.HexToAddress(addr).Bytes())
assert.Equal(t, expected, label)
})
}

func TestOrgIDToLabel(t *testing.T) {
t.Run("org_id produces SHA256 label", func(t *testing.T) {
orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz"
label := vaultutils.OrgIDToLabel(orgID)

expected := sha256.Sum256([]byte(orgID))
assert.Equal(t, expected, label)
})

t.Run("short string", func(t *testing.T) {
orgID := "my-org-id"
label := vaultutils.OrgIDToLabel(orgID)

expected := sha256.Sum256([]byte(orgID))
assert.Equal(t, expected, label)
})
}

func TestEnsureRightLabelOnSecret_WorkflowOwnerOnly(t *testing.T) {
pk, _ := generateTestKeys(t)
owner := "0x0001020304050607080900010203040506070809"
secret := encryptWithEthAddressLabel(t, pk, owner)

err := EnsureRightLabelOnSecret(pk, secret, owner, "")
assert.NoError(t, err)
}

func TestEnsureRightLabelOnSecret_OrgIDOnly(t *testing.T) {
pk, _ := generateTestKeys(t)
orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz"
secret := encryptWithOrgIDLabel(t, pk, orgID)

err := EnsureRightLabelOnSecret(pk, secret, "", orgID)
assert.NoError(t, err)
}

func TestEnsureRightLabelOnSecret_DualMatchesWorkflowOwner(t *testing.T) {
pk, _ := generateTestKeys(t)
ethAddr := "0x0001020304050607080900010203040506070809"
orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz"
secret := encryptWithEthAddressLabel(t, pk, ethAddr)

err := EnsureRightLabelOnSecret(pk, secret, ethAddr, orgID)
assert.NoError(t, err)
}

func TestEnsureRightLabelOnSecret_DualMatchesOrgID(t *testing.T) {
pk, _ := generateTestKeys(t)
ethAddr := "0x0001020304050607080900010203040506070809"
orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz"
secret := encryptWithOrgIDLabel(t, pk, orgID)

err := EnsureRightLabelOnSecret(pk, secret, ethAddr, orgID)
assert.NoError(t, err)
}

func TestEnsureRightLabelOnSecret_NeitherMatches(t *testing.T) {
pk, _ := generateTestKeys(t)
ethAddr := "0x0001020304050607080900010203040506070809"
wrongAddr := "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
wrongOrgID := "org_wrong"
secret := encryptWithEthAddressLabel(t, pk, ethAddr)

err := EnsureRightLabelOnSecret(pk, secret, wrongAddr, wrongOrgID)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not match any of the provided owner labels")
}

func TestEnsureRightLabelOnSecret_BothEmpty(t *testing.T) {
pk, _ := generateTestKeys(t)
ethAddr := "0x0001020304050607080900010203040506070809"
secret := encryptWithEthAddressLabel(t, pk, ethAddr)

err := EnsureRightLabelOnSecret(pk, secret, "", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "does not match any of the provided owner labels")
}

func TestEnsureRightLabelOnSecret_NilPublicKey(t *testing.T) {
pk, _ := generateTestKeys(t)
ethAddr := "0x0001020304050607080900010203040506070809"
secret := encryptWithEthAddressLabel(t, pk, ethAddr)

err := EnsureRightLabelOnSecret(nil, secret, ethAddr, "")
assert.NoError(t, err)
}

func TestEnsureRightLabelOnSecret_InvalidHexSecret(t *testing.T) {
pk, _ := generateTestKeys(t)

err := EnsureRightLabelOnSecret(pk, "not-valid-hex!", "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to decode encrypted value")
}

func TestEnsureRightLabelOnSecret_InvalidCiphertext(t *testing.T) {
pk, _ := generateTestKeys(t)

err := EnsureRightLabelOnSecret(pk, hex.EncodeToString([]byte("garbage")), "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to verify encrypted value")
}

func TestEnsureRightLabelOnSecret_WrongPublicKey(t *testing.T) {
pk, _ := generateTestKeys(t)
wrongPK, _ := generateTestKeys(t)
ethAddr := "0x0001020304050607080900010203040506070809"
secret := encryptWithEthAddressLabel(t, pk, ethAddr)

err := EnsureRightLabelOnSecret(wrongPK, secret, ethAddr, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to verify encrypted value")
}

func TestEnsureRightLabelOnSecret_BackwardCompatSingleOwner(t *testing.T) {
pk, _ := generateTestKeys(t)
owner := "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B"
secret := encryptWithEthAddressLabel(t, pk, owner)

err := EnsureRightLabelOnSecret(pk, secret, owner, "")
assert.NoError(t, err)
}

func TestEnsureRightLabelOnSecret_LegacySecretReadViaNewFlow(t *testing.T) {
pk, _ := generateTestKeys(t)
workflowOwner := "0x0001020304050607080900010203040506070809"
orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz"

secret := encryptWithEthAddressLabel(t, pk, workflowOwner)
err := EnsureRightLabelOnSecret(pk, secret, workflowOwner, orgID)
assert.NoError(t, err)
}

func TestEnsureRightLabelOnSecret_NewSecretReadViaNewFlow(t *testing.T) {
pk, _ := generateTestKeys(t)
orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz"
workflowOwner := "0x0001020304050607080900010203040506070809"

secret := encryptWithOrgIDLabel(t, pk, orgID)
err := EnsureRightLabelOnSecret(pk, secret, workflowOwner, orgID)
assert.NoError(t, err)
}

func TestRequestValidator_CiphertextSizeLimit(t *testing.T) {
validator := NewRequestValidator(
limits.NewUpperBoundLimiter(10),
Expand Down
53 changes: 53 additions & 0 deletions core/capabilities/vault/vaultutils/labels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package vaultutils

import (
"crypto/sha256"
"encoding/hex"
"fmt"

"github.com/ethereum/go-ethereum/common"
"github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy"
)

// WorkflowOwnerToLabel converts a workflow owner string to a 32-byte TDH2 ciphertext
// label using the Ethereum address encoding: 12 zero bytes followed by the 20-byte address.
// This matches the legacy label format used when secrets are encrypted with a workflow owner.
func WorkflowOwnerToLabel(owner string) [32]byte {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@prashantkumar1982 Is it worth accepting a common.Address here? Atm HexToAddress will truncate to the right length; I also wonder what it will do if the input isn't hex 🤔

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Feel free to follow up if this is urgent btw)

var label [32]byte
addr := common.HexToAddress(owner)
copy(label[12:], addr.Bytes())
return label
}

// OrgIDToLabel converts an org_id string to a 32-byte TDH2 ciphertext label
// using SHA256 hashing.
func OrgIDToLabel(orgID string) [32]byte {
return sha256.Sum256([]byte(orgID))
}

// EncryptSecretWithWorkflowOwner encrypts a secret using a TDH2 public key with a label
// derived from a workflow owner's Ethereum address (left-padded to 32 bytes).
func EncryptSecretWithWorkflowOwner(secret string, masterPublicKey *tdh2easy.PublicKey, owner common.Address) (string, error) {
var label [32]byte
copy(label[12:], owner.Bytes())
return encryptWithLabel(secret, masterPublicKey, label)
}

// EncryptSecretWithOrgID encrypts a secret using a TDH2 public key with a label
// derived from an org_id (SHA256 hash of the org_id string).
func EncryptSecretWithOrgID(secret string, masterPublicKey *tdh2easy.PublicKey, orgID string) (string, error) {
label := sha256.Sum256([]byte(orgID))
return encryptWithLabel(secret, masterPublicKey, label)
}

func encryptWithLabel(secret string, masterPublicKey *tdh2easy.PublicKey, label [32]byte) (string, error) {
cipher, err := tdh2easy.EncryptWithLabel(masterPublicKey, []byte(secret), label)
if err != nil {
return "", fmt.Errorf("failed to encrypt secret: %w", err)
}
cipherBytes, err := cipher.Marshal()
if err != nil {
return "", fmt.Errorf("failed to marshal encrypted secret: %w", err)
}
return hex.EncodeToString(cipherBytes), nil
}
Loading
Loading