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
79 changes: 68 additions & 11 deletions core/capabilities/vault/capability.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,33 +169,41 @@ func (s *Capability) Execute(ctx context.Context, request capabilities.Capabilit

func (s *Capability) CreateSecrets(ctx context.Context, request *vaultcommon.CreateSecretsRequest) (*vaulttypes.Response, error) {
s.lggr.Debugf("Received Request: %s", request.String())
err := s.ValidateCreateSecretsRequest(ctx, s.publicKey.Get(), request)
if err != nil {
s.lggr.Debugf("RequestId: [%s] failed validation checks: %s", request.RequestId, err.Error())
return nil, err
}
resolvedIdentity, err := s.resolveRequestIdentity(ctx, request.OrgId, request.WorkflowOwner)
if err != nil {
return nil, err
}
request.OrgId = resolvedIdentity.OrgID
request.WorkflowOwner = resolvedIdentity.WorkflowOwner
if ownerErr := validateEncryptedSecretOwnersMatchResolvedIdentity(request.EncryptedSecrets, resolvedIdentity); ownerErr != nil {
s.lggr.Debugf("RequestId: [%s] failed identity owner checks: %s", request.RequestId, ownerErr.Error())
return nil, ownerErr
}
err = s.ValidateCreateSecretsRequest(ctx, s.publicKey.Get(), request, false)
if err != nil {
s.lggr.Debugf("RequestId: [%s] failed validation checks: %s", request.RequestId, err.Error())
return nil, err
}
return s.handleRequest(ctx, request.RequestId, request)
}

func (s *Capability) UpdateSecrets(ctx context.Context, request *vaultcommon.UpdateSecretsRequest) (*vaulttypes.Response, error) {
s.lggr.Debugf("Received Request: %s", request.String())
err := s.ValidateUpdateSecretsRequest(ctx, s.publicKey.Get(), request)
if err != nil {
s.lggr.Debugf("RequestId: [%s] failed validation checks: %s", request.RequestId, err.Error())
return nil, err
}
resolvedIdentity, err := s.resolveRequestIdentity(ctx, request.OrgId, request.WorkflowOwner)
if err != nil {
return nil, err
}
request.OrgId = resolvedIdentity.OrgID
request.WorkflowOwner = resolvedIdentity.WorkflowOwner
if ownerErr := validateEncryptedSecretOwnersMatchResolvedIdentity(request.EncryptedSecrets, resolvedIdentity); ownerErr != nil {
s.lggr.Debugf("RequestId: [%s] failed identity owner checks: %s", request.RequestId, ownerErr.Error())
return nil, ownerErr
}
err = s.ValidateUpdateSecretsRequest(ctx, s.publicKey.Get(), request, false)
if err != nil {
s.lggr.Debugf("RequestId: [%s] failed validation checks: %s", request.RequestId, err.Error())
return nil, err
}
return s.handleRequest(ctx, request.RequestId, request)
}

Expand All @@ -212,6 +220,10 @@ func (s *Capability) DeleteSecrets(ctx context.Context, request *vaultcommon.Del
}
request.OrgId = resolvedIdentity.OrgID
request.WorkflowOwner = resolvedIdentity.WorkflowOwner
if err := validateSecretIdentifierOwnersMatchResolvedIdentity(request.Ids, resolvedIdentity); err != nil {
s.lggr.Debugf("Request: [%s] failed identity owner checks: %s", request.String(), err.Error())
return nil, err
}
return s.handleRequest(ctx, request.RequestId, request)
}

Expand Down Expand Up @@ -239,6 +251,10 @@ func (s *Capability) ListSecretIdentifiers(ctx context.Context, request *vaultco
}
request.OrgId = resolvedIdentity.OrgID
request.WorkflowOwner = resolvedIdentity.WorkflowOwner
if err := validateOwnerMatchesResolvedIdentity("owner", request.Owner, resolvedIdentity); err != nil {
s.lggr.Debugf("Request: [%s] failed identity owner checks: %s", request.String(), err.Error())
return nil, err
}
return s.handleRequest(ctx, request.RequestId, request)
}

Expand Down Expand Up @@ -267,6 +283,47 @@ func normalizeOwner(owner string) string {
return strings.ToLower(strings.TrimPrefix(owner, "0x"))
}

