From 64a3bb3149ee740e164122b17fc4985980a5c2a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 14 Aug 2024 17:18:14 +0300 Subject: [PATCH] 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 --- cmd/defaults.yaml | 17 + cmd/initialise/config.go | 6 +- cmd/mirror/config.go | 1 + cmd/ready/config.go | 1 + cmd/setup/config.go | 2 + cmd/start/config.go | 1 + cmd/start/start.go | 4 + docs/docusaurus.config.js | 8 + docs/sidebars.js | 14 + internal/api/grpc/feature/v2/converter.go | 2 + .../api/grpc/feature/v2/converter_test.go | 10 + internal/api/grpc/feature/v2beta/converter.go | 2 + .../api/grpc/feature/v2beta/converter_test.go | 10 + .../v3alpha/execution_integration_test.go | 20 +- .../execution_target_integration_test.go | 2 +- .../action/v3alpha/query_integration_test.go | 6 +- .../action/v3alpha/target_integration_test.go | 8 +- .../api/grpc/resources/webkey/v3/server.go | 47 ++ .../api/grpc/resources/webkey/v3/webkey.go | 87 ++ .../resources/webkey/v3/webkey_converter.go | 173 ++++ .../webkey/v3/webkey_converter_test.go | 529 ++++++++++++ .../webkey/v3/webkey_integration_test.go | 245 ++++++ internal/api/oidc/key.go | 3 +- internal/api/oidc/key_test.go | 12 +- internal/api/saml/certificate.go | 19 +- internal/api/saml/storage.go | 6 +- internal/command/command.go | 3 + internal/command/instance.go | 46 +- internal/command/instance_features.go | 25 +- internal/command/instance_features_model.go | 5 + internal/command/key_pair.go | 9 +- internal/command/key_pair_model.go | 3 +- internal/command/web_key.go | 188 +++++ internal/command/web_key_model.go | 131 +++ internal/command/web_key_test.go | 754 ++++++++++++++++++ internal/crypto/crypto.go | 19 + internal/crypto/ellipticcurve_enumer.go | 116 +++ internal/crypto/rsabits_enumer.go | 136 ++++ internal/crypto/rsahasher_enumer.go | 116 +++ internal/crypto/web_key.go | 238 ++++++ internal/crypto/web_key_test.go | 269 +++++++ internal/crypto/webkeyconfigtype_enumer.go | 116 +++ internal/domain/key_pair.go | 25 +- internal/domain/web_key.go | 11 + internal/eventstore/aggregate.go | 16 +- .../repository/mock/repository.mock.impl.go | 2 +- internal/feature/feature.go | 2 + internal/feature/key_enumer.go | 12 +- internal/integration/client.go | 13 +- internal/query/certificate.go | 3 +- internal/query/certificate_test.go | 3 +- internal/query/instance_features.go | 1 + internal/query/instance_features_model.go | 3 + internal/query/key.go | 11 +- internal/query/key_test.go | 13 +- .../query/projection/instance_features.go | 4 + internal/query/projection/key_test.go | 13 +- internal/query/projection/projection.go | 3 + internal/query/projection/web_key.go | 165 ++++ internal/query/web_key.go | 154 ++++ internal/query/web_key_by_state.sql | 5 + internal/query/web_key_list.sql | 4 + internal/query/web_key_model.go | 74 ++ internal/query/web_key_public_keys.sql | 3 + internal/query/web_key_test.go | 382 +++++++++ .../feature/feature_v2/eventstore.go | 1 + .../repository/feature/feature_v2/feature.go | 1 + internal/repository/keypair/key_pair.go | 5 +- internal/repository/webkey/aggregate.go | 25 + internal/repository/webkey/eventstore.go | 12 + internal/repository/webkey/webkey.go | 160 ++++ internal/static/i18n/bg.yaml | 14 + internal/static/i18n/cs.yaml | 13 + internal/static/i18n/de.yaml | 13 + internal/static/i18n/en.yaml | 13 + internal/static/i18n/es.yaml | 13 + internal/static/i18n/fr.yaml | 268 ++++--- internal/static/i18n/it.yaml | 25 +- internal/static/i18n/ja.yaml | 13 + internal/static/i18n/mk.yaml | 13 + internal/static/i18n/nl.yaml | 13 + internal/static/i18n/pl.yaml | 13 + internal/static/i18n/pt.yaml | 13 + internal/static/i18n/ru.yaml | 14 + internal/static/i18n/sv.yaml | 13 + internal/static/i18n/zh.yaml | 25 +- proto/zitadel/feature/v2/instance.proto | 14 + proto/zitadel/feature/v2beta/instance.proto | 14 + .../resources/webkey/v3alpha/config.proto | 41 + .../resources/webkey/v3alpha/key.proto | 31 + .../webkey/v3alpha/webkey_service.proto | 278 +++++++ 91 files changed, 5133 insertions(+), 256 deletions(-) create mode 100644 internal/api/grpc/resources/webkey/v3/server.go create mode 100644 internal/api/grpc/resources/webkey/v3/webkey.go create mode 100644 internal/api/grpc/resources/webkey/v3/webkey_converter.go create mode 100644 internal/api/grpc/resources/webkey/v3/webkey_converter_test.go create mode 100644 internal/api/grpc/resources/webkey/v3/webkey_integration_test.go create mode 100644 internal/command/web_key.go create mode 100644 internal/command/web_key_model.go create mode 100644 internal/command/web_key_test.go create mode 100644 internal/crypto/ellipticcurve_enumer.go create mode 100644 internal/crypto/rsabits_enumer.go create mode 100644 internal/crypto/rsahasher_enumer.go create mode 100644 internal/crypto/web_key.go create mode 100644 internal/crypto/web_key_test.go create mode 100644 internal/crypto/webkeyconfigtype_enumer.go create mode 100644 internal/domain/web_key.go create mode 100644 internal/query/projection/web_key.go create mode 100644 internal/query/web_key.go create mode 100644 internal/query/web_key_by_state.sql create mode 100644 internal/query/web_key_list.sql create mode 100644 internal/query/web_key_model.go create mode 100644 internal/query/web_key_public_keys.sql create mode 100644 internal/query/web_key_test.go create mode 100644 internal/repository/webkey/aggregate.go create mode 100644 internal/repository/webkey/eventstore.go create mode 100644 internal/repository/webkey/webkey.go create mode 100644 proto/zitadel/resources/webkey/v3alpha/config.proto create mode 100644 proto/zitadel/resources/webkey/v3alpha/key.proto create mode 100644 proto/zitadel/resources/webkey/v3alpha/webkey_service.proto diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 6afbaddbd..45d598ee8 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -757,6 +757,19 @@ DefaultInstance: MaxOTPAttempts: 0 # ZITADEL_DEFAULTINSTANCE_LOCKOUTPOLICY_MAXOTPATTEMPTS ShouldShowLockoutFailure: true # ZITADEL_DEFAULTINSTANCE_LOCKOUTPOLICY_SHOULDSHOWLOCKOUTFAILURE EmailTemplate: CjwhZG9jdHlwZSBodG1sPgo8aHRtbCB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgeG1sbnM6dj0idXJuOnNjaGVtYXMtbWljcm9zb2Z0LWNvbTp2bWwiIHhtbG5zOm89InVybjpzY2hlbWFzLW1pY3Jvc29mdC1jb206b2ZmaWNlOm9mZmljZSI+CjxoZWFkPgogIDx0aXRsZT4KCiAgPC90aXRsZT4KICA8IS0tW2lmICFtc29dPjwhLS0+CiAgPG1ldGEgaHR0cC1lcXVpdj0iWC1VQS1Db21wYXRpYmxlIiBjb250ZW50PSJJRT1lZGdlIj4KICA8IS0tPCFbZW5kaWZdLS0+CiAgPG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9VVRGLTgiPgogIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MSI+CiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KICAgICNvdXRsb29rIGEgeyBwYWRkaW5nOjA7IH0KICAgIGJvZHkgeyBtYXJnaW46MDtwYWRkaW5nOjA7LXdlYmtpdC10ZXh0LXNpemUtYWRqdXN0OjEwMCU7LW1zLXRleHQtc2l6ZS1hZGp1c3Q6MTAwJTsgfQogICAgdGFibGUsIHRkIHsgYm9yZGVyLWNvbGxhcHNlOmNvbGxhcHNlO21zby10YWJsZS1sc3BhY2U6MHB0O21zby10YWJsZS1yc3BhY2U6MHB0OyB9CiAgICBpbWcgeyBib3JkZXI6MDtoZWlnaHQ6YXV0bztsaW5lLWhlaWdodDoxMDAlOyBvdXRsaW5lOm5vbmU7dGV4dC1kZWNvcmF0aW9uOm5vbmU7LW1zLWludGVycG9sYXRpb24tbW9kZTpiaWN1YmljOyB9CiAgICBwIHsgZGlzcGxheTpibG9jazttYXJnaW46MTNweCAwOyB9CiAgPC9zdHlsZT4KICA8IS0tW2lmIG1zb10+CiAgPHhtbD4KICAgIDxvOk9mZmljZURvY3VtZW50U2V0dGluZ3M+CiAgICAgIDxvOkFsbG93UE5HLz4KICAgICAgPG86UGl4ZWxzUGVySW5jaD45NjwvbzpQaXhlbHNQZXJJbmNoPgogICAgPC9vOk9mZmljZURvY3VtZW50U2V0dGluZ3M+CiAgPC94bWw+CiAgPCFbZW5kaWZdLS0+CiAgPCEtLVtpZiBsdGUgbXNvIDExXT4KICA8c3R5bGUgdHlwZT0idGV4dC9jc3MiPgogICAgLm1qLW91dGxvb2stZ3JvdXAtZml4IHsgd2lkdGg6MTAwJSAhaW1wb3J0YW50OyB9CiAgPC9zdHlsZT4KICA8IVtlbmRpZl0tLT4KCgogIDxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+CiAgICBAbWVkaWEgb25seSBzY3JlZW4gYW5kIChtaW4td2lkdGg6NDgwcHgpIHsKICAgICAgLm1qLWNvbHVtbi1wZXItMTAwIHsgd2lkdGg6MTAwJSAhaW1wb3J0YW50OyBtYXgtd2lkdGg6IDEwMCU7IH0KICAgICAgLm1qLWNvbHVtbi1wZXItNjAgeyB3aWR0aDo2MCUgIWltcG9ydGFudDsgbWF4LXdpZHRoOiA2MCU7IH0KICAgIH0KICA8L3N0eWxlPgoKCiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCgoKICAgIEBtZWRpYSBvbmx5IHNjcmVlbiBhbmQgKG1heC13aWR0aDo0ODBweCkgewogICAgICB0YWJsZS5tai1mdWxsLXdpZHRoLW1vYmlsZSB7IHdpZHRoOiAxMDAlICFpbXBvcnRhbnQ7IH0KICAgICAgdGQubWotZnVsbC13aWR0aC1tb2JpbGUgeyB3aWR0aDogYXV0byAhaW1wb3J0YW50OyB9CiAgICB9CgogIDwvc3R5bGU+CiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4uc2hhZG93IGEgewogICAgYm94LXNoYWRvdzogMHB4IDNweCAxcHggLTJweCByZ2JhKDAsIDAsIDAsIDAuMiksIDBweCAycHggMnB4IDBweCByZ2JhKDAsIDAsIDAsIDAuMTQpLCAwcHggMXB4IDVweCAwcHggcmdiYSgwLCAwLCAwLCAwLjEyKTsKICB9PC9zdHlsZT4KCiAge3tpZiAuRm9udFVSTH19CiAgPHN0eWxlPgogICAgQGZvbnQtZmFjZSB7CiAgICAgIGZvbnQtZmFtaWx5OiAne3suRm9udEZhY2VGYW1pbHl9fSc7CiAgICAgIGZvbnQtc3R5bGU6IG5vcm1hbDsKICAgICAgZm9udC1kaXNwbGF5OiBzd2FwOwogICAgICBzcmM6IHVybCh7ey5Gb250VVJMfX0pOwogICAgfQogIDwvc3R5bGU+CiAge3tlbmR9fQoKPC9oZWFkPgo8Ym9keSBzdHlsZT0id29yZC1zcGFjaW5nOm5vcm1hbDsiPgoKCjxkaXYKICAgICAgICBzdHlsZT0iIgo+CgogIDx0YWJsZQogICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9ImJhY2tncm91bmQ6e3suQmFja2dyb3VuZENvbG9yfX07YmFja2dyb3VuZC1jb2xvcjp7ey5CYWNrZ3JvdW5kQ29sb3J9fTt3aWR0aDoxMDAlO2JvcmRlci1yYWRpdXM6MTZweDsiCiAgPgogICAgPHRib2R5PgogICAgPHRyPgogICAgICA8dGQ+CgoKICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIGNsYXNzPSIiIHN0eWxlPSJ3aWR0aDo4MDBweDsiIHdpZHRoPSI4MDAiID48dHI+PHRkIHN0eWxlPSJsaW5lLWhlaWdodDowcHg7Zm9udC1zaXplOjBweDttc28tbGluZS1oZWlnaHQtcnVsZTpleGFjdGx5OyI+PCFbZW5kaWZdLS0+CgoKICAgICAgICA8ZGl2ICBzdHlsZT0ibWFyZ2luOjBweCBhdXRvO2JvcmRlci1yYWRpdXM6MTZweDttYXgtd2lkdGg6ODAwcHg7Ij4KCiAgICAgICAgICA8dGFibGUKICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9IndpZHRoOjEwMCU7Ym9yZGVyLXJhZGl1czoxNnB4OyIKICAgICAgICAgID4KICAgICAgICAgICAgPHRib2R5PgogICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICBzdHlsZT0iZGlyZWN0aW9uOmx0cjtmb250LXNpemU6MHB4O3BhZGRpbmc6MjBweCAwO3BhZGRpbmctbGVmdDowO3RleHQtYWxpZ246Y2VudGVyOyIKICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgcm9sZT0icHJlc2VudGF0aW9uIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCI+PHRyPjx0ZCBjbGFzcz0iIiB3aWR0aD0iODAwcHgiID48IVtlbmRpZl0tLT4KCiAgICAgICAgICAgICAgICA8dGFibGUKICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9IndpZHRoOjEwMCU7IgogICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICA8dGQ+CgoKICAgICAgICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjx0YWJsZSBhbGlnbj0iY2VudGVyIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgY2xhc3M9IiIgc3R5bGU9IndpZHRoOjgwMHB4OyIgd2lkdGg9IjgwMCIgPjx0cj48dGQgc3R5bGU9ImxpbmUtaGVpZ2h0OjBweDtmb250LXNpemU6MHB4O21zby1saW5lLWhlaWdodC1ydWxlOmV4YWN0bHk7Ij48IVtlbmRpZl0tLT4KCgogICAgICAgICAgICAgICAgICAgICAgPGRpdiAgc3R5bGU9Im1hcmdpbjowcHggYXV0bzttYXgtd2lkdGg6ODAwcHg7Ij4KCiAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIGJvcmRlcj0iMCIgY2VsbHBhZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiByb2xlPSJwcmVzZW50YXRpb24iIHN0eWxlPSJ3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgIDx0Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImRpcmVjdGlvbjpsdHI7Zm9udC1zaXplOjBweDtwYWRkaW5nOjA7dGV4dC1hbGlnbjpjZW50ZXI7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgcm9sZT0icHJlc2VudGF0aW9uIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCI+PHRyPjx0ZCBjbGFzcz0iIiBzdHlsZT0id2lkdGg6ODAwcHg7IiA+PCFbZW5kaWZdLS0+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8ZGl2CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgY2xhc3M9Im1qLWNvbHVtbi1wZXItMTAwIG1qLW91dGxvb2stZ3JvdXAtZml4IiBzdHlsZT0iZm9udC1zaXplOjA7bGluZS1oZWlnaHQ6MDt0ZXh0LWFsaWduOmxlZnQ7ZGlzcGxheTppbmxpbmUtYmxvY2s7d2lkdGg6MTAwJTtkaXJlY3Rpb246bHRyOyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjx0YWJsZSBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiA+PHRyPjx0ZCBzdHlsZT0idmVydGljYWwtYWxpZ246dG9wO3dpZHRoOjgwMHB4OyIgPjwhW2VuZGlmXS0tPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8ZGl2CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBjbGFzcz0ibWotY29sdW1uLXBlci0xMDAgbWotb3V0bG9vay1ncm91cC1maXgiIHN0eWxlPSJmb250LXNpemU6MHB4O3RleHQtYWxpZ246bGVmdDtkaXJlY3Rpb246bHRyO2Rpc3BsYXk6aW5saW5lLWJsb2NrO3ZlcnRpY2FsLWFsaWduOnRvcDt3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGJvcmRlcj0iMCIgY2VsbHBhZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiByb2xlPSJwcmVzZW50YXRpb24iIHdpZHRoPSIxMDAlIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQgIHN0eWxlPSJ2ZXJ0aWNhbC1hbGlnbjp0b3A7cGFkZGluZzowOyI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB7e2lmIC5Mb2dvVVJMfX0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iIiB3aWR0aD0iMTAwJSIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRib2R5PgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgc3R5bGU9ImZvbnQtc2l6ZTowcHg7cGFkZGluZzo1MHB4IDAgMzBweCAwO3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iYm9yZGVyLWNvbGxhcHNlOmNvbGxhcHNlO2JvcmRlci1zcGFjaW5nOjBweDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZCAgc3R5bGU9IndpZHRoOjE4MHB4OyI+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGltZwogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBoZWlnaHQ9ImF1dG8iIHNyYz0ie3suTG9nb1VSTH19IiBzdHlsZT0iYm9yZGVyOjA7Ym9yZGVyLXJhZGl1czo4cHg7ZGlzcGxheTpibG9jaztvdXRsaW5lOm5vbmU7dGV4dC1kZWNvcmF0aW9uOm5vbmU7aGVpZ2h0OmF1dG87d2lkdGg6MTAwJTtmb250LXNpemU6MTNweDsiIHdpZHRoPSIxODAiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3Rib2R5PgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90ZD4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAge3tlbmR9fQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L2Rpdj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPCEtLVtpZiBtc28gfCBJRV0+PC90ZD48L3RyPjwvdGFibGU+PCFbZW5kaWZdLS0+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvZGl2PgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPCEtLVtpZiBtc28gfCBJRV0+PC90ZD48L3RyPjwvdGFibGU+PCFbZW5kaWZdLS0+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgPC90Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICAgICAgICA8L2Rpdj4KCgogICAgICAgICAgICAgICAgICAgICAgPCEtLVtpZiBtc28gfCBJRV0+PC90ZD48L3RyPjwvdGFibGU+PCFbZW5kaWZdLS0+CgoKICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICA8L3RyPgogICAgICAgICAgICAgICAgICA8L3Rib2R5PgogICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48L3RkPjwvdHI+PHRyPjx0ZCBjbGFzcz0iIiB3aWR0aD0iODAwcHgiID48IVtlbmRpZl0tLT4KCiAgICAgICAgICAgICAgICA8dGFibGUKICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9IndpZHRoOjEwMCU7IgogICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICA8dGQ+CgoKICAgICAgICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjx0YWJsZSBhbGlnbj0iY2VudGVyIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgY2xhc3M9IiIgc3R5bGU9IndpZHRoOjgwMHB4OyIgd2lkdGg9IjgwMCIgPjx0cj48dGQgc3R5bGU9ImxpbmUtaGVpZ2h0OjBweDtmb250LXNpemU6MHB4O21zby1saW5lLWhlaWdodC1ydWxlOmV4YWN0bHk7Ij48IVtlbmRpZl0tLT4KCgogICAgICAgICAgICAgICAgICAgICAgPGRpdiAgc3R5bGU9Im1hcmdpbjowcHggYXV0bzttYXgtd2lkdGg6ODAwcHg7Ij4KCiAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIGJvcmRlcj0iMCIgY2VsbHBhZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiByb2xlPSJwcmVzZW50YXRpb24iIHN0eWxlPSJ3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgIDx0Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImRpcmVjdGlvbjpsdHI7Zm9udC1zaXplOjBweDtwYWRkaW5nOjA7dGV4dC1hbGlnbjpjZW50ZXI7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgcm9sZT0icHJlc2VudGF0aW9uIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCI+PHRyPjx0ZCBjbGFzcz0iIiBzdHlsZT0idmVydGljYWwtYWxpZ246dG9wO3dpZHRoOjQ4MHB4OyIgPjwhW2VuZGlmXS0tPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGRpdgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNsYXNzPSJtai1jb2x1bW4tcGVyLTYwIG1qLW91dGxvb2stZ3JvdXAtZml4IiBzdHlsZT0iZm9udC1zaXplOjBweDt0ZXh0LWFsaWduOmxlZnQ7ZGlyZWN0aW9uOmx0cjtkaXNwbGF5OmlubGluZS1ibG9jazt2ZXJ0aWNhbC1hbGlnbjp0b3A7d2lkdGg6MTAwJTsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiB3aWR0aD0iMTAwJSIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZCAgc3R5bGU9InZlcnRpY2FsLWFsaWduOnRvcDtwYWRkaW5nOjA7Ij4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iIiB3aWR0aD0iMTAwJSIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGJvZHk+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBhbGlnbj0iY2VudGVyIiBzdHlsZT0iZm9udC1zaXplOjBweDtwYWRkaW5nOjEwcHggMjVweDt3b3JkLWJyZWFrOmJyZWFrLXdvcmQ7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDxkaXYKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHN0eWxlPSJmb250LWZhbWlseTp7ey5Gb250RmFtaWx5fX07Zm9udC1zaXplOjI0cHg7Zm9udC13ZWlnaHQ6NTAwO2xpbmUtaGVpZ2h0OjE7dGV4dC1hbGlnbjpjZW50ZXI7Y29sb3I6e3suRm9udENvbG9yfX07IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID57ey5HcmVldGluZ319PC9kaXY+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIHN0eWxlPSJmb250LXNpemU6MHB4O3BhZGRpbmc6MTBweCAyNXB4O3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGRpdgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImZvbnQtZmFtaWx5Ont7LkZvbnRGYW1pbHl9fTtmb250LXNpemU6MTZweDtmb250LXdlaWdodDpsaWdodDtsaW5lLWhlaWdodDoxLjU7dGV4dC1hbGlnbjpjZW50ZXI7Y29sb3I6e3suRm9udENvbG9yfX07IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID57ey5UZXh0fX08L2Rpdj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgoKCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIHZlcnRpY2FsLWFsaWduPSJtaWRkbGUiIGNsYXNzPSJzaGFkb3ciIHN0eWxlPSJmb250LXNpemU6MHB4O3BhZGRpbmc6MTBweCAyNXB4O3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iYm9yZGVyLWNvbGxhcHNlOnNlcGFyYXRlO2xpbmUtaGVpZ2h0OjEwMCU7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYmdjb2xvcj0ie3suUHJpbWFyeUNvbG9yfX0iIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9ImJvcmRlcjpub25lO2JvcmRlci1yYWRpdXM6NnB4O2N1cnNvcjphdXRvO21zby1wYWRkaW5nLWFsdDoxMHB4IDI1cHg7YmFja2dyb3VuZDp7ey5QcmltYXJ5Q29sb3J9fTsiIHZhbGlnbj0ibWlkZGxlIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGEKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGhyZWY9Int7LlVSTH19IiByZWw9Im5vb3BlbmVyIG5vcmVmZXJyZXIgbm90cmFjayIgc3R5bGU9ImRpc3BsYXk6aW5saW5lLWJsb2NrO2JhY2tncm91bmQ6e3suUHJpbWFyeUNvbG9yfX07Y29sb3I6I2ZmZmZmZjtmb250LWZhbWlseTp7ey5Gb250RmFtaWx5fX07Zm9udC1zaXplOjE0cHg7Zm9udC13ZWlnaHQ6NTAwO2xpbmUtaGVpZ2h0OjEyMCU7bWFyZ2luOjA7dGV4dC1kZWNvcmF0aW9uOm5vbmU7dGV4dC10cmFuc2Zvcm06bm9uZTtwYWRkaW5nOjEwcHggMjVweDttc28tcGFkZGluZy1hbHQ6MHB4O2JvcmRlci1yYWRpdXM6NnB4OyIgdGFyZ2V0PSJfYmxhbmsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAge3suQnV0dG9uVGV4dH19CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC9hPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90ZD4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB7e2lmIC5JbmNsdWRlRm9vdGVyfX0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgc3R5bGU9ImZvbnQtc2l6ZTowcHg7cGFkZGluZzoxMHB4IDI1cHg7cGFkZGluZy10b3A6MjBweDtwYWRkaW5nLXJpZ2h0OjIwcHg7cGFkZGluZy1ib3R0b206MjBweDtwYWRkaW5nLWxlZnQ6MjBweDt3b3JkLWJyZWFrOmJyZWFrLXdvcmQ7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDxwCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBzdHlsZT0iYm9yZGVyLXRvcDpzb2xpZCAycHggI2RiZGJkYjtmb250LXNpemU6MXB4O21hcmdpbjowcHggYXV0bzt3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC9wPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHN0eWxlPSJib3JkZXItdG9wOnNvbGlkIDJweCAjZGJkYmRiO2ZvbnQtc2l6ZToxcHg7bWFyZ2luOjBweCBhdXRvO3dpZHRoOjQ0MHB4OyIgcm9sZT0icHJlc2VudGF0aW9uIiB3aWR0aD0iNDQwcHgiID48dHI+PHRkIHN0eWxlPSJoZWlnaHQ6MDtsaW5lLWhlaWdodDowOyI+ICZuYnNwOwogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+PC90cj48L3RhYmxlPjwhW2VuZGlmXS0tPgoKCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgc3R5bGU9ImZvbnQtc2l6ZTowcHg7cGFkZGluZzoxNnB4O3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGRpdgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImZvbnQtZmFtaWx5Ont7LkZvbnRGYW1pbHl9fTtmb250LXNpemU6MTNweDtsaW5lLWhlaWdodDoxO3RleHQtYWxpZ246Y2VudGVyO2NvbG9yOnt7LkZvbnRDb2xvcn19OyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+e3suRm9vdGVyVGV4dH19PC9kaXY+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC9kaXY+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48L3RkPjwvdHI+PC90YWJsZT48IVtlbmRpZl0tLT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KICAgICAgICAgICAgICAgICAgICAgICAgICA8L3Rib2R5PgogICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgIDwvZGl2PgoKCiAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48L3RkPjwvdHI+PC90YWJsZT48IVtlbmRpZl0tLT4KCgogICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjwvdGQ+PC90cj48L3RhYmxlPjwhW2VuZGlmXS0tPgogICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICA8L2Rpdj4KCgogICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjwvdGQ+PC90cj48L3RhYmxlPjwhW2VuZGlmXS0tPgoKCiAgICAgIDwvdGQ+CiAgICA8L3RyPgogICAgPC90Ym9keT4KICA8L3RhYmxlPgoKPC9kaXY+Cgo8L2JvZHk+CjwvaHRtbD4K # ZITADEL_DEFAULTINSTANCE_EMAILTEMPLATE + + # WebKeys configures the OIDC token signing keys that are generated when a new instance is created. + # WebKeys are still in alpha, so the config is disabled here. This will prevent generation of keys for now. + # WebKeys: + # Type: "rsa" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_TYPE + # Config: + # Bits: "2048" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_BITS + # Hasher: "sha256" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_HASHER + # WebKeys: + # Type: "ecdsa" + # Config: + # Curve: "P256" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_CURVE + # Sets the default values for lifetime and expiration for OIDC in each newly created instance # This default can be overwritten for each instance during runtime # Overwrites the system defaults @@ -1002,6 +1015,9 @@ InternalAuthZ: - "iam.feature.delete" - "iam.restrictions.read" - "iam.restrictions.write" + - "iam.web_key.write" + - "iam.web_key.delete" + - "iam.web_key.read" - "org.read" - "org.global.read" - "org.create" @@ -1078,6 +1094,7 @@ InternalAuthZ: - "iam.flow.read" - "iam.restrictions.read" - "iam.feature.read" + - "iam.web_key.read" - "org.read" - "org.member.read" - "org.idp.read" diff --git a/cmd/initialise/config.go b/cmd/initialise/config.go index b3499ea7a..3fe717386 100644 --- a/cmd/initialise/config.go +++ b/cmd/initialise/config.go @@ -1,6 +1,7 @@ package initialise import ( + "github.com/mitchellh/mapstructure" "github.com/spf13/viper" "github.com/zitadel/logging" @@ -17,7 +18,10 @@ type Config struct { func MustNewConfig(v *viper.Viper) *Config { config := new(Config) err := v.Unmarshal(config, - viper.DecodeHook(database.DecodeHook), + viper.DecodeHook(mapstructure.ComposeDecodeHookFunc( + database.DecodeHook, + mapstructure.TextUnmarshallerHookFunc(), + )), ) logging.OnError(err).Fatal("unable to read config") diff --git a/cmd/mirror/config.go b/cmd/mirror/config.go index 5d2ec8fac..cc9800086 100644 --- a/cmd/mirror/config.go +++ b/cmd/mirror/config.go @@ -74,6 +74,7 @@ func mustNewConfig(v *viper.Viper, config any) { database.DecodeHook, actions.HTTPConfigDecodeHook, hook.EnumHookFunc(internal_authz.MemberTypeString), + mapstructure.TextUnmarshallerHookFunc(), )), ) logging.OnError(err).Fatal("unable to read default config") diff --git a/cmd/ready/config.go b/cmd/ready/config.go index aaa7e2d7e..f5067c562 100644 --- a/cmd/ready/config.go +++ b/cmd/ready/config.go @@ -27,6 +27,7 @@ func MustNewConfig(v *viper.Viper) *Config { mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToSliceHookFunc(","), hook.EnumHookFunc(internal_authz.MemberTypeString), + mapstructure.TextUnmarshallerHookFunc(), )), ) logging.OnError(err).Fatal("unable to read default config") diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 2bee4642a..6ac1767ca 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -74,6 +74,7 @@ func MustNewConfig(v *viper.Viper) *Config { mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToSliceHookFunc(","), + mapstructure.TextUnmarshallerHookFunc(), )), ) logging.OnError(err).Fatal("unable to read default config") @@ -139,6 +140,7 @@ func MustNewSteps(v *viper.Viper) *Steps { mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToSliceHookFunc(","), + mapstructure.TextUnmarshallerHookFunc(), )), ) logging.OnError(err).Fatal("unable to read steps") diff --git a/cmd/start/config.go b/cmd/start/config.go index 4ac5da13a..71175024e 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -100,6 +100,7 @@ func MustNewConfig(v *viper.Viper) *Config { mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToTimeHookFunc(time.RFC3339), mapstructure.StringToSliceHookFunc(","), + mapstructure.TextUnmarshallerHookFunc(), )), ) logging.OnError(err).Fatal("unable to read config") diff --git a/cmd/start/start.go b/cmd/start/start.go index d542e8be6..f944ef032 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -44,6 +44,7 @@ import ( org_v2 "github.com/zitadel/zitadel/internal/api/grpc/org/v2" org_v2beta "github.com/zitadel/zitadel/internal/api/grpc/org/v2beta" action_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/action/v3alpha" + "github.com/zitadel/zitadel/internal/api/grpc/resources/webkey/v3" session_v2 "github.com/zitadel/zitadel/internal/api/grpc/session/v2" session_v2beta "github.com/zitadel/zitadel/internal/api/grpc/session/v2beta" settings_v2 "github.com/zitadel/zitadel/internal/api/grpc/settings/v2" @@ -442,6 +443,9 @@ func startAPIs( if err := apis.RegisterService(ctx, user_schema_v3_alpha.CreateServer(commands, queries)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { + return nil, err + } instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index c067ef25c..b65a53d20 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -340,6 +340,14 @@ module.exports = { categoryLinkSource: "auto", }, }, + webkey_v3: { + specPath: ".artifacts/openapi/zitadel/resources/webkey/v3alpha/webkey_service.swagger.json", + outputDir: "docs/apis/resources/webkey_service_v3", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, feature_v2: { specPath: ".artifacts/openapi/zitadel/feature/v2/feature_service.swagger.json", outputDir: "docs/apis/resources/feature_service_v2", diff --git a/docs/sidebars.js b/docs/sidebars.js index 2c377ea48..49107a380 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -732,6 +732,20 @@ module.exports = { }, items: require("./docs/apis/resources/action_service_v3/sidebar.ts"), }, + { + type: "category", + label: "Web key Lifecycle (Preview)", + link: { + type: "generated-index", + title: "Action Service API (Preview)", + slug: "/apis/resources/action_service_v3", + description: + "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n" + + "\n" + + "This project is in preview state. It can AND will continue breaking until a stable version is released.", + }, + items: require("./docs/apis/resources/webkey_service_v3/sidebar.ts"), + }, ], }, { diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 4d0698fea..3d35694bd 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -42,6 +42,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm TokenExchange: req.OidcTokenExchange, Actions: req.Actions, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + WebKey: req.WebKey, } } @@ -55,6 +56,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), Actions: featureSourceToFlagPb(&f.Actions), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + WebKey: featureSourceToFlagPb(&f.WebKey), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index 7c2cf5fc3..e6335145b 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -123,6 +123,7 @@ func Test_instanceFeaturesToCommand(t *testing.T) { OidcTokenExchange: gu.Ptr(true), Actions: gu.Ptr(true), ImprovedPerformance: nil, + WebKey: gu.Ptr(true), } want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), @@ -132,6 +133,7 @@ func Test_instanceFeaturesToCommand(t *testing.T) { TokenExchange: gu.Ptr(true), Actions: gu.Ptr(true), ImprovedPerformance: nil, + WebKey: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -172,6 +174,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, + WebKey: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, } want := &feature_pb.GetInstanceFeaturesResponse{ Details: &object.Details{ @@ -207,6 +213,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, + WebKey: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, } got := instanceFeaturesToPb(arg) assert.Equal(t, want, got) diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index c866cc017..16654d1e6 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -42,6 +42,7 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm TokenExchange: req.OidcTokenExchange, Actions: req.Actions, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + WebKey: req.WebKey, } } @@ -55,6 +56,7 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), Actions: featureSourceToFlagPb(&f.Actions), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + WebKey: featureSourceToFlagPb(&f.WebKey), } } diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 35dbf9801..b8a69f86a 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -123,6 +123,7 @@ func Test_instanceFeaturesToCommand(t *testing.T) { OidcTokenExchange: gu.Ptr(true), Actions: gu.Ptr(true), ImprovedPerformance: nil, + WebKey: gu.Ptr(true), } want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), @@ -132,6 +133,7 @@ func Test_instanceFeaturesToCommand(t *testing.T) { TokenExchange: gu.Ptr(true), Actions: gu.Ptr(true), ImprovedPerformance: nil, + WebKey: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -172,6 +174,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, + WebKey: query.FeatureSource[bool]{ + Level: feature.LevelInstance, + Value: true, + }, } want := &feature_pb.GetInstanceFeaturesResponse{ Details: &object.Details{ @@ -207,6 +213,10 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, + WebKey: &feature_pb.FeatureFlag{ + Enabled: true, + Source: feature_pb.Source_SOURCE_INSTANCE, + }, } got := instanceFeaturesToPb(arg) assert.Equal(t, want, got) diff --git a/internal/api/grpc/resources/action/v3alpha/execution_integration_test.go b/internal/api/grpc/resources/action/v3alpha/execution_integration_test.go index 3056d450c..326e5e62b 100644 --- a/internal/api/grpc/resources/action/v3alpha/execution_integration_test.go +++ b/internal/api/grpc/resources/action/v3alpha/execution_integration_test.go @@ -188,8 +188,8 @@ func TestServer_SetExecution_Request(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // We want to have the same response no matter how often we call the function - Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) - got, err := Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) + Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) + got, err := Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -326,8 +326,8 @@ func TestServer_SetExecution_Request_Include(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // We want to have the same response no matter how often we call the function - Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) - got, err := Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) + Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) + got, err := Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -506,8 +506,8 @@ func TestServer_SetExecution_Response(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // We want to have the same response no matter how often we call the function - Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) - got, err := Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) + Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) + got, err := Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -692,8 +692,8 @@ func TestServer_SetExecution_Event(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // We want to have the same response no matter how often we call the function - Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) - got, err := Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) + Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) + got, err := Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return @@ -791,8 +791,8 @@ func TestServer_SetExecution_Function(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // We want to have the same response no matter how often we call the function - Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) - got, err := Tester.Client.ActionV3.SetExecution(tt.ctx, tt.req) + Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) + got, err := Tester.Client.ActionV3Alpha.SetExecution(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return diff --git a/internal/api/grpc/resources/action/v3alpha/execution_target_integration_test.go b/internal/api/grpc/resources/action/v3alpha/execution_target_integration_test.go index 0fe042eb0..80cf83138 100644 --- a/internal/api/grpc/resources/action/v3alpha/execution_target_integration_test.go +++ b/internal/api/grpc/resources/action/v3alpha/execution_target_integration_test.go @@ -248,7 +248,7 @@ func TestServer_ExecutionTarget(t *testing.T) { defer close() } - got, err := Tester.Client.ActionV3.GetTarget(tt.ctx, tt.req) + got, err := Tester.Client.ActionV3Alpha.GetTarget(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return diff --git a/internal/api/grpc/resources/action/v3alpha/query_integration_test.go b/internal/api/grpc/resources/action/v3alpha/query_integration_test.go index b4f757828..2542e8672 100644 --- a/internal/api/grpc/resources/action/v3alpha/query_integration_test.go +++ b/internal/api/grpc/resources/action/v3alpha/query_integration_test.go @@ -214,7 +214,7 @@ func TestServer_GetTarget(t *testing.T) { err := tt.args.dep(tt.args.ctx, tt.args.req, tt.want) require.NoError(t, err) } - got, getErr := Tester.Client.ActionV3.GetTarget(tt.args.ctx, tt.args.req) + got, getErr := Tester.Client.ActionV3Alpha.GetTarget(tt.args.ctx, tt.args.req) if tt.wantErr { assert.Error(t, getErr, "Error: "+getErr.Error()) } else { @@ -476,7 +476,7 @@ func TestServer_ListTargets(t *testing.T) { } require.EventuallyWithT(t, func(ttt *assert.CollectT) { - got, listErr := Tester.Client.ActionV3.SearchTargets(tt.args.ctx, tt.args.req) + got, listErr := Tester.Client.ActionV3Alpha.SearchTargets(tt.args.ctx, tt.args.req) if tt.wantErr { assert.Error(ttt, listErr, "Error: "+listErr.Error()) } else { @@ -864,7 +864,7 @@ func TestServer_SearchExecutions(t *testing.T) { } require.EventuallyWithT(t, func(ttt *assert.CollectT) { - got, listErr := Tester.Client.ActionV3.SearchExecutions(tt.args.ctx, tt.args.req) + got, listErr := Tester.Client.ActionV3Alpha.SearchExecutions(tt.args.ctx, tt.args.req) if tt.wantErr { assert.Error(t, listErr, "Error: "+listErr.Error()) } else { diff --git a/internal/api/grpc/resources/action/v3alpha/target_integration_test.go b/internal/api/grpc/resources/action/v3alpha/target_integration_test.go index c94c08067..849ed7649 100644 --- a/internal/api/grpc/resources/action/v3alpha/target_integration_test.go +++ b/internal/api/grpc/resources/action/v3alpha/target_integration_test.go @@ -197,7 +197,7 @@ func TestServer_CreateTarget(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Tester.Client.ActionV3.CreateTarget(tt.ctx, &action.CreateTargetRequest{Target: tt.req}) + got, err := Tester.Client.ActionV3Alpha.CreateTarget(tt.ctx, &action.CreateTargetRequest{Target: tt.req}) if tt.wantErr { require.Error(t, err) return @@ -382,8 +382,8 @@ func TestServer_PatchTarget(t *testing.T) { err := tt.prepare(tt.args.req) require.NoError(t, err) // We want to have the same response no matter how often we call the function - Tester.Client.ActionV3.PatchTarget(tt.args.ctx, tt.args.req) - got, err := Tester.Client.ActionV3.PatchTarget(tt.args.ctx, tt.args.req) + Tester.Client.ActionV3Alpha.PatchTarget(tt.args.ctx, tt.args.req) + got, err := Tester.Client.ActionV3Alpha.PatchTarget(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) return @@ -438,7 +438,7 @@ func TestServer_DeleteTarget(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Tester.Client.ActionV3.DeleteTarget(tt.ctx, tt.req) + got, err := Tester.Client.ActionV3Alpha.DeleteTarget(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return diff --git a/internal/api/grpc/resources/webkey/v3/server.go b/internal/api/grpc/resources/webkey/v3/server.go new file mode 100644 index 000000000..4e9796593 --- /dev/null +++ b/internal/api/grpc/resources/webkey/v3/server.go @@ -0,0 +1,47 @@ +package webkey + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + webkey "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" +) + +type Server struct { + webkey.UnimplementedZITADELWebKeysServer + command *command.Commands + query *query.Queries +} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + webkey.RegisterZITADELWebKeysServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return webkey.ZITADELWebKeys_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return webkey.ZITADELWebKeys_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return webkey.ZITADELWebKeys_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return webkey.RegisterZITADELWebKeysHandler +} diff --git a/internal/api/grpc/resources/webkey/v3/webkey.go b/internal/api/grpc/resources/webkey/v3/webkey.go new file mode 100644 index 000000000..8a6e72f95 --- /dev/null +++ b/internal/api/grpc/resources/webkey/v3/webkey.go @@ -0,0 +1,87 @@ +package webkey + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + webkey "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" +) + +func (s *Server) CreateWebKey(ctx context.Context, req *webkey.CreateWebKeyRequest) (_ *webkey.CreateWebKeyResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err = checkWebKeyFeature(ctx); err != nil { + return nil, err + } + webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req)) + if err != nil { + return nil, err + } + + return &webkey.CreateWebKeyResponse{ + Details: resource_object.DomainToDetailsPb(webKey.ObjectDetails, object.OwnerType_OWNER_TYPE_INSTANCE, authz.GetInstance(ctx).InstanceID()), + }, nil +} + +func (s *Server) ActivateWebKey(ctx context.Context, req *webkey.ActivateWebKeyRequest) (_ *webkey.ActivateWebKeyResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err = checkWebKeyFeature(ctx); err != nil { + return nil, err + } + details, err := s.command.ActivateWebKey(ctx, req.GetId()) + if err != nil { + return nil, err + } + + return &webkey.ActivateWebKeyResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, authz.GetInstance(ctx).InstanceID()), + }, nil +} + +func (s *Server) DeleteWebKey(ctx context.Context, req *webkey.DeleteWebKeyRequest) (_ *webkey.DeleteWebKeyResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err = checkWebKeyFeature(ctx); err != nil { + return nil, err + } + details, err := s.command.DeleteWebKey(ctx, req.GetId()) + if err != nil { + return nil, err + } + + return &webkey.DeleteWebKeyResponse{ + Details: resource_object.DomainToDetailsPb(details, object.OwnerType_OWNER_TYPE_INSTANCE, authz.GetInstance(ctx).InstanceID()), + }, nil +} + +func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) (_ *webkey.ListWebKeysResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err = checkWebKeyFeature(ctx); err != nil { + return nil, err + } + list, err := s.query.ListWebKeys(ctx) + if err != nil { + return nil, err + } + + return &webkey.ListWebKeysResponse{ + WebKeys: webKeyDetailsListToPb(list, authz.GetInstance(ctx).InstanceID()), + }, nil +} + +func checkWebKeyFeature(ctx context.Context) error { + if !authz.GetFeatures(ctx).WebKey { + return zerrors.ThrowPreconditionFailed(nil, "WEBKEY-Ohx6E", "Errors.WebKey.FeatureDisabled") + } + return nil +} diff --git a/internal/api/grpc/resources/webkey/v3/webkey_converter.go b/internal/api/grpc/resources/webkey/v3/webkey_converter.go new file mode 100644 index 000000000..b460775dd --- /dev/null +++ b/internal/api/grpc/resources/webkey/v3/webkey_converter.go @@ -0,0 +1,173 @@ +package webkey + +import ( + resource_object "github.com/zitadel/zitadel/internal/api/grpc/resources/object/v3alpha" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + webkey "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" +) + +func createWebKeyRequestToConfig(req *webkey.CreateWebKeyRequest) crypto.WebKeyConfig { + switch config := req.GetKey().GetConfig().(type) { + case *webkey.WebKey_Rsa: + return webKeyRSAConfigToCrypto(config.Rsa) + case *webkey.WebKey_Ecdsa: + return webKeyECDSAConfigToCrypto(config.Ecdsa) + case *webkey.WebKey_Ed25519: + return new(crypto.WebKeyED25519Config) + default: + return webKeyRSAConfigToCrypto(nil) + } +} + +func webKeyRSAConfigToCrypto(config *webkey.WebKeyRSAConfig) *crypto.WebKeyRSAConfig { + out := new(crypto.WebKeyRSAConfig) + + switch config.GetBits() { + case webkey.WebKeyRSAConfig_RSA_BITS_UNSPECIFIED: + out.Bits = crypto.RSABits2048 + case webkey.WebKeyRSAConfig_RSA_BITS_2048: + out.Bits = crypto.RSABits2048 + case webkey.WebKeyRSAConfig_RSA_BITS_3072: + out.Bits = crypto.RSABits3072 + case webkey.WebKeyRSAConfig_RSA_BITS_4096: + out.Bits = crypto.RSABits4096 + default: + out.Bits = crypto.RSABits2048 + } + + switch config.GetHasher() { + case webkey.WebKeyRSAConfig_RSA_HASHER_UNSPECIFIED: + out.Hasher = crypto.RSAHasherSHA256 + case webkey.WebKeyRSAConfig_RSA_HASHER_SHA256: + out.Hasher = crypto.RSAHasherSHA256 + case webkey.WebKeyRSAConfig_RSA_HASHER_SHA384: + out.Hasher = crypto.RSAHasherSHA384 + case webkey.WebKeyRSAConfig_RSA_HASHER_SHA512: + out.Hasher = crypto.RSAHasherSHA512 + default: + out.Hasher = crypto.RSAHasherSHA256 + } + + return out +} + +func webKeyECDSAConfigToCrypto(config *webkey.WebKeyECDSAConfig) *crypto.WebKeyECDSAConfig { + out := new(crypto.WebKeyECDSAConfig) + + switch config.GetCurve() { + case webkey.WebKeyECDSAConfig_ECDSA_CURVE_UNSPECIFIED: + out.Curve = crypto.EllipticCurveP256 + case webkey.WebKeyECDSAConfig_ECDSA_CURVE_P256: + out.Curve = crypto.EllipticCurveP256 + case webkey.WebKeyECDSAConfig_ECDSA_CURVE_P384: + out.Curve = crypto.EllipticCurveP384 + case webkey.WebKeyECDSAConfig_ECDSA_CURVE_P512: + out.Curve = crypto.EllipticCurveP512 + default: + out.Curve = crypto.EllipticCurveP256 + } + + return out +} + +func webKeyDetailsListToPb(list []query.WebKeyDetails, instanceID string) []*webkey.GetWebKey { + out := make([]*webkey.GetWebKey, len(list)) + for i := range list { + out[i] = webKeyDetailsToPb(&list[i], instanceID) + } + return out +} + +func webKeyDetailsToPb(details *query.WebKeyDetails, instanceID string) *webkey.GetWebKey { + out := &webkey.GetWebKey{ + Details: resource_object.DomainToDetailsPb(&domain.ObjectDetails{ + ID: details.KeyID, + CreationDate: details.CreationDate, + EventDate: details.ChangeDate, + }, object.OwnerType_OWNER_TYPE_INSTANCE, instanceID), + State: webKeyStateToPb(details.State), + Config: &webkey.WebKey{}, + } + + switch config := details.Config.(type) { + case *crypto.WebKeyRSAConfig: + out.Config.Config = &webkey.WebKey_Rsa{ + Rsa: webKeyRSAConfigToPb(config), + } + case *crypto.WebKeyECDSAConfig: + out.Config.Config = &webkey.WebKey_Ecdsa{ + Ecdsa: webKeyECDSAConfigToPb(config), + } + case *crypto.WebKeyED25519Config: + out.Config.Config = &webkey.WebKey_Ed25519{ + Ed25519: new(webkey.WebKeyED25519Config), + } + } + + return out +} + +func webKeyStateToPb(state domain.WebKeyState) webkey.WebKeyState { + switch state { + case domain.WebKeyStateUnspecified: + return webkey.WebKeyState_STATE_UNSPECIFIED + case domain.WebKeyStateInitial: + return webkey.WebKeyState_STATE_INITIAL + case domain.WebKeyStateActive: + return webkey.WebKeyState_STATE_ACTIVE + case domain.WebKeyStateInactive: + return webkey.WebKeyState_STATE_INACTIVE + case domain.WebKeyStateRemoved: + return webkey.WebKeyState_STATE_REMOVED + default: + return webkey.WebKeyState_STATE_UNSPECIFIED + } +} + +func webKeyRSAConfigToPb(config *crypto.WebKeyRSAConfig) *webkey.WebKeyRSAConfig { + out := new(webkey.WebKeyRSAConfig) + + switch config.Bits { + case crypto.RSABitsUnspecified: + out.Bits = webkey.WebKeyRSAConfig_RSA_BITS_UNSPECIFIED + case crypto.RSABits2048: + out.Bits = webkey.WebKeyRSAConfig_RSA_BITS_2048 + case crypto.RSABits3072: + out.Bits = webkey.WebKeyRSAConfig_RSA_BITS_3072 + case crypto.RSABits4096: + out.Bits = webkey.WebKeyRSAConfig_RSA_BITS_4096 + } + + switch config.Hasher { + case crypto.RSAHasherUnspecified: + out.Hasher = webkey.WebKeyRSAConfig_RSA_HASHER_UNSPECIFIED + case crypto.RSAHasherSHA256: + out.Hasher = webkey.WebKeyRSAConfig_RSA_HASHER_SHA256 + case crypto.RSAHasherSHA384: + out.Hasher = webkey.WebKeyRSAConfig_RSA_HASHER_SHA384 + case crypto.RSAHasherSHA512: + out.Hasher = webkey.WebKeyRSAConfig_RSA_HASHER_SHA512 + } + + return out +} + +func webKeyECDSAConfigToPb(config *crypto.WebKeyECDSAConfig) *webkey.WebKeyECDSAConfig { + out := new(webkey.WebKeyECDSAConfig) + + switch config.Curve { + case crypto.EllipticCurveUnspecified: + out.Curve = webkey.WebKeyECDSAConfig_ECDSA_CURVE_UNSPECIFIED + case crypto.EllipticCurveP256: + out.Curve = webkey.WebKeyECDSAConfig_ECDSA_CURVE_P256 + case crypto.EllipticCurveP384: + out.Curve = webkey.WebKeyECDSAConfig_ECDSA_CURVE_P384 + case crypto.EllipticCurveP512: + out.Curve = webkey.WebKeyECDSAConfig_ECDSA_CURVE_P512 + } + + return out +} diff --git a/internal/api/grpc/resources/webkey/v3/webkey_converter_test.go b/internal/api/grpc/resources/webkey/v3/webkey_converter_test.go new file mode 100644 index 000000000..e755d2be0 --- /dev/null +++ b/internal/api/grpc/resources/webkey/v3/webkey_converter_test.go @@ -0,0 +1,529 @@ +package webkey + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + resource_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" + webkey "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" +) + +func Test_createWebKeyRequestToConfig(t *testing.T) { + type args struct { + req *webkey.CreateWebKeyRequest + } + tests := []struct { + name string + args args + want crypto.WebKeyConfig + }{ + { + name: "RSA", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.WebKey{ + Config: &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_3072, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA384, + }, + }, + }, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + name: "ECDSA", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.WebKey{ + Config: &webkey.WebKey_Ecdsa{ + Ecdsa: &webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P384, + }, + }, + }, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + { + name: "ED25519", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.WebKey{ + Config: &webkey.WebKey_Ed25519{ + Ed25519: &webkey.WebKeyED25519Config{}, + }, + }, + }}, + want: &crypto.WebKeyED25519Config{}, + }, + { + name: "default", + args: args{&webkey.CreateWebKeyRequest{}}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := createWebKeyRequestToConfig(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyRSAConfigToCrypto(t *testing.T) { + type args struct { + config *webkey.WebKeyRSAConfig + } + tests := []struct { + name string + args args + want *crypto.WebKeyRSAConfig + }{ + { + name: "unspecified", + args: args{&webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_UNSPECIFIED, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_UNSPECIFIED, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + { + name: "2048, RSA256", + args: args{&webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + { + name: "3072, RSA384", + args: args{&webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_3072, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA384, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + name: "4096, RSA512", + args: args{&webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_4096, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA512, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits4096, + Hasher: crypto.RSAHasherSHA512, + }, + }, + { + name: "invalid", + args: args{&webkey.WebKeyRSAConfig{ + Bits: 99, + Hasher: 99, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyRSAConfigToCrypto(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyECDSAConfigToCrypto(t *testing.T) { + type args struct { + config *webkey.WebKeyECDSAConfig + } + tests := []struct { + name string + args args + want *crypto.WebKeyECDSAConfig + }{ + { + name: "unspecified", + args: args{&webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_UNSPECIFIED, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + { + name: "P256", + args: args{&webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P256, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + { + name: "P384", + args: args{&webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P384, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + { + name: "P512", + args: args{&webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P512, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP512, + }, + }, + { + name: "invalid", + args: args{&webkey.WebKeyECDSAConfig{ + Curve: 99, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyECDSAConfigToCrypto(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyDetailsListToPb(t *testing.T) { + instanceID := "ownerid" + list := []query.WebKeyDetails{ + { + KeyID: "key1", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + KeyID: "key2", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyED25519Config{}, + }, + } + want := []*webkey.GetWebKey{ + { + Details: &resource_object.Details{ + Id: "key1", + Created: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + Changed: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + Owner: &object.Owner{Type: object.OwnerType_OWNER_TYPE_INSTANCE, Id: instanceID}, + }, + State: webkey.WebKeyState_STATE_ACTIVE, + Config: &webkey.WebKey{ + Config: &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_3072, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA384, + }, + }, + }, + }, + { + Details: &resource_object.Details{ + Id: "key2", + Created: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + Changed: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + Owner: &object.Owner{Type: object.OwnerType_OWNER_TYPE_INSTANCE, Id: instanceID}, + }, + State: webkey.WebKeyState_STATE_ACTIVE, + Config: &webkey.WebKey{ + Config: &webkey.WebKey_Ed25519{ + Ed25519: &webkey.WebKeyED25519Config{}, + }, + }, + }, + } + got := webKeyDetailsListToPb(list, instanceID) + assert.Equal(t, want, got) +} + +func Test_webKeyDetailsToPb(t *testing.T) { + instanceID := "ownerid" + type args struct { + details *query.WebKeyDetails + } + tests := []struct { + name string + args args + want *webkey.GetWebKey + }{ + { + name: "RSA", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }}, + want: &webkey.GetWebKey{ + Details: &resource_object.Details{ + Id: "keyID", + Created: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + Changed: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + Owner: &object.Owner{Type: object.OwnerType_OWNER_TYPE_INSTANCE, Id: instanceID}, + }, + State: webkey.WebKeyState_STATE_ACTIVE, + Config: &webkey.WebKey{ + Config: &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_3072, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA384, + }, + }, + }, + }, + }, + { + name: "ECDSA", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }}, + want: &webkey.GetWebKey{ + Details: &resource_object.Details{ + Id: "keyID", + Created: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + Changed: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + Owner: &object.Owner{Type: object.OwnerType_OWNER_TYPE_INSTANCE, Id: instanceID}, + }, + State: webkey.WebKeyState_STATE_ACTIVE, + Config: &webkey.WebKey{ + Config: &webkey.WebKey_Ecdsa{ + Ecdsa: &webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P384, + }, + }, + }, + }, + }, + { + name: "ED25519", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyED25519Config{}, + }}, + want: &webkey.GetWebKey{ + Details: &resource_object.Details{ + Id: "keyID", + Created: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + Changed: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + Owner: &object.Owner{Type: object.OwnerType_OWNER_TYPE_INSTANCE, Id: instanceID}, + }, + State: webkey.WebKeyState_STATE_ACTIVE, + Config: &webkey.WebKey{ + Config: &webkey.WebKey_Ed25519{ + Ed25519: &webkey.WebKeyED25519Config{}, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyDetailsToPb(tt.args.details, instanceID) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyStateToPb(t *testing.T) { + type args struct { + state domain.WebKeyState + } + tests := []struct { + name string + args args + want webkey.WebKeyState + }{ + { + name: "unspecified", + args: args{domain.WebKeyStateUnspecified}, + want: webkey.WebKeyState_STATE_UNSPECIFIED, + }, + { + name: "initial", + args: args{domain.WebKeyStateInitial}, + want: webkey.WebKeyState_STATE_INITIAL, + }, + { + name: "active", + args: args{domain.WebKeyStateActive}, + want: webkey.WebKeyState_STATE_ACTIVE, + }, + { + name: "inactive", + args: args{domain.WebKeyStateInactive}, + want: webkey.WebKeyState_STATE_INACTIVE, + }, + { + name: "removed", + args: args{domain.WebKeyStateRemoved}, + want: webkey.WebKeyState_STATE_REMOVED, + }, + { + name: "invalid", + args: args{99}, + want: webkey.WebKeyState_STATE_UNSPECIFIED, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyStateToPb(tt.args.state) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyRSAConfigToPb(t *testing.T) { + type args struct { + config *crypto.WebKeyRSAConfig + } + tests := []struct { + name string + args args + want *webkey.WebKeyRSAConfig + }{ + { + name: "2048, RSA256", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }}, + want: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }, + { + name: "3072, RSA384", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }}, + want: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_3072, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA384, + }, + }, + { + name: "4096, RSA512", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits4096, + Hasher: crypto.RSAHasherSHA512, + }}, + want: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_4096, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA512, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyRSAConfigToPb(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyECDSAConfigToPb(t *testing.T) { + type args struct { + config *crypto.WebKeyECDSAConfig + } + tests := []struct { + name string + args args + want *webkey.WebKeyECDSAConfig + }{ + { + name: "P256", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }}, + want: &webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P256, + }, + }, + { + name: "P384", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }}, + want: &webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P384, + }, + }, + { + name: "P512", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP512, + }}, + want: &webkey.WebKeyECDSAConfig{ + Curve: webkey.WebKeyECDSAConfig_ECDSA_CURVE_P512, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyECDSAConfigToPb(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/resources/webkey/v3/webkey_integration_test.go b/internal/api/grpc/resources/webkey/v3/webkey_integration_test.go new file mode 100644 index 000000000..2fae24fb0 --- /dev/null +++ b/internal/api/grpc/resources/webkey/v3/webkey_integration_test.go @@ -0,0 +1,245 @@ +//go:build integration + +package webkey_test + +import ( + "context" + "net" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + object "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" + resource_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" + webkey "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" +) + +var ( + CTX context.Context + SystemCTX context.Context + Tester *integration.Tester +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, _, cancel := integration.Contexts(time.Hour) + defer cancel() + + Tester = integration.NewTester(ctx) + defer Tester.Done() + + SystemCTX = Tester.WithAuthorization(ctx, integration.SystemUser) + CTX = Tester.WithAuthorization(ctx, integration.IAMOwner) + return m.Run() + }()) +} + +func TestServer_Feature_Disabled(t *testing.T) { + client, iamCTX := createInstanceAndClients(t, false) + + t.Run("CreateWebKey", func(t *testing.T) { + _, err := client.CreateWebKey(iamCTX, &webkey.CreateWebKeyRequest{}) + assertFeatureDisabledError(t, err) + }) + t.Run("ActivateWebKey", func(t *testing.T) { + _, err := client.ActivateWebKey(iamCTX, &webkey.ActivateWebKeyRequest{ + Id: "1", + }) + assertFeatureDisabledError(t, err) + }) + t.Run("DeleteWebKey", func(t *testing.T) { + _, err := client.DeleteWebKey(iamCTX, &webkey.DeleteWebKeyRequest{ + Id: "1", + }) + assertFeatureDisabledError(t, err) + }) + t.Run("ListWebKeys", func(t *testing.T) { + _, err := client.ListWebKeys(iamCTX, &webkey.ListWebKeysRequest{}) + assertFeatureDisabledError(t, err) + }) +} + +func TestServer_ListWebKeys(t *testing.T) { + client, iamCtx := createInstanceAndClients(t, true) + // After the feature is first enabled, we can expect 2 generated keys with the default config. + checkWebKeyListState(iamCtx, t, client, 2, "", &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }) +} + +func TestServer_CreateWebKey(t *testing.T) { + client, iamCtx := createInstanceAndClients(t, true) + _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.WebKey{ + Config: &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }, + }, + }) + require.NoError(t, err) + + checkWebKeyListState(iamCtx, t, client, 3, "", &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }) +} + +func TestServer_ActivateWebKey(t *testing.T) { + client, iamCtx := createInstanceAndClients(t, true) + resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.WebKey{ + Config: &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }, + }, + }) + require.NoError(t, err) + + _, err = client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ + Id: resp.GetDetails().GetId(), + }) + require.NoError(t, err) + + checkWebKeyListState(iamCtx, t, client, 3, resp.GetDetails().GetId(), &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }) +} + +func TestServer_DeleteWebKey(t *testing.T) { + client, iamCtx := createInstanceAndClients(t, true) + keyIDs := make([]string, 2) + for i := 0; i < 2; i++ { + resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.WebKey{ + Config: &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }, + }, + }) + require.NoError(t, err) + keyIDs[i] = resp.GetDetails().GetId() + } + _, err := client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ + Id: keyIDs[0], + }) + require.NoError(t, err) + + ok := t.Run("cannot delete active key", func(t *testing.T) { + _, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[0], + }) + require.Error(t, err) + s := status.Convert(err) + assert.Equal(t, codes.FailedPrecondition, s.Code()) + assert.Contains(t, s.Message(), "COMMAND-Chai1") + }) + if !ok { + return + } + + ok = t.Run("delete inactive key", func(t *testing.T) { + _, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[1], + }) + require.NoError(t, err) + }) + if !ok { + return + } + + // There are 2 keys from feature setup, +2 created, -1 deleted = 3 + checkWebKeyListState(iamCtx, t, client, 3, keyIDs[0], &webkey.WebKey_Rsa{ + Rsa: &webkey.WebKeyRSAConfig{ + Bits: webkey.WebKeyRSAConfig_RSA_BITS_2048, + Hasher: webkey.WebKeyRSAConfig_RSA_HASHER_SHA256, + }, + }) +} + +func createInstanceAndClients(t *testing.T, enableFeature bool) (webkey.ZITADELWebKeysClient, context.Context) { + domain, _, _, iamCTX := Tester.UseIsolatedInstance(t, CTX, SystemCTX) + cc, err := grpc.NewClient( + net.JoinHostPort(domain, "8080"), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + if enableFeature { + features := feature.NewFeatureServiceClient(cc) + _, err = features.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{ + WebKey: proto.Bool(true), + }) + require.NoError(t, err) + time.Sleep(time.Second) + } + + return webkey.NewZITADELWebKeysClient(cc), iamCTX +} + +func assertFeatureDisabledError(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + s := status.Convert(err) + assert.Equal(t, codes.FailedPrecondition, s.Code()) + assert.Contains(t, s.Message(), "WEBKEY-Ohx6E") +} + +func checkWebKeyListState(ctx context.Context, t *testing.T, client webkey.ZITADELWebKeysClient, nKeys int, expectActiveKeyID string, config any) { + resp, err := client.ListWebKeys(ctx, &webkey.ListWebKeysRequest{}) + require.NoError(t, err) + list := resp.GetWebKeys() + require.Len(t, list, nKeys) + + now := time.Now() + var gotActiveKeyID string + for _, key := range list { + integration.AssertResourceDetails(t, &resource_object.Details{ + Created: timestamppb.Now(), + Changed: timestamppb.Now(), + Owner: &object.Owner{ + Type: object.OwnerType_OWNER_TYPE_INSTANCE, + Id: Tester.Instance.InstanceID(), + }, + }, key.GetDetails()) + assert.WithinRange(t, key.GetDetails().GetChanged().AsTime(), now.Add(-time.Minute), now.Add(time.Minute)) + assert.NotEqual(t, webkey.WebKeyState_STATE_UNSPECIFIED, key.GetState()) + assert.NotEqual(t, webkey.WebKeyState_STATE_REMOVED, key.GetState()) + assert.Equal(t, config, key.GetConfig().GetConfig()) + + if key.GetState() == webkey.WebKeyState_STATE_ACTIVE { + gotActiveKeyID = key.GetDetails().GetId() + } + } + assert.NotEmpty(t, gotActiveKeyID) + if expectActiveKeyID != "" { + assert.Equal(t, expectActiveKeyID, gotActiveKeyID) + } +} diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index c4102c1fd..2db5baf83 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -14,7 +14,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" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/instance" @@ -208,7 +207,7 @@ func (k keySetMap) getKey(keyID string) (*jose.JSONWebKey, error) { return &jose.JSONWebKey{ Key: pubKey, KeyID: keyID, - Use: domain.KeyUsageSigning.String(), + Use: crypto.KeyUsageSigning.String(), }, nil } diff --git a/internal/api/oidc/key_test.go b/internal/api/oidc/key_test.go index e7cf39c09..3f84722a9 100644 --- a/internal/api/oidc/key_test.go +++ b/internal/api/oidc/key_test.go @@ -12,14 +12,14 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query" ) type publicKey struct { id string alg string - use domain.KeyUsage + use crypto.KeyUsage seq uint64 expiry time.Time key any @@ -33,7 +33,7 @@ func (k *publicKey) Algorithm() string { return k.alg } -func (k *publicKey) Use() domain.KeyUsage { +func (k *publicKey) Use() crypto.KeyUsage { return k.use } @@ -55,21 +55,21 @@ var ( "key1": { id: "key1", alg: "alg", - use: domain.KeyUsageSigning, + use: crypto.KeyUsageSigning, seq: 1, expiry: clock.Now().Add(time.Minute), }, "key2": { id: "key2", alg: "alg", - use: domain.KeyUsageSigning, + use: crypto.KeyUsageSigning, seq: 3, expiry: clock.Now().Add(10 * time.Hour), }, "exp1": { id: "key2", alg: "alg", - use: domain.KeyUsageSigning, + use: crypto.KeyUsageSigning, seq: 4, expiry: clock.Now().Add(-time.Hour), }, diff --git a/internal/api/saml/certificate.go b/internal/api/saml/certificate.go index 144a7e10a..2eac0e4d3 100644 --- a/internal/api/saml/certificate.go +++ b/internal/api/saml/certificate.go @@ -11,7 +11,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" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/instance" @@ -53,7 +52,7 @@ func (c *CertificateAndKey) ID() string { return c.id } -func (p *Storage) GetCertificateAndKey(ctx context.Context, usage domain.KeyUsage) (certAndKey *key.CertificateAndKey, err error) { +func (p *Storage) GetCertificateAndKey(ctx context.Context, usage crypto.KeyUsage) (certAndKey *key.CertificateAndKey, err error) { err = retry(func() error { certAndKey, err = p.getCertificateAndKey(ctx, usage) if err != nil { @@ -67,7 +66,7 @@ func (p *Storage) GetCertificateAndKey(ctx context.Context, usage domain.KeyUsag return certAndKey, err } -func (p *Storage) getCertificateAndKey(ctx context.Context, usage domain.KeyUsage) (*key.CertificateAndKey, error) { +func (p *Storage) getCertificateAndKey(ctx context.Context, usage crypto.KeyUsage) (*key.CertificateAndKey, error) { certs, err := p.query.ActiveCertificates(ctx, time.Now().Add(gracefulPeriod), usage) if err != nil { return nil, err @@ -87,7 +86,7 @@ func (p *Storage) getCertificateAndKey(ctx context.Context, usage domain.KeyUsag func (p *Storage) refreshCertificate( ctx context.Context, - usage domain.KeyUsage, + usage crypto.KeyUsage, position float64, ) error { ok, err := p.ensureIsLatestCertificate(ctx, position) @@ -112,7 +111,7 @@ func (p *Storage) ensureIsLatestCertificate(ctx context.Context, position float6 return position >= maxSequence, nil } -func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage domain.KeyUsage) error { +func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage crypto.KeyUsage) error { ctx, cancel := context.WithCancel(ctx) defer cancel() ctx = setSAMLCtx(ctx) @@ -128,8 +127,8 @@ func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage do } switch usage { - case domain.KeyUsageSAMLMetadataSigning, domain.KeyUsageSAMLResponseSinging: - certAndKey, err := p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLCA) + case crypto.KeyUsageSAMLMetadataSigning, crypto.KeyUsageSAMLResponseSinging: + certAndKey, err := p.GetCertificateAndKey(ctx, crypto.KeyUsageSAMLCA) if err != nil { return fmt.Errorf("error while reading ca certificate: %w", err) } @@ -138,14 +137,14 @@ func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage do } switch usage { - case domain.KeyUsageSAMLMetadataSigning: + case crypto.KeyUsageSAMLMetadataSigning: return p.command.GenerateSAMLMetadataCertificate(setSAMLCtx(ctx), p.certificateAlgorithm, certAndKey.Key, certAndKey.Certificate) - case domain.KeyUsageSAMLResponseSinging: + case crypto.KeyUsageSAMLResponseSinging: return p.command.GenerateSAMLResponseCertificate(setSAMLCtx(ctx), p.certificateAlgorithm, certAndKey.Key, certAndKey.Certificate) default: return fmt.Errorf("unknown usage") } - case domain.KeyUsageSAMLCA: + case crypto.KeyUsageSAMLCA: return p.command.GenerateSAMLCACertificate(setSAMLCtx(ctx), p.certificateAlgorithm) default: return fmt.Errorf("unknown certificate usage") diff --git a/internal/api/saml/storage.go b/internal/api/saml/storage.go index bd8afffe5..ca523398f 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -87,15 +87,15 @@ func (p *Storage) Health(context.Context) error { } func (p *Storage) GetCA(ctx context.Context) (*key.CertificateAndKey, error) { - return p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLCA) + return p.GetCertificateAndKey(ctx, crypto.KeyUsageSAMLCA) } func (p *Storage) GetMetadataSigningKey(ctx context.Context) (*key.CertificateAndKey, error) { - return p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLMetadataSigning) + return p.GetCertificateAndKey(ctx, crypto.KeyUsageSAMLMetadataSigning) } func (p *Storage) GetResponseSigningKey(ctx context.Context) (*key.CertificateAndKey, error) { - return p.GetCertificateAndKey(ctx, domain.KeyUsageSAMLResponseSinging) + return p.GetCertificateAndKey(ctx, crypto.KeyUsageSAMLResponseSinging) } func (p *Storage) CreateAuthRequest(ctx context.Context, req *samlp.AuthnRequestType, acsUrl, protocolBinding, relayState, applicationID string) (_ models.AuthRequestInt, err error) { diff --git a/internal/command/command.go b/internal/command/command.go index 22a0ba819..89f23e6ff 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -13,6 +13,7 @@ import ( "sync" "time" + "github.com/go-jose/go-jose/v4" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -76,6 +77,7 @@ type Commands struct { defaultSecretGenerators *SecretGenerators samlCertificateAndKeyGenerator func(id string) ([]byte, []byte, error) + webKeyGenerator func(keyID string, alg crypto.EncryptionAlgorithm, genConfig crypto.WebKeyConfig) (encryptedPrivate *crypto.CryptoValue, public *jose.JSONWebKey, err error) GrpcMethodExisting func(method string) bool GrpcServiceExisting func(method string) bool @@ -157,6 +159,7 @@ func StartCommands( defaultRefreshTokenIdleLifetime: defaultRefreshTokenIdleLifetime, defaultSecretGenerators: defaultSecretGenerators, samlCertificateAndKeyGenerator: samlCertificateAndKeyGenerator(defaults.KeyConfig.CertificateSize, defaults.KeyConfig.CertificateLifetime), + webKeyGenerator: crypto.GenerateEncryptedWebKey, // always true for now until we can check with an eventlist EventExisting: func(event string) bool { return true }, // always true for now until we can check with an eventlist diff --git a/internal/command/instance.go b/internal/command/instance.go index 4d3e1d252..a0cc77301 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -34,12 +34,20 @@ const ( ) type InstanceSetup struct { - zitadel ZitadelConfig - InstanceName string - CustomDomain string - DefaultLanguage language.Tag - Org InstanceOrgSetup - SecretGenerators *SecretGenerators + zitadel ZitadelConfig + InstanceName string + CustomDomain string + DefaultLanguage language.Tag + Org InstanceOrgSetup + SecretGenerators *SecretGenerators + WebKeys struct { + Type crypto.WebKeyConfigType + Config struct { + RSABits crypto.RSABits + RSAHasher crypto.RSAHasher + EllipticCurve crypto.EllipticCurve + } + } PasswordComplexityPolicy struct { MinLength uint64 HasLowercase bool @@ -267,6 +275,9 @@ func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (vali return nil, nil, nil, err } setupSMTPSettings(c, &validations, setup.SMTPConfiguration, instanceAgg) + if err := setupWebKeys(c, &validations, setup.zitadel.instanceID, setup); err != nil { + return nil, nil, nil, err + } setupOIDCSettings(c, &validations, setup.OIDCSettings, instanceAgg) setupFeatures(&validations, setup.Features, setup.zitadel.instanceID) setupLimits(c, &validations, limits.NewAggregate(setup.zitadel.limitsID, setup.zitadel.instanceID), setup.Limits) @@ -390,6 +401,29 @@ func setupFeatures(validations *[]preparation.Validation, features *InstanceFeat } } +func setupWebKeys(c *Commands, validations *[]preparation.Validation, instanceID string, setup *InstanceSetup) error { + var conf crypto.WebKeyConfig + switch setup.WebKeys.Type { + case crypto.WebKeyConfigTypeUnspecified: + return nil // config disabled, skip + case crypto.WebKeyConfigTypeRSA: + conf = &crypto.WebKeyRSAConfig{ + Bits: setup.WebKeys.Config.RSABits, + Hasher: setup.WebKeys.Config.RSAHasher, + } + case crypto.WebKeyConfigTypeECDSA: + conf = &crypto.WebKeyECDSAConfig{ + Curve: setup.WebKeys.Config.EllipticCurve, + } + case crypto.WebKeyConfigTypeED25519: + conf = &crypto.WebKeyED25519Config{} + default: + return zerrors.ThrowInternalf(nil, "COMMAND-sieX0", "Errors.Internal unknown web key type %q", setup.WebKeys.Type) + } + *validations = append(*validations, c.prepareGenerateInitialWebKeys(instanceID, conf)) + return nil +} + func setupOIDCSettings(commands *Commands, validations *[]preparation.Validation, oidcSettings *OIDCSettings, instanceAgg *instance.Aggregate) { if oidcSettings == nil { return diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 3acc789d1..e6e448da9 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -3,8 +3,11 @@ package command import ( "context" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/feature" @@ -20,6 +23,7 @@ type InstanceFeatures struct { TokenExchange *bool Actions *bool ImprovedPerformance []feature.ImprovedPerformanceType + WebKey *bool } func (m *InstanceFeatures) isEmpty() bool { @@ -30,7 +34,8 @@ func (m *InstanceFeatures) isEmpty() bool { m.TokenExchange == nil && m.Actions == nil && // nil check to allow unset improvements - m.ImprovedPerformance == nil + m.ImprovedPerformance == nil && + m.WebKey == nil } func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) (*domain.ObjectDetails, error) { @@ -41,6 +46,9 @@ func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { return nil, err } + if err := c.setupWebKeyFeature(ctx, wm, f); err != nil { + return nil, err + } commands := wm.setCommands(ctx, f) if len(commands) == 0 { return writeModelToObjectDetails(wm.WriteModel), nil @@ -61,6 +69,21 @@ func prepareSetFeatures(instanceID string, f *InstanceFeatures) preparation.Vali } } +// setupWebKeyFeature generates the initial web keys for the instance, +// if the feature is enabled in the request and the feature wasn't enabled already in the writeModel. +// [Commands.GenerateInitialWebKeys] checks if keys already exist and does nothing if that's the case. +// The default config of a RSA key with 2048 and the SHA256 hasher is assumed. +// Users can customize this after using the webkey/v3 API. +func (c *Commands) setupWebKeyFeature(ctx context.Context, wm *InstanceFeaturesWriteModel, f *InstanceFeatures) error { + if !gu.Value(f.WebKey) || gu.Value(wm.WebKey) { + return nil + } + return c.GenerateInitialWebKeys(ctx, &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }) +} + func (c *Commands) ResetInstanceFeatures(ctx context.Context) (*domain.ObjectDetails, error) { instanceID := authz.GetInstance(ctx).InstanceID() wm := NewInstanceFeaturesWriteModel(instanceID) diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index bfd606e67..bdb46d2e0 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -67,6 +67,7 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceActionsEventType, feature_v2.InstanceImprovedPerformanceEventType, + feature_v2.InstanceWebKeyEventType, ). Builder().ResourceOwner(m.ResourceOwner) } @@ -100,6 +101,9 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyImprovedPerformance: v := value.([]feature.ImprovedPerformanceType) features.ImprovedPerformance = v + case feature.KeyWebKey: + v := value.(bool) + features.WebKey = &v } } @@ -113,5 +117,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.Actions, f.Actions, feature_v2.InstanceActionsEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) + cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.WebKey, f.WebKey, feature_v2.InstanceWebKeyEventType) return cmds } diff --git a/internal/command/key_pair.go b/internal/command/key_pair.go index ac379aa96..90eaf7e3d 100644 --- a/internal/command/key_pair.go +++ b/internal/command/key_pair.go @@ -10,7 +10,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/repository/keypair" ) @@ -32,7 +31,7 @@ func (c *Commands) GenerateSigningKeyPair(ctx context.Context, algorithm string) _, err = c.eventstore.Push(ctx, keypair.NewAddedEvent( ctx, keyAgg, - domain.KeyUsageSigning, + crypto.KeyUsageSigning, algorithm, privateCrypto, publicCrypto, privateKeyExp, publicKeyExp)) @@ -69,7 +68,7 @@ func (c *Commands) GenerateSAMLCACertificate(ctx context.Context, algorithm stri keypair.NewAddedEvent( ctx, keyAgg, - domain.KeyUsageSAMLCA, + crypto.KeyUsageSAMLCA, algorithm, privateCrypto, publicCrypto, after, after, @@ -115,7 +114,7 @@ func (c *Commands) GenerateSAMLResponseCertificate(ctx context.Context, algorith keypair.NewAddedEvent( ctx, keyAgg, - domain.KeyUsageSAMLResponseSinging, + crypto.KeyUsageSAMLResponseSinging, algorithm, privateCrypto, publicCrypto, after, after, @@ -160,7 +159,7 @@ func (c *Commands) GenerateSAMLMetadataCertificate(ctx context.Context, algorith keypair.NewAddedEvent( ctx, keyAgg, - domain.KeyUsageSAMLMetadataSigning, + crypto.KeyUsageSAMLMetadataSigning, algorithm, privateCrypto, publicCrypto, after, after), diff --git a/internal/command/key_pair_model.go b/internal/command/key_pair_model.go index e3796f6c6..fe052166b 100644 --- a/internal/command/key_pair_model.go +++ b/internal/command/key_pair_model.go @@ -1,6 +1,7 @@ package command import ( + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/keypair" @@ -9,7 +10,7 @@ import ( type KeyPairWriteModel struct { eventstore.WriteModel - Usage domain.KeyUsage + Usage crypto.KeyUsage Algorithm string PrivateKey *domain.Key PublicKey *domain.Key diff --git a/internal/command/web_key.go b/internal/command/web_key.go new file mode 100644 index 000000000..e8481541c --- /dev/null +++ b/internal/command/web_key.go @@ -0,0 +1,188 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command/preparation" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/webkey" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type WebKeyDetails struct { + KeyID string + ObjectDetails *domain.ObjectDetails +} + +// CreateWebKey creates one web key pair for the instance. +// If the instance does not have an active key, the new key is activated. +func (c *Commands) CreateWebKey(ctx context.Context, conf crypto.WebKeyConfig) (_ *WebKeyDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + _, activeID, err := c.getAllWebKeys(ctx) + if err != nil { + return nil, err + } + addedCmd, aggregate, err := c.generateWebKeyCommand(ctx, authz.GetInstance(ctx).InstanceID(), conf) + if err != nil { + return nil, err + } + commands := []eventstore.Command{addedCmd} + if activeID == "" { + commands = append(commands, webkey.NewActivatedEvent(ctx, aggregate)) + } + model := NewWebKeyWriteModel(aggregate.ID, authz.GetInstance(ctx).InstanceID()) + err = c.pushAppendAndReduce(ctx, model, commands...) + if err != nil { + return nil, err + } + return &WebKeyDetails{ + KeyID: aggregate.ID, + ObjectDetails: writeModelToObjectDetails(&model.WriteModel), + }, nil +} + +// GenerateInitialWebKeys creates 2 web key pairs for the instance. +// The first key is activated for signing use. +// If the instance already has keys, this is noop. +func (c *Commands) GenerateInitialWebKeys(ctx context.Context, conf crypto.WebKeyConfig) (err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + keys, _, err := c.getAllWebKeys(ctx) + if err != nil { + return err + } + if len(keys) != 0 { + return nil + } + commands, err := c.generateInitialWebKeysCommands(ctx, authz.GetInstance(ctx).InstanceID(), conf) + if err != nil { + return err + } + _, err = c.eventstore.Push(ctx, commands...) + return err +} + +func (c *Commands) generateInitialWebKeysCommands(ctx context.Context, instanceID string, conf crypto.WebKeyConfig) (_ []eventstore.Command, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + commands := make([]eventstore.Command, 0, 3) + for i := 0; i < 2; i++ { + addedCmd, aggregate, err := c.generateWebKeyCommand(ctx, instanceID, conf) + if err != nil { + return nil, err + } + commands = append(commands, addedCmd) + if i == 0 { + commands = append(commands, webkey.NewActivatedEvent(ctx, aggregate)) + } + } + return commands, nil +} + +func (c *Commands) generateWebKeyCommand(ctx context.Context, instanceID string, conf crypto.WebKeyConfig) (_ eventstore.Command, _ *eventstore.Aggregate, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + keyID, err := c.idGenerator.Next() + if err != nil { + return nil, nil, err + } + encryptedPrivate, public, err := c.webKeyGenerator(keyID, c.keyAlgorithm, conf) + if err != nil { + return nil, nil, err + } + aggregate := webkey.NewAggregate(keyID, instanceID) + addedCmd, err := webkey.NewAddedEvent(ctx, aggregate, encryptedPrivate, public, conf) + if err != nil { + return nil, nil, err + } + return addedCmd, aggregate, nil +} + +// ActivateWebKey activates the key identified by keyID. +// Any previously activated key on the current instance is deactivated. +func (c *Commands) ActivateWebKey(ctx context.Context, keyID string) (_ *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + keys, activeID, err := c.getAllWebKeys(ctx) + if err != nil { + return nil, err + } + if activeID == keyID { + return writeModelToObjectDetails( + &keys[activeID].WriteModel, + ), nil + } + nextActive, ok := keys[keyID] + if !ok { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-teiG3", "Errors.WebKey.NotFound") + } + + commands := make([]eventstore.Command, 0, 2) + commands = append(commands, webkey.NewActivatedEvent(ctx, + webkey.AggregateFromWriteModel(ctx, &nextActive.WriteModel), + )) + if activeID != "" { + commands = append(commands, webkey.NewDeactivatedEvent(ctx, + webkey.AggregateFromWriteModel(ctx, &keys[activeID].WriteModel), + )) + } + err = c.pushAppendAndReduce(ctx, nextActive, commands...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&nextActive.WriteModel), nil +} + +// getAllWebKeys searches for all web keys on the instance and returns a map of key IDs. +// activeID is the id of the currently active key. +func (c *Commands) getAllWebKeys(ctx context.Context) (_ map[string]*WebKeyWriteModel, activeID string, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + models := newWebKeyWriteModels(authz.GetInstance(ctx).InstanceID()) + if err = c.eventstore.FilterToQueryReducer(ctx, models); err != nil { + return nil, "", err + } + return models.keys, models.activeID, nil +} + +func (c *Commands) DeleteWebKey(ctx context.Context, keyID string) (_ *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + model := NewWebKeyWriteModel(keyID, authz.GetInstance(ctx).InstanceID()) + if err = c.eventstore.FilterToQueryReducer(ctx, model); err != nil { + return nil, err + } + if model.State == domain.WebKeyStateUnspecified { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-ooCa7", "Errors.WebKey.NotFound") + } + if model.State == domain.WebKeyStateActive { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Chai1", "Errors.WebKey.ActiveDelete") + } + err = c.pushAppendAndReduce(ctx, model, webkey.NewRemovedEvent(ctx, + webkey.AggregateFromWriteModel(ctx, &model.WriteModel), + )) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&model.WriteModel), nil +} + +func (c *Commands) prepareGenerateInitialWebKeys(instanceID string, conf crypto.WebKeyConfig) preparation.Validation { + return func() (preparation.CreateCommands, error) { + return func(ctx context.Context, _ preparation.FilterToQueryReducer) ([]eventstore.Command, error) { + return c.generateInitialWebKeysCommands(ctx, instanceID, conf) + }, nil + } +} diff --git a/internal/command/web_key_model.go b/internal/command/web_key_model.go new file mode 100644 index 000000000..aca375bb5 --- /dev/null +++ b/internal/command/web_key_model.go @@ -0,0 +1,131 @@ +package command + +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 WebKeyWriteModel struct { + eventstore.WriteModel + State domain.WebKeyState + PrivateKey *crypto.CryptoValue + PublicKey *jose.JSONWebKey +} + +func NewWebKeyWriteModel(keyID, resourceOwner string) *WebKeyWriteModel { + return &WebKeyWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: keyID, + ResourceOwner: resourceOwner, + }, + } +} + +func (wm *WebKeyWriteModel) AppendEvents(events ...eventstore.Event) { + wm.WriteModel.AppendEvents(events...) +} + +func (wm *WebKeyWriteModel) Reduce() error { + for _, event := range wm.Events { + if event.Aggregate().ID != wm.AggregateID { + continue + } + switch e := event.(type) { + case *webkey.AddedEvent: + wm.State = domain.WebKeyStateInitial + wm.PrivateKey = e.PrivateKey + wm.PublicKey = e.PublicKey + 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.WriteModel.Reduce() +} + +func (wm *WebKeyWriteModel) 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() +} + +type webKeyWriteModels struct { + resourceOwner string + events []eventstore.Event + keys map[string]*WebKeyWriteModel + activeID string +} + +func newWebKeyWriteModels(resourceOwner string) *webKeyWriteModels { + return &webKeyWriteModels{ + resourceOwner: resourceOwner, + keys: make(map[string]*WebKeyWriteModel), + } +} + +func (models *webKeyWriteModels) AppendEvents(events ...eventstore.Event) { + models.events = append(models.events, events...) +} + +func (models *webKeyWriteModels) Reduce() error { + for _, event := range models.events { + aggregate := event.Aggregate() + if models.keys[aggregate.ID] == nil { + models.keys[aggregate.ID] = NewWebKeyWriteModel(aggregate.ID, aggregate.ResourceOwner) + } + + switch event.(type) { + case *webkey.AddedEvent: + break + case *webkey.ActivatedEvent: + models.activeID = aggregate.ID + case *webkey.DeactivatedEvent: + if models.activeID == aggregate.ID { + models.activeID = "" + } + case *webkey.RemovedEvent: + delete(models.keys, aggregate.ID) + continue + } + + model := models.keys[aggregate.ID] + model.AppendEvents(event) + if err := model.Reduce(); err != nil { + return err + } + } + models.events = models.events[0:0] + return nil +} + +func (models *webKeyWriteModels) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(models.resourceOwner). + AddQuery(). + AggregateTypes(webkey.AggregateType). + EventTypes( + webkey.AddedEventType, + webkey.ActivatedEventType, + webkey.DeactivatedEventType, + webkey.RemovedEventType, + ). + Builder() +} diff --git a/internal/command/web_key_test.go b/internal/command/web_key_test.go new file mode 100644 index 000000000..63463de1d --- /dev/null +++ b/internal/command/web_key_test.go @@ -0,0 +1,754 @@ +package command + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "io" + "testing" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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" + "github.com/zitadel/zitadel/internal/id" + id_mock "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/webkey" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_CreateWebKey(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 + idGenerator id.Generator + webKeyGenerator func(keyID string, alg crypto.EncryptionAlgorithm, genConfig crypto.WebKeyConfig) (encryptedPrivate *crypto.CryptoValue, public *jose.JSONWebKey, err error) + } + type args struct { + conf crypto.WebKeyConfig + } + tests := []struct { + name string + fields fields + args args + want *WebKeyDetails + wantErr error + }{ + { + name: "filter error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "generate error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + webKeyGenerator: func(string, crypto.EncryptionAlgorithm, crypto.WebKeyConfig) (*crypto.CryptoValue, *jose.JSONWebKey, error) { + return nil, nil, io.ErrClosedPipe + }, + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "generate key, 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, + }, + )), + eventFromEventPusher(webkey.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + ), + expectPush( + mustNewWebkeyAddedEvent(ctx, + webkey.NewAggregate("key2", "instance1"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, + &jose.JSONWebKey{ + Key: &key.PublicKey, + KeyID: "key2", + Algorithm: string(jose.ES384), + Use: crypto.KeyUsageSigning.String(), + }, + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key2"), + webKeyGenerator: func(keyID string, _ crypto.EncryptionAlgorithm, _ crypto.WebKeyConfig) (*crypto.CryptoValue, *jose.JSONWebKey, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, &jose.JSONWebKey{ + Key: &key.PublicKey, + KeyID: keyID, + Algorithm: string(jose.ES384), + Use: crypto.KeyUsageSigning.String(), + }, nil + }, + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + want: &WebKeyDetails{ + KeyID: "key2", + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "instance1", + ID: "key2", + }, + }, + }, + { + name: "generate and activate key, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + 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, + }, + ), + webkey.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + webKeyGenerator: func(keyID string, _ crypto.EncryptionAlgorithm, _ crypto.WebKeyConfig) (*crypto.CryptoValue, *jose.JSONWebKey, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, &jose.JSONWebKey{ + Key: &key.PublicKey, + KeyID: keyID, + Algorithm: string(jose.ES384), + Use: crypto.KeyUsageSigning.String(), + }, nil + }, + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + want: &WebKeyDetails{ + KeyID: "key1", + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "instance1", + ID: "key1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + webKeyGenerator: tt.fields.webKeyGenerator, + } + got, err := c.CreateWebKey(ctx, tt.args.conf) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommands_GenerateInitialWebKeys(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 + idGenerator id.Generator + webKeyGenerator func(keyID string, alg crypto.EncryptionAlgorithm, genConfig crypto.WebKeyConfig) (encryptedPrivate *crypto.CryptoValue, public *jose.JSONWebKey, err error) + } + type args struct { + conf crypto.WebKeyConfig + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "filter error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + wantErr: io.ErrClosedPipe, + }, + { + name: "key found, noop", + 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.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + ), + ), + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + wantErr: nil, + }, + { + name: "id generator error", + fields: fields{ + eventstore: expectEventstore(expectFilter()), + idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrUnexpectedEOF), + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + wantErr: io.ErrUnexpectedEOF, + }, + { + name: "keys generated and activated", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + 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, + }, + ), + webkey.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + ), + mustNewWebkeyAddedEvent(ctx, + webkey.NewAggregate("key2", "instance1"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, + &jose.JSONWebKey{ + Key: &key.PublicKey, + KeyID: "key2", + Algorithm: string(jose.ES384), + Use: crypto.KeyUsageSigning.String(), + }, + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1", "key2"), + webKeyGenerator: func(keyID string, _ crypto.EncryptionAlgorithm, _ crypto.WebKeyConfig) (*crypto.CryptoValue, *jose.JSONWebKey, error) { + return &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, &jose.JSONWebKey{ + Key: &key.PublicKey, + KeyID: keyID, + Algorithm: string(jose.ES384), + Use: crypto.KeyUsageSigning.String(), + }, nil + }, + }, + args: args{ + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + webKeyGenerator: tt.fields.webKeyGenerator, + } + err := c.GenerateInitialWebKeys(ctx, tt.args.conf) + require.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestCommands_ActivateWebKey(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 + webKeyGenerator func(keyID string, alg crypto.EncryptionAlgorithm, genConfig crypto.WebKeyConfig) (encryptedPrivate *crypto.CryptoValue, public *jose.JSONWebKey, err error) + } + type args struct { + keyID string + } + tests := []struct { + name string + fields fields + args args + want *domain.ObjectDetails + wantErr error + }{ + { + name: "filter error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(io.ErrClosedPipe), + ), + }, + args: args{"key2"}, + wantErr: io.ErrClosedPipe, + }, + { + name: "no changes", + 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.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + ), + ), + }, + args: args{"key1"}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + ID: "key1", + }, + }, + { + name: "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.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + ), + ), + }, + args: args{"key2"}, + wantErr: zerrors.ThrowNotFound(nil, "COMMAND-teiG3", "Errors.WebKey.NotFound"), + }, + { + name: "activate next, de-activate old, 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, + }, + )), + eventFromEventPusher(webkey.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + eventFromEventPusher(mustNewWebkeyAddedEvent(ctx, + webkey.NewAggregate("key2", "instance1"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, + &jose.JSONWebKey{ + Key: &key.PublicKey, + KeyID: "key2", + Algorithm: string(jose.ES384), + Use: crypto.KeyUsageSigning.String(), + }, + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + )), + ), + expectPush( + webkey.NewActivatedEvent(ctx, + webkey.NewAggregate("key2", "instance1"), + ), + webkey.NewDeactivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + ), + ), + ), + }, + args: args{"key2"}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + ID: "key2", + }, + }, + { + name: "activate next, 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, + }, + )), + ), + expectPush( + webkey.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + ), + ), + ), + }, + args: args{"key1"}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + ID: "key1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + webKeyGenerator: tt.fields.webKeyGenerator, + } + got, err := c.ActivateWebKey(ctx, tt.args.keyID) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestCommands_DeleteWebKey(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 *domain.ObjectDetails + 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, "COMMAND-ooCa7", "Errors.WebKey.NotFound"), + }, + { + name: "key active 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.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + ), + ), + }, + args: args{"key1"}, + wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Chai1", "Errors.WebKey.ActiveDelete"), + }, + { + name: "delete deactivated key", + 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.NewActivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + eventFromEventPusher(mustNewWebkeyAddedEvent(ctx, + webkey.NewAggregate("key2", "instance1"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "alg", + KeyID: "encKey", + Crypted: []byte("crypted"), + }, + &jose.JSONWebKey{ + Key: &key.PublicKey, + KeyID: "key2", + Algorithm: string(jose.ES384), + Use: crypto.KeyUsageSigning.String(), + }, + &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + )), + eventFromEventPusher(webkey.NewActivatedEvent(ctx, + webkey.NewAggregate("key2", "instance1"), + )), + eventFromEventPusher(webkey.NewDeactivatedEvent(ctx, + webkey.NewAggregate("key1", "instance1"), + )), + ), + expectPush( + webkey.NewRemovedEvent(ctx, webkey.NewAggregate("key1", "instance1")), + ), + ), + }, + args: args{"key1"}, + want: &domain.ObjectDetails{ + ResourceOwner: "instance1", + ID: "key1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + eventstore: tt.fields.eventstore(t), + } + got, err := c.DeleteWebKey(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 +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index a74f97a05..ff3b6e241 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -68,6 +68,14 @@ func Encrypt(value []byte, alg EncryptionAlgorithm) (*CryptoValue, error) { }, nil } +func EncryptJSON(obj any, alg EncryptionAlgorithm) (*CryptoValue, error) { + data, err := json.Marshal(obj) + if err != nil { + return nil, zerrors.ThrowInternal(err, "CRYPT-Ei6doF", "error encrypting value") + } + return Encrypt(data, alg) +} + func Decrypt(value *CryptoValue, alg EncryptionAlgorithm) ([]byte, error) { if err := checkEncryptionAlgorithm(value, alg); err != nil { return nil, err @@ -75,6 +83,17 @@ func Decrypt(value *CryptoValue, alg EncryptionAlgorithm) ([]byte, error) { return alg.Decrypt(value.Crypted, value.KeyID) } +func DecryptJSON(value *CryptoValue, dst any, alg EncryptionAlgorithm) error { + data, err := Decrypt(value, alg) + if err != nil { + return err + } + if err = json.Unmarshal(data, dst); err != nil { + return zerrors.ThrowInternal(err, "CRYPT-Jaik2R", "error decrypting value") + } + return nil +} + // DecryptString decrypts the value using the key identified by keyID. // When the decrypted value contains non-UTF8 characters an error is returned. func DecryptString(value *CryptoValue, alg EncryptionAlgorithm) (string, error) { diff --git a/internal/crypto/ellipticcurve_enumer.go b/internal/crypto/ellipticcurve_enumer.go new file mode 100644 index 000000000..770f4e46c --- /dev/null +++ b/internal/crypto/ellipticcurve_enumer.go @@ -0,0 +1,116 @@ +// Code generated by "enumer -type EllipticCurve -trimprefix EllipticCurve -text -json -linecomment"; DO NOT EDIT. + +package crypto + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _EllipticCurveName = "P256P384P512" + +var _EllipticCurveIndex = [...]uint8{0, 0, 4, 8, 12} + +const _EllipticCurveLowerName = "p256p384p512" + +func (i EllipticCurve) String() string { + if i < 0 || i >= EllipticCurve(len(_EllipticCurveIndex)-1) { + return fmt.Sprintf("EllipticCurve(%d)", i) + } + return _EllipticCurveName[_EllipticCurveIndex[i]:_EllipticCurveIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _EllipticCurveNoOp() { + var x [1]struct{} + _ = x[EllipticCurveUnspecified-(0)] + _ = x[EllipticCurveP256-(1)] + _ = x[EllipticCurveP384-(2)] + _ = x[EllipticCurveP512-(3)] +} + +var _EllipticCurveValues = []EllipticCurve{EllipticCurveUnspecified, EllipticCurveP256, EllipticCurveP384, EllipticCurveP512} + +var _EllipticCurveNameToValueMap = map[string]EllipticCurve{ + _EllipticCurveName[0:0]: EllipticCurveUnspecified, + _EllipticCurveLowerName[0:0]: EllipticCurveUnspecified, + _EllipticCurveName[0:4]: EllipticCurveP256, + _EllipticCurveLowerName[0:4]: EllipticCurveP256, + _EllipticCurveName[4:8]: EllipticCurveP384, + _EllipticCurveLowerName[4:8]: EllipticCurveP384, + _EllipticCurveName[8:12]: EllipticCurveP512, + _EllipticCurveLowerName[8:12]: EllipticCurveP512, +} + +var _EllipticCurveNames = []string{ + _EllipticCurveName[0:0], + _EllipticCurveName[0:4], + _EllipticCurveName[4:8], + _EllipticCurveName[8:12], +} + +// EllipticCurveString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func EllipticCurveString(s string) (EllipticCurve, error) { + if val, ok := _EllipticCurveNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _EllipticCurveNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to EllipticCurve values", s) +} + +// EllipticCurveValues returns all values of the enum +func EllipticCurveValues() []EllipticCurve { + return _EllipticCurveValues +} + +// EllipticCurveStrings returns a slice of all String values of the enum +func EllipticCurveStrings() []string { + strs := make([]string, len(_EllipticCurveNames)) + copy(strs, _EllipticCurveNames) + return strs +} + +// IsAEllipticCurve returns "true" if the value is listed in the enum definition. "false" otherwise +func (i EllipticCurve) IsAEllipticCurve() bool { + for _, v := range _EllipticCurveValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for EllipticCurve +func (i EllipticCurve) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for EllipticCurve +func (i *EllipticCurve) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("EllipticCurve should be a string, got %s", data) + } + + var err error + *i, err = EllipticCurveString(s) + return err +} + +// MarshalText implements the encoding.TextMarshaler interface for EllipticCurve +func (i EllipticCurve) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for EllipticCurve +func (i *EllipticCurve) UnmarshalText(text []byte) error { + var err error + *i, err = EllipticCurveString(string(text)) + return err +} diff --git a/internal/crypto/rsabits_enumer.go b/internal/crypto/rsabits_enumer.go new file mode 100644 index 000000000..f3856c87c --- /dev/null +++ b/internal/crypto/rsabits_enumer.go @@ -0,0 +1,136 @@ +// Code generated by "enumer -type RSABits -trimprefix RSABits -text -json -linecomment"; DO NOT EDIT. + +package crypto + +import ( + "encoding/json" + "fmt" + "strings" +) + +const ( + _RSABitsName_0 = "" + _RSABitsLowerName_0 = "" + _RSABitsName_1 = "2048" + _RSABitsLowerName_1 = "2048" + _RSABitsName_2 = "3072" + _RSABitsLowerName_2 = "3072" + _RSABitsName_3 = "4096" + _RSABitsLowerName_3 = "4096" +) + +var ( + _RSABitsIndex_0 = [...]uint8{0, 0} + _RSABitsIndex_1 = [...]uint8{0, 4} + _RSABitsIndex_2 = [...]uint8{0, 4} + _RSABitsIndex_3 = [...]uint8{0, 4} +) + +func (i RSABits) String() string { + switch { + case i == 0: + return _RSABitsName_0 + case i == 2048: + return _RSABitsName_1 + case i == 3072: + return _RSABitsName_2 + case i == 4096: + return _RSABitsName_3 + default: + return fmt.Sprintf("RSABits(%d)", i) + } +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _RSABitsNoOp() { + var x [1]struct{} + _ = x[RSABitsUnspecified-(0)] + _ = x[RSABits2048-(2048)] + _ = x[RSABits3072-(3072)] + _ = x[RSABits4096-(4096)] +} + +var _RSABitsValues = []RSABits{RSABitsUnspecified, RSABits2048, RSABits3072, RSABits4096} + +var _RSABitsNameToValueMap = map[string]RSABits{ + _RSABitsName_0[0:0]: RSABitsUnspecified, + _RSABitsLowerName_0[0:0]: RSABitsUnspecified, + _RSABitsName_1[0:4]: RSABits2048, + _RSABitsLowerName_1[0:4]: RSABits2048, + _RSABitsName_2[0:4]: RSABits3072, + _RSABitsLowerName_2[0:4]: RSABits3072, + _RSABitsName_3[0:4]: RSABits4096, + _RSABitsLowerName_3[0:4]: RSABits4096, +} + +var _RSABitsNames = []string{ + _RSABitsName_0[0:0], + _RSABitsName_1[0:4], + _RSABitsName_2[0:4], + _RSABitsName_3[0:4], +} + +// RSABitsString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func RSABitsString(s string) (RSABits, error) { + if val, ok := _RSABitsNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _RSABitsNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to RSABits values", s) +} + +// RSABitsValues returns all values of the enum +func RSABitsValues() []RSABits { + return _RSABitsValues +} + +// RSABitsStrings returns a slice of all String values of the enum +func RSABitsStrings() []string { + strs := make([]string, len(_RSABitsNames)) + copy(strs, _RSABitsNames) + return strs +} + +// IsARSABits returns "true" if the value is listed in the enum definition. "false" otherwise +func (i RSABits) IsARSABits() bool { + for _, v := range _RSABitsValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for RSABits +func (i RSABits) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for RSABits +func (i *RSABits) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("RSABits should be a string, got %s", data) + } + + var err error + *i, err = RSABitsString(s) + return err +} + +// MarshalText implements the encoding.TextMarshaler interface for RSABits +func (i RSABits) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for RSABits +func (i *RSABits) UnmarshalText(text []byte) error { + var err error + *i, err = RSABitsString(string(text)) + return err +} diff --git a/internal/crypto/rsahasher_enumer.go b/internal/crypto/rsahasher_enumer.go new file mode 100644 index 000000000..31023d873 --- /dev/null +++ b/internal/crypto/rsahasher_enumer.go @@ -0,0 +1,116 @@ +// Code generated by "enumer -type RSAHasher -trimprefix RSAHasher -text -json -linecomment"; DO NOT EDIT. + +package crypto + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _RSAHasherName = "SHA256SHA384SHA512" + +var _RSAHasherIndex = [...]uint8{0, 0, 6, 12, 18} + +const _RSAHasherLowerName = "sha256sha384sha512" + +func (i RSAHasher) String() string { + if i < 0 || i >= RSAHasher(len(_RSAHasherIndex)-1) { + return fmt.Sprintf("RSAHasher(%d)", i) + } + return _RSAHasherName[_RSAHasherIndex[i]:_RSAHasherIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _RSAHasherNoOp() { + var x [1]struct{} + _ = x[RSAHasherUnspecified-(0)] + _ = x[RSAHasherSHA256-(1)] + _ = x[RSAHasherSHA384-(2)] + _ = x[RSAHasherSHA512-(3)] +} + +var _RSAHasherValues = []RSAHasher{RSAHasherUnspecified, RSAHasherSHA256, RSAHasherSHA384, RSAHasherSHA512} + +var _RSAHasherNameToValueMap = map[string]RSAHasher{ + _RSAHasherName[0:0]: RSAHasherUnspecified, + _RSAHasherLowerName[0:0]: RSAHasherUnspecified, + _RSAHasherName[0:6]: RSAHasherSHA256, + _RSAHasherLowerName[0:6]: RSAHasherSHA256, + _RSAHasherName[6:12]: RSAHasherSHA384, + _RSAHasherLowerName[6:12]: RSAHasherSHA384, + _RSAHasherName[12:18]: RSAHasherSHA512, + _RSAHasherLowerName[12:18]: RSAHasherSHA512, +} + +var _RSAHasherNames = []string{ + _RSAHasherName[0:0], + _RSAHasherName[0:6], + _RSAHasherName[6:12], + _RSAHasherName[12:18], +} + +// RSAHasherString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func RSAHasherString(s string) (RSAHasher, error) { + if val, ok := _RSAHasherNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _RSAHasherNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to RSAHasher values", s) +} + +// RSAHasherValues returns all values of the enum +func RSAHasherValues() []RSAHasher { + return _RSAHasherValues +} + +// RSAHasherStrings returns a slice of all String values of the enum +func RSAHasherStrings() []string { + strs := make([]string, len(_RSAHasherNames)) + copy(strs, _RSAHasherNames) + return strs +} + +// IsARSAHasher returns "true" if the value is listed in the enum definition. "false" otherwise +func (i RSAHasher) IsARSAHasher() bool { + for _, v := range _RSAHasherValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for RSAHasher +func (i RSAHasher) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for RSAHasher +func (i *RSAHasher) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("RSAHasher should be a string, got %s", data) + } + + var err error + *i, err = RSAHasherString(s) + return err +} + +// MarshalText implements the encoding.TextMarshaler interface for RSAHasher +func (i RSAHasher) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for RSAHasher +func (i *RSAHasher) UnmarshalText(text []byte) error { + var err error + *i, err = RSAHasherString(string(text)) + return err +} diff --git a/internal/crypto/web_key.go b/internal/crypto/web_key.go new file mode 100644 index 000000000..c769cb121 --- /dev/null +++ b/internal/crypto/web_key.go @@ -0,0 +1,238 @@ +package crypto + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "encoding/json" + + "github.com/go-jose/go-jose/v4" + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/zerrors" +) + +type KeyUsage int32 + +const ( + KeyUsageSigning KeyUsage = iota + KeyUsageSAMLMetadataSigning + KeyUsageSAMLResponseSinging + KeyUsageSAMLCA +) + +func (u KeyUsage) String() string { + switch u { + case KeyUsageSigning: + return "sig" + case KeyUsageSAMLCA: + return "saml_ca" + case KeyUsageSAMLResponseSinging: + return "saml_response_sig" + case KeyUsageSAMLMetadataSigning: + return "saml_metadata_sig" + } + return "" +} + +//go:generate enumer -type WebKeyConfigType -trimprefix WebKeyConfigType -text -json -linecomment +type WebKeyConfigType int + +const ( + WebKeyConfigTypeUnspecified WebKeyConfigType = iota // + WebKeyConfigTypeRSA + WebKeyConfigTypeECDSA + WebKeyConfigTypeED25519 +) + +//go:generate enumer -type RSABits -trimprefix RSABits -text -json -linecomment +type RSABits int + +const ( + RSABitsUnspecified RSABits = 0 // + RSABits2048 RSABits = 2048 + RSABits3072 RSABits = 3072 + RSABits4096 RSABits = 4096 +) + +type RSAHasher int + +//go:generate enumer -type RSAHasher -trimprefix RSAHasher -text -json -linecomment +const ( + RSAHasherUnspecified RSAHasher = iota // + RSAHasherSHA256 + RSAHasherSHA384 + RSAHasherSHA512 +) + +type EllipticCurve int + +//go:generate enumer -type EllipticCurve -trimprefix EllipticCurve -text -json -linecomment +const ( + EllipticCurveUnspecified EllipticCurve = iota // + EllipticCurveP256 + EllipticCurveP384 + EllipticCurveP512 +) + +type WebKeyConfig interface { + Alg() jose.SignatureAlgorithm + Type() WebKeyConfigType // Type is needed to make Unmarshal work + IsValid() error +} + +func UnmarshalWebKeyConfig(data []byte, configType WebKeyConfigType) (config WebKeyConfig, err error) { + switch configType { + case WebKeyConfigTypeUnspecified: + return nil, zerrors.ThrowInternal(nil, "CRYPT-Ii3AiH", "Errors.Internal") + case WebKeyConfigTypeRSA: + config = new(WebKeyRSAConfig) + case WebKeyConfigTypeECDSA: + config = new(WebKeyECDSAConfig) + case WebKeyConfigTypeED25519: + config = new(WebKeyED25519Config) + default: + return nil, zerrors.ThrowInternal(nil, "CRYPT-Eig8ho", "Errors.Internal") + } + if err = json.Unmarshal(data, config); err != nil { + return nil, zerrors.ThrowInternal(err, "CRYPT-waeR0N", "Errors.Internal") + } + return config, nil +} + +type WebKeyRSAConfig struct { + Bits RSABits + Hasher RSAHasher +} + +func (c WebKeyRSAConfig) Alg() jose.SignatureAlgorithm { + switch c.Hasher { + case RSAHasherUnspecified: + return "" + case RSAHasherSHA256: + return jose.RS256 + case RSAHasherSHA384: + return jose.RS384 + case RSAHasherSHA512: + return jose.RS512 + default: + return "" + } +} + +func (WebKeyRSAConfig) Type() WebKeyConfigType { + return WebKeyConfigTypeRSA +} + +func (c WebKeyRSAConfig) IsValid() error { + if !c.Bits.IsARSABits() || c.Bits == RSABitsUnspecified { + return zerrors.ThrowInvalidArgument(nil, "CRYPTO-eaz3T", "Errors.WebKey.Config") + } + if !c.Hasher.IsARSAHasher() || c.Hasher == RSAHasherUnspecified { + return zerrors.ThrowInvalidArgument(nil, "CRYPTO-ODie7", "Errors.WebKey.Config") + } + return nil +} + +type WebKeyECDSAConfig struct { + Curve EllipticCurve +} + +func (c WebKeyECDSAConfig) Alg() jose.SignatureAlgorithm { + switch c.Curve { + case EllipticCurveUnspecified: + return "" + case EllipticCurveP256: + return jose.ES256 + case EllipticCurveP384: + return jose.ES384 + case EllipticCurveP512: + return jose.ES512 + default: + return "" + } +} + +func (WebKeyECDSAConfig) Type() WebKeyConfigType { + return WebKeyConfigTypeECDSA +} + +func (c WebKeyECDSAConfig) IsValid() error { + if !c.Curve.IsAEllipticCurve() || c.Curve == EllipticCurveUnspecified { + return zerrors.ThrowInvalidArgument(nil, "CRYPTO-Ii2ai", "Errors.WebKey.Config") + } + return nil +} + +func (c WebKeyECDSAConfig) GetCurve() elliptic.Curve { + switch c.Curve { + case EllipticCurveUnspecified: + return nil + case EllipticCurveP256: + return elliptic.P256() + case EllipticCurveP384: + return elliptic.P384() + case EllipticCurveP512: + return elliptic.P521() + default: + return nil + } +} + +type WebKeyED25519Config struct{} + +func (WebKeyED25519Config) Alg() jose.SignatureAlgorithm { + return jose.EdDSA +} + +func (WebKeyED25519Config) Type() WebKeyConfigType { + return WebKeyConfigTypeED25519 +} + +func (WebKeyED25519Config) IsValid() error { + return nil +} + +func GenerateEncryptedWebKey(keyID string, alg EncryptionAlgorithm, genConfig WebKeyConfig) (encryptedPrivate *CryptoValue, public *jose.JSONWebKey, err error) { + private, public, err := generateWebKey(keyID, genConfig) + if err != nil { + return nil, nil, err + } + encryptedPrivate, err = EncryptJSON(private, alg) + if err != nil { + return nil, nil, err + } + return encryptedPrivate, public, nil +} + +func generateWebKey(keyID string, genConfig WebKeyConfig) (private, public *jose.JSONWebKey, err error) { + if err = genConfig.IsValid(); err != nil { + return nil, nil, err + } + var key any + switch conf := genConfig.(type) { + case *WebKeyRSAConfig: + key, err = rsa.GenerateKey(rand.Reader, int(conf.Bits)) + case *WebKeyECDSAConfig: + key, err = ecdsa.GenerateKey(conf.GetCurve(), rand.Reader) + case *WebKeyED25519Config: + _, key, err = ed25519.GenerateKey(rand.Reader) + } + if err != nil { + return nil, nil, err + } + + private = newJSONWebkey(key, keyID, genConfig.Alg()) + return private, gu.Ptr(private.Public()), err +} + +func newJSONWebkey(key any, keyID string, algorithm jose.SignatureAlgorithm) *jose.JSONWebKey { + return &jose.JSONWebKey{ + Key: key, + KeyID: keyID, + Algorithm: string(algorithm), + Use: KeyUsageSigning.String(), + } +} diff --git a/internal/crypto/web_key_test.go b/internal/crypto/web_key_test.go new file mode 100644 index 000000000..e6b1a5a56 --- /dev/null +++ b/internal/crypto/web_key_test.go @@ -0,0 +1,269 @@ +package crypto + +import ( + "crypto/elliptic" + "testing" + + "github.com/go-jose/go-jose/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestUnmarshalWebKeyConfig(t *testing.T) { + type args struct { + data []byte + configType WebKeyConfigType + } + tests := []struct { + name string + args args + wantConfig WebKeyConfig + wantErr error + }{ + { + name: "unspecified", + args: args{ + []byte(`{}`), + WebKeyConfigTypeUnspecified, + }, + wantErr: zerrors.ThrowInternal(nil, "CRYPT-Ii3AiH", "Errors.Internal"), + }, + { + name: "rsa", + args: args{ + []byte(`{"bits":"2048", "hasher":"sha256"}`), + WebKeyConfigTypeRSA, + }, + wantConfig: &WebKeyRSAConfig{ + Bits: RSABits2048, + Hasher: RSAHasherSHA256, + }, + }, + { + name: "ecdsa", + args: args{ + []byte(`{"curve":"p256"}`), + WebKeyConfigTypeECDSA, + }, + wantConfig: &WebKeyECDSAConfig{ + Curve: EllipticCurveP256, + }, + }, + { + name: "ed25519", + args: args{ + []byte(`{}`), + WebKeyConfigTypeED25519, + }, + wantConfig: &WebKeyED25519Config{}, + }, + { + name: "unknown type error", + args: args{ + []byte(`{"curve":0}`), + 99, + }, + wantErr: zerrors.ThrowInternal(nil, "CRYPT-Eig8ho", "Errors.Internal"), + }, + { + name: "unmarshal error", + args: args{ + []byte(`~~`), + WebKeyConfigTypeED25519, + }, + wantErr: zerrors.ThrowInternal(nil, "CRYPT-waeR0N", "Errors.Internal"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotConfig, err := UnmarshalWebKeyConfig(tt.args.data, tt.args.configType) + require.ErrorIs(t, err, tt.wantErr) + assert.Equal(t, gotConfig, tt.wantConfig) + }) + } +} + +func TestWebKeyECDSAConfig_Alg(t *testing.T) { + type fields struct { + Curve EllipticCurve + } + tests := []struct { + name string + fields fields + want jose.SignatureAlgorithm + }{ + { + name: "unspecified", + fields: fields{ + Curve: EllipticCurveUnspecified, + }, + want: "", + }, + { + name: "P256", + fields: fields{ + Curve: EllipticCurveP256, + }, + want: jose.ES256, + }, + { + name: "P384", + fields: fields{ + Curve: EllipticCurveP384, + }, + want: jose.ES384, + }, + { + name: "P512", + fields: fields{ + Curve: EllipticCurveP512, + }, + want: jose.ES512, + }, + { + name: "default", + fields: fields{ + Curve: 99, + }, + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := WebKeyECDSAConfig{ + Curve: tt.fields.Curve, + } + got := c.Alg() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestWebKeyECDSAConfig_GetCurve(t *testing.T) { + type fields struct { + Curve EllipticCurve + } + tests := []struct { + name string + fields fields + want elliptic.Curve + }{ + { + name: "unspecified", + fields: fields{EllipticCurveUnspecified}, + want: nil, + }, + { + name: "P256", + fields: fields{EllipticCurveP256}, + want: elliptic.P256(), + }, + { + name: "P384", + fields: fields{EllipticCurveP384}, + want: elliptic.P384(), + }, + { + name: "P512", + fields: fields{EllipticCurveP512}, + want: elliptic.P521(), + }, + { + name: "default", + fields: fields{99}, + want: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := WebKeyECDSAConfig{ + Curve: tt.fields.Curve, + } + got := c.GetCurve() + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_generateEncryptedWebKey(t *testing.T) { + type args struct { + keyID string + genConfig WebKeyConfig + } + tests := []struct { + name string + args args + assertPrivate func(t *testing.T, got *jose.JSONWebKey) + assertPublic func(t *testing.T, got *jose.JSONWebKey) + wantErr error + }{ + { + name: "invalid", + args: args{ + keyID: "keyID", + genConfig: &WebKeyRSAConfig{ + Bits: RSABitsUnspecified, + Hasher: RSAHasherSHA256, + }, + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "CRYPTO-eaz3T", "Errors.WebKey.Config"), + }, + { + name: "RSA", + args: args{ + keyID: "keyID", + genConfig: &WebKeyRSAConfig{ + Bits: RSABits2048, + Hasher: RSAHasherSHA256, + }, + }, + assertPrivate: assertJSONWebKey("keyID", "RS256", "sig", false), + assertPublic: assertJSONWebKey("keyID", "RS256", "sig", true), + }, + { + name: "ECDSA", + args: args{ + keyID: "keyID", + genConfig: &WebKeyECDSAConfig{ + Curve: EllipticCurveP256, + }, + }, + assertPrivate: assertJSONWebKey("keyID", "ES256", "sig", false), + assertPublic: assertJSONWebKey("keyID", "ES256", "sig", true), + }, + { + name: "ED25519", + args: args{ + keyID: "keyID", + genConfig: &WebKeyED25519Config{}, + }, + assertPrivate: assertJSONWebKey("keyID", "EdDSA", "sig", false), + assertPublic: assertJSONWebKey("keyID", "EdDSA", "sig", true), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotPrivate, gotPublic, err := generateWebKey(tt.args.keyID, tt.args.genConfig) + require.ErrorIs(t, err, tt.wantErr) + if tt.assertPrivate != nil { + tt.assertPrivate(t, gotPrivate) + } + if tt.assertPublic != nil { + tt.assertPublic(t, gotPublic) + } + }) + } +} + +func assertJSONWebKey(keyID, algorithm, use string, isPublic bool) func(t *testing.T, got *jose.JSONWebKey) { + return func(t *testing.T, got *jose.JSONWebKey) { + assert.NotNil(t, got) + assert.NotNil(t, got.Key, "key") + assert.Equal(t, keyID, got.KeyID, "keyID") + assert.Equal(t, algorithm, got.Algorithm, "algorithm") + assert.Equal(t, use, got.Use, "user") + assert.Equal(t, isPublic, got.IsPublic(), "isPublic") + } +} diff --git a/internal/crypto/webkeyconfigtype_enumer.go b/internal/crypto/webkeyconfigtype_enumer.go new file mode 100644 index 000000000..272540201 --- /dev/null +++ b/internal/crypto/webkeyconfigtype_enumer.go @@ -0,0 +1,116 @@ +// Code generated by "enumer -type WebKeyConfigType -trimprefix WebKeyConfigType -text -json -linecomment"; DO NOT EDIT. + +package crypto + +import ( + "encoding/json" + "fmt" + "strings" +) + +const _WebKeyConfigTypeName = "RSAECDSAED25519" + +var _WebKeyConfigTypeIndex = [...]uint8{0, 0, 3, 8, 15} + +const _WebKeyConfigTypeLowerName = "rsaecdsaed25519" + +func (i WebKeyConfigType) String() string { + if i < 0 || i >= WebKeyConfigType(len(_WebKeyConfigTypeIndex)-1) { + return fmt.Sprintf("WebKeyConfigType(%d)", i) + } + return _WebKeyConfigTypeName[_WebKeyConfigTypeIndex[i]:_WebKeyConfigTypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _WebKeyConfigTypeNoOp() { + var x [1]struct{} + _ = x[WebKeyConfigTypeUnspecified-(0)] + _ = x[WebKeyConfigTypeRSA-(1)] + _ = x[WebKeyConfigTypeECDSA-(2)] + _ = x[WebKeyConfigTypeED25519-(3)] +} + +var _WebKeyConfigTypeValues = []WebKeyConfigType{WebKeyConfigTypeUnspecified, WebKeyConfigTypeRSA, WebKeyConfigTypeECDSA, WebKeyConfigTypeED25519} + +var _WebKeyConfigTypeNameToValueMap = map[string]WebKeyConfigType{ + _WebKeyConfigTypeName[0:0]: WebKeyConfigTypeUnspecified, + _WebKeyConfigTypeLowerName[0:0]: WebKeyConfigTypeUnspecified, + _WebKeyConfigTypeName[0:3]: WebKeyConfigTypeRSA, + _WebKeyConfigTypeLowerName[0:3]: WebKeyConfigTypeRSA, + _WebKeyConfigTypeName[3:8]: WebKeyConfigTypeECDSA, + _WebKeyConfigTypeLowerName[3:8]: WebKeyConfigTypeECDSA, + _WebKeyConfigTypeName[8:15]: WebKeyConfigTypeED25519, + _WebKeyConfigTypeLowerName[8:15]: WebKeyConfigTypeED25519, +} + +var _WebKeyConfigTypeNames = []string{ + _WebKeyConfigTypeName[0:0], + _WebKeyConfigTypeName[0:3], + _WebKeyConfigTypeName[3:8], + _WebKeyConfigTypeName[8:15], +} + +// WebKeyConfigTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func WebKeyConfigTypeString(s string) (WebKeyConfigType, error) { + if val, ok := _WebKeyConfigTypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _WebKeyConfigTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to WebKeyConfigType values", s) +} + +// WebKeyConfigTypeValues returns all values of the enum +func WebKeyConfigTypeValues() []WebKeyConfigType { + return _WebKeyConfigTypeValues +} + +// WebKeyConfigTypeStrings returns a slice of all String values of the enum +func WebKeyConfigTypeStrings() []string { + strs := make([]string, len(_WebKeyConfigTypeNames)) + copy(strs, _WebKeyConfigTypeNames) + return strs +} + +// IsAWebKeyConfigType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i WebKeyConfigType) IsAWebKeyConfigType() bool { + for _, v := range _WebKeyConfigTypeValues { + if i == v { + return true + } + } + return false +} + +// MarshalJSON implements the json.Marshaler interface for WebKeyConfigType +func (i WebKeyConfigType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for WebKeyConfigType +func (i *WebKeyConfigType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("WebKeyConfigType should be a string, got %s", data) + } + + var err error + *i, err = WebKeyConfigTypeString(s) + return err +} + +// MarshalText implements the encoding.TextMarshaler interface for WebKeyConfigType +func (i WebKeyConfigType) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for WebKeyConfigType +func (i *WebKeyConfigType) UnmarshalText(text []byte) error { + var err error + *i, err = WebKeyConfigTypeString(string(text)) + return err +} diff --git a/internal/domain/key_pair.go b/internal/domain/key_pair.go index ff0ec773f..ffc0e38e5 100644 --- a/internal/domain/key_pair.go +++ b/internal/domain/key_pair.go @@ -10,36 +10,13 @@ import ( type KeyPair struct { es_models.ObjectRoot - Usage KeyUsage + Usage crypto.KeyUsage Algorithm string PrivateKey *Key PublicKey *Key Certificate *Key } -type KeyUsage int32 - -const ( - KeyUsageSigning KeyUsage = iota - KeyUsageSAMLMetadataSigning - KeyUsageSAMLResponseSinging - KeyUsageSAMLCA -) - -func (u KeyUsage) String() string { - switch u { - case KeyUsageSigning: - return "sig" - case KeyUsageSAMLCA: - return "saml_ca" - case KeyUsageSAMLResponseSinging: - return "saml_response_sig" - case KeyUsageSAMLMetadataSigning: - return "saml_metadata_sig" - } - return "" -} - type Key struct { Key *crypto.CryptoValue Expiry time.Time diff --git a/internal/domain/web_key.go b/internal/domain/web_key.go new file mode 100644 index 000000000..4246ee770 --- /dev/null +++ b/internal/domain/web_key.go @@ -0,0 +1,11 @@ +package domain + +type WebKeyState int + +const ( + WebKeyStateUnspecified WebKeyState = iota + WebKeyStateInitial + WebKeyStateActive + WebKeyStateInactive + WebKeyStateRemoved +) diff --git a/internal/eventstore/aggregate.go b/internal/eventstore/aggregate.go index 9939d8335..87282e900 100644 --- a/internal/eventstore/aggregate.go +++ b/internal/eventstore/aggregate.go @@ -48,15 +48,25 @@ func WithInstanceID(id string) aggregateOpt { } } -// AggregateFromWriteModel maps the given WriteModel to an Aggregate +// AggregateFromWriteModel maps the given WriteModel to an Aggregate. +// Deprecated: Creates linter errors on missing context. Use [AggregateFromWriteModelCtx] instead. func AggregateFromWriteModel( wm *WriteModel, typ AggregateType, version Version, +) *Aggregate { + return AggregateFromWriteModelCtx(context.Background(), wm, typ, version) +} + +// AggregateFromWriteModelCtx maps the given WriteModel to an Aggregate. +func AggregateFromWriteModelCtx( + ctx context.Context, + wm *WriteModel, + typ AggregateType, + version Version, ) *Aggregate { return NewAggregate( - // TODO: the linter complains if this function is called without passing a context - context.Background(), + ctx, wm.AggregateID, typ, version, diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index 6ae64ddf0..d41521ad8 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -166,7 +166,7 @@ func (e *mockEvent) DataAsBytes() []byte { } payload, err := json.Marshal(e.Payload()) if err != nil { - panic("unable to unmarshal") + panic(err) } return payload } diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 5f0453f07..34dd5d908 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -14,6 +14,7 @@ const ( KeyTokenExchange KeyActions KeyImprovedPerformance + KeyWebKey ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -37,6 +38,7 @@ type Features struct { TokenExchange bool `json:"token_exchange,omitempty"` Actions bool `json:"actions,omitempty"` ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` + WebKey bool `json:"web_key,omitempty"` } type ImprovedPerformanceType int32 diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index ca3e156b6..6452a258c 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performance" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_key" -var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133} +var _KeyIndex = [...]uint8{0, 11, 28, 61, 81, 92, 106, 113, 133, 140} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performance" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_key" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -32,9 +32,10 @@ func _KeyNoOp() { _ = x[KeyTokenExchange-(5)] _ = x[KeyActions-(6)] _ = x[KeyImprovedPerformance-(7)] + _ = x[KeyWebKey-(8)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey} var _KeyNameToValueMap = map[string]Key{ _KeyName[0:11]: KeyUnspecified, @@ -53,6 +54,8 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[106:113]: KeyActions, _KeyName[113:133]: KeyImprovedPerformance, _KeyLowerName[113:133]: KeyImprovedPerformance, + _KeyName[133:140]: KeyWebKey, + _KeyLowerName[133:140]: KeyWebKey, } var _KeyNames = []string{ @@ -64,6 +67,7 @@ var _KeyNames = []string{ _KeyName[92:106], _KeyName[106:113], _KeyName[113:133], + _KeyName[133:140], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/integration/client.go b/internal/integration/client.go index 9e5dc63dd..947c11508 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -36,6 +36,7 @@ import ( org "github.com/zitadel/zitadel/pkg/grpc/org/v2" org_v2beta "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" action "github.com/zitadel/zitadel/pkg/grpc/resources/action/v3alpha" + webkey_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha" session "github.com/zitadel/zitadel/pkg/grpc/session/v2" session_v2beta "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2" @@ -63,10 +64,11 @@ type Client struct { OrgV2beta org_v2beta.OrganizationServiceClient OrgV2 org.OrganizationServiceClient System system.SystemServiceClient - ActionV3 action.ZITADELActionsClient + ActionV3Alpha action.ZITADELActionsClient FeatureV2beta feature_v2beta.FeatureServiceClient FeatureV2 feature.FeatureServiceClient UserSchemaV3 schema.UserSchemaServiceClient + WebKeyV3Alpha webkey_v3alpha.ZITADELWebKeysClient } func newClient(cc *grpc.ClientConn) Client { @@ -86,10 +88,11 @@ func newClient(cc *grpc.ClientConn) Client { OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), OrgV2: org.NewOrganizationServiceClient(cc), System: system.NewSystemServiceClient(cc), - ActionV3: action.NewZITADELActionsClient(cc), + ActionV3Alpha: action.NewZITADELActionsClient(cc), FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), FeatureV2: feature.NewFeatureServiceClient(cc), UserSchemaV3: schema.NewUserSchemaServiceClient(cc), + WebKeyV3Alpha: webkey_v3alpha.NewZITADELWebKeysClient(cc), } } @@ -649,20 +652,20 @@ func (s *Tester) CreateTarget(ctx context.Context, t *testing.T, name, endpoint RestAsync: &action.SetRESTAsync{}, } } - target, err := s.Client.ActionV3.CreateTarget(ctx, &action.CreateTargetRequest{Target: reqTarget}) + target, err := s.Client.ActionV3Alpha.CreateTarget(ctx, &action.CreateTargetRequest{Target: reqTarget}) require.NoError(t, err) return target } func (s *Tester) DeleteExecution(ctx context.Context, t *testing.T, cond *action.Condition) { - _, err := s.Client.ActionV3.SetExecution(ctx, &action.SetExecutionRequest{ + _, err := s.Client.ActionV3Alpha.SetExecution(ctx, &action.SetExecutionRequest{ Condition: cond, }) require.NoError(t, err) } func (s *Tester) SetExecution(ctx context.Context, t *testing.T, cond *action.Condition, targets []*action.ExecutionTargetType) *action.SetExecutionResponse { - target, err := s.Client.ActionV3.SetExecution(ctx, &action.SetExecutionRequest{ + target, err := s.Client.ActionV3Alpha.SetExecution(ctx, &action.SetExecutionRequest{ Condition: cond, Execution: &action.Execution{ Targets: targets, diff --git a/internal/query/certificate.go b/internal/query/certificate.go index f4254e023..e4d53213c 100644 --- a/internal/query/certificate.go +++ b/internal/query/certificate.go @@ -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) }() diff --git a/internal/query/certificate_test.go b/internal/query/certificate_test.go index a6c862c8a..01e563de1 100644 --- a/internal/query/certificate_test.go +++ b/internal/query/certificate_test.go @@ -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"), diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 12d8e0d80..f10039fa6 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -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) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 215442c91..a2ab09d26 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -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 } diff --git a/internal/query/key.go b/internal/query/key.go index ae733e8dd..d7475e424 100644 --- a/internal/query/key.go +++ b/internal/query/key.go @@ -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 { diff --git a/internal/query/key_test.go b/internal/query/key_test.go index 70e5860eb..a977bfb58 100644 --- a/internal/query/key_test.go +++ b/internal/query/key_test.go @@ -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 { diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 06090a2f5..d24fe6d20 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -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), diff --git a/internal/query/projection/key_test.go b/internal/query/projection/key_test.go index 7022c1b9e..75358cce1 100644 --- a/internal/query/projection/key_test.go +++ b/internal/query/projection/key_test.go @@ -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) + `"}}`) } diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index a7776d24a..0151a9953 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -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, } } diff --git a/internal/query/projection/web_key.go b/internal/query/projection/web_key.go new file mode 100644 index 000000000..231f5ddfb --- /dev/null +++ b/internal/query/projection/web_key.go @@ -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 +} diff --git a/internal/query/web_key.go b/internal/query/web_key.go new file mode 100644 index 000000000..f8930a628 --- /dev/null +++ b/internal/query/web_key.go @@ -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 +} diff --git a/internal/query/web_key_by_state.sql b/internal/query/web_key_by_state.sql new file mode 100644 index 000000000..3d7875477 --- /dev/null +++ b/internal/query/web_key_by_state.sql @@ -0,0 +1,5 @@ +select private_key +from projections.web_keys +where instance_id = $1 +and state = $2 +limit 1; diff --git a/internal/query/web_key_list.sql b/internal/query/web_key_list.sql new file mode 100644 index 000000000..1671e55ee --- /dev/null +++ b/internal/query/web_key_list.sql @@ -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; diff --git a/internal/query/web_key_model.go b/internal/query/web_key_model.go new file mode 100644 index 000000000..117f2ba20 --- /dev/null +++ b/internal/query/web_key_model.go @@ -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() +} diff --git a/internal/query/web_key_public_keys.sql b/internal/query/web_key_public_keys.sql new file mode 100644 index 000000000..e59ca6174 --- /dev/null +++ b/internal/query/web_key_public_keys.sql @@ -0,0 +1,3 @@ +select public_key +from projections.web_keys +where instance_id = $1; diff --git a/internal/query/web_key_test.go b/internal/query/web_key_test.go new file mode 100644 index 000000000..6008ec652 --- /dev/null +++ b/internal/query/web_key_test.go @@ -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) + }) + }) + } +} diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index bd8b22eec..97b4e4ed3 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -23,4 +23,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceActionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) + eventstore.RegisterFilterEventMapper(AggregateType, InstanceWebKeyEventType, eventstore.GenericEventMapper[SetEvent[bool]]) } diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index d5beea8bf..27e1ed40f 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -28,6 +28,7 @@ var ( InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) InstanceActionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyActions) InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) + InstanceWebKeyEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyWebKey) ) const ( diff --git a/internal/repository/keypair/key_pair.go b/internal/repository/keypair/key_pair.go index 8bf2e7708..aeb02bb06 100644 --- a/internal/repository/keypair/key_pair.go +++ b/internal/repository/keypair/key_pair.go @@ -5,7 +5,6 @@ import ( "time" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -18,7 +17,7 @@ const ( type AddedEvent struct { eventstore.BaseEvent `json:"-"` - Usage domain.KeyUsage `json:"usage"` + Usage crypto.KeyUsage `json:"usage"` Algorithm string `json:"algorithm"` PrivateKey *Key `json:"privateKey"` PublicKey *Key `json:"publicKey"` @@ -40,7 +39,7 @@ func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { func NewAddedEvent( ctx context.Context, aggregate *eventstore.Aggregate, - usage domain.KeyUsage, + usage crypto.KeyUsage, algorithm string, privateCrypto, publicCrypto *crypto.CryptoValue, diff --git a/internal/repository/webkey/aggregate.go b/internal/repository/webkey/aggregate.go new file mode 100644 index 000000000..afe63aaee --- /dev/null +++ b/internal/repository/webkey/aggregate.go @@ -0,0 +1,25 @@ +package webkey + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + AggregateType = "web_key" + AggregateVersion = "v1" +) + +func NewAggregate(id, resourceOwner string) *eventstore.Aggregate { + return &eventstore.Aggregate{ + Type: AggregateType, + Version: AggregateVersion, + ID: id, + ResourceOwner: resourceOwner, + } +} + +func AggregateFromWriteModel(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, AggregateType, AggregateVersion) +} diff --git a/internal/repository/webkey/eventstore.go b/internal/repository/webkey/eventstore.go new file mode 100644 index 000000000..d02d9b1c4 --- /dev/null +++ b/internal/repository/webkey/eventstore.go @@ -0,0 +1,12 @@ +package webkey + +import ( + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, AddedEventType, eventstore.GenericEventMapper[AddedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, ActivatedEventType, eventstore.GenericEventMapper[ActivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, DeactivatedEventType, eventstore.GenericEventMapper[DeactivatedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, RemovedEventType, eventstore.GenericEventMapper[RemovedEvent]) +} diff --git a/internal/repository/webkey/webkey.go b/internal/repository/webkey/webkey.go new file mode 100644 index 000000000..e5e3c5f02 --- /dev/null +++ b/internal/repository/webkey/webkey.go @@ -0,0 +1,160 @@ +package webkey + +import ( + "context" + "encoding/json" + + "github.com/go-jose/go-jose/v4" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + UniqueWebKeyType = "web_key" +) + +const ( + eventTypePrefix = eventstore.EventType("web_key.") + AddedEventType = eventTypePrefix + "added" + ActivatedEventType = eventTypePrefix + "activated" + DeactivatedEventType = eventTypePrefix + "deactivated" + RemovedEventType = eventTypePrefix + "removed" +) + +type AddedEvent struct { + *eventstore.BaseEvent `json:"-"` + + PrivateKey *crypto.CryptoValue `json:"privateKey"` + PublicKey *jose.JSONWebKey `json:"publicKey"` + Config json.RawMessage `json:"config"` + ConfigType crypto.WebKeyConfigType `json:"configType"` +} + +func (e *AddedEvent) Payload() interface{} { + return e +} + +func (e *AddedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{ + eventstore.NewAddEventUniqueConstraint(UniqueWebKeyType, e.Agg.ID, "Errors.WebKey.Duplicate"), + } +} + +func (e *AddedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = base +} + +func NewAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + privateKey *crypto.CryptoValue, + publicKey *jose.JSONWebKey, + config crypto.WebKeyConfig, +) (*AddedEvent, error) { + configJson, err := json.Marshal(config) + if err != nil { + return nil, zerrors.ThrowInternal(err, "WEBKEY-IY9fa", "Errors.Internal") + } + return &AddedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + AddedEventType, + ), + PrivateKey: privateKey, + PublicKey: publicKey, + Config: configJson, + ConfigType: config.Type(), + }, nil +} + +type ActivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *ActivatedEvent) Payload() interface{} { + return e +} + +func (e *ActivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *ActivatedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = base +} + +func NewActivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *ActivatedEvent { + return &ActivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + ActivatedEventType, + ), + } +} + +type DeactivatedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *DeactivatedEvent) Payload() interface{} { + return e +} + +func (e *DeactivatedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *DeactivatedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = base +} + +func NewDeactivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *DeactivatedEvent { + return &DeactivatedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + DeactivatedEventType, + ), + } +} + +type RemovedEvent struct { + *eventstore.BaseEvent `json:"-"` +} + +func (e *RemovedEvent) Payload() interface{} { + return e +} + +func (e *RemovedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return []*eventstore.UniqueConstraint{ + eventstore.NewRemoveUniqueConstraint(UniqueWebKeyType, e.Agg.ID), + } +} + +func (e *RemovedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = base +} + +func NewRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *RemovedEvent { + return &RemovedEvent{ + BaseEvent: eventstore.NewBaseEventForPush( + ctx, + aggregate, + RemovedEventType, + ), + } +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index fd7bfee66..7f868b3c3 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -603,6 +603,13 @@ Errors: NotForAPI: Имитирани токени не са разрешени за API Impersonation: PolicyDisabled: Имитирането е деактивирано в политиката за сигурност на екземпляра + WebKey: + ActiveDelete: Не може да се изтрие активен уеб ключ + Config: Невалидна конфигурация на уеб ключ + Duplicate: ID на уеб ключ не е уникален + FeatureDisabled: Ключовата уеб функция е деактивирана + NoActive: Не е намерен активен уеб ключ + NotFound: Уеб ключът не е намерен AggregateTypes: action: Действие @@ -626,6 +633,7 @@ AggregateTypes: restrictions: Ограничения system: Система session: Сесия + web_key: Уеб ключ EventTypes: execution: @@ -1342,6 +1350,12 @@ EventTypes: deactivated: Потребителската схема е деактивирана reactivated: Потребителската схема е активирана отново deleted: Потребителската схема е изтрита + web_key: + added: Добавен уеб ключ + activated: Уеб ключът е активиран + deactivated: Уеб ключът е деактивиран + removed: Уеб ключът е премахнат + Application: OIDC: UnsupportedVersion: Вашата OIDC версия не се поддържа diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 986bc6327..2baf69411 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -584,6 +584,13 @@ Errors: NotForAPI: Zosobněné tokeny nejsou pro API povoleny Impersonation: PolicyDisabled: Zosobnění je zakázáno v zásadách zabezpečení instance + WebKey: + ActiveDelete: Aktivní webový klíč nelze smazat + Config: Neplatná konfigurace webového klíče + Duplicate: ID webového klíče není jedinečné + FeatureDisabled: Funkce webového klíče je zakázána + NoActive: Nebyl nalezen žádný aktivní webový klíč + NotFound: Webový klíč nebyl nalezen AggregateTypes: action: Akce @@ -607,6 +614,7 @@ AggregateTypes: restrictions: Omezení system: Systém session: Sezení + web_key: Webový klíč EventTypes: execution: @@ -1308,6 +1316,11 @@ EventTypes: deactivated: Uživatelské schéma deaktivováno reactivated: Uživatelské schéma bylo znovu aktivováno deleted: Uživatelské schéma bylo smazáno + web_key: + added: Přidán webový klíč + activated: Web Key aktivován + deactivated: Web Key deaktivován + removed: Odstraňte webový klíč Application: OIDC: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index ec7d5976b..fb9ab8e21 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Imitierte Token sind für die API nicht zulässig Impersonation: PolicyDisabled: Der Identitätswechsel ist in der Sicherheitsrichtlinie der Instanz deaktiviert + WebKey: + ActiveDelete: Aktiver Webschlüssel kann nicht gelöscht werden + Config: Ungültige Webschlüsselkonfiguration + Duplicate: Webschlüssel-ID nicht eindeutig + FeatureDisabled: Webschlüsselfunktion deaktiviert + NoActive: Kein aktiver Webschlüssel gefunden + NotFound: Webschlüssel nicht gefunden AggregateTypes: action: Action @@ -609,6 +616,7 @@ AggregateTypes: restrictions: Restriktionen system: System session: Session + web_key: Webschlüssel EventTypes: execution: @@ -1310,6 +1318,11 @@ EventTypes: deactivated: Benutzerschema deaktiviert reactivated: Benutzerschema reaktiviert deleted: Benutzerschema gelöscht + web_key: + added: Web Key hinzugefügt + activated: Web Key aktiviert + deactivated: Web Key deaktiviert + removed: Web Key entfernen Application: OIDC: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index b1bf5907c..0b1908fc8 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Impersonated tokens not allowed for API Impersonation: PolicyDisabled: Impersonation is disabled in the instance security policy + WebKey: + ActiveDelete: Cannot delete active web key + Config: Invalid web key config + Duplicate: Web key ID not unique + FeatureDisabled: Web key feature disabled + NoActive: No active web key found + NotFound: Web key not found AggregateTypes: @@ -610,6 +617,7 @@ AggregateTypes: restrictions: Restrictions system: System session: Session + web_key: Web Key EventTypes: execution: @@ -1311,6 +1319,11 @@ EventTypes: deactivated: User Schema deactivated reactivated: User Schema reactivated deleted: User Schema deleted + web_key: + added: Web Key added + activated: Web Key activated + deactivated: Web Key deactivated + removed: Web Key removed Application: OIDC: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index cec5cfedd..5ab5ee454 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Tokens suplantados no permitidos para API Impersonation: PolicyDisabled: La suplantación está deshabilitada en la política de seguridad de la instancia. + WebKey: + ActiveDelete: No se puede eliminar la clave web activa + Config: Configuración de clave web no válida + Duplicate: ID de clave web no único + FeatureDisabled: Función de clave web deshabilitada + NoActive: No se encontró ninguna clave web activa + NotFound: Clave web no encontrada AggregateTypes: action: Acción @@ -609,6 +616,7 @@ AggregateTypes: restrictions: Restricciones system: Sistema session: Sesión + web_key: Clave web EventTypes: execution: @@ -1310,6 +1318,11 @@ EventTypes: deactivated: Esquema de usuario desactivado reactivated: Esquema de usuario reactivado deleted: Esquema de usuario eliminado + web_key: + added: Clave web añadida + activated: Clave web activada + deactivated: Clave web desactivada + removed: Clave web eliminada Application: OIDC: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 987172570..79f133395 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Les jetons usurpés d'identité ne sont pas autorisés pour l'API Impersonation: PolicyDisabled: L'usurpation d'identité est désactivée dans la politique de sécurité de l'instance + WebKey: + ActiveDelete: Impossible de supprimer la clé Web active + Config: Configuration de clé Web non valide + Duplicate: L'ID de clé Web n'est pas unique + FeatureDisabled: Fonctionnalité de clé Web désactivée + NoActive: Aucune clé Web active trouvée + NotFound: Clé Web introuvable AggregateTypes: action: Action @@ -609,6 +616,7 @@ AggregateTypes: restrictions: Restrictions system: Système session: Session + web_key: Clé Web EventTypes: execution: @@ -1171,140 +1179,146 @@ EventTypes: deactivated: Action désactivée reactivated: Action réactivée removed: Action supprimée + instance: + added: Instance ajoutée + changed: Instance modifiée + customtext: + removed: Texte personnalisé supprimé + set: Ensemble de texte personnalisé + template: + removed: Modèle de texte personnalisé supprimé + default: + language: + set: Langue par défaut définie + org: + set: Ensemble d'organisation par défaut + domain: + added: Domaine ajouté + primary: + set: Ensemble de domaines principal + removed: Domaine supprimé + iam: + console: + set: Ensemble d'applications Console ZITADEL + project: + set: ZITADEL project set + mail: + template: + added: Modèle de courrier électronique ajouté + changed: Modèle d'e-mail modifié + text: + added: Texte de l'e-mail ajouté + changed: Le texte de l'e-mail a été modifié + member: + added: Membre de l'instance ajouté + changed: Membre de l'instance modifié + removed: Membre de l'instance supprimé + cascade: + removed: Cascade de membres de l'instance supprimée + notification: + provider: + debug: + fileadded: Fournisseur de notification de débogage de fichiers ajouté + filechanged: Le fournisseur de notification de débogage de fichier a été modifié + fileremoved: Fournisseur de notification de débogage de fichier supprimé + logadded: Fournisseur de notification de débogage de journal ajouté + logchanged: Le fournisseur de notification de débogage du journal a été modifié + logremoved: Fournisseur de notification de débogage du journal supprimé + oidc: + settings: + added: Paramètres OIDC ajoutés + changed: Paramètres OIDC modifiés + policy: + domain: + added: Politique de domaine ajoutée + changed: Politique de domaine modifiée + label: + activated: Politique d'étiquetage activée + added: Politique d'étiquetage ajoutée + assets: + removed: L'élément de la stratégie d'étiquette a été supprimé + changed: Politique d'étiquetage modifiée + font: + added: Police ajoutée à la stratégie d'étiquette + removed: Police supprimée de la stratégie relative aux étiquettes + icon: + added: Icône ajoutée à la politique d'étiquetage + removed: Icône supprimée des règles relatives aux étiquettes + dark: + added: Icône ajoutée à la politique d'étiquette sombre + removed: Icône supprimée de la politique relative aux étiquettes sombres + logo: + added: Logo ajouté à la politique d'étiquetage + removed: Logo supprimé de la politique relative aux étiquettes + dark: + added: Logo ajouté à la politique relative aux étiquettes sombres + removed: Logo supprimé de la politique relative aux étiquettes sombres + lockout: + added: Politique de verrouillage ajoutée + changed: La politique de verrouillage a été modifiée + login: + added: Politique de connexion ajoutée + changed: Politique de connexion modifiée + idpprovider: + added: Fournisseur d'identité ajouté à la politique de connexion + cascade: + removed: Cascade de fournisseurs d'identité supprimée de la stratégie de connexion + removed: Fournisseur d'identité supprimé de la stratégie de connexion + multifactor: + added: Multifactor ajouté à la politique de connexion + removed: Multifactor supprimé de la politique de connexion + secondfactor: + added: Deuxième facteur ajouté à la politique de connexion + removed: Deuxième facteur supprimé de la politique de connexion + password: + age: + added: Politique d'âge du mot de passe ajoutée + changed: La politique relative à l'âge du mot de passe a été modifiée + complexity: + added: Politique de complexité des mots de passe ajoutée + changed: Politique de complexité des mots de passe supprimée + privacy: + added: Politique de confidentialité ajoutée + changed: Politique de confidentialité modifiée + security: + set: Ensemble de règles de sécurité + + removed: Instance removed + secret: + generator: + added: Générateur de secrets ajouté + changed: Le générateur de secrets a changé + removed: Générateur de secrets supprimé + sms: + configtwilio: + activated: Configuration SMS Twilio activée + added: Configuration SMS Twilio ajoutée + changed: La configuration des SMS Twilio a été modifiée + deactivated: Configuration SMS Twilio désactivée + removed: Configuration SMS Twilio supprimée + token: + changed: Jeton de configuration SMS Twilio modifié + smtp: + config: + added: Configuration SMTP ajoutée + changed: Configuration SMTP modifiée + activated: Configuration SMTP activée + deactivated: Configuration SMTP désactivée + password: + changed: Mot de passe de configuration SMTP modifié + removed: Configuration SMTP supprimée user_schema: created: Schéma utilisateur créé updated: Schéma utilisateur mis à jour deactivated: Schéma utilisateur désactivé reactivated: Schéma utilisateur réactivé deleted: Schéma utilisateur supprimé -instance: - added: Instance ajoutée - changed: Instance modifiée - customtext: - removed: Texte personnalisé supprimé - set: Ensemble de texte personnalisé - template: - removed: Modèle de texte personnalisé supprimé - default: - language: - set: Langue par défaut définie - org: - set: Ensemble d'organisation par défaut - domain: - added: Domaine ajouté - primary: - set: Ensemble de domaines principal - removed: Domaine supprimé - iam: - console: - set: Ensemble d'applications Console ZITADEL - project: - set: ZITADEL project set - mail: - template: - added: Modèle de courrier électronique ajouté - changed: Modèle d'e-mail modifié - text: - added: Texte de l'e-mail ajouté - changed: Le texte de l'e-mail a été modifié - member: - added: Membre de l'instance ajouté - changed: Membre de l'instance modifié - removed: Membre de l'instance supprimé - cascade: - removed: Cascade de membres de l'instance supprimée - notification: - provider: - debug: - fileadded: Fournisseur de notification de débogage de fichiers ajouté - filechanged: Le fournisseur de notification de débogage de fichier a été modifié - fileremoved: Fournisseur de notification de débogage de fichier supprimé - logadded: Fournisseur de notification de débogage de journal ajouté - logchanged: Le fournisseur de notification de débogage du journal a été modifié - logremoved: Fournisseur de notification de débogage du journal supprimé - oidc: - settings: - added: Paramètres OIDC ajoutés - changed: Paramètres OIDC modifiés - policy: - domain: - added: Politique de domaine ajoutée - changed: Politique de domaine modifiée - label: - activated: Politique d'étiquetage activée - added: Politique d'étiquetage ajoutée - assets: - removed: L'élément de la stratégie d'étiquette a été supprimé - changed: Politique d'étiquetage modifiée - font: - added: Police ajoutée à la stratégie d'étiquette - removed: Police supprimée de la stratégie relative aux étiquettes - icon: - added: Icône ajoutée à la politique d'étiquetage - removed: Icône supprimée des règles relatives aux étiquettes - dark: - added: Icône ajoutée à la politique d'étiquette sombre - removed: Icône supprimée de la politique relative aux étiquettes sombres - logo: - added: Logo ajouté à la politique d'étiquetage - removed: Logo supprimé de la politique relative aux étiquettes - dark: - added: Logo ajouté à la politique relative aux étiquettes sombres - removed: Logo supprimé de la politique relative aux étiquettes sombres - lockout: - added: Politique de verrouillage ajoutée - changed: La politique de verrouillage a été modifiée - login: - added: Politique de connexion ajoutée - changed: Politique de connexion modifiée - idpprovider: - added: Fournisseur d'identité ajouté à la politique de connexion - cascade: - removed: Cascade de fournisseurs d'identité supprimée de la stratégie de connexion - removed: Fournisseur d'identité supprimé de la stratégie de connexion - multifactor: - added: Multifactor ajouté à la politique de connexion - removed: Multifactor supprimé de la politique de connexion - secondfactor: - added: Deuxième facteur ajouté à la politique de connexion - removed: Deuxième facteur supprimé de la politique de connexion - password: - age: - added: Politique d'âge du mot de passe ajoutée - changed: La politique relative à l'âge du mot de passe a été modifiée - complexity: - added: Politique de complexité des mots de passe ajoutée - changed: Politique de complexité des mots de passe supprimée - privacy: - added: Politique de confidentialité ajoutée - changed: Politique de confidentialité modifiée - security: - set: Ensemble de règles de sécurité + web_key: + added: Clé Web ajoutée + activated: Clé Web activée + deactivated: Clé Web désactivée + removed: Clé Web supprimée - removed: Instance removed - secret: - generator: - added: Générateur de secrets ajouté - changed: Le générateur de secrets a changé - removed: Générateur de secrets supprimé - sms: - configtwilio: - activated: Configuration SMS Twilio activée - added: Configuration SMS Twilio ajoutée - changed: La configuration des SMS Twilio a été modifiée - deactivated: Configuration SMS Twilio désactivée - removed: Configuration SMS Twilio supprimée - token: - changed: Jeton de configuration SMS Twilio modifié - smtp: - config: - added: Configuration SMTP ajoutée - changed: Configuration SMTP modifiée - activated: Configuration SMTP activée - deactivated: Configuration SMTP désactivée - password: - changed: Mot de passe de configuration SMTP modifié - removed: Configuration SMTP supprimée Application: OIDC: UnsupportedVersion: Votre version de l'OIDC n'est pas prise en charge diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 73180b982..2df6792a7 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Token rappresentati non consentiti per l'API Impersonation: PolicyDisabled: La rappresentazione è disabilitata nella policy di sicurezza dell'istanza + WebKey: + ActiveDelete: Impossibile eliminare la chiave Web attiva + Config: Configurazione chiave Web non valida + Duplicate: ID chiave Web non univoco + FeatureDisabled: Funzione chiave Web disabilitata + NoActive: Nessuna chiave Web attiva trovata + NotFound: Chiave Web non trovata AggregateTypes: action: Azione @@ -609,6 +616,7 @@ AggregateTypes: restrictions: Restrizioni system: Sistema session: Sessione + web_key: Chiave Web EventTypes: execution: @@ -1172,12 +1180,6 @@ EventTypes: deactivated: Azione disattivata reactivated: Azione riattivata removed: Azione rimossa - user_schema: - created: Schema utente creato - updated: Schema utente aggiornato - deactivated: Schema utente disattivato - reactivated: Schema utente riattivato - deleted: Schema utente eliminato instance: added: Istanza aggiunta changed: L'istanza è cambiata @@ -1306,6 +1308,17 @@ EventTypes: password: changed: La password della configurazione SMTP è cambiata removed: Configurazione SMTP rimossa + user_schema: + created: Schema utente creato + updated: Schema utente aggiornato + deactivated: Schema utente disattivato + reactivated: Schema utente riattivato + deleted: Schema utente eliminato + web_key: + added: Web Key aggiunto + activated: Web Key attivato + deactivated: Web Key disattivato + removed: Web Key rimosso Application: OIDC: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 3b8b4cbb9..32e1c645f 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -575,6 +575,13 @@ Errors: NotForAPI: 偽装されたトークンは API では許可されません Impersonation: PolicyDisabled: インスタンスのセキュリティ ポリシーで偽装が無効になっています + WebKey: + ActiveDelete: アクティブな Web キーを削除できません + Config: 無効な Web キー設定 + Duplicate: Web キー ID が一意ではありません + FeatureDisabled: Web キー機能が無効です + NoActive: アクティブな Web キーが見つかりません + NotFound: Web キーが見つかりません AggregateTypes: action: アクション @@ -598,6 +605,7 @@ AggregateTypes: restrictions: 制限 system: システム session: セッション + web_key: Web キー EventTypes: execution: @@ -1296,6 +1304,11 @@ EventTypes: deactivated: ユーザースキーマが非アクティブ化されました reactivated: ユーザースキーマが再アクティブ化されました deleted: ユーザースキーマが削除されました + web_key: + added: Web キーが追加されました + activated: Web キーが有効化されました + deactivated: Web キーが無効化されました + removed: Web キーが削除されました Application: OIDC: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index cec3cbb50..627cdabd7 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -585,6 +585,13 @@ Errors: NotForAPI: Имитирани токени не се дозволени за API Impersonation: PolicyDisabled: Имитирањето е оневозможено во политиката за безбедност на примерот + WebKey: + ActiveDelete: Не може да се избрише активниот веб-клуч + Config: Неважечка конфигурација на веб-клуч + Duplicate: ID на веб-клучот не е единствен + FeatureDisabled: Функцијата за веб-клуч е оневозможена + NoActive: Не е пронајден активен веб-клуч + NotFound: Веб-клучот не е пронајден AggregateTypes: action: Акција @@ -608,6 +615,7 @@ AggregateTypes: restrictions: Ограничувања system: Систем session: Сесија + web_key: Веб клуч EventTypes: execution: @@ -1308,6 +1316,11 @@ EventTypes: deactivated: Корисничката шема е деактивирана reactivated: Корисничката шема е реактивирана deleted: Корисничката шема е избришана + web_key: + added: Додаден е веб-клуч + activated: Веб-клучот е активиран + deactivated: Веб-клучот е деактивиран + removed: Веб-клучот е отстранет Application: OIDC: diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 062ee7f5c..b9f29169e 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Nagebootste tokens zijn niet toegestaan voor API Impersonation: PolicyDisabled: Nabootsing van identiteit is uitgeschakeld in het beveiligingsbeleid van de instantie. + WebKey: + ActiveDelete: Kan actieve websleutel niet verwijderen + Config: Ongeldige websleutelconfiguratie + Duplicate: Websleutel-ID niet uniek + FeatureDisabled: Websleutelfunctie uitgeschakeld + NoActive: Geen actieve websleutel gevonden + NotFound: Websleutel niet gevonden AggregateTypes: action: Actie @@ -609,6 +616,7 @@ AggregateTypes: restrictions: Beperkingen system: Systeem session: Sessie + web_key: Websleutel EventTypes: execution: @@ -1305,6 +1313,11 @@ EventTypes: deactivated: Gebruikersschema gedeactiveerd reactivated: Gebruikersschema opnieuw geactiveerd deleted: Gebruikersschema verwijderd + web_key: + added: Web Key toegevoegd + activated: Web Key geactiveerd + deactivated: Web Key gedeactiveerd + removed: Web Key verwijderd Application: OIDC: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 4705320d8..36da9b177 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Podrabiane tokeny nie są dozwolone w interfejsie API Impersonation: PolicyDisabled: Podszywanie się jest wyłączone w polityce bezpieczeństwa instancji + WebKey: + ActiveDelete: Nie można usunąć aktywnego klucza internetowego + Config: Nieprawidłowa konfiguracja klucza internetowego + Duplicate: Identyfikator klucza internetowego nie jest unikalny + FeatureDisabled: Funkcja klucza internetowego jest wyłączona + NoActive: Nie znaleziono aktywnego klucza internetowego + NotFound: Nie znaleziono klucza internetowego AggregateTypes: action: Działanie @@ -609,6 +616,7 @@ AggregateTypes: restrictions: Ograniczenia system: System session: Sesja + web_key: Klucz internetowy EventTypes: execution: @@ -1310,6 +1318,11 @@ EventTypes: deactivated: Schemat użytkownika dezaktywowany reactivated: Schemat użytkownika został ponownie aktywowany deleted: Schemat użytkownika został usunięty + web_key: + added: Dodano klucz internetowy + activated: Klucz internetowy aktywowano + deactivated: Klucz internetowy dezaktywowano + removed: Klucz internetowy usunięto Application: OIDC: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index acb69e2c0..cb6e01382 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -581,6 +581,13 @@ Errors: NotForAPI: Tokens personificados não permitidos para API Impersonation: PolicyDisabled: A representação está desativada na política de segurança da instância + WebKey: + ActiveDelete: Não é possível eliminar a chave web ativa + Config: Configuração de chave web inválida + Duplicate: ID da chave Web não exclusivo + FeatureDisabled: Recurso chave da Web desativado + NoActive: Nenhuma chave web ativa encontrada + NotFound: Chave Web não encontrada AggregateTypes: action: Ação @@ -604,6 +611,7 @@ AggregateTypes: restrictions: Restrições system: Sistema session: Sessão + web_key: Chave da Web EventTypes: execution: @@ -1302,6 +1310,11 @@ EventTypes: deactivated: Esquema de usuário desativado reactivated: Esquema do usuário reativado deleted: Esquema do usuário excluído + web_key: + added: Chave Web adicionada + activated: Chave Web ativada + deactivated: Chave Web desativada + removed: Chave Web removida Application: OIDC: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 36918b9c1..df7015bc0 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -575,6 +575,13 @@ Errors: NotForAPI: Олицетворенные токены не разрешены для API. Impersonation: PolicyDisabled: Олицетворение отключено в политике безопасности экземпляра. + WebKey: + ActiveDelete: Невозможно удалить активный веб-ключ + Config: Неверная конфигурация веб-ключа + Duplicate: Идентификатор веб-ключа не уникален + FeatureDisabled: Функция веб-ключа отключена + NoActive: Активный веб-ключ не найден + NotFound: Веб-ключ не найден AggregateTypes: action: Действие @@ -598,6 +605,7 @@ AggregateTypes: restrictions: Ограничения system: Система session: Сеанс + web_key: Веб-ключ EventTypes: execution: @@ -1296,6 +1304,12 @@ EventTypes: deactivated: Пользовательская схема деактивирована reactivated: Пользовательская схема повторно активирована deleted: Пользовательская схема удалена + web_key: + added: Добавлен веб-ключ + activated: Веб-ключ активирован + deactivated: Веб-ключ деактивирован + removed: Веб-ключ удален + Application: OIDC: UnsupportedVersion: Ваша версия OIDC не поддерживается diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index ee0a6a3b0..45326841e 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: Imitationstoken tillåts inte för API Impersonation: PolicyDisabled: Imitation är inaktiverad i instansens säkerhetspolicy + WebKey: + ActiveDelete: Det går inte att ta bort aktiv webbnyckel + Config: Ogiltig webbnyckelkonfiguration + Duplicate: Webnyckel-ID är inte unikt + FeatureDisabled: Webnyckelfunktion inaktiverad + NoActive: Ingen aktiv webbnyckel hittades + NotFound: Webnyckel hittades inte AggregateTypes: action: Åtgärd @@ -609,6 +616,7 @@ AggregateTypes: restrictions: Restriktioner system: System session: Session + web_key: Webbnyckel EventTypes: execution: @@ -1310,6 +1318,11 @@ EventTypes: deactivated: Användarschema avaktiverat reactivated: Användarschema återaktiverat deleted: Användarschema borttaget + web_key: + added: Webbnyckel har lagts till + activated: Webbnyckel aktiverad + deactivated: Webnyckel avaktiverad + removed: Webbnyckeln har tagits bort Application: OIDC: diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 9d22c3089..05fa70324 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -586,6 +586,13 @@ Errors: NotForAPI: API 不允许使用模拟令牌 Impersonation: PolicyDisabled: 实例安全策略中禁用模拟 + WebKey: + ActiveDelete: 无法删除活动 Web 密钥 + Config: 无效的 Web 密钥配置 + Duplicate: Web 密钥 ID 不唯一 + FeatureDisabled: Web 密钥功能已禁用 + NoActive: 未找到活动 Web 密钥 + NotFound: 未找到 Web 密钥 AggregateTypes: action: 动作 @@ -609,6 +616,7 @@ AggregateTypes: restrictions: 限制 system: 系统 session: 会话 + web_key: Web 密钥 EventTypes: execution: @@ -1175,12 +1183,6 @@ EventTypes: deactivated: 停用动作 reactivated: 启用动作 removed: 删除动作 - user_schema: - created: 已创建用户架构 - updated: 用户架构已更新 - deactivated: 用户架构已停用 - reactivated: 用户架构已重新激活 - deleted: 用户架构已删除 instance: added: 实例已添加 changed: 实例已更改 @@ -1309,6 +1311,17 @@ EventTypes: password: changed: SMTP 配置密码已更改 removed: SMTP 配置已删除 + user_schema: + created: 已创建用户架构 + updated: 用户架构已更新 + deactivated: 用户架构已停用 + reactivated: 用户架构已重新激活 + deleted: 用户架构已删除 + web_key: + added: 已添加 Web Key + activated: 已激活 Web Key + deactivated: 已停用 Web Key + removed: 已删除 Web Key Application: OIDC: diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index 52b28f210..24c6df5db 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -58,6 +58,13 @@ message SetInstanceFeaturesRequest{ description: "Improves performance of specified execution paths."; } ]; + + optional bool web_key = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; + } + ]; } message SetInstanceFeaturesResponse { @@ -129,4 +136,11 @@ message GetInstanceFeaturesResponse { description: "Improves performance of specified execution paths."; } ]; + + FeatureFlag web_key = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; + } + ]; } diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 292fcc510..33d00af3e 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -58,6 +58,13 @@ message SetInstanceFeaturesRequest{ description: "Improves performance of specified execution paths."; } ]; + + optional bool web_key = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; + } + ]; } message SetInstanceFeaturesResponse { @@ -129,4 +136,11 @@ message GetInstanceFeaturesResponse { description: "Improves performance of specified execution paths."; } ]; + + FeatureFlag web_key = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "true"; + description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; + } + ]; } diff --git a/proto/zitadel/resources/webkey/v3alpha/config.proto b/proto/zitadel/resources/webkey/v3alpha/config.proto new file mode 100644 index 000000000..170334afa --- /dev/null +++ b/proto/zitadel/resources/webkey/v3alpha/config.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package zitadel.resources.webkey.v3alpha; + +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha;webkey"; + +message WebKeyRSAConfig { + enum RSABits { + RSA_BITS_UNSPECIFIED = 0; + RSA_BITS_2048 = 1; + RSA_BITS_3072 = 2; + RSA_BITS_4096 = 3; + } + + enum RSAHasher { + RSA_HASHER_UNSPECIFIED = 0; + RSA_HASHER_SHA256 = 1; + RSA_HASHER_SHA384 = 2; + RSA_HASHER_SHA512 = 3; + } + + // bit size of the RSA key + RSABits bits = 1 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; + // signing algrithm used + RSAHasher hasher = 2 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; +} + +message WebKeyECDSAConfig { + enum ECDSACurve { + ECDSA_CURVE_UNSPECIFIED = 0; + ECDSA_CURVE_P256 = 1; + ECDSA_CURVE_P384 = 2; + ECDSA_CURVE_P512 = 3; + } + + ECDSACurve curve = 1 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; +} + +message WebKeyED25519Config {} diff --git a/proto/zitadel/resources/webkey/v3alpha/key.proto b/proto/zitadel/resources/webkey/v3alpha/key.proto new file mode 100644 index 000000000..47486f7ae --- /dev/null +++ b/proto/zitadel/resources/webkey/v3alpha/key.proto @@ -0,0 +1,31 @@ +syntax = "proto3"; + +package zitadel.resources.webkey.v3alpha; + +import "google/protobuf/timestamp.proto"; +import "zitadel/resources/webkey/v3alpha/config.proto"; +import "zitadel/resources/object/v3alpha/object.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha;webkey"; + +enum WebKeyState { + STATE_UNSPECIFIED = 0; + STATE_INITIAL = 1; + STATE_ACTIVE = 2; + STATE_INACTIVE = 3; + STATE_REMOVED = 4; +} + +message GetWebKey { + zitadel.resources.object.v3alpha.Details details = 1; + WebKey config = 2; + WebKeyState state = 3; +} + +message WebKey { + oneof config { + WebKeyRSAConfig rsa = 6; + WebKeyECDSAConfig ecdsa = 7; + WebKeyED25519Config ed25519 = 8; + } +} diff --git a/proto/zitadel/resources/webkey/v3alpha/webkey_service.proto b/proto/zitadel/resources/webkey/v3alpha/webkey_service.proto new file mode 100644 index 000000000..c79424095 --- /dev/null +++ b/proto/zitadel/resources/webkey/v3alpha/webkey_service.proto @@ -0,0 +1,278 @@ +syntax = "proto3"; + +package zitadel.resources.webkey.v3alpha; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +import "zitadel/resources/webkey/v3alpha/key.proto"; +import "zitadel/resources/object/v3alpha/object.proto"; +import "zitadel/object/v3alpha/object.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/resources/webkey/v3alpha;webkey"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Web key Service"; + version: "3.0-preview"; + description: "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens. This project is in preview state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSE"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + produces: "application/json"; + + consumes: "application/grpc"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "${ZITADEL_DOMAIN}"; + base_path: "/resources/v3alpha/web_keys"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +service ZITADELWebKeys { + rpc CreateWebKey(CreateWebKeyRequest) returns (CreateWebKeyResponse) { + option (google.api.http) = { + post: "/" + body: "key" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.write" + } + http_response: { + success_code: 201 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Generate a web key pair for the instance"; + description: "Generate a private and public key pair. The private key can be used to sign OIDC tokens after activation. The public key can be used to valite OIDC tokens." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ActivateWebKey(ActivateWebKeyRequest) returns (ActivateWebKeyResponse) { + option (google.api.http) = { + post: "/{id}/_activate" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.write" + } + http_response: { + success_code: 200 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Activate a signing key for the instance"; + description: "Switch the active signing web key. The previously active key will be deactivated." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc DeleteWebKey(DeleteWebKeyRequest) returns (DeleteWebKeyResponse) { + option (google.api.http) = { + delete: "/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.delete" + } + http_response: { + success_code: 200 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Generate a web key pair for the instance"; + description: "Delete a web key. Only inactive keys can be deleted. Once a key is deleted, any tokens signed by this key will be invalid." + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ListWebKeys(ListWebKeysRequest) returns (ListWebKeysResponse) { + option (google.api.http) = { + get: "/" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.read" + } + http_response: { + success_code: 200 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "Generate a web key pair for the instance"; + description: "List web key details for the instance" + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } +} + +message CreateWebKeyRequest { + optional zitadel.object.v3alpha.Instance instance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"domain from HOST or :authority header\"" + } + ]; + WebKey key = 2; +} + +message CreateWebKeyResponse { + zitadel.resources.object.v3alpha.Details details = 1; +} + +message ActivateWebKeyRequest { + optional zitadel.object.v3alpha.Instance instance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"domain from HOST or :authority header\"" + } + ]; + string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message ActivateWebKeyResponse { + zitadel.resources.object.v3alpha.Details details = 1; +} + +message DeleteWebKeyRequest { + optional zitadel.object.v3alpha.Instance instance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"domain from HOST or :authority header\"" + } + ]; + string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message DeleteWebKeyResponse { + zitadel.resources.object.v3alpha.Details details = 1; +} + +message ListWebKeysRequest { + optional zitadel.object.v3alpha.Instance instance = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"domain from HOST or :authority header\"" + } + ]; +} + +message ListWebKeysResponse { + repeated GetWebKey web_keys = 1; +} \ No newline at end of file