From f1c026ee84436400e37c1908630ca24498558944 Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Wed, 7 Oct 2026 09:27:49 -0400 Subject: [PATCH 1/6] Added vault get secrets directly and feature flag --- go.mod | 1 + go.sum | 2 + pkg/capabilities/actions/vault/errors.go | 20 +++ pkg/capabilities/actions/vault/messages.pb.go | 18 ++- pkg/capabilities/actions/vault/messages.proto | 2 + .../actions/vault/vaultcrypto/vaultcrypto.go | 149 ++++++++++++++++++ .../vault/vaultcrypto/vaultcrypto_test.go | 143 +++++++++++++++++ pkg/settings/cresettings/README.md | 1 + pkg/settings/cresettings/defaults.json | 3 +- pkg/settings/cresettings/defaults.toml | 1 + pkg/settings/cresettings/settings.go | 4 + pkg/settings/cresettings/settings_test.go | 12 ++ 12 files changed, 351 insertions(+), 5 deletions(-) create mode 100644 pkg/capabilities/actions/vault/errors.go create mode 100644 pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go create mode 100644 pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go diff --git a/go.mod b/go.mod index c30985e125..8a374a23b9 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342 + github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c github.com/stellar/go-stellar-sdk v0.4.0 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 diff --git a/go.sum b/go.sum index 9aa8e13089..798d223faa 100644 --- a/go.sum +++ b/go.sum @@ -273,6 +273,8 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342 h1:pEcgcjTGA83MzpqbTbyIg9AJrOs62s77SooDdJGIg9w= github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342/go.mod h1:5JPtsRwjugpyfsdEALC4RopfvohqK/G+3DHaR8uv+Bc= +github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c h1:S1AFIjfHT95ev6gqHKBGy1zj3Tz0fIN3XzkaDUn77wY= +github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c/go.mod h1:NSc7hgOQbXG3DAwkOdWnZzLTZENXSwDJ7Va1nBp0YU0= github.com/stellar/go-stellar-sdk v0.4.0 h1:WBLeJk7EllU7WhwrTH1L5Mu2EhZor/xy5sVi/82JzNQ= github.com/stellar/go-stellar-sdk v0.4.0/go.mod h1:tLKAQPxa2I5UvGMabBbUXcY3fmgYnfDudrMeK7CDX4w= github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf h1:GY1RVbX3Hg7poPXEf6yojjP0hyypvgUgZmCqQU9D0xg= diff --git a/pkg/capabilities/actions/vault/errors.go b/pkg/capabilities/actions/vault/errors.go new file mode 100644 index 0000000000..475de91b3a --- /dev/null +++ b/pkg/capabilities/actions/vault/errors.go @@ -0,0 +1,20 @@ +package vault + +import ( + "errors" + "strings" +) + +const SecretVersionSkewMessage = "vault: secret version skew across vault nodes" + +// ErrSecretVersionSkew is returned for direct GetSecrets requests when the Vault +// nodes did not agree on a single ciphertext for a secret. The request can be +// retried under a new request ID. +var ErrSecretVersionSkew = errors.New(SecretVersionSkewMessage) + +func IsSecretVersionSkew(err error) bool { + if err == nil { + return false + } + return errors.Is(err, ErrSecretVersionSkew) || strings.Contains(err.Error(), SecretVersionSkewMessage) +} diff --git a/pkg/capabilities/actions/vault/messages.pb.go b/pkg/capabilities/actions/vault/messages.pb.go index 81a867b8e4..55cc08baf8 100644 --- a/pkg/capabilities/actions/vault/messages.pb.go +++ b/pkg/capabilities/actions/vault/messages.pb.go @@ -496,8 +496,10 @@ type GetSecretsRequest struct { OrgId string `protobuf:"bytes,2,opt,name=org_id,json=orgId,proto3" json:"org_id,omitempty"` // Deprecated: Marked as deprecated in capabilities/actions/vault/messages.proto. WorkflowOwner string `protobuf:"bytes,3,opt,name=workflow_owner,json=workflowOwner,proto3" json:"workflow_owner,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Serve from each Vault node's local state instead of through OCR. + GetSecretsDirectly bool `protobuf:"varint,4,opt,name=get_secrets_directly,json=getSecretsDirectly,proto3" json:"get_secrets_directly,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetSecretsRequest) Reset() { @@ -553,6 +555,13 @@ func (x *GetSecretsRequest) GetWorkflowOwner() string { return "" } +func (x *GetSecretsRequest) GetGetSecretsDirectly() bool { + if x != nil { + return x.GetSecretsDirectly + } + return false +} + type GetSecretsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Responses []*SecretResponse `protobuf:"bytes,1,rep,name=responses,proto3" json:"responses,omitempty"` @@ -2532,11 +2541,12 @@ const file_capabilities_actions_vault_messages_proto_rawDesc = "" + "\x02id\x18\x01 \x01(\v2\x17.vault.SecretIdentifierR\x02id\x12'\n" + "\x04data\x18\x02 \x01(\v2\x11.vault.SecretDataH\x00R\x04data\x12\x16\n" + "\x05error\x18\x03 \x01(\tH\x00R\x05errorB\b\n" + - "\x06result\"\x8b\x01\n" + + "\x06result\"\xbd\x01\n" + "\x11GetSecretsRequest\x120\n" + "\brequests\x18\x01 \x03(\v2\x14.vault.SecretRequestR\brequests\x12\x19\n" + "\x06org_id\x18\x02 \x01(\tB\x02\x18\x01R\x05orgId\x12)\n" + - "\x0eworkflow_owner\x18\x03 \x01(\tB\x02\x18\x01R\rworkflowOwner\"z\n" + + "\x0eworkflow_owner\x18\x03 \x01(\tB\x02\x18\x01R\rworkflowOwner\x120\n" + + "\x14get_secrets_directly\x18\x04 \x01(\bR\x12getSecretsDirectly\"z\n" + "\x12GetSecretsResponse\x123\n" + "\tresponses\x18\x01 \x03(\v2\x15.vault.SecretResponseR\tresponses\x12/\n" + "\x14raw_vault_public_key\x18\x02 \x01(\tR\x11rawVaultPublicKey\"c\n" + diff --git a/pkg/capabilities/actions/vault/messages.proto b/pkg/capabilities/actions/vault/messages.proto index 42a5de062f..ac2a38f784 100644 --- a/pkg/capabilities/actions/vault/messages.proto +++ b/pkg/capabilities/actions/vault/messages.proto @@ -41,6 +41,8 @@ message GetSecretsRequest { repeated SecretRequest requests = 1; string org_id = 2 [deprecated = true]; string workflow_owner = 3 [deprecated = true]; + // Serve from each Vault node's local state instead of through OCR. + bool get_secrets_directly = 4; } message GetSecretsResponse { diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go new file mode 100644 index 0000000000..319f3a98ce --- /dev/null +++ b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go @@ -0,0 +1,149 @@ +// Package vaultcrypto holds the TDH2 share helpers shared by the Vault DON and its consumers. +package vaultcrypto + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + + "golang.org/x/crypto/curve25519" + "golang.org/x/crypto/nacl/box" + + "github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" +) + +// ErrInvalidRecipientKey matches EncryptShareBinary's errors for a malformed recipient key. +var ErrInvalidRecipientKey = errors.New("invalid recipient public key") + +type invalidRecipientKeyError struct{ msg string } + +func (e invalidRecipientKeyError) Error() string { return e.msg } +func (e invalidRecipientKeyError) Is(target error) bool { return target == ErrInvalidRecipientKey } + +// GeneratePlaintextShare returns this node's decryption share after checking the ciphertext's label. +func GeneratePlaintextShare(publicKey *tdh2easy.PublicKey, privateKeyShare *tdh2easy.PrivateShare, encryptedSecret []byte, expectedLabel [32]byte) ([]byte, error) { + if publicKey == nil { + return nil, errors.New("vault public key is not set") + } + if privateKeyShare == nil { + return nil, errors.New("vault private key share is not set") + } + + ct := &tdh2easy.Ciphertext{} + if err := ct.UnmarshalVerify(encryptedSecret, publicKey); err != nil { + return nil, fmt.Errorf("failed to unmarshal ciphertext: %w", err) + } + + if label := ct.Label(); label != expectedLabel { + return nil, fmt.Errorf("failed to verify label on secret. error: secret label [%s] does not match workflow owner label [%s]", + hex.EncodeToString(label[:]), hex.EncodeToString(expectedLabel[:])) + } + + s, err := tdh2easy.Decrypt(ct, privateKeyShare) + if err != nil { + return nil, fmt.Errorf("could not generate decryption share: %w", err) + } + + sb, err := s.Marshal() + if err != nil { + return nil, errors.New("could not marshal decryption share") + } + return sb, nil +} + +// EncryptShareBinary seals the share for a hex encoded curve25519 public key. +func EncryptShareBinary(share []byte, recipientPublicKeyHex string) ([]byte, error) { + publicKey, err := hex.DecodeString(recipientPublicKeyHex) + if err != nil { + return nil, invalidRecipientKeyError{"failed to convert public key to bytes: " + err.Error()} + } + + if len(publicKey) != curve25519.PointSize { + return nil, invalidRecipientKeyError{fmt.Sprintf("invalid public key size: expected %d bytes, got %d bytes", curve25519.PointSize, len(publicKey))} + } + + pk := [curve25519.PointSize]byte(publicKey) + encrypted, err := box.SealAnonymous(nil, share, &pk, rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to encrypt decryption share: %w", err) + } + return encrypted, nil +} + +// SharesForKey collects the shares for a recipient key from every matching entry, +// preferring an entry's binary shares over its legacy hex shares. +func SharesForKey(entries []*vaultcommon.EncryptedShares, recipientPublicKeyHex string) ([][]byte, error) { + var out [][]byte + for _, e := range entries { + if e.GetEncryptionKey() != recipientPublicKeyHex { + continue + } + if len(e.GetBinaryShares()) > 0 { + out = append(out, e.GetBinaryShares()...) + continue + } + for _, s := range e.GetShares() { + b, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("failed to decode hex share: %w", err) + } + out = append(out, b) + } + } + return out, nil +} + +type Decrypter interface { + Decrypt(encrypted []byte) ([]byte, error) +} + +type DecryptFunc func(encrypted []byte) ([]byte, error) + +func (f DecryptFunc) Decrypt(encrypted []byte) ([]byte, error) { return f(encrypted) } + +// VerifyShares returns the valid shares, and an error for each rejected one. +func VerifyShares(ct *tdh2easy.Ciphertext, publicKey *tdh2easy.PublicKey, encryptedShares [][]byte, d Decrypter) ([]*tdh2easy.DecryptionShare, []error) { + valid := make([]*tdh2easy.DecryptionShare, 0, len(encryptedShares)) + var rejected []error + for i, es := range encryptedShares { + b, err := d.Decrypt(es) + if err != nil { + rejected = append(rejected, fmt.Errorf("share %d: failed to decrypt: %w", i, err)) + continue + } + share := &tdh2easy.DecryptionShare{} + if err := share.Unmarshal(b); err != nil { + rejected = append(rejected, fmt.Errorf("share %d: failed to unmarshal: %w", i, err)) + continue + } + if err := tdh2easy.VerifyShare(ct, publicKey, share); err != nil { + rejected = append(rejected, fmt.Errorf("share %d: failed to verify: %w", i, err)) + continue + } + valid = append(valid, share) + } + return valid, rejected +} + +// DecryptSecret also returns the rejected shares' errors, for logging. +func DecryptSecret(encryptedSecret []byte, publicKey *tdh2easy.PublicKey, threshold int, encryptedShares [][]byte, d Decrypter) ([]byte, []error, error) { + ct := &tdh2easy.Ciphertext{} + if err := ct.UnmarshalVerify(encryptedSecret, publicKey); err != nil { + return nil, nil, errors.New("failed to unmarshal encrypted secret: " + err.Error()) + } + + valid, rejected := VerifyShares(ct, publicKey, encryptedShares, d) + if len(valid) < threshold { + return nil, rejected, fmt.Errorf("not enough decryption shares to decrypt the secret: have %d, need at least %d", len(valid), threshold) + } + + // Aggregate's n is only an allocation hint. + plaintext, err := tdh2easy.Aggregate(ct, valid, len(encryptedShares)) + if err != nil { + return nil, rejected, errors.New("failed to aggregate decryption shares: " + err.Error()) + } + return plaintext, rejected, nil +} diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go new file mode 100644 index 0000000000..d1f7b83e60 --- /dev/null +++ b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go @@ -0,0 +1,143 @@ +package vaultcrypto + +import ( + "crypto/rand" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/nacl/box" + + "github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" +) + +var label = [32]byte{31: 1} + +type recipient struct { + pub *[32]byte + priv *[32]byte +} + +func newRecipient(t *testing.T) recipient { + pub, priv, err := box.GenerateKey(rand.Reader) + require.NoError(t, err) + return recipient{pub: pub, priv: priv} +} + +func (r recipient) hex() string { return hex.EncodeToString(r.pub[:]) } + +func (r recipient) decrypter() Decrypter { + return DecryptFunc(func(b []byte) ([]byte, error) { + out, ok := box.OpenAnonymous(nil, b, r.pub, r.priv) + if !ok { + return nil, assert.AnError + } + return out, nil + }) +} + +func setup(t *testing.T, k, n int, secret string) (*tdh2easy.PublicKey, []*tdh2easy.PrivateShare, []byte) { + _, pk, shares, err := tdh2easy.GenerateKeys(k, n) + require.NoError(t, err) + ct, err := tdh2easy.EncryptWithLabel(pk, []byte(secret), label) + require.NoError(t, err) + ctb, err := ct.Marshal() + require.NoError(t, err) + return pk, shares, ctb +} + +func TestGenerateShareEncryptAndDecrypt(t *testing.T) { + const k, n = 2, 4 + pk, keyShares, ctb := setup(t, k, n, "my secret") + r := newRecipient(t) + + var encrypted [][]byte + for _, ks := range keyShares[:k] { + share, err := GeneratePlaintextShare(pk, ks, ctb, label) + require.NoError(t, err) + enc, err := EncryptShareBinary(share, r.hex()) + require.NoError(t, err) + encrypted = append(encrypted, enc) + } + + plaintext, rejected, err := DecryptSecret(ctb, pk, k, encrypted, r.decrypter()) + require.NoError(t, err) + assert.Empty(t, rejected) + assert.Equal(t, "my secret", string(plaintext)) +} + +func TestGeneratePlaintextShare_Errors(t *testing.T) { + pk, keyShares, ctb := setup(t, 2, 4, "my secret") + + _, err := GeneratePlaintextShare(pk, keyShares[0], ctb, [32]byte{31: 2}) + require.ErrorContains(t, err, "failed to verify label on secret") + + _, err = GeneratePlaintextShare(pk, keyShares[0], []byte("garbage"), label) + require.ErrorContains(t, err, "failed to unmarshal ciphertext") + + _, err = GeneratePlaintextShare(nil, keyShares[0], ctb, label) + require.ErrorContains(t, err, "public key is not set") + + _, err = GeneratePlaintextShare(pk, nil, ctb, label) + require.ErrorContains(t, err, "private key share is not set") +} + +func TestEncryptShareBinary_InvalidKey(t *testing.T) { + _, err := EncryptShareBinary([]byte("share"), "zz") + require.ErrorIs(t, err, ErrInvalidRecipientKey) + + _, err = EncryptShareBinary([]byte("share"), "abcd") + require.ErrorIs(t, err, ErrInvalidRecipientKey) +} + +func TestDecryptSecret_SkipsBadShares(t *testing.T) { + const k, n = 2, 4 + pk, keyShares, ctb := setup(t, k, n, "my secret") + r := newRecipient(t) + other := newRecipient(t) + + encryptFor := func(ks *tdh2easy.PrivateShare, to recipient) []byte { + share, err := GeneratePlaintextShare(pk, ks, ctb, label) + require.NoError(t, err) + enc, err := EncryptShareBinary(share, to.hex()) + require.NoError(t, err) + return enc + } + + // A share addressed to someone else and a garbage share are rejected. + garbage, err := EncryptShareBinary([]byte("not a share"), r.hex()) + require.NoError(t, err) + bad := [][]byte{encryptFor(keyShares[0], other), garbage} + + _, rejected, err := DecryptSecret(ctb, pk, k, append(bad, encryptFor(keyShares[0], r)), r.decrypter()) + require.ErrorContains(t, err, "not enough decryption shares") + assert.Len(t, rejected, 2) + + plaintext, rejected, err := DecryptSecret(ctb, pk, k, append(bad, encryptFor(keyShares[0], r), encryptFor(keyShares[1], r)), r.decrypter()) + require.NoError(t, err) + assert.Len(t, rejected, 2) + assert.Equal(t, "my secret", string(plaintext)) +} + +func TestSharesForKey(t *testing.T) { + entries := []*vaultcommon.EncryptedShares{ + {EncryptionKey: "a", BinaryShares: [][]byte{{1}, {2}}}, + {EncryptionKey: "b", BinaryShares: [][]byte{{3}}}, + {EncryptionKey: "a", Shares: []string{"04"}}, + {EncryptionKey: "a", Shares: []string{"05"}, BinaryShares: [][]byte{{6}}}, + } + + got, err := SharesForKey(entries, "a") + require.NoError(t, err) + assert.Equal(t, [][]byte{{1}, {2}, {4}, {6}}, got) + + got, err = SharesForKey(entries, "c") + require.NoError(t, err) + assert.Empty(t, got) + + _, err = SharesForKey([]*vaultcommon.EncryptedShares{{EncryptionKey: "a", Shares: []string{"zz"}}}, "a") + require.Error(t, err) +} diff --git a/pkg/settings/cresettings/README.md b/pkg/settings/cresettings/README.md index 87120257d2..f22cfb55b7 100644 --- a/pkg/settings/cresettings/README.md +++ b/pkg/settings/cresettings/README.md @@ -200,6 +200,7 @@ flowchart PerWorkflow.FeatureWorkflowTagBackfillActivePeriod[/PerWorkflow.FeatureWorkflowTagBackfillActivePeriod\]:::gate PerWorkflow.FeatureConsensusStricterMedianQuorumActivePeriod[/PerWorkflow.FeatureConsensusStricterMedianQuorumActivePeriod\]:::gate PerWorkflow.FeatureConsensusIncludeAllTimestampsActivePeriod[/PerWorkflow.FeatureConsensusIncludeAllTimestampsActivePeriod\]:::gate + PerWorkflow.FeatureVaultGetSecretsDirectlyActivePeriod[/PerWorkflow.FeatureVaultGetSecretsDirectlyActivePeriod\]:::gate PerWorkflow.ExecutionTimestampsEnabled-->PerWorkflow.ExecutionTimeout-->PerWorkflow.ExecutionResponseLimit end diff --git a/pkg/settings/cresettings/defaults.json b/pkg/settings/cresettings/defaults.json index e047845f2e..f6d138e8b5 100644 --- a/pkg/settings/cresettings/defaults.json +++ b/pkg/settings/cresettings/defaults.json @@ -233,6 +233,7 @@ "FeatureRequestHashIncludeWorkflowTagActivePeriod": "[0001-01-01 00:00:00 +0000 UTC,2100-01-01 00:00:00 +0000 UTC]", "FeatureWorkflowTagBackfillActivePeriod": "[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]", "FeatureConsensusStricterMedianQuorumActivePeriod": "[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]", - "FeatureConsensusIncludeAllTimestampsActivePeriod": "[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]" + "FeatureConsensusIncludeAllTimestampsActivePeriod": "[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]", + "FeatureVaultGetSecretsDirectlyActivePeriod": "[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]" } } \ No newline at end of file diff --git a/pkg/settings/cresettings/defaults.toml b/pkg/settings/cresettings/defaults.toml index 7311ee04e6..40cbf30520 100644 --- a/pkg/settings/cresettings/defaults.toml +++ b/pkg/settings/cresettings/defaults.toml @@ -142,6 +142,7 @@ FeatureRequestHashIncludeWorkflowTagActivePeriod = '[0001-01-01 00:00:00 +0000 U FeatureWorkflowTagBackfillActivePeriod = '[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]' FeatureConsensusStricterMedianQuorumActivePeriod = '[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]' FeatureConsensusIncludeAllTimestampsActivePeriod = '[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]' +FeatureVaultGetSecretsDirectlyActivePeriod = '[2100-01-01 00:00:00 +0000 UTC,2101-01-01 00:00:00 +0000 UTC]' [PerWorkflow.ChainAllowed] Default = 'false' diff --git a/pkg/settings/cresettings/settings.go b/pkg/settings/cresettings/settings.go index 458253b058..a1ae6a46ca 100644 --- a/pkg/settings/cresettings/settings.go +++ b/pkg/settings/cresettings/settings.go @@ -367,6 +367,9 @@ var Default = Schema{ FeatureConsensusIncludeAllTimestampsActivePeriod: TimeRange( time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2101, 1, 1, 0, 0, 0, 0, time.UTC)), + FeatureVaultGetSecretsDirectlyActivePeriod: TimeRange( + time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2101, 1, 1, 0, 0, 0, 0, time.UTC)), }, } @@ -566,6 +569,7 @@ type Workflows struct { FeatureWorkflowTagBackfillActivePeriod Setting[Range[config.Timestamp]] FeatureConsensusStricterMedianQuorumActivePeriod Setting[Range[config.Timestamp]] FeatureConsensusIncludeAllTimestampsActivePeriod Setting[Range[config.Timestamp]] + FeatureVaultGetSecretsDirectlyActivePeriod Setting[Range[config.Timestamp]] } type cronTrigger struct { diff --git a/pkg/settings/cresettings/settings_test.go b/pkg/settings/cresettings/settings_test.go index 1edaf361a3..4abfa1ba1e 100644 --- a/pkg/settings/cresettings/settings_test.go +++ b/pkg/settings/cresettings/settings_test.go @@ -288,6 +288,18 @@ func TestCentralTriggerEventQueueLimit(t *testing.T) { assert.Equal(t, 1000, got) } +func TestFeatureVaultGetSecretsDirectlyActivePeriodKeyInit(t *testing.T) { + s := Default.PerWorkflow.FeatureVaultGetSecretsDirectlyActivePeriod + + assert.Equal(t, "PerWorkflow.FeatureVaultGetSecretsDirectlyActivePeriod", s.GetKey()) + assert.Equal(t, settings.ScopeWorkflow, s.Scope) + assert.NotNil(t, s.Parse) + assert.Equal(t, settings.Range[config.Timestamp]{ + Lower: config.Timestamp(time.Date(2100, 1, 1, 0, 0, 0, 0, time.UTC).Unix()), + Upper: config.Timestamp(time.Date(2101, 1, 1, 0, 0, 0, 0, time.UTC).Unix()), + }, s.DefaultValue) +} + func TestGatewayProxyDonIDKeyInit(t *testing.T) { s := Default.PerWorkflow.HTTPAction.GatewayProxyDonID From 95cd9b5f9507863b26e224e8336a7bac42327f5c Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Tue, 20 Oct 2026 09:24:00 -0400 Subject: [PATCH 2/6] Added tdh2 --- go.md | 1 + 1 file changed, 1 insertion(+) diff --git a/go.md b/go.md index 4f2954c662..d0ca7574de 100644 --- a/go.md +++ b/go.md @@ -14,6 +14,7 @@ flowchart LR chainlink-common --> freeport chainlink-common --> grpc-proxy chainlink-common --> libocr + chainlink-common --> tdh2/go/tdh2 click chainlink-common href "https://github.com/smartcontractkit/chainlink-common" chainlink-common/keystore --> chainlink-common chainlink-common/keystore --> smdkg From 1138399b7e12560a156089f8576450c2a4f97796 Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Tue, 20 Oct 2026 21:30:53 -0400 Subject: [PATCH 3/6] Dedup shares before aggregating --- .../actions/vault/vaultcrypto/vaultcrypto.go | 7 +++ .../vault/vaultcrypto/vaultcrypto_test.go | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go index 319f3a98ce..d6069ddd9c 100644 --- a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go +++ b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go @@ -105,8 +105,10 @@ type DecryptFunc func(encrypted []byte) ([]byte, error) func (f DecryptFunc) Decrypt(encrypted []byte) ([]byte, error) { return f(encrypted) } // VerifyShares returns the valid shares, and an error for each rejected one. +// At most one share per node index is returned func VerifyShares(ct *tdh2easy.Ciphertext, publicKey *tdh2easy.PublicKey, encryptedShares [][]byte, d Decrypter) ([]*tdh2easy.DecryptionShare, []error) { valid := make([]*tdh2easy.DecryptionShare, 0, len(encryptedShares)) + seenIndexes := make(map[int]struct{}, len(encryptedShares)) var rejected []error for i, es := range encryptedShares { b, err := d.Decrypt(es) @@ -123,6 +125,11 @@ func VerifyShares(ct *tdh2easy.Ciphertext, publicKey *tdh2easy.PublicKey, encryp rejected = append(rejected, fmt.Errorf("share %d: failed to verify: %w", i, err)) continue } + if _, dup := seenIndexes[share.Index()]; dup { + rejected = append(rejected, fmt.Errorf("share %d: duplicate share for node index %d", i, share.Index())) + continue + } + seenIndexes[share.Index()] = struct{}{} valid = append(valid, share) } return valid, rejected diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go index d1f7b83e60..0e5f3a441e 100644 --- a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go +++ b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go @@ -122,6 +122,50 @@ func TestDecryptSecret_SkipsBadShares(t *testing.T) { assert.Equal(t, "my secret", string(plaintext)) } +func TestDecryptSecret_DuplicateShares(t *testing.T) { + const k, n = 2, 4 + pk, keyShares, ctb := setup(t, k, n, "my secret") + r := newRecipient(t) + + encryptFor := func(ks *tdh2easy.PrivateShare, to recipient) []byte { + share, err := GeneratePlaintextShare(pk, ks, ctb, label) + require.NoError(t, err) + enc, err := EncryptShareBinary(share, to.hex()) + require.NoError(t, err) + return enc + } + + // The same node's share encrypted twice: the sealed boxes differ, but both + // decrypt to a share with the same node index. + share0, err := GeneratePlaintextShare(pk, keyShares[0], ctb, label) + require.NoError(t, err) + share0Enc1, err := EncryptShareBinary(share0, r.hex()) + require.NoError(t, err) + share0Enc2, err := EncryptShareBinary(share0, r.hex()) + require.NoError(t, err) + + // An exact-duplicate share and a distinct share still decrypt at threshold. + plaintext, rejected, err := DecryptSecret(ctb, pk, k, [][]byte{share0Enc1, share0Enc2, encryptFor(keyShares[1], r)}, r.decrypter()) + require.NoError(t, err) + assert.Equal(t, "my secret", string(plaintext)) + require.Len(t, rejected, 1) + assert.ErrorContains(t, rejected[0], "duplicate share for node index 0") + + // A re-randomized share from the same node (fresh proof scalars, same u_i + // and index) is also dropped; byte-level dedup would miss this one. + plaintext, rejected, err = DecryptSecret(ctb, pk, k, [][]byte{share0Enc1, encryptFor(keyShares[0], r), encryptFor(keyShares[1], r)}, r.decrypter()) + require.NoError(t, err) + assert.Equal(t, "my secret", string(plaintext)) + require.Len(t, rejected, 1) + assert.ErrorContains(t, rejected[0], "duplicate share for node index 0") + + // Duplicates never inflate the distinct-share count past the threshold. + _, rejected, err = DecryptSecret(ctb, pk, k, [][]byte{share0Enc1, share0Enc2}, r.decrypter()) + require.ErrorContains(t, err, "not enough decryption shares") + require.Len(t, rejected, 1) + assert.ErrorContains(t, rejected[0], "duplicate share for node index 0") +} + func TestSharesForKey(t *testing.T) { entries := []*vaultcommon.EncryptedShares{ {EncryptionKey: "a", BinaryShares: [][]byte{{1}, {2}}}, From bdbccb8f53fe1bd9d1b51aa29d7a65a5bc3baffa Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Tue, 20 Oct 2026 21:47:34 -0400 Subject: [PATCH 4/6] Updated with comments --- pkg/capabilities/actions/vault/errors.go | 16 ++-------------- .../actions/vault/vaultcrypto/vaultcrypto.go | 18 +++++++++--------- .../vault/vaultcrypto/vaultcrypto_test.go | 17 +++++++++++++++-- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/pkg/capabilities/actions/vault/errors.go b/pkg/capabilities/actions/vault/errors.go index 475de91b3a..c587d0909c 100644 --- a/pkg/capabilities/actions/vault/errors.go +++ b/pkg/capabilities/actions/vault/errors.go @@ -1,20 +1,8 @@ package vault -import ( - "errors" - "strings" -) - -const SecretVersionSkewMessage = "vault: secret version skew across vault nodes" +import "errors" // ErrSecretVersionSkew is returned for direct GetSecrets requests when the Vault // nodes did not agree on a single ciphertext for a secret. The request can be // retried under a new request ID. -var ErrSecretVersionSkew = errors.New(SecretVersionSkewMessage) - -func IsSecretVersionSkew(err error) bool { - if err == nil { - return false - } - return errors.Is(err, ErrSecretVersionSkew) || strings.Contains(err.Error(), SecretVersionSkewMessage) -} +var ErrSecretVersionSkew = errors.New("vault: secret version skew across vault nodes") diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go index d6069ddd9c..8d3c13c8e0 100644 --- a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go +++ b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go @@ -20,8 +20,12 @@ var ErrInvalidRecipientKey = errors.New("invalid recipient public key") type invalidRecipientKeyError struct{ msg string } -func (e invalidRecipientKeyError) Error() string { return e.msg } -func (e invalidRecipientKeyError) Is(target error) bool { return target == ErrInvalidRecipientKey } +func (e invalidRecipientKeyError) Error() string { return e.msg } + +func (e invalidRecipientKeyError) Is(target error) bool { + _, ok := target.(invalidRecipientKeyError) + return ok || target == ErrInvalidRecipientKey +} // GeneratePlaintextShare returns this node's decryption share after checking the ciphertext's label. func GeneratePlaintextShare(publicKey *tdh2easy.PublicKey, privateKeyShare *tdh2easy.PrivateShare, encryptedSecret []byte, expectedLabel [32]byte) ([]byte, error) { @@ -96,17 +100,13 @@ func SharesForKey(entries []*vaultcommon.EncryptedShares, recipientPublicKeyHex return out, nil } -type Decrypter interface { +type decrypter interface { Decrypt(encrypted []byte) ([]byte, error) } -type DecryptFunc func(encrypted []byte) ([]byte, error) - -func (f DecryptFunc) Decrypt(encrypted []byte) ([]byte, error) { return f(encrypted) } - // VerifyShares returns the valid shares, and an error for each rejected one. // At most one share per node index is returned -func VerifyShares(ct *tdh2easy.Ciphertext, publicKey *tdh2easy.PublicKey, encryptedShares [][]byte, d Decrypter) ([]*tdh2easy.DecryptionShare, []error) { +func VerifyShares(ct *tdh2easy.Ciphertext, publicKey *tdh2easy.PublicKey, encryptedShares [][]byte, d decrypter) ([]*tdh2easy.DecryptionShare, []error) { valid := make([]*tdh2easy.DecryptionShare, 0, len(encryptedShares)) seenIndexes := make(map[int]struct{}, len(encryptedShares)) var rejected []error @@ -136,7 +136,7 @@ func VerifyShares(ct *tdh2easy.Ciphertext, publicKey *tdh2easy.PublicKey, encryp } // DecryptSecret also returns the rejected shares' errors, for logging. -func DecryptSecret(encryptedSecret []byte, publicKey *tdh2easy.PublicKey, threshold int, encryptedShares [][]byte, d Decrypter) ([]byte, []error, error) { +func DecryptSecret(encryptedSecret []byte, publicKey *tdh2easy.PublicKey, threshold int, encryptedShares [][]byte, d decrypter) ([]byte, []error, error) { ct := &tdh2easy.Ciphertext{} if err := ct.UnmarshalVerify(encryptedSecret, publicKey); err != nil { return nil, nil, errors.New("failed to unmarshal encrypted secret: " + err.Error()) diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go index 0e5f3a441e..9040a75b31 100644 --- a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go +++ b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go @@ -29,8 +29,8 @@ func newRecipient(t *testing.T) recipient { func (r recipient) hex() string { return hex.EncodeToString(r.pub[:]) } -func (r recipient) decrypter() Decrypter { - return DecryptFunc(func(b []byte) ([]byte, error) { +func (r recipient) decrypter() decrypter { + return decryptFunc(func(b []byte) ([]byte, error) { out, ok := box.OpenAnonymous(nil, b, r.pub, r.priv) if !ok { return nil, assert.AnError @@ -39,6 +39,10 @@ func (r recipient) decrypter() Decrypter { }) } +type decryptFunc func(encrypted []byte) ([]byte, error) + +func (f decryptFunc) Decrypt(encrypted []byte) ([]byte, error) { return f(encrypted) } + func setup(t *testing.T, k, n int, secret string) (*tdh2easy.PublicKey, []*tdh2easy.PrivateShare, []byte) { _, pk, shares, err := tdh2easy.GenerateKeys(k, n) require.NoError(t, err) @@ -93,6 +97,15 @@ func TestEncryptShareBinary_InvalidKey(t *testing.T) { require.ErrorIs(t, err, ErrInvalidRecipientKey) } +func TestInvalidRecipientKeyError_MatchesItself(t *testing.T) { + _, err := EncryptShareBinary([]byte("share"), "zz") + require.ErrorIs(t, err, ErrInvalidRecipientKey) + + other := invalidRecipientKeyError{"some other detail"} + assert.ErrorIs(t, err, other) + assert.ErrorIs(t, other, err) +} + func TestDecryptSecret_SkipsBadShares(t *testing.T) { const k, n = 2, 4 pk, keyShares, ctb := setup(t, k, n, "my secret") From 0b6374787605df1b9303c565d07538e02d1236f5 Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Wed, 21 Oct 2026 08:36:04 -0400 Subject: [PATCH 5/6] Removed vaultcrypto package --- go.mod | 1 - go.sum | 2 - .../actions/vault/vaultcrypto/vaultcrypto.go | 156 -------------- .../vault/vaultcrypto/vaultcrypto_test.go | 200 ------------------ 4 files changed, 359 deletions(-) delete mode 100644 pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go delete mode 100644 pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go diff --git a/go.mod b/go.mod index be837ce31f..71ea8a4118 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,6 @@ require ( github.com/smartcontractkit/freeport v0.1.3-0.20250716200817-cb5dfd0e369e github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342 - github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c github.com/stellar/go-stellar-sdk v0.4.0 github.com/stretchr/testify v1.12.1 go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 diff --git a/go.sum b/go.sum index 459e236111..428ef2957d 100644 --- a/go.sum +++ b/go.sum @@ -273,8 +273,6 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342 h1:pEcgcjTGA83MzpqbTbyIg9AJrOs62s77SooDdJGIg9w= github.com/smartcontractkit/libocr v0.0.0-20260810200708-618b5bf7f342/go.mod h1:5JPtsRwjugpyfsdEALC4RopfvohqK/G+3DHaR8uv+Bc= -github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c h1:S1AFIjfHT95ev6gqHKBGy1zj3Tz0fIN3XzkaDUn77wY= -github.com/smartcontractkit/tdh2/go/tdh2 v0.0.0-20251120172354-e8ec0386b06c/go.mod h1:NSc7hgOQbXG3DAwkOdWnZzLTZENXSwDJ7Va1nBp0YU0= github.com/stellar/go-stellar-sdk v0.4.0 h1:WBLeJk7EllU7WhwrTH1L5Mu2EhZor/xy5sVi/82JzNQ= github.com/stellar/go-stellar-sdk v0.4.0/go.mod h1:tLKAQPxa2I5UvGMabBbUXcY3fmgYnfDudrMeK7CDX4w= github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf h1:GY1RVbX3Hg7poPXEf6yojjP0hyypvgUgZmCqQU9D0xg= diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go deleted file mode 100644 index 8d3c13c8e0..0000000000 --- a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto.go +++ /dev/null @@ -1,156 +0,0 @@ -// Package vaultcrypto holds the TDH2 share helpers shared by the Vault DON and its consumers. -package vaultcrypto - -import ( - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - - "golang.org/x/crypto/curve25519" - "golang.org/x/crypto/nacl/box" - - "github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy" - - vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" -) - -// ErrInvalidRecipientKey matches EncryptShareBinary's errors for a malformed recipient key. -var ErrInvalidRecipientKey = errors.New("invalid recipient public key") - -type invalidRecipientKeyError struct{ msg string } - -func (e invalidRecipientKeyError) Error() string { return e.msg } - -func (e invalidRecipientKeyError) Is(target error) bool { - _, ok := target.(invalidRecipientKeyError) - return ok || target == ErrInvalidRecipientKey -} - -// GeneratePlaintextShare returns this node's decryption share after checking the ciphertext's label. -func GeneratePlaintextShare(publicKey *tdh2easy.PublicKey, privateKeyShare *tdh2easy.PrivateShare, encryptedSecret []byte, expectedLabel [32]byte) ([]byte, error) { - if publicKey == nil { - return nil, errors.New("vault public key is not set") - } - if privateKeyShare == nil { - return nil, errors.New("vault private key share is not set") - } - - ct := &tdh2easy.Ciphertext{} - if err := ct.UnmarshalVerify(encryptedSecret, publicKey); err != nil { - return nil, fmt.Errorf("failed to unmarshal ciphertext: %w", err) - } - - if label := ct.Label(); label != expectedLabel { - return nil, fmt.Errorf("failed to verify label on secret. error: secret label [%s] does not match workflow owner label [%s]", - hex.EncodeToString(label[:]), hex.EncodeToString(expectedLabel[:])) - } - - s, err := tdh2easy.Decrypt(ct, privateKeyShare) - if err != nil { - return nil, fmt.Errorf("could not generate decryption share: %w", err) - } - - sb, err := s.Marshal() - if err != nil { - return nil, errors.New("could not marshal decryption share") - } - return sb, nil -} - -// EncryptShareBinary seals the share for a hex encoded curve25519 public key. -func EncryptShareBinary(share []byte, recipientPublicKeyHex string) ([]byte, error) { - publicKey, err := hex.DecodeString(recipientPublicKeyHex) - if err != nil { - return nil, invalidRecipientKeyError{"failed to convert public key to bytes: " + err.Error()} - } - - if len(publicKey) != curve25519.PointSize { - return nil, invalidRecipientKeyError{fmt.Sprintf("invalid public key size: expected %d bytes, got %d bytes", curve25519.PointSize, len(publicKey))} - } - - pk := [curve25519.PointSize]byte(publicKey) - encrypted, err := box.SealAnonymous(nil, share, &pk, rand.Reader) - if err != nil { - return nil, fmt.Errorf("failed to encrypt decryption share: %w", err) - } - return encrypted, nil -} - -// SharesForKey collects the shares for a recipient key from every matching entry, -// preferring an entry's binary shares over its legacy hex shares. -func SharesForKey(entries []*vaultcommon.EncryptedShares, recipientPublicKeyHex string) ([][]byte, error) { - var out [][]byte - for _, e := range entries { - if e.GetEncryptionKey() != recipientPublicKeyHex { - continue - } - if len(e.GetBinaryShares()) > 0 { - out = append(out, e.GetBinaryShares()...) - continue - } - for _, s := range e.GetShares() { - b, err := hex.DecodeString(s) - if err != nil { - return nil, fmt.Errorf("failed to decode hex share: %w", err) - } - out = append(out, b) - } - } - return out, nil -} - -type decrypter interface { - Decrypt(encrypted []byte) ([]byte, error) -} - -// VerifyShares returns the valid shares, and an error for each rejected one. -// At most one share per node index is returned -func VerifyShares(ct *tdh2easy.Ciphertext, publicKey *tdh2easy.PublicKey, encryptedShares [][]byte, d decrypter) ([]*tdh2easy.DecryptionShare, []error) { - valid := make([]*tdh2easy.DecryptionShare, 0, len(encryptedShares)) - seenIndexes := make(map[int]struct{}, len(encryptedShares)) - var rejected []error - for i, es := range encryptedShares { - b, err := d.Decrypt(es) - if err != nil { - rejected = append(rejected, fmt.Errorf("share %d: failed to decrypt: %w", i, err)) - continue - } - share := &tdh2easy.DecryptionShare{} - if err := share.Unmarshal(b); err != nil { - rejected = append(rejected, fmt.Errorf("share %d: failed to unmarshal: %w", i, err)) - continue - } - if err := tdh2easy.VerifyShare(ct, publicKey, share); err != nil { - rejected = append(rejected, fmt.Errorf("share %d: failed to verify: %w", i, err)) - continue - } - if _, dup := seenIndexes[share.Index()]; dup { - rejected = append(rejected, fmt.Errorf("share %d: duplicate share for node index %d", i, share.Index())) - continue - } - seenIndexes[share.Index()] = struct{}{} - valid = append(valid, share) - } - return valid, rejected -} - -// DecryptSecret also returns the rejected shares' errors, for logging. -func DecryptSecret(encryptedSecret []byte, publicKey *tdh2easy.PublicKey, threshold int, encryptedShares [][]byte, d decrypter) ([]byte, []error, error) { - ct := &tdh2easy.Ciphertext{} - if err := ct.UnmarshalVerify(encryptedSecret, publicKey); err != nil { - return nil, nil, errors.New("failed to unmarshal encrypted secret: " + err.Error()) - } - - valid, rejected := VerifyShares(ct, publicKey, encryptedShares, d) - if len(valid) < threshold { - return nil, rejected, fmt.Errorf("not enough decryption shares to decrypt the secret: have %d, need at least %d", len(valid), threshold) - } - - // Aggregate's n is only an allocation hint. - plaintext, err := tdh2easy.Aggregate(ct, valid, len(encryptedShares)) - if err != nil { - return nil, rejected, errors.New("failed to aggregate decryption shares: " + err.Error()) - } - return plaintext, rejected, nil -} diff --git a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go b/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go deleted file mode 100644 index 9040a75b31..0000000000 --- a/pkg/capabilities/actions/vault/vaultcrypto/vaultcrypto_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package vaultcrypto - -import ( - "crypto/rand" - "encoding/hex" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "golang.org/x/crypto/nacl/box" - - "github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy" - - vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" -) - -var label = [32]byte{31: 1} - -type recipient struct { - pub *[32]byte - priv *[32]byte -} - -func newRecipient(t *testing.T) recipient { - pub, priv, err := box.GenerateKey(rand.Reader) - require.NoError(t, err) - return recipient{pub: pub, priv: priv} -} - -func (r recipient) hex() string { return hex.EncodeToString(r.pub[:]) } - -func (r recipient) decrypter() decrypter { - return decryptFunc(func(b []byte) ([]byte, error) { - out, ok := box.OpenAnonymous(nil, b, r.pub, r.priv) - if !ok { - return nil, assert.AnError - } - return out, nil - }) -} - -type decryptFunc func(encrypted []byte) ([]byte, error) - -func (f decryptFunc) Decrypt(encrypted []byte) ([]byte, error) { return f(encrypted) } - -func setup(t *testing.T, k, n int, secret string) (*tdh2easy.PublicKey, []*tdh2easy.PrivateShare, []byte) { - _, pk, shares, err := tdh2easy.GenerateKeys(k, n) - require.NoError(t, err) - ct, err := tdh2easy.EncryptWithLabel(pk, []byte(secret), label) - require.NoError(t, err) - ctb, err := ct.Marshal() - require.NoError(t, err) - return pk, shares, ctb -} - -func TestGenerateShareEncryptAndDecrypt(t *testing.T) { - const k, n = 2, 4 - pk, keyShares, ctb := setup(t, k, n, "my secret") - r := newRecipient(t) - - var encrypted [][]byte - for _, ks := range keyShares[:k] { - share, err := GeneratePlaintextShare(pk, ks, ctb, label) - require.NoError(t, err) - enc, err := EncryptShareBinary(share, r.hex()) - require.NoError(t, err) - encrypted = append(encrypted, enc) - } - - plaintext, rejected, err := DecryptSecret(ctb, pk, k, encrypted, r.decrypter()) - require.NoError(t, err) - assert.Empty(t, rejected) - assert.Equal(t, "my secret", string(plaintext)) -} - -func TestGeneratePlaintextShare_Errors(t *testing.T) { - pk, keyShares, ctb := setup(t, 2, 4, "my secret") - - _, err := GeneratePlaintextShare(pk, keyShares[0], ctb, [32]byte{31: 2}) - require.ErrorContains(t, err, "failed to verify label on secret") - - _, err = GeneratePlaintextShare(pk, keyShares[0], []byte("garbage"), label) - require.ErrorContains(t, err, "failed to unmarshal ciphertext") - - _, err = GeneratePlaintextShare(nil, keyShares[0], ctb, label) - require.ErrorContains(t, err, "public key is not set") - - _, err = GeneratePlaintextShare(pk, nil, ctb, label) - require.ErrorContains(t, err, "private key share is not set") -} - -func TestEncryptShareBinary_InvalidKey(t *testing.T) { - _, err := EncryptShareBinary([]byte("share"), "zz") - require.ErrorIs(t, err, ErrInvalidRecipientKey) - - _, err = EncryptShareBinary([]byte("share"), "abcd") - require.ErrorIs(t, err, ErrInvalidRecipientKey) -} - -func TestInvalidRecipientKeyError_MatchesItself(t *testing.T) { - _, err := EncryptShareBinary([]byte("share"), "zz") - require.ErrorIs(t, err, ErrInvalidRecipientKey) - - other := invalidRecipientKeyError{"some other detail"} - assert.ErrorIs(t, err, other) - assert.ErrorIs(t, other, err) -} - -func TestDecryptSecret_SkipsBadShares(t *testing.T) { - const k, n = 2, 4 - pk, keyShares, ctb := setup(t, k, n, "my secret") - r := newRecipient(t) - other := newRecipient(t) - - encryptFor := func(ks *tdh2easy.PrivateShare, to recipient) []byte { - share, err := GeneratePlaintextShare(pk, ks, ctb, label) - require.NoError(t, err) - enc, err := EncryptShareBinary(share, to.hex()) - require.NoError(t, err) - return enc - } - - // A share addressed to someone else and a garbage share are rejected. - garbage, err := EncryptShareBinary([]byte("not a share"), r.hex()) - require.NoError(t, err) - bad := [][]byte{encryptFor(keyShares[0], other), garbage} - - _, rejected, err := DecryptSecret(ctb, pk, k, append(bad, encryptFor(keyShares[0], r)), r.decrypter()) - require.ErrorContains(t, err, "not enough decryption shares") - assert.Len(t, rejected, 2) - - plaintext, rejected, err := DecryptSecret(ctb, pk, k, append(bad, encryptFor(keyShares[0], r), encryptFor(keyShares[1], r)), r.decrypter()) - require.NoError(t, err) - assert.Len(t, rejected, 2) - assert.Equal(t, "my secret", string(plaintext)) -} - -func TestDecryptSecret_DuplicateShares(t *testing.T) { - const k, n = 2, 4 - pk, keyShares, ctb := setup(t, k, n, "my secret") - r := newRecipient(t) - - encryptFor := func(ks *tdh2easy.PrivateShare, to recipient) []byte { - share, err := GeneratePlaintextShare(pk, ks, ctb, label) - require.NoError(t, err) - enc, err := EncryptShareBinary(share, to.hex()) - require.NoError(t, err) - return enc - } - - // The same node's share encrypted twice: the sealed boxes differ, but both - // decrypt to a share with the same node index. - share0, err := GeneratePlaintextShare(pk, keyShares[0], ctb, label) - require.NoError(t, err) - share0Enc1, err := EncryptShareBinary(share0, r.hex()) - require.NoError(t, err) - share0Enc2, err := EncryptShareBinary(share0, r.hex()) - require.NoError(t, err) - - // An exact-duplicate share and a distinct share still decrypt at threshold. - plaintext, rejected, err := DecryptSecret(ctb, pk, k, [][]byte{share0Enc1, share0Enc2, encryptFor(keyShares[1], r)}, r.decrypter()) - require.NoError(t, err) - assert.Equal(t, "my secret", string(plaintext)) - require.Len(t, rejected, 1) - assert.ErrorContains(t, rejected[0], "duplicate share for node index 0") - - // A re-randomized share from the same node (fresh proof scalars, same u_i - // and index) is also dropped; byte-level dedup would miss this one. - plaintext, rejected, err = DecryptSecret(ctb, pk, k, [][]byte{share0Enc1, encryptFor(keyShares[0], r), encryptFor(keyShares[1], r)}, r.decrypter()) - require.NoError(t, err) - assert.Equal(t, "my secret", string(plaintext)) - require.Len(t, rejected, 1) - assert.ErrorContains(t, rejected[0], "duplicate share for node index 0") - - // Duplicates never inflate the distinct-share count past the threshold. - _, rejected, err = DecryptSecret(ctb, pk, k, [][]byte{share0Enc1, share0Enc2}, r.decrypter()) - require.ErrorContains(t, err, "not enough decryption shares") - require.Len(t, rejected, 1) - assert.ErrorContains(t, rejected[0], "duplicate share for node index 0") -} - -func TestSharesForKey(t *testing.T) { - entries := []*vaultcommon.EncryptedShares{ - {EncryptionKey: "a", BinaryShares: [][]byte{{1}, {2}}}, - {EncryptionKey: "b", BinaryShares: [][]byte{{3}}}, - {EncryptionKey: "a", Shares: []string{"04"}}, - {EncryptionKey: "a", Shares: []string{"05"}, BinaryShares: [][]byte{{6}}}, - } - - got, err := SharesForKey(entries, "a") - require.NoError(t, err) - assert.Equal(t, [][]byte{{1}, {2}, {4}, {6}}, got) - - got, err = SharesForKey(entries, "c") - require.NoError(t, err) - assert.Empty(t, got) - - _, err = SharesForKey([]*vaultcommon.EncryptedShares{{EncryptionKey: "a", Shares: []string{"zz"}}}, "a") - require.Error(t, err) -} From ffb5b15b7651e86a66294547119031b836cba348 Mon Sep 17 00:00:00 2001 From: Russell Stern Date: Sun, 1 Nov 2026 20:40:21 -0500 Subject: [PATCH 6/6] Fixed package issue --- go.md | 1 - 1 file changed, 1 deletion(-) diff --git a/go.md b/go.md index d0ca7574de..4f2954c662 100644 --- a/go.md +++ b/go.md @@ -14,7 +14,6 @@ flowchart LR chainlink-common --> freeport chainlink-common --> grpc-proxy chainlink-common --> libocr - chainlink-common --> tdh2/go/tdh2 click chainlink-common href "https://github.com/smartcontractkit/chainlink-common" chainlink-common/keystore --> chainlink-common chainlink-common/keystore --> smdkg