func validateEncryptedSecretOwnersMatchResolvedIdentity(encryptedSecrets []*vaultcommon.EncryptedSecret, resolvedIdentity LinkedVaultRequestIdentity) error {
for idx, encryptedSecret := range encryptedSecrets {
if encryptedSecret == nil || encryptedSecret.Id == nil {
continue
}
if err := validateOwnerMatchesResolvedIdentity(fmt.Sprintf("encrypted secret owner at index %d", idx), encryptedSecret.Id.Owner, resolvedIdentity); err != nil {
return err
}
}

return nil
}

func validateSecretIdentifierOwnersMatchResolvedIdentity(ids []*vaultcommon.SecretIdentifier, resolvedIdentity LinkedVaultRequestIdentity) error {
for idx, id := range ids {
if id == nil {
continue
}
if err := validateOwnerMatchesResolvedIdentity(fmt.Sprintf("secret identifier owner at index %d", idx), id.Owner, resolvedIdentity); err != nil {
return err
}
}

return nil
}

func validateOwnerMatchesResolvedIdentity(field string, owner string, resolvedIdentity LinkedVaultRequestIdentity) error {
if resolvedIdentity.WorkflowOwner == "" && resolvedIdentity.OrgID == "" {
return nil
}

if resolvedIdentity.WorkflowOwner != "" && normalizeOwner(owner) == normalizeOwner(resolvedIdentity.WorkflowOwner) {
return nil
}
if resolvedIdentity.OrgID != "" && owner == resolvedIdentity.OrgID {
return nil
}

return fmt.Errorf("%s %q must match resolved workflow owner %q or org_id %q", field, owner, resolvedIdentity.WorkflowOwner, resolvedIdentity.OrgID)
}

