diff --git a/.sqlx/query-fa5d5b618ddd5c71b09d1b22eec063b8e4b4cbaf79fee6e28f7703352588b8ab.json b/.sqlx/query-94632716a5f1088ff341bd74a48aab41ede332cf22af3fa9ac264c9c9b6a47f2.json similarity index 95% rename from .sqlx/query-fa5d5b618ddd5c71b09d1b22eec063b8e4b4cbaf79fee6e28f7703352588b8ab.json rename to .sqlx/query-94632716a5f1088ff341bd74a48aab41ede332cf22af3fa9ac264c9c9b6a47f2.json index 4285c6ab8..911ec5e31 100644 --- a/.sqlx/query-fa5d5b618ddd5c71b09d1b22eec063b8e4b4cbaf79fee6e28f7703352588b8ab.json +++ b/.sqlx/query-94632716a5f1088ff341bd74a48aab41ede332cf22af3fa9ac264c9c9b6a47f2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, enrollment_send_welcome_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, ldap_remote_enrollment_enabled, ldap_remote_enrollment_send_invite, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, default_admin_id, secret_key, enable_stats_purge, stats_purge_frequency_hours, stats_purge_threshold_days, enrollment_token_timeout_hours, password_reset_token_timeout_hours, enrollment_session_timeout_minutes, password_reset_session_timeout_minutes FROM \"settings\" WHERE id = 1", + "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, enrollment_send_welcome_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, ldap_remote_enrollment_enabled, ldap_remote_enrollment_send_invite, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, default_admin_id, secret_key, openid_signing_key_der, enable_stats_purge, stats_purge_frequency_hours, stats_purge_threshold_days, enrollment_token_timeout_hours, password_reset_token_timeout_hours, enrollment_session_timeout_minutes, password_reset_session_timeout_minutes FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -327,36 +327,41 @@ }, { "ordinal": 58, + "name": "openid_signing_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 59, "name": "enable_stats_purge", "type_info": "Bool" }, { - "ordinal": 59, + "ordinal": 60, "name": "stats_purge_frequency_hours", "type_info": "Int4" }, { - "ordinal": 60, + "ordinal": 61, "name": "stats_purge_threshold_days", "type_info": "Int4" }, { - "ordinal": 61, + "ordinal": 62, "name": "enrollment_token_timeout_hours", "type_info": "Int4" }, { - "ordinal": 62, + "ordinal": 63, "name": "password_reset_token_timeout_hours", "type_info": "Int4" }, { - "ordinal": 63, + "ordinal": 64, "name": "enrollment_session_timeout_minutes", "type_info": "Int4" }, { - "ordinal": 64, + "ordinal": 65, "name": "password_reset_session_timeout_minutes", "type_info": "Int4" } @@ -423,6 +428,7 @@ false, true, true, + true, false, false, false, @@ -432,5 +438,5 @@ false ] }, - "hash": "fa5d5b618ddd5c71b09d1b22eec063b8e4b4cbaf79fee6e28f7703352588b8ab" + "hash": "94632716a5f1088ff341bd74a48aab41ede332cf22af3fa9ac264c9c9b6a47f2" } diff --git a/.sqlx/query-01f5f2906c76d16f544617963f1342eb0b718a23b6d8629f24eeeed4504b2b20.json b/.sqlx/query-e65193de91eb08866f0303a4a842263ddcbdf3d3b4803d63d6f8ecb986cb22d0.json similarity index 88% rename from .sqlx/query-01f5f2906c76d16f544617963f1342eb0b718a23b6d8629f24eeeed4504b2b20.json rename to .sqlx/query-e65193de91eb08866f0303a4a842263ddcbdf3d3b4803d63d6f8ecb986cb22d0.json index b6add23ae..539d9403a 100644 --- a/.sqlx/query-01f5f2906c76d16f544617963f1342eb0b718a23b6d8629f24eeeed4504b2b20.json +++ b/.sqlx/query-e65193de91eb08866f0303a4a842263ddcbdf3d3b4803d63d6f8ecb986cb22d0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, enrollment_send_welcome_email = $20, uuid = $21, ldap_url = $22, ldap_bind_username = $23, ldap_bind_password = $24, ldap_group_search_base = $25, ldap_user_search_base = $26, ldap_user_obj_class = $27, ldap_group_obj_class = $28, ldap_username_attr = $29, ldap_groupname_attr = $30, ldap_group_member_attr = $31, ldap_member_attr = $32, ldap_use_starttls = $33, ldap_tls_verify_cert = $34, openid_create_account = $35, license = $36, gateway_disconnect_notifications_enabled = $37, gateway_disconnect_notifications_inactivity_threshold = $38, gateway_disconnect_notifications_reconnect_notification_enabled = $39, ldap_sync_status = $40, ldap_enabled = $41, ldap_sync_enabled = $42, ldap_is_authoritative = $43, ldap_sync_interval = $44, ldap_user_auxiliary_obj_classes = $45, ldap_uses_ad = $46, ldap_user_rdn_attr = $47, ldap_sync_groups = $48, ldap_remote_enrollment_enabled = $49, ldap_remote_enrollment_send_invite = $50, openid_username_handling = $51, defguard_url = $52, default_admin_group_name = $53, authentication_period_days = $54, mfa_code_timeout_seconds = $55, public_proxy_url = $56, default_admin_id = $57, secret_key = $58, enable_stats_purge = $59, stats_purge_frequency_hours = $60, stats_purge_threshold_days = $61, enrollment_token_timeout_hours = $62, password_reset_token_timeout_hours = $63, enrollment_session_timeout_minutes = $64, password_reset_session_timeout_minutes = $65 WHERE id = 1", + "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, enrollment_send_welcome_email = $20, uuid = $21, ldap_url = $22, ldap_bind_username = $23, ldap_bind_password = $24, ldap_group_search_base = $25, ldap_user_search_base = $26, ldap_user_obj_class = $27, ldap_group_obj_class = $28, ldap_username_attr = $29, ldap_groupname_attr = $30, ldap_group_member_attr = $31, ldap_member_attr = $32, ldap_use_starttls = $33, ldap_tls_verify_cert = $34, openid_create_account = $35, license = $36, gateway_disconnect_notifications_enabled = $37, gateway_disconnect_notifications_inactivity_threshold = $38, gateway_disconnect_notifications_reconnect_notification_enabled = $39, ldap_sync_status = $40, ldap_enabled = $41, ldap_sync_enabled = $42, ldap_is_authoritative = $43, ldap_sync_interval = $44, ldap_user_auxiliary_obj_classes = $45, ldap_uses_ad = $46, ldap_user_rdn_attr = $47, ldap_sync_groups = $48, ldap_remote_enrollment_enabled = $49, ldap_remote_enrollment_send_invite = $50, openid_username_handling = $51, defguard_url = $52, default_admin_group_name = $53, authentication_period_days = $54, mfa_code_timeout_seconds = $55, public_proxy_url = $56, default_admin_id = $57, secret_key = $58, openid_signing_key_der = $59, enable_stats_purge = $60, stats_purge_frequency_hours = $61, stats_purge_threshold_days = $62, enrollment_token_timeout_hours = $63, password_reset_token_timeout_hours = $64, enrollment_session_timeout_minutes = $65, password_reset_session_timeout_minutes = $66 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -95,6 +95,7 @@ "Text", "Int8", "Text", + "Bytea", "Bool", "Int4", "Int4", @@ -106,5 +107,5 @@ }, "nullable": [] }, - "hash": "01f5f2906c76d16f544617963f1342eb0b718a23b6d8629f24eeeed4504b2b20" + "hash": "e65193de91eb08866f0303a4a842263ddcbdf3d3b4803d63d6f8ecb986cb22d0" } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index 78a3272c2..6f78904b9 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -94,12 +94,6 @@ async fn main() -> Result<(), anyhow::Error> { ) .await; - if config.openid_signing_key.is_some() { - info!("Using RSA OpenID signing key"); - } else { - info!("Using HMAC OpenID signing key"); - } - // initialize global settings struct initialize_current_settings(&pool).await?; @@ -196,6 +190,13 @@ async fn main() -> Result<(), anyhow::Error> { let settings = Settings::get_current_settings(); + #[allow(deprecated)] + if config.hmac { + info!("Using HMAC OpenID signing key (forced by deprecated config flag)"); + } else { + info!("Using RSA OpenID signing key"); + } + // create event channels for services let (api_event_tx, api_event_rx) = unbounded_channel::(); let (bidi_event_tx, bidi_event_rx) = unbounded_channel::(); diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index ef1a96234..77ac0dfc8 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -71,9 +71,17 @@ pub struct DefGuardConfig { pub grpc_key: Option, #[arg(long, env = "DEFGUARD_OPENID_KEY", value_parser = Self::parse_openid_key)] + #[deprecated(since = "2.0.0", note = "Use auto-generated openid signing key")] #[serde(skip_serializing)] pub openid_signing_key: Option, + #[arg(long, env = "DEFGUARD_HMAC", default_value_t = false)] + #[deprecated( + since = "2.0.0", + note = "Temporary compatibility flag for OpenID signing" + )] + pub hmac: bool, + #[arg(long, env = "DEFGUARD_URL", value_parser = Url::parse)] #[serde(skip_serializing)] #[deprecated(since = "2.0.0", note = "Use Settings.defguard_url instead")] @@ -261,6 +269,7 @@ impl DefGuardConfig { grpc_cert: None, grpc_key: None, openid_signing_key: None, + hmac: false, url: None, disable_stats_purge: None, stats_purge_frequency: None, @@ -315,7 +324,9 @@ impl DefGuardConfig { } #[must_use] + #[deprecated(since = "2.0.0", note = "Use auto-generated openid signing key")] pub fn openid_key(&self) -> Option { + #[allow(deprecated)] let key = self.openid_signing_key.as_ref()?; if let Ok(pem) = key.to_pkcs1_pem(LineEnding::default()) { let key_id = JsonWebKeyId::new(key.n().to_str_radix(36)); diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 5efb492c0..514c9747e 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -1,7 +1,14 @@ use std::{collections::HashMap, fmt, net::IpAddr, time::Duration}; use base64::{Engine, prelude::BASE64_STANDARD}; +use openidconnect::{JsonWebKeyId, core::CoreRsaPrivateSigningKey}; use rand::{RngCore, rngs::OsRng}; +use rsa::{ + RsaPrivateKey, + pkcs1::EncodeRsaPrivateKey, + pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}, + traits::PublicKeyParts, +}; use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, PgPool, Type, query, query_as}; @@ -18,6 +25,7 @@ use crate::{ }; global_value!(SETTINGS, Option, None, set_settings, get_settings); +pub const OPENID_KEY_SIZE: usize = 2048; /// Initializes global `SETTINGS` struct at program startup pub async fn initialize_current_settings(pool: &PgPool) -> sqlx::Result<()> { @@ -215,6 +223,8 @@ pub struct Settings { pub default_admin_id: Option, // 1.6 config options pub secret_key: Option, + #[serde(skip)] + pub openid_signing_key_der: Option>, pub enable_stats_purge: bool, stats_purge_frequency_hours: i32, stats_purge_threshold_days: i32, @@ -342,6 +352,60 @@ impl Settings { BASE64_STANDARD.encode(bytes) } + /// Generates a new RSA private key for OpenID signing and serializes it as PKCS#8 DER. + fn generate_openid_signing_key_der() -> Result, SettingsInitializationError> { + let key = RsaPrivateKey::new(&mut OsRng, OPENID_KEY_SIZE).map_err(|_| { + SettingsInitializationError::Invalid( + "openid_signing_key_der", + "failed to generate OpenID signing key", + ) + })?; + + let key_der = key.to_pkcs8_der().map_err(|_| { + SettingsInitializationError::Invalid( + "openid_signing_key_der", + "failed to serialize OpenID signing key", + ) + })?; + + Ok(key_der.as_bytes().to_vec()) + } + + fn validate_openid_signing_key_der(key_der: &[u8]) -> Result<(), SettingsInitializationError> { + RsaPrivateKey::from_pkcs8_der(key_der) + .map(|_| ()) + .map_err(|_| { + SettingsInitializationError::Invalid( + "openid_signing_key_der", + "invalid RSA private key", + ) + }) + } + + /// Serializes the deprecated config-provided OpenID RSA key as PKCS#8 DER for storage. + fn openid_signing_key_der_from_config( + key: &RsaPrivateKey, + ) -> Result, SettingsInitializationError> { + key.to_pkcs8_der() + .map(|doc| doc.as_bytes().to_vec()) + .map_err(|_| { + SettingsInitializationError::Invalid( + "openid_signing_key_der", + "failed to serialize RSA private key", + ) + }) + } + + /// Builds the runtime OpenID signing key from the stored DER-encoded private key. + #[must_use] + pub fn openid_key(&self) -> Option { + let key_der = self.openid_signing_key_der.as_deref()?; + let key = RsaPrivateKey::from_pkcs8_der(key_der).ok()?; + let pem = key.to_pkcs1_pem(LineEnding::default()).ok()?; + let key_id = JsonWebKeyId::new(key.n().to_str_radix(36)); + CoreRsaPrivateSigningKey::from_pem(pem.as_ref(), Some(key_id)).ok() + } + /// Parse `defguard_url` and reject unsupported host forms. fn parse_defguard_url(&self) -> Result { let url = Url::parse(&self.defguard_url) @@ -426,7 +490,7 @@ impl Settings { defguard_url, \ default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, \ public_proxy_url, \ - default_admin_id, secret_key, enable_stats_purge, \ + default_admin_id, secret_key, openid_signing_key_der, enable_stats_purge, \ stats_purge_frequency_hours, stats_purge_threshold_days, \ enrollment_token_timeout_hours, password_reset_token_timeout_hours, \ enrollment_session_timeout_minutes, password_reset_session_timeout_minutes \ @@ -542,13 +606,14 @@ impl Settings { public_proxy_url = $56, \ default_admin_id = $57, \ secret_key = $58, \ - enable_stats_purge = $59, \ - stats_purge_frequency_hours = $60, \ - stats_purge_threshold_days = $61, \ - enrollment_token_timeout_hours = $62, \ - password_reset_token_timeout_hours = $63, \ - enrollment_session_timeout_minutes = $64, \ - password_reset_session_timeout_minutes = $65 \ + openid_signing_key_der = $59, \ + enable_stats_purge = $60, \ + stats_purge_frequency_hours = $61, \ + stats_purge_threshold_days = $62, \ + enrollment_token_timeout_hours = $63, \ + password_reset_token_timeout_hours = $64, \ + enrollment_session_timeout_minutes = $65, \ + password_reset_session_timeout_minutes = $66 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -608,6 +673,7 @@ impl Settings { self.public_proxy_url, self.default_admin_id, self.secret_key, + &self.openid_signing_key_der as &Option>, self.enable_stats_purge, self.stats_purge_frequency_hours, self.stats_purge_threshold_days, @@ -664,6 +730,16 @@ impl Settings { } } + match settings.openid_signing_key_der.as_deref() { + Some(key_der) => { + Settings::validate_openid_signing_key_der(key_der)?; + } + None => { + settings.openid_signing_key_der = + Some(Settings::generate_openid_signing_key_der()?); + } + } + update_current_settings(pool, settings).await?; Ok(()) @@ -772,6 +848,26 @@ impl Settings { Ok(secret_key) } + /// Builds the runtime OpenID signing key from stored DER bytes or returns an error if missing or invalid. + pub fn openid_key_required( + &self, + ) -> Result { + let key_der = + self.openid_signing_key_der + .as_deref() + .ok_or(SettingsInitializationError::Missing( + "openid_signing_key_der", + ))?; + + Settings::validate_openid_signing_key_der(key_der)?; + + self.openid_key() + .ok_or(SettingsInitializationError::Invalid( + "openid_signing_key_der", + "failed to build OpenID signing key", + )) + } + pub fn proxy_public_url(&self) -> Result { Url::parse(&self.public_proxy_url) } @@ -811,6 +907,19 @@ impl Settings { self.secret_key = Some(secret_key.to_string()); } } + if let Some(openid_signing_key) = &config.openid_signing_key { + match Settings::openid_signing_key_der_from_config(openid_signing_key) { + Ok(key_der) => { + self.openid_signing_key_der = Some(key_der); + } + Err(err) => { + warn!( + "Invalid openid_signing_key provided in deprecated config, generating new one: {err}" + ); + self.openid_signing_key_der = Settings::generate_openid_signing_key_der().ok(); + } + } + } if let Some(enrollment_url) = &config.enrollment_url { self.public_proxy_url = enrollment_url.to_string(); } @@ -957,6 +1066,7 @@ mod test { use humantime::Duration; use reqwest::Url; + use rsa::{RsaPrivateKey, pkcs8::EncodePrivateKey}; use secrecy::SecretString; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -1290,6 +1400,75 @@ mod test { assert_eq!(settings.secret_key.as_deref(), Some(valid_secret.as_str())); } + #[test] + #[allow(deprecated)] + fn test_apply_from_config_valid_openid_signing_key_overwrites_existing_value() { + let mut settings = Settings { + openid_signing_key_der: Some(Settings::generate_openid_signing_key_der().unwrap()), + ..Default::default() + }; + let mut config = DefGuardConfig::new_test_config(); + let configured_key = RsaPrivateKey::new(&mut OsRng, OPENID_KEY_SIZE).unwrap(); + let expected_der = configured_key.to_pkcs8_der().unwrap().as_bytes().to_vec(); + config.openid_signing_key = Some(configured_key); + + settings.apply_from_config(&config); + + assert_eq!(settings.openid_signing_key_der, Some(expected_der)); + } + + #[test] + fn test_apply_from_config_keeps_openid_signing_key_when_config_is_none() { + let existing = Settings::generate_openid_signing_key_der().unwrap(); + let mut settings = Settings { + openid_signing_key_der: Some(existing.clone()), + ..Default::default() + }; + let config = DefGuardConfig::new_test_config(); + + settings.apply_from_config(&config); + + assert_eq!(settings.openid_signing_key_der, Some(existing)); + } + + #[test] + fn test_openid_key_required_rejects_missing_key() { + let settings = Settings::default(); + + assert!(matches!( + settings.openid_key_required(), + Err(SettingsInitializationError::Missing( + "openid_signing_key_der" + )) + )); + } + + #[test] + fn test_openid_key_required_rejects_invalid_der() { + let settings = Settings { + openid_signing_key_der: Some(vec![1, 2, 3]), + ..Default::default() + }; + + assert!(matches!( + settings.openid_key_required(), + Err(SettingsInitializationError::Invalid( + "openid_signing_key_der", + _ + )) + )); + } + + #[test] + fn test_openid_key_required_accepts_valid_der() { + let settings = Settings { + openid_signing_key_der: Some(Settings::generate_openid_signing_key_der().unwrap()), + ..Default::default() + }; + + assert!(settings.openid_key_required().is_ok()); + } + #[sqlx::test] #[allow(deprecated)] async fn test_update_from_config_persists_and_updates_current_settings( @@ -1348,6 +1527,32 @@ mod test { assert_eq!(from_db.webauthn_rp_id().unwrap(), "defguard.example.com"); } + #[sqlx::test] + async fn test_initialize_runtime_defaults_generates_openid_signing_key( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool).await.unwrap(); + + Settings::initialize_runtime_defaults(&pool).await.unwrap(); + + let current = Settings::get_current_settings(); + let from_db = Settings::get(&pool).await.unwrap().unwrap(); + + let current_key = current + .openid_signing_key_der + .as_deref() + .expect("current settings should contain OpenID signing key"); + let db_key = from_db + .openid_signing_key_der + .as_deref() + .expect("database settings should contain OpenID signing key"); + + assert!(RsaPrivateKey::from_pkcs8_der(current_key).is_ok()); + assert!(RsaPrivateKey::from_pkcs8_der(db_key).is_ok()); + } + #[test] fn test_edge_callback_url() { let mut s = Settings { diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 0e926acce..54742d396 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -94,7 +94,7 @@ impl From<&UserClaims> for StandardClaims { pub async fn discovery_keys() -> ApiResult { let mut keys = Vec::new(); - if let Some(openid_key) = server_config().openid_key() { + if let Some(openid_key) = runtime_openid_key()? { keys.push(openid_key.as_verification_key()); } @@ -114,6 +114,21 @@ pub type DefguardIdTokenFields = IdTokenFields< pub type DefguardTokenResponse = StandardTokenResponse; pub struct OAuth2ClientExtractor(Option>); +#[allow(deprecated)] +fn runtime_openid_key() -> Result, WebError> { + if server_config().hmac { + Ok(None) + } else { + Settings::get_current_settings() + .openid_key_required() + .map(Some) + .map_err(|err| { + error!("OpenID signing key is unavailable: {err}"); + WebError::Http(StatusCode::INTERNAL_SERVER_ERROR) + }) + } +} + /// Provide `OAuth2Client` when Basic Authorization header contains `client_id` and `client_secret`. impl FromRequestParts for OAuth2ClientExtractor where @@ -874,9 +889,9 @@ pub async fn token( } else { GroupClaims { groups: None } }; - let config = server_config(); let user_claims = UserClaims::from_user(&user, &client, &token); let base_url = Settings::url()?; + let openid_key = runtime_openid_key()?; match form.authorization_code_flow( &auth_code, @@ -884,7 +899,7 @@ pub async fn token( (&user_claims).into(), &base_url, client.client_secret, - config.openid_key(), + openid_key, group_claims, ) { Ok(response) => { diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index f514e313b..56d20f666 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -60,6 +60,7 @@ pub(crate) struct ClientState { pub worker_state: Arc>, pub wireguard_rx: Receiver, pub test_user: User, + #[allow(dead_code)] pub config: DefGuardConfig, } diff --git a/crates/defguard_core/tests/integration/api/openid.rs b/crates/defguard_core/tests/integration/api/openid.rs index 3eb607a6f..a10a21ae3 100644 --- a/crates/defguard_core/tests/integration/api/openid.rs +++ b/crates/defguard_core/tests/integration/api/openid.rs @@ -3,7 +3,11 @@ use std::str::FromStr; use axum::http::header::ToStrError; use defguard_common::db::{ Id, - models::{OAuth2AuthorizedApp, Settings, User, oauth2client::OAuth2Client}, + models::{ + OAuth2AuthorizedApp, Settings, User, + oauth2client::OAuth2Client, + settings::{OPENID_KEY_SIZE, update_current_settings}, + }, }; use defguard_core::handlers::{Auth, openid_clients::NewOpenIDClient}; use openidconnect::{ @@ -19,7 +23,7 @@ use reqwest::{ StatusCode, Url, header::{AUTHORIZATION, CONTENT_TYPE, HeaderName, LOCATION, USER_AGENT}, }; -use rsa::RsaPrivateKey; +use rsa::{RsaPrivateKey, pkcs8::EncodePrivateKey}; use serde::Deserialize; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; @@ -32,6 +36,13 @@ use super::{ }; use crate::api::PaginatedApiResponse; +async fn seed_openid_signing_key(pool: &sqlx::PgPool) { + let mut settings = Settings::get_current_settings(); + let key = RsaPrivateKey::new(&mut rand::thread_rng(), OPENID_KEY_SIZE).unwrap(); + settings.openid_signing_key_der = Some(key.to_pkcs8_der().unwrap().as_bytes().to_vec()); + update_current_settings(pool, settings).await.unwrap(); +} + #[derive(Deserialize)] pub struct AuthenticationResponse<'r> { pub code: &'r str, @@ -108,7 +119,7 @@ async fn test_openid_client(_: PgPoolOptions, options: PgConnectOptions) { async fn test_openid_flow(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let (client, _) = make_test_client(pool).await; + let (client, _) = make_test_client(pool.clone()).await; let auth = Auth::new("admin", "pass123"); let response = client.post("/api/v1/auth").json(&auth).send().await; assert_eq!(response.status(), StatusCode::OK); @@ -477,7 +488,7 @@ static FAKE_REDIRECT_URI: &str = "http://test.server.tnt:12345/"; async fn test_openid_authorization_code(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let (client, _) = make_test_client(pool).await; + let (client, _) = make_test_client(pool.clone()).await; let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); @@ -574,6 +585,43 @@ async fn test_openid_authorization_code(_: PgPoolOptions, options: PgConnectOpti assert!(refresh_response.refresh_token().is_some()); } +#[sqlx::test] +async fn test_openid_flow_fails_when_rsa_key_is_missing_and_hmac_is_not_forced( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (client, _) = make_test_client(pool.clone()).await; + + let mut settings = Settings::get_current_settings(); + settings.openid_signing_key_der = None; + update_current_settings(&pool, settings).await.unwrap(); + + let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); + let provider_metadata = + CoreProviderMetadata::discover_async(issuer_url, &|r| http_client(r, &client)).await; + + assert!(provider_metadata.is_err()); + + let auth = Auth::new("admin", "pass123"); + let response = client.post("/api/v1/auth").json(&auth).send().await; + assert_eq!(response.status(), StatusCode::OK); + + let oauth2client = NewOpenIDClient { + name: "My test client".into(), + redirect_uri: vec![FAKE_REDIRECT_URI.into()], + scope: vec!["openid".into()], + enabled: true, + }; + let response = client + .post("/api/v1/oauth") + .json(&oauth2client) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} + #[sqlx::test] async fn dg25_20_test_openid_disabled_client_doesnt_generate_code( _: PgPoolOptions, @@ -685,10 +733,7 @@ async fn dg25_25_openid_disabled_client_userinfo_fails( let pool = setup_pool(options).await; let (client, state) = make_test_client(pool).await; - let mut config = state.config; - - let mut rng = rand::thread_rng(); - config.openid_signing_key = RsaPrivateKey::new(&mut rng, 2048).ok(); + seed_openid_signing_key(&state.pool).await; let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); @@ -816,10 +861,7 @@ async fn test_openid_authorization_code_with_pkce(_: PgPoolOptions, options: PgC let pool = setup_pool(options).await; let (client, state) = make_test_client(pool).await; - let mut config = state.config; - - let mut rng = rand::thread_rng(); - config.openid_signing_key = RsaPrivateKey::new(&mut rng, 2048).ok(); + seed_openid_signing_key(&state.pool).await; let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); @@ -1265,7 +1307,7 @@ async fn dg25_22_test_respect_openid_scope_in_userinfo( let pool = setup_pool(options).await; let (client, state) = make_test_client(pool).await; - let mut config = state.config; + seed_openid_signing_key(&state.pool).await; let mut admin = User::find_by_username(&state.pool, "admin") .await @@ -1275,9 +1317,6 @@ async fn dg25_22_test_respect_openid_scope_in_userinfo( admin.phone = Some("+123456789".into()); admin.save(&state.pool).await.unwrap(); - let mut rng = rand::thread_rng(); - config.openid_signing_key = RsaPrivateKey::new(&mut rng, 2048).ok(); - let issuer_url = IssuerUrl::from_url(Settings::url().unwrap().clone()); // discover OpenID service diff --git a/crates/defguard_core/tests/integration/common.rs b/crates/defguard_core/tests/integration/common.rs index ec07ae227..5cdbd6aef 100644 --- a/crates/defguard_core/tests/integration/common.rs +++ b/crates/defguard_core/tests/integration/common.rs @@ -2,7 +2,9 @@ use defguard_common::{ config::{DefGuardConfig, SERVER_CONFIG}, db::models::{ Settings, User, - settings::{initialize_current_settings, update_current_settings}, + settings::{ + SettingsInitializationError, initialize_current_settings, update_current_settings, + }, }, }; use defguard_core::enterprise::license::{License, LicenseTier, SupportType, set_cached_license}; @@ -39,6 +41,15 @@ pub(crate) async fn init_config( update_current_settings(pool, settings) .await .expect("Could not update current settings in the database"); + Settings::initialize_runtime_defaults(pool) + .await + .map_err(|err| match err { + SettingsInitializationError::Save(err) => err, + other => { + panic!("Could not initialize runtime default settings in the database: {other}") + } + }) + .expect("Could not initialize runtime default settings in the database"); set_test_license_business(); let _ = SERVER_CONFIG.set(config.clone()); diff --git a/migrations/20260422113000_[2.0.0]_openid_signing_key_der.down.sql b/migrations/20260422113000_[2.0.0]_openid_signing_key_der.down.sql new file mode 100644 index 000000000..d8e1d4745 --- /dev/null +++ b/migrations/20260422113000_[2.0.0]_openid_signing_key_der.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE settings + DROP COLUMN IF EXISTS openid_signing_key_der, + ADD COLUMN openid_signing_key TEXT; diff --git a/migrations/20260422113000_[2.0.0]_openid_signing_key_der.up.sql b/migrations/20260422113000_[2.0.0]_openid_signing_key_der.up.sql new file mode 100644 index 000000000..1787447ca --- /dev/null +++ b/migrations/20260422113000_[2.0.0]_openid_signing_key_der.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE settings + DROP COLUMN IF EXISTS openid_signing_key, + ADD COLUMN openid_signing_key_der BYTEA;