feat(v3alpha): web key resource (#8262)

# Which Problems Are Solved

Implement a new API service that allows management of OIDC signing web
keys.
This allows users to manage rotation of the instance level keys. which
are currently managed based on expiry.

The API accepts the generation of the following key types and
parameters:

- RSA keys with 2048, 3072 or 4096 bit in size and:
  - Signing with SHA-256 (RS256)
  - Signing with SHA-384 (RS384)
  - Signing with SHA-512 (RS512)
- ECDSA keys with
  - P256 curve
  - P384 curve
  - P512 curve
- ED25519 keys

# How the Problems Are Solved

Keys are serialized for storage using the JSON web key format from the
`jose` library. This is the format that will be used by OIDC for
signing, verification and publication.

Each instance can have a number of key pairs. All existing public keys
are meant to be used for token verification and publication the keys
endpoint. Keys can be activated and the active private key is meant to
sign new tokens. There is always exactly 1 active signing key:

1. When the first key for an instance is generated, it is automatically
activated.
2. Activation of the next key automatically deactivates the previously
active key.
3. Keys cannot be manually deactivated from the API
4. Active keys cannot be deleted

# Additional Changes

- Query methods that later will be used by the OIDC package are already
implemented. Preparation for #8031
- Fix indentation in french translation for instance event
- Move user_schema translations to consistent positions in all
translation files

# Additional Context

- Closes #8030
- Part of #7809

---------

Co-authored-by: Elio Bischof <elio@zitadel.com>
This commit is contained in:
Tim Möhlmann
2024-08-14 17:18:14 +03:00
committed by GitHub
parent e2e1100124
commit 64a3bb3149
91 changed files with 5133 additions and 256 deletions

View File

@@ -10,7 +10,6 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/call"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
@@ -66,7 +65,7 @@ var (
}
)
func (q *Queries) ActiveCertificates(ctx context.Context, t time.Time, usage domain.KeyUsage) (certs *Certificates, err error) {
func (q *Queries) ActiveCertificates(ctx context.Context, t time.Time, usage crypto.KeyUsage) (certs *Certificates, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()

View File

@@ -9,7 +9,6 @@ import (
"testing"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/zerrors"
)
@@ -109,7 +108,7 @@ func Test_CertificatePrepares(t *testing.T) {
sequence: 20211109,
resourceOwner: "ro",
algorithm: "",
use: domain.KeyUsageSAMLMetadataSigning,
use: crypto.KeyUsageSAMLMetadataSigning,
},
expiry: testNow,
certificate: []byte("privateKey"),

View File

@@ -16,6 +16,7 @@ type InstanceFeatures struct {
TokenExchange FeatureSource[bool]
Actions FeatureSource[bool]
ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType]
WebKey FeatureSource[bool]
}
func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) {

View File

@@ -67,6 +67,7 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder {
feature_v2.InstanceTokenExchangeEventType,
feature_v2.InstanceActionsEventType,
feature_v2.InstanceImprovedPerformanceEventType,
feature_v2.InstanceWebKeyEventType,
).
Builder().ResourceOwner(m.ResourceOwner)
}
@@ -115,6 +116,8 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_
features.Actions.set(level, event.Value)
case feature.KeyImprovedPerformance:
features.ImprovedPerformance.set(level, event.Value)
case feature.KeyWebKey:
features.WebKey.set(level, event.Value)
}
return nil
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/call"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query/projection"
"github.com/zitadel/zitadel/internal/repository/keypair"
@@ -22,7 +21,7 @@ import (
type Key interface {
ID() string
Algorithm() string
Use() domain.KeyUsage
Use() crypto.KeyUsage
Sequence() uint64
}
@@ -55,7 +54,7 @@ type key struct {
sequence uint64
resourceOwner string
algorithm string
use domain.KeyUsage
use crypto.KeyUsage
}
func (k *key) ID() string {
@@ -66,7 +65,7 @@ func (k *key) Algorithm() string {
return k.algorithm
}
func (k *key) Use() domain.KeyUsage {
func (k *key) Use() crypto.KeyUsage {
return k.use
}
@@ -222,7 +221,7 @@ func (q *Queries) ActivePrivateSigningKey(ctx context.Context, t time.Time) (key
query, args, err := stmt.Where(
sq.And{
sq.Eq{
KeyColUse.identifier(): domain.KeyUsageSigning,
KeyColUse.identifier(): crypto.KeyUsageSigning,
KeyColInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(),
},
sq.Gt{KeyPrivateColExpiry.identifier(): t},
@@ -358,7 +357,7 @@ type PublicKeyReadModel struct {
Algorithm string
Key *crypto.CryptoValue
Expiry time.Time
Usage domain.KeyUsage
Usage crypto.KeyUsage
}
func NewPublicKeyReadModel(keyID, resourceOwner string) *PublicKeyReadModel {

View File

@@ -19,7 +19,6 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
key_repo "github.com/zitadel/zitadel/internal/repository/keypair"
"github.com/zitadel/zitadel/internal/zerrors"
@@ -131,7 +130,7 @@ func Test_KeyPrepares(t *testing.T) {
sequence: 20211109,
resourceOwner: "ro",
algorithm: "RS256",
use: domain.KeyUsageSigning,
use: crypto.KeyUsageSigning,
},
expiry: testNow,
publicKey: &rsa.PublicKey{
@@ -212,7 +211,7 @@ func Test_KeyPrepares(t *testing.T) {
sequence: 20211109,
resourceOwner: "ro",
algorithm: "RS256",
use: domain.KeyUsageSigning,
use: crypto.KeyUsageSigning,
},
expiry: testNow,
privateKey: &crypto.CryptoValue{
@@ -306,7 +305,7 @@ func TestQueries_GetPublicKeyByID(t *testing.T) {
InstanceID: "instanceID",
Version: key_repo.AggregateVersion,
},
domain.KeyUsageSigning, "alg",
crypto.KeyUsageSigning, "alg",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
@@ -345,7 +344,7 @@ func TestQueries_GetPublicKeyByID(t *testing.T) {
InstanceID: "instanceID",
Version: key_repo.AggregateVersion,
},
domain.KeyUsageSigning, "alg",
crypto.KeyUsageSigning, "alg",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
@@ -385,7 +384,7 @@ func TestQueries_GetPublicKeyByID(t *testing.T) {
InstanceID: "instanceID",
Version: key_repo.AggregateVersion,
},
domain.KeyUsageSigning, "alg",
crypto.KeyUsageSigning, "alg",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
@@ -416,7 +415,7 @@ func TestQueries_GetPublicKeyByID(t *testing.T) {
id: "keyID",
resourceOwner: "instanceID",
algorithm: "alg",
use: domain.KeyUsageSigning,
use: crypto.KeyUsageSigning,
},
expiry: future,
publicKey: func() *rsa.PublicKey {

View File

@@ -88,6 +88,10 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer {
Event: feature_v2.InstanceImprovedPerformanceEventType,
Reduce: reduceInstanceSetFeature[[]feature.ImprovedPerformanceType],
},
{
Event: feature_v2.InstanceWebKeyEventType,
Reduce: reduceInstanceSetFeature[bool],
},
{
Event: instance.InstanceRemovedEventType,
Reduce: reduceInstanceRemovedHelper(InstanceDomainInstanceIDCol),

View File

@@ -8,7 +8,6 @@ import (
"go.uber.org/mock/gomock"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/repository/instance"
@@ -33,7 +32,7 @@ func TestKeyProjection_reduces(t *testing.T) {
testEvent(
keypair.AddedEventType,
keypair.AggregateType,
keypairAddedEventData(domain.KeyUsageSigning, time.Now().Add(time.Hour)),
keypairAddedEventData(crypto.KeyUsageSigning, time.Now().Add(time.Hour)),
), keypair.AddedEventMapper),
},
reduce: (&keyProjection{encryptionAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t))}).reduceKeyPairAdded,
@@ -52,7 +51,7 @@ func TestKeyProjection_reduces(t *testing.T) {
"instance-id",
uint64(15),
"algorithm",
domain.KeyUsageSigning,
crypto.KeyUsageSigning,
},
},
{
@@ -89,7 +88,7 @@ func TestKeyProjection_reduces(t *testing.T) {
testEvent(
keypair.AddedEventType,
keypair.AggregateType,
keypairAddedEventData(domain.KeyUsageSigning, time.Now().Add(-time.Hour)),
keypairAddedEventData(crypto.KeyUsageSigning, time.Now().Add(-time.Hour)),
), keypair.AddedEventMapper),
},
reduce: (&keyProjection{}).reduceKeyPairAdded,
@@ -132,7 +131,7 @@ func TestKeyProjection_reduces(t *testing.T) {
testEvent(
keypair.AddedCertificateEventType,
keypair.AggregateType,
certificateAddedEventData(domain.KeyUsageSAMLMetadataSigning, time.Now().Add(time.Hour)),
certificateAddedEventData(crypto.KeyUsageSAMLMetadataSigning, time.Now().Add(time.Hour)),
), keypair.AddedCertificateEventMapper),
},
reduce: (&keyProjection{certEncryptionAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t))}).reduceCertificateAdded,
@@ -170,10 +169,10 @@ func TestKeyProjection_reduces(t *testing.T) {
}
}
func keypairAddedEventData(usage domain.KeyUsage, t time.Time) []byte {
func keypairAddedEventData(usage crypto.KeyUsage, t time.Time) []byte {
return []byte(`{"algorithm": "algorithm", "usage": ` + fmt.Sprintf("%d", usage) + `, "privateKey": {"key": {"cryptoType": 0, "algorithm": "enc", "keyID": "id", "crypted": "cHJpdmF0ZUtleQ=="}, "expiry": "` + t.Format(time.RFC3339) + `"}, "publicKey": {"key": {"cryptoType": 0, "algorithm": "enc", "keyID": "id", "crypted": "cHVibGljS2V5"}, "expiry": "` + t.Format(time.RFC3339) + `"}}`)
}
func certificateAddedEventData(usage domain.KeyUsage, t time.Time) []byte {
func certificateAddedEventData(usage crypto.KeyUsage, t time.Time) []byte {
return []byte(`{"algorithm": "algorithm", "usage": ` + fmt.Sprintf("%d", usage) + `, "certificate": {"key": {"cryptoType": 0, "algorithm": "enc", "keyID": "id", "crypted": "cHJpdmF0ZUtleQ=="}, "expiry": "` + t.Format(time.RFC3339) + `"}}`)
}

View File

@@ -78,6 +78,7 @@ var (
TargetProjection *handler.Handler
ExecutionProjection *handler.Handler
UserSchemaProjection *handler.Handler
WebKeyProjection *handler.Handler
ProjectGrantFields *handler.FieldHandler
OrgDomainVerifiedFields *handler.FieldHandler
@@ -163,6 +164,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore,
TargetProjection = newTargetProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["targets"]))
ExecutionProjection = newExecutionProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["executions"]))
UserSchemaProjection = newUserSchemaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_schemas"]))
WebKeyProjection = newWebKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["web_keys"]))
ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant]))
OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified]))
@@ -292,5 +294,6 @@ func newProjectionsList() {
TargetProjection,
ExecutionProjection,
UserSchemaProjection,
WebKeyProjection,
}
}

