From c717da348a651cb55c3aab9fd278fee9ee87dde2 Mon Sep 17 00:00:00 2001 From: Roman Kivalin Date: Fri, 17 Apr 2026 21:03:59 +0200 Subject: [PATCH] Add custom file permissions for key and certificate files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support key_mode, key_owner, key_group and cert_mode, cert_owner, cert_group per certificate. Mode is an octal string (e.g. "0640"), owner/group are resolved by name via getpwnam/getgrnam. Permissions are checked before applying — system calls are only made when the current state differs from the desired state, and changes are logged. Runs on every renew for existing files, not just when certificates are renewed. Defaults: key 0600, cert 0644, owner/group unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 2 + Cargo.lock | 1 + Cargo.toml | 3 + README.md | 19 +++++++ debian/changelog | 2 +- examples/config.toml | 6 ++ src/config.rs | 33 +++++++++++ src/main.rs | 1 + src/permissions.rs | 131 +++++++++++++++++++++++++++++++++++++++++++ src/renew.rs | 19 +++++-- 10 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 src/permissions.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index df8162b..7628f37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +- Custom file permissions for key and certificate files (`key_mode`, `key_owner`, `key_group`, `cert_mode`, `cert_owner`, `cert_group`). Permissions are applied on every renewal run, even if the certificate is not due for renewal. + ### Fixes ### Changes diff --git a/Cargo.lock b/Cargo.lock index dc14d07..87488cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -359,6 +359,7 @@ dependencies = [ "hyper", "hyper-util", "instant-acme", + "libc", "pem", "rcgen 0.13.2", "ring", diff --git a/Cargo.toml b/Cargo.toml index 39dc2a4..4e35d46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,5 +64,8 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std" # Trait async support async-trait = "0.1" +# Unix permissions +libc = "0.2" + # systemd D-Bus zbus = "5" diff --git a/README.md b/README.md index 845aed3..2d9e45b 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,25 @@ If neither `solver` nor `solvers` is set, the global `default_solver` is used: default_solver = "dns" ``` +### File permissions + +Key and certificate files support custom permissions. Mode is specified as an octal string. Owner and group are resolved by name: + +```toml +[[certificate]] +name = "mail" +key_path = "/etc/certforge/keys/mail.key" +key_mode = "0640" +key_owner = "root" +key_group = "postfix" +cert_path = "/etc/certforge/certs/mail.pem" +cert_mode = "0644" +cert_owner = "root" +cert_group = "postfix" +``` + +Defaults: key mode `0600`, cert mode `0644`. Owner and group are not changed unless explicitly set. Permissions are applied on every `certforge renew` run, even when no certificates need renewal. + ### ACME profiles Some ACME providers require a specific profile for certain certificate types. Set `profile` on a certificate to select one. For example, Let's Encrypt requires the `shortlived` profile for IP address certificates (see [Let's Encrypt profiles](https://letsencrypt.org/docs/profiles/)). diff --git a/debian/changelog b/debian/changelog index 744dbbb..000a7c3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,6 +1,6 @@ certforge (0.7.0-1) unstable; urgency=low - * No changes + * Custom file permissions for key and certificate files -- Roman Kivalin Mon, 14 Apr 2026 00:00:00 +0000 diff --git a/examples/config.toml b/examples/config.toml index 5766617..3f85bbb 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -81,7 +81,13 @@ dns = "main" # references [dns.main] # key_type = "ecdsa-p256" # ecdsa-p256, ecdsa-p384 # key_credential = "/etc/credstore.encrypted/mail-tls-key" # # key_path = "/etc/certforge/keys/mail.key" +# # key_mode = "0640" # file mode (default: 0600) +# # key_owner = "root" # file owner (default: unchanged) +# # key_group = "postfix" # file group (default: unchanged) # cert_path = "/etc/certforge/certs/mail.pem" +# # cert_mode = "0644" # file mode (default: 0644) +# # cert_owner = "root" +# # cert_group = "postfix" # renew_before_days = 30 # rotate_key = false # # Named hooks (deduplicated if multiple certs reference the same hook) diff --git a/src/config.rs b/src/config.rs index d6e0d6e..e0c0d66 100644 --- a/src/config.rs +++ b/src/config.rs @@ -187,8 +187,26 @@ pub struct CertificateConfig { pub key_credential: Option, pub key_path: Option, + /// File mode for key_path (octal string, e.g. "0640"). Default: "0600". + #[serde(default)] + pub key_mode: Option, + /// Owner user for key_path. + #[serde(default)] + pub key_owner: Option, + /// Owner group for key_path. + #[serde(default)] + pub key_group: Option, pub cert_path: PathBuf, + /// File mode for cert_path (octal string, e.g. "0644"). Default: "0644". + #[serde(default)] + pub cert_mode: Option, + /// Owner user for cert_path. + #[serde(default)] + pub cert_owner: Option, + /// Owner group for cert_path. + #[serde(default)] + pub cert_group: Option, /// ACME profile to use for the order. /// Some providers require a specific profile for certain certificate types @@ -357,6 +375,11 @@ pub struct NamedHookConfig { pub hook: HookConfig, } +/// Parse an octal mode string (e.g., "0640") to a u32. +pub fn parse_mode(s: &str) -> Result { + u32::from_str_radix(s, 8).map_err(|_| Error::Config(format!("invalid file mode '{s}' (expected octal, e.g. \"0640\")"))) +} + impl CertificateConfig { /// Resolve the solver name for the i-th domain. /// Priority: solvers[i] > solver > default_solver. @@ -503,6 +526,16 @@ impl Config { cert.name ))); } + if let Some(mode) = &cert.key_mode { + parse_mode(mode).map_err(|_| Error::Config(format!( + "certificate {}: invalid key_mode '{mode}'", cert.name + )))?; + } + if let Some(mode) = &cert.cert_mode { + parse_mode(mode).map_err(|_| Error::Config(format!( + "certificate {}: invalid cert_mode '{mode}'", cert.name + )))?; + } // solver and solvers are mutually exclusive if cert.solver.is_some() && cert.solvers.is_some() { diff --git a/src/main.rs b/src/main.rs index 6b5e147..7d7a560 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod dns; mod error; mod hooks; mod keys; +mod permissions; mod renew; mod solver; mod state; diff --git a/src/permissions.rs b/src/permissions.rs new file mode 100644 index 0000000..5be8efe --- /dev/null +++ b/src/permissions.rs @@ -0,0 +1,131 @@ +use std::path::Path; + +use crate::config::{self, CertificateConfig}; +use crate::error::{Error, Result}; + +/// Apply configured file permissions (mode, owner, group) to a path. +/// Only makes system calls when the current state differs from the desired state. +/// Skips if the file does not exist. +#[cfg(unix)] +pub fn apply( + path: &Path, + mode: Option<&str>, + default_mode: u32, + owner: Option<&str>, + group: Option<&str>, +) -> Result<()> { + if !path.exists() { + return Ok(()); + } + + let desired_mode = match mode { + Some(s) => config::parse_mode(s)?, + None => default_mode, + }; + + // Check and apply mode + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(path)?; + let current_mode = metadata.permissions().mode() & 0o7777; + if current_mode != desired_mode { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(desired_mode))?; + tracing::info!(path = %path.display(), from = format!("{current_mode:04o}"), to = format!("{desired_mode:04o}"), "updated file mode"); + } + + // Check and apply ownership + if owner.is_some() || group.is_some() { + use std::os::unix::fs::MetadataExt; + let current_uid = metadata.uid(); + let current_gid = metadata.gid(); + + let desired_uid = match owner { + Some(name) => Some(resolve_uid(name)?), + None => None, + }; + let desired_gid = match group { + Some(name) => Some(resolve_gid(name)?), + None => None, + }; + + let uid_changed = desired_uid.is_some_and(|u| u != current_uid); + let gid_changed = desired_gid.is_some_and(|g| g != current_gid); + + if uid_changed || gid_changed { + chown(path, desired_uid, desired_gid)?; + tracing::info!( + path = %path.display(), + uid = ?desired_uid.filter(|_| uid_changed), + gid = ?desired_gid.filter(|_| gid_changed), + "updated file ownership" + ); + } + } + + Ok(()) +} + +/// Ensure file permissions for a certificate's key and cert files. +pub fn ensure_permissions(cert_config: &CertificateConfig) -> Result<()> { + #[cfg(unix)] + { + if let Some(key_path) = &cert_config.key_path { + apply( + key_path, + cert_config.key_mode.as_deref(), + 0o600, + cert_config.key_owner.as_deref(), + cert_config.key_group.as_deref(), + )?; + } + apply( + &cert_config.cert_path, + cert_config.cert_mode.as_deref(), + 0o644, + cert_config.cert_owner.as_deref(), + cert_config.cert_group.as_deref(), + )?; + } + Ok(()) +} + +#[cfg(unix)] +fn resolve_uid(name: &str) -> Result { + use std::ffi::CString; + let c_name = CString::new(name) + .map_err(|_| Error::Config(format!("invalid user name '{name}'")))?; + let pw = unsafe { libc::getpwnam(c_name.as_ptr()) }; + if pw.is_null() { + return Err(Error::Config(format!("user '{name}' not found"))); + } + Ok(unsafe { (*pw).pw_uid }) +} + +#[cfg(unix)] +fn resolve_gid(name: &str) -> Result { + use std::ffi::CString; + let c_name = CString::new(name) + .map_err(|_| Error::Config(format!("invalid group name '{name}'")))?; + let gr = unsafe { libc::getgrnam(c_name.as_ptr()) }; + if gr.is_null() { + return Err(Error::Config(format!("group '{name}' not found"))); + } + Ok(unsafe { (*gr).gr_gid }) +} + +#[cfg(unix)] +fn chown(path: &Path, uid: Option, gid: Option) -> Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + + let c_path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| Error::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid path")))?; + + let uid = uid.map(|u| u as libc::uid_t).unwrap_or(u32::MAX); + let gid = gid.map(|g| g as libc::gid_t).unwrap_or(u32::MAX); + + let ret = unsafe { libc::chown(c_path.as_ptr(), uid, gid) }; + if ret != 0 { + return Err(Error::Io(std::io::Error::last_os_error())); + } + Ok(()) +} diff --git a/src/renew.rs b/src/renew.rs index b2aeb77..f72df2c 100644 --- a/src/renew.rs +++ b/src/renew.rs @@ -77,6 +77,17 @@ pub async fn run( let mut state_dirty = false; let mut pending_hooks: Vec = Vec::new(); + // Ensure file permissions on existing files before renewal + for cert_config in &config.certificates { + if let Some(filter) = name_filter + && cert_config.name != filter { + continue; + } + if let Err(e) = crate::permissions::ensure_permissions(cert_config) { + tracing::warn!(name = %cert_config.name, error = %e, "failed to set file permissions"); + } + } + let acme = AcmeClient::new(&config.acme).await?; for cert_config in &config.certificates { @@ -263,6 +274,9 @@ async fn renew_certificate( save_key(&key, cert_config).await?; + // Apply file permissions + crate::permissions::ensure_permissions(cert_config)?; + // Publish DANE TLSA records if !cert_config.dane.is_empty() { let cert_info = CertInfo::from_pem(&cert_pem)?; @@ -373,11 +387,6 @@ async fn save_key(key: &CertKeyPair, cert_config: &CertificateConfig) -> Result< std::fs::create_dir_all(parent)?; } std::fs::write(key_path, &pem)?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?; - } tracing::info!(path = %key_path.display(), "saved private key"); }