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
37 changed files with 745 additions and 122 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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"),
},
},
},
{

View File

@@ -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" = ''

View File

@@ -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" = ''