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/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/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 10df52e02..341ea7150 100644 --- a/crates/config/src/sections/secrets.rs +++ b/crates/config/src/sections/secrets.rs @@ -46,18 +46,70 @@ 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")] + #[serde(skip_serializing_if = "Option::is_none")] + 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")] + #[serde(skip_serializing_if = "Option::is_none")] + 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 +170,33 @@ 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) => { + 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) + } + } } } @@ -246,7 +322,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 +367,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..6867dd8d2 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,18 @@ "items": { "$ref": "#/definitions/KeyConfig" } + }, + "encryption_file": { + "description": "File containing the encryption key for secure cookies.", + "type": "string" + }, + "encryption": { + "description": "Encryption key for secure cookies.", + "examples": [ + "0000111122223333444455556666777788889999aaaabbbbccccddddeeeeffff" + ], + "type": "string", + "pattern": "[0-9a-fA-F]{64}" } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 2303e889e..628ec6c52 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 encrypted +> information in the database. + ### `secrets.keys` The service can use a number of key types for signing.