feat: action v2 signing (#8779)

# Which Problems Are Solved

The action v2 messages were didn't contain anything providing security
for the sent content.

# How the Problems Are Solved

Each Target now has a SigningKey, which can also be newly generated
through the API and returned at creation and through the Get-Endpoints.
There is now a HTTP header "Zitadel-Signature", which is generated with
the SigningKey and Payload, and also contains a timestamp to check with
a tolerance if the message took to long to sent.

# Additional Changes

The functionality to create and check the signature is provided in the
pkg/actions package, and can be reused in the SDK.

# Additional Context

Closes #7924

---------

Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Stefan Benz 2024-11-28 11:06:52 +01:00 committed by GitHub
parent 8537805ea5
commit 7caa43ab23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 745 additions and 122 deletions

View File

@ -633,6 +633,9 @@ EncryptionKeys:
User: User:
EncryptionKeyID: "userKey" # ZITADEL_ENCRYPTIONKEYS_USER_ENCRYPTIONKEYID EncryptionKeyID: "userKey" # ZITADEL_ENCRYPTIONKEYS_USER_ENCRYPTIONKEYID
DecryptionKeyIDs: # ZITADEL_ENCRYPTIONKEYS_USER_DECRYPTIONKEYIDS (comma separated list) 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 CSRFCookieKeyID: "csrfCookieKey" # ZITADEL_ENCRYPTIONKEYS_CSRFCOOKIEKEYID
UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID
@ -910,6 +913,12 @@ DefaultInstance:
IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEUPPERLETTERS IncludeUpperLetters: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEUPPERLETTERS
IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEDIGITS IncludeDigits: true # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDEDIGITS
IncludeSymbols: false # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_INITIALIZEUSERCODE_INCLUDESYMBOLS 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: PasswordComplexityPolicy:
MinLength: 8 # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_MINLENGTH MinLength: 8 # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_MINLENGTH
HasLowercase: true # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASLOWERCASE HasLowercase: true # ZITADEL_DEFAULTINSTANCE_PASSWORDCOMPLEXITYPOLICY_HASLOWERCASE

View File

@ -17,6 +17,7 @@ var (
"smsKey", "smsKey",
"smtpKey", "smtpKey",
"userKey", "userKey",
"targetKey",
"csrfCookieKey", "csrfCookieKey",
"userAgentCookieKey", "userAgentCookieKey",
} }
@ -31,6 +32,7 @@ type EncryptionKeyConfig struct {
SMS *crypto.KeyConfig SMS *crypto.KeyConfig
SMTP *crypto.KeyConfig SMTP *crypto.KeyConfig
User *crypto.KeyConfig User *crypto.KeyConfig
Target *crypto.KeyConfig
CSRFCookieKeyID string CSRFCookieKeyID string
UserAgentCookieKeyID string UserAgentCookieKeyID string
} }
@ -44,6 +46,7 @@ type EncryptionKeys struct {
SMS crypto.EncryptionAlgorithm SMS crypto.EncryptionAlgorithm
SMTP crypto.EncryptionAlgorithm SMTP crypto.EncryptionAlgorithm
User crypto.EncryptionAlgorithm User crypto.EncryptionAlgorithm
Target crypto.EncryptionAlgorithm
CSRFCookieKey []byte CSRFCookieKey []byte
UserAgentCookieKey []byte UserAgentCookieKey []byte
OIDCKey []byte OIDCKey []byte
@ -91,6 +94,10 @@ func EnsureEncryptionKeys(ctx context.Context, keyConfig *EncryptionKeyConfig, k
if err != nil { if err != nil {
return nil, err return nil, err
} }
keys.Target, err = crypto.NewAESCrypto(keyConfig.Target, keyStorage)
if err != nil {
return nil, err
}
key, err = crypto.LoadKey(keyConfig.CSRFCookieKeyID, keyStorage) key, err = crypto.LoadKey(keyConfig.CSRFCookieKeyID, keyStorage)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -145,6 +145,7 @@ func projections(
keys.OTP, keys.OTP,
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
keys.Target,
config.InternalAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings,
sessionTokenVerifier, sessionTokenVerifier,
func(q *query.Queries) domain.PermissionCheck { func(q *query.Queries) domain.PermissionCheck {
@ -183,6 +184,7 @@ func projections(
keys.DomainVerification, keys.DomainVerification,
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
keys.Target,
&http.Client{}, &http.Client{},
func(ctx context.Context, permission, orgID, resourceID string) (err error) { func(ctx context.Context, permission, orgID, resourceID string) (err error) {
return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID) return internal_authz.CheckPermission(ctx, authZRepo, config.InternalAuthZ.RolePermissionMappings, permission, orgID, resourceID)

View File

@ -86,6 +86,7 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error
nil, nil,
nil, nil,
nil, nil,
nil,
0, 0,
0, 0,
0, 0,

View File

@ -53,6 +53,7 @@ func (mig *externalConfigChange) Execute(ctx context.Context, _ eventstore.Event
nil, nil,
nil, nil,
nil, nil,
nil,
0, 0,
0, 0,
0, 0,

View File

@ -366,6 +366,7 @@ func initProjections(
keys.OTP, keys.OTP,
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
keys.Target,
config.InternalAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings,
sessionTokenVerifier, sessionTokenVerifier,
func(q *query.Queries) domain.PermissionCheck { func(q *query.Queries) domain.PermissionCheck {
@ -422,6 +423,7 @@ func initProjections(
keys.DomainVerification, keys.DomainVerification,
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
keys.Target,
&http.Client{}, &http.Client{},
permissionCheck, permissionCheck,
sessionTokenVerifier, sessionTokenVerifier,

View File

@ -196,6 +196,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
keys.OTP, keys.OTP,
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
keys.Target,
config.InternalAuthZ.RolePermissionMappings, config.InternalAuthZ.RolePermissionMappings,
sessionTokenVerifier, sessionTokenVerifier,
func(q *query.Queries) domain.PermissionCheck { func(q *query.Queries) domain.PermissionCheck {
@ -245,6 +246,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server
keys.DomainVerification, keys.DomainVerification,
keys.OIDC, keys.OIDC,
keys.SAML, keys.SAML,
keys.Target,
&http.Client{}, &http.Client{},
permissionCheck, permissionCheck,
sessionTokenVerifier, sessionTokenVerifier,

View File

@ -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. 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 ## 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: 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:

View File

@ -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) 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 ## Execution
ZITADEL decides on specific conditions if one or more Targets have to be called. ZITADEL decides on specific conditions if one or more Targets have to be called.

View File

@ -17,7 +17,7 @@
"generate:grpc": "buf generate ../proto", "generate:grpc": "buf generate ../proto",
"generate:apidocs": "docusaurus gen-api-docs all", "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: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" "generate:clean-all": "docusaurus clean-api-docs all"
}, },
"dependencies": { "dependencies": {

View File

@ -62,6 +62,7 @@ func TestServer_GetTarget(t *testing.T) {
request.Id = resp.GetDetails().GetId() request.Id = resp.GetDetails().GetId()
response.Target.Config.Name = name response.Target.Config.Name = name
response.Target.Details = resp.GetDetails() response.Target.Details = resp.GetDetails()
response.Target.SigningKey = resp.GetSigningKey()
return nil return nil
}, },
req: &action.GetTargetRequest{}, req: &action.GetTargetRequest{},
@ -92,6 +93,7 @@ func TestServer_GetTarget(t *testing.T) {
request.Id = resp.GetDetails().GetId() request.Id = resp.GetDetails().GetId()
response.Target.Config.Name = name response.Target.Config.Name = name
response.Target.Details = resp.GetDetails() response.Target.Details = resp.GetDetails()
response.Target.SigningKey = resp.GetSigningKey()
return nil return nil
}, },
req: &action.GetTargetRequest{}, req: &action.GetTargetRequest{},
@ -122,6 +124,7 @@ func TestServer_GetTarget(t *testing.T) {
request.Id = resp.GetDetails().GetId() request.Id = resp.GetDetails().GetId()
response.Target.Config.Name = name response.Target.Config.Name = name
response.Target.Details = resp.GetDetails() response.Target.Details = resp.GetDetails()
response.Target.SigningKey = resp.GetSigningKey()
return nil return nil
}, },
req: &action.GetTargetRequest{}, req: &action.GetTargetRequest{},
@ -154,6 +157,7 @@ func TestServer_GetTarget(t *testing.T) {
request.Id = resp.GetDetails().GetId() request.Id = resp.GetDetails().GetId()
response.Target.Config.Name = name response.Target.Config.Name = name
response.Target.Details = resp.GetDetails() response.Target.Details = resp.GetDetails()
response.Target.SigningKey = resp.GetSigningKey()
return nil return nil
}, },
req: &action.GetTargetRequest{}, req: &action.GetTargetRequest{},
@ -186,6 +190,7 @@ func TestServer_GetTarget(t *testing.T) {
request.Id = resp.GetDetails().GetId() request.Id = resp.GetDetails().GetId()
response.Target.Config.Name = name response.Target.Config.Name = name
response.Target.Details = resp.GetDetails() response.Target.Details = resp.GetDetails()
response.Target.SigningKey = resp.GetSigningKey()
return nil return nil
}, },
req: &action.GetTargetRequest{}, req: &action.GetTargetRequest{},
@ -230,6 +235,7 @@ func TestServer_GetTarget(t *testing.T) {
gotTarget := got.GetTarget() gotTarget := got.GetTarget()
integration.AssertResourceDetails(ttt, wantTarget.GetDetails(), gotTarget.GetDetails()) integration.AssertResourceDetails(ttt, wantTarget.GetDetails(), gotTarget.GetDetails())
assert.EqualExportedValues(ttt, wantTarget.GetConfig(), gotTarget.GetConfig()) assert.EqualExportedValues(ttt, wantTarget.GetConfig(), gotTarget.GetConfig())
assert.Equal(ttt, wantTarget.GetSigningKey(), gotTarget.GetSigningKey())
}, retryDuration, tick, "timeout waiting for expected target result") }, retryDuration, tick, "timeout waiting for expected target result")
}) })
} }
@ -492,6 +498,7 @@ func TestServer_ListTargets(t *testing.T) {
for i := range tt.want.Result { for i := range tt.want.Result {
integration.AssertResourceDetails(ttt, tt.want.Result[i].GetDetails(), got.Result[i].GetDetails()) 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.EqualExportedValues(ttt, tt.want.Result[i].GetConfig(), got.Result[i].GetConfig())
assert.NotEmpty(ttt, got.Result[i].GetSigningKey())
} }
} }
integration.AssertResourceListDetails(ttt, tt.want, got) integration.AssertResourceListDetails(ttt, tt.want, got)