func (s *Capability) handleRequest(ctx context.Context, requestID string, request proto.Message) (*vaulttypes.Response, error) {
respCh := make(chan *vaulttypes.Response, 1)
s.handler.SendRequest(ctx, &vaulttypes.Request{
Expand Down Expand Up @@ -314,7 +371,7 @@ func NewCapability(
orgResolver orgresolver.OrgResolver,
limitsFactory limits.Factory,
) (*Capability, error) {
limiter, err := limits.MakeBoundLimiter(limitsFactory, cresettings.Default.VaultRequestBatchSizeLimit)
limiter, err := limits.MakeUpperBoundLimiter(limitsFactory, cresettings.Default.VaultRequestBatchSizeLimit)
if err != nil {
return nil, fmt.Errorf("could not create request batch size limiter: %w", err)
}
Expand Down
26 changes: 0 additions & 26 deletions core/capabilities/vault/gw_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,6 @@ func (h *GatewayHandler) authorizeAndPrefixRequest(ctx context.Context, req *jso
return nil, authErr
}
authorizedOwner := authResult.AuthorizedOwner()
if incomingOwner != "" && normalizeOwner(incomingOwner) != normalizeOwner(authorizedOwner) {
prefixErr := fmt.Errorf("request owner prefix %q does not match authorized owner %q", incomingOwner, authorizedOwner)
h.lggr.Errorw("gateway request owner prefix mismatch", "method", req.Method, "requestID", originalRequestID, "incomingOwner", incomingOwner, "authorizedOwner", authorizedOwner, "error", prefixErr)
return nil, prefixErr
}

req.ID = authorizedOwner + vaulttypes.RequestIDSeparator + originalRequestID
h.lggr.Debugw("authorized gateway request", "method", req.Method, "requestID", req.ID, "owner", authorizedOwner, "orgID", authResult.OrgID(), "workflowOwner", authResult.WorkflowOwner())
Expand Down Expand Up @@ -334,17 +329,10 @@ func (h *GatewayHandler) handleSecretsCreate(ctx context.Context, gatewayID stri
return h.errorResponse(ctx, gatewayID, req, api.UserMessageParseError, err)
}

authorizedOwner := authResult.AuthorizedOwner()
vaultCapRequest.RequestId = req.ID
if err := setAuthorizedIdentityFields(&vaultCapRequest, authResult); err != nil {
return h.errorResponse(ctx, gatewayID, req, api.FatalError, err)
}
for idx, encryptedSecret := range vaultCapRequest.EncryptedSecrets {
if encryptedSecret != nil && encryptedSecret.Id != nil && normalizeOwner(encryptedSecret.Id.Owner) != normalizeOwner(authorizedOwner) {
h.lggr.Debugw("create secrets request owner mismatch", "requestID", req.ID, "secretOwner", encryptedSecret.Id.Owner, "authorizedOwner", authorizedOwner, "index", idx)
return h.errorResponse(ctx, gatewayID, req, api.FatalError, fmt.Errorf("secret ID owner %q does not match authorized owner %q at index %d", encryptedSecret.Id.Owner, authorizedOwner, idx))
}
}

h.lggr.Debugf("Processing authorized and normalized create secrets request [%s]", vaultCapRequest.String())
vaultCapResponse, err := h.secretsService.CreateSecrets(ctx, &vaultCapRequest)
Expand All @@ -364,17 +352,10 @@ func (h *GatewayHandler) handleSecretsUpdate(ctx context.Context, gatewayID stri
if err := json.Unmarshal(*req.Params, &vaultCapRequest); err != nil {
return h.errorResponse(ctx, gatewayID, req, api.UserMessageParseError, err)
}
authorizedOwner := authResult.AuthorizedOwner()
vaultCapRequest.RequestId = req.ID
if err := setAuthorizedIdentityFields(&vaultCapRequest, authResult); err != nil {
return h.errorResponse(ctx, gatewayID, req, api.FatalError, err)
}
for idx, encryptedSecret := range vaultCapRequest.EncryptedSecrets {
if encryptedSecret != nil && encryptedSecret.Id != nil && normalizeOwner(encryptedSecret.Id.Owner) != normalizeOwner(authorizedOwner) {
h.lggr.Debugw("update secrets request owner mismatch", "requestID", req.ID, "secretOwner", encryptedSecret.Id.Owner, "authorizedOwner", authorizedOwner, "index", idx)
return h.errorResponse(ctx, gatewayID, req, api.FatalError, fmt.Errorf("secret ID owner %q does not match authorized owner %q at index %d", encryptedSecret.Id.Owner, authorizedOwner, idx))
}
}

h.lggr.Debugf("Processing authorized and normalized update secrets request [%s]", vaultCapRequest.String())
vaultCapResponse, err := h.secretsService.UpdateSecrets(ctx, &vaultCapRequest)
Expand All @@ -394,17 +375,10 @@ func (h *GatewayHandler) handleSecretsDelete(ctx context.Context, gatewayID stri
if err := json.Unmarshal(*req.Params, r); err != nil {
return h.errorResponse(ctx, gatewayID, req, api.UserMessageParseError, err)
}
authorizedOwner := authResult.AuthorizedOwner()
r.RequestId = req.ID
if err := setAuthorizedIdentityFields(r, authResult); err != nil {
return h.errorResponse(ctx, gatewayID, req, api.FatalError, err)
}
for idx, secretID := range r.Ids {
if secretID != nil && normalizeOwner(secretID.Owner) != normalizeOwner(authorizedOwner) {
h.lggr.Debugw("delete secrets request owner mismatch", "requestID", req.ID, "secretOwner", secretID.Owner, "authorizedOwner", authorizedOwner, "index", idx)
return h.errorResponse(ctx, gatewayID, req, api.FatalError, fmt.Errorf("secret ID owner %q does not match authorized owner %q at index %d", secretID.Owner, authorizedOwner, idx))
}
}

h.lggr.Debugf("Processing authorized and normalized delete secrets request [%s]", r.String())
resp, err := h.secretsService.DeleteSecrets(ctx, r)
Expand Down
18 changes: 13 additions & 5 deletions core/capabilities/vault/gw_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) {
expectedError: false,
},
{
name: "success - strips owner prefix from forwarded request before authorization",
name: "success - replaces owner prefix from forwarded request after authorization",
setupMocks: func(ss *vaulttypesmocks.SecretsService, gc *connector_mocks.GatewayConnector, ra *vaultcapmocks.Authorizer) {
ra.EXPECT().AuthorizeRequest(mock.Anything, mock.MatchedBy(func(req jsonrpc.Request[json.RawMessage]) bool {
if req.Method != vaulttypes.MethodSecretsCreate || req.ID != "1" || req.Params == nil {
Expand Down Expand Up @@ -448,10 +448,10 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) {
},
request: &jsonrpc.Request[json.RawMessage]{
Method: vaulttypes.MethodSecretsCreate,
ID: "0xAbC" + vaulttypes.RequestIDSeparator + "1",
ID: "0xDef" + vaulttypes.RequestIDSeparator + "1",
Params: func() *json.RawMessage {
params, _ := json.Marshal(vaultcommon.CreateSecretsRequest{
RequestId: "0xAbC" + vaulttypes.RequestIDSeparator + "1",
RequestId: "0xDef" + vaulttypes.RequestIDSeparator + "1",
EncryptedSecrets: []*vaultcommon.EncryptedSecret{
{
Id: &vaultcommon.SecretIdentifier{
Expand All @@ -469,13 +469,21 @@ func TestGatewayHandler_HandleGatewayMessage(t *testing.T) {
expectedError: false,
},
{
name: "failure - owner mismatch against authorized owner",
name: "failure - capability rejects owner mismatch",
setupMocks: func(ss *vaulttypesmocks.SecretsService, gc *connector_mocks.GatewayConnector, ra *vaultcapmocks.Authorizer) {
ra.EXPECT().AuthorizeRequest(mock.Anything, mock.Anything).Return(authResult("", "0xdef"), nil)
ss.EXPECT().CreateSecrets(mock.Anything, mock.MatchedBy(func(req *vaultcommon.CreateSecretsRequest) bool {
return len(req.EncryptedSecrets) == 1 &&
req.EncryptedSecrets[0].Id.Key == "test-secret" &&
req.EncryptedSecrets[0].Id.Owner == "0xabc" &&
req.RequestId == "0xdef"+vaulttypes.RequestIDSeparator+"1" &&
req.OrgId == "" &&
req.WorkflowOwner == "0xdef"
})).Return(nil, errors.New("capability owner validation failed"))
gc.On("SendToGateway", mock.Anything, "gateway-1", mock.MatchedBy(func(resp *jsonrpc.Response[json.RawMessage]) bool {
return resp.Error != nil &&
resp.Error.Code == api.ToJSONRPCErrorCode(api.FatalError) &&
resp.Error.Message == `secret ID owner "0xabc" does not match authorized owner "0xdef" at index 0`
resp.Error.Message == "capability owner validation failed"
})).Return(nil)
},
request: &jsonrpc.Request[json.RawMessage]{
Expand Down
91 changes: 85 additions & 6 deletions core/capabilities/vault/jwt_based_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import (

"github.com/golang-jwt/jwt/v5"

vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault"
jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2"
"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/services"
"github.com/smartcontractkit/chainlink-common/pkg/settings/cresettings"
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
"github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes"
)

var (
Expand Down Expand Up @@ -79,7 +81,7 @@ type jsonWebKeySet struct {

// JWTBasedAuth verifies Auth0-issued RS256 JWTs using the provider's
// public JWKS endpoint and extracts Vault-specific claims (org_id,
// workflow_owner, request_digest). It is safe for concurrent use.
// optional workflow_owner, request_digest). It is safe for concurrent use.
//
// JWKS keys are fetched lazily on the first token validation and refreshed
// on key-ID misses, rate-limited to at most once per JWKSRefreshInterval.
Expand Down Expand Up @@ -220,6 +222,14 @@ func (v *jwtBasedAuth) AuthorizeRequest(ctx context.Context, req jsonrpc.Request
return nil, fmt.Errorf("request digest mismatch: computed=%s claimed=%s", requestDigest, claims.RequestDigest)
}

if claims.WorkflowOwner == "" {
if err := validateOrgIDOwnedVaultRequest(req, claims.OrgID); err != nil {
wrappedErr := fmt.Errorf("%w: %w", ErrMissingWorkflowOwner, err)
v.lggr.Debugw("JWTBasedAuth missing workflow owner rejected non-org-owned request", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "error", wrappedErr)
return nil, fmt.Errorf("invalid JWT auth token: %w", wrappedErr)
}
}

v.lggr.Debugw("JWTBasedAuth authorization succeeded", "method", req.Method, "requestID", req.ID, "orgID", claims.OrgID, "workflowOwner", claims.WorkflowOwner, "digest", requestDigest, "expiresAt", claims.ExpiresAt.UTC().Unix())
return &AuthResult{
orgID: claims.OrgID,
Expand All @@ -231,7 +241,7 @@ func (v *jwtBasedAuth) AuthorizeRequest(ctx context.Context, req jsonrpc.Request

// validateToken verifies the JWT signature via Auth0 JWKS, validates
// standard claims (iss, aud, exp), and extracts Vault-specific claims
// (org_id, workflow_owner, request_digest).
// (org_id, optional workflow_owner, request_digest).
func (v *jwtBasedAuth) validateToken(ctx context.Context, tokenString string) (*JWTClaims, error) {
if tokenString == "" {
return nil, ErrMissingToken
Expand Down Expand Up @@ -262,9 +272,10 @@ func (v *jwtBasedAuth) validateToken(ctx context.Context, tokenString string) (*
jwt.WithAudience(v.audience),
jwt.WithExpirationRequired(),
jwt.WithIssuedAt(),
jwt.WithLeeway(time.Minute),
)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidToken, err)
return nil, fmt.Errorf("%w: %w. Expected Issuer: %s, Actual Issuer: %s", ErrInvalidToken, err, v.issuerURL, unverified.Claims.(jwt.MapClaims)["iss"])
}

claims, ok := token.Claims.(jwt.MapClaims)
Expand Down Expand Up @@ -332,13 +343,81 @@ func extractAuthorizationDetails(claims jwt.MapClaims) (workflowOwner, requestDi
if requestDigest == "" {
return "", "", ErrMissingRequestDigest
}
if workflowOwner == "" {
return "", "", ErrMissingWorkflowOwner
}

return workflowOwner, requestDigest, nil
}

func validateOrgIDOwnedVaultRequest(req jsonrpc.Request[json.RawMessage], orgID string) error {
if orgID == "" {
return ErrMissingOrgID
}
if req.Params == nil {
return errors.New("request params are required")
}

switch req.Method {
case vaulttypes.MethodSecretsCreate:
parsed := &vaultcommon.CreateSecretsRequest{}
if err := json.Unmarshal(*req.Params, parsed); err != nil {
return fmt.Errorf("failed to parse create secrets request: %w", err)
}
return validateEncryptedSecretOwnersMatchOrgID(parsed.EncryptedSecrets, orgID)
case vaulttypes.MethodSecretsUpdate:
parsed := &vaultcommon.UpdateSecretsRequest{}
if err := json.Unmarshal(*req.Params, parsed); err != nil {
return fmt.Errorf("failed to parse update secrets request: %w", err)
}
return validateEncryptedSecretOwnersMatchOrgID(parsed.EncryptedSecrets, orgID)
case vaulttypes.MethodSecretsDelete:
parsed := &vaultcommon.DeleteSecretsRequest{}
if err := json.Unmarshal(*req.Params, parsed); err != nil {
return fmt.Errorf("failed to parse delete secrets request: %w", err)
}
return validateSecretIdentifierOwnersMatchOrgID(parsed.Ids, orgID)
case vaulttypes.MethodSecretsList:
parsed := &vaultcommon.ListSecretIdentifiersRequest{}
if err := json.Unmarshal(*req.Params, parsed); err != nil {
return fmt.Errorf("failed to parse list secrets request: %w", err)
}
if parsed.Owner != orgID {
return fmt.Errorf("list secrets owner %q does not match org_id %q", parsed.Owner, orgID)
}
return nil
default:
return fmt.Errorf("method %q does not carry org-owned secret identifiers", req.Method)
}
}

func validateEncryptedSecretOwnersMatchOrgID(encryptedSecrets []*vaultcommon.EncryptedSecret, orgID string) error {
if len(encryptedSecrets) == 0 {
return errors.New("encrypted secrets must contain at least one identifier")
}
for idx, encryptedSecret := range encryptedSecrets {
if encryptedSecret == nil || encryptedSecret.Id == nil {
return fmt.Errorf("encrypted secret at index %d must include an identifier", idx)
}
if encryptedSecret.Id.Owner != orgID {
return fmt.Errorf("encrypted secret owner at index %d %q does not match org_id %q", idx, encryptedSecret.Id.Owner, orgID)
}
}
return nil
}

func validateSecretIdentifierOwnersMatchOrgID(ids []*vaultcommon.SecretIdentifier, orgID string) error {
if len(ids) == 0 {
return errors.New("secret identifiers must not be empty")
}
for idx, id := range ids {
if id == nil {
return fmt.Errorf("secret identifier at index %d must not be nil", idx)
}
if id.Owner != orgID {
return fmt.Errorf("secret identifier owner at index %d %q does not match org_id %q", idx, id.Owner, orgID)
}
}
return nil
}

// resolveSigningKey looks up the RSA public key for the given kid from the
// JWKS cache, refreshing the cache if necessary.
func (v *jwtBasedAuth) resolveSigningKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
Expand Down
Loading
Loading