View File

@@ -0,0 +1,165 @@
package projection
import (
"context"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
old_handler "github.com/zitadel/zitadel/internal/eventstore/handler"
"github.com/zitadel/zitadel/internal/eventstore/handler/v2"
"github.com/zitadel/zitadel/internal/repository/instance"
"github.com/zitadel/zitadel/internal/repository/webkey"
"github.com/zitadel/zitadel/internal/zerrors"
)
const (
WebKeyTable = "projections.web_keys"
WebKeyInstanceIDCol = "instance_id"
WebKeyKeyIDCol = "key_id"
WebKeyCreationDateCol = "creation_date"
WebKeyChangeDateCol = "change_date"
WebKeySequenceCol = "sequence"
WebKeyStateCol = "state"
WebKeyPrivateKeyCol = "private_key"
WebKeyPublicKeyCol = "public_key"
WebKeyConfigCol = "config"
WebKeyConfigTypeCol = "config_type"
)
type webKeyProjection struct{}
func newWebKeyProjection(ctx context.Context, config handler.Config) *handler.Handler {
return handler.NewHandler(ctx, &config, new(webKeyProjection))
}
func (*webKeyProjection) Name() string {
return WebKeyTable
}
func (*webKeyProjection) Init() *old_handler.Check {
return handler.NewTableCheck(
handler.NewTable(
[]*handler.InitColumn{
handler.NewColumn(WebKeyInstanceIDCol, handler.ColumnTypeText),
handler.NewColumn(WebKeyKeyIDCol, handler.ColumnTypeText),
handler.NewColumn(WebKeyCreationDateCol, handler.ColumnTypeTimestamp),
handler.NewColumn(WebKeyChangeDateCol, handler.ColumnTypeTimestamp),
handler.NewColumn(WebKeySequenceCol, handler.ColumnTypeInt64),
handler.NewColumn(WebKeyStateCol, handler.ColumnTypeInt64),
handler.NewColumn(WebKeyPrivateKeyCol, handler.ColumnTypeJSONB),
handler.NewColumn(WebKeyPublicKeyCol, handler.ColumnTypeJSONB),
handler.NewColumn(WebKeyConfigCol, handler.ColumnTypeJSONB),
handler.NewColumn(WebKeyConfigTypeCol, handler.ColumnTypeInt64),
},
handler.NewPrimaryKey(WebKeyInstanceIDCol, WebKeyKeyIDCol),
// index to find the current active private key for an instance.
handler.WithIndex(handler.NewIndex(
"web_key_state",
[]string{WebKeyInstanceIDCol, WebKeyStateCol},
handler.WithInclude(
WebKeyPrivateKeyCol,
),
)),
),
)
}
func (p *webKeyProjection) Reducers() []handler.AggregateReducer {
return []handler.AggregateReducer{{
Aggregate: webkey.AggregateType,
EventReducers: []handler.EventReducer{
{
Event: webkey.AddedEventType,
Reduce: p.reduceWebKeyAdded,
},
{
Event: webkey.ActivatedEventType,
Reduce: p.reduceWebKeyActivated,
},
{
Event: webkey.DeactivatedEventType,
Reduce: p.reduceWebKeyDeactivated,
},
{
Event: webkey.RemovedEventType,
Reduce: p.reduceWebKeyRemoved,
},
{
Event: instance.InstanceRemovedEventType,
Reduce: reduceInstanceRemovedHelper(WebKeyInstanceIDCol),
},
},
}}
}
func (p *webKeyProjection) reduceWebKeyAdded(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*webkey.AddedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-jei2K", "reduce.wrong.event.type %s", webkey.AddedEventType)
}
return handler.NewCreateStatement(e,
[]handler.Column{
handler.NewCol(WebKeyInstanceIDCol, e.Agg.InstanceID),
handler.NewCol(WebKeyKeyIDCol, e.Agg.ID),
handler.NewCol(WebKeyCreationDateCol, e.CreationDate()),
handler.NewCol(WebKeyChangeDateCol, e.CreationDate()),
handler.NewCol(WebKeySequenceCol, e.Sequence()),
handler.NewCol(WebKeyStateCol, domain.WebKeyStateInitial),
handler.NewCol(WebKeyPrivateKeyCol, e.PrivateKey),
handler.NewCol(WebKeyPublicKeyCol, e.PublicKey),
handler.NewCol(WebKeyConfigCol, e.Config),
handler.NewCol(WebKeyConfigTypeCol, e.ConfigType),
},
), nil
}
func (p *webKeyProjection) reduceWebKeyActivated(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*webkey.ActivatedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-iiQu2", "reduce.wrong.event.type %s", webkey.ActivatedEventType)
}
return handler.NewUpdateStatement(e,
[]handler.Column{
handler.NewCol(WebKeyChangeDateCol, e.CreationDate()),
handler.NewCol(WebKeySequenceCol, e.Sequence()),
handler.NewCol(WebKeyStateCol, domain.WebKeyStateActive),
},
[]handler.Condition{
handler.NewCond(WebKeyInstanceIDCol, e.Agg.InstanceID),
handler.NewCond(WebKeyKeyIDCol, e.Agg.ID),
},
), nil
}
func (p *webKeyProjection) reduceWebKeyDeactivated(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*webkey.DeactivatedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-zei3E", "reduce.wrong.event.type %s", webkey.DeactivatedEventType)
}
return handler.NewUpdateStatement(e,
[]handler.Column{
handler.NewCol(WebKeyChangeDateCol, e.CreationDate()),
handler.NewCol(WebKeySequenceCol, e.Sequence()),
handler.NewCol(WebKeyStateCol, domain.WebKeyStateInactive),
},
[]handler.Condition{
handler.NewCond(WebKeyInstanceIDCol, e.Agg.InstanceID),
handler.NewCond(WebKeyKeyIDCol, e.Agg.ID),
},
), nil
}
func (p *webKeyProjection) reduceWebKeyRemoved(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*webkey.RemovedEvent)
if !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-Zei6f", "reduce.wrong.event.type %s", webkey.RemovedEventType)
}
return handler.NewDeleteStatement(e,
[]handler.Condition{
handler.NewCond(WebKeyInstanceIDCol, e.Agg.InstanceID),
handler.NewCond(WebKeyKeyIDCol, e.Agg.ID),
},
), nil
}

