From 173c32ff5abc655a586547c7b93b8cbd986cab97 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 5 Aug 2026 11:05:53 +0200 Subject: [PATCH 1/8] Refactor online certificate lookup - Allow searching for fingerprints - needed for the updater mechanism. --- src/common/crypto.rs | 121 ++++++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 37 deletions(-) diff --git a/src/common/crypto.rs b/src/common/crypto.rs index 12db06c..ef40920 100644 --- a/src/common/crypto.rs +++ b/src/common/crypto.rs @@ -96,12 +96,33 @@ const USER_AGENT: &'static str = concat!("husk/", env!("CARGO_PKG_VERSION")); const CONNECT_TIMEOUT: Duration = Duration::new(5, 0); const REQUEST_TIMEOUT: Duration = Duration::new(5, 0); -/// Try to fetch certificates for `email` from online sources (keyservers, wkd, +#[derive(Debug, Clone)] +pub enum OnlineQuery { + Fingerprint(Fingerprint), + Email(String), +} + +/// Try to fetch certificates for email from online sources (keyservers, wkd, /// dane). -pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str) +pub async fn lookup_email<'hc>(context: &HuskContext<'hc>, email: &str) + -> anyhow::Result> { + query_online_sources(context, OnlineQuery::Email(email.into())).await +} + +/// Try to fetch certificates for fingerprint from keyservers. wkd +/// and dane can only be queried for email addresses. +pub async fn lookup_fingerprint<'hc>(context: &HuskContext<'hc>, fpr: &str) + -> anyhow::Result> { + let fpr = fpr.parse::()?; + query_online_sources(context, OnlineQuery::Fingerprint(fpr)).await +} + +/// Try to fetch certificates for query from online sources (keyservers, wkd, +/// dane). +async fn query_online_sources<'hc>(context: &HuskContext<'hc>, query: OnlineQuery) -> anyhow::Result> { - log::debug!("remote lookup for {}", email); + log::debug!("remote lookup for {:?}", query); let mut jobs = JoinSet::new(); let http_client = sequoia_net::reqwest::Client::builder() @@ -111,25 +132,32 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str) .build()?; // local function to check certificates for usability - let collect_certs = |certs: Vec>, email: String, policy: &StandardPolicy| { + let collect_certs = |certs: Vec>, query: OnlineQuery, policy: &StandardPolicy| { let mut result = Vec::new(); let mut seen: HashSet = HashSet::new(); for cert in certs.into_iter().flatten() { if let Ok(vc) = cert.with_policy(policy, None) { - // check if the returned certificate contains a userid with the - // email address in question. - let mut userid_found = false; - for userid in vc.userids() { - if let Ok(Some(u)) = userid.userid().email() { - if u == email { - userid_found = true; + let mut found = false; + match query.clone() { + OnlineQuery::Email(email) => { + // check if the returned certificate contains a userid with the + // email address in question. + for userid in vc.userids() { + if let Ok(Some(u)) = userid.userid().email() { + if u == email { + found = true; + } + } } + }, + OnlineQuery::Fingerprint(_) => { + found = true; } } - if userid_found { + if found { // check if the certificate can be used (for encryption). vc.keys() .supported() @@ -156,43 +184,62 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str) if let Ok(ks) = KeyServer::with_client(keyserver.as_str(), http_client.clone()) { - let e = email.to_string(); let p = StandardPolicy::new(); + let query = query.clone(); jobs.spawn(async move { let mut partial_result = Vec::new(); - if let Ok(certs) = ks.search(e.as_str()).await { - partial_result = collect_certs(certs, e, &p); + let q = query.clone(); + match query { + OnlineQuery::Email(e) => { + if let Ok(certs) = ks.search(e.as_str()).await { + partial_result = collect_certs(certs, q, &p); + } + partial_result + }, + OnlineQuery::Fingerprint(f) => { + if let Ok(certs) = ks.get(f).await { + partial_result = collect_certs(certs, q, &p); + } + partial_result + }, } - partial_result }); } } - // WKD - let e = email.to_string(); - let p = StandardPolicy::new(); - jobs.spawn(async move { - let mut partial_result = Vec::new(); + // WKD and DANE need an email address + if let OnlineQuery::Email(_) = query { + let p = StandardPolicy::new(); + let q = query.clone(); - if let Ok(certs) = wkd::get(&http_client.clone(), e.as_str()).await { - partial_result = collect_certs(certs, e, &p); - } - partial_result - }); + // WKD + jobs.spawn(async move { + let mut partial_result = Vec::new(); - // DANE - let e = email.to_string(); - let p = StandardPolicy::new(); - jobs.spawn(async move { - let mut partial_result = Vec::new(); + if let OnlineQuery::Email(e) = q.clone() { + if let Ok(certs) = wkd::get(&http_client.clone(), e.as_str()).await { + partial_result = collect_certs(certs, q, &p); + } + } + partial_result + }); - if let Ok(certs) = dane::get(e.as_str()).await { - partial_result = collect_certs(certs, e, &p); - } - partial_result - }); + // DANE + let p = StandardPolicy::new(); + let q = query.clone(); + jobs.spawn(async move { + let mut partial_result = Vec::new(); + + if let OnlineQuery::Email(e) = q { + if let Ok(certs) = dane::get(e.as_str()).await { + partial_result = collect_certs(certs, query, &p); + } + } + partial_result + }); + } // join the partial results. As the same certificate might be returned by // several sources, dedup and merge the certificates. @@ -240,7 +287,7 @@ pub async fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) introducer.cert.self_signed_userids().map(|u| u.userid()).collect::>()); if introducer.can_introduce(format!("<{}>", email).as_str()) { log::debug!(" introducer regex matches!"); - match lookup_certificates(context, email).await { + match lookup_email(context, email).await { Ok(certs) => { for cert in certs { if introducer.is_introducing(&cert) { From cd8935b3f312f1bfd9ef6cdca2ed656101a27acb Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 5 Aug 2026 11:07:42 +0200 Subject: [PATCH 2/8] cleanup dependencies --- src/commands/daemon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index 4784bf8..b9b4f9f 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -4,7 +4,7 @@ use crate::cli::cli_args::{DaemonCommand, DaemonSubcommand}; use crate::common::process_descr::ProcessDescr; use crate::config::HuskConfigContainer; use crate::config::HuskConfig; -use crate::Daemon; +use crate::daemon::Daemon; pub async fn dispatch(cmd: DaemonCommand, config: HuskConfigContainer) -> Result<()> { From 118b930c1d36f1853088e1ef525dc3be8b64a2fe Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Thu, 6 Aug 2026 11:47:32 +0200 Subject: [PATCH 3/8] Refactor HuskContext - Provide a way to initialize a `HuskContext` without determine the signing key. This saves startup time for commands which do not need the signing key. --- src/types/husk_context.rs | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index 9efac1d..8e13464 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -36,8 +36,17 @@ pub struct HuskContext<'hc> { impl<'hc> HuskContext<'hc> { - /// Create a new HuskContext instance. pub fn new(config: &HuskConfig) -> anyhow::Result> { + Self::create(config, true) + } + + /// Create a new HuskContext without determine the signing key + pub fn without_signkey(config: &HuskConfig) -> anyhow::Result> { + Self::create(config, false) + } + + /// Create a new HuskContext instance. + pub fn create(config: &HuskConfig, with_signkey: bool) -> anyhow::Result> { let sequoia_home = Home::new(PathBuf::from(&config.sequoia_home))?; let cert_store_base = sequoia_home.data_dir(sequoia_directories::Component::CertD); @@ -48,16 +57,20 @@ impl<'hc> HuskContext<'hc> { let local_trust_root = crypto::get_local_trust_root(&cert_store)?; let introducers = crypto::get_introducers(&cert_store, &policy, local_trust_root.fingerprint()); - let signing_key = match crypto::get_signing_key(sequoia_home, &cert_store, &policy, &local_trust_root) { - Ok(Some(cert)) => Some(cert), - Ok(None) => { - log::warn!("No signing key found"); - None - }, - Err(e) => { - log::error!("Error while fetching signing key: {}", e); - None + let signing_key = if with_signkey { + match crypto::get_signing_key(sequoia_home, &cert_store, &policy, &local_trust_root) { + Ok(Some(cert)) => Some(cert), + Ok(None) => { + log::warn!("No signing key found"); + None + }, + Err(e) => { + log::error!("Error while fetching signing key: {}", e); + None + } } + } else { + None }; Ok(HuskContext { From 1ed3e1e98810234bd2e540ea3bf5749d69ec68e5 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Fri, 7 Aug 2026 11:11:55 +0200 Subject: [PATCH 4/8] Updater - The updater looks for updates to certificates from the certificate store. - The updates are spaced by random time intervals so that the reconstruction of the content of the cert store is made more difficult. - The updater can be send into the background - start, stop and status are implemented. - The state of the updater is persisted to disk so that the update procedure survives a restart. --- Cargo.lock | 45 +++- Cargo.toml | 1 + config/config.toml | 4 + config/log4rs.yml | 16 -- src/cli/cli_args.rs | 37 ++++ src/commands.rs | 4 + src/commands/updater.rs | 49 +++++ src/config.rs | 62 ++++++ src/main.rs | 3 +- src/types/defaults.rs | 7 +- src/updater/mod.rs | 85 ++++++++ src/updater/state.rs | 461 ++++++++++++++++++++++++++++++++++++++++ 12 files changed, 753 insertions(+), 21 deletions(-) delete mode 100644 config/log4rs.yml create mode 100644 src/commands/updater.rs create mode 100644 src/updater/mod.rs create mode 100644 src/updater/state.rs diff --git a/Cargo.lock b/Cargo.lock index 93fb039..ea2903a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,7 +99,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -333,6 +333,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -447,6 +458,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1055,6 +1075,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", ] [[package]] @@ -1270,6 +1291,7 @@ dependencies = [ "lettre", "log", "log4rs", + "rand 0.10.2", "sequoia-cert-store", "sequoia-directories", "sequoia-keystore", @@ -1614,7 +1636,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2290,6 +2312,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2328,6 +2361,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rayon" version = "1.12.0" @@ -2940,7 +2979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] diff --git a/Cargo.toml b/Cargo.toml index 09748ea..8082def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ indymilter = "0.3.0" lettre = "0.11.22" log = "0.4.30" log4rs = "1.3.0" +rand = "0.10.2" sequoia-cert-store = "0.7.3" sequoia-directories = "0.1.0" sequoia-keystore = "0.7.2" diff --git a/config/config.toml b/config/config.toml index 9f7f7ec..5cab41a 100644 --- a/config/config.toml +++ b/config/config.toml @@ -8,6 +8,10 @@ sequoia_home = "/tmp/sq_home" #keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ] keyservers = [ "hkp://example.com" ] +[updater] +wait_min = 1 +wait_max = 2 + [logging.appenders.console] kind = "console" [logging.appenders.console.encoder] diff --git a/config/log4rs.yml b/config/log4rs.yml deleted file mode 100644 index 64797bb..0000000 --- a/config/log4rs.yml +++ /dev/null @@ -1,16 +0,0 @@ -appenders: - stdout_appender: - kind: console - encoder: - pattern: "{h({d(%Y-%m-%d %H:%M:%S)(utc)} - {l}: {m}{n})}" - - file_appender: - kind: file - path: "/tmp/husk.log" - encoder: - pattern: "{h({d(%Y-%m-%d %H:%M:%S)(utc)} - {l}: {m}{n})}" - -root: - level: debug - appenders: - - stdout_appender diff --git a/src/cli/cli_args.rs b/src/cli/cli_args.rs index 08a46d3..24d44a7 100644 --- a/src/cli/cli_args.rs +++ b/src/cli/cli_args.rs @@ -34,6 +34,7 @@ pub enum HuskSubcommands { Introducer(IntroducerCommand), Locals(LocalsCommand), Signkey(SignkeyCommand), + Updater(UpdaterCommand), } // Daemon subcommands @@ -72,6 +73,42 @@ pub struct DaemonStartCommand { background: bool, } +// Updater subcommands + +#[derive(Parser, Debug)] +#[clap( + name = "updater", + about = "Manage the updater process.", + subcommand_required = true, + arg_required_else_help = true, + disable_colored_help = true, + disable_version_flag = true, +)] +pub struct UpdaterCommand { + #[clap(subcommand)] + pub subcommand: UpdaterSubcommand, +} +#[derive(Debug, Subcommand)] +pub enum UpdaterSubcommand { + Start(UpdaterStartCommand), + Stop, + Status +} + +#[derive(Parser, Debug)] +#[clap( + name = "daemon", + about = "Start the updater.", +)] +pub struct UpdaterStartCommand { + #[clap( + long = "background", + help = "Start the updater as a background process.", + default_value_t = false, + )] + background: bool, +} + // Introducer subcommands #[derive(Parser, Debug)] diff --git a/src/commands.rs b/src/commands.rs index acb5cfd..52830da 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -7,6 +7,7 @@ use crate::cli::cli_args::{CliArgs, HuskSubcommands}; use crate::config::HuskConfigContainer; pub mod daemon; +pub mod updater; pub mod introducer; pub mod locals; pub mod signkey; @@ -48,6 +49,9 @@ pub async fn dispatch(cli: CliArgs, config: HuskConfigContainer) -> Result<()> { HuskSubcommands::Signkey(subcmd) => { commands::signkey::dispatch(subcmd, config).await?; } + HuskSubcommands::Updater(subcmd) => { + commands::updater::dispatch(subcmd, config).await?; + } } Ok(()) diff --git a/src/commands/updater.rs b/src/commands/updater.rs new file mode 100644 index 0000000..e90eb4b --- /dev/null +++ b/src/commands/updater.rs @@ -0,0 +1,49 @@ +//! Updater first level dispatcher + +use anyhow::Result; + +use crate::cli::cli_args::{UpdaterCommand, UpdaterSubcommand}; +use crate::common::process_descr::ProcessDescr; +use crate::config::HuskConfigContainer; +use crate::config::HuskConfig; +use crate::updater::Updater; + +pub async fn dispatch(cmd: UpdaterCommand, config: HuskConfigContainer) -> Result<()> { + + let c: HuskConfig = config.clone().into(); + + match cmd.subcommand { + UpdaterSubcommand::Start(_sub_cmd) => { + // Write the pid file. + let pid_file_name = c.get_updater_pid_file(); + ProcessDescr::create(pid_file_name.clone())?; + + let result_code = Updater::run(config).await; + + // and remove it when the daemon terminated. + let _ = ProcessDescr::remove(pid_file_name); + return result_code; + }, + UpdaterSubcommand::Stop => { + let process_descr = ProcessDescr::load(c.get_updater_pid_file())?; + if process_descr.send_sig_interrupt().is_ok() { + let _ = ProcessDescr::remove(process_descr.file_location); + } + }, + UpdaterSubcommand::Status => { + if let Ok(process_descr) = ProcessDescr::load(c.get_updater_pid_file()) { + if process_descr.is_running() { + println!("Running, with pid: {}", process_descr.pid); + } else { + println!("Not running, but stale pid file detected: {}", + c.get_updater_pid_file()); + } + } else { + println!("Not running"); + } + } + } + + Ok(()) +} + diff --git a/src/config.rs b/src/config.rs index 3096469..08b3615 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,9 +10,21 @@ use log4rs; use crate::types::errors::HuskError; use crate::types::defaults::CONSOLE_LOGGING; +use crate::types::defaults::UPDATER_PID_FILE_LOCATION; +use crate::types::defaults::UPDATER_STATE_FILE_LOCATION; +use crate::types::defaults::UPDATER_WAIT_MIN; +use crate::types::defaults::UPDATER_WAIT_MAX; use crate::types::defaults::PID_FILE_LOCATION; use crate::types::defaults::SUBJECT_REPLACEMENT; +#[derive(Deserialize, Debug, Clone)] +pub struct UpdaterConfig { + pub wait_min: Option, + pub wait_max: Option, + pub pid_file: Option, + pub state_file: Option, +} + #[derive(Deserialize, Debug, Clone)] pub struct HuskConfig { pub connection: String, @@ -20,6 +32,7 @@ pub struct HuskConfig { pub keyservers: Option>, pub subject_replacement: Option, pub pid_file: Option, + updater: Option, logging: Option, } @@ -79,6 +92,7 @@ impl HuskConfig { SUBJECT_REPLACEMENT.to_string() } } + pub fn get_pid_file(&self) -> String { if let Some(pid_file) = &self.pid_file { pid_file.clone() @@ -86,6 +100,53 @@ impl HuskConfig { PID_FILE_LOCATION.to_string() } } + + pub fn get_updater_pid_file(&self) -> String { + if let Some(updater_config) = &self.updater { + if let Some(pid_file) = &updater_config.pid_file { + pid_file.clone() + } else { + UPDATER_PID_FILE_LOCATION.to_string() + } + } else { + UPDATER_PID_FILE_LOCATION.to_string() + } + } + + pub fn get_updater_state_file(&self) -> String { + if let Some(updater_config) = &self.updater { + if let Some(state_file) = &updater_config.state_file { + state_file.clone() + } else { + UPDATER_STATE_FILE_LOCATION.to_string() + } + } else { + UPDATER_STATE_FILE_LOCATION.to_string() + } + } + + pub fn get_updater_min_wait(&self) -> u32 { + if let Some(updater_config) = &self.updater { + if let Some(wait_min) = &updater_config.wait_min { + *wait_min + } else { + UPDATER_WAIT_MIN + } + } else { + UPDATER_WAIT_MIN + } + } + pub fn get_updater_max_wait(&self) -> u32 { + if let Some(updater_config) = &self.updater { + if let Some(wait_max) = &updater_config.wait_max { + *wait_max + } else { + UPDATER_WAIT_MAX + } + } else { + UPDATER_WAIT_MAX + } + } } pub type HuskConfigContainer = Arc>; @@ -113,6 +174,7 @@ mod tests { subject_replacement: None, logging: None, pid_file: None, + updater: None, }; assert_eq!(config.get_subject_replacement(), defaults::SUBJECT_REPLACEMENT); diff --git a/src/main.rs b/src/main.rs index f7f2985..aac784b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use config::{HuskConfig, HuskConfigContainer}; pub mod types; pub mod mail; pub mod daemon; -use daemon::Daemon; +pub mod updater; pub mod common; pub fn main() -> anyhow::Result<()> { @@ -26,6 +26,7 @@ pub fn main() -> anyhow::Result<()> { // XXX keep this in sync with cli/cli_args.rs for arg in std::env::args() { if arg == "daemon" && daemonize == 0 { daemonize = 1; } + if arg == "updater" && daemonize == 0 { daemonize = 1; } if arg == "start" && daemonize == 1 { daemonize = 2; } if arg == "--background" && daemonize == 2 { daemonize = 3; } } diff --git a/src/types/defaults.rs b/src/types/defaults.rs index 774301d..12e0446 100644 --- a/src/types/defaults.rs +++ b/src/types/defaults.rs @@ -6,7 +6,12 @@ pub const CONFIG_FILE_LOCATION: &str = "/etc/husk/config.toml"; /// and replaced by `SUBJECT_REPLACEMENT` (unless configured otherwise). pub const SUBJECT_REPLACEMENT: &str = "..."; -pub const PID_FILE_LOCATION: &str = "/run/husk/pid"; +pub const PID_FILE_LOCATION: &str = "/var/lib/husk/husk.pid"; +pub const UPDATER_PID_FILE_LOCATION: &str = "/var/lib/husk/updater.pid"; +pub const UPDATER_STATE_FILE_LOCATION: &str = "/var/lib/husk/updater.state"; + +pub const UPDATER_WAIT_MIN: u32 = 5; +pub const UPDATER_WAIT_MAX: u32 = 3600; pub const FOOTER: &str = "---\nProcessed by Husk at {date}.\n"; pub const FOOTER_RECIPIENTS: &str = "This mail was also send to {rcpts}.\n"; diff --git a/src/updater/mod.rs b/src/updater/mod.rs new file mode 100644 index 0000000..db49460 --- /dev/null +++ b/src/updater/mod.rs @@ -0,0 +1,85 @@ +//! Husk certificate updater + +use std::path::Path; +use std::sync::Arc; + +use sequoia_cert_store::LazyCert; +use sequoia_cert_store::StoreUpdate; +use sequoia_cert_store::store::MergePublicCollectStats; +use tokio::time::sleep; +use tokio::time::Duration; + +use crate::config::HuskConfig; +use crate::config::HuskConfigContainer; +use crate::common::crypto; +use crate::types::husk_context::HuskContext; +use state::UpdaterState; + +pub mod state; + +pub struct Updater { } + +impl Updater { + + async fn delay(min: u32, max: u32) { + + let val = rand::random_range(min..max); + log::debug!("updater sleeping for {} secs", val); + sleep(Duration::from_secs(val as u64)).await; + } + + pub async fn run(config_container: HuskConfigContainer) -> anyhow::Result<()> { + + let c: HuskConfig = config_container.into(); + + let mut stay = true; + let state_file = c.get_updater_state_file(); + let state_path = Path::new(&state_file); + while stay { + Self::delay(c.get_updater_min_wait(), c.get_updater_max_wait()).await; + + let mut updater_state = UpdaterState::thaw(state_path) + .await + .map_err(|e| { stay = false; e })?; + let context = &HuskContext::without_signkey(&c)?; + updater_state.refresh(context); + + if let Some(fpr) = updater_state.next() { + log::debug!("try to update {}", fpr.to_hex()); + + let stats = MergePublicCollectStats::new(); + + let certs = crypto::lookup_fingerprint(context, fpr.to_hex().as_str()).await?; + if !certs.is_empty() { + for cert in certs { + let cert = Arc::new(LazyCert::from_cert(cert)); + if context.cert_store.update_by(cert, &stats).is_ok() { + updater_state.mark_processed(&fpr, stats.updated_certs() > 0); + } else { + updater_state.incr_retry_count(&fpr); + } + } + } else { + // no updates found + updater_state.mark_processed(&fpr, false); + } + UpdaterState::freeze(state_path, &updater_state) + .await + .map_err(|e| { stay = false; e })?; + } else { + // finished update round + let (processed, updated) = updater_state.summarize(); + log::info!("Updater finished round: {} certificates processed, {} updated", + processed, + updated); + // remove state from disk + UpdaterState::remove(state_path) + .await + .map_err(|e| { stay = false; e })?; + } + } + + Ok(()) + } + +} diff --git a/src/updater/state.rs b/src/updater/state.rs new file mode 100644 index 0000000..9b9f2e5 --- /dev/null +++ b/src/updater/state.rs @@ -0,0 +1,461 @@ +//! manage the state of certificates while updating + +use std::{fmt, fs}; +use std::collections::BTreeMap; +use std::fs::OpenOptions; +use std::io::{Read, Write}; +use std::path::Path; + +use chrono::{Local, NaiveDateTime}; +use rand::prelude::*; +use sequoia_openpgp::Fingerprint; +use sequoia_openpgp::types::RevocationStatus; +use sequoia_openpgp::parse::buffered_reader::File; +use sequoia_cert_store::Store; +use thiserror::Error; + +use crate::types::husk_context::HuskContext; + +#[derive(Error, Debug)] +pub enum UpdaterError { + #[error("IO Error {0}")] + IoError(String), + #[error("Start time missing")] + StartTimeMissing, + #[error("Wrong record size {0}")] + WrongRecordSize(usize), + #[error("Data missing")] + DataMissing, + #[error("Broken persisted cert state: {0}")] + BrokenState(String), + #[error("File not found {0}")] + FileNotFound(String), +} + +const MAX_RETRY: u32 = 3; +const DATETIME_PATTERN: &str = "%Y-%m-%d %H:%M:%S"; + +#[derive(Debug, PartialEq)] +pub struct CertState { + fpr: Fingerprint, + processed: bool, + updated: bool, + // XXX: do we really want to retry within the same update round? + retry_count: u32, +} + +impl fmt::Display for CertState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}:{}:{}:{}", + self.fpr, + self.processed, + self.updated, + self.retry_count) + } +} + +impl TryFrom<&str> for CertState { + type Error = UpdaterError; + + fn try_from(line: &str) -> Result { + let line = line.trim(); + let parts: Vec<&str> = line.split(":").collect(); + + if parts.len() != 4 { + return Err(UpdaterError::WrongRecordSize(parts.len())); + } + + let fpr = parts[0] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[0].to_string()))?; + let processed = parts[1] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[1].to_string()))?; + let updated = parts[2] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[2].to_string()))?; + let retry_count = parts[3] + .parse::() + .map_err(|_e| UpdaterError::BrokenState(parts[3].to_string()))?; + Ok(CertState { + fpr, + processed, + updated, + retry_count, + }) + } +} + +impl TryFrom for CertState { + type Error = UpdaterError; + + fn try_from(line: String) -> Result { + line.as_str().try_into() + } +} + +impl CertState { + + pub fn new(fpr: Fingerprint) -> Self { + Self { + fpr: fpr, + processed: false, + updated: false, + retry_count: 0, + } + } + + pub fn need_processing(&self) -> bool { + !(self.processed || (self.retry_count >= MAX_RETRY)) + } + + /// Mark this certificate as processed - this doesn't mean that + /// there was an actual update. + pub fn mark_processed(&mut self) { + self.processed = true; + } + + /// Mark this certificate as updated - this doesn't mark the + /// certificate as processed. + pub fn mark_updated(&mut self) { + self.updated = true; + } + + /// Increment the retry count. + pub fn incr_retry_count(&mut self) { + self.retry_count += 1; + } +} + +#[derive(Debug, PartialEq)] +pub struct UpdaterState { + started: NaiveDateTime, + certs: BTreeMap, +} + +impl TryFrom for UpdaterState { + type Error = UpdaterError; + + fn try_from(data: String) -> Result { + let mut result = BTreeMap::new(); + let mut lines = data.lines(); + let started = lines + .next() + .and_then(|l| + NaiveDateTime::parse_from_str(l, DATETIME_PATTERN).ok()); + if started.is_none() { + return Err(UpdaterError::DataMissing); + } + + for line in lines { + if line.is_empty() { continue; } + let cert_state: CertState = line.try_into()?; + result.insert(cert_state.fpr.clone(), cert_state); + } + Ok(UpdaterState { + started: started.unwrap(), + certs: result + }) + } + +} + +impl TryFrom> for UpdaterState { + type Error = UpdaterError; + + fn try_from(data: Vec) -> Result { + Self::try_from(String::from_utf8_lossy(&data[..]).to_string()) + } +} + +impl fmt::Display for UpdaterState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.started.format(DATETIME_PATTERN))?; + for (_, cert) in &self.certs { + write!(f, "{}", cert)?; + } + + Ok(()) + } +} + +impl UpdaterState { + + pub fn new() -> Self { + Self { + started: Local::now().naive_local(), + certs: BTreeMap::new() + } + } + + /// Get the next certificate from the updater, which still needs + /// processing - this includes retries. + pub fn next(&mut self) -> Option { + let set: Vec<&CertState> = self.certs.values() + .filter(|c| c.need_processing()) + .collect(); + let mut rng = rand::rng(); + set.choose(&mut rng) + .and_then(|c| Some(c.fpr.clone())) + } + + /// Mark the certificate (identified by its fingerprint) as processed. Also + /// mark it as updated if `updated` is true. + pub fn mark_processed(&mut self, fpr: &Fingerprint, updated: bool) { + if let Some(entry) = self.certs.get_mut(fpr) { + entry.mark_processed(); + if updated { + entry.mark_updated(); + } + } + } + + /// Increment the retry count of the certificate (identified by its + /// fingerprint. + pub fn incr_retry_count(&mut self, fpr: &Fingerprint) { + if let Some(entry) = self.certs.get_mut(fpr) { + entry.incr_retry_count(); + } + } + + /// Scan the certificate store for new certificates and add them to the + /// state (as unprocessed). + pub fn refresh(&mut self, context: &HuskContext<'_>) { + + let count = self.certs.keys().count(); + + context.cert_store.certs() + .filter(|c| { + if let Ok(vc) = c.with_policy(&context.policy, None) { + // Ignore revoked certificates. + if let RevocationStatus::Revoked(_) = vc.revocation_status() { + return false; + } + + // Ignore non exportable certificates, this includes internal + // certificates like the trust root or shadow CAs. + vc.cert().exportable() + } else { + // Certificate is invalid under the current policy. + false + } + }) + .map(|c| c.fingerprint()) + .for_each(|fpr| { + if ! self.certs.contains_key(&fpr) { + self.certs.insert(fpr.clone(), CertState::new(fpr)); + } + }); + + log::debug!("refresh from {} to {}", + count, + self.certs.keys().count()); + } + + /// Recreate the state from file. + pub async fn thaw(file: &Path) -> Result { + + if !file.exists() { + log::debug!("file not existing: {:?}", file); + if let Some(parent) = file.parent() { + log::debug!("assert parent directory"); + std::fs::create_dir_all(parent) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + } + return Ok(Self::new()); + } + + let meta = file.metadata() + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + let updater_state = if meta.len() == 0 { + // no previous state + Self::new() + } else { + let mut handle = File::open(file) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + let mut content = Vec::with_capacity(meta.len() as usize); + handle.read_to_end(&mut content) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + content.try_into()? + }; + + Ok(updater_state) + } + + /// Persist state to file. + pub async fn freeze(file: &Path, updater_state: &UpdaterState) -> Result<(), UpdaterError> { + // freeze comes after thaw, so we assume that the parent directory + // exists. + // XXX: on unix set mode 0o666 + let mut handle = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(file) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + + handle.write_all(updater_state.to_string().as_bytes()) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + + Ok(()) + } + + /// Remove the state file. + pub async fn remove(file: &Path) -> Result<(), UpdaterError> { + if file.exists() { + fs::remove_file(file) + .map_err(|e| UpdaterError::IoError(e.to_string()))?; + } + Ok(()) + } + + /// Create some statistics. + pub fn summarize(&self) -> (usize, usize) { + let mut processed = 0; + let mut updated = 0; + for (_, cert) in &self.certs { + if cert.processed { processed += 1; } + if cert.updated { updated += 1; } + } + (processed, updated) + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::updater::state::{DATETIME_PATTERN, UpdaterError}; + use chrono::NaiveDateTime; + use sequoia_openpgp::Fingerprint; + + use super::{CertState, UpdaterState}; + + + #[test] + fn serialize_deserialize( ) -> Result<(), UpdaterError> { + + let cert_state = CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + }; + + let serialized: String = format!("{}", cert_state); + let deserialized: CertState = serialized.try_into()?; + + assert_eq!(cert_state, deserialized); + + let cert_state = CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 3, + }; + + let serialized: String = format!("{}", cert_state); + eprintln!("{}", serialized); + + let deserialized: CertState = serialized.try_into()?; + + assert_eq!(cert_state, deserialized); + + Ok(()) + } + + #[test] + fn serialize_deserialize_store( ) -> Result<(), UpdaterError> { + + let store = UpdaterState { + started: NaiveDateTime::parse_from_str("2026-07-11 15:06:10", DATETIME_PATTERN) + .map_err(|_e| UpdaterError::BrokenState("in test".into()))?, + certs: [ + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 3, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 3, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: true, + retry_count: 1, + } + ].into_iter() + .map(|c| (c.fpr.clone(), c)) + .collect::>() + }; + + let serialized = store.to_string(); + eprintln!("{}", serialized); + let deserialized: UpdaterState = serialized.try_into()?; + + assert_eq!(store, deserialized); + + Ok(()) + } + + #[test] + fn update_turnaround() -> Result<(), UpdaterError> { + let mut store = UpdaterState { + started: NaiveDateTime::parse_from_str("2026-07-11 15:06:10", DATETIME_PATTERN) + .map_err(|_e| UpdaterError::BrokenState("in test".into()))?, + certs: [ + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB68" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB69" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + }, + CertState { + fpr: "E936CB3BBCECC7F53769C87C006ECEC2B1DFFB6A" + .parse::() + .map_err(|_e| UpdaterError::BrokenState("Fingerprint wrong in test".into()))?, + processed: false, + updated: false, + retry_count: 0, + } + ].into_iter() + .map(|c| (c.fpr.clone(), c)) + .collect::>() + }; + + let mut break_counter = 0; + // this loop has to end + while let Some(fpr) = store.next() { + assert!(break_counter < store.certs.len()); + break_counter += 1; + store.mark_processed(&fpr, false); + } + + Ok(()) + } +} From 04322f9cd94f44e7cef783d8df31d7c54aa3157e Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Mon, 10 Aug 2026 15:12:50 +0200 Subject: [PATCH 5/8] Tor support - Add support for using Tor via a socks5 proxy. - Tor usage of the milter and the updater process are configured separately. - Restructure the config file and add some documentation. --- Cargo.lock | 1 + Cargo.toml | 2 + config/config.toml | 47 ++++++++++++++++++- src/common/crypto.rs | 36 +++++++++++--- src/config.rs | 99 +++++++++++++++++++++++++++++++-------- src/daemon/mod.rs | 3 +- src/types/defaults.rs | 3 ++ src/types/husk_context.rs | 12 ++++- src/updater/mod.rs | 7 +-- 9 files changed, 177 insertions(+), 33 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea2903a..67f107a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1292,6 +1292,7 @@ dependencies = [ "log", "log4rs", "rand 0.10.2", + "reqwest", "sequoia-cert-store", "sequoia-directories", "sequoia-keystore", diff --git a/Cargo.toml b/Cargo.toml index 8082def..c181d71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ lettre = "0.11.22" log = "0.4.30" log4rs = "1.3.0" rand = "0.10.2" +# the reqwest version has to be the same sequoia-net reexports! +reqwest = { version = "0.12", features = ["socks"] } sequoia-cert-store = "0.7.3" sequoia-directories = "0.1.0" sequoia-keystore = "0.7.2" diff --git a/config/config.toml b/config/config.toml index 5cab41a..07c39c1 100644 --- a/config/config.toml +++ b/config/config.toml @@ -1,17 +1,60 @@ # # Husk config file # -connection = "localhost:3000" +# The directory for anything Sequoia PGP related (keystore, certstore). +# +# default: /var/lib/husk sequoia_home = "/tmp/sq_home" -#keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ] +[milter] +# IP and port the milter will listen on. +# +# no default, must be specified. +connection = "localhost:3000" + +# Keyservers to use for key lookup. +# +# Example: +# keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ] +# +# The default is the empty list, which prevents keyserver lookups. keyservers = [ "hkp://example.com" ] +# Location of the pid file of the milter. +# +# default: /var/lib/husk/husk.pid + +# Subject header lines are protected (exchanged). +# +# Specify your replacement string: +# subject_replacement = "Encrypted message" +# +# default: "..." + +# Tor usage: If configured, the milter will use Tor via the specified +# sock5 proxy. +# +# use_tor_via = "socks5://localhost:9050/" +# +# default: unconfigured, meaning: no usage of Tor. + [updater] + +# The interval between two update requests for a certificate are +# random. +# +# wait_min and wait_max specify the range for that randomness. +# defaults: +# min_wait = 5 +# max_wait = 3600 wait_min = 1 wait_max = 2 +# Tor usage of the updater process. Semantics (and default) are the same +# as with the milter. +use_tor_via = "socks5://localhost:9050/" + [logging.appenders.console] kind = "console" [logging.appenders.console.encoder] diff --git a/src/common/crypto.rs b/src/common/crypto.rs index ef40920..2cd5315 100644 --- a/src/common/crypto.rs +++ b/src/common/crypto.rs @@ -30,6 +30,7 @@ use sequoia_wot::{self as wot, CertSynopsis}; use wot::store::Store as _; use wot::{Depth, Path}; +use crate::config::TorConnectorType; use crate::types::errors::HuskError; use crate::types::husk_context::HuskContext; use crate::types::introducer::Introducer; @@ -125,11 +126,23 @@ async fn query_online_sources<'hc>(context: &HuskContext<'hc>, query: OnlineQuer log::debug!("remote lookup for {:?}", query); let mut jobs = JoinSet::new(); - let http_client = sequoia_net::reqwest::Client::builder() + let mut http_client_builder = sequoia_net::reqwest::Client::builder() .user_agent(USER_AGENT) .connect_timeout(CONNECT_TIMEOUT) - .timeout(REQUEST_TIMEOUT) - .build()?; + .timeout(REQUEST_TIMEOUT); + + if let TorConnectorType::SocksProxy(socks_url) = &context.use_tor_via { + // Configure the use of a tor proxy. + let mut nonce: [u8; 4] = [0; 4]; + sequoia_openpgp::crypto::random(&mut nonce[..])?; + let nonce: String = sequoia_openpgp::fmt::hex::encode(&nonce); + let url: String = format!("socks5h://anonymous:{}@{}", nonce, socks_url); + + http_client_builder = http_client_builder + .proxy(sequoia_net::reqwest::Proxy::all(url)?); + } + + let http_client = http_client_builder.build()?; // local function to check certificates for usability let collect_certs = |certs: Vec>, query: OnlineQuery, policy: &StandardPolicy| { @@ -152,8 +165,8 @@ async fn query_online_sources<'hc>(context: &HuskContext<'hc>, query: OnlineQuer } } }, - OnlineQuery::Fingerprint(_) => { - found = true; + OnlineQuery::Fingerprint(f) => { + found = cert.fingerprint() == f; } } @@ -193,9 +206,19 @@ async fn query_online_sources<'hc>(context: &HuskContext<'hc>, query: OnlineQuer let q = query.clone(); match query { OnlineQuery::Email(e) => { + match ks.search(e.as_str()).await { + Ok(certs) => { + partial_result = collect_certs(certs, q, &p); + } + Err(e) => { + log::debug!("Error: {}", e.to_string()); + } + } + /* if let Ok(certs) = ks.search(e.as_str()).await { partial_result = collect_certs(certs, q, &p); } + */ partial_result }, OnlineQuery::Fingerprint(f) => { @@ -934,7 +957,8 @@ jk6lh01Ej9QTGQ== introducers: Vec::new(), keyservers: Vec::new(), subject_replacement: "...".into(), - mail: MailContext::new() + mail: MailContext::new(), + use_tor_via: crate::config::TorConnectorType::NotUsed, }; // store the local trust root diff --git a/src/config.rs b/src/config.rs index 08b3615..47ea2d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ use toml; use log4rs; use crate::types::errors::HuskError; +use crate::types::defaults::CONNECTION; use crate::types::defaults::CONSOLE_LOGGING; use crate::types::defaults::UPDATER_PID_FILE_LOCATION; use crate::types::defaults::UPDATER_STATE_FILE_LOCATION; @@ -17,21 +18,55 @@ use crate::types::defaults::UPDATER_WAIT_MAX; use crate::types::defaults::PID_FILE_LOCATION; use crate::types::defaults::SUBJECT_REPLACEMENT; +#[derive(Deserialize, Debug, Clone)] +pub enum TorConnectorType { + NotUsed, + Errornous, + SocksProxy(String), + Arti, +} + +impl From> for TorConnectorType { + fn from(value: Option) -> Self { + match value { + Some(v) => { + if v.to_ascii_lowercase() == "arti" { + TorConnectorType::Arti + } else { + if v.starts_with("socks5://") { + TorConnectorType::SocksProxy(v[9..].to_string()) + } else { + TorConnectorType::Errornous + } + } + }, + None => TorConnectorType::NotUsed, + } + } +} + +#[derive(Deserialize, Debug, Clone)] +pub struct MilterConfig { + pub connection: String, + pub pid_file: Option, + pub keyservers: Option>, + pub subject_replacement: Option, + use_tor_via: Option, +} + #[derive(Deserialize, Debug, Clone)] pub struct UpdaterConfig { pub wait_min: Option, pub wait_max: Option, pub pid_file: Option, pub state_file: Option, + use_tor_via: Option, } #[derive(Deserialize, Debug, Clone)] pub struct HuskConfig { - pub connection: String, pub sequoia_home: String, - pub keyservers: Option>, - pub subject_replacement: Option, - pub pid_file: Option, + milter: Option, updater: Option, logging: Option, } @@ -77,28 +112,47 @@ impl HuskConfig { Ok(()) } - pub fn get_keyservers(&self) -> Vec { - if let Some(kss) = &self.keyservers { - kss.clone() + pub fn get_connection(&self) -> String { + if let Some(milter_config) = &self.milter { + milter_config.connection.clone() } else { - Vec::new() + CONNECTION.to_string() } } + pub fn get_milter_tor_usage(&self) -> TorConnectorType { + if let Some(milter_config) = &self.milter { + milter_config.use_tor_via.clone().into() + } else { + TorConnectorType::NotUsed + } + } + + pub fn get_keyservers(&self) -> Vec { + if let Some(milter_config) = &self.milter { + if let Some(kss) = &milter_config.keyservers { + return kss.clone(); + } + } + Vec::new() + } + pub fn get_subject_replacement(&self) -> String { - if let Some(replacement) = &self.subject_replacement { - replacement.clone() - } else { - SUBJECT_REPLACEMENT.to_string() + if let Some(milter_config) = &self.milter { + if let Some(replacement) = &milter_config.subject_replacement { + return replacement.clone(); + } } + SUBJECT_REPLACEMENT.to_string() } pub fn get_pid_file(&self) -> String { - if let Some(pid_file) = &self.pid_file { - pid_file.clone() - } else { - PID_FILE_LOCATION.to_string() + if let Some(milter_config) = &self.milter { + if let Some(pid_file) = &milter_config.pid_file { + return pid_file.clone(); + } } + PID_FILE_LOCATION.to_string() } pub fn get_updater_pid_file(&self) -> String { @@ -147,6 +201,14 @@ impl HuskConfig { UPDATER_WAIT_MAX } } + + pub fn get_updater_tor_usage(&self) -> TorConnectorType { + if let Some(updater_config) = &self.updater { + updater_config.use_tor_via.clone().into() + } else { + TorConnectorType::NotUsed + } + } } pub type HuskConfigContainer = Arc>; @@ -168,12 +230,9 @@ mod tests { #[test] fn subject_line() { let config = HuskConfig { - connection: "".into(), sequoia_home: "".into(), - keyservers: None, - subject_replacement: None, logging: None, - pid_file: None, + milter: None, updater: None, }; diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index fd8bec6..16cad98 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -33,7 +33,7 @@ impl Daemon { let config: HuskConfig = config_container.clone().into(); - let listener = TcpListener::bind(&config.connection) + let listener = TcpListener::bind(&config.get_connection()) .await .expect(" opening milter socket"); @@ -116,6 +116,7 @@ impl Daemon { // setup Context match HuskContext::new(&config) { Ok(mut context) => { + context.for_milter(&config); context.set_sender(sender.unwrap()); cx.data = Some(context); Status::Continue diff --git a/src/types/defaults.rs b/src/types/defaults.rs index 12e0446..1272c36 100644 --- a/src/types/defaults.rs +++ b/src/types/defaults.rs @@ -1,5 +1,8 @@ //! Defaults +/// Default host and port of the milter. +pub const CONNECTION: &str = "localhost:3000"; + pub const CONFIG_FILE_LOCATION: &str = "/etc/husk/config.toml"; /// If encrypting a mail, the subject is moved into the encrypted body diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index 8e13464..bee28d4 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -17,7 +17,7 @@ use sequoia_cert_store::CertStore; use sequoia_directories::Home; use sequoia_keystore::Key; -use crate::config::HuskConfig; +use crate::config::{HuskConfig, TorConnectorType}; use crate::common::crypto; use crate::types::mail_context::MailContext; @@ -32,6 +32,7 @@ pub struct HuskContext<'hc> { pub keyservers: Vec, pub subject_replacement: String, pub mail: MailContext<'hc>, + pub use_tor_via: TorConnectorType, } impl<'hc> HuskContext<'hc> { @@ -82,6 +83,7 @@ impl<'hc> HuskContext<'hc> { keyservers: config.get_keyservers(), subject_replacement: config.get_subject_replacement(), mail: MailContext::new(), + use_tor_via: TorConnectorType::NotUsed, }) } @@ -109,4 +111,12 @@ impl<'hc> HuskContext<'hc> { self.mail.add_recipient(rcpt, certs) } + pub fn for_milter(&mut self, config: &HuskConfig) { + self.use_tor_via = config.get_milter_tor_usage(); + } + + pub fn for_updater(&mut self, config: &HuskConfig) { + self.use_tor_via = config.get_updater_tor_usage(); + } + } diff --git a/src/updater/mod.rs b/src/updater/mod.rs index db49460..a8164a1 100644 --- a/src/updater/mod.rs +++ b/src/updater/mod.rs @@ -41,15 +41,16 @@ impl Updater { let mut updater_state = UpdaterState::thaw(state_path) .await .map_err(|e| { stay = false; e })?; - let context = &HuskContext::without_signkey(&c)?; - updater_state.refresh(context); + let mut context = HuskContext::without_signkey(&c)?; + context.for_updater(&c); + updater_state.refresh(&context); if let Some(fpr) = updater_state.next() { log::debug!("try to update {}", fpr.to_hex()); let stats = MergePublicCollectStats::new(); - let certs = crypto::lookup_fingerprint(context, fpr.to_hex().as_str()).await?; + let certs = crypto::lookup_fingerprint(&context, fpr.to_hex().as_str()).await?; if !certs.is_empty() { for cert in certs { let cert = Arc::new(LazyCert::from_cert(cert)); From f9df6a5daac51f7de015d22bb8e36b1a92121c13 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 12 Aug 2026 11:35:58 +0200 Subject: [PATCH 6/8] Add lookup for email addresses in the updater - Only considering the fingerprint of a certificate for online querying of updates leaves out WKD and DANE as sources. Add the email addresses of a certificate to the update process. --- config/config.toml | 4 ++-- src/common/crypto.rs | 12 ++++++++++-- src/config.rs | 6 +++--- src/updater/mod.rs | 40 ++++++++++++++++++++++++++++++++++++---- src/updater/state.rs | 12 +++++++++--- 5 files changed, 60 insertions(+), 14 deletions(-) diff --git a/config/config.toml b/config/config.toml index 07c39c1..34d96b9 100644 --- a/config/config.toml +++ b/config/config.toml @@ -46,8 +46,8 @@ keyservers = [ "hkp://example.com" ] # # wait_min and wait_max specify the range for that randomness. # defaults: -# min_wait = 5 -# max_wait = 3600 +# wait_min = 5 +# wait_max = 3600 wait_min = 1 wait_max = 2 diff --git a/src/common/crypto.rs b/src/common/crypto.rs index 2cd5315..7b353c5 100644 --- a/src/common/crypto.rs +++ b/src/common/crypto.rs @@ -22,6 +22,7 @@ use sequoia_openpgp::packet::prelude::SignatureBuilder; use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse}; use sequoia_openpgp::policy::StandardPolicy; use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, Signer, LiteralWriter}; +use sequoia_openpgp::serialize::stream::padding::Padder; use sequoia_openpgp::{Fingerprint, Cert, Packet}; use sequoia_openpgp::types::SignatureType; use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate}; @@ -210,8 +211,8 @@ async fn query_online_sources<'hc>(context: &HuskContext<'hc>, query: OnlineQuer Ok(certs) => { partial_result = collect_certs(certs, q, &p); } - Err(e) => { - log::debug!("Error: {}", e.to_string()); + Err(err) => { + log::debug!("Error: {} {}", e, err.to_string()); } } /* @@ -482,9 +483,14 @@ pub fn is_encrypted(body: &Bytes) -> bool { pub fn encrypt(context: &HuskContext<'_>, body: &[u8], recipients: &Vec) -> anyhow::Result { + log::debug!("encrypt for {}", recipients.iter() + .map(|r| r.email.clone()) + .collect::>() + .join(", ")); let mut keys = Vec::new(); for rcpt in recipients { + log::debug!("Check recipient {}", rcpt.email); if rcpt.can_encrypt() { for cert in &rcpt.certs { cert.with_policy(&context.policy, None)? @@ -504,6 +510,7 @@ pub fn encrypt(context: &HuskContext<'_>, body: &[u8], recipients: &Vec, body: &[u8], recipients: &Vec> for TorConnectorType { fn from(value: Option) -> Self { match value { Some(v) => { - if v.to_ascii_lowercase() == "arti" { + if v.eq_ignore_ascii_case("arti") { TorConnectorType::Arti } else { - if v.starts_with("socks5://") { - TorConnectorType::SocksProxy(v[9..].to_string()) + if let Some(url) = v.strip_prefix("socks5://") { + TorConnectorType::SocksProxy(url.to_string()) } else { TorConnectorType::Errornous } diff --git a/src/updater/mod.rs b/src/updater/mod.rs index a8164a1..c8ab305 100644 --- a/src/updater/mod.rs +++ b/src/updater/mod.rs @@ -4,6 +4,7 @@ use std::path::Path; use std::sync::Arc; use sequoia_cert_store::LazyCert; +use sequoia_cert_store::Store; use sequoia_cert_store::StoreUpdate; use sequoia_cert_store::store::MergePublicCollectStats; use tokio::time::sleep; @@ -40,7 +41,7 @@ impl Updater { let mut updater_state = UpdaterState::thaw(state_path) .await - .map_err(|e| { stay = false; e })?; + .inspect_err(|_| { stay = false; })?; let mut context = HuskContext::without_signkey(&c)?; context.for_updater(&c); updater_state.refresh(&context); @@ -48,9 +49,40 @@ impl Updater { if let Some(fpr) = updater_state.next() { log::debug!("try to update {}", fpr.to_hex()); + // get local cert from cert store to get the user emails + let mut emails = Vec::new(); + if let Ok(local_certs) = context.cert_store.lookup_by_cert(&fpr.clone().into()) { + local_certs.iter() + .map(|c| c.with_policy(&context.policy, None)) + .filter_map(|c| c.ok()) + .filter(|c| + c.keys() + .supported() + .alive() + .revoked(false) + .for_transport_encryption() + .count() > 0) + .for_each(|c| + c.userids() + .map(|u| u.userid().email()) + .filter_map(|u| if let Ok(e) = u { e } else { None }) + .for_each(|e| emails.push(e.to_string())) + ); + + } + log::debug!("with emails: {:?}", emails); + let stats = MergePublicCollectStats::new(); - let certs = crypto::lookup_fingerprint(&context, fpr.to_hex().as_str()).await?; + let mut certs = crypto::lookup_fingerprint(&context, fpr.to_hex().as_str()).await?; + for email in emails { + crypto::lookup_email(&context, email.as_str()).await?.iter() + .for_each(|c| { + if c.fingerprint() == fpr { + certs.push(c.clone()); + } + }); + } if !certs.is_empty() { for cert in certs { let cert = Arc::new(LazyCert::from_cert(cert)); @@ -66,7 +98,7 @@ impl Updater { } UpdaterState::freeze(state_path, &updater_state) .await - .map_err(|e| { stay = false; e })?; + .inspect_err(|_| { stay = false; })?; } else { // finished update round let (processed, updated) = updater_state.summarize(); @@ -76,7 +108,7 @@ impl Updater { // remove state from disk UpdaterState::remove(state_path) .await - .map_err(|e| { stay = false; e })?; + .inspect_err(|_| { stay = false; })?; } } diff --git a/src/updater/state.rs b/src/updater/state.rs index 9b9f2e5..209d430 100644 --- a/src/updater/state.rs +++ b/src/updater/state.rs @@ -98,7 +98,7 @@ impl CertState { pub fn new(fpr: Fingerprint) -> Self { Self { - fpr: fpr, + fpr, processed: false, updated: false, retry_count: 0, @@ -171,7 +171,7 @@ impl TryFrom> for UpdaterState { impl fmt::Display for UpdaterState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "{}", self.started.format(DATETIME_PATTERN))?; - for (_, cert) in &self.certs { + for cert in self.certs.values() { write!(f, "{}", cert)?; } @@ -179,6 +179,12 @@ impl fmt::Display for UpdaterState { } } +impl Default for UpdaterState { + fn default() -> Self { + Self::new() + } +} + impl UpdaterState { pub fn new() -> Self { @@ -313,7 +319,7 @@ impl UpdaterState { pub fn summarize(&self) -> (usize, usize) { let mut processed = 0; let mut updated = 0; - for (_, cert) in &self.certs { + for cert in self.certs.values() { if cert.processed { processed += 1; } if cert.updated { updated += 1; } } From add79d179653e6a1f039e505867c429790d1a66a Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Wed, 12 Aug 2026 11:38:27 +0200 Subject: [PATCH 7/8] Adjust tests - Add Tor to the docker image - Increase sleeping time in the mailsplit test --- docker/MTAContainerFile | 3 ++- e2e-tests/tests/70_mail_split.sh | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docker/MTAContainerFile b/docker/MTAContainerFile index cb4056e..d7454bd 100644 --- a/docker/MTAContainerFile +++ b/docker/MTAContainerFile @@ -61,7 +61,8 @@ RUN apt-get update -yqq && \ sq \ bind9-host \ inotify-tools \ - mailutils + mailutils \ + tor RUN adduser --shell /bin/bash --disabled-password --gecos "" hockeypuck diff --git a/e2e-tests/tests/70_mail_split.sh b/e2e-tests/tests/70_mail_split.sh index f1efb35..b59a24f 100644 --- a/e2e-tests/tests/70_mail_split.sh +++ b/e2e-tests/tests/70_mail_split.sh @@ -12,7 +12,7 @@ authenticate $KARL_FPR create_unix_user laura echo -e "To: karl@example.com, Laura \nSubject: Testmail\n\nTestmail." | sendmail -t -sleep 1 +sleep 2 R=1 if [ $(expect_mail karl encrypted) == 0 ] ; then From e8f4b163143ae44ddc13d943b0875078d2dbabfc Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Thu, 13 Aug 2026 11:54:29 +0200 Subject: [PATCH 8/8] Set mode 0o600 on updater state file --- src/updater/state.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/updater/state.rs b/src/updater/state.rs index 209d430..36ed497 100644 --- a/src/updater/state.rs +++ b/src/updater/state.rs @@ -6,6 +6,9 @@ use std::fs::OpenOptions; use std::io::{Read, Write}; use std::path::Path; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; + use chrono::{Local, NaiveDateTime}; use rand::prelude::*; use sequoia_openpgp::Fingerprint; @@ -105,6 +108,7 @@ impl CertState { } } + /// Check if this certificate is already processed. pub fn need_processing(&self) -> bool { !(self.processed || (self.retry_count >= MAX_RETRY)) } @@ -217,7 +221,7 @@ impl UpdaterState { } /// Increment the retry count of the certificate (identified by its - /// fingerprint. + /// fingerprint). pub fn incr_retry_count(&mut self, fpr: &Fingerprint) { if let Some(entry) = self.certs.get_mut(fpr) { entry.incr_retry_count(); @@ -292,12 +296,14 @@ impl UpdaterState { pub async fn freeze(file: &Path, updater_state: &UpdaterState) -> Result<(), UpdaterError> { // freeze comes after thaw, so we assume that the parent directory // exists. - // XXX: on unix set mode 0o666 - let mut handle = OpenOptions::new() - .write(true) + let mut options = OpenOptions::new(); + options.write(true) .create(true) - .truncate(true) - .open(file) + .truncate(true); + #[cfg(unix)] + options.mode(0o600); + + let mut handle = options.open(file) .map_err(|e| UpdaterError::IoError(e.to_string()))?; handle.write_all(updater_state.to_string().as_bytes())