diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 6afbaddbd7..45d598ee86 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 b3499ea7ad..3fe7173860 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 5d2ec8fac7..cc98000869 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 aaa7e2d7ee..f5067c562e 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 2bee4642aa..6ac1767ca6 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 4ac5da13ab..71175024e6 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 d542e8be62..f944ef0327 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 c067ef25c4..b65a53d20b 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 2c377ea48f..49107a380c 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 4d0698feaf..3d35694bdd 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 7c2cf5fc39..e6335145b0 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 c866cc017d..16654d1e6b 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 35dbf98014..b8a69f86a8 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 3056d450c6..326e5e62be 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 0fe042eb08..80cf83138a 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 b4f7578286..2542e8672b 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 c94c080674..849ed7649b 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 0000000000..4e97965932 --- /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 0000000000..8a6e72f950 --- /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 0000000000..b460775dd5 --- /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 0000000000..e755d2be08 --- /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 0000000000..2fae24fb0f --- /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 c4102c1fd2..2db5baf832 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 e7cf39c090..3f84722a9b 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 144a7e10a0..2eac0e4d36 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 bd8afffe54..ca523398f7 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 22a0ba819b..89f23e6ff7 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 4d3e1d2528..a0cc773019 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 3acc789d1b..e6e448da9e 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 bfd606e672..bdb46d2e04 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 ac379aa964..90eaf7e3da 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 e3796f6c64..fe052166b3 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 0000000000..e8481541c3 --- /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 0000000000..aca375bb5f --- /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 0000000000..63463de1df --- /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 a74f97a054..ff3b6e2418 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 0000000000..770f4e46c9 --- /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 0000000000..f3856c87c0 --- /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 0000000000..31023d8731 --- /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 0000000000..c769cb1213 --- /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 0000000000..e6b1a5a56b --- /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 0000000000..2725402013 --- /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 ff0ec773f7..ffc0e38e53 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 0000000000..4246ee7708 --- /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 9939d8335c..87282e9007 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 6ae64ddf0f..d41521ad8f 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 5f0453f078..34dd5d908a 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 ca3e156b61..6452a258c3 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 9e5dc63dde..947c11508b 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 f4254e0231..e4d53213cf 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 a6c862c8ad..01e563de11 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 12d8e0d80d..f10039fa66 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 215442c911..a2ab09d263 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 ae733e8dd3..d7475e424b 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 70e5860eb0..a977bfb58e 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 06090a2f5d..d24fe6d203 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 7022c1b9ec..75358cce12 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 a7776d24af..0151a9953b 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 0000000000..231f5ddfb1 --- /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 0000000000..f8930a6280 --- /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 0000000000..3d7875477f --- /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 0000000000..1671e55eef --- /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 0000000000..117f2ba202 --- /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 0000000000..e59ca6174a --- /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 0000000000..6008ec6528 --- /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 bd8b22eec8..97b4e4ed3a 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 d5beea8bf4..27e1ed40fc 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 8bf2e77080..aeb02bb06e 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 0000000000..afe63aaee5 --- /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 0000000000..d02d9b1c43 --- /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 0000000000..e5e3c5f020 --- /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 fd7bfee660..7f868b3c35 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 986bc6327f..2baf69411c 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 ec7d5976b5..fb9ab8e21f 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 b1bf5907cf..0b1908fc8c 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 cec5cfedd1..5ab5ee454b 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 9871725704..79f1333956 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 73180b982b..2df6792a70 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 3b8b4cbb92..32e1c645f5 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 cec3cbb506..627cdabd7b 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 062ee7f5c9..b9f29169e7 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 4705320d84..36da9b1775 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 acb69e2c0b..cb6e013829 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 36918b9c1f..df7015bc02 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 ee0a6a3b04..45326841ea 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 9d22c30891..05fa703240 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 52b28f2101..24c6df5db6 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 292fcc5101..33d00af3eb 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 0000000000..170334afa5 --- /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 0000000000..47486f7aee --- /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 0000000000..c79424095b --- /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