154
internal/query/web_key.go Normal file
View File

@@ -0,0 +1,154 @@
package query
import (
"context"
"database/sql"
_ "embed"
"encoding/json"
"errors"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
var (
//go:embed web_key_by_state.sql
webKeyByStateQuery string
//go:embed web_key_list.sql
webKeyListQuery string
//go:embed web_key_public_keys.sql
webKeyPublicKeysQuery string
)
// GetPublicWebKeyByID gets a public key by it's keyID directly from the eventstore.
func (q *Queries) GetPublicWebKeyByID(ctx context.Context, keyID string) (webKey *jose.JSONWebKey, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
model := NewWebKeyReadModel(keyID, authz.GetInstance(ctx).InstanceID())
if err = q.eventstore.FilterToQueryReducer(ctx, model); err != nil {
return nil, err
}
if model.State == domain.WebKeyStateUnspecified || model.State == domain.WebKeyStateRemoved {
return nil, zerrors.ThrowNotFound(nil, "QUERY-AiCh0", "Errors.WebKey.NotFound")
}
return model.PublicKey, nil
}
// GetActiveSigningWebKey gets the current active signing key from the web_keys projection.
// The active signing key is eventual consistent.
func (q *Queries) GetActiveSigningWebKey(ctx context.Context) (webKey *jose.JSONWebKey, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
var keyValue *crypto.CryptoValue
err = q.client.QueryRowContext(ctx, func(row *sql.Row) error {
return row.Scan(&keyValue)
},
webKeyByStateQuery,
authz.GetInstance(ctx).InstanceID(),
domain.WebKeyStateActive,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, zerrors.ThrowInternal(err, "QUERY-Opoh7", "Errors.WebKey.NoActive")
}
return nil, zerrors.ThrowInternal(err, "QUERY-Shoo0", "Errors.Internal")
}
if err = crypto.DecryptJSON(keyValue, &webKey, q.keyEncryptionAlgorithm); err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-Iuk0s", "Errors.Internal")
}
return webKey, nil
}
type WebKeyDetails struct {
KeyID string
CreationDate time.Time
ChangeDate time.Time
Sequence int64
State domain.WebKeyState
Config crypto.WebKeyConfig
}
type WebKeyList struct {
Keys []WebKeyDetails
}
// ListWebKeys gets a list of [WebKeyDetails] for the complete instance from the web_keys projection.
// The list is eventual consistent.
func (q *Queries) ListWebKeys(ctx context.Context) (list []WebKeyDetails, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
err = q.client.QueryContext(ctx, func(rows *sql.Rows) error {
for rows.Next() {
var (
configData []byte
configType crypto.WebKeyConfigType
)
var details WebKeyDetails
if err = rows.Scan(
&details.KeyID,
&details.CreationDate,
&details.ChangeDate,
&details.Sequence,
&details.State,
&configData,
&configType,
); err != nil {
return err
}
details.Config, err = crypto.UnmarshalWebKeyConfig(configData, configType)
if err != nil {
return err
}
list = append(list, details)
}
return rows.Err()
},
webKeyListQuery,
authz.GetInstance(ctx).InstanceID(),
)
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-Ohl3A", "Errors.Internal")
}
return list, nil
}
// GetWebKeySet gets a JSON Web Key set from the web_keys projection.
// The set contains all existing public keys for the instance.
// The set is eventual consistent.
func (q *Queries) GetWebKeySet(ctx context.Context) (_ *jose.JSONWebKeySet, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
var keys []jose.JSONWebKey
err = q.client.QueryContext(ctx, func(rows *sql.Rows) error {
for rows.Next() {
var webKeyData []byte
if err = rows.Scan(&webKeyData); err != nil {
return err
}
var webKey jose.JSONWebKey
if err = json.Unmarshal(webKeyData, &webKey); err != nil {
return err
}
keys = append(keys, webKey)
}
return rows.Err()
},
webKeyPublicKeysQuery,
authz.GetInstance(ctx).InstanceID(),
)
if err != nil {
return nil, zerrors.ThrowInternal(err, "QUERY-Eeng7", "Errors.Internal")
}
return &jose.JSONWebKeySet{Keys: keys}, nil
}

