From c8cbb7329e955a45923db904bd6c7f9ab88a4a3a Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Mon, 26 May 2025 14:29:36 +0200 Subject: [PATCH 1/5] Add secrets.encryption_file config option Signed-off-by: Kai A. Hiller --- crates/cli/src/commands/config.rs | 2 +- crates/cli/src/commands/server.rs | 8 ++- crates/cli/src/commands/syn2mas.rs | 2 +- crates/config/src/sections/secrets.rs | 94 +++++++++++++++++++++++---- docs/config.schema.json | 24 +++---- 5 files changed, 101 insertions(+), 29 deletions(-) diff --git a/crates/cli/src/commands/config.rs b/crates/cli/src/commands/config.rs index 8416e5592..00600026c 100644 --- a/crates/cli/src/commands/config.rs +++ b/crates/cli/src/commands/config.rs @@ -123,7 +123,7 @@ impl Options { SC::Sync { prune, dry_run } => { let config = SyncConfig::extract(figment)?; let clock = SystemClock::default(); - let encrypter = config.secrets.encrypter(); + let encrypter = config.secrets.encrypter().await?; // Grab a connection to the database let mut conn = database_connection_from_config(&config.database).await?; diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 4f2fc6205..dcdbca0d3 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -94,7 +94,7 @@ impl Options { .context("could not run database migrations")?; } - let encrypter = config.secrets.encrypter(); + let encrypter = config.secrets.encrypter().await?; if self.no_sync { info!("Skipping configuration sync"); @@ -124,8 +124,10 @@ impl Options { .await .context("could not import keys from config")?; - let cookie_manager = - CookieManager::derive_from(config.http.public_base.clone(), &config.secrets.encryption); + let cookie_manager = CookieManager::derive_from( + config.http.public_base.clone(), + &config.secrets.encryption().await?, + ); // Load and compile the WASM policies (and fallback to the default embedded one) info!("Loading and compiling the policy module"); diff --git a/crates/cli/src/commands/syn2mas.rs b/crates/cli/src/commands/syn2mas.rs index ac009f5cf..22194953e 100644 --- a/crates/cli/src/commands/syn2mas.rs +++ b/crates/cli/src/commands/syn2mas.rs @@ -133,7 +133,7 @@ impl Options { // in the MAS database let config = SyncConfig::extract(figment)?; let clock = SystemClock::default(); - let encrypter = config.secrets.encrypter(); + let encrypter = config.secrets.encrypter().await?; crate::sync::config_sync( config.upstream_oauth2, diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 10df52e02..1800ebc2e 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -6,7 +6,7 @@ use std::borrow::Cow; -use anyhow::{Context, bail}; +use anyhow::{Context, anyhow, bail}; use camino::Utf8PathBuf; use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; use mas_keystore::{Encrypter, Keystore, PrivateKey}; @@ -46,18 +46,68 @@ pub struct KeyConfig { key_file: Option, } -/// Application secrets +/// Encryption config option. +#[derive(Debug, Clone)] +pub enum Encryption { + File(Utf8PathBuf), + Value([u8; 32]), +} + +/// Encryption fields as serialized in JSON. #[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SecretsConfig { - /// Encryption key for secure cookies +#[derive(JsonSchema, Serialize, Deserialize, Debug, Clone)] +struct EncryptionRaw { + /// File containing the encryption key for secure cookies. + #[schemars(with = "Option")] + encryption_file: Option, + + /// Encryption key for secure cookies. #[schemars( - with = "String", + with = "Option", regex(pattern = r"[0-9a-fA-F]{64}"), example = "example_secret" )] - #[serde_as(as = "serde_with::hex::Hex")] - pub encryption: [u8; 32], + #[serde_as(as = "Option")] + encryption: Option<[u8; 32]>, +} + +impl TryFrom for Encryption { + type Error = anyhow::Error; + + fn try_from(value: EncryptionRaw) -> Result { + match (value.encryption, value.encryption_file) { + (None, None) => bail!("Missing `encryption` or `encryption_file`"), + (None, Some(path)) => Ok(Encryption::File(path)), + (Some(encryption), None) => Ok(Encryption::Value(encryption)), + (Some(_), Some(_)) => bail!("Cannot specify both `encryption` and `encryption_file`"), + } + } +} + +impl From for EncryptionRaw { + fn from(value: Encryption) -> Self { + match value { + Encryption::File(path) => EncryptionRaw { + encryption_file: Some(path), + encryption: None, + }, + Encryption::Value(encryption) => EncryptionRaw { + encryption_file: None, + encryption: Some(encryption), + }, + } + } +} + +/// Application secrets +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SecretsConfig { + /// Encryption key for secure cookies + #[schemars(with = "EncryptionRaw")] + #[serde_as(as = "serde_with::TryFromInto")] + #[serde(flatten)] + encryption: Encryption, /// List of private keys to use for signing and encrypting payloads #[serde(default)] @@ -118,9 +168,27 @@ impl SecretsConfig { } /// Derive an [`Encrypter`] out of the config - #[must_use] - pub fn encrypter(&self) -> Encrypter { - Encrypter::new(&self.encryption) + /// + /// # Errors + /// + /// Returns an error when the Encryptor can not be created. + pub async fn encrypter(&self) -> anyhow::Result { + Ok(Encrypter::new(&self.encryption().await?)) + } + + /// Returns the encryption secret. + /// + /// # Errors + /// + /// Returns an error when the encryption secret could not be read from file. + pub async fn encryption(&self) -> anyhow::Result<[u8; 32]> { + // Read the encryption secret either embedded in the config file or on disk + match self.encryption { + Encryption::Value(encryption) => Ok(encryption), + Encryption::File(ref path) => tokio::fs::read(path).await?.try_into().map_err(|_| { + anyhow!("Content of `encryption_file` must be exactly 32 bytes long.") + }), + } } } @@ -246,7 +314,7 @@ impl SecretsConfig { }; Ok(Self { - encryption: Standard.sample(&mut rng), + encryption: Encryption::Value(Standard.sample(&mut rng)), keys: vec![rsa_key, ec_p256_key, ec_p384_key, ec_k256_key], }) } @@ -291,7 +359,7 @@ impl SecretsConfig { }; Self { - encryption: [0xEA; 32], + encryption: Encryption::Value([0xEA; 32]), keys: vec![rsa_key, ecdsa_key], } } diff --git a/docs/config.schema.json b/docs/config.schema.json index 3bc0f407d..102857999 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1515,18 +1515,7 @@ "SecretsConfig": { "description": "Application secrets", "type": "object", - "required": [ - "encryption" - ], "properties": { - "encryption": { - "description": "Encryption key for secure cookies", - "examples": [ - "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" - ], - "type": "string", - "pattern": "[0-9a-fA-F]{64}" - }, "keys": { "description": "List of private keys to use for signing and encrypting payloads", "default": [], @@ -1534,6 +1523,19 @@ "items": { "$ref": "#/definitions/KeyConfig" } + }, + "encryption_file": { + "description": "File containing the encryption key for secure cookies.", + "type": "string" + }, + "encryption": { + "description": "Encryption key for secure cookies.", + "default": null, + "examples": [ + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" + ], + "type": "string", + "pattern": "[0-9a-fA-F]{64}" } } }, From fbee4bfe8cde0a79aed09b553aaecccc065e156c Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Mon, 2 Jun 2025 18:08:39 +0200 Subject: [PATCH 2/5] Document secrets.encryption_file Signed-off-by: Kai A. Hiller --- docs/reference/configuration.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2303e889e..389cd5a7d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -232,6 +232,21 @@ secrets: -----END EC PRIVATE KEY----- ``` +### `secrets.encryption{_file}` + +The encryption secret used for encrypting cookies and database fields. It takes +the form of a 32-bytes-long hex-encoded string. To provide the encryption secret +via file, set `secrets.encryption_file` to the file path; alternatively use +`secrets.encryption` for declaring the secret inline. The options +`secrets.encryption_file` and `secrets.encryption` are mutually exclusive. + +If given via file, the encyption secret is only read at application startup. +The secret is not updated when the content of the file changes. + +> ⚠️ **Warning** – Do not change the encryption secret after the initial start. +> Changing the encryption secret afterwards will lead to a loss of all +> information stored in the database. + ### `secrets.keys` The service can use a number of key types for signing. From f1937ff5c1434d8cdf847cb8a0d4b50f2c244d98 Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 4 Jun 2025 11:27:12 +0200 Subject: [PATCH 3/5] Treat content of encryption_file as hex-encoded Signed-off-by: Kai A. Hiller --- Cargo.lock | 1 + crates/config/Cargo.toml | 1 + crates/config/src/sections/secrets.rs | 14 ++++++++++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5d0ba1748..242d039f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3220,6 +3220,7 @@ dependencies = [ "chrono", "figment", "governor", + "hex", "indoc", "ipnetwork", "lettre", diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 7566b59c0..b44e958aa 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -19,6 +19,7 @@ anyhow.workspace = true camino = { workspace = true, features = ["serde1"] } chrono.workspace = true figment.workspace = true +hex.workspace = true ipnetwork = { version = "0.20.0", features = ["serde", "schemars"] } lettre.workspace = true schemars.workspace = true diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 1800ebc2e..2027a8a5a 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -6,7 +6,7 @@ use std::borrow::Cow; -use anyhow::{Context, anyhow, bail}; +use anyhow::{Context, bail}; use camino::Utf8PathBuf; use mas_jose::jwk::{JsonWebKey, JsonWebKeySet}; use mas_keystore::{Encrypter, Keystore, PrivateKey}; @@ -185,9 +185,15 @@ impl SecretsConfig { // Read the encryption secret either embedded in the config file or on disk match self.encryption { Encryption::Value(encryption) => Ok(encryption), - Encryption::File(ref path) => tokio::fs::read(path).await?.try_into().map_err(|_| { - anyhow!("Content of `encryption_file` must be exactly 32 bytes long.") - }), + Encryption::File(ref path) => { + let mut bytes = [0; 32]; + let content = tokio::fs::read(path).await?; + hex::decode_to_slice(content, &mut bytes).context( + "Content of `encryption_file` must contain hex characters \ + encoding exactly 32 bytes", + )?; + Ok(bytes) + } } } } From 3ac2e983bb8c195d12a6c123c312b2692ccf9aed Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 4 Jun 2025 11:40:11 +0200 Subject: [PATCH 4/5] Skip encryption serialization if None Signed-off-by: Kai A. Hiller --- crates/config/src/sections/secrets.rs | 2 ++ docs/config.schema.json | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/config/src/sections/secrets.rs b/crates/config/src/sections/secrets.rs index 2027a8a5a..341ea7150 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -59,6 +59,7 @@ pub enum Encryption { struct EncryptionRaw { /// File containing the encryption key for secure cookies. #[schemars(with = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] encryption_file: Option, /// Encryption key for secure cookies. @@ -68,6 +69,7 @@ struct EncryptionRaw { example = "example_secret" )] #[serde_as(as = "Option")] + #[serde(skip_serializing_if = "Option::is_none")] encryption: Option<[u8; 32]>, } diff --git a/docs/config.schema.json b/docs/config.schema.json index 102857999..6867dd8d2 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -1530,7 +1530,6 @@ }, "encryption": { "description": "Encryption key for secure cookies.", - "default": null, "examples": [ "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" ], From 187838802de3ef962cb120d18816d2006a1d07ad Mon Sep 17 00:00:00 2001 From: "Kai A. Hiller" Date: Wed, 4 Jun 2025 14:46:32 +0200 Subject: [PATCH 5/5] Update encryption secret warning in docs --- docs/reference/configuration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 389cd5a7d..628ec6c52 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -243,9 +243,9 @@ via file, set `secrets.encryption_file` to the file path; alternatively use If given via file, the encyption secret is only read at application startup. The secret is not updated when the content of the file changes. -> ⚠️ **Warning** – Do not change the encryption secret after the initial start. -> Changing the encryption secret afterwards will lead to a loss of all -> information stored in the database. +> ⚠️ **Warning** – Do not change the encryption secret after the initial start! +> Changing the encryption secret afterwards will lead to a loss of all encrypted +> information in the database. ### `secrets.keys`