diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 16c321251a..21ed1a5e53 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -633,6 +633,9 @@ EncryptionKeys: User: EncryptionKeyID: "userKey" # ZITADEL_ENCRYPTIONKEYS_USER_ENCRYPTIONKEYID DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_USER_DECRYPTIONKEYIDS (comma separated list) + Target: + EncryptionKeyID: "targetKey" # ZITADEL_ENCRYPTIONKEYS_TARGET_ENCRYPTIONKEYID + DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_TARGET_DECRYPTIONKEYIDS (comma separated list) CSRFCookieKeyID: "csrfCookieKey" # ZITADEL_ENCRYPTIONKEYS_CSRFCOOKIEKEYID UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID @@ -910,6 +913,12 @@ DefaultInstance: IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEUPPERLETTERS IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEDIGITS IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDESYMBOLS + SigningKey: + Length: 36 # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_LENGTH + IncludeLowerLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDELOWERLETTERS + IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDEUPPERLETTERS + IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDEDIGITS + IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_SIGNINGKEY_INCLUDESYMBOLS PasswordComplexityPolicy: MinLength: 8 # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_MINLENGTH HasLowercase: true # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASLOWERCASE diff --git a/cmd/encryption/encryption_keys.go b/cmd/encryption/encryption_keys.go index b4772e7957..9a26d572c0 100644 --- a/cmd/encryption/encryption_keys.go +++ b/cmd/encryption/encryption_keys.go @@ -17,6 +17,7 @@ var ( "smsKey", "smtpKey", "userKey", + "targetKey", "csrfCookieKey", "userAgentCookieKey", } @@ -31,6 +32,7 @@ type EncryptionKeyConfig struct { SMS *crypto.KeyConfig SMTP *crypto.KeyConfig User *crypto.KeyConfig + Target *crypto.KeyConfig CSRFCookieKeyID string UserAgentCookieKeyID string } @@ -44,6 +46,7 @@ type EncryptionKeys struct { SMS crypto.EncryptionAlgorithm SMTP crypto.EncryptionAlgorithm User crypto.EncryptionAlgorithm + Target crypto.EncryptionAlgorithm CSRFCookieKey []byte UserAgentCookieKey []byte OIDCKey []byte @@ -91,6 +94,10 @@ func EnsureEncryptionKeys(ctx context.Context, keyConfig *EncryptionKeyConfig, k if err != nil { return nil, err } + keys.Target, err = crypto.NewAESCrypto(keyConfig.Target, keyStorage) + if err != nil { + return nil, err + } key, err = crypto.LoadKey(keyConfig.CSRFCookieKeyID, keyStorage) if err != nil { return nil, err diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index f849d01217..ae903d90c5 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -145,6 +145,7 @@ func projections( keys.OTP, keys.OIDC, keys.SAML, + keys.Target, config.InternalAuthZ.RolePermissionMappings, sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { @@ -183,6 +184,7 @@ func projections( keys.DomainVerification, keys.OIDC, keys.SAML, + keys.Target, &http.Client{}, func(ctx context.Context, permission, orgID, resourceID string) (err error) { return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 4d4231ea9c..588ac71610 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -86,6 +86,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error nil, nil, nil, + nil, 0, 0, 0, diff --git a/cmd/setup/config_change.go b/cmd/setup/config_change.go index f38508af2c..fb3ae08d52 100644 --- a/cmd/setup/config_change.go +++ b/cmd/setup/config_change.go @@ -53,6 +53,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context, _ eventstore.Event nil, nil, nil, + nil, 0, 0, 0, diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 1ad3037009..e9721c6b39 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -366,6 +366,7 @@ func initProjections( keys.OTP, keys.OIDC, keys.SAML, + keys.Target, config.InternalAuthZ.RolePermissionMappings, sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { @@ -422,6 +423,7 @@ func initProjections( keys.DomainVerification, keys.OIDC, keys.SAML, + keys.Target, &http.Client{}, permissionCheck, sessionTokenVerifier, diff --git a/cmd/start/start.go b/cmd/start/start.go index c9147fe653..38a8450b46 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -196,6 +196,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.OTP, keys.OIDC, keys.SAML, + keys.Target, config.InternalAuthZ.RolePermissionMappings, sessionTokenVerifier, func(q *query.Queries) domain.PermissionCheck { @@ -245,6 +246,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server keys.DomainVerification, keys.OIDC, keys.SAML, + keys.Target, &http.Client{}, permissionCheck, sessionTokenVerifier, diff --git a/docs/docs/apis/actions/v3/testing-locally.md b/docs/docs/apis/actions/v3/testing-locally.md index 7662c2bfe0..b5b3cb389f 100644 --- a/docs/docs/apis/actions/v3/testing-locally.md +++ b/docs/docs/apis/actions/v3/testing-locally.md @@ -48,6 +48,20 @@ func main() { What happens here is only a target which prints out the received request, which could also be handled with a different logic. +### Check Signature + +To additionally check the signature header you can add the following to the example: +```go + // validate signature + if err := actions.ValidatePayload(sentBody, req.Header.Get(actions.SigningHeader), signingKey); err != nil { + // if the signed content is not equal the sent content return an error + http.Error(w, "error", http.StatusInternalServerError) + return + } +``` + +Where you can replace 'signingKey' with the key received in the next step 'Create target'. + ## Create target As you see in the example above the target is created with HTTP and port '8090' and if we want to use it as webhook, the target can be created as follows: diff --git a/docs/docs/apis/actions/v3/usage.md b/docs/docs/apis/actions/v3/usage.md index 686c9d5445..2e89f3ce36 100644 --- a/docs/docs/apis/actions/v3/usage.md +++ b/docs/docs/apis/actions/v3/usage.md @@ -64,6 +64,13 @@ There are different types of Targets: The API documentation to create a target can be found [here](/apis/resources/action_service_v3/zitadel-actions-create-target) +### Content Signing + +To ensure the integrity of request content, each call includes a 'ZITADEL-Signature' in the headers. This header contains an HMAC value computed from the request content and a timestamp, which can be used to time out requests. The logic for this process is provided in 'pkg/actions/signing.go'. The goal is to verify that the HMAC value in the header matches the HMAC value computed by the Target, ensuring that the sent and received requests are identical. + +Each Target resource now contains also a Signing Key, which gets generated and returned when a Target is [created](/apis/resources/action_service_v3/zitadel-actions-create-target), +and can also be newly generated when a Target is [patched](/apis/resources/action_service_v3/zitadel-actions-patch-target). + ## Execution ZITADEL decides on specific conditions if one or more Targets have to be called. diff --git a/docs/package.json b/docs/package.json index e322206563..f9636418dd 100644 --- a/docs/package.json +++ b/docs/package.json @@ -17,7 +17,7 @@ "generate:grpc": "buf generate ../proto", "generate:apidocs": "docusaurus gen-api-docs all", "generate:configdocs": "cp -r ../cmd/defaults.yaml ./docs/self-hosting/manage/configure/ && cp -r ../cmd/setup/steps.yaml ./docs/self-hosting/manage/configure/", - "generate:re-gen": "yarn clean-all && yarn gen-all", + "generate:re-gen": "yarn generate:clean-all && yarn generate", "generate:clean-all": "docusaurus clean-api-docs all" }, "dependencies": { diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go index 23fb860cd3..aa748ac4d8 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/query_test.go @@ -62,6 +62,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -92,6 +93,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -122,6 +124,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -154,6 +157,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -186,6 +190,7 @@ func TestServer_GetTarget(t *testing.T) { request.Id = resp.GetDetails().GetId() response.Target.Config.Name = name response.Target.Details = resp.GetDetails() + response.Target.SigningKey = resp.GetSigningKey() return nil }, req: &action.GetTargetRequest{}, @@ -230,6 +235,7 @@ func TestServer_GetTarget(t *testing.T) { gotTarget := got.GetTarget() integration.AssertResourceDetails(ttt, wantTarget.GetDetails(), gotTarget.GetDetails()) assert.EqualExportedValues(ttt, wantTarget.GetConfig(), gotTarget.GetConfig()) + assert.Equal(ttt, wantTarget.GetSigningKey(), gotTarget.GetSigningKey()) }, retryDuration, tick, "timeout waiting for expected target result") }) } @@ -492,6 +498,7 @@ func TestServer_ListTargets(t *testing.T) { for i := range tt.want.Result { integration.AssertResourceDetails(ttt, tt.want.Result[i].GetDetails(), got.Result[i].GetDetails()) assert.EqualExportedValues(ttt, tt.want.Result[i].GetConfig(), got.Result[i].GetConfig()) + assert.NotEmpty(ttt, got.Result[i].GetSigningKey()) } } integration.AssertResourceListDetails(ttt, tt.want, got) diff --git a/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go b/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go index 04fa60982d..b5d1903ca6 100644 --- a/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go +++ b/internal/api/grpc/resources/action/v3alpha/integration_test/target_test.go @@ -9,6 +9,7 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -200,11 +201,12 @@ func TestServer_CreateTarget(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := instance.Client.ActionV3Alpha.CreateTarget(tt.ctx, &action.CreateTargetRequest{Target: tt.req}) if tt.wantErr { - require.Error(t, err) - return + assert.Error(t, err) + } else { + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) + assert.NotEmpty(t, got.GetSigningKey()) } - require.NoError(t, err) - integration.AssertResourceDetails(t, tt.want, got.Details) }) } } @@ -217,11 +219,15 @@ func TestServer_PatchTarget(t *testing.T) { ctx context.Context req *action.PatchTargetRequest } + type want struct { + details *resource_object.Details + signingKey bool + } tests := []struct { name string prepare func(request *action.PatchTargetRequest) error args args - want *resource_object.Details + want want wantErr bool }{ { @@ -272,14 +278,42 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, + { + name: "regenerate signingkey, ok", + prepare: func(request *action.PatchTargetRequest) error { + targetID := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false).GetDetails().GetId() + request.Id = targetID + return nil + }, + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &action.PatchTargetRequest{ + Target: &action.PatchTarget{ + ExpirationSigningKey: durationpb.New(0 * time.Second), + }, + }, + }, + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, + }, + signingKey: true, + }, + }, { name: "change type, ok", prepare: func(request *action.PatchTargetRequest) error { @@ -299,11 +333,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -322,11 +358,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -345,11 +383,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -370,11 +410,13 @@ func TestServer_PatchTarget(t *testing.T) { }, }, }, - want: &resource_object.Details{ - Changed: timestamppb.Now(), - Owner: &object.Owner{ - Type: object.OwnerType_OWNER_TYPE_INSTANCE, - Id: instance.ID(), + want: want{ + details: &resource_object.Details{ + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: instance.ID(), + }, }, }, }, @@ -387,11 +429,14 @@ func TestServer_PatchTarget(t *testing.T) { instance.Client.ActionV3Alpha.PatchTarget(tt.args.ctx, tt.args.req) got, err := instance.Client.ActionV3Alpha.PatchTarget(tt.args.ctx, tt.args.req) if tt.wantErr { - require.Error(t, err) - return + assert.Error(t, err) + } else { + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.want.details, got.Details) + if tt.want.signingKey { + assert.NotEmpty(t, got.SigningKey) + } } - require.NoError(t, err) - integration.AssertResourceDetails(t, tt.want, got.Details) }) } } @@ -443,11 +488,12 @@ func TestServer_DeleteTarget(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := instance.Client.ActionV3Alpha.DeleteTarget(tt.ctx, tt.req) if tt.wantErr { - require.Error(t, err) + assert.Error(t, err) return + } else { + assert.NoError(t, err) + integration.AssertResourceDetails(t, tt.want, got.Details) } - require.NoError(t, err) - integration.AssertResourceDetails(t, tt.want, got.Details) }) } } diff --git a/internal/api/grpc/resources/action/v3alpha/query.go b/internal/api/grpc/resources/action/v3alpha/query.go index ec7ed8b9c8..7cdedd8134 100644 --- a/internal/api/grpc/resources/action/v3alpha/query.go +++ b/internal/api/grpc/resources/action/v3alpha/query.go @@ -97,6 +97,7 @@ func targetToPb(t *query.Target) *action.GetTarget { Timeout: durationpb.New(t.Timeout), Endpoint: t.Endpoint, }, + SigningKey: t.SigningKey, } switch t.TargetType { case domain.TargetTypeWebhook: diff --git a/internal/api/grpc/resources/action/v3alpha/target.go b/internal/api/grpc/resources/action/v3alpha/target.go index 031cd99477..621b6677b7 100644 --- a/internal/api/grpc/resources/action/v3alpha/target.go +++ b/internal/api/grpc/resources/action/v3alpha/target.go @@ -25,7 +25,8 @@ func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetReque return nil, err } return &action.CreateTargetResponse{ - Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + SigningKey: add.SigningKey, }, nil } @@ -34,12 +35,14 @@ func (s *Server) PatchTarget(ctx context.Context, req *action.PatchTargetRequest return nil, err } instanceID := authz.GetInstance(ctx).InstanceID() - details, err := s.command.ChangeTarget(ctx, patchTargetToCommand(req), instanceID) + patch := patchTargetToCommand(req) + details, err := s.command.ChangeTarget(ctx, patch, instanceID) if err != nil { return nil, err } return &action.PatchTargetResponse{ - Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + SigningKey: patch.SigningKey, }, nil } @@ -83,6 +86,12 @@ func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { } func patchTargetToCommand(req *action.PatchTargetRequest) *command.ChangeTarget { + expirationSigningKey := false + // TODO handle expiration, currently only immediate expiration is supported + if req.GetTarget().GetExpirationSigningKey() != nil { + expirationSigningKey = true + } + reqTarget := req.GetTarget() if reqTarget == nil { return nil @@ -91,8 +100,9 @@ func patchTargetToCommand(req *action.PatchTargetRequest) *command.ChangeTarget ObjectRoot: models.ObjectRoot{ AggregateID: req.GetId(), }, - Name: reqTarget.Name, - Endpoint: reqTarget.Endpoint, + Name: reqTarget.Name, + Endpoint: reqTarget.Endpoint, + ExpirationSigningKey: expirationSigningKey, } if reqTarget.TargetType != nil { switch t := reqTarget.GetTargetType().(type) { diff --git a/internal/api/grpc/server/middleware/execution_interceptor_test.go b/internal/api/grpc/server/middleware/execution_interceptor_test.go index f59fd00441..6a5b74c5e4 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/middleware/execution_interceptor_test.go @@ -26,6 +26,7 @@ type mockExecutionTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + SigningKey string } func (e *mockExecutionTarget) SetEndpoint(endpoint string) { @@ -49,6 +50,9 @@ func (e *mockExecutionTarget) GetTargetID() string { func (e *mockExecutionTarget) GetExecutionID() string { return e.ExecutionID } +func (e *mockExecutionTarget) GetSigningKey() string { + return e.SigningKey +} type mockContentRequest struct { Content string @@ -157,6 +161,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetID: "target", TargetType: domain.TargetTypeCall, Timeout: time.Minute, + SigningKey: "signingkey", }, }, targets: []target{ @@ -186,6 +191,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, @@ -216,6 +222,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -245,6 +252,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -269,6 +277,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -297,6 +306,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetID: "target", TargetType: domain.TargetTypeAsync, Timeout: time.Second, + SigningKey: "signingkey", }, }, targets: []target{ @@ -325,6 +335,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetID: "target", TargetType: domain.TargetTypeAsync, Timeout: time.Minute, + SigningKey: "signingkey", }, }, targets: []target{ @@ -354,6 +365,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -382,6 +394,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeWebhook, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -411,6 +424,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeWebhook, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -440,6 +454,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -448,6 +463,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -456,6 +472,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, @@ -498,6 +515,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -506,6 +524,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, &mockExecutionTarget{ InstanceID: "instance", @@ -514,6 +533,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Second, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -692,6 +712,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ @@ -721,6 +742,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { TargetType: domain.TargetTypeCall, Timeout: time.Minute, InterruptOnError: true, + SigningKey: "signingkey", }, }, targets: []target{ diff --git a/internal/command/action_v2_execution_test.go b/internal/command/action_v2_execution_test.go index be05929695..6833125a0a 100644 --- a/internal/command/action_v2_execution_test.go +++ b/internal/command/action_v2_execution_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/execution" @@ -172,6 +173,12 @@ func TestCommands_SetExecutionRequest(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), @@ -221,6 +228,12 @@ func TestCommands_SetExecutionRequest(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), @@ -270,6 +283,12 @@ func TestCommands_SetExecutionRequest(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), @@ -836,6 +855,12 @@ func TestCommands_SetExecutionResponse(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), expectPushFailed( @@ -930,6 +955,12 @@ func TestCommands_SetExecutionResponse(t *testing.T) { "https://example.com", time.Second, true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), diff --git a/internal/command/action_v2_target.go b/internal/command/action_v2_target.go index d1f06b79b2..95dd097ed0 100644 --- a/internal/command/action_v2_target.go +++ b/internal/command/action_v2_target.go @@ -5,6 +5,8 @@ import ( "net/url" "time" + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/target" @@ -19,6 +21,8 @@ type AddTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + + SigningKey string } func (a *AddTarget) IsValid() error { @@ -58,7 +62,11 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner if wm.State.Exists() { return nil, zerrors.ThrowAlreadyExists(nil, "INSTANCE-9axkz0jvzm", "Errors.Target.AlreadyExists") } - + code, err := c.newSigningKey(ctx, c.eventstore.Filter, c.targetEncryption) //nolint + if err != nil { + return nil, err + } + add.SigningKey = code.PlainCode() pushedEvents, err := c.eventstore.Push(ctx, target.NewAddedEvent( ctx, TargetAggregateFromWriteModel(&wm.WriteModel), @@ -67,6 +75,7 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner add.Endpoint, add.Timeout, add.InterruptOnError, + code.Crypted, )) if err != nil { return nil, err @@ -85,6 +94,9 @@ type ChangeTarget struct { Endpoint *string Timeout *time.Duration InterruptOnError *bool + + ExpirationSigningKey bool + SigningKey *string } func (a *ChangeTarget) IsValid() error { @@ -120,6 +132,17 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou if !existing.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-xj14f2cccn", "Errors.Target.NotFound") } + + var changedSigningKey *crypto.CryptoValue + if change.ExpirationSigningKey { + code, err := c.newSigningKey(ctx, c.eventstore.Filter, c.targetEncryption) //nolint + if err != nil { + return nil, err + } + changedSigningKey = code.Crypted + change.SigningKey = &code.Plain + } + changedEvent := existing.NewChangedEvent( ctx, TargetAggregateFromWriteModel(&existing.WriteModel), @@ -127,7 +150,9 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou change.TargetType, change.Endpoint, change.Timeout, - change.InterruptOnError) + change.InterruptOnError, + changedSigningKey, + ) if changedEvent == nil { return writeModelToObjectDetails(&existing.WriteModel), nil } @@ -184,3 +209,7 @@ func (c *Commands) getTargetWriteModelByID(ctx context.Context, id string, resou } return wm, nil } + +func (c *Commands) newSigningKey(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*EncryptedCode, error) { + return c.newEncryptedCodeWithDefault(ctx, filter, domain.SecretGeneratorTypeSigningKey, alg, c.defaultSecretGenerators.SigningKey) +} diff --git a/internal/command/action_v2_target_model.go b/internal/command/action_v2_target_model.go index 24dd76c80a..cf20c9923d 100644 --- a/internal/command/action_v2_target_model.go +++ b/internal/command/action_v2_target_model.go @@ -5,6 +5,7 @@ import ( "slices" "time" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/target" @@ -18,6 +19,7 @@ type TargetWriteModel struct { Endpoint string Timeout time.Duration InterruptOnError bool + SigningKey *crypto.CryptoValue State domain.TargetState } @@ -41,6 +43,7 @@ func (wm *TargetWriteModel) Reduce() error { wm.Endpoint = e.Endpoint wm.Timeout = e.Timeout wm.State = domain.TargetActive + wm.SigningKey = e.SigningKey case *target.ChangedEvent: if e.Name != nil { wm.Name = *e.Name @@ -57,6 +60,9 @@ func (wm *TargetWriteModel) Reduce() error { if e.InterruptOnError != nil { wm.InterruptOnError = *e.InterruptOnError } + if e.SigningKey != nil { + wm.SigningKey = e.SigningKey + } case *target.RemovedEvent: wm.State = domain.TargetRemoved } @@ -84,6 +90,7 @@ func (wm *TargetWriteModel) NewChangedEvent( endpoint *string, timeout *time.Duration, interruptOnError *bool, + signingKey *crypto.CryptoValue, ) *target.ChangedEvent { changes := make([]target.Changes, 0) if name != nil && wm.Name != *name { @@ -101,6 +108,10 @@ func (wm *TargetWriteModel) NewChangedEvent( if interruptOnError != nil && wm.InterruptOnError != *interruptOnError { changes = append(changes, target.ChangeInterruptOnError(*interruptOnError)) } + // if signingkey is set, update it as it is encrypted + if signingKey != nil { + changes = append(changes, target.ChangeSigningKey(signingKey)) + } if len(changes) == 0 { return nil } diff --git a/internal/command/action_v2_target_model_test.go b/internal/command/action_v2_target_model_test.go index 8042da23b1..e8c40c04c8 100644 --- a/internal/command/action_v2_target_model_test.go +++ b/internal/command/action_v2_target_model_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/target" @@ -20,6 +21,12 @@ func targetAddEvent(aggID, resourceOwner string) *target.AddedEvent { "https://example.com", time.Second, false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ) } diff --git a/internal/command/action_v2_target_test.go b/internal/command/action_v2_target_test.go index 12f76c4629..ed7d6163a0 100644 --- a/internal/command/action_v2_target_test.go +++ b/internal/command/action_v2_target_test.go @@ -8,6 +8,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -19,8 +20,10 @@ import ( func TestCommands_AddTarget(t *testing.T) { type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore - idGenerator id.Generator + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -132,10 +135,18 @@ func TestCommands_AddTarget(t *testing.T) { "https://example.com", time.Second, false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }, ), ), ), - idGenerator: mock.ExpectID(t, "id1"), + idGenerator: mock.ExpectID(t, "id1"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -186,7 +197,9 @@ func TestCommands_AddTarget(t *testing.T) { targetAddEvent("id1", "instance"), ), ), - idGenerator: mock.ExpectID(t, "id1"), + idGenerator: mock.ExpectID(t, "id1"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -219,7 +232,9 @@ func TestCommands_AddTarget(t *testing.T) { }(), ), ), - idGenerator: mock.ExpectID(t, "id1"), + idGenerator: mock.ExpectID(t, "id1"), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -244,8 +259,10 @@ func TestCommands_AddTarget(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, } details, err := c.AddTarget(tt.args.ctx, tt.args.add, tt.args.resourceOwner) if tt.res.err == nil { @@ -264,7 +281,9 @@ func TestCommands_AddTarget(t *testing.T) { func TestCommands_ChangeTarget(t *testing.T) { type fields struct { - eventstore func(t *testing.T) *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc + defaultSecretGenerators *SecretGenerators } type args struct { ctx context.Context @@ -510,10 +529,18 @@ func TestCommands_ChangeTarget(t *testing.T) { target.ChangeTargetType(domain.TargetTypeCall), target.ChangeTimeout(10 * time.Second), target.ChangeInterruptOnError(true), + target.ChangeSigningKey(&crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("12345678"), + }), }, ), ), ), + newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour), + defaultSecretGenerators: &SecretGenerators{}, }, args{ ctx: context.Background(), @@ -521,11 +548,12 @@ func TestCommands_ChangeTarget(t *testing.T) { ObjectRoot: models.ObjectRoot{ AggregateID: "id1", }, - Name: gu.Ptr("name2"), - Endpoint: gu.Ptr("https://example2.com"), - TargetType: gu.Ptr(domain.TargetTypeCall), - Timeout: gu.Ptr(10 * time.Second), - InterruptOnError: gu.Ptr(true), + Name: gu.Ptr("name2"), + Endpoint: gu.Ptr("https://example2.com"), + TargetType: gu.Ptr(domain.TargetTypeCall), + Timeout: gu.Ptr(10 * time.Second), + InterruptOnError: gu.Ptr(true), + ExpirationSigningKey: true, }, resourceOwner: "instance", }, @@ -540,7 +568,9 @@ func TestCommands_ChangeTarget(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore(t), + eventstore: tt.fields.eventstore(t), + newEncryptedCodeWithDefault: tt.fields.newEncryptedCodeWithDefault, + defaultSecretGenerators: tt.fields.defaultSecretGenerators, } details, err := c.ChangeTarget(tt.args.ctx, tt.args.change, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/command.go b/internal/command/command.go index bc3f189a4a..ab047fccdb 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -54,6 +54,7 @@ type Commands struct { smtpEncryption crypto.EncryptionAlgorithm smsEncryption crypto.EncryptionAlgorithm userEncryption crypto.EncryptionAlgorithm + targetEncryption crypto.EncryptionAlgorithm userPasswordHasher *crypto.Hasher secretHasher *crypto.Hasher machineKeySize int @@ -108,7 +109,7 @@ func StartCommands( externalDomain string, externalSecure bool, externalPort uint16, - idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption crypto.EncryptionAlgorithm, + idpConfigEncryption, otpEncryption, smtpEncryption, smsEncryption, userEncryption, domainVerificationEncryption, oidcEncryption, samlEncryption, targetEncryption crypto.EncryptionAlgorithm, httpClient *http.Client, permissionCheck domain.PermissionCheck, sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), @@ -153,6 +154,7 @@ func StartCommands( smtpEncryption: smtpEncryption, smsEncryption: smsEncryption, userEncryption: userEncryption, + targetEncryption: targetEncryption, userPasswordHasher: userPasswordHasher, secretHasher: secretHasher, machineKeySize: int(defaults.SecretGenerators.MachineKeySize), diff --git a/internal/command/instance.go b/internal/command/instance.go index 3491aaf4a2..c5ac4d8472 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -157,6 +157,7 @@ type SecretGenerators struct { OTPSMS *crypto.GeneratorConfig OTPEmail *crypto.GeneratorConfig InviteCode *crypto.GeneratorConfig + SigningKey *crypto.GeneratorConfig } type ZitadelConfig struct { diff --git a/internal/domain/secret_generator.go b/internal/domain/secret_generator.go index 855e3447c1..25998bd205 100644 --- a/internal/domain/secret_generator.go +++ b/internal/domain/secret_generator.go @@ -15,6 +15,7 @@ const ( SecretGeneratorTypeOTPSMS SecretGeneratorTypeOTPEmail SecretGeneratorTypeInviteCode + SecretGeneratorTypeSigningKey secretGeneratorTypeCount ) diff --git a/internal/execution/execution.go b/internal/execution/execution.go index c4756b86a2..99d7f6182f 100644 --- a/internal/execution/execution.go +++ b/internal/execution/execution.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/actions" ) type ContextInfo interface { @@ -28,6 +29,7 @@ type Target interface { GetEndpoint() string GetTargetType() domain.TargetType GetTimeout() time.Duration + GetSigningKey() string } // CallTargets call a list of targets in order with handling of error and responses @@ -72,13 +74,13 @@ func CallTarget( switch target.GetTargetType() { // get request, ignore response and return request and error for handling in list of targets case domain.TargetTypeWebhook: - return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()) + return nil, webhook(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()) // get request, return response and error case domain.TargetTypeCall: - return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()) + return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()) case domain.TargetTypeAsync: go func(target Target, info ContextInfoRequest) { - if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody()); err != nil { + if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()); err != nil { logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err) } }(target, info) @@ -89,13 +91,13 @@ func CallTarget( } // webhook call a webhook, ignore the response but return the errror -func webhook(ctx context.Context, url string, timeout time.Duration, body []byte) error { - _, err := Call(ctx, url, timeout, body) +func webhook(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) error { + _, err := Call(ctx, url, timeout, body, signingKey) return err } // Call function to do a post HTTP request to a desired url with timeout -func Call(ctx context.Context, url string, timeout time.Duration, body []byte) (_ []byte, err error) { +func Call(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) (_ []byte, err error) { ctx, cancel := context.WithTimeout(ctx, timeout) ctx, span := tracing.NewSpan(ctx) defer func() { @@ -108,6 +110,9 @@ func Call(ctx context.Context, url string, timeout time.Duration, body []byte) ( return nil, err } req.Header.Set("Content-Type", "application/json") + if signingKey != "" { + req.Header.Set(actions.SigningHeader, actions.ComputeSignatureHeader(time.Now(), body, signingKey)) + } client := http.DefaultClient resp, err := client.Do(req) diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index 184823f9b2..5a45d96625 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/execution" "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/actions" ) func Test_Call(t *testing.T) { @@ -29,6 +30,7 @@ func Test_Call(t *testing.T) { body []byte respBody []byte statusCode int + signingKey string } type res struct { body []byte @@ -84,6 +86,22 @@ func Test_Call(t *testing.T) { body: []byte("{\"response\": \"values\"}"), }, }, + { + "ok, signed", + args{ + ctx: context.Background(), + timeout: time.Minute, + sleep: time.Second, + method: http.MethodPost, + body: []byte("{\"request\": \"values\"}"), + respBody: []byte("{\"response\": \"values\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + res{ + body: []byte("{\"response\": \"values\"}"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -95,7 +113,7 @@ func Test_Call(t *testing.T) { statusCode: tt.args.statusCode, respondBody: tt.args.respBody, }, - testCall(tt.args.ctx, tt.args.timeout, tt.args.body), + testCall(tt.args.ctx, tt.args.timeout, tt.args.body, tt.args.signingKey), ) if tt.res.wantErr { assert.Error(t, err) @@ -186,6 +204,29 @@ func Test_CallTarget(t *testing.T) { body: nil, }, }, + { + "webhook, signed, ok", + args{ + ctx: context.Background(), + info: requestContextInfo1, + server: &callTestServer{ + timeout: time.Second, + method: http.MethodPost, + expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), + respondBody: []byte("{\"request\":\"content2\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + target: &mockTarget{ + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + res{ + body: nil, + }, + }, { "request response, error", args{ @@ -228,6 +269,29 @@ func Test_CallTarget(t *testing.T) { body: []byte("{\"request\":\"content2\"}"), }, }, + { + "request response, signed, ok", + args{ + ctx: context.Background(), + info: requestContextInfo1, + server: &callTestServer{ + timeout: time.Second, + method: http.MethodPost, + expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), + respondBody: []byte("{\"request\":\"content2\"}"), + statusCode: http.StatusOK, + signingKey: "signingkey", + }, + target: &mockTarget{ + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + res{ + body: []byte("{\"request\":\"content2\"}"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -392,6 +456,7 @@ type mockTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + SigningKey string } func (e *mockTarget) GetTargetID() string { @@ -409,6 +474,9 @@ func (e *mockTarget) GetTargetType() domain.TargetType { func (e *mockTarget) GetTimeout() time.Duration { return e.Timeout } +func (e *mockTarget) GetSigningKey() string { + return e.SigningKey +} type callTestServer struct { method string @@ -416,6 +484,7 @@ type callTestServer struct { timeout time.Duration statusCode int respondBody []byte + signingKey string } func testServers( @@ -447,7 +516,7 @@ func listen( c *callTestServer, ) (url string, close func()) { handler := func(w http.ResponseWriter, r *http.Request) { - checkRequest(t, r, c.method, c.expectBody) + checkRequest(t, r, c.method, c.expectBody, c.signingKey) if c.statusCode != http.StatusOK { http.Error(w, "error", c.statusCode) @@ -466,16 +535,19 @@ func listen( return server.URL, server.Close } -func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte) { +func checkRequest(t *testing.T, sent *http.Request, method string, expectedBody []byte, signingKey string) { sentBody, err := io.ReadAll(sent.Body) require.NoError(t, err) require.Equal(t, expectedBody, sentBody) require.Equal(t, method, sent.Method) + if signingKey != "" { + require.NoError(t, actions.ValidatePayload(sentBody, sent.Header.Get(actions.SigningHeader), signingKey)) + } } -func testCall(ctx context.Context, timeout time.Duration, body []byte) func(string) ([]byte, error) { +func testCall(ctx context.Context, timeout time.Duration, body []byte, signingKey string) func(string) ([]byte, error) { return func(url string) ([]byte, error) { - return execution.Call(ctx, url, timeout, body) + return execution.Call(ctx, url, timeout, body, signingKey) } } diff --git a/internal/query/execution.go b/internal/query/execution.go index 5ce5e36a94..b98c680f57 100644 --- a/internal/query/execution.go +++ b/internal/query/execution.go @@ -11,6 +11,7 @@ import ( sq "github.com/Masterminds/squirrel" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query/projection" @@ -175,6 +176,11 @@ func (q *Queries) TargetsByExecutionID(ctx context.Context, ids []string) (execu instanceID, database.TextArray[string](ids), ) + for i := range execution { + if err := execution[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + } return execution, err } @@ -205,6 +211,11 @@ func (q *Queries) TargetsByExecutionIDs(ctx context.Context, ids1, ids2 []string database.TextArray[string](ids1), database.TextArray[string](ids2), ) + for i := range execution { + if err := execution[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + } return execution, err } @@ -352,6 +363,8 @@ type ExecutionTarget struct { Endpoint string Timeout time.Duration InterruptOnError bool + signingKey *crypto.CryptoValue + SigningKey string } func (e *ExecutionTarget) GetExecutionID() string { @@ -372,6 +385,21 @@ func (e *ExecutionTarget) GetTargetType() domain.TargetType { func (e *ExecutionTarget) GetTimeout() time.Duration { return e.Timeout } +func (e *ExecutionTarget) GetSigningKey() string { + return e.SigningKey +} + +func (t *ExecutionTarget) decryptSigningKey(alg crypto.EncryptionAlgorithm) error { + if t.signingKey == nil { + return nil + } + keyValue, err := crypto.DecryptString(t.signingKey, alg) + if err != nil { + return zerrors.ThrowInternal(err, "QUERY-bxevy3YXwy", "Errors.Internal") + } + t.SigningKey = keyValue + return nil +} func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { targets := make([]*ExecutionTarget, 0) @@ -386,6 +414,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { endpoint = &sql.NullString{} timeout = &sql.NullInt64{} interruptOnError = &sql.NullBool{} + signingKey = &crypto.CryptoValue{} ) err := rows.Scan( @@ -396,6 +425,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { endpoint, timeout, interruptOnError, + signingKey, ) if err != nil { @@ -409,6 +439,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) { target.Endpoint = endpoint.String target.Timeout = time.Duration(timeout.Int64) target.InterruptOnError = interruptOnError.Bool + target.signingKey = signingKey targets = append(targets, target) } diff --git a/internal/query/projection/target.go b/internal/query/projection/target.go index d39a75b6dc..acc42b9604 100644 --- a/internal/query/projection/target.go +++ b/internal/query/projection/target.go @@ -11,7 +11,7 @@ import ( ) const ( - TargetTable = "projections.targets1" + TargetTable = "projections.targets2" TargetIDCol = "id" TargetCreationDateCol = "creation_date" TargetChangeDateCol = "change_date" @@ -23,6 +23,7 @@ const ( TargetEndpointCol = "endpoint" TargetTimeoutCol = "timeout" TargetInterruptOnErrorCol = "interrupt_on_error" + TargetSigningKey = "signing_key" ) type targetProjection struct{} @@ -49,6 +50,7 @@ func (*targetProjection) Init() *old_handler.Check { handler.NewColumn(TargetEndpointCol, handler.ColumnTypeText), handler.NewColumn(TargetTimeoutCol, handler.ColumnTypeInt64), handler.NewColumn(TargetInterruptOnErrorCol, handler.ColumnTypeBool), + handler.NewColumn(TargetSigningKey, handler.ColumnTypeJSONB, handler.Nullable()), }, handler.NewPrimaryKey(TargetInstanceIDCol, TargetIDCol), ), @@ -105,6 +107,7 @@ func (p *targetProjection) reduceTargetAdded(event eventstore.Event) (*handler.S handler.NewCol(TargetTargetType, e.TargetType), handler.NewCol(TargetTimeoutCol, e.Timeout), handler.NewCol(TargetInterruptOnErrorCol, e.InterruptOnError), + handler.NewCol(TargetSigningKey, e.SigningKey), }, ), nil } @@ -134,6 +137,9 @@ func (p *targetProjection) reduceTargetChanged(event eventstore.Event) (*handler if e.InterruptOnError != nil { values = append(values, handler.NewCol(TargetInterruptOnErrorCol, *e.InterruptOnError)) } + if e.SigningKey != nil { + values = append(values, handler.NewCol(TargetSigningKey, e.SigningKey)) + } return handler.NewUpdateStatement( e, values, diff --git a/internal/query/projection/target_test.go b/internal/query/projection/target_test.go index 30067c6640..6517e78f04 100644 --- a/internal/query/projection/target_test.go +++ b/internal/query/projection/target_test.go @@ -29,7 +29,7 @@ func TestTargetProjection_reduces(t *testing.T) { testEvent( target.AddedEventType, target.AggregateType, - []byte(`{"name": "name", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true}`), + []byte(`{"name": "name", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }}`), ), eventstore.GenericEventMapper[target.AddedEvent], ), @@ -41,7 +41,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.targets1 (instance_id, resource_owner, id, creation_date, change_date, sequence, name, endpoint, target_type, timeout, interrupt_on_error) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + expectedStmt: "INSERT INTO projections.targets2 (instance_id, resource_owner, id, creation_date, change_date, sequence, name, endpoint, target_type, timeout, interrupt_on_error, signing_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", expectedArgs: []interface{}{ "instance-id", "ro-id", @@ -54,6 +54,7 @@ func TestTargetProjection_reduces(t *testing.T) { domain.TargetTypeWebhook, 3 * time.Second, true, + anyArg{}, }, }, }, @@ -67,7 +68,7 @@ func TestTargetProjection_reduces(t *testing.T) { testEvent( target.ChangedEventType, target.AggregateType, - []byte(`{"name": "name2", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true}`), + []byte(`{"name": "name2", "targetType":0, "endpoint":"https://example.com", "timeout": 3000000000, "async": true, "interruptOnError": true, "signingKey": { "cryptoType": 0, "algorithm": "RSA-265", "keyId": "key-id" }}`), ), eventstore.GenericEventMapper[target.ChangedEvent], ), @@ -79,7 +80,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.targets1 SET (change_date, sequence, resource_owner, name, target_type, endpoint, timeout, interrupt_on_error) = ($1, $2, $3, $4, $5, $6, $7, $8) WHERE (instance_id = $9) AND (id = $10)", + expectedStmt: "UPDATE projections.targets2 SET (change_date, sequence, resource_owner, name, target_type, endpoint, timeout, interrupt_on_error, signing_key) = ($1, $2, $3, $4, $5, $6, $7, $8, $9) WHERE (instance_id = $10) AND (id = $11)", expectedArgs: []interface{}{ anyArg{}, uint64(15), @@ -89,6 +90,7 @@ func TestTargetProjection_reduces(t *testing.T) { "https://example.com", 3 * time.Second, true, + anyArg{}, "instance-id", "agg-id", }, @@ -116,7 +118,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.targets1 WHERE (instance_id = $1) AND (id = $2)", + expectedStmt: "DELETE FROM projections.targets2 WHERE (instance_id = $1) AND (id = $2)", expectedArgs: []interface{}{ "instance-id", "agg-id", @@ -145,7 +147,7 @@ func TestTargetProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.targets1 WHERE (instance_id = $1)", + expectedStmt: "DELETE FROM projections.targets2 WHERE (instance_id = $1)", expectedArgs: []interface{}{ "agg-id", }, diff --git a/internal/query/query.go b/internal/query/query.go index b39dbe9ca1..5fd06d5643 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -29,10 +29,11 @@ type Queries struct { client *database.DB caches *Caches - keyEncryptionAlgorithm crypto.EncryptionAlgorithm - idpConfigEncryption crypto.EncryptionAlgorithm - sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) - checkPermission domain.PermissionCheck + keyEncryptionAlgorithm crypto.EncryptionAlgorithm + idpConfigEncryption crypto.EncryptionAlgorithm + targetEncryptionAlgorithm crypto.EncryptionAlgorithm + sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) + checkPermission domain.PermissionCheck DefaultLanguage language.Tag mutex sync.Mutex @@ -52,7 +53,7 @@ func StartQueries( cacheConnectors connector.Connectors, projections projection.Config, defaults sd.SystemDefaults, - idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, + idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm, targetEncryptionAlgorithm crypto.EncryptionAlgorithm, zitadelRoles []authz.RoleMapping, sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), permissionCheck func(q *Queries) domain.PermissionCheck, @@ -70,6 +71,7 @@ func StartQueries( zitadelRoles: zitadelRoles, keyEncryptionAlgorithm: keyEncryptionAlgorithm, idpConfigEncryption: idpConfigEncryption, + targetEncryptionAlgorithm: targetEncryptionAlgorithm, sessionTokenVerifier: sessionTokenVerifier, multifactors: domain.MultifactorConfigs{ OTP: domain.OTPConfig{ diff --git a/internal/query/target.go b/internal/query/target.go index 8d926a699b..03db85236c 100644 --- a/internal/query/target.go +++ b/internal/query/target.go @@ -9,6 +9,7 @@ import ( sq "github.com/Masterminds/squirrel" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/zerrors" @@ -59,6 +60,10 @@ var ( name: projection.TargetInterruptOnErrorCol, table: targetTable, } + TargetColumnSigningKey = Column{ + name: projection.TargetSigningKey, + table: targetTable, + } ) type Targets struct { @@ -78,6 +83,20 @@ type Target struct { Endpoint string Timeout time.Duration InterruptOnError bool + signingKey *crypto.CryptoValue + SigningKey string +} + +func (t *Target) decryptSigningKey(alg crypto.EncryptionAlgorithm) error { + if t.signingKey == nil { + return nil + } + keyValue, err := crypto.DecryptString(t.signingKey, alg) + if err != nil { + return zerrors.ThrowInternal(err, "QUERY-bxevy3YXwy", "Errors.Internal") + } + t.SigningKey = keyValue + return nil } type TargetSearchQueries struct { @@ -93,21 +112,37 @@ func (q *TargetSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { return query } -func (q *Queries) SearchTargets(ctx context.Context, queries *TargetSearchQueries) (targets *Targets, err error) { +func (q *Queries) SearchTargets(ctx context.Context, queries *TargetSearchQueries) (*Targets, error) { eq := sq.Eq{ TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), } query, scan := prepareTargetsQuery(ctx, q.client) - return genericRowsQueryWithState[*Targets](ctx, q.client, targetTable, combineToWhereStmt(query, queries.toQuery, eq), scan) + targets, err := genericRowsQueryWithState[*Targets](ctx, q.client, targetTable, combineToWhereStmt(query, queries.toQuery, eq), scan) + if err != nil { + return nil, err + } + for i := range targets.Targets { + if err := targets.Targets[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + } + return targets, nil } -func (q *Queries) GetTargetByID(ctx context.Context, id string) (target *Target, err error) { +func (q *Queries) GetTargetByID(ctx context.Context, id string) (*Target, error) { eq := sq.Eq{ TargetColumnID.identifier(): id, TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), } query, scan := prepareTargetQuery(ctx, q.client) - return genericRowQuery[*Target](ctx, q.client, query.Where(eq), scan) + target, err := genericRowQuery[*Target](ctx, q.client, query.Where(eq), scan) + if err != nil { + return nil, err + } + if err := target.decryptSigningKey(q.targetEncryptionAlgorithm); err != nil { + return nil, err + } + return target, nil } func NewTargetNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { @@ -129,6 +164,7 @@ func prepareTargetsQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fu TargetColumnTimeout.identifier(), TargetColumnURL.identifier(), TargetColumnInterruptOnError.identifier(), + TargetColumnSigningKey.identifier(), countColumn.identifier(), ).From(targetTable.identifier()). PlaceholderFormat(sq.Dollar), @@ -147,6 +183,7 @@ func prepareTargetsQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fu &target.Timeout, &target.Endpoint, &target.InterruptOnError, + &target.signingKey, &count, ) if err != nil { @@ -179,6 +216,7 @@ func prepareTargetQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fun TargetColumnTimeout.identifier(), TargetColumnURL.identifier(), TargetColumnInterruptOnError.identifier(), + TargetColumnSigningKey.identifier(), ).From(targetTable.identifier()). PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*Target, error) { @@ -193,6 +231,7 @@ func prepareTargetQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fun &target.Timeout, &target.Endpoint, &target.InterruptOnError, + &target.signingKey, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { diff --git a/internal/query/target_test.go b/internal/query/target_test.go index 1b6edd1ad7..aa1ad517b7 100644 --- a/internal/query/target_test.go +++ b/internal/query/target_test.go @@ -9,22 +9,24 @@ import ( "testing" "time" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" ) var ( - prepareTargetsStmt = `SELECT projections.targets1.id,` + - ` projections.targets1.creation_date,` + - ` projections.targets1.change_date,` + - ` projections.targets1.resource_owner,` + - ` projections.targets1.name,` + - ` projections.targets1.target_type,` + - ` projections.targets1.timeout,` + - ` projections.targets1.endpoint,` + - ` projections.targets1.interrupt_on_error,` + + prepareTargetsStmt = `SELECT projections.targets2.id,` + + ` projections.targets2.creation_date,` + + ` projections.targets2.change_date,` + + ` projections.targets2.resource_owner,` + + ` projections.targets2.name,` + + ` projections.targets2.target_type,` + + ` projections.targets2.timeout,` + + ` projections.targets2.endpoint,` + + ` projections.targets2.interrupt_on_error,` + + ` projections.targets2.signing_key,` + ` COUNT(*) OVER ()` + - ` FROM projections.targets1` + ` FROM projections.targets2` prepareTargetsCols = []string{ "id", "creation_date", @@ -35,19 +37,21 @@ var ( "timeout", "endpoint", "interrupt_on_error", + "signing_key", "count", } - prepareTargetStmt = `SELECT projections.targets1.id,` + - ` projections.targets1.creation_date,` + - ` projections.targets1.change_date,` + - ` projections.targets1.resource_owner,` + - ` projections.targets1.name,` + - ` projections.targets1.target_type,` + - ` projections.targets1.timeout,` + - ` projections.targets1.endpoint,` + - ` projections.targets1.interrupt_on_error` + - ` FROM projections.targets1` + prepareTargetStmt = `SELECT projections.targets2.id,` + + ` projections.targets2.creation_date,` + + ` projections.targets2.change_date,` + + ` projections.targets2.resource_owner,` + + ` projections.targets2.name,` + + ` projections.targets2.target_type,` + + ` projections.targets2.timeout,` + + ` projections.targets2.endpoint,` + + ` projections.targets2.interrupt_on_error,` + + ` projections.targets2.signing_key` + + ` FROM projections.targets2` prepareTargetCols = []string{ "id", "creation_date", @@ -58,6 +62,7 @@ var ( "timeout", "endpoint", "interrupt_on_error", + "signing_key", } ) @@ -102,6 +107,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, ), @@ -123,6 +134,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: true, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, }, @@ -145,6 +162,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { "id-2", @@ -156,6 +179,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { "id-3", @@ -167,6 +196,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", false, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, ), @@ -188,6 +223,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: true, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { ObjectDetails: domain.ObjectDetails{ @@ -201,6 +242,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: false, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, { ObjectDetails: domain.ObjectDetails{ @@ -214,6 +261,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: false, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, }, @@ -270,6 +323,12 @@ func Test_TargetPrepares(t *testing.T) { 1 * time.Second, "https://example.com", true, + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, ), }, @@ -285,6 +344,12 @@ func Test_TargetPrepares(t *testing.T) { Timeout: 1 * time.Second, Endpoint: "https://example.com", InterruptOnError: true, + signingKey: &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, }, }, { diff --git a/internal/query/targets_by_execution_id.sql b/internal/query/targets_by_execution_id.sql index f8248479b0..f3ee25d675 100644 --- a/internal/query/targets_by_execution_id.sql +++ b/internal/query/targets_by_execution_id.sql @@ -31,9 +31,9 @@ WITH RECURSIVE ON e.instance_id = p.instance_id AND e.include IS NOT NULL AND e.include = p.execution_id) -select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error +select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error, t.signing_key FROM dissolved_execution_targets e - JOIN projections.targets1 t + JOIN projections.targets2 t ON e.instance_id = t.instance_id AND e.target_id = t.id WHERE "include" = '' diff --git a/internal/query/targets_by_execution_ids.sql b/internal/query/targets_by_execution_ids.sql index 749d9387b2..277826a81b 100644 --- a/internal/query/targets_by_execution_ids.sql +++ b/internal/query/targets_by_execution_ids.sql @@ -38,9 +38,9 @@ WITH RECURSIVE ON e.instance_id = p.instance_id AND e.include IS NOT NULL AND e.include = p.execution_id) -select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error +select e.execution_id, e.instance_id, e.target_id, t.target_type, t.endpoint, t.timeout, t.interrupt_on_error, t.signing_key FROM dissolved_execution_targets e - JOIN projections.targets1 t + JOIN projections.targets2 t ON e.instance_id = t.instance_id AND e.target_id = t.id WHERE "include" = '' diff --git a/internal/repository/target/target.go b/internal/repository/target/target.go index 85e3ae7023..3df1b31480 100644 --- a/internal/repository/target/target.go +++ b/internal/repository/target/target.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" ) @@ -18,11 +19,12 @@ const ( type AddedEvent struct { eventstore.BaseEvent `json:"-"` - Name string `json:"name"` - TargetType domain.TargetType `json:"targetType"` - Endpoint string `json:"endpoint"` - Timeout time.Duration `json:"timeout"` - InterruptOnError bool `json:"interruptOnError"` + Name string `json:"name"` + TargetType domain.TargetType `json:"targetType"` + Endpoint string `json:"endpoint"` + Timeout time.Duration `json:"timeout"` + InterruptOnError bool `json:"interruptOnError"` + SigningKey *crypto.CryptoValue `json:"signingKey"` } func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { @@ -45,22 +47,24 @@ func NewAddedEvent( endpoint string, timeout time.Duration, interruptOnError bool, + signingKey *crypto.CryptoValue, ) *AddedEvent { return &AddedEvent{ *eventstore.NewBaseEventForPush( ctx, aggregate, AddedEventType, ), - name, targetType, endpoint, timeout, interruptOnError} + name, targetType, endpoint, timeout, interruptOnError, signingKey} } type ChangedEvent struct { eventstore.BaseEvent `json:"-"` - Name *string `json:"name,omitempty"` - TargetType *domain.TargetType `json:"targetType,omitempty"` - Endpoint *string `json:"endpoint,omitempty"` - Timeout *time.Duration `json:"timeout,omitempty"` - InterruptOnError *bool `json:"interruptOnError,omitempty"` + Name *string `json:"name,omitempty"` + TargetType *domain.TargetType `json:"targetType,omitempty"` + Endpoint *string `json:"endpoint,omitempty"` + Timeout *time.Duration `json:"timeout,omitempty"` + InterruptOnError *bool `json:"interruptOnError,omitempty"` + SigningKey *crypto.CryptoValue `json:"signingKey,omitempty"` oldName string } @@ -134,6 +138,12 @@ func ChangeInterruptOnError(interruptOnError bool) func(event *ChangedEvent) { } } +func ChangeSigningKey(signingKey *crypto.CryptoValue) func(event *ChangedEvent) { + return func(e *ChangedEvent) { + e.SigningKey = signingKey + } +} + type RemovedEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/pkg/actions/signing.go b/pkg/actions/signing.go new file mode 100644 index 0000000000..0b39327450 --- /dev/null +++ b/pkg/actions/signing.go @@ -0,0 +1,115 @@ +package actions + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +var ( + ErrNoValidSignature = errors.New("no valid signature") + ErrInvalidHeader = errors.New("webhook has invalid Zitadel-Signature header") + ErrNotSigned = errors.New("webhook has no Zitadel-Signature header") + ErrTooOld = errors.New("timestamp wasn't within tolerance") +) + +const ( + SigningHeader = "ZITADEL-Signature" + signingTimestamp = "t" + signingVersion string = "v1" + DefaultTolerance = 300 * time.Second + partSeparator = "," +) + +func ComputeSignatureHeader(t time.Time, payload []byte, signingKey ...string) string { + parts := []string{ + fmt.Sprintf("%s=%d", signingTimestamp, t.Unix()), + } + for _, k := range signingKey { + parts = append(parts, fmt.Sprintf("%s=%s", signingVersion, hex.EncodeToString(computeSignature(t, payload, k)))) + } + return strings.Join(parts, partSeparator) +} + +func computeSignature(t time.Time, payload []byte, signingKey string) []byte { + mac := hmac.New(sha256.New, []byte(signingKey)) + mac.Write([]byte(fmt.Sprintf("%d", t.Unix()))) + mac.Write([]byte(".")) + mac.Write(payload) + return mac.Sum(nil) +} + +func ValidatePayload(payload []byte, header string, signingKey string) error { + return ValidatePayloadWithTolerance(payload, header, signingKey, DefaultTolerance) +} + +func ValidatePayloadWithTolerance(payload []byte, header string, signingKey string, tolerance time.Duration) error { + return validatePayload(payload, header, signingKey, tolerance, true) +} + +func validatePayload(payload []byte, sigHeader string, signingKey string, tolerance time.Duration, enforceTolerance bool) error { + header, err := parseSignatureHeader(sigHeader) + if err != nil { + return err + } + + expectedSignature := computeSignature(header.timestamp, payload, signingKey) + expiredTimestamp := time.Since(header.timestamp) > tolerance + if enforceTolerance && expiredTimestamp { + return ErrTooOld + } + + for _, sig := range header.signatures { + if hmac.Equal(expectedSignature, sig) { + return nil + } + } + return ErrNoValidSignature +} + +type signedHeader struct { + timestamp time.Time + signatures [][]byte +} + +func parseSignatureHeader(header string) (*signedHeader, error) { + sh := &signedHeader{} + if header == "" { + return sh, ErrNotSigned + } + + pairs := strings.Split(header, ",") + for _, pair := range pairs { + parts := strings.Split(pair, "=") + if len(parts) != 2 { + return sh, ErrInvalidHeader + } + switch parts[0] { + case signingTimestamp: + timestamp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil { + return sh, ErrInvalidHeader + } + sh.timestamp = time.Unix(timestamp, 0) + + case signingVersion: + sig, err := hex.DecodeString(parts[1]) + if err != nil { + continue + } + sh.signatures = append(sh.signatures, sig) + default: + continue + } + } + + if len(sh.signatures) == 0 { + return sh, ErrNoValidSignature + } + return sh, nil +} diff --git a/proto/zitadel/resources/action/v3alpha/action_service.proto b/proto/zitadel/resources/action/v3alpha/action_service.proto index fa07a9f854..bc3739861d 100644 --- a/proto/zitadel/resources/action/v3alpha/action_service.proto +++ b/proto/zitadel/resources/action/v3alpha/action_service.proto @@ -408,6 +408,12 @@ message CreateTargetRequest { message CreateTargetResponse { zitadel.resources.object.v3alpha.Details details = 1; + // Key used to sign and check payload sent to the target. + string signing_key = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; } message PatchTargetRequest { @@ -433,6 +439,12 @@ message PatchTargetRequest { message PatchTargetResponse { zitadel.resources.object.v3alpha.Details details = 1; + // Key used to sign and check payload sent to the target. + optional string signing_key = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; } message DeleteTargetRequest { diff --git a/proto/zitadel/resources/action/v3alpha/target.proto b/proto/zitadel/resources/action/v3alpha/target.proto index cb1ff85883..8524ab3639 100644 --- a/proto/zitadel/resources/action/v3alpha/target.proto +++ b/proto/zitadel/resources/action/v3alpha/target.proto @@ -9,6 +9,7 @@ import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "google/protobuf/timestamp.proto"; import "zitadel/resources/object/v3alpha/object.proto"; @@ -51,6 +52,11 @@ message Target { message GetTarget { zitadel.resources.object.v3alpha.Details details = 1; Target config = 2; + string signing_key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; } message PatchTarget { @@ -84,6 +90,21 @@ message PatchTarget { max_length: 1000 } ]; + // Regenerate the key used for signing and checking the payload sent to the target. + // Set the graceful period for the existing key. During that time, the previous + // signing key and the new one will be used to sign the request to allow you a smooth + // transition onf your API. + // + // Note that we currently only allow an immediate rotation ("0s") and will support + // longer expirations in the future. + optional google.protobuf.Duration expiration_signing_key = 7 [ + (validate.rules).duration = {const: {seconds: 0, nanos: 0}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"0s\"" + minimum: 0 + maximum: 0 + } + ]; }