View File

@@ -0,0 +1,5 @@
select private_key
from projections.web_keys
where instance_id = $1
and state = $2
limit 1;

View File

@@ -0,0 +1,4 @@
select key_id, creation_date, change_date, sequence, state, config, config_type
from projections.web_keys
where instance_id = $1
order by creation_date asc;

View File

@@ -0,0 +1,74 @@
package query
import (
"github.com/go-jose/go-jose/v4"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/webkey"
)
type WebKeyReadModel struct {
eventstore.ReadModel
State domain.WebKeyState
PrivateKey *crypto.CryptoValue
PublicKey *jose.JSONWebKey
Config crypto.WebKeyConfig
}
func NewWebKeyReadModel(keyID, resourceOwner string) *WebKeyReadModel {
return &WebKeyReadModel{
ReadModel: eventstore.ReadModel{
AggregateID: keyID,
ResourceOwner: resourceOwner,
},
}
}
func (wm *WebKeyReadModel) AppendEvents(events ...eventstore.Event) {
wm.ReadModel.AppendEvents(events...)
}
func (wm *WebKeyReadModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *webkey.AddedEvent:
if err := wm.reduceAdded(e); err != nil {
return err
}
case *webkey.ActivatedEvent:
wm.State = domain.WebKeyStateActive
case *webkey.DeactivatedEvent:
wm.State = domain.WebKeyStateInactive
case *webkey.RemovedEvent:
wm.State = domain.WebKeyStateRemoved
wm.PrivateKey = nil
wm.PublicKey = nil
}
}
return wm.ReadModel.Reduce()
}
func (wm *WebKeyReadModel) reduceAdded(e *webkey.AddedEvent) (err error) {
wm.State = domain.WebKeyStateInitial
wm.PrivateKey = e.PrivateKey
wm.PublicKey = e.PublicKey
wm.Config, err = crypto.UnmarshalWebKeyConfig(e.Config, e.ConfigType)
return err
}
func (wm *WebKeyReadModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(webkey.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
webkey.AddedEventType,
webkey.ActivatedEventType,
webkey.DeactivatedEventType,
webkey.RemovedEventType,
).
Builder()
}

