Skip to content
Binary file modified core/capabilities/compute/test/fetch/cmd/testdata/output.wasm.br
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion core/scripts/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ require (
github.com/smartcontractkit/chain-selectors v1.0.104
github.com/smartcontractkit/chainlink-automation v0.8.1
github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260618155522-3600f66e26cd
github.com/smartcontractkit/chainlink-common v0.11.2-0.20260701091216-9264d4444ce0
github.com/smartcontractkit/chainlink-common v0.11.2-0.20260706164853-7fb6f76d132d
github.com/smartcontractkit/chainlink-common/keystore v1.2.0
github.com/smartcontractkit/chainlink-data-streams v0.1.15-0.20260522094612-5f9f748bd87a
github.com/smartcontractkit/chainlink-deployments-framework v0.111.1-0.20260612191326-e31c0ae4cd54
Expand Down
4 changes: 2 additions & 2 deletions core/scripts/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions core/services/chainlink/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ func NewApplication(ctx context.Context, opts ApplicationOpts) (Application, err
atomicSettings,
creServices.OCRConfigService,
cfg.Capabilities().Local(),
creServices.WorkflowRegistrySyncer,
)
delegates[job.StandardCapabilities] = stdcapDelegate
if creServices.SetDelegatesDeps != nil {
Expand Down
31 changes: 31 additions & 0 deletions core/services/standardcapabilities/delegate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import (
"context"
"crypto"
"encoding/json"
"fmt"
"strconv"
"strings"

"github.com/google/uuid"
"github.com/pelletier/go-toml"
Expand Down Expand Up @@ -38,6 +40,7 @@
"github.com/smartcontractkit/chainlink/v2/core/services/pipeline"
"github.com/smartcontractkit/chainlink/v2/core/services/standardcapabilities/conversions"
"github.com/smartcontractkit/chainlink/v2/core/services/telemetry"
syncerV2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/syncer/v2"
"github.com/smartcontractkit/chainlink/v2/plugins"
)

Expand Down Expand Up @@ -66,6 +69,7 @@
creSettings core.SettingsBroadcaster
ocrConfigService capregconfig.OCRConfigService
localCfg coreconfig.LocalCapabilities
workflowRegistrySyncer syncerV2.WorkflowRegistrySyncer
initErr error

isNewlyCreatedJob bool
Expand All @@ -79,7 +83,7 @@

type NewOracleFactoryFn func(generic.OracleFactoryParams) (core.OracleFactory, error)

func NewDelegate(

Check warning on line 86 in core/services/standardcapabilities/delegate.go

View check run for this annotation

CL-sonarqube-production / SonarQube Code Analysis

This function has 20 parameters, which is greater than the 10 authorized.

[S107] Functions should not have too many parameters See more on https://sonarqube.main.prod.cldev.sh/project/issues?id=smartcontractkit_chainlink&pullRequest=23013&issues=91c8a3db-509d-4faf-b6f0-917ec8df14ad&open=91c8a3db-509d-4faf-b6f0-917ec8df14ad
logger logger.Logger,
ds sqlutil.DataSource,
jobORM job.ORM,
Expand All @@ -98,6 +102,7 @@
creSettings core.SettingsBroadcaster,
ocrConfigService capregconfig.OCRConfigService,
localCfg coreconfig.LocalCapabilities,
workflowRegistrySyncer syncerV2.WorkflowRegistrySyncer,
opts ...func(*gateway.RoundRobinSelector),
) *Delegate {
initErr := registerOptionalMockStreamsTrigger(logger, localCfg, registry)
Expand Down Expand Up @@ -125,6 +130,7 @@
creSettings: creSettings,
ocrConfigService: ocrConfigService,
localCfg: localCfg,
workflowRegistrySyncer: workflowRegistrySyncer,
initErr: initErr,
selectorOpts: opts,
}
Expand All @@ -146,6 +152,12 @@

command := spec.StandardCapabilitiesSpec.Command
configJSON := spec.StandardCapabilitiesSpec.Config
if d.workflowRegistrySyncer != nil {
if tenantID, ok := parseTenantID(configJSON); ok {
d.workflowRegistrySyncer.SetTenantID(tenantID)

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.

:( This feels pretty wrong tbh -- what happens if multiple capabilities have different tenant IDs? Atm the last one ones and we're probably not guaranteed that it will consistently win

d.logger.Infow("Configured tenant ID from capability job spec", "tenantID", tenantID, "command", command)
}
}

if d.localCfg != nil {
capabilityID := conversions.GetCapabilityIDFromCommand(command, configJSON)
Expand All @@ -168,6 +180,25 @@
return d.NewServices(ctx, command, configJSON, spec.ID, spec.Name.ValueOrZero(), spec.ExternalJobID, spec.StandardCapabilitiesSpec.OracleFactory, 0)
}

// parseTenantID extracts tenantID from the consensus capability job spec config. It ignores all other fields so it does not
// interfere with the capability plugin's own use of the config. The second return value is false when tenantID is
// absent, zero, or the config cannot be parsed.
func parseTenantID(configJSON string) (uint64, bool) {
if strings.TrimSpace(configJSON) == "" {
return 0, false
}
var c struct {
TenantID uint64 `json:"tenantID"`
}
if err := json.Unmarshal([]byte(configJSON), &c); err != nil {
return 0, false
}
if c.TenantID == 0 {
return 0, false
}
return c.TenantID, true
}

// NewServices builds the per-job services for a Standard Capabilities LOOP.
//
// capabilityDonID is the on-chain DON ID this plugin process is being spawned
Expand Down
34 changes: 34 additions & 0 deletions core/services/standardcapabilities/tenant_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package standardcapabilities

import (
"testing"

"github.com/stretchr/testify/assert"
)

func Test_parseTenantID(t *testing.T) {
t.Parallel()

tests := []struct {
name string
config string
want uint64
wantOK bool
}{
{name: "empty config", config: "", want: 0, wantOK: false},
{name: "no tenantID", config: `{"schedule":"1s"}`, want: 0, wantOK: false},
{name: "zero tenantID", config: `{"tenantID":0}`, want: 0, wantOK: false},
{name: "tenantID set", config: `{"tenantID":1}`, want: 1, wantOK: true},
{name: "tenantID with other fields", config: `{"foo":"bar","tenantID":42}`, want: 42, wantOK: true},
{name: "invalid json", config: `{not json`, want: 0, wantOK: false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, ok := parseTenantID(tt.config)
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.want, got)
})
}
}
Binary file not shown.
123 changes: 122 additions & 1 deletion core/services/workflows/syncer/v2/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import (
"fmt"
"io"
"maps"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"

"go.opentelemetry.io/otel"
Expand Down Expand Up @@ -89,7 +92,11 @@ type eventHandler struct {
workflowDonSubscriber capabilities.DonSubscriber
billingClient metering.BillingClient
orgResolver orgresolver.OrgResolver
secretsFetcher v2.SecretsFetcher
// tenantID is the tenant numeric id for the CRE environment. It is sourced from a
// job spec (see SetTenantID, wired from the consensus OCR2 plugin) and may be
// updated at runtime, so access is atomic. Zero falls back to defaultTenantID.
tenantID atomic.Uint64
secretsFetcher v2.SecretsFetcher
// localSecretOverrides is keyed by owner address; values are secret id -> secret value
localSecretOverrides map[string]map[string]string

Expand Down Expand Up @@ -180,6 +187,21 @@ func WithOrgResolver(orgResolver orgresolver.OrgResolver) func(*eventHandler) {
}
}

// WithTenantID sets the tenant numeric id for the CRE environment. A zero value
// falls back to defaultTenantID.
func WithTenantID(tenantID uint64) func(*eventHandler) {
return func(e *eventHandler) {
e.tenantID.Store(tenantID)
}
}

// SetTenantID updates the tenant numeric id for the CRE environment. It is safe for
// concurrent use and is called at runtime when the owning job spec (consensus OCR2
// plugin) is applied.
func (h *eventHandler) SetTenantID(tenantID uint64) {
h.tenantID.Store(tenantID)
}

// WithDebugMode enables OTel tracing when debugMode is true.
// When disabled (default), a noop tracer is used for zero overhead.
// The debugMode is also propagated to workflow engines created by this handler.
Expand Down Expand Up @@ -640,6 +662,24 @@ func (h *eventHandler) createWorkflowSpec(ctx context.Context, payload WorkflowR
}
ctx = contexts.WithCRE(ctx, contexts.CRE{Org: orgID, Owner: owner, Workflow: wfID})

// Defense-in-depth against centralized workflow source owner spoofing:
// on-chain sources enforce ownership via msg.sender, but centralized (off-chain)
// sources accept an owner claim without any owner-signed proof. For centralized
// sources, verify that the claimed owner is the address deterministically derived
// from the organization ID that the Linking Service maps it to. A mismatch means
// the registry is asserting an owner it cannot prove ownership of (e.g. an on-chain
// EOA), so we log a critical error and reject the workflow (fail-closed).
if isCentralizedWorkflowSource(payload.Source) {
if gateErr := h.engineLimiters.CentralizedWorkflowOwnerVerificationEnabled.AllowErr(ctx); gateErr == nil {
if verr := h.verifyCentralizedOwnerOrgMapping(payload.Source, owner, orgID); verr != nil {
return nil, verr
}
} else if !errors.Is(gateErr, limits.ErrorNotAllowed{}) {
h.lggr.Warnw("failed to evaluate limit CentralizedWorkflowOwnerVerificationEnabled", "error", gateErr)
return nil, gateErr
}
}

// With Workflow Registry contract v2 the BinaryURL and ConfigURL are expected to be identifiers that put through the Storage Service.
decodedBinary, config, err := h.workflowArtifactsStore.FetchWorkflowArtifacts(ctx, wfID, payload.BinaryURL, payload.ConfigURL)
if err != nil {
Expand Down Expand Up @@ -670,6 +710,87 @@ func (h *eventHandler) createWorkflowSpec(ctx context.Context, payload WorkflowR
return entry, nil
}

// defaultTenantID is the fallback tenant numeric id for the CRE environment when none
// is configured on the job spec. It matches defaultJWTAuthJobSpecTenantID in
// core/capabilities/vault and cre-platform-graphql's account service.
const defaultTenantID uint64 = 1

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.

I think we should be very careful when using defaults - if the tenant ID ever disappears from the job spec by mistake, DONs will start rejecting requests. I would rather enforce it in the job spec if that's a possibility.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We're using the same pattern in other vault checks too, to default to a tenantID of 1 if not provided.
My thinking is if you fail to set it, things will break, and you then must set it correctly in the right job-specs.

I could also make this an explicit failure if tenantID wasn't set. But that can only be done after we first fix all job-specs and deploy them across all environments. Otherwise this change will start breaking rollouts immediately.

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.

But if things break, isn't it harder for you to fix an invalid tenant ID because you have to go through the process of job spec rollout for the entire DON and the resolution time depends on how quickly a NOP can accept the updated job spec?

I understand that you can't get rid of defaults immediately due to what you have described above, but just asking to consider the risks one more time (I'm unfamiliar with this codebase, so I can't tell you if this is a bad or a good idea, but I'll defer to @cedric-cordenier to weigh in).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My idea was to set the right tenantID on all environments job-specs, and push them out.
Once set correctly, there's no reason to believe somebody will incorrectly reset them.
Here was the PR for it: https://github.com/smartcontractkit/chainlink-deployments/pull/16126


// ErrCentralizedOwnerOrgMismatch is returned when a workflow owner claimed by a
// centralized (off-chain) source does not match the owner deterministically derived
// from its Linking Service organization ID. The workflow is rejected: admitting it
// would let a centralized registry impersonate an owner (e.g. an on-chain EOA) and
// exfiltrate that owner's Vault secrets.
var ErrCentralizedOwnerOrgMismatch = errors.New("centralized workflow owner does not match owner derived from its organization ID")

// tenantIDOrDefault returns the tenant id for the CRE environment, falling back to
// defaultTenantID when the job spec does not configure one.
func (h *eventHandler) tenantIDOrDefault() uint64 {
if v := h.tenantID.Load(); v != 0 {
return v
}
return defaultTenantID
}

// isCentralizedWorkflowSource reports whether workflow metadata originated from a
// centralized / off-chain source (the gRPC workflow registry or a local file source),
// as opposed to the on-chain workflow registry contract (source prefix "contract:").
// On-chain ownership is enforced by msg.sender; off-chain sources accept an owner
// claim without on-chain proof and therefore require independent owner<->orgID
// verification before the workflow is admitted.
func isCentralizedWorkflowSource(source string) bool {
return strings.HasPrefix(source, "grpc:") || strings.HasPrefix(source, "file:")

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.

Where does this source string come from?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Here:

Source string // source that provided this workflow metadata

And that field is set here:

I will also ask @cedric-cordenier to confirm again, that this is the right authoritative place to read the source from, and isn't spoofable.

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.

Okay, I'm asking because I'm pretty sure that the workflow registry does not provide this field, so the workflow metadata must have been augmented during processing on the node side.

}

// verifyCentralizedOwnerOrgMapping checks that a workflow owner claimed by a
// centralized source is consistent with the organization the Linking Service maps it
// to. Legitimate centralized CRE workflow owners are deterministically derived from
// (tenantID, orgID) via workflows.GenerateWorkflowOwnerAddress. This mirrors the
// derivation in core/capabilities/vault/workflow_owner_derivation.go
// (DeriveJWTAuthorizedVaultWorkflowOwner) and cre-platform-graphql
// account_service.GetCreOrganizationInfo, which is also the source of truth the Vault
// DON authorizes secret access against. A mismatch means the centralized registry is
// asserting an owner it cannot prove ownership of (for example an on-chain EOA),
// which indicates data corruption or a malicious registry attempting to hijack
// another owner's identity and secrets.
//
// On a mismatch it logs a critical error and returns ErrCentralizedOwnerOrgMismatch
// so the caller rejects the workflow (fail-closed). If the orgID cannot be resolved
// or the derivation fails, verification is skipped (returns nil) rather than blocking
// legitimate workflows on a transient Linking Service outage.
func (h *eventHandler) verifyCentralizedOwnerOrgMapping(source, ownerHex, orgID string) error {

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.

Did you consider putting this logic in the GRPC contract source instead? Feels like that is the more natural place for it -- we should check that we can re-derive the owner from the org ID + tenant ID there

if orgID == "" {
// Without an orgID we cannot derive the expected owner; the resolution failure
// was already logged by fetchOrganizationID.
h.lggr.Warnw("skipping centralized workflow owner/orgID verification: no organization ID resolved",

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.

Are we sure we don't want to treat this as an error instead? What happens when you can't resolve an org, do you usually just proceed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, in my understanding too, if we cannot fetch orgID, we should likely fail even running any workflow, not just this specific check.
Because we have other sub-components depending on the orgID to be set correctly.
But not sure why the code currently is set to only warn on missing orgID.

I didn't explicitly force that for this check, because it means all workflows will start failing without orgID, which currently isn't so.

@cedric-cordenier do you know if there's a harm in enforcing strict orgID presence?

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.

Atm we fail open, so I wouldn't start enforcing this as part of this PR
I think we /should/ aim to enforce it long-term though, but in my mind this would be part of the "reliable org resolver" work we talked about the other day (with the static mapping)

"source", source, "workflowOwner", ownerHex)
return nil
}

tenantID := h.tenantIDOrDefault()
derived, err := pkgworkflows.GenerateWorkflowOwnerAddress(strconv.FormatUint(tenantID, 10), orgID)
if err != nil {
h.lggr.Errorw("failed to derive expected workflow owner for centralized source",
"source", source, "workflowOwner", ownerHex, "organizationID", orgID, "error", err)
return nil
}
derivedHex := hex.EncodeToString(derived)

claimedOwner := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(ownerHex), "0x"))
if strings.ToLower(derivedHex) != claimedOwner {
logger.Sugared(h.lggr).Criticalw(
"centralized workflow owner does not match owner derived from its organization ID: possible data corruption or malicious workflow registry",
"source", source,
"claimedWorkflowOwner", ownerHex,
"organizationID", orgID,
"derivedWorkflowOwner", derivedHex,
"tenantID", tenantID,
)
return fmt.Errorf("%w: source=%s claimedOwner=%s organizationID=%s derivedOwner=%s",
ErrCentralizedOwnerOrgMismatch, source, ownerHex, orgID, derivedHex)
}
return nil
}

// fetchOrganizationID fetches the organization ID for the given workflow owner using the OrgResolver
func (h *eventHandler) fetchOrganizationID(ctx context.Context, workflowOwner string) (string, error) {
if h.orgResolver == nil {
Expand Down
Loading
Loading