View File

@ -9,6 +9,7 @@ import (
"github.com/brianvoe/gofakeit/v6" "github.com/brianvoe/gofakeit/v6"
"github.com/muhlemmer/gu" "github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb" "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) { t.Run(tt.name, func(t *testing.T) {
got, err := instance.Client.ActionV3Alpha.CreateTarget(tt.ctx, &action.CreateTargetRequest{Target: tt.req}) got, err := instance.Client.ActionV3Alpha.CreateTarget(tt.ctx, &action.CreateTargetRequest{Target: tt.req})
if tt.wantErr { if tt.wantErr {
require.Error(t, err) assert.Error(t, err)
return } 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 ctx context.Context
req *action.PatchTargetRequest req *action.PatchTargetRequest
} }
type want struct {
details *resource_object.Details
signingKey bool
}
tests := []struct { tests := []struct {
name string name string
prepare func(request *action.PatchTargetRequest) error prepare func(request *action.PatchTargetRequest) error
args args args args
want *resource_object.Details want want
wantErr bool wantErr bool
}{ }{
{ {
@ -272,14 +278,42 @@ func TestServer_PatchTarget(t *testing.T) {
}, },
}, },
}, },
want: &resource_object.Details{ want: want{
Changed: timestamppb.Now(), details: &resource_object.Details{
Owner: &object.Owner{ Changed: timestamppb.Now(),
Type: object.OwnerType_OWNER_TYPE_INSTANCE, Owner: &object.Owner{
Id: instance.ID(), 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", name: "change type, ok",
prepare: func(request *action.PatchTargetRequest) error { prepare: func(request *action.PatchTargetRequest) error {
@ -299,11 +333,13 @@ func TestServer_PatchTarget(t *testing.T) {
}, },
}, },
}, },
want: &resource_object.Details{ want: want{
Changed: timestamppb.Now(), details: &resource_object.Details{
Owner: &object.Owner{ Changed: timestamppb.Now(),
Type: object.OwnerType_OWNER_TYPE_INSTANCE, Owner: &object.Owner{
Id: instance.ID(), Type: object.OwnerType_OWNER_TYPE_INSTANCE,
Id: instance.ID(),
},
}, },
}, },
}, },
@ -322,11 +358,13 @@ func TestServer_PatchTarget(t *testing.T) {
}, },
}, },
}, },
want: &resource_object.Details{ want: want{
Changed: timestamppb.Now(), details: &resource_object.Details{
Owner: &object.Owner{ Changed: timestamppb.Now(),
Type: object.OwnerType_OWNER_TYPE_INSTANCE, Owner: &object.Owner{
Id: instance.ID(), Type: object.OwnerType_OWNER_TYPE_INSTANCE,
Id: instance.ID(),
},
}, },
}, },
}, },
@ -345,11 +383,13 @@ func TestServer_PatchTarget(t *testing.T) {
}, },
}, },
}, },
want: &resource_object.Details{ want: want{
Changed: timestamppb.Now(), details: &resource_object.Details{
Owner: &object.Owner{ Changed: timestamppb.Now(),
Type: object.OwnerType_OWNER_TYPE_INSTANCE, Owner: &object.Owner{
Id: instance.ID(), Type: object.OwnerType_OWNER_TYPE_INSTANCE,
Id: instance.ID(),
},
}, },
}, },
}, },
@ -370,11 +410,13 @@ func TestServer_PatchTarget(t *testing.T) {
}, },
}, },
}, },
want: &resource_object.Details{ want: want{
Changed: timestamppb.Now(), details: &resource_object.Details{
Owner: &object.Owner{ Changed: timestamppb.Now(),
Type: object.OwnerType_OWNER_TYPE_INSTANCE, Owner: &object.Owner{
Id: instance.ID(), 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) instance.Client.ActionV3Alpha.PatchTarget(tt.args.ctx, tt.args.req)
got, err := 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 { if tt.wantErr {
require.Error(t, err) assert.Error(t, err)
return } 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) { t.Run(tt.name, func(t *testing.T) {
got, err := instance.Client.ActionV3Alpha.DeleteTarget(tt.ctx, tt.req) got, err := instance.Client.ActionV3Alpha.DeleteTarget(tt.ctx, tt.req)
if tt.wantErr { if tt.wantErr {
require.Error(t, err) assert.Error(t, err)
return return
} else {
assert.NoError(t, err)
integration.AssertResourceDetails(t, tt.want, got.Details)
} }
require.NoError(t, err)
integration.AssertResourceDetails(t, tt.want, got.Details)
}) })
} }
} }

View File

@ -97,6 +97,7 @@ func targetToPb(t *query.Target) *action.GetTarget {
Timeout: durationpb.New(t.Timeout), Timeout: durationpb.New(t.Timeout),
Endpoint: t.Endpoint, Endpoint: t.Endpoint,
}, },
SigningKey: t.SigningKey,
} }
switch t.TargetType { switch t.TargetType {
case domain.TargetTypeWebhook: case domain.TargetTypeWebhook:

View File

@ -25,7 +25,8 @@ func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetReque
return nil, err return nil, err
} }
return &action.CreateTargetResponse{ 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 }, nil
} }
@ -34,12 +35,14 @@ func (s *Server) PatchTarget(ctx context.Context, req *action.PatchTargetRequest
return nil, err return nil, err
} }
instanceID := authz.GetInstance(ctx).InstanceID() 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 { if err != nil {
return nil, err return nil, err
} }
return &action.PatchTargetResponse{ 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 }, nil
} }
@ -83,6 +86,12 @@ func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget {
} }
func patchTargetToCommand(req *action.PatchTargetRequest) *command.ChangeTarget { 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() reqTarget := req.GetTarget()
if reqTarget == nil { if reqTarget == nil {
return nil return nil
@ -91,8 +100,9 @@ func patchTargetToCommand(req *action.PatchTargetRequest) *command.ChangeTarget
ObjectRoot: models.ObjectRoot{ ObjectRoot: models.ObjectRoot{
AggregateID: req.GetId(), AggregateID: req.GetId(),
}, },
Name: reqTarget.Name, Name: reqTarget.Name,
Endpoint: reqTarget.Endpoint, Endpoint: reqTarget.Endpoint,
ExpirationSigningKey: expirationSigningKey,
} }
if reqTarget.TargetType != nil { if reqTarget.TargetType != nil {
switch t := reqTarget.GetTargetType().(type) { switch t := reqTarget.GetTargetType().(type) {

View File

@ -26,6 +26,7 @@ type mockExecutionTarget struct {
Endpoint string Endpoint string
Timeout time.Duration Timeout time.Duration
InterruptOnError bool InterruptOnError bool
SigningKey string
} }
func (e *mockExecutionTarget) SetEndpoint(endpoint string) { func (e *mockExecutionTarget) SetEndpoint(endpoint string) {
@ -49,6 +50,9 @@ func (e *mockExecutionTarget) GetTargetID() string {
func (e *mockExecutionTarget) GetExecutionID() string { func (e *mockExecutionTarget) GetExecutionID() string {
return e.ExecutionID return e.ExecutionID
} }
func (e *mockExecutionTarget) GetSigningKey() string {
return e.SigningKey
}
type mockContentRequest struct { type mockContentRequest struct {
Content string Content string
@ -157,6 +161,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetID: "target", TargetID: "target",
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -186,6 +191,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
@ -216,6 +222,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Second, Timeout: time.Second,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -245,6 +252,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Second, Timeout: time.Second,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -269,6 +277,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -297,6 +306,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetID: "target", TargetID: "target",
TargetType: domain.TargetTypeAsync, TargetType: domain.TargetTypeAsync,
Timeout: time.Second, Timeout: time.Second,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -325,6 +335,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetID: "target", TargetID: "target",
TargetType: domain.TargetTypeAsync, TargetType: domain.TargetTypeAsync,
Timeout: time.Minute, Timeout: time.Minute,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -354,6 +365,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeWebhook, TargetType: domain.TargetTypeWebhook,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -382,6 +394,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeWebhook, TargetType: domain.TargetTypeWebhook,
Timeout: time.Second, Timeout: time.Second,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -411,6 +424,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeWebhook, TargetType: domain.TargetTypeWebhook,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -440,6 +454,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
&mockExecutionTarget{ &mockExecutionTarget{
InstanceID: "instance", InstanceID: "instance",
@ -448,6 +463,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
&mockExecutionTarget{ &mockExecutionTarget{
InstanceID: "instance", InstanceID: "instance",
@ -456,6 +472,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
@ -498,6 +515,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
&mockExecutionTarget{ &mockExecutionTarget{
InstanceID: "instance", InstanceID: "instance",
@ -506,6 +524,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Second, Timeout: time.Second,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
&mockExecutionTarget{ &mockExecutionTarget{
InstanceID: "instance", InstanceID: "instance",
@ -514,6 +533,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Second, Timeout: time.Second,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -692,6 +712,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{
@ -721,6 +742,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) {
TargetType: domain.TargetTypeCall, TargetType: domain.TargetTypeCall,
Timeout: time.Minute, Timeout: time.Minute,
InterruptOnError: true, InterruptOnError: true,
SigningKey: "signingkey",
}, },
}, },
targets: []target{ targets: []target{

View File

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/execution" "github.com/zitadel/zitadel/internal/repository/execution"
@ -172,6 +173,12 @@ func TestCommands_SetExecutionRequest(t *testing.T) {
"https://example.com", "https://example.com",
time.Second, time.Second,
true, 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", "https://example.com",
time.Second, time.Second,
true, 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", "https://example.com",
time.Second, time.Second,
true, 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", "https://example.com",
time.Second, time.Second,
true, true,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("12345678"),
},
), ),
), ),
expectPushFailed( expectPushFailed(
@ -930,6 +955,12 @@ func TestCommands_SetExecutionResponse(t *testing.T) {
"https://example.com", "https://example.com",
time.Second, time.Second,
true, true,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("12345678"),
},
), ),
), ),
), ),

View File

@ -5,6 +5,8 @@ import (
"net/url" "net/url"
"time" "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/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/repository/target" "github.com/zitadel/zitadel/internal/repository/target"
@ -19,6 +21,8 @@ type AddTarget struct {
Endpoint string Endpoint string
Timeout time.Duration Timeout time.Duration
InterruptOnError bool InterruptOnError bool
SigningKey string
} }
func (a *AddTarget) IsValid() error { func (a *AddTarget) IsValid() error {
@ -58,7 +62,11 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner
if wm.State.Exists() { if wm.State.Exists() {
return nil, zerrors.ThrowAlreadyExists(nil, "INSTANCE-9axkz0jvzm", "Errors.Target.AlreadyExists") 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( pushedEvents, err := c.eventstore.Push(ctx, target.NewAddedEvent(
ctx, ctx,
TargetAggregateFromWriteModel(&wm.WriteModel), TargetAggregateFromWriteModel(&wm.WriteModel),
@ -67,6 +75,7 @@ func (c *Commands) AddTarget(ctx context.Context, add *AddTarget, resourceOwner
add.Endpoint, add.Endpoint,
add.Timeout, add.Timeout,
add.InterruptOnError, add.InterruptOnError,
code.Crypted,
)) ))
if err != nil { if err != nil {
return nil, err return nil, err
@ -85,6 +94,9 @@ type ChangeTarget struct {
Endpoint *string Endpoint *string
Timeout *time.Duration Timeout *time.Duration
InterruptOnError *bool InterruptOnError *bool
ExpirationSigningKey bool
SigningKey *string
} }
func (a *ChangeTarget) IsValid() error { func (a *ChangeTarget) IsValid() error {
@ -120,6 +132,17 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou
if !existing.State.Exists() { if !existing.State.Exists() {
return nil, zerrors.ThrowNotFound(nil, "COMMAND-xj14f2cccn", "Errors.Target.NotFound") 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( changedEvent := existing.NewChangedEvent(
ctx, ctx,
TargetAggregateFromWriteModel(&existing.WriteModel), TargetAggregateFromWriteModel(&existing.WriteModel),
@ -127,7 +150,9 @@ func (c *Commands) ChangeTarget(ctx context.Context, change *ChangeTarget, resou
change.TargetType, change.TargetType,
change.Endpoint, change.Endpoint,
change.Timeout, change.Timeout,
change.InterruptOnError) change.InterruptOnError,
changedSigningKey,
)
if changedEvent == nil { if changedEvent == nil {
return writeModelToObjectDetails(&existing.WriteModel), nil return writeModelToObjectDetails(&existing.WriteModel), nil
} }
@ -184,3 +209,7 @@ func (c *Commands) getTargetWriteModelByID(ctx context.Context, id string, resou
} }
return wm, nil 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)
}

View File

@ -5,6 +5,7 @@ import (
"slices" "slices"
"time" "time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/target" "github.com/zitadel/zitadel/internal/repository/target"
@ -18,6 +19,7 @@ type TargetWriteModel struct {
Endpoint string Endpoint string
Timeout time.Duration Timeout time.Duration
InterruptOnError bool InterruptOnError bool
SigningKey *crypto.CryptoValue
State domain.TargetState State domain.TargetState
} }
@ -41,6 +43,7 @@ func (wm *TargetWriteModel) Reduce() error {
wm.Endpoint = e.Endpoint wm.Endpoint = e.Endpoint
wm.Timeout = e.Timeout wm.Timeout = e.Timeout
wm.State = domain.TargetActive wm.State = domain.TargetActive
wm.SigningKey = e.SigningKey
case *target.ChangedEvent: case *target.ChangedEvent:
if e.Name != nil { if e.Name != nil {
wm.Name = *e.Name wm.Name = *e.Name
@ -57,6 +60,9 @@ func (wm *TargetWriteModel) Reduce() error {
if e.InterruptOnError != nil { if e.InterruptOnError != nil {
wm.InterruptOnError = *e.InterruptOnError wm.InterruptOnError = *e.InterruptOnError
} }
if e.SigningKey != nil {
wm.SigningKey = e.SigningKey
}
case *target.RemovedEvent: case *target.RemovedEvent:
wm.State = domain.TargetRemoved wm.State = domain.TargetRemoved
} }
@ -84,6 +90,7 @@ func (wm *TargetWriteModel) NewChangedEvent(
endpoint *string, endpoint *string,
timeout *time.Duration, timeout *time.Duration,
interruptOnError *bool, interruptOnError *bool,
signingKey *crypto.CryptoValue,
) *target.ChangedEvent { ) *target.ChangedEvent {
changes := make([]target.Changes, 0) changes := make([]target.Changes, 0)
if name != nil && wm.Name != *name { if name != nil && wm.Name != *name {
@ -101,6 +108,10 @@ func (wm *TargetWriteModel) NewChangedEvent(
if interruptOnError != nil && wm.InterruptOnError != *interruptOnError { if interruptOnError != nil && wm.InterruptOnError != *interruptOnError {
changes = append(changes, target.ChangeInterruptOnError(*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 { if len(changes) == 0 {
return nil return nil
} }

View File

@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/target" "github.com/zitadel/zitadel/internal/repository/target"
@ -20,6 +21,12 @@ func targetAddEvent(aggID, resourceOwner string) *target.AddedEvent {
"https://example.com", "https://example.com",
time.Second, time.Second,
false, false,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("12345678"),
},
) )
} }

View File

@ -8,6 +8,7 @@ import (
"github.com/muhlemmer/gu" "github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/eventstore/v1/models"
@ -19,8 +20,10 @@ import (
func TestCommands_AddTarget(t *testing.T) { func TestCommands_AddTarget(t *testing.T) {
type fields struct { type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator idGenerator id.Generator
newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc
defaultSecretGenerators *SecretGenerators
} }
type args struct { type args struct {
ctx context.Context ctx context.Context
@ -132,10 +135,18 @@ func TestCommands_AddTarget(t *testing.T) {
"https://example.com", "https://example.com",
time.Second, time.Second,
false, 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{ args{
ctx: context.Background(), ctx: context.Background(),
@ -186,7 +197,9 @@ func TestCommands_AddTarget(t *testing.T) {
targetAddEvent("id1", "instance"), targetAddEvent("id1", "instance"),
), ),
), ),
idGenerator: mock.ExpectID(t, "id1"), idGenerator: mock.ExpectID(t, "id1"),
newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("12345678", time.Hour),
defaultSecretGenerators: &SecretGenerators{},
}, },
args{ args{
ctx: context.Background(), 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{ args{
ctx: context.Background(), ctx: context.Background(),
@ -244,8 +259,10 @@ func TestCommands_AddTarget(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
c := &Commands{ c := &Commands{
eventstore: tt.fields.eventstore(t), eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator, 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) details, err := c.AddTarget(tt.args.ctx, tt.args.add, tt.args.resourceOwner)
if tt.res.err == nil { if tt.res.err == nil {
@ -264,7 +281,9 @@ func TestCommands_AddTarget(t *testing.T) {
func TestCommands_ChangeTarget(t *testing.T) { func TestCommands_ChangeTarget(t *testing.T) {
type fields struct { type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore eventstore func(t *testing.T) *eventstore.Eventstore
newEncryptedCodeWithDefault encryptedCodeWithDefaultFunc
defaultSecretGenerators *SecretGenerators
} }
type args struct { type args struct {
ctx context.Context ctx context.Context
@ -510,10 +529,18 @@ func TestCommands_ChangeTarget(t *testing.T) {
target.ChangeTargetType(domain.TargetTypeCall), target.ChangeTargetType(domain.TargetTypeCall),
target.ChangeTimeout(10 * time.Second), target.ChangeTimeout(10 * time.Second),
target.ChangeInterruptOnError(true), 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{ args{
ctx: context.Background(), ctx: context.Background(),
@ -521,11 +548,12 @@ func TestCommands_ChangeTarget(t *testing.T) {
ObjectRoot: models.ObjectRoot{ ObjectRoot: models.ObjectRoot{
AggregateID: "id1", AggregateID: "id1",
}, },
Name: gu.Ptr("name2"), Name: gu.Ptr("name2"),
Endpoint: gu.Ptr("https://example2.com"), Endpoint: gu.Ptr("https://example2.com"),
TargetType: gu.Ptr(domain.TargetTypeCall), TargetType: gu.Ptr(domain.TargetTypeCall),
Timeout: gu.Ptr(10 * time.Second), Timeout: gu.Ptr(10 * time.Second),
InterruptOnError: gu.Ptr(true), InterruptOnError: gu.Ptr(true),
ExpirationSigningKey: true,
}, },
resourceOwner: "instance", resourceOwner: "instance",
}, },
@ -540,7 +568,9 @@ func TestCommands_ChangeTarget(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
c := &Commands{ 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) details, err := c.ChangeTarget(tt.args.ctx, tt.args.change, tt.args.resourceOwner)
if tt.res.err == nil { if tt.res.err == nil {

View File

@ -54,6 +54,7 @@ type Commands struct {
smtpEncryption crypto.EncryptionAlgorithm smtpEncryption crypto.EncryptionAlgorithm
smsEncryption crypto.EncryptionAlgorithm smsEncryption crypto.EncryptionAlgorithm
userEncryption crypto.EncryptionAlgorithm userEncryption crypto.EncryptionAlgorithm
targetEncryption crypto.EncryptionAlgorithm
userPasswordHasher *crypto.Hasher userPasswordHasher *crypto.Hasher
secretHasher *crypto.Hasher secretHasher *crypto.Hasher
machineKeySize int machineKeySize int
@ -108,7 +109,7 @@ func StartCommands(
externalDomain string, externalDomain string,
externalSecure bool, externalSecure bool,
externalPort uint16, 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, httpClient *http.Client,
permissionCheck domain.PermissionCheck, permissionCheck domain.PermissionCheck,
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error),
@ -153,6 +154,7 @@ func StartCommands(
smtpEncryption: smtpEncryption, smtpEncryption: smtpEncryption,
smsEncryption: smsEncryption, smsEncryption: smsEncryption,
userEncryption: userEncryption, userEncryption: userEncryption,
targetEncryption: targetEncryption,
userPasswordHasher: userPasswordHasher, userPasswordHasher: userPasswordHasher,
secretHasher: secretHasher, secretHasher: secretHasher,
machineKeySize: int(defaults.SecretGenerators.MachineKeySize), machineKeySize: int(defaults.SecretGenerators.MachineKeySize),

View File

@ -157,6 +157,7 @@ type SecretGenerators struct {
OTPSMS *crypto.GeneratorConfig OTPSMS *crypto.GeneratorConfig
OTPEmail *crypto.GeneratorConfig OTPEmail *crypto.GeneratorConfig
InviteCode *crypto.GeneratorConfig InviteCode *crypto.GeneratorConfig
SigningKey *crypto.GeneratorConfig
} }
type ZitadelConfig struct { type ZitadelConfig struct {

View File

@ -15,6 +15,7 @@ const (
SecretGeneratorTypeOTPSMS SecretGeneratorTypeOTPSMS
SecretGeneratorTypeOTPEmail SecretGeneratorTypeOTPEmail
SecretGeneratorTypeInviteCode SecretGeneratorTypeInviteCode
SecretGeneratorTypeSigningKey
secretGeneratorTypeCount secretGeneratorTypeCount
) )

View File

@ -14,6 +14,7 @@ import (
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
"github.com/zitadel/zitadel/pkg/actions"
) )
type ContextInfo interface { type ContextInfo interface {
@ -28,6 +29,7 @@ type Target interface {
GetEndpoint() string GetEndpoint() string
GetTargetType() domain.TargetType GetTargetType() domain.TargetType
GetTimeout() time.Duration GetTimeout() time.Duration
GetSigningKey() string
} }
// CallTargets call a list of targets in order with handling of error and responses // CallTargets call a list of targets in order with handling of error and responses
@ -72,13 +74,13 @@ func CallTarget(
switch target.GetTargetType() { switch target.GetTargetType() {
// get request, ignore response and return request and error for handling in list of targets // get request, ignore response and return request and error for handling in list of targets
case domain.TargetTypeWebhook: 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 // get request, return response and error
case domain.TargetTypeCall: 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: case domain.TargetTypeAsync:
go func(target Target, info ContextInfoRequest) { 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) logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err)
} }
}(target, info) }(target, info)
@ -89,13 +91,13 @@ func CallTarget(
} }
// webhook call a webhook, ignore the response but return the errror // webhook call a webhook, ignore the response but return the errror
func webhook(ctx context.Context, url string, timeout time.Duration, body []byte) error { func webhook(ctx context.Context, url string, timeout time.Duration, body []byte, signingKey string) error {
_, err := Call(ctx, url, timeout, body) _, err := Call(ctx, url, timeout, body, signingKey)
return err return err
} }
// Call function to do a post HTTP request to a desired url with timeout // 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, cancel := context.WithTimeout(ctx, timeout)
ctx, span := tracing.NewSpan(ctx) ctx, span := tracing.NewSpan(ctx)
defer func() { defer func() {
@ -108,6 +110,9 @@ func Call(ctx context.Context, url string, timeout time.Duration, body []byte) (
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if signingKey != "" {
req.Header.Set(actions.SigningHeader, actions.ComputeSignatureHeader(time.Now(), body, signingKey))
}
client := http.DefaultClient client := http.DefaultClient
resp, err := client.Do(req) resp, err := client.Do(req)

View File

@ -18,6 +18,7 @@ import (
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/execution" "github.com/zitadel/zitadel/internal/execution"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
"github.com/zitadel/zitadel/pkg/actions"
) )
func Test_Call(t *testing.T) { func Test_Call(t *testing.T) {
@ -29,6 +30,7 @@ func Test_Call(t *testing.T) {
body []byte body []byte
respBody []byte respBody []byte
statusCode int statusCode int
signingKey string
} }
type res struct { type res struct {
body []byte body []byte
@ -84,6 +86,22 @@ func Test_Call(t *testing.T) {
body: []byte("{\"response\": \"values\"}"), 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -95,7 +113,7 @@ func Test_Call(t *testing.T) {
statusCode: tt.args.statusCode, statusCode: tt.args.statusCode,
respondBody: tt.args.respBody, 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 { if tt.res.wantErr {
assert.Error(t, err) assert.Error(t, err)
@ -186,6 +204,29 @@ func Test_CallTarget(t *testing.T) {
body: nil, 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", "request response, error",
args{ args{
@ -228,6 +269,29 @@ func Test_CallTarget(t *testing.T) {
body: []byte("{\"request\":\"content2\"}"), 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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@ -392,6 +456,7 @@ type mockTarget struct {
Endpoint string Endpoint string
Timeout time.Duration Timeout time.Duration
InterruptOnError bool InterruptOnError bool
SigningKey string
} }
func (e *mockTarget) GetTargetID() string { func (e *mockTarget) GetTargetID() string {
@ -409,6 +474,9 @@ func (e *mockTarget) GetTargetType() domain.TargetType {
func (e *mockTarget) GetTimeout() time.Duration { func (e *mockTarget) GetTimeout() time.Duration {
return e.Timeout return e.Timeout
} }
func (e *mockTarget) GetSigningKey() string {
return e.SigningKey
}
type callTestServer struct { type callTestServer struct {
method string method string
@ -416,6 +484,7 @@ type callTestServer struct {
timeout time.Duration timeout time.Duration
statusCode int statusCode int
respondBody []byte respondBody []byte
signingKey string
} }
func testServers( func testServers(
@ -447,7 +516,7 @@ func listen(
c *callTestServer, c *callTestServer,
) (url string, close func()) { ) (url string, close func()) {
handler := func(w http.ResponseWriter, r *http.Request) { 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 { if c.statusCode != http.StatusOK {
http.Error(w, "error", c.statusCode) http.Error(w, "error", c.statusCode)
@ -466,16 +535,19 @@ func listen(
return server.URL, server.Close 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) sentBody, err := io.ReadAll(sent.Body)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, expectedBody, sentBody) require.Equal(t, expectedBody, sentBody)
require.Equal(t, method, sent.Method) 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 func(url string) ([]byte, error) {
return execution.Call(ctx, url, timeout, body) return execution.Call(ctx, url, timeout, body, signingKey)
} }
} }

View File

@ -11,6 +11,7 @@ import (
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/database"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/query/projection"
@ -175,6 +176,11 @@ func (q *Queries) TargetsByExecutionID(ctx context.Context, ids []string) (execu
instanceID, instanceID,
database.TextArray[string](ids), database.TextArray[string](ids),
) )
for i := range execution {
if err := execution[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil {
return nil, err
}
}
return execution, 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](ids1),
database.TextArray[string](ids2), database.TextArray[string](ids2),
) )
for i := range execution {
if err := execution[i].decryptSigningKey(q.targetEncryptionAlgorithm); err != nil {
return nil, err
}
}
return execution, err return execution, err
} }
@ -352,6 +363,8 @@ type ExecutionTarget struct {
Endpoint string Endpoint string
Timeout time.Duration Timeout time.Duration
InterruptOnError bool InterruptOnError bool
signingKey *crypto.CryptoValue
SigningKey string
} }
func (e *ExecutionTarget) GetExecutionID() string { func (e *ExecutionTarget) GetExecutionID() string {
@ -372,6 +385,21 @@ func (e *ExecutionTarget) GetTargetType() domain.TargetType {
func (e *ExecutionTarget) GetTimeout() time.Duration { func (e *ExecutionTarget) GetTimeout() time.Duration {
return e.Timeout 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) { func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) {
targets := make([]*ExecutionTarget, 0) targets := make([]*ExecutionTarget, 0)
@ -386,6 +414,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) {
endpoint = &sql.NullString{} endpoint = &sql.NullString{}
timeout = &sql.NullInt64{} timeout = &sql.NullInt64{}
interruptOnError = &sql.NullBool{} interruptOnError = &sql.NullBool{}
signingKey = &crypto.CryptoValue{}
) )
err := rows.Scan( err := rows.Scan(
@ -396,6 +425,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) {
endpoint, endpoint,
timeout, timeout,
interruptOnError, interruptOnError,
signingKey,
) )
if err != nil { if err != nil {
@ -409,6 +439,7 @@ func scanExecutionTargets(rows *sql.Rows) ([]*ExecutionTarget, error) {
target.Endpoint = endpoint.String target.Endpoint = endpoint.String
target.Timeout = time.Duration(timeout.Int64) target.Timeout = time.Duration(timeout.Int64)
target.InterruptOnError = interruptOnError.Bool target.InterruptOnError = interruptOnError.Bool
target.signingKey = signingKey
targets = append(targets, target) targets = append(targets, target)
} }

View File

@ -11,7 +11,7 @@ import (
) )
const ( const (
TargetTable = "projections.targets1" TargetTable = "projections.targets2"
TargetIDCol = "id" TargetIDCol = "id"
TargetCreationDateCol = "creation_date" TargetCreationDateCol = "creation_date"
TargetChangeDateCol = "change_date" TargetChangeDateCol = "change_date"
@ -23,6 +23,7 @@ const (
TargetEndpointCol = "endpoint" TargetEndpointCol = "endpoint"
TargetTimeoutCol = "timeout" TargetTimeoutCol = "timeout"
TargetInterruptOnErrorCol = "interrupt_on_error" TargetInterruptOnErrorCol = "interrupt_on_error"
TargetSigningKey = "signing_key"
) )
type targetProjection struct{} type targetProjection struct{}
@ -49,6 +50,7 @@ func (*targetProjection) Init() *old_handler.Check {
handler.NewColumn(TargetEndpointCol, handler.ColumnTypeText), handler.NewColumn(TargetEndpointCol, handler.ColumnTypeText),
handler.NewColumn(TargetTimeoutCol, handler.ColumnTypeInt64), handler.NewColumn(TargetTimeoutCol, handler.ColumnTypeInt64),
handler.NewColumn(TargetInterruptOnErrorCol, handler.ColumnTypeBool), handler.NewColumn(TargetInterruptOnErrorCol, handler.ColumnTypeBool),
handler.NewColumn(TargetSigningKey, handler.ColumnTypeJSONB, handler.Nullable()),
}, },
handler.NewPrimaryKey(TargetInstanceIDCol, TargetIDCol), handler.NewPrimaryKey(TargetInstanceIDCol, TargetIDCol),
), ),
@ -105,6 +107,7 @@ func (p *targetProjection) reduceTargetAdded(event eventstore.Event) (*handler.S
handler.NewCol(TargetTargetType, e.TargetType), handler.NewCol(TargetTargetType, e.TargetType),
handler.NewCol(TargetTimeoutCol, e.Timeout), handler.NewCol(TargetTimeoutCol, e.Timeout),
handler.NewCol(TargetInterruptOnErrorCol, e.InterruptOnError), handler.NewCol(TargetInterruptOnErrorCol, e.InterruptOnError),
handler.NewCol(TargetSigningKey, e.SigningKey),
}, },
), nil ), nil
} }
@ -134,6 +137,9 @@ func (p *targetProjection) reduceTargetChanged(event eventstore.Event) (*handler
if e.InterruptOnError != nil { if e.InterruptOnError != nil {
values = append(values, handler.NewCol(TargetInterruptOnErrorCol, *e.InterruptOnError)) values = append(values, handler.NewCol(TargetInterruptOnErrorCol, *e.InterruptOnError))
} }
if e.SigningKey != nil {
values = append(values, handler.NewCol(TargetSigningKey, e.SigningKey))
}
return handler.NewUpdateStatement( return handler.NewUpdateStatement(
e, e,
values, values,

View File

@ -29,7 +29,7 @@ func TestTargetProjection_reduces(t *testing.T) {
testEvent( testEvent(
target.AddedEventType, target.AddedEventType,
target.AggregateType, 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], eventstore.GenericEventMapper[target.AddedEvent],
), ),
@ -41,7 +41,7 @@ func TestTargetProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
"instance-id", "instance-id",
"ro-id", "ro-id",
@ -54,6 +54,7 @@ func TestTargetProjection_reduces(t *testing.T) {
domain.TargetTypeWebhook, domain.TargetTypeWebhook,
3 * time.Second, 3 * time.Second,
true, true,
anyArg{},
}, },
}, },
}, },
@ -67,7 +68,7 @@ func TestTargetProjection_reduces(t *testing.T) {
testEvent( testEvent(
target.ChangedEventType, target.ChangedEventType,
target.AggregateType, 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], eventstore.GenericEventMapper[target.ChangedEvent],
), ),
@ -79,7 +80,7 @@ func TestTargetProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
anyArg{}, anyArg{},
uint64(15), uint64(15),
@ -89,6 +90,7 @@ func TestTargetProjection_reduces(t *testing.T) {
"https://example.com", "https://example.com",
3 * time.Second, 3 * time.Second,
true, true,
anyArg{},
"instance-id", "instance-id",
"agg-id", "agg-id",
}, },
@ -116,7 +118,7 @@ func TestTargetProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
"instance-id", "instance-id",
"agg-id", "agg-id",
@ -145,7 +147,7 @@ func TestTargetProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ executions: []execution{
{ {
expectedStmt: "DELETE FROM projections.targets1 WHERE (instance_id = $1)", expectedStmt: "DELETE FROM projections.targets2 WHERE (instance_id = $1)",
expectedArgs: []interface{}{ expectedArgs: []interface{}{
"agg-id", "agg-id",
}, },

View File

@ -29,10 +29,11 @@ type Queries struct {
client *database.DB client *database.DB
caches *Caches caches *Caches
keyEncryptionAlgorithm crypto.EncryptionAlgorithm keyEncryptionAlgorithm crypto.EncryptionAlgorithm
idpConfigEncryption crypto.EncryptionAlgorithm idpConfigEncryption crypto.EncryptionAlgorithm
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error) targetEncryptionAlgorithm crypto.EncryptionAlgorithm
checkPermission domain.PermissionCheck sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error)
checkPermission domain.PermissionCheck
DefaultLanguage language.Tag DefaultLanguage language.Tag
mutex sync.Mutex mutex sync.Mutex
@ -52,7 +53,7 @@ func StartQueries(
cacheConnectors connector.Connectors, cacheConnectors connector.Connectors,
projections projection.Config, projections projection.Config,
defaults sd.SystemDefaults, defaults sd.SystemDefaults,
idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm crypto.EncryptionAlgorithm, idpConfigEncryption, otpEncryption, keyEncryptionAlgorithm, certEncryptionAlgorithm, targetEncryptionAlgorithm crypto.EncryptionAlgorithm,
zitadelRoles []authz.RoleMapping, zitadelRoles []authz.RoleMapping,
sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error), sessionTokenVerifier func(ctx context.Context, sessionToken string, sessionID string, tokenID string) (err error),
permissionCheck func(q *Queries) domain.PermissionCheck, permissionCheck func(q *Queries) domain.PermissionCheck,
@ -70,6 +71,7 @@ func StartQueries(
zitadelRoles: zitadelRoles, zitadelRoles: zitadelRoles,
keyEncryptionAlgorithm: keyEncryptionAlgorithm, keyEncryptionAlgorithm: keyEncryptionAlgorithm,
idpConfigEncryption: idpConfigEncryption, idpConfigEncryption: idpConfigEncryption,
targetEncryptionAlgorithm: targetEncryptionAlgorithm,
sessionTokenVerifier: sessionTokenVerifier, sessionTokenVerifier: sessionTokenVerifier,
multifactors: domain.MultifactorConfigs{ multifactors: domain.MultifactorConfigs{
OTP: domain.OTPConfig{ OTP: domain.OTPConfig{

View File

@ -9,6 +9,7 @@ import (
sq "github.com/Masterminds/squirrel" sq "github.com/Masterminds/squirrel"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
@ -59,6 +60,10 @@ var (
name: projection.TargetInterruptOnErrorCol, name: projection.TargetInterruptOnErrorCol,
table: targetTable, table: targetTable,
} }
TargetColumnSigningKey = Column{
name: projection.TargetSigningKey,
table: targetTable,
}
) )
type Targets struct { type Targets struct {
@ -78,6 +83,20 @@ type Target struct {
Endpoint string Endpoint string
Timeout time.Duration Timeout time.Duration
InterruptOnError bool 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 { type TargetSearchQueries struct {
@ -93,21 +112,37 @@ func (q *TargetSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder {
return query 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{ eq := sq.Eq{
TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
} }
query, scan := prepareTargetsQuery(ctx, q.client) 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{ eq := sq.Eq{
TargetColumnID.identifier(): id, TargetColumnID.identifier(): id,
TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), TargetColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
} }
query, scan := prepareTargetQuery(ctx, q.client) 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) { func NewTargetNameSearchQuery(method TextComparison, value string) (SearchQuery, error) {
@ -129,6 +164,7 @@ func prepareTargetsQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fu
TargetColumnTimeout.identifier(), TargetColumnTimeout.identifier(),
TargetColumnURL.identifier(), TargetColumnURL.identifier(),
TargetColumnInterruptOnError.identifier(), TargetColumnInterruptOnError.identifier(),
TargetColumnSigningKey.identifier(),
countColumn.identifier(), countColumn.identifier(),
).From(targetTable.identifier()). ).From(targetTable.identifier()).
PlaceholderFormat(sq.Dollar), PlaceholderFormat(sq.Dollar),
@ -147,6 +183,7 @@ func prepareTargetsQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fu
&target.Timeout, &target.Timeout,
&target.Endpoint, &target.Endpoint,
&target.InterruptOnError, &target.InterruptOnError,
&target.signingKey,
&count, &count,
) )
if err != nil { if err != nil {
@ -179,6 +216,7 @@ func prepareTargetQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fun
TargetColumnTimeout.identifier(), TargetColumnTimeout.identifier(),
TargetColumnURL.identifier(), TargetColumnURL.identifier(),
TargetColumnInterruptOnError.identifier(), TargetColumnInterruptOnError.identifier(),
TargetColumnSigningKey.identifier(),
).From(targetTable.identifier()). ).From(targetTable.identifier()).
PlaceholderFormat(sq.Dollar), PlaceholderFormat(sq.Dollar),
func(row *sql.Row) (*Target, error) { func(row *sql.Row) (*Target, error) {
@ -193,6 +231,7 @@ func prepareTargetQuery(context.Context, prepareDatabase) (sq.SelectBuilder, fun
&target.Timeout, &target.Timeout,
&target.Endpoint, &target.Endpoint,
&target.InterruptOnError, &target.InterruptOnError,
&target.signingKey,
) )
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {

View File

@ -9,22 +9,24 @@ import (
"testing" "testing"
"time" "time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/internal/zerrors"
) )
var ( var (
prepareTargetsStmt = `SELECT projections.targets1.id,` + prepareTargetsStmt = `SELECT projections.targets2.id,` +
` projections.targets1.creation_date,` + ` projections.targets2.creation_date,` +
` projections.targets1.change_date,` + ` projections.targets2.change_date,` +
` projections.targets1.resource_owner,` + ` projections.targets2.resource_owner,` +
` projections.targets1.name,` + ` projections.targets2.name,` +
` projections.targets1.target_type,` + ` projections.targets2.target_type,` +
` projections.targets1.timeout,` + ` projections.targets2.timeout,` +
` projections.targets1.endpoint,` + ` projections.targets2.endpoint,` +
` projections.targets1.interrupt_on_error,` + ` projections.targets2.interrupt_on_error,` +
` projections.targets2.signing_key,` +
` COUNT(*) OVER ()` + ` COUNT(*) OVER ()` +
` FROM projections.targets1` ` FROM projections.targets2`
prepareTargetsCols = []string{ prepareTargetsCols = []string{
"id", "id",
"creation_date", "creation_date",
@ -35,19 +37,21 @@ var (
"timeout", "timeout",
"endpoint", "endpoint",
"interrupt_on_error", "interrupt_on_error",
"signing_key",
"count", "count",
} }
prepareTargetStmt = `SELECT projections.targets1.id,` + prepareTargetStmt = `SELECT projections.targets2.id,` +
` projections.targets1.creation_date,` + ` projections.targets2.creation_date,` +
` projections.targets1.change_date,` + ` projections.targets2.change_date,` +
` projections.targets1.resource_owner,` + ` projections.targets2.resource_owner,` +
` projections.targets1.name,` + ` projections.targets2.name,` +
` projections.targets1.target_type,` + ` projections.targets2.target_type,` +
` projections.targets1.timeout,` + ` projections.targets2.timeout,` +
` projections.targets1.endpoint,` + ` projections.targets2.endpoint,` +
` projections.targets1.interrupt_on_error` + ` projections.targets2.interrupt_on_error,` +
` FROM projections.targets1` ` projections.targets2.signing_key` +
` FROM projections.targets2`
prepareTargetCols = []string{ prepareTargetCols = []string{
"id", "id",
"creation_date", "creation_date",
@ -58,6 +62,7 @@ var (
"timeout", "timeout",
"endpoint", "endpoint",
"interrupt_on_error", "interrupt_on_error",
"signing_key",
} }
) )
@ -102,6 +107,12 @@ func Test_TargetPrepares(t *testing.T) {
1 * time.Second, 1 * time.Second,
"https://example.com", "https://example.com",
true, 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, Timeout: 1 * time.Second,
Endpoint: "https://example.com", Endpoint: "https://example.com",
InterruptOnError: true, 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, 1 * time.Second,
"https://example.com", "https://example.com",
true, true,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
KeyID: "encKey",
Crypted: []byte("crypted"),
},
}, },
{ {
"id-2", "id-2",
@ -156,6 +179,12 @@ func Test_TargetPrepares(t *testing.T) {
1 * time.Second, 1 * time.Second,
"https://example.com", "https://example.com",
false, false,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
KeyID: "encKey",
Crypted: []byte("crypted"),
},
}, },
{ {
"id-3", "id-3",
@ -167,6 +196,12 @@ func Test_TargetPrepares(t *testing.T) {
1 * time.Second, 1 * time.Second,
"https://example.com", "https://example.com",
false, 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, Timeout: 1 * time.Second,
Endpoint: "https://example.com", Endpoint: "https://example.com",
InterruptOnError: true, InterruptOnError: true,
signingKey: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
KeyID: "encKey",
Crypted: []byte("crypted"),
},
}, },
{ {
ObjectDetails: domain.ObjectDetails{ ObjectDetails: domain.ObjectDetails{
@ -201,6 +242,12 @@ func Test_TargetPrepares(t *testing.T) {
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
Endpoint: "https://example.com", Endpoint: "https://example.com",
InterruptOnError: false, InterruptOnError: false,
signingKey: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
KeyID: "encKey",
Crypted: []byte("crypted"),
},
}, },
{ {
ObjectDetails: domain.ObjectDetails{ ObjectDetails: domain.ObjectDetails{
@ -214,6 +261,12 @@ func Test_TargetPrepares(t *testing.T) {
Timeout: 1 * time.Second, Timeout: 1 * time.Second,
Endpoint: "https://example.com", Endpoint: "https://example.com",
InterruptOnError: false, 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, 1 * time.Second,
"https://example.com", "https://example.com",
true, 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, Timeout: 1 * time.Second,
Endpoint: "https://example.com", Endpoint: "https://example.com",
InterruptOnError: true, InterruptOnError: true,
signingKey: &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
KeyID: "encKey",
Crypted: []byte("crypted"),
},
}, },
}, },
{ {

View File

@ -31,9 +31,9 @@ WITH RECURSIVE
ON e.instance_id = p.instance_id ON e.instance_id = p.instance_id
AND e.include IS NOT NULL AND e.include IS NOT NULL
AND e.include = p.execution_id) 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 FROM dissolved_execution_targets e
JOIN projections.targets1 t JOIN projections.targets2 t
ON e.instance_id = t.instance_id ON e.instance_id = t.instance_id
AND e.target_id = t.id AND e.target_id = t.id
WHERE "include" = '' WHERE "include" = ''

View File

@ -38,9 +38,9 @@ WITH RECURSIVE
ON e.instance_id = p.instance_id ON e.instance_id = p.instance_id
AND e.include IS NOT NULL AND e.include IS NOT NULL
AND e.include = p.execution_id) 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 FROM dissolved_execution_targets e
JOIN projections.targets1 t JOIN projections.targets2 t
ON e.instance_id = t.instance_id ON e.instance_id = t.instance_id
AND e.target_id = t.id AND e.target_id = t.id
WHERE "include" = '' WHERE "include" = ''

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"time" "time"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
) )
@ -18,11 +19,12 @@ const (
type AddedEvent struct { type AddedEvent struct {
eventstore.BaseEvent `json:"-"` eventstore.BaseEvent `json:"-"`
Name string `json:"name"` Name string `json:"name"`
TargetType domain.TargetType `json:"targetType"` TargetType domain.TargetType `json:"targetType"`
Endpoint string `json:"endpoint"` Endpoint string `json:"endpoint"`
Timeout time.Duration `json:"timeout"` Timeout time.Duration `json:"timeout"`
InterruptOnError bool `json:"interruptOnError"` InterruptOnError bool `json:"interruptOnError"`
SigningKey *crypto.CryptoValue `json:"signingKey"`
} }
func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) { func (e *AddedEvent) SetBaseEvent(b *eventstore.BaseEvent) {
@ -45,22 +47,24 @@ func NewAddedEvent(
endpoint string, endpoint string,
timeout time.Duration, timeout time.Duration,
interruptOnError bool, interruptOnError bool,
signingKey *crypto.CryptoValue,
) *AddedEvent { ) *AddedEvent {
return &AddedEvent{ return &AddedEvent{
*eventstore.NewBaseEventForPush( *eventstore.NewBaseEventForPush(
ctx, aggregate, AddedEventType, ctx, aggregate, AddedEventType,
), ),
name, targetType, endpoint, timeout, interruptOnError} name, targetType, endpoint, timeout, interruptOnError, signingKey}
} }
type ChangedEvent struct { type ChangedEvent struct {
eventstore.BaseEvent `json:"-"` eventstore.BaseEvent `json:"-"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
TargetType *domain.TargetType `json:"targetType,omitempty"` TargetType *domain.TargetType `json:"targetType,omitempty"`
Endpoint *string `json:"endpoint,omitempty"` Endpoint *string `json:"endpoint,omitempty"`
Timeout *time.Duration `json:"timeout,omitempty"` Timeout *time.Duration `json:"timeout,omitempty"`
InterruptOnError *bool `json:"interruptOnError,omitempty"` InterruptOnError *bool `json:"interruptOnError,omitempty"`
SigningKey *crypto.CryptoValue `json:"signingKey,omitempty"`
oldName string 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 { type RemovedEvent struct {
eventstore.BaseEvent `json:"-"` eventstore.BaseEvent `json:"-"`

115
pkg/actions/signing.go Normal file
View File

@ -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
}

View File

@ -408,6 +408,12 @@ message CreateTargetRequest {
message CreateTargetResponse { message CreateTargetResponse {
zitadel.resources.object.v3alpha.Details details = 1; 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 { message PatchTargetRequest {
@ -433,6 +439,12 @@ message PatchTargetRequest {
message PatchTargetResponse { message PatchTargetResponse {
zitadel.resources.object.v3alpha.Details details = 1; 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 { message DeleteTargetRequest {

View File

@ -9,6 +9,7 @@ import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto"; import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto"; import "validate/validate.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "google/protobuf/timestamp.proto";
import "zitadel/resources/object/v3alpha/object.proto"; import "zitadel/resources/object/v3alpha/object.proto";
@ -51,6 +52,11 @@ message Target {
message GetTarget { message GetTarget {
zitadel.resources.object.v3alpha.Details details = 1; zitadel.resources.object.v3alpha.Details details = 1;
Target config = 2; Target config = 2;
string signing_key = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
example: "\"98KmsU67\""
}
];
} }
message PatchTarget { message PatchTarget {
@ -84,6 +90,21 @@ message PatchTarget {
max_length: 1000 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
}
];
} }