View File

@@ -0,0 +1,3 @@
select public_key
from projections.web_keys
where instance_id = $1;

View File

@@ -0,0 +1,382 @@
package query
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"database/sql"
"database/sql/driver"
"encoding/json"
"io"
"regexp"
"strconv"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"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/eventstore"
"github.com/zitadel/zitadel/internal/repository/webkey"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestQueries_GetPublicWebKeyByID(t *testing.T) {
ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil)
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
require.NoError(t, err)
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
keyID string
}
tests := []struct {
name string
fields fields
args args
want *jose.JSONWebKey
wantErr error
}{
{
name: "filter error",
fields: fields{
eventstore: expectEventstore(
expectFilterError(io.ErrClosedPipe),
),
},
args: args{"key1"},
wantErr: io.ErrClosedPipe,
},
{
name: "not found error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
),
},
args: args{"key1"},
wantErr: zerrors.ThrowNotFound(nil, "QUERY-AiCh0", "Errors.WebKey.NotFound"),
},
{
name: "removed, not found error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(mustNewWebkeyAddedEvent(ctx,
webkey.NewAggregate("key1", "instance1"),
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
KeyID: "encKey",
Crypted: []byte("crypted"),
},
&jose.JSONWebKey{
Key: &key.PublicKey,
KeyID: "key1",
Algorithm: string(jose.ES384),
Use: crypto.KeyUsageSigning.String(),
},
&crypto.WebKeyECDSAConfig{
Curve: crypto.EllipticCurveP384,
},
)),
eventFromEventPusher(webkey.NewRemovedEvent(ctx,
webkey.NewAggregate("key1", "instance1"),
)),
),
),
},
args: args{"key1"},
wantErr: zerrors.ThrowNotFound(nil, "QUERY-AiCh0", "Errors.WebKey.NotFound"),
},
{
name: "ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(mustNewWebkeyAddedEvent(ctx,
webkey.NewAggregate("key1", "instance1"),
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "alg",
KeyID: "encKey",
Crypted: []byte("crypted"),
},
&jose.JSONWebKey{
Key: &key.PublicKey,
KeyID: "key1",
Algorithm: string(jose.ES384),
Use: crypto.KeyUsageSigning.String(),
},
&crypto.WebKeyECDSAConfig{
Curve: crypto.EllipticCurveP384,
},
)),
),
),
},
args: args{"key1"},
want: &jose.JSONWebKey{
Key: &key.PublicKey,
KeyID: "key1",
Algorithm: string(jose.ES384),
Use: crypto.KeyUsageSigning.String(),
Certificates: []*x509.Certificate{},
CertificateThumbprintSHA1: []byte{},
CertificateThumbprintSHA256: []byte{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
q := &Queries{
eventstore: tt.fields.eventstore(t),
}
got, err := q.GetPublicWebKeyByID(ctx, tt.args.keyID)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
}
}
func mustNewWebkeyAddedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
privateKey *crypto.CryptoValue,
publicKey *jose.JSONWebKey,
config crypto.WebKeyConfig) *webkey.AddedEvent {
event, err := webkey.NewAddedEvent(ctx, aggregate, privateKey, publicKey, config)
if err != nil {
panic(err)
}
return event
}
func TestQueries_GetActiveSigningWebKey(t *testing.T) {
ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil)
expQuery := regexp.QuoteMeta(webKeyByStateQuery)
queryArgs := []driver.Value{"instance1", domain.WebKeyStateActive}
cols := []string{"private_key"}
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
encryptedPrivate, _, err := crypto.GenerateEncryptedWebKey("key1", alg, &crypto.WebKeyED25519Config{})
require.NoError(t, err)
var expectedWebKey *jose.JSONWebKey
err = crypto.DecryptJSON(encryptedPrivate, &expectedWebKey, alg)
require.NoError(t, err)
tests := []struct {
name string
mock sqlExpectation
want *jose.JSONWebKey
wantErr error
}{
{
name: "no active error",
mock: mockQueryErr(expQuery, sql.ErrNoRows, queryArgs...),
wantErr: zerrors.ThrowInternal(sql.ErrNoRows, "QUERY-Opoh7", "Errors.WebKey.NoActive"),
},
{
name: "internal error",
mock: mockQueryErr(expQuery, sql.ErrConnDone, queryArgs...),
wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Shoo0", "Errors.Internal"),
},
{
name: "invalid crypto value error",
mock: mockQuery(expQuery, cols, []driver.Value{&crypto.CryptoValue{}}, queryArgs...),
wantErr: zerrors.ThrowInvalidArgument(nil, "CRYPT-Nx7XlT", "value was encrypted with a different key"),
},
{
name: "found, ok",
mock: mockQuery(expQuery, cols, []driver.Value{encryptedPrivate}, queryArgs...),
want: expectedWebKey,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
execMock(t, tt.mock, func(db *sql.DB) {
q := &Queries{
client: &database.DB{
DB: db,
Database: &prepareDB{},
},
keyEncryptionAlgorithm: alg,
}
got, err := q.GetActiveSigningWebKey(ctx)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
})
}
}
func TestQueries_ListWebKeys(t *testing.T) {
ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil)
expQuery := regexp.QuoteMeta(webKeyListQuery)
queryArgs := []driver.Value{"instance1"}
cols := []string{"key_id", "creation_date", "change_date", "sequence", "state", "config", "config_type"}
webKeyConfig := &crypto.WebKeyRSAConfig{
Bits: crypto.RSABits4096,
Hasher: crypto.RSAHasherSHA512,
}
webKeyConfigJSON, err := json.Marshal(webKeyConfig)
require.NoError(t, err)
tests := []struct {
name string
mock sqlExpectation
want []WebKeyDetails
wantErr error
}{
{
name: "internal error",
mock: mockQueryErr(expQuery, sql.ErrConnDone, queryArgs...),
wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Ohl3A", "Errors.Internal"),
},
{
name: "invalid json error",
mock: mockQueriesScanErr(expQuery, cols, [][]driver.Value{
{
"key1",
time.Unix(1, 2),
time.Unix(3, 4),
1,
domain.WebKeyStateActive,
"~~~~~",
crypto.WebKeyConfigTypeRSA,
},
}, queryArgs...),
wantErr: zerrors.ThrowInternal(err, "QUERY-Ohl3A", "Errors.Internal"),
},
{
name: "ok",
mock: mockQueries(expQuery, cols, [][]driver.Value{
{
"key1",
time.Unix(1, 2),
time.Unix(3, 4),
1,
domain.WebKeyStateActive,
webKeyConfigJSON,
crypto.WebKeyConfigTypeRSA,
},
{
"key2",
time.Unix(5, 6),
time.Unix(7, 8),
2,
domain.WebKeyStateInitial,
webKeyConfigJSON,
crypto.WebKeyConfigTypeRSA,
},
}, queryArgs...),
want: []WebKeyDetails{
{
KeyID: "key1",
CreationDate: time.Unix(1, 2),
ChangeDate: time.Unix(3, 4),
Sequence: 1,
State: domain.WebKeyStateActive,
Config: webKeyConfig,
},
{
KeyID: "key2",
CreationDate: time.Unix(5, 6),
ChangeDate: time.Unix(7, 8),
Sequence: 2,
State: domain.WebKeyStateInitial,
Config: webKeyConfig,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
execMock(t, tt.mock, func(db *sql.DB) {
q := &Queries{
client: &database.DB{
DB: db,
Database: &prepareDB{},
},
}
got, err := q.ListWebKeys(ctx)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
})
}
}
func TestQueries_GetWebKeySet(t *testing.T) {
ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil)
expQuery := regexp.QuoteMeta(webKeyPublicKeysQuery)
queryArgs := []driver.Value{"instance1"}
cols := []string{"public_key"}
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
conf := &crypto.WebKeyED25519Config{}
expectedKeySet := &jose.JSONWebKeySet{
Keys: make([]jose.JSONWebKey, 3),
}
expectedRows := make([][]driver.Value, 3)
for i := 0; i < 3; i++ {
_, pubKey, err := crypto.GenerateEncryptedWebKey(strconv.Itoa(i), alg, conf)
require.NoError(t, err)
pubKeyJSON, err := json.Marshal(pubKey)
require.NoError(t, err)
err = json.Unmarshal(pubKeyJSON, &expectedKeySet.Keys[i])
require.NoError(t, err)
expectedRows[i] = []driver.Value{pubKeyJSON}
}
tests := []struct {
name string
mock sqlExpectation
want *jose.JSONWebKeySet
wantErr error
}{
{
name: "internal error",
mock: mockQueryErr(expQuery, sql.ErrConnDone, queryArgs...),
wantErr: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-Eeng7", "Errors.Internal"),
},
{
name: "invalid json error",
mock: mockQueriesScanErr(expQuery, cols, [][]driver.Value{{"~~~"}}, queryArgs...),
wantErr: zerrors.ThrowInternal(nil, "QUERY-Eeng7", "Errors.Internal"),
},
{
name: "ok",
mock: mockQueries(expQuery, cols, expectedRows, queryArgs...),
want: expectedKeySet,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
execMock(t, tt.mock, func(db *sql.DB) {
q := &Queries{
client: &database.DB{
DB: db,
Database: &prepareDB{},
},
}
got, err := q.GetWebKeySet(ctx)
require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got)
})
})
}
}