diff --git a/.github/workflows/cre-system-tests.yaml b/.github/workflows/cre-system-tests.yaml index 61d537c07b5..ff69fbf189c 100644 --- a/.github/workflows/cre-system-tests.yaml +++ b/.github/workflows/cre-system-tests.yaml @@ -96,7 +96,8 @@ jobs: PER_TEST_TOPOLOGIES_JSON=${PER_TEST_TOPOLOGIES_JSON:-'{ "Test_CRE_V2_Suite_Bucket_B": [ {"topology":"workflow-gateway-capabilities","configs":"configs/workflow-gateway-capabilities-don.toml"}, - {"topology":"workflow-gateway-capabilities-vault-jwt_auth-enabled","configs":"configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml"} + {"topology":"workflow-gateway-capabilities-vault-jwt_auth-enabled","configs":"configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml"}, + {"topology":"workflow-gateway-capabilities-vault-base64-enabled","configs":"configs/workflow-gateway-capabilities-don-vault-base64-enabled.toml"} ], "Test_CRE_V2_Aptos_Suite": [ {"topology":"workflow-gateway-aptos","configs":"configs/workflow-gateway-don-aptos.toml"} diff --git a/core/capabilities/vault/allow_list_based_auth.go b/core/capabilities/vault/allow_list_based_auth.go index 93ae5af79e9..9c2bfa769ff 100644 --- a/core/capabilities/vault/allow_list_based_auth.go +++ b/core/capabilities/vault/allow_list_based_auth.go @@ -2,15 +2,19 @@ package vault import ( "context" + "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "time" + 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/settings/limits" "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/workflow_registry_wrapper_v2" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" workflowsyncerv2 "github.com/smartcontractkit/chainlink/v2/core/services/workflows/syncer/v2" ) @@ -27,13 +31,26 @@ type allowListBasedAuth struct { lggr logger.Logger retryCount int retryInterval time.Duration + // vaultBase64EncodingEnabled, when non-nil and AllowErr(ctx)==nil, indicates the + // gateway forwards ciphertext as base64. Allowlist digests are always registered + // against the client (hex) wire form, so we normalize before Digest(). + vaultBase64EncodingEnabled limits.GateLimiter } // AuthorizeRequest authorizes a request using AllowListBasedAuth. // It does NOT check if the request method is allowed. func (r *allowListBasedAuth) AuthorizeRequest(ctx context.Context, req jsonrpc.Request[json.RawMessage]) (*AuthResult, error) { r.lggr.Debugw("AllowListBasedAuth authorizing request", "method", req.Method, "requestID", req.ID) - requestDigest, err := req.Digest() + reqForDigest := req + if r.vaultBase64EncodingEnabled != nil && r.vaultBase64EncodingEnabled.AllowErr(ctx) == nil { + normalized, normErr := vaultRequestEncryptedSecretsBase64ToHexForAllowlistDigest(req) + if normErr != nil { + r.lggr.Debugw("AllowListBasedAuth failed to normalize ciphertext for digest", "method", req.Method, "requestID", req.ID, "error", normErr) + return nil, normErr + } + reqForDigest = normalized + } + requestDigest, err := reqForDigest.Digest() if err != nil { r.lggr.Debugw("AllowListBasedAuth failed to create digest", "method", req.Method, "requestID", req.ID, "error", err) return nil, err @@ -120,15 +137,74 @@ func (r *allowListBasedAuth) fetchAllowlistedItem(allowListedRequests []workflow } // NewAllowListBasedAuth creates the allowlist-backed Vault auth mechanism. -func NewAllowListBasedAuth(lggr logger.Logger, workflowRegistrySyncer workflowsyncerv2.WorkflowRegistrySyncer) *allowListBasedAuth { +// vaultBase64EncodingEnabled may be nil; when set and limits allow, ciphertext in +// create/update params is decoded from base64 to hex before computing the allowlist digest. +func NewAllowListBasedAuth(lggr logger.Logger, workflowRegistrySyncer workflowsyncerv2.WorkflowRegistrySyncer, vaultBase64EncodingEnabled limits.GateLimiter) *allowListBasedAuth { return &allowListBasedAuth{ - workflowRegistrySyncer: workflowRegistrySyncer, - lggr: logger.Named(lggr, "VaultAllowListBasedAuth"), - retryCount: allowListBasedAuthRetryCount, - retryInterval: allowListBasedAuthRetryInterval, + workflowRegistrySyncer: workflowRegistrySyncer, + lggr: logger.Named(lggr, "VaultAllowListBasedAuth"), + retryCount: allowListBasedAuthRetryCount, + retryInterval: allowListBasedAuthRetryInterval, + vaultBase64EncodingEnabled: vaultBase64EncodingEnabled, + } +} + +func vaultRequestEncryptedSecretsBase64ToHexForAllowlistDigest(req jsonrpc.Request[json.RawMessage]) (jsonrpc.Request[json.RawMessage], error) { + if req.Params == nil { + return req, nil + } + switch req.Method { + case vaulttypes.MethodSecretsCreate: + parsed := &vaultcommon.CreateSecretsRequest{} + if err := json.Unmarshal(*req.Params, parsed); err != nil { + return jsonrpc.Request[json.RawMessage]{}, err + } + if err := convertEncryptedSecretsBase64WireToHexStrings(parsed.EncryptedSecrets); err != nil { + return jsonrpc.Request[json.RawMessage]{}, err + } + b, err := json.Marshal(parsed) + if err != nil { + return jsonrpc.Request[json.RawMessage]{}, err + } + raw := json.RawMessage(b) + out := req + out.Params = &raw + return out, nil + case vaulttypes.MethodSecretsUpdate: + parsed := &vaultcommon.UpdateSecretsRequest{} + if err := json.Unmarshal(*req.Params, parsed); err != nil { + return jsonrpc.Request[json.RawMessage]{}, err + } + if err := convertEncryptedSecretsBase64WireToHexStrings(parsed.EncryptedSecrets); err != nil { + return jsonrpc.Request[json.RawMessage]{}, err + } + b, err := json.Marshal(parsed) + if err != nil { + return jsonrpc.Request[json.RawMessage]{}, err + } + raw := json.RawMessage(b) + out := req + out.Params = &raw + return out, nil + default: + return req, nil } } +func convertEncryptedSecretsBase64WireToHexStrings(secrets []*vaultcommon.EncryptedSecret) error { + for i, item := range secrets { + if item == nil || item.EncryptedValue == "" { + continue + } + raw, err := base64.StdEncoding.DecodeString(item.EncryptedValue) + if err != nil { + return fmt.Errorf("encrypted secret index %d: %w", i, err) + } + item.EncryptedValue = hex.EncodeToString(raw) + } + return nil +} + func sleepWithContext(ctx context.Context, d time.Duration) error { timer := time.NewTimer(d) defer timer.Stop() diff --git a/core/capabilities/vault/allow_list_based_auth_test.go b/core/capabilities/vault/allow_list_based_auth_test.go index 6bb684063b4..550692d9331 100644 --- a/core/capabilities/vault/allow_list_based_auth_test.go +++ b/core/capabilities/vault/allow_list_based_auth_test.go @@ -2,6 +2,7 @@ package vault import ( "context" + "encoding/base64" "encoding/hex" "encoding/json" "testing" @@ -13,6 +14,7 @@ import ( vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" + "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-evm/gethwrappers/workflow/generated/workflow_registry_wrapper_v2" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" "github.com/smartcontractkit/chainlink/v2/core/logger" @@ -159,7 +161,7 @@ func testAuthForRequests(t *testing.T, allowlistedRequest, notAllowlistedRequest owner := common.Address{1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t) - auth := NewAllowListBasedAuth(lggr, mockSyncer) + auth := NewAllowListBasedAuth(lggr, mockSyncer, nil) auth.retryCount = 0 auth.retryInterval = time.Millisecond @@ -226,7 +228,7 @@ func TestAllowListBasedAuth_RetriesUntilRequestIsAllowlisted(t *testing.T) { } mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t) - auth := NewAllowListBasedAuth(lggr, mockSyncer) + auth := NewAllowListBasedAuth(lggr, mockSyncer, nil) auth.retryCount = 2 auth.retryInterval = time.Millisecond @@ -247,7 +249,7 @@ func TestAllowListBasedAuth_FailsAfterAllowlistReadRetries(t *testing.T) { mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t) mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{}).Times(3) - auth := NewAllowListBasedAuth(lggr, mockSyncer) + auth := NewAllowListBasedAuth(lggr, mockSyncer, nil) auth.retryCount = 2 auth.retryInterval = time.Millisecond @@ -266,7 +268,7 @@ func TestAllowListBasedAuth_StopsRetriesWhenContextCanceled(t *testing.T) { mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t) mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{}).Once() - auth := NewAllowListBasedAuth(lggr, mockSyncer) + auth := NewAllowListBasedAuth(lggr, mockSyncer, nil) auth.retryCount = 2 auth.retryInterval = time.Second @@ -275,6 +277,62 @@ func TestAllowListBasedAuth_StopsRetriesWhenContextCanceled(t *testing.T) { require.ErrorIs(t, err, context.Canceled) } +func TestAllowListBasedAuth_Base64WireMatchesHexAllowlistedDigest(t *testing.T) { + lggr := logger.TestLogger(t) + owner := common.Address{9, 8, 7} + + hexParams, err := json.Marshal(vaultcommon.CreateSecretsRequest{ + RequestId: "rid-1", + EncryptedSecrets: []*vaultcommon.EncryptedSecret{{ + Id: &vaultcommon.SecretIdentifier{Key: "k", Namespace: "ns", Owner: "o"}, + EncryptedValue: "41", + }}, + }) + require.NoError(t, err) + hexRaw := json.RawMessage(hexParams) + hexReq := jsonrpc.Request[json.RawMessage]{ + ID: "123", + Method: vaulttypes.MethodSecretsCreate, + Params: &hexRaw, + } + digest, err := hexReq.Digest() + require.NoError(t, err) + digestBytes, err := hex.DecodeString(digest) + require.NoError(t, err) + + b64Val := base64.StdEncoding.EncodeToString([]byte{0x41}) + b64Params, err := json.Marshal(vaultcommon.CreateSecretsRequest{ + RequestId: "rid-1", + EncryptedSecrets: []*vaultcommon.EncryptedSecret{{ + Id: &vaultcommon.SecretIdentifier{Key: "k", Namespace: "ns", Owner: "o"}, + EncryptedValue: b64Val, + }}, + }) + require.NoError(t, err) + b64Raw := json.RawMessage(b64Params) + b64Req := jsonrpc.Request[json.RawMessage]{ + ID: "123", + Method: vaulttypes.MethodSecretsCreate, + Params: &b64Raw, + } + + mockSyncer := syncerv2mocks.NewWorkflowRegistrySyncer(t) + expiry := time.Now().UTC().Unix() + 100 + mockSyncer.On("GetAllowlistedRequests", mock.Anything).Return([]workflow_registry_wrapper_v2.WorkflowRegistryOwnerAllowlistedRequest{{ + RequestDigest: [32]byte(digestBytes), + Owner: owner, + ExpiryTimestamp: uint32(expiry), //nolint:gosec // it is a safe conversion + }}) + + auth := NewAllowListBasedAuth(lggr, mockSyncer, limits.NewGateLimiter(true)) + auth.retryCount = 0 + auth.retryInterval = time.Millisecond + + authResult, err := auth.AuthorizeRequest(t.Context(), b64Req) + require.NoError(t, err) + require.Equal(t, owner.Hex(), authResult.AuthorizedOwner()) +} + func makeListSecretsRequest(t *testing.T, id, namespace string) jsonrpc.Request[json.RawMessage] { t.Helper() diff --git a/core/capabilities/vault/capability.go b/core/capabilities/vault/capability.go index bf78826144f..21f588954a0 100644 --- a/core/capabilities/vault/capability.go +++ b/core/capabilities/vault/capability.go @@ -21,6 +21,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" "github.com/smartcontractkit/chainlink-common/pkg/types/core" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils" ) var _ capabilities.ExecutableCapability = (*Capability)(nil) @@ -33,6 +34,7 @@ type Capability struct { capabilitiesRegistry core.CapabilitiesRegistry publicKey *LazyPublicKey linker *OrgIDToWorkflowOwnerLinker + base64Gate limits.GateLimiter *RequestValidator } @@ -92,9 +94,20 @@ func (s *Capability) Close() error { err = errors.Join(err, fmt.Errorf("error closing org_id linker: %w", lerr)) } + if lerr := s.base64Gate.Close(); lerr != nil { + err = errors.Join(err, fmt.Errorf("error closing base64 encoding gate: %w", lerr)) + } + return err } +func (s *Capability) ciphertextStringEncoding(ctx context.Context) vaultutils.CiphertextStringEncoding { + if s.base64Gate.AllowErr(ctx) == nil { + return vaultutils.CiphertextStringEncodingBase64 + } + return vaultutils.CiphertextStringEncodingHex +} + func (s *Capability) Info(_ context.Context) (capabilities.CapabilityInfo, error) { return capabilities.NewCapabilityInfo(vaultcommon.CapabilityID, capabilities.CapabilityTypeAction, "Vault Capability") } @@ -195,7 +208,7 @@ func (s *Capability) CreateSecrets(ctx context.Context, request *vaultcommon.Cre s.lggr.Debugw("failed identity owner checks", "requestID", request.RequestId, "err", ownerErr) return nil, ownerErr } - err = s.ValidateCreateSecretsRequest(ctx, s.publicKey.Get(), request, false) + err = s.ValidateCreateSecretsRequest(ctx, s.publicKey.Get(), request, false, s.ciphertextStringEncoding(ctx)) if err != nil { s.lggr.Debugw("failed validation checks", "requestID", request.RequestId, "err", err) return nil, err @@ -215,7 +228,7 @@ func (s *Capability) UpdateSecrets(ctx context.Context, request *vaultcommon.Upd s.lggr.Debugw("failed identity owner checks", "requestID", request.RequestId, "err", ownerErr) return nil, ownerErr } - err = s.ValidateUpdateSecretsRequest(ctx, s.publicKey.Get(), request, false) + err = s.ValidateUpdateSecretsRequest(ctx, s.publicKey.Get(), request, false, s.ciphertextStringEncoding(ctx)) if err != nil { s.lggr.Debugw("failed validation checks", "requestID", request.RequestId, "err", err) return nil, err @@ -411,6 +424,10 @@ func NewCapability( if err != nil { return nil, fmt.Errorf("could not create identifier namespace length limiter: %w", err) } + base64Gate, err := limits.MakeGateLimiter(limitsFactory, cresettings.Default.VaultBase64EncodingEnabled) + if err != nil { + return nil, fmt.Errorf("could not create base64 encoding gate: %w", err) + } return &Capability{ lggr: logger.Named(lggr, "VaultCapability"), clock: clock, @@ -419,6 +436,7 @@ func NewCapability( capabilitiesRegistry: capabilitiesRegistry, publicKey: publicKey, linker: linker, + base64Gate: base64Gate, RequestValidator: NewRequestValidator(limiter, ciphertextLimiter, idKeyLengthLimiter, idOwnerLengthLimiter, idNamespaceLengthLimiter), }, nil } diff --git a/core/capabilities/vault/gw_handler.go b/core/capabilities/vault/gw_handler.go index aeebb16e273..76c31a1d09a 100644 --- a/core/capabilities/vault/gw_handler.go +++ b/core/capabilities/vault/gw_handler.go @@ -14,6 +14,7 @@ import ( vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" jsonrpc "github.com/smartcontractkit/chainlink-common/pkg/jsonrpc2" "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-common/pkg/types/core" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" @@ -65,10 +66,11 @@ type GatewayHandler struct { secretsService vaulttypes.SecretsService gatewayConnector gatewayConnector - authorizer Authorizer - jwtAuthService services.Service - lggr logger.Logger - metrics *metrics + authorizer Authorizer + jwtAuthService services.Service + vaultBase64AllowlistGate limits.GateLimiter + lggr logger.Logger + metrics *metrics } // NewGatewayHandler creates a Vault gateway connector handler with internal auth wiring. @@ -97,8 +99,14 @@ func NewGatewayHandler( jwtBasedAuth = jwtAuthService.(Authorizer) } + var vaultBase64AllowlistGate limits.GateLimiter if authorizer == nil { - allowListBasedAuth := NewAllowListBasedAuth(lggr, workflowRegistrySyncer) + var gateErr error + vaultBase64AllowlistGate, gateErr = limits.MakeGateLimiter(limitsFactory, cresettings.Default.VaultBase64EncodingEnabled) + if gateErr != nil { + return nil, fmt.Errorf("failed to create VaultBase64EncodingEnabled gate: %w", gateErr) + } + allowListBasedAuth := NewAllowListBasedAuth(lggr, workflowRegistrySyncer, vaultBase64AllowlistGate) authorizer = NewAuthorizer(allowListBasedAuth, jwtBasedAuth, lggr) } @@ -108,12 +116,13 @@ func NewGatewayHandler( } gh := &GatewayHandler{ - secretsService: secretsService, - gatewayConnector: connector, - authorizer: authorizer, - jwtAuthService: jwtAuthService, - lggr: lggr.Named(HandlerName), - metrics: metrics, + secretsService: secretsService, + gatewayConnector: connector, + authorizer: authorizer, + jwtAuthService: jwtAuthService, + vaultBase64AllowlistGate: vaultBase64AllowlistGate, + lggr: lggr.Named(HandlerName), + metrics: metrics, } gh.Service, gh.eng = services.Config{ Name: "GatewayHandler", @@ -136,14 +145,21 @@ func (h *GatewayHandler) start(ctx context.Context) error { } func (h *GatewayHandler) close() error { - var jwtAuthErr error + var errs []error + if h.vaultBase64AllowlistGate != nil { + if err := h.vaultBase64AllowlistGate.Close(); err != nil { + errs = append(errs, fmt.Errorf("failed to close vault base64 allowlist gate: %w", err)) + } + } if h.jwtAuthService != nil { - jwtAuthErr = h.jwtAuthService.Close() + if err := h.jwtAuthService.Close(); err != nil { + errs = append(errs, err) + } } if gwerr := h.gatewayConnector.RemoveHandler(context.Background(), h.Methods()); gwerr != nil { - return errors.Join(fmt.Errorf("failed to remove vault handler from connector: %w", gwerr), jwtAuthErr) + errs = append(errs, fmt.Errorf("failed to remove vault handler from connector: %w", gwerr)) } - return jwtAuthErr + return errors.Join(errs...) } func (h *GatewayHandler) ID(ctx context.Context) (string, error) { diff --git a/core/capabilities/vault/validator.go b/core/capabilities/vault/validator.go index 9ab042b3005..98f97a7e9e2 100644 --- a/core/capabilities/vault/validator.go +++ b/core/capabilities/vault/validator.go @@ -31,17 +31,17 @@ type RequestValidator struct { MaxIdentifierNamespaceLengthLimiter limits.BoundLimiter[pkgconfig.Size] } -func (r *RequestValidator) ValidateCreateSecretsRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, request *vaultcommon.CreateSecretsRequest, skipLabelValidation bool) error { - return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets, skipLabelValidation) +func (r *RequestValidator) ValidateCreateSecretsRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, request *vaultcommon.CreateSecretsRequest, skipLabelValidation bool, ciphertextEnc vaultutils.CiphertextStringEncoding) error { + return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets, skipLabelValidation, ciphertextEnc) } -func (r *RequestValidator) ValidateUpdateSecretsRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, request *vaultcommon.UpdateSecretsRequest, skipLabelValidation bool) error { - return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets, skipLabelValidation) +func (r *RequestValidator) ValidateUpdateSecretsRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, request *vaultcommon.UpdateSecretsRequest, skipLabelValidation bool, ciphertextEnc vaultutils.CiphertextStringEncoding) error { + return r.validateWriteRequest(ctx, publicKey, request.RequestId, request.OrgId, request.WorkflowOwner, request.EncryptedSecrets, skipLabelValidation, ciphertextEnc) } // validateWriteRequest performs common validation for CreateSecrets and UpdateSecrets requests. // It treats publicKey as optional, since it can be nil if the gateway nodes don't have the public key cached yet. -func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, id string, orgID string, workflowOwner string, encryptedSecrets []*vaultcommon.EncryptedSecret, skipLabelValidation bool) error { +func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey *tdh2easy.PublicKey, id string, orgID string, workflowOwner string, encryptedSecrets []*vaultcommon.EncryptedSecret, skipLabelValidation bool, ciphertextEnc vaultutils.CiphertextStringEncoding) error { if id == "" { return errors.New("request ID must not be empty") } @@ -74,11 +74,11 @@ func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey * if err := r.ValidateSecretIdentifier(ctx, req.Id.Key, req.Id.Owner, req.Id.Namespace); err != nil { return fmt.Errorf("invalid secret identifier at index %d: %w", idx, err) } - if err := r.ValidateCiphertextSize(ctx, req.Id.Owner, req.EncryptedValue); err != nil { + if err := r.ValidateCiphertextSize(ctx, req.Id.Owner, req.EncryptedValue, ciphertextEnc); err != nil { return fmt.Errorf("secret encrypted value at index %d is invalid: %w", idx, err) } if skipLabelValidation { - if _, err := verifyEncryptedSecret(publicKey, req.EncryptedValue); err != nil { + if _, err := verifyEncryptedSecret(publicKey, req.EncryptedValue, ciphertextEnc); err != nil { return errors.New("Encrypted Secret at index [" + strconv.Itoa(idx) + "] is invalid. Error: " + err.Error()) } } else { @@ -86,7 +86,7 @@ func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey * if expectedWorkflowOwner == "" && orgID == "" { expectedWorkflowOwner = req.Id.Owner } - err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, expectedWorkflowOwner, orgID) + err := EnsureRightLabelOnSecret(publicKey, req.EncryptedValue, expectedWorkflowOwner, orgID, ciphertextEnc) if err != nil { return errors.New("Encrypted Secret at index [" + strconv.Itoa(idx) + "] doesn't have owner as the label. Error: " + err.Error()) } @@ -102,8 +102,8 @@ func (r *RequestValidator) validateWriteRequest(ctx context.Context, publicKey * return nil } -func (r *RequestValidator) ValidateCiphertextSize(ctx context.Context, owner string, encryptedValue string) error { - rawCiphertext, err := hex.DecodeString(encryptedValue) +func (r *RequestValidator) ValidateCiphertextSize(ctx context.Context, owner string, encryptedValue string, ciphertextEnc vaultutils.CiphertextStringEncoding) error { + rawCiphertext, err := vaultutils.DecodeEncryptedValue(encryptedValue, ciphertextEnc) if err != nil { return fmt.Errorf("failed to decode encrypted value: %w", err) } @@ -243,9 +243,9 @@ func NewRequestValidator( // 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, err := verifyEncryptedSecret(publicKey, secret) +// at least one non-empty owner. ciphertextEnc must match how `secret` is encoded (hex vs base64). +func EnsureRightLabelOnSecret(publicKey *tdh2easy.PublicKey, secret string, workflowOwner string, orgID string, ciphertextEnc vaultutils.CiphertextStringEncoding) error { + cipherText, err := verifyEncryptedSecret(publicKey, secret, ciphertextEnc) if err != nil { return err } @@ -274,10 +274,10 @@ func EnsureRightLabelOnSecret(publicKey *tdh2easy.PublicKey, secret string, work return errors.New("secret label [" + hex.EncodeToString(secretLabel[:]) + "] does not match any of the provided owner labels; expectedLabels=[" + strings.Join(expectedLabels, ", ") + "]") } -func verifyEncryptedSecret(publicKey *tdh2easy.PublicKey, secret string) (*tdh2easy.Ciphertext, error) { - cipherBytes, err := hex.DecodeString(secret) +func verifyEncryptedSecret(publicKey *tdh2easy.PublicKey, secret string, ciphertextEnc vaultutils.CiphertextStringEncoding) (*tdh2easy.Ciphertext, error) { + cipherBytes, err := vaultutils.DecodeEncryptedValue(secret, ciphertextEnc) if err != nil { - return nil, errors.New("failed to decode encrypted value:" + err.Error()) + return nil, 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). @@ -287,7 +287,7 @@ func verifyEncryptedSecret(publicKey *tdh2easy.PublicKey, secret string) (*tdh2e cipherText := &tdh2easy.Ciphertext{} if err := cipherText.UnmarshalVerify(cipherBytes, publicKey); err != nil { - return nil, errors.New("failed to verify encrypted value:" + err.Error()) + return nil, errors.New("failed to verify encrypted value: " + err.Error()) } return cipherText, nil } diff --git a/core/capabilities/vault/validator_test.go b/core/capabilities/vault/validator_test.go index a4b2f57b112..1874aba835c 100644 --- a/core/capabilities/vault/validator_test.go +++ b/core/capabilities/vault/validator_test.go @@ -2,6 +2,7 @@ package vault import ( "crypto/sha256" + "encoding/base64" "encoding/hex" "fmt" "testing" @@ -91,7 +92,7 @@ func TestEnsureRightLabelOnSecret_WorkflowOwnerOnly(t *testing.T) { owner := "0x0001020304050607080900010203040506070809" secret := encryptWithEthAddressLabel(t, pk, owner) - err := EnsureRightLabelOnSecret(pk, secret, owner, "") + err := EnsureRightLabelOnSecret(pk, secret, owner, "", vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } @@ -100,7 +101,7 @@ func TestEnsureRightLabelOnSecret_OrgIDOnly(t *testing.T) { orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz" secret := encryptWithOrgIDLabel(t, pk, orgID) - err := EnsureRightLabelOnSecret(pk, secret, "", orgID) + err := EnsureRightLabelOnSecret(pk, secret, "", orgID, vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } @@ -110,7 +111,7 @@ func TestEnsureRightLabelOnSecret_DualMatchesWorkflowOwner(t *testing.T) { orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz" secret := encryptWithEthAddressLabel(t, pk, ethAddr) - err := EnsureRightLabelOnSecret(pk, secret, ethAddr, orgID) + err := EnsureRightLabelOnSecret(pk, secret, ethAddr, orgID, vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } @@ -120,7 +121,7 @@ func TestEnsureRightLabelOnSecret_DualMatchesOrgID(t *testing.T) { orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz" secret := encryptWithOrgIDLabel(t, pk, orgID) - err := EnsureRightLabelOnSecret(pk, secret, ethAddr, orgID) + err := EnsureRightLabelOnSecret(pk, secret, ethAddr, orgID, vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } @@ -135,7 +136,7 @@ func TestEnsureRightLabelOnSecret_NeitherMatches(t *testing.T) { expectedWorkflowOwnerLabel := hex.EncodeToString(expectedWorkflowOwnerLabelBytes[:]) expectedOrgIDLabel := hex.EncodeToString(expectedOrgIDLabelBytes[:]) - err := EnsureRightLabelOnSecret(pk, secret, wrongAddr, wrongOrgID) + err := EnsureRightLabelOnSecret(pk, secret, wrongAddr, wrongOrgID, vaultutils.CiphertextStringEncodingHex) require.Error(t, err) assert.Contains(t, err.Error(), "does not match any of the provided owner labels") assert.Contains(t, err.Error(), "expectedLabels=["+expectedWorkflowOwnerLabel+", "+expectedOrgIDLabel+"]") @@ -146,7 +147,7 @@ func TestEnsureRightLabelOnSecret_BothEmpty(t *testing.T) { ethAddr := "0x0001020304050607080900010203040506070809" secret := encryptWithEthAddressLabel(t, pk, ethAddr) - err := EnsureRightLabelOnSecret(pk, secret, "", "") + err := EnsureRightLabelOnSecret(pk, secret, "", "", vaultutils.CiphertextStringEncodingHex) require.Error(t, err) assert.Contains(t, err.Error(), "does not match any of the provided owner labels") assert.Contains(t, err.Error(), "expectedLabels=[]") @@ -157,14 +158,14 @@ func TestEnsureRightLabelOnSecret_NilPublicKey(t *testing.T) { ethAddr := "0x0001020304050607080900010203040506070809" secret := encryptWithEthAddressLabel(t, pk, ethAddr) - err := EnsureRightLabelOnSecret(nil, secret, ethAddr, "") + err := EnsureRightLabelOnSecret(nil, secret, ethAddr, "", vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } func TestEnsureRightLabelOnSecret_InvalidHexSecret(t *testing.T) { pk, _ := generateTestKeys(t) - err := EnsureRightLabelOnSecret(pk, "not-valid-hex!", "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "") + err := EnsureRightLabelOnSecret(pk, "not-valid-hex!", "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "", vaultutils.CiphertextStringEncodingHex) require.Error(t, err) assert.Contains(t, err.Error(), "failed to decode encrypted value") } @@ -172,7 +173,7 @@ func TestEnsureRightLabelOnSecret_InvalidHexSecret(t *testing.T) { func TestEnsureRightLabelOnSecret_InvalidCiphertext(t *testing.T) { pk, _ := generateTestKeys(t) - err := EnsureRightLabelOnSecret(pk, hex.EncodeToString([]byte("garbage")), "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "") + err := EnsureRightLabelOnSecret(pk, hex.EncodeToString([]byte("garbage")), "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "", vaultutils.CiphertextStringEncodingHex) require.Error(t, err) assert.Contains(t, err.Error(), "failed to verify encrypted value") } @@ -183,7 +184,7 @@ func TestEnsureRightLabelOnSecret_WrongPublicKey(t *testing.T) { ethAddr := "0x0001020304050607080900010203040506070809" secret := encryptWithEthAddressLabel(t, pk, ethAddr) - err := EnsureRightLabelOnSecret(wrongPK, secret, ethAddr, "") + err := EnsureRightLabelOnSecret(wrongPK, secret, ethAddr, "", vaultutils.CiphertextStringEncodingHex) require.Error(t, err) assert.Contains(t, err.Error(), "failed to verify encrypted value") } @@ -193,7 +194,7 @@ func TestEnsureRightLabelOnSecret_BackwardCompatSingleOwner(t *testing.T) { owner := "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B" secret := encryptWithEthAddressLabel(t, pk, owner) - err := EnsureRightLabelOnSecret(pk, secret, owner, "") + err := EnsureRightLabelOnSecret(pk, secret, owner, "", vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } @@ -203,7 +204,7 @@ func TestEnsureRightLabelOnSecret_LegacySecretReadViaNewFlow(t *testing.T) { orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz" secret := encryptWithEthAddressLabel(t, pk, workflowOwner) - err := EnsureRightLabelOnSecret(pk, secret, workflowOwner, orgID) + err := EnsureRightLabelOnSecret(pk, secret, workflowOwner, orgID, vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } @@ -213,7 +214,7 @@ func TestEnsureRightLabelOnSecret_NewSecretReadViaNewFlow(t *testing.T) { workflowOwner := "0x0001020304050607080900010203040506070809" secret := encryptWithOrgIDLabel(t, pk, orgID) - err := EnsureRightLabelOnSecret(pk, secret, workflowOwner, orgID) + err := EnsureRightLabelOnSecret(pk, secret, workflowOwner, orgID, vaultutils.CiphertextStringEncodingHex) assert.NoError(t, err) } @@ -254,7 +255,7 @@ func TestRequestValidator_BatchSizeLimit(t *testing.T) { return v.ValidateCreateSecretsRequest(t.Context(), nil, &vaultcommon.CreateSecretsRequest{ RequestId: "request-id", EncryptedSecrets: makeSecrets(2), - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, }, { @@ -263,7 +264,7 @@ func TestRequestValidator_BatchSizeLimit(t *testing.T) { return v.ValidateCreateSecretsRequest(t.Context(), nil, &vaultcommon.CreateSecretsRequest{ RequestId: "request-id", EncryptedSecrets: makeSecrets(3), - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, errSubstr: "request batch size exceeds maximum of 2", }, @@ -273,7 +274,7 @@ func TestRequestValidator_BatchSizeLimit(t *testing.T) { return v.ValidateUpdateSecretsRequest(t.Context(), nil, &vaultcommon.UpdateSecretsRequest{ RequestId: "request-id", EncryptedSecrets: makeSecrets(2), - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, }, { @@ -282,7 +283,7 @@ func TestRequestValidator_BatchSizeLimit(t *testing.T) { return v.ValidateUpdateSecretsRequest(t.Context(), nil, &vaultcommon.UpdateSecretsRequest{ RequestId: "request-id", EncryptedSecrets: makeSecrets(3), - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, errSubstr: "request batch size exceeds maximum of 2", }, @@ -330,7 +331,7 @@ func TestRequestValidator_CiphertextSizeLimit(t *testing.T) { EncryptedSecrets: []*vaultcommon.EncryptedSecret{ {Id: id, EncryptedValue: value}, }, - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, value: hex.EncodeToString(make([]byte, 10)), }, @@ -342,7 +343,7 @@ func TestRequestValidator_CiphertextSizeLimit(t *testing.T) { EncryptedSecrets: []*vaultcommon.EncryptedSecret{ {Id: id, EncryptedValue: value}, }, - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, value: hex.EncodeToString(make([]byte, 11)), errSubstr: "ciphertext size exceeds maximum allowed size", @@ -355,7 +356,7 @@ func TestRequestValidator_CiphertextSizeLimit(t *testing.T) { EncryptedSecrets: []*vaultcommon.EncryptedSecret{ {Id: id, EncryptedValue: value}, }, - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, value: hex.EncodeToString(make([]byte, 10)), }, @@ -367,7 +368,7 @@ func TestRequestValidator_CiphertextSizeLimit(t *testing.T) { EncryptedSecrets: []*vaultcommon.EncryptedSecret{ {Id: id, EncryptedValue: value}, }, - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) }, value: hex.EncodeToString(make([]byte, 11)), errSubstr: "ciphertext size exceeds maximum allowed size", @@ -416,7 +417,7 @@ func TestRequestValidator_ValidateCreateSecretsRequest_UsesRequestIdentityForOrg EncryptedValue: encrypted, }, }, - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) require.NoError(t, err) } @@ -446,7 +447,7 @@ func TestRequestValidator_ValidateCreateSecretsRequest_FallsBackToSecretOwnerFor EncryptedValue: encrypted, }, }, - }, false) + }, false, vaultutils.CiphertextStringEncodingHex) require.NoError(t, err) } @@ -681,7 +682,7 @@ func TestRequestValidator_IdentifierLengths(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validator.ValidateCreateSecretsRequest(t.Context(), nil, makeRequest(tt.key, tt.owner, tt.namespace), false) + err := validator.ValidateCreateSecretsRequest(t.Context(), nil, makeRequest(tt.key, tt.owner, tt.namespace), false, vaultutils.CiphertextStringEncodingHex) if tt.errSubstr == "" { require.NoError(t, err) return @@ -1002,12 +1003,12 @@ func TestRequestValidator_OwnerSpecificCiphertextLimit(t *testing.T) { } // Regular owner is rejected because 15 bytes exceeds their 10-byte limit - err := validator.ValidateCreateSecretsRequest(t.Context(), nil, makeRequest("req-1", "regularowner"), false) + err := validator.ValidateCreateSecretsRequest(t.Context(), nil, makeRequest("req-1", "regularowner"), false, vaultutils.CiphertextStringEncodingHex) require.Error(t, err) require.ErrorContains(t, err, "ciphertext size exceeds maximum allowed size") // Privileged owner is accepted because 15 bytes is within their 20-byte limit - err = validator.ValidateCreateSecretsRequest(t.Context(), nil, makeRequest("req-2", privilegedOwner), false) + err = validator.ValidateCreateSecretsRequest(t.Context(), nil, makeRequest("req-2", privilegedOwner), false, vaultutils.CiphertextStringEncodingHex) require.NoError(t, err) } @@ -1039,10 +1040,10 @@ func TestRequestValidator_ValidateCreateSecretsRequest_SkipsLabelValidationWithB }, } - err := validator.ValidateCreateSecretsRequest(t.Context(), pk, request, false) + err := validator.ValidateCreateSecretsRequest(t.Context(), pk, request, false, vaultutils.CiphertextStringEncodingHex) require.ErrorContains(t, err, "doesn't have owner as the label") - err = validator.ValidateCreateSecretsRequest(t.Context(), pk, request, true) + err = validator.ValidateCreateSecretsRequest(t.Context(), pk, request, true, vaultutils.CiphertextStringEncodingHex) require.NoError(t, err) } @@ -1071,7 +1072,7 @@ func TestRequestValidator_NormalizesEmptyNamespaceOnStructs(t *testing.T) { {Id: id, EncryptedValue: validValue}, }, } - require.NoError(t, validator.ValidateCreateSecretsRequest(t.Context(), nil, req, true)) + require.NoError(t, validator.ValidateCreateSecretsRequest(t.Context(), nil, req, true, vaultutils.CiphertextStringEncodingHex)) assert.Equal(t, vaulttypes.DefaultNamespace, id.Namespace) }) @@ -1088,3 +1089,28 @@ func TestRequestValidator_NormalizesEmptyNamespaceOnStructs(t *testing.T) { assert.Equal(t, vaulttypes.DefaultNamespace, req.Namespace) }) } + +func TestValidateCiphertextSize_Base64(t *testing.T) { + validator := NewRequestValidator( + limits.NewUpperBoundLimiter(10), + limits.NewUpperBoundLimiter(100*pkgconfig.Byte), + limits.NewUpperBoundLimiter(64*pkgconfig.Byte), + limits.NewUpperBoundLimiter(64*pkgconfig.Byte), + limits.NewUpperBoundLimiter(64*pkgconfig.Byte), + ) + raw := make([]byte, 50) + b64 := base64.StdEncoding.EncodeToString(raw) + err := validator.ValidateCiphertextSize(t.Context(), "0x1111111111111111111111111111111111111111", b64, vaultutils.CiphertextStringEncodingBase64) + require.NoError(t, err) +} + +func TestEnsureRightLabelOnSecret_Base64EncodedCiphertext(t *testing.T) { + pk, _ := generateTestKeys(t) + owner := "0x0001020304050607080900010203040506070809" + hexSecret := encryptWithEthAddressLabel(t, pk, owner) + raw, err := hex.DecodeString(hexSecret) + require.NoError(t, err) + b64 := base64.StdEncoding.EncodeToString(raw) + err = EnsureRightLabelOnSecret(pk, b64, owner, "", vaultutils.CiphertextStringEncodingBase64) + require.NoError(t, err) +} diff --git a/core/capabilities/vault/vaultutils/encoding.go b/core/capabilities/vault/vaultutils/encoding.go new file mode 100644 index 00000000000..cca209b60f6 --- /dev/null +++ b/core/capabilities/vault/vaultutils/encoding.go @@ -0,0 +1,36 @@ +package vaultutils + +import ( + "encoding/base64" + "encoding/hex" + "fmt" + + "github.com/pkg/errors" +) + +// CiphertextStringEncoding selects how an encrypted value string is encoded on the wire. +// Callers must choose the encoding that matches how the value was produced; there is no +// auto-detection in DecodeEncryptedValue, so ambiguous strings cannot be mis-decoded. +type CiphertextStringEncoding int + +const ( + // CiphertextStringEncodingHex is hex-encoded raw ciphertext bytes (legacy gateway input). + CiphertextStringEncodingHex CiphertextStringEncoding = iota + // CiphertextStringEncodingBase64 is standard base64 (StdEncoding) without a prefix. + CiphertextStringEncodingBase64 +) + +// DecodeEncryptedValue decodes ciphertext using exactly one wire encoding. +func DecodeEncryptedValue(s string, enc CiphertextStringEncoding) ([]byte, error) { + switch enc { + case CiphertextStringEncodingHex: + return hex.DecodeString(s) + case CiphertextStringEncodingBase64: + if s == "" { + return nil, errors.New("empty base64 ciphertext") + } + return base64.StdEncoding.DecodeString(s) + default: + return nil, fmt.Errorf("unknown ciphertext string encoding: %d", enc) + } +} diff --git a/core/capabilities/vault/vaultutils/encoding_test.go b/core/capabilities/vault/vaultutils/encoding_test.go new file mode 100644 index 00000000000..f0ee44e4f77 --- /dev/null +++ b/core/capabilities/vault/vaultutils/encoding_test.go @@ -0,0 +1,53 @@ +package vaultutils + +import ( + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDecodeEncryptedValue_Hex(t *testing.T) { + raw := []byte{0xde, 0xad, 0xbe, 0xef} + enc := hex.EncodeToString(raw) + got, err := DecodeEncryptedValue(enc, CiphertextStringEncodingHex) + require.NoError(t, err) + require.Equal(t, raw, got) +} + +func TestDecodeEncryptedValue_Hex_oddLengthRejected(t *testing.T) { + _, err := DecodeEncryptedValue("abc", CiphertextStringEncodingHex) + require.Error(t, err) +} + +func TestDecodeEncryptedValue_Base64(t *testing.T) { + raw := []byte{0x01, 0x02, 0x03, 0xff} + enc := base64.StdEncoding.EncodeToString(raw) + got, err := DecodeEncryptedValue(enc, CiphertextStringEncodingBase64) + require.NoError(t, err) + require.Equal(t, raw, got) +} + +func TestDecodeEncryptedValue_Base64_ambiguousHexStringUsesBase64(t *testing.T) { + // "aabb" is valid hex ([0xaa,0xbb]) and valid base64 (different bytes). + s := "aabb" + gotHex, err := DecodeEncryptedValue(s, CiphertextStringEncodingHex) + require.NoError(t, err) + gotB64, err := DecodeEncryptedValue(s, CiphertextStringEncodingBase64) + require.NoError(t, err) + require.NotEqual(t, gotHex, gotB64) +} + +func TestDecodeEncryptedValue_InvalidEncodingConst(t *testing.T) { + _, err := DecodeEncryptedValue("00", CiphertextStringEncoding(99)) + require.Error(t, err) +} + +func TestDecodeEncryptedValue_Invalid(t *testing.T) { + _, err := DecodeEncryptedValue("not-valid-hex!", CiphertextStringEncodingHex) + require.Error(t, err) + + _, err = DecodeEncryptedValue("!!!not-base64!!!", CiphertextStringEncodingBase64) + require.Error(t, err) +} diff --git a/core/scripts/cre/environment/configs/workflow-gateway-capabilities-don-vault-base64-enabled.toml b/core/scripts/cre/environment/configs/workflow-gateway-capabilities-don-vault-base64-enabled.toml new file mode 100644 index 00000000000..d7ae987e8bf --- /dev/null +++ b/core/scripts/cre/environment/configs/workflow-gateway-capabilities-don-vault-base64-enabled.toml @@ -0,0 +1,103 @@ +# Same as workflow-gateway-capabilities-don.toml but sets VaultBase64EncodingEnabled on workflow, +# capabilities (vault OCR), and gateway nodes so Vault OCR observations/outcomes use base64 ciphertext. +# Use this topology for manual or CI smoke runs that exercise the smaller OCR payloads end-to-end. + +[chip_router] + image = "local-cre-chip-router:v1.0.1" + +[[blockchains]] + type = "anvil" + chain_id = "1337" + container_name = "anvil-1337" + docker_cmd_params = ["-b", "0.5", "--mixed-mining"] + +[[blockchains]] + type = "anvil" + chain_id = "2337" + container_name = "anvil-2337" + port = "8546" + docker_cmd_params = ["-b", "0.5", "--mixed-mining"] + +[jd] + csa_encryption_key = "d1093c0060d50a3c89c189b2e485da5a3ce57f3dcb38ab7e2c0d5f0bb2314a44" + image = "job-distributor:0.22.1" + +[fake] + port = 8171 + +[fake_http] + port = 8666 + +[infra] + type = "docker" + +[[nodesets]] + nodes = 4 + name = "workflow" + don_types = ["workflow"] + override_mode = "all" + http_port_range_start = 10100 + supported_evm_chains = [1337, 2337] + + env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node", CL_CRE_SETTINGS_DEFAULT = '{"VaultBase64EncodingEnabled":"true","VaultJWTAuthEnabled":"true"}' } + capabilities = ["ocr3", "custom-compute", "web-api-trigger", "cron", "http-action", "http-trigger", "consensus", "don-time", "write-evm-1337", "read-contract-1337", "evm-1337"] + registry_based_launch_allowlist = ["cron-trigger@1.0.0"] + + [nodesets.db] + image = "postgres:12.0" + port = 13000 + + [[nodesets.node_specs]] + roles = ["plugin"] + [nodesets.node_specs.node] + docker_ctx = "../../../.." + docker_file = "core/chainlink.Dockerfile" + docker_build_args = { "CL_IS_PROD_BUILD" = "false" } + user_config_overrides = "" + +[[nodesets]] + nodes = 4 + name = "capabilities" + don_types = ["capabilities"] + exposes_remote_capabilities = true + override_mode = "all" + http_port_range_start = 10200 + supported_evm_chains = [1337, 2337] + + env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node", CL_CRE_SETTINGS_DEFAULT = '{"VaultBase64EncodingEnabled":"true","VaultJWTAuthEnabled":"true"}' } + capabilities = ["web-api-target", "vault", "write-evm-2337", "read-contract-2337", "evm-2337"] + + [nodesets.db] + image = "postgres:12.0" + port = 13100 + + [[nodesets.node_specs]] + roles = ["plugin"] + [nodesets.node_specs.node] + docker_ctx = "../../../.." + docker_file = "core/chainlink.Dockerfile" + docker_build_args = { "CL_IS_PROD_BUILD" = "false" } + user_config_overrides = "" + +[[nodesets]] + nodes = 1 + name = "bootstrap-gateway" + don_types = ["bootstrap", "gateway"] + override_mode = "each" + http_port_range_start = 10300 + + env_vars = { CL_EVM_CMD = "", OTEL_SERVICE_NAME = "chainlink-node", CL_CRE_SETTINGS_DEFAULT = '{"VaultBase64EncodingEnabled":"true"}' } + supported_evm_chains = [1337, 2337] + + [nodesets.db] + image = "postgres:12.0" + port = 13200 + + [[nodesets.node_specs]] + roles = ["bootstrap", "gateway"] + [nodesets.node_specs.node] + docker_ctx = "../../../.." + docker_file = "core/chainlink.Dockerfile" + docker_build_args = { "CL_IS_PROD_BUILD" = "false" } + custom_ports = ["5002:5002","15002:15002"] + user_config_overrides = "" diff --git a/core/services/gateway/handlers/vault/handler.go b/core/services/gateway/handlers/vault/handler.go index 42a055b8554..a73c94c375b 100644 --- a/core/services/gateway/handlers/vault/handler.go +++ b/core/services/gateway/handlers/vault/handler.go @@ -2,6 +2,7 @@ package vault import ( "context" + "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -31,6 +32,7 @@ import ( "github.com/smartcontractkit/chainlink-common/pkg/settings/limits" vaultcap "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault" "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaultutils" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/api" "github.com/smartcontractkit/chainlink/v2/core/services/gateway/config" gwhandlers "github.com/smartcontractkit/chainlink/v2/core/services/gateway/handlers" @@ -147,6 +149,7 @@ type handler struct { writeMethodsEnabled limits.GateLimiter orgIDAsSecretOwnerEnabled limits.GateLimiter + vaultBase64Gate limits.GateLimiter activeRequests map[string]*activeRequest metrics *metrics @@ -187,7 +190,7 @@ func NewHandler(methodConfig json.RawMessage, donConfig *config.DONConfig, don g return nil, fmt.Errorf("failed to unmarshal method config: %w", err) } - allowListBasedAuth := vaultcap.NewAllowListBasedAuth(lggr, workflowRegistrySyncer) + allowListBasedAuth := vaultcap.NewAllowListBasedAuth(lggr, workflowRegistrySyncer, nil) var jwtBasedAuth vaultcap.Authorizer var jwtAuth services.Service if cfg.Auth0 != nil { @@ -255,6 +258,10 @@ func newHandlerWithAuthorizer(methodConfig json.RawMessage, donConfig *config.DO if err != nil { return nil, fmt.Errorf("could not create vault org ID as secret owner limiter: %w", err) } + vaultBase64Gate, err := limits.MakeGateLimiter(limitsFactory, cresettings.Default.VaultBase64EncodingEnabled) + if err != nil { + return nil, fmt.Errorf("could not create vault base64 encoding limiter: %w", err) + } return &handler{ methodConfig: cfg, @@ -265,6 +272,7 @@ func newHandlerWithAuthorizer(methodConfig json.RawMessage, donConfig *config.DO nodeRateLimiter: nodeRateLimiter, writeMethodsEnabled: writeMethodsEnabled, orgIDAsSecretOwnerEnabled: orgIDAsSecretOwnerEnabled, + vaultBase64Gate: vaultBase64Gate, activeRequests: make(map[string]*activeRequest), mu: sync.RWMutex{}, authorizer: authorizer, @@ -320,6 +328,7 @@ func (h *handler) Close() error { jwtAuthErr, h.writeMethodsEnabled.Close(), h.orgIDAsSecretOwnerEnabled.Close(), + h.vaultBase64Gate.Close(), h.MaxRequestBatchSizeLimiter.Close(), ) }) @@ -605,6 +614,34 @@ func (h *handler) skipSecretLabelValidation(ctx context.Context, orgID string) ( return orgIDAsSecretOwnerEnabled && orgID == "", nil } +func (h *handler) vaultBase64EncodingEnabled(ctx context.Context) bool { + return h.vaultBase64Gate.AllowErr(ctx) == nil +} + +func convertEncryptedSecretsHexToBase64(secrets []*vaultcommon.EncryptedSecret) error { + for i, item := range secrets { + if item == nil { + continue + } + raw, err := hex.DecodeString(item.EncryptedValue) + if err != nil { + return fmt.Errorf("error decoding encrypted value at index %d: %w", i, err) + } + item.EncryptedValue = base64.StdEncoding.EncodeToString(raw) + } + return nil +} + +func (h *handler) maybeConvertEncryptedSecretsHexToBase64(ctx context.Context, secrets []*vaultcommon.EncryptedSecret) error { + if !h.vaultBase64EncodingEnabled(ctx) { + return nil + } + if err := convertEncryptedSecretsHexToBase64(secrets); err != nil { + return fmt.Errorf("failed to convert ciphertext encoding: %w", err) + } + return nil +} + func (h *handler) handleSecretsCreate(ctx context.Context, ar *activeRequest) error { l := logger.With(h.lggr, "method", ar.req.Method, "requestID", ar.req.ID) @@ -642,12 +679,17 @@ func (h *handler) handleSecretsCreate(ctx context.Context, ar *activeRequest) er validationRequest = proto.Clone(createSecretsRequest).(*vaultcommon.CreateSecretsRequest) validationRequest.WorkflowOwner = "" } - err = h.ValidateCreateSecretsRequest(ctx, cachedPublicKey, validationRequest, skipLabelValidation) + err = h.ValidateCreateSecretsRequest(ctx, cachedPublicKey, validationRequest, skipLabelValidation, vaultutils.CiphertextStringEncodingHex) if err != nil { l.Warnw("failed to validate create secrets request", "error", err) return h.sendResponse(ctx, ar, h.errorResponse(ar.req, api.InvalidParamsError, fmt.Errorf("failed to validate create secrets request: %w", err), nil)) } + if convErr := h.maybeConvertEncryptedSecretsHexToBase64(ctx, createSecretsRequest.EncryptedSecrets); convErr != nil { + l.Errorw("failed to convert ciphertext to base64 after validation", "error", convErr) + return h.sendResponse(ctx, ar, h.errorResponse(ar.req, api.FatalError, convErr, nil)) + } + reqBytes, err := json.Marshal(createSecretsRequest) if err != nil { l.Errorw("failed to marshal request", "error", err) @@ -697,12 +739,17 @@ func (h *handler) handleSecretsUpdate(ctx context.Context, ar *activeRequest) er validationRequest = proto.Clone(updateSecretsRequest).(*vaultcommon.UpdateSecretsRequest) validationRequest.WorkflowOwner = "" } - vaultCapErr := h.ValidateUpdateSecretsRequest(ctx, cachedPublicKey, validationRequest, skipLabelValidation) + vaultCapErr := h.ValidateUpdateSecretsRequest(ctx, cachedPublicKey, validationRequest, skipLabelValidation, vaultutils.CiphertextStringEncodingHex) if vaultCapErr != nil { l.Warnw("failed to validate update secrets request", "error", vaultCapErr) return h.sendResponse(ctx, ar, h.errorResponse(ar.req, api.InvalidParamsError, fmt.Errorf("failed to validate update secrets request: %w", vaultCapErr), nil)) } + if convErr := h.maybeConvertEncryptedSecretsHexToBase64(ctx, updateSecretsRequest.EncryptedSecrets); convErr != nil { + l.Errorw("failed to convert ciphertext to base64 after validation", "error", convErr) + return h.sendResponse(ctx, ar, h.errorResponse(ar.req, api.FatalError, convErr, nil)) + } + reqBytes, err := json.Marshal(updateSecretsRequest) if err != nil { l.Errorw("failed to marshal request", "error", err) diff --git a/core/services/gateway/handlers/vault/handler_test.go b/core/services/gateway/handlers/vault/handler_test.go index 3c99e9c1e22..dc800ef9bd4 100644 --- a/core/services/gateway/handlers/vault/handler_test.go +++ b/core/services/gateway/handlers/vault/handler_test.go @@ -1,7 +1,9 @@ package vault import ( + "bytes" "context" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -1071,3 +1073,344 @@ func TestVaultHandler_PublicKeyGet(t *testing.T) { assert.Equal(t, jsonRequest.ID, publicKeyResponse.ID, "request ID should match") assert.Equal(t, publicKey, publicKeyResponse.Result.PublicKey, "public key should match") } + +func TestHandleSecretsCreate_Base64EncodingEnabled_ForwardsBase64ToNodes(t *testing.T) { + getter, err := settings.NewJSONGetter([]byte(`{"global":{"VaultBase64EncodingEnabled":true}}`)) + require.NoError(t, err) + + h, callback, don, _ := setupHandlerWithLimitsFactory(t, limits.Factory{Settings: getter}) + don.On("SendToNode", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + createSecretsRequest := &vaultcommon.CreateSecretsRequest{ + RequestId: "test_request_id", + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "test_id", + Owner: owner, + Namespace: "main", + }, + EncryptedValue: "deadbeef", + }, + }, + } + params, err := json.Marshal(createSecretsRequest) + require.NoError(t, err) + + requestID := "1" + validJSONRequest := jsonrpc.Request[json.RawMessage]{ + ID: requestID, + Method: vaulttypes.MethodSecretsCreate, + Params: (*json.RawMessage)(¶ms), + } + + responseData := &vaultcommon.CreateSecretsResponse{ + Responses: []*vaultcommon.CreateSecretResponse{ + { + Id: createSecretsRequest.EncryptedSecrets[0].Id, + Success: true, + }, + }, + } + resultBytes, err := json.Marshal(responseData) + require.NoError(t, err) + expectedRequestID := owner + vaulttypes.RequestIDSeparator + requestID + response := jsonrpc.Response[json.RawMessage]{ + ID: expectedRequestID, + Result: (*json.RawMessage)(&resultBytes), + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, err2 := callback.Wait(t.Context()) + assert.NoError(t, err2) + }() + + err = h.HandleJSONRPCUserMessage(t.Context(), validJSONRequest, callback) + require.NoError(t, err) + + err = h.HandleNodeMessage(t.Context(), &response, NodeOne.Address) + require.NoError(t, err) + wg.Wait() + + don.AssertCalled(t, "SendToNode", mock.Anything, mock.Anything, mock.MatchedBy(func(req *jsonrpc.Request[json.RawMessage]) bool { + var parsed vaultcommon.CreateSecretsRequest + if json.Unmarshal(*req.Params, &parsed) != nil { + return false + } + if len(parsed.EncryptedSecrets) != 1 { + return false + } + ev := parsed.EncryptedSecrets[0].EncryptedValue + _, err := base64.StdEncoding.DecodeString(ev) + return err == nil + })) +} + +func TestHandleSecretsUpdate_Base64EncodingEnabled_ForwardsBase64ToNodes(t *testing.T) { + runUpdateAndAssert := func(t *testing.T, updateSecretsRequest *vaultcommon.UpdateSecretsRequest, assertPayload func(*vaultcommon.UpdateSecretsRequest) bool) { + t.Helper() + getter, err := settings.NewJSONGetter([]byte(`{"global":{"VaultBase64EncodingEnabled":true}}`)) + require.NoError(t, err) + h, callback, don, _ := setupHandlerWithLimitsFactory(t, limits.Factory{Settings: getter}) + don.On("SendToNode", mock.Anything, mock.Anything, mock.Anything).Return(nil) + + params, err := json.Marshal(updateSecretsRequest) + require.NoError(t, err) + requestID := "1" + validJSONRequest := jsonrpc.Request[json.RawMessage]{ + ID: requestID, + Method: vaulttypes.MethodSecretsUpdate, + Params: (*json.RawMessage)(¶ms), + } + resps := make([]*vaultcommon.UpdateSecretResponse, 0, len(updateSecretsRequest.EncryptedSecrets)) + for _, es := range updateSecretsRequest.EncryptedSecrets { + resps = append(resps, &vaultcommon.UpdateSecretResponse{Id: es.Id, Success: true}) + } + responseData := &vaultcommon.UpdateSecretsResponse{Responses: resps} + resultBytes, err := json.Marshal(responseData) + require.NoError(t, err) + expectedRequestID := owner + vaulttypes.RequestIDSeparator + requestID + response := jsonrpc.Response[json.RawMessage]{ + ID: expectedRequestID, + Result: (*json.RawMessage)(&resultBytes), + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, err2 := callback.Wait(t.Context()) + assert.NoError(t, err2) + }() + err = h.HandleJSONRPCUserMessage(t.Context(), validJSONRequest, callback) + require.NoError(t, err) + err = h.HandleNodeMessage(t.Context(), &response, NodeOne.Address) + require.NoError(t, err) + wg.Wait() + don.AssertCalled(t, "SendToNode", mock.Anything, mock.Anything, mock.MatchedBy(func(req *jsonrpc.Request[json.RawMessage]) bool { + var parsed vaultcommon.UpdateSecretsRequest + if json.Unmarshal(*req.Params, &parsed) != nil { + return false + } + return assertPayload(&parsed) + })) + } + + t.Run("single_secret", func(t *testing.T) { + wantRaw, err := hex.DecodeString("deadbeef") + require.NoError(t, err) + updateSecretsRequest := &vaultcommon.UpdateSecretsRequest{ + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "sk1", + Owner: owner, + Namespace: "main", + }, + EncryptedValue: "deadbeef", + }, + }, + } + runUpdateAndAssert(t, updateSecretsRequest, func(parsed *vaultcommon.UpdateSecretsRequest) bool { + if len(parsed.EncryptedSecrets) != 1 { + return false + } + raw, err := base64.StdEncoding.DecodeString(parsed.EncryptedSecrets[0].EncryptedValue) + return err == nil && bytes.Equal(raw, wantRaw) + }) + }) + + t.Run("multiple_secrets_distinct_ciphertexts", func(t *testing.T) { + want0, err := hex.DecodeString("deadbeef") + require.NoError(t, err) + want1, err := hex.DecodeString("010203ff") + require.NoError(t, err) + updateSecretsRequest := &vaultcommon.UpdateSecretsRequest{ + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "a", + Owner: owner, + Namespace: "main", + }, + EncryptedValue: "deadbeef", + }, + { + Id: &vaultcommon.SecretIdentifier{ + Key: "b", + Owner: owner, + Namespace: "main", + }, + EncryptedValue: "010203ff", + }, + }, + } + runUpdateAndAssert(t, updateSecretsRequest, func(parsed *vaultcommon.UpdateSecretsRequest) bool { + if len(parsed.EncryptedSecrets) != 2 { + return false + } + for i, want := range [][]byte{want0, want1} { + raw, err := base64.StdEncoding.DecodeString(parsed.EncryptedSecrets[i].EncryptedValue) + if err != nil || !bytes.Equal(raw, want) { + return false + } + } + return parsed.EncryptedSecrets[0].Id.Key == "a" && parsed.EncryptedSecrets[1].Id.Key == "b" + }) + }) + + t.Run("org_labeled_ciphertext_forwarded_as_base64_when_org_owner_flag_enabled", func(t *testing.T) { + getter, err := settings.NewJSONGetter([]byte(`{"global":{"VaultBase64EncodingEnabled":true,"VaultOrgIdAsSecretOwnerEnabled":true}}`)) + require.NoError(t, err) + _, pk, _, err := tdh2easy.GenerateKeys(1, 3) + require.NoError(t, err) + orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz" + encryptedSecret, err := vaultutils.EncryptSecretWithOrgID("org_upd", pk, orgID) + require.NoError(t, err) + wantRaw, err := hex.DecodeString(encryptedSecret) + require.NoError(t, err) + + h, callback, don, _ := setupHandlerWithLimitsFactory(t, limits.Factory{Settings: getter}) + cacheVaultPublicKeyForTest(t, h.(*handler), pk) + don.On("SendToNode", mock.Anything, mock.Anything, mock.MatchedBy(func(req *jsonrpc.Request[json.RawMessage]) bool { + var parsed vaultcommon.UpdateSecretsRequest + if json.Unmarshal(*req.Params, &parsed) != nil { + return false + } + if len(parsed.EncryptedSecrets) != 1 { + return false + } + raw, decodeErr := base64.StdEncoding.DecodeString(parsed.EncryptedSecrets[0].EncryptedValue) + return decodeErr == nil && bytes.Equal(raw, wantRaw) + })).Return(nil) + + updateSecretsRequest := &vaultcommon.UpdateSecretsRequest{ + EncryptedSecrets: []*vaultcommon.EncryptedSecret{ + { + Id: &vaultcommon.SecretIdentifier{ + Key: "oidx", + Owner: owner, + Namespace: "main", + }, + EncryptedValue: encryptedSecret, + }, + }, + } + params, err := json.Marshal(updateSecretsRequest) + require.NoError(t, err) + requestID := "org-up-1" + validJSONRequest := jsonrpc.Request[json.RawMessage]{ + ID: requestID, + Method: vaulttypes.MethodSecretsUpdate, + Params: (*json.RawMessage)(¶ms), + } + responseData := &vaultcommon.UpdateSecretsResponse{ + Responses: []*vaultcommon.UpdateSecretResponse{ + {Id: updateSecretsRequest.EncryptedSecrets[0].Id, Success: true}, + }, + } + resultBytes, err := json.Marshal(responseData) + require.NoError(t, err) + expectedRequestID := owner + vaulttypes.RequestIDSeparator + requestID + response := jsonrpc.Response[json.RawMessage]{ + ID: expectedRequestID, + Result: (*json.RawMessage)(&resultBytes), + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, err2 := callback.Wait(t.Context()) + assert.NoError(t, err2) + }() + err = h.HandleJSONRPCUserMessage(t.Context(), validJSONRequest, callback) + require.NoError(t, err) + err = h.HandleNodeMessage(t.Context(), &response, NodeOne.Address) + require.NoError(t, err) + wg.Wait() + don.AssertExpectations(t) + }) + + t.Run("jwt_org_id_validation_clone_path_forwards_base64", func(t *testing.T) { + getter, err := settings.NewJSONGetter([]byte(`{"global":{"VaultBase64EncodingEnabled":true,"VaultOrgIdAsSecretOwnerEnabled":true}}`)) + require.NoError(t, err) + _, pk, _, err := tdh2easy.GenerateKeys(1, 3) + require.NoError(t, err) + orgID := "org_2xAbCdEfGhIjKlMnOpQrStUvWxYz" + workflowOwner := "0x0001020304050607080900010203040506070809" + encryptedSecret, err := vaultutils.EncryptSecretWithOrgID("jwt_upd", pk, orgID) + require.NoError(t, err) + wantRaw, err := hex.DecodeString(encryptedSecret) + require.NoError(t, err) + + h, callback, don, clock := setupHandlerWithLimitsFactory(t, limits.Factory{Settings: getter}) + h.(*handler).authorizer = &stubAuthorizer{result: vaultcap.NewAuthResult(orgID, workflowOwner, "digest-jwt-upd", clock.Now().Add(time.Minute).Unix())} + cacheVaultPublicKeyForTest(t, h.(*handler), pk) + + don.On("SendToNode", mock.Anything, mock.Anything, mock.MatchedBy(func(req *jsonrpc.Request[json.RawMessage]) bool { + var parsed vaultcommon.UpdateSecretsRequest + if json.Unmarshal(*req.Params, &parsed) != nil { + return false + } + if parsed.OrgId != orgID { + return false + } + if parsed.WorkflowOwner != workflowOwner { + return false + } + if len(parsed.EncryptedSecrets) != 1 { + return false + } + raw, decodeErr := base64.StdEncoding.DecodeString(parsed.EncryptedSecrets[0].EncryptedValue) + return decodeErr == nil && bytes.Equal(raw, wantRaw) + })).Return(nil) + + updateSecretsRequest := &vaultcommon.UpdateSecretsRequest{ + OrgId: orgID, + WorkflowOwner: workflowOwner, + EncryptedSecrets: []*vaultcommon.EncryptedSecret{{ + Id: &vaultcommon.SecretIdentifier{ + Key: "jwt_key", + Owner: orgID, + Namespace: "main", + }, + EncryptedValue: encryptedSecret, + }}, + } + params, err := json.Marshal(updateSecretsRequest) + require.NoError(t, err) + requestID := "jwt-up-1" + validJSONRequest := jsonrpc.Request[json.RawMessage]{ + ID: requestID, + Method: vaulttypes.MethodSecretsUpdate, + Params: (*json.RawMessage)(¶ms), + } + responseData := &vaultcommon.UpdateSecretsResponse{ + Responses: []*vaultcommon.UpdateSecretResponse{ + {Id: updateSecretsRequest.EncryptedSecrets[0].Id, Success: true}, + }, + } + resultBytes, err := json.Marshal(responseData) + require.NoError(t, err) + expectedRequestID := orgID + vaulttypes.RequestIDSeparator + requestID + response := jsonrpc.Response[json.RawMessage]{ + ID: expectedRequestID, + Result: (*json.RawMessage)(&resultBytes), + } + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _, err2 := callback.Wait(t.Context()) + assert.NoError(t, err2) + }() + err = h.HandleJSONRPCUserMessage(t.Context(), validJSONRequest, callback) + require.NoError(t, err) + err = h.HandleNodeMessage(t.Context(), &response, NodeOne.Address) + require.NoError(t, err) + wg.Wait() + don.AssertExpectations(t) + }) +} diff --git a/core/services/ocr2/plugins/vault/plugin.go b/core/services/ocr2/plugins/vault/plugin.go index 31337052026..203dd60b1c9 100644 --- a/core/services/ocr2/plugins/vault/plugin.go +++ b/core/services/ocr2/plugins/vault/plugin.go @@ -5,6 +5,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -63,6 +64,7 @@ type ReportingPluginConfig struct { MaxRequestBatchSize limits.BoundLimiter[int] MaxBatchSize limits.BoundLimiter[int] OrgIDAsSecretOwnerEnabled limits.GateLimiter + Base64EncodingEnabled limits.GateLimiter } func NewReportingPluginFactory( @@ -256,6 +258,11 @@ func newReportingPluginConfigLimiters(factory limits.Factory) (*ReportingPluginC return nil, fmt.Errorf("VaultOrgIDAsSecretOwnerEnabled: %w", err) } + base64EncodingEnabled, err := limits.MakeGateLimiter(factory, cresettings.Default.VaultBase64EncodingEnabled) + if err != nil { + return nil, fmt.Errorf("VaultBase64EncodingEnabled: %w", err) + } + return &ReportingPluginConfig{ MaxShareLengthBytes: maxShareLengthBytesLimiter, MaxRequestBatchSize: maxRequestBatchSizeLimiter, @@ -264,6 +271,7 @@ func newReportingPluginConfigLimiters(factory limits.Factory) (*ReportingPluginC MaxIdentifierOwnerLengthBytes: maxIdentifierOwnerLengthBytesLimiter, MaxIdentifierNamespaceLengthBytes: maxIdentifierNamespaceLengthBytesLimiter, OrgIDAsSecretOwnerEnabled: orgIDAsSecretOwnerEnabled, + Base64EncodingEnabled: base64EncodingEnabled, }, nil } @@ -400,6 +408,17 @@ func (r *ReportingPlugin) orgIDAsSecretOwnerEnabled(ctx context.Context) bool { return r.cfg.OrgIDAsSecretOwnerEnabled.AllowErr(ctx) == nil } +func (r *ReportingPlugin) base64EncodingEnabled(ctx context.Context) bool { + return r.cfg.Base64EncodingEnabled.AllowErr(ctx) == nil +} + +func (r *ReportingPlugin) ciphertextStringEncoding(ctx context.Context) vaultutils.CiphertextStringEncoding { + if r.base64EncodingEnabled(ctx) { + return vaultutils.CiphertextStringEncodingBase64 + } + return vaultutils.CiphertextStringEncodingHex +} + // canonicalResponseID rewrites Vault responses to the canonical owner identity. // // When VaultOrgIdAsSecretOwnerEnabled is on, requests may still arrive keyed by @@ -689,7 +708,7 @@ type share struct { data []byte } -func (s *share) encryptWithKey(pk string) (string, error) { +func (s *share) encryptWithKey(pk string, enc vaultutils.CiphertextStringEncoding) (string, error) { publicKey, err := hex.DecodeString(pk) if err != nil { return "", newUserError("failed to convert public key to bytes: " + err.Error()) @@ -705,7 +724,14 @@ func (s *share) encryptWithKey(pk string) (string, error) { return "", fmt.Errorf("failed to encrypt decryption share: %w", err) } - return hex.EncodeToString(encrypted), nil + switch enc { + case vaultutils.CiphertextStringEncodingBase64: + return base64.StdEncoding.EncodeToString(encrypted), nil + case vaultutils.CiphertextStringEncodingHex: + return hex.EncodeToString(encrypted), nil + default: + return "", fmt.Errorf("invalid ciphertext string encoding: %d", enc) + } } func generatePlaintextShare(publicKey *tdh2easy.PublicKey, privateKeyShare *tdh2easy.PrivateShare, encryptedSecret []byte, workflowOwner string, orgID string) (*share, error) { @@ -716,7 +742,7 @@ func generatePlaintextShare(publicKey *tdh2easy.PublicKey, privateKeyShare *tdh2 } es := hex.EncodeToString(encryptedSecret) - err = vaultcap.EnsureRightLabelOnSecret(publicKey, es, workflowOwner, orgID) + err = vaultcap.EnsureRightLabelOnSecret(publicKey, es, workflowOwner, orgID, vaultutils.CiphertextStringEncodingHex) if err != nil { return nil, errors.New("failed to verify label on secret. error: " + err.Error()) } @@ -757,8 +783,9 @@ func (r *ReportingPlugin) observeGetSecretsRequest(ctx context.Context, reader R } shares := []*vaultcommon.EncryptedShares{} + wireEnc := r.ciphertextStringEncoding(ctx) for _, pk := range secretRequest.EncryptionKeys { - encShare, err := sh.encryptWithKey(pk) + encShare, err := sh.encryptWithKey(pk, wireEnc) if err != nil { return nil, err } @@ -771,11 +798,21 @@ func (r *ReportingPlugin) observeGetSecretsRequest(ctx context.Context, reader R }) } + var encVal string + switch wireEnc { + case vaultutils.CiphertextStringEncodingBase64: + encVal = base64.StdEncoding.EncodeToString(secret.EncryptedSecret) + case vaultutils.CiphertextStringEncodingHex: + encVal = hex.EncodeToString(secret.EncryptedSecret) + default: + return nil, fmt.Errorf("invalid ciphertext string encoding: %d", wireEnc) + } + return &vaultcommon.SecretResponse{ Id: r.canonicalResponseID(ctx, id, orgID), Result: &vaultcommon.SecretResponse_Data{ Data: &vaultcommon.SecretData{ - EncryptedValue: hex.EncodeToString(secret.EncryptedSecret), + EncryptedValue: encVal, EncryptedDecryptionKeyShares: shares, }, }, @@ -844,14 +881,14 @@ func (r *ReportingPlugin) observeCreateSecretRequest(ctx context.Context, reader return id, newUserError("duplicate request for secret identifier " + vaulttypes.KeyFor(id)) } - if ierr := r.validator.ValidateCiphertextSize(ctx, secretRequest.Id.Owner, secretRequest.EncryptedValue); ierr != nil { + if ierr := r.validator.ValidateCiphertextSize(ctx, secretRequest.Id.Owner, secretRequest.EncryptedValue, r.ciphertextStringEncoding(ctx)); ierr != nil { return id, newUserError(ierr.Error()) } if !r.orgIDAsSecretOwnerEnabled(ctx) { orgID = "" } - err = vaultcap.EnsureRightLabelOnSecret(r.cfg.PublicKey, secretRequest.EncryptedValue, workflowOwner, orgID) + err = vaultcap.EnsureRightLabelOnSecret(r.cfg.PublicKey, secretRequest.EncryptedValue, workflowOwner, orgID, r.ciphertextStringEncoding(ctx)) if err != nil { return id, newUserError("failed to verify ciphertext: " + err.Error()) } @@ -1374,7 +1411,7 @@ func (r *ReportingPlugin) validateCreateSecretsObservation(ctx context.Context, idSet[vaulttypes.KeyFor(s.Id)] = true - if err := r.validator.ValidateCiphertextSize(ctx, s.Id.Owner, s.EncryptedValue); err != nil { + if err := r.validator.ValidateCiphertextSize(ctx, s.Id.Owner, s.EncryptedValue, r.ciphertextStringEncoding(ctx)); err != nil { return fmt.Errorf("CreateSecrets request: %w", err) } } @@ -1418,7 +1455,7 @@ func (r *ReportingPlugin) validateUpdateSecretsObservation(ctx context.Context, idSet[vaulttypes.KeyFor(s.Id)] = true - if err := r.validator.ValidateCiphertextSize(ctx, s.Id.Owner, s.EncryptedValue); err != nil { + if err := r.validator.ValidateCiphertextSize(ctx, s.Id.Owner, s.EncryptedValue, r.ciphertextStringEncoding(ctx)); err != nil { return fmt.Errorf("UpdateSecrets request: %w", err) } } @@ -1935,9 +1972,9 @@ func (r *ReportingPlugin) stateTransitionCreateSecretsRequest(ctx context.Contex return resp, newUserError(resp.GetError()) } - encryptedSecret, err := hex.DecodeString(req.EncryptedValue) + encryptedSecret, err := vaultutils.DecodeEncryptedValue(req.EncryptedValue, r.ciphertextStringEncoding(ctx)) if err != nil { - return nil, newUserError("could not decode secret value: invalid hex" + err.Error()) + return nil, newUserError("could not decode secret value: " + err.Error()) } secret, err := store.GetSecret(ctx, req.Id) @@ -2053,9 +2090,9 @@ func (r *ReportingPlugin) stateTransitionUpdateSecretsRequest(ctx context.Contex return resp, newUserError(resp.GetError()) } - encryptedSecret, err := hex.DecodeString(req.EncryptedValue) + encryptedSecret, err := vaultutils.DecodeEncryptedValue(req.EncryptedValue, r.ciphertextStringEncoding(ctx)) if err != nil { - return nil, newUserError("could not decode secret value: invalid hex" + err.Error()) + return nil, newUserError("could not decode secret value: " + err.Error()) } secret, err := store.GetSecret(ctx, req.Id) @@ -2188,6 +2225,38 @@ func (r *ReportingPlugin) Committed(ctx context.Context, seqNr uint64, keyValueR return errors.New("not implemented") } +// hexEncodeGetSecretsResponseForReport returns a deep copy of resp with ciphertext and shares +// converted from base64 to hex strings for the signed OCR report consumed by workflow nodes +// (secrets.go expects hex). It is only used when VaultBase64EncodingEnabled is on, so every +// value is base64 on the wire. The input resp is not modified. +func (r *ReportingPlugin) hexEncodeGetSecretsResponseForReport(resp *vaultcommon.GetSecretsResponse) (*vaultcommon.GetSecretsResponse, error) { + if resp == nil { + return nil, nil + } + out := proto.Clone(resp).(*vaultcommon.GetSecretsResponse) + for i, sr := range out.Responses { + d := sr.GetData() + if d == nil { + continue + } + raw, err := vaultutils.DecodeEncryptedValue(d.EncryptedValue, vaultutils.CiphertextStringEncodingBase64) + if err != nil { + return nil, fmt.Errorf("error decoding encrypted value at index %d: %w", i, err) + } + d.EncryptedValue = hex.EncodeToString(raw) + for _, es := range d.EncryptedDecryptionKeyShares { + for shareIdx, shareStr := range es.Shares { + sb, err := vaultutils.DecodeEncryptedValue(shareStr, vaultutils.CiphertextStringEncodingBase64) + if err != nil { + return nil, fmt.Errorf("error decoding share at index %d: %w", shareIdx, err) + } + es.Shares[shareIdx] = hex.EncodeToString(sb) + } + } + } + return out, nil +} + func (r *ReportingPlugin) Reports(ctx context.Context, seqNr uint64, reportsPlusPrecursor ocr3_1types.ReportsPlusPrecursor) ([]ocr3types.ReportPlus[[]byte], error) { outcomes := &vaultcommon.Outcomes{} err := proto.Unmarshal([]byte(reportsPlusPrecursor), outcomes) @@ -2195,11 +2264,21 @@ func (r *ReportingPlugin) Reports(ctx context.Context, seqNr uint64, reportsPlus return nil, fmt.Errorf("could not unmarshal outcomes: %w", err) } + useBase64 := r.base64EncodingEnabled(ctx) reports := []ocr3types.ReportPlus[[]byte]{} for _, o := range outcomes.Outcomes { switch o.RequestType { case vaultcommon.RequestType_GET_SECRETS: - rep, err := r.generateProtoReport(o.Id, o.RequestType, o.GetGetSecretsResponse()) + resp := o.GetGetSecretsResponse() + if useBase64 { + normalized, herr := r.hexEncodeGetSecretsResponseForReport(resp) + if herr != nil { + r.lggr.Errorw("failed to normalize get secrets response to hex for report", "error", herr, "id", o.Id) + continue + } + resp = normalized + } + rep, err := r.generateProtoReport(o.Id, o.RequestType, resp) if err != nil { r.lggr.Errorw("failed to generate Proto report", "error", err, "id", o.Id) continue @@ -2342,5 +2421,6 @@ func (r *ReportingPlugin) Close() error { r.cfg.MaxRequestBatchSize.Close(), r.cfg.MaxBatchSize.Close(), r.cfg.OrgIDAsSecretOwnerEnabled.Close(), + r.cfg.Base64EncodingEnabled.Close(), ) } diff --git a/core/services/ocr2/plugins/vault/plugin_base64_test.go b/core/services/ocr2/plugins/vault/plugin_base64_test.go new file mode 100644 index 00000000000..0d80a761f5e --- /dev/null +++ b/core/services/ocr2/plugins/vault/plugin_base64_test.go @@ -0,0 +1,277 @@ +package vault + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/smartcontractkit/libocr/offchainreporting2plus/types" + "github.com/smartcontractkit/tdh2/go/tdh2/tdh2easy" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/nacl/box" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" + + vaultcommon "github.com/smartcontractkit/chainlink-common/pkg/capabilities/actions/vault" + + "github.com/smartcontractkit/chainlink/v2/core/capabilities/vault/vaulttypes" +) + +func TestHexEncodeGetSecretsResponseForReport(t *testing.T) { + r := newTestReportingPlugin(t) + raw := []byte{1, 2, 3, 4, 5} + b64ev := base64.StdEncoding.EncodeToString(raw) + b64share := base64.StdEncoding.EncodeToString([]byte{9, 9, 9}) + resp := &vaultcommon.GetSecretsResponse{ + Responses: []*vaultcommon.SecretResponse{ + { + Id: &vaultcommon.SecretIdentifier{Key: "k", Namespace: "n", Owner: "o"}, + Result: &vaultcommon.SecretResponse_Data{ + Data: &vaultcommon.SecretData{ + EncryptedValue: b64ev, + EncryptedDecryptionKeyShares: []*vaultcommon.EncryptedShares{ + {EncryptionKey: "pk", Shares: []string{b64share}}, + }, + }, + }, + }, + }, + } + got, err := r.hexEncodeGetSecretsResponseForReport(resp) + require.NoError(t, err) + require.Equal(t, b64ev, resp.Responses[0].GetData().EncryptedValue) + require.Equal(t, b64share, resp.Responses[0].GetData().EncryptedDecryptionKeyShares[0].Shares[0]) + require.Equal(t, hex.EncodeToString(raw), got.Responses[0].GetData().EncryptedValue) + require.Equal(t, hex.EncodeToString([]byte{9, 9, 9}), got.Responses[0].GetData().EncryptedDecryptionKeyShares[0].Shares[0]) +} + +func TestPlugin_Observation_GetSecrets_Base64EncodingEnabled(t *testing.T) { + _, pk, shares, err := tdh2easy.GenerateKeys(1, 3) + require.NoError(t, err) + r := newTestReportingPlugin(t, withKeys(pk, shares[0]), withBase64EncodingEnabled()) + + owner := "0x0001020304050607080900010203040506070809" + id := &vaultcommon.SecretIdentifier{ + Owner: owner, + Namespace: "main", + Key: "my_secret", + } + rdr := &kv{m: make(map[string]response)} + + plaintext := []byte("my-secret-value") + var label [32]byte + ownerAddress := common.HexToAddress(owner) + copy(label[12:], ownerAddress.Bytes()) + ciphertext, err := tdh2easy.EncryptWithLabel(pk, plaintext, label) + require.NoError(t, err) + ciphertextBytes, err := ciphertext.Marshal() + require.NoError(t, err) + + err = newTestWriteStore(t, rdr).WriteSecret(t.Context(), id, &vaultcommon.StoredSecret{ + EncryptedSecret: ciphertextBytes, + }) + require.NoError(t, err) + + pubK, _, err := box.GenerateKey(rand.Reader) + require.NoError(t, err) + pks := hex.EncodeToString(pubK[:]) + + p := &vaultcommon.GetSecretsRequest{ + Requests: []*vaultcommon.SecretRequest{ + {Id: id, EncryptionKeys: []string{pks}}, + }, + WorkflowOwner: owner, + } + anyp, err := anypb.New(p) + require.NoError(t, err) + err = newTestWriteStore(t, rdr).WritePendingQueue(t.Context(), + []*vaultcommon.StoredPendingQueueItem{ + {Id: "request-1", Item: anyp}, + }, + ) + require.NoError(t, err) + + data, err := r.Observation(t.Context(), 1, types.AttributedQuery{}, rdr, &blobber{}) + require.NoError(t, err) + + obs := &vaultcommon.Observations{} + require.NoError(t, proto.Unmarshal(data, obs)) + require.Len(t, obs.Observations, 1) + + batchResp := obs.Observations[0].GetGetSecretsResponse() + require.Len(t, batchResp.Responses, 1) + resp := batchResp.Responses[0].GetData() + require.NotNil(t, resp) + + _, err = base64.StdEncoding.DecodeString(resp.EncryptedValue) + require.NoError(t, err, "EncryptedValue should be base64 when flag is enabled") + require.Len(t, resp.EncryptedDecryptionKeyShares, 1) + _, err = base64.StdEncoding.DecodeString(resp.EncryptedDecryptionKeyShares[0].Shares[0]) + require.NoError(t, err, "share should be base64 when flag is enabled") +} + +func TestStateTransitionCreateSecretsRequest_DecodesBase64EncryptedValue(t *testing.T) { + _, pk, shares, err := tdh2easy.GenerateKeys(1, 3) + require.NoError(t, err) + r := newTestReportingPlugin(t, withKeys(pk, shares[0]), withBase64EncodingEnabled()) + + owner := "0x0001020304050607080900010203040506070809" + id := &vaultcommon.SecretIdentifier{Owner: owner, Namespace: "main", Key: "k"} + plaintext := []byte("secret") + var label [32]byte + copy(label[12:], common.HexToAddress(owner).Bytes()) + ct, err := tdh2easy.EncryptWithLabel(pk, plaintext, label) + require.NoError(t, err) + rawCipher, err := ct.Marshal() + require.NoError(t, err) + b64 := base64.StdEncoding.EncodeToString(rawCipher) + + rdr := &kv{m: make(map[string]response)} + store := newTestWriteStore(t, rdr) + + resp := &vaultcommon.CreateSecretResponse{Id: id, Success: false, Error: ""} + out, err := r.stateTransitionCreateSecretsRequest(t.Context(), store, &vaultcommon.EncryptedSecret{ + Id: id, EncryptedValue: b64, + }, resp, "") + require.NoError(t, err) + require.True(t, out.Success) + + stored, err := store.GetSecret(t.Context(), id) + require.NoError(t, err) + require.NotNil(t, stored) + require.Equal(t, rawCipher, stored.EncryptedSecret) +} + +func TestStateTransitionUpdateSecretsRequest_DecodesBase64EncryptedValue(t *testing.T) { + _, pk, shares, err := tdh2easy.GenerateKeys(1, 3) + require.NoError(t, err) + r := newTestReportingPlugin(t, withKeys(pk, shares[0]), withBase64EncodingEnabled()) + + owner := "0x0001020304050607080900010203040506070809" + id := &vaultcommon.SecretIdentifier{Owner: owner, Namespace: "main", Key: "k"} + plaintext := []byte("original") + var label [32]byte + copy(label[12:], common.HexToAddress(owner).Bytes()) + ct, err := tdh2easy.EncryptWithLabel(pk, plaintext, label) + require.NoError(t, err) + rawCipherOriginal, err := ct.Marshal() + require.NoError(t, err) + + rdr := &kv{m: make(map[string]response)} + store := newTestWriteStore(t, rdr) + require.NoError(t, store.WriteSecret(t.Context(), id, &vaultcommon.StoredSecret{EncryptedSecret: rawCipherOriginal})) + + plaintext2 := []byte("updated") + ct2, err := tdh2easy.EncryptWithLabel(pk, plaintext2, label) + require.NoError(t, err) + rawCipherNew, err := ct2.Marshal() + require.NoError(t, err) + b64 := base64.StdEncoding.EncodeToString(rawCipherNew) + + resp := &vaultcommon.UpdateSecretResponse{Id: id, Success: false, Error: ""} + out, err := r.stateTransitionUpdateSecretsRequest(t.Context(), store, &vaultcommon.EncryptedSecret{ + Id: id, EncryptedValue: b64, + }, resp, "") + require.NoError(t, err) + require.True(t, out.Success) + + stored, err := store.GetSecret(t.Context(), id) + require.NoError(t, err) + require.NotNil(t, stored) + require.Equal(t, rawCipherNew, stored.EncryptedSecret) +} + +func TestPlugin_Reports_GetSecrets_Base64Outcome_NormalizesToHexInReport(t *testing.T) { + rawCipher := []byte{1, 2, 3, 10, 11} + rawShare1 := []byte{9} + rawShare2 := []byte{8, 7} + rawCipher2 := []byte{5, 6} + + id := &vaultcommon.SecretIdentifier{Owner: "o", Namespace: "main", Key: "a"} + id2 := &vaultcommon.SecretIdentifier{Owner: "o", Namespace: "main", Key: "b"} + id3 := &vaultcommon.SecretIdentifier{Owner: "o", Namespace: "main", Key: "c"} + + resp := &vaultcommon.GetSecretsResponse{ + Responses: []*vaultcommon.SecretResponse{ + { + Id: id, + Result: &vaultcommon.SecretResponse_Data{ + Data: &vaultcommon.SecretData{ + EncryptedValue: base64.StdEncoding.EncodeToString(rawCipher), + EncryptedDecryptionKeyShares: []*vaultcommon.EncryptedShares{ + { + EncryptionKey: "pk1", + Shares: []string{ + base64.StdEncoding.EncodeToString(rawShare1), + base64.StdEncoding.EncodeToString(rawShare2), + }, + }, + }, + }, + }, + }, + { + Id: id2, + Result: &vaultcommon.SecretResponse_Data{ + Data: &vaultcommon.SecretData{ + EncryptedValue: base64.StdEncoding.EncodeToString(rawCipher2), + }, + }, + }, + { + Id: id3, + Result: &vaultcommon.SecretResponse_Error{Error: "boom"}, + }, + }, + } + + req := &vaultcommon.GetSecretsRequest{Requests: []*vaultcommon.SecretRequest{{Id: id}}} + out := &vaultcommon.Outcome{ + Id: vaulttypes.KeyFor(id), + RequestType: vaultcommon.RequestType_GET_SECRETS, + Request: &vaultcommon.Outcome_GetSecretsRequest{GetSecretsRequest: req}, + Response: &vaultcommon.Outcome_GetSecretsResponse{GetSecretsResponse: resp}, + } + os := &vaultcommon.Outcomes{Outcomes: []*vaultcommon.Outcome{out}} + osb, err := proto.Marshal(os) + require.NoError(t, err) + + rp := newTestReportingPlugin(t, withBase64EncodingEnabled()) + reports, err := rp.Reports(t.Context(), 1, osb) + require.NoError(t, err) + require.Len(t, reports, 1) + + info, err := extractReportInfo(reports[0].ReportWithInfo) + require.NoError(t, err) + require.Equal(t, vaultcommon.ReportFormat_REPORT_FORMAT_PROTOBUF, info.Format) + require.Equal(t, vaultcommon.RequestType_GET_SECRETS, info.RequestType) + + got := &vaultcommon.GetSecretsResponse{} + require.NoError(t, proto.Unmarshal(reports[0].ReportWithInfo.Report, got)) + require.Len(t, got.Responses, 3) + + d0 := got.Responses[0].GetData() + require.NotNil(t, d0) + dec0, err := hex.DecodeString(d0.EncryptedValue) + require.NoError(t, err) + require.Equal(t, rawCipher, dec0) + require.Len(t, d0.EncryptedDecryptionKeyShares, 1) + sh := d0.EncryptedDecryptionKeyShares[0].Shares + require.Len(t, sh, 2) + s0, err := hex.DecodeString(sh[0]) + require.NoError(t, err) + require.Equal(t, rawShare1, s0) + s1, err := hex.DecodeString(sh[1]) + require.NoError(t, err) + require.Equal(t, rawShare2, s1) + + d1 := got.Responses[1].GetData() + require.NotNil(t, d1) + dec1, err := hex.DecodeString(d1.EncryptedValue) + require.NoError(t, err) + require.Equal(t, rawCipher2, dec1) + + require.Equal(t, "boom", got.Responses[2].GetError()) +} diff --git a/core/services/ocr2/plugins/vault/plugin_helpers_test.go b/core/services/ocr2/plugins/vault/plugin_helpers_test.go index c19dfe62a14..9df7974909d 100644 --- a/core/services/ocr2/plugins/vault/plugin_helpers_test.go +++ b/core/services/ocr2/plugins/vault/plugin_helpers_test.go @@ -34,6 +34,7 @@ type testPluginBuildOpts struct { maxRequestBatchSize int batchSize int orgIDAsSecretOwnerEnabled bool + base64EncodingEnabled bool marshalBlob func(ocr3_1types.BlobHandle) ([]byte, error) unmarshalBlob func([]byte) (ocr3_1types.BlobHandle, error) } @@ -73,6 +74,10 @@ func withOrgIDEnabled() testPluginOption { return func(o *testPluginBuildOpts) { o.orgIDAsSecretOwnerEnabled = true } } +func withBase64EncodingEnabled() testPluginOption { + return func(o *testPluginBuildOpts) { o.base64EncodingEnabled = true } +} + func withOnchainCfg(n int, f int) testPluginOption { return func(o *testPluginBuildOpts) { o.onchainCfg = ocr3types.ReportingPluginConfig{N: n, F: f} @@ -117,6 +122,9 @@ func newTestReportingPlugin(t *testing.T, opts ...testPluginOption) *ReportingPl if o.orgIDAsSecretOwnerEnabled { cfg.OrgIDAsSecretOwnerEnabled = limits.NewGateLimiter(true) } + if o.base64EncodingEnabled { + cfg.Base64EncodingEnabled = limits.NewGateLimiter(true) + } return &ReportingPlugin{ lggr: o.lggr, store: o.store, @@ -188,6 +196,7 @@ func makeReportingPluginConfig( MaxIdentifierKeyLengthBytes: keyLimiter, MaxRequestBatchSize: requestBatchSizeLimiter, OrgIDAsSecretOwnerEnabled: limits.NewGateLimiter(false), + Base64EncodingEnabled: limits.NewGateLimiter(false), } } diff --git a/core/services/ocr2/plugins/vault/plugin_test.go b/core/services/ocr2/plugins/vault/plugin_test.go index 438adf75261..daf68902ef8 100644 --- a/core/services/ocr2/plugins/vault/plugin_test.go +++ b/core/services/ocr2/plugins/vault/plugin_test.go @@ -6590,7 +6590,7 @@ func TestPlugin_MaxShareSize(t *testing.T) { share, err := generatePlaintextShare(pk, shares[0], ctb, owner, "") require.NoError(t, err) - eds, err := share.encryptWithKey(hex.EncodeToString(recipientPub[:])) + eds, err := share.encryptWithKey(hex.EncodeToString(recipientPub[:]), vaultutils.CiphertextStringEncodingHex) require.NoError(t, err) assert.GreaterOrEqual(t, expectedSize, len(eds), "share size should be constant regardless of plaintext size (plaintext=%d bytes)", len(plaintext)) diff --git a/system-tests/tests/smoke/cre/cre_suite_test.go b/system-tests/tests/smoke/cre/cre_suite_test.go index 9a4c28fe64e..763c528ad3c 100644 --- a/system-tests/tests/smoke/cre/cre_suite_test.go +++ b/system-tests/tests/smoke/cre/cre_suite_test.go @@ -151,6 +151,8 @@ func runV2SuiteScenario(t *testing.T, topology string, scenario v2suite_config.S vaultConfig = getVaultJWTAuthEnabledTestConfig(t) allowlistSubtestName = "allowlist_auth_when_jwt_auth_enabled" jwtSubtestName = "jwt_auth_when_jwt_auth_enabled" + } else if isVaultBase64EncodingEnabledTopology(topology) { + vaultConfig = getVaultBase64EncodingEnabledTestConfig(t) } fixture := setupVaultSharedScenarioFixture(t, vaultConfig) allowlistEnv := fixture.TestEnv diff --git a/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go b/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go index 8c497f5a820..232aed2030b 100644 --- a/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go +++ b/system-tests/tests/smoke/cre/v2_vault_don_test_helpers.go @@ -51,9 +51,10 @@ import ( ) const ( - vaultDefaultConfigPath = "/configs/workflow-gateway-capabilities-don.toml" - vaultJWTAuthEnabledConfigPath = "/configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml" - vaultJWTIssuerListenAddr = "0.0.0.0:18123" + vaultDefaultConfigPath = "/configs/workflow-gateway-capabilities-don.toml" + vaultJWTAuthEnabledConfigPath = "/configs/workflow-gateway-capabilities-don-vault-jwt_auth-enabled.toml" + vaultBase64EncodingEnabledConfigPath = "/configs/workflow-gateway-capabilities-don-vault-base64-enabled.toml" + vaultJWTIssuerListenAddr = "0.0.0.0:18123" ) func FetchVaultPublicKey(t *testing.T, gatewayURL string) (publicKey string) { @@ -207,10 +208,20 @@ func getVaultDefaultTestConfig(t *testing.T) *ttypes.TestConfig { return t_helpers.GetTestConfig(t, vaultDefaultConfigPath) } +func getVaultBase64EncodingEnabledTestConfig(t *testing.T) *ttypes.TestConfig { + t.Helper() + + return t_helpers.GetTestConfig(t, vaultBase64EncodingEnabledConfigPath) +} + func isVaultJWTAuthEnabledTopology(topologyName string) bool { return strings.Contains(topologyName, "vault-jwt_auth-enabled") } +func isVaultBase64EncodingEnabledTopology(topologyName string) bool { + return strings.Contains(topologyName, "vault-base64-enabled") +} + func setupVaultScenarioFixture(t *testing.T, baseConfig *ttypes.TestConfig, usePerTestKeys bool) *vaultScenarioFixture { t.Helper() @@ -376,6 +387,7 @@ func sendVaultSignedOCRRequestToGateway(t *testing.T, gatewayURL string, jsonReq } statusCode, httpResponseBody := sendVaultRequestToGatewayWithHeaders(t, gatewayURL, requestBody, headers) + framework.L.Info().Msgf("DEBUGGING: Gateway response: %s", string(httpResponseBody)) require.Equal(t, http.StatusOK, statusCode, "Gateway endpoint should respond with 200 OK") var jsonResponse jsonrpc.Response[vaulttypes.SignedOCRResponse]