From bf53af50ecf785b1243cc7e705563da81cbe7543 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Mon, 4 May 2026 22:55:02 +0200 Subject: [PATCH] Add introducer add command - Create a command to add an introducer (with domains). - Add a module for escaping regular expressions used in certifications - Add some helper functions. --- Cargo.lock | 3 +- Cargo.toml | 1 + src/commands.rs | 10 +++ src/commands/introducer.rs | 2 +- src/commands/introducer/add.rs | 30 +++++++- src/common/escape.rs | 35 +++++++++- src/crypto.rs | 1 - src/mail.rs | 16 +++++ src/types/introducer.rs | 123 ++++++++++++++++++++++++++++++++- 9 files changed, 213 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37f75b9..4680322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -987,6 +987,7 @@ checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" name = "husk" version = "0.1.0" dependencies = [ + "aho-corasick", "anyhow", "bytes", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8d717f7..ab3f0f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ categories = ["cryptography", "email"] rust-version = "1.85" [dependencies] +aho-corasick = "1.1.4" anyhow = "1.0.100" bytes = "1.11.1" clap = { version = "4", features = ["derive"] } diff --git a/src/commands.rs b/src/commands.rs index 26b4f54..491fb51 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,5 +1,7 @@ use anyhow::Result; +use sequoia_openpgp::Fingerprint; + use crate::commands; use crate::cli::cli_args::{CliArgs, HuskSubcommands}; use crate::config::HuskConfigContainer; @@ -8,6 +10,14 @@ pub mod daemon; pub mod introducer; pub mod local; +#[derive(thiserror::Error, Debug)] +pub enum CommandError { + #[error("Certificate not found: {0}")] + CertNotFound(Fingerprint), + #[error("List of domains is empty")] + DomainListEmpty, +} + pub async fn dispatch(cli: CliArgs, config: HuskConfigContainer) -> Result<()> { match cli.subcommand { diff --git a/src/commands/introducer.rs b/src/commands/introducer.rs index 9b76179..26e9889 100644 --- a/src/commands/introducer.rs +++ b/src/commands/introducer.rs @@ -11,7 +11,7 @@ pub mod remove; pub async fn dispatch(cmd: IntroducerCommand, config: HuskConfigContainer) -> Result<()> { match cmd.subcommand { IntroducerSubcommand::Add(add_cmd) => { - add::dispatch(add_cmd, &config).await?; + add::dispatch(add_cmd, config).await?; }, IntroducerSubcommand::Remove(rm_cmd) => { remove::dispatch(rm_cmd, &config).await?; diff --git a/src/commands/introducer/add.rs b/src/commands/introducer/add.rs index 3ac238c..2ce7dd3 100644 --- a/src/commands/introducer/add.rs +++ b/src/commands/introducer/add.rs @@ -1,12 +1,40 @@ use anyhow::Result; +use sequoia_cert_store::Store; +use sequoia_openpgp::cert::ValidCert; +use sequoia_openpgp::{Fingerprint, KeyHandle}; use crate::cli::cli_args::IntroducerAddCommand; +use crate::commands::CommandError; use crate::config::HuskConfigContainer; +use crate::types::husk_context::HuskContext; +use crate::types::introducer::Introducer; -pub async fn dispatch(cmd: IntroducerAddCommand, _config: &HuskConfigContainer) -> Result<()> { +pub async fn dispatch(cmd: IntroducerAddCommand, config: HuskConfigContainer) -> Result<()> { println!("add"); println!(" {:?}", cmd.cert); println!(" {:?}", cmd.domains); + + let context = HuskContext::new(&config.into())?; + let policy = &context.policy; + let cert_store = &context.cert_store; + + // Get the certificate + let fpr = Fingerprint::from_hex(&cmd.cert)?; + let certs = cert_store.lookup_by_cert(&KeyHandle::try_from(&fpr)?) + .map_err(|_| CommandError::CertNotFound(fpr.clone()))?; + + let certs: Vec = certs.iter() + .filter_map(|c| + c.with_policy(policy, None).ok() + ) + .collect(); + + if let Some(cert) = certs.first() { + Introducer::create(&context, cert, cmd.domains)?; + } else { + return Err(CommandError::CertNotFound(fpr).into()); + } + Ok(()) } diff --git a/src/common/escape.rs b/src/common/escape.rs index 151b8f9..8a76ba4 100644 --- a/src/common/escape.rs +++ b/src/common/escape.rs @@ -4,9 +4,9 @@ use aho_corasick::AhoCorasick; use anyhow::Result; const REGEX_CONTROL_CHARS: &[&str] - = &[ ".", "|", "(", ")", "*", "+", "?", "^", "$", "[", "]", "\\" ]; + = &[ "\\", ".", "|", "(", ")", "*", "+", "?", "^", "$", "[", "]" ]; const REGEX_ESCAPED_CHARS: &[&str] - = &[ "\\.", "\\|", "\\(", "\\)", "\\*", "\\+", "\\?", "\\^", "\\$", "\\[", "\\]", "\\\\" ]; + = &[ "\\\\", "\\.", "\\|", "\\(", "\\)", "\\*", "\\+", "\\?", "\\^", "\\$", "\\[", "\\]" ]; pub fn regex_ctrl_escape(value: &str) -> Result { let r = AhoCorasick::new(REGEX_CONTROL_CHARS)?; @@ -18,6 +18,24 @@ pub fn regex_ctrl_unescape(value: &str) -> Result { Ok(r.replace_all(value, REGEX_CONTROL_CHARS)) } +pub fn regex_domain_escape(value: &str) -> Result { + regex_ctrl_escape(format!("<[^>]+[@.]{}>$", value).as_str()) +} + +pub fn regex_domain_unescape(value: &str) -> Result { + let value = regex_ctrl_unescape(value)?; + + if let Some(stripped) = value.strip_prefix("<[^>]+[@.]") { + if let Some(inner) = stripped.strip_suffix(">$") { + Ok(inner.into()) + } else { + Ok(value) + } + } else { + Ok(value) + } +} + #[cfg(test)] pub mod tests { @@ -34,4 +52,17 @@ pub mod tests { assert_eq!(original, unescaped); Ok(()) } + + #[test] + fn domain_escape_test() -> Result<()> { + + let domain = "example.com"; + let escaped_domain = regex_domain_escape(domain)?; + let unescaped_domain = regex_domain_unescape(&escaped_domain)?; + + assert_eq!("<\\[\\^>\\]\\+\\[@\\.\\]example\\.com>\\$", escaped_domain); + assert_eq!(unescaped_domain, domain); + + Ok(()) + } } diff --git a/src/crypto.rs b/src/crypto.rs index 120f964..204fccc 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -342,7 +342,6 @@ pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec Option { else { None } } else { None } } + +/// Checks if the parameter has a valid email format. +pub fn is_email(email: &str) -> bool { + let uid = UserID::from(email); + + if let Ok(Some(e)) = uid.email() { + e == email + } else { + false + } +} + +/// Checks if the parameter has a valid domain format. +pub fn is_domain(domain: &str) -> bool { + is_email(format!("test@{}", domain).as_str()) +} diff --git a/src/types/introducer.rs b/src/types/introducer.rs index 8ede07c..71c3559 100644 --- a/src/types/introducer.rs +++ b/src/types/introducer.rs @@ -4,10 +4,38 @@ // introducers // +use std::sync::Arc; +use std::collections::HashSet; + +use anyhow::Result; + +use sequoia_cert_store::StoreUpdate; +use sequoia_cert_store::store::MergePublicCollectStats; +use sequoia_openpgp::packet::prelude::SignatureBuilder; use sequoia_wot as wot; use wot::{CertSynopsis, Certification}; -use sequoia_openpgp::policy::StandardPolicy; +use sequoia_openpgp::{cert::ValidCert, policy::StandardPolicy}; use sequoia_openpgp::Cert; +use sequoia_openpgp::Fingerprint; +use sequoia_openpgp::types::SignatureType; + +use crate::mail; +use crate::types::husk_context::HuskContext; +use crate::common::escape; + +#[derive(thiserror::Error, Debug)] +pub enum IntroducerError { + #[error("Unsuitable certificate")] + UnsuitableCertificate, + #[error("Unlimited introducer")] + UnlimitedIntroducer, + #[error("Invalid domain name: '{0}'")] + InvalidDomainName(String), + #[error("Certificate {0} contains no usable user id")] + NoUserIDs(Fingerprint), + #[error("Local trust root is damanged")] + RootDamaged, +} #[derive(Debug, Clone)] pub struct Introducer { @@ -54,6 +82,97 @@ impl Introducer { result } + + pub fn create(context: &HuskContext, cert: &ValidCert, domains: Vec) + -> Result<()> { + + // Check if the local trust root certificate should be made an + // introducer, which would be an error. + if cert.fingerprint() == context.local_trust_root.fingerprint() { + return Err(IntroducerError::UnsuitableCertificate.into()); + } + + let policy = &context.policy; + let root_signing_key = match context.local_trust_root.with_policy(policy, None)? + .keys() + .supported() + .alive() + .revoked(false) + .for_certification() + .next() { + Some(k) => k.key(), + None => { + return Err(IntroducerError::RootDamaged.into()); + } + }; + let mut root_signer = root_signing_key + .clone() + .parts_into_secret()? + .into_keypair()?; + + + // Check if domain list is not empty. + // An empty list would create an unlimited introducer. + if domains.is_empty() { + return Err(IntroducerError::UnlimitedIntroducer.into()); + } + + // Create the SignatureBuilder with a default preconfiguration. + let mut builder = + SignatureBuilder::new(SignatureType::GenericCertification) + .set_signature_creation_time(std::time::SystemTime::now())? + .set_trust_signature(1, 120)? + .set_exportable_certification(false)? + .set_revocable(true)?; + // set no expiration date + + // Add domains + for domain in domains { + if !mail::is_domain(domain.as_str()) { + return Err(IntroducerError::InvalidDomainName(domain).into()); + } + + builder = builder.add_regular_expression(format!("<[^>]+[@.]{}>$", + escape::regex_ctrl_escape(domain.as_str())? + ))?; + } + + // For a certification, user ids are needed. Build a list of + // valid (non self revoked) user ids. + let user_ids: Vec<_> = cert.userids() + .filter(|u| { + u.self_revocations().count() == 0 + }) + .collect(); + + if user_ids.is_empty() { + return Err(IntroducerError::NoUserIDs(cert.fingerprint()).into()); + } + + // now certify + let signatures: Vec<_> = user_ids.into_iter() + .filter_map(|u| { + let b = builder.clone(); + + b.sign_userid_binding( + &mut root_signer, + cert.primary_key().key(), + u.userid()).ok() + }) + .collect(); + let (new_cert, _) = cert.cert().clone().insert_packets(signatures)?; + + // and (re)import + let new_cert = Arc::new(new_cert.into()); + let stats = MergePublicCollectStats::new(); + let cert_store = &context.cert_store; + cert_store.update_by(new_cert, &stats)?; + + log::debug!("{:?}", stats); + + // XXX: do something with the created stats + Ok(()) + } } use std::fmt::{Display, Formatter}; @@ -65,7 +184,7 @@ impl Display for Introducer { .collect::>() .join(", "); writeln!(f, "Introducer: {}", self.cert.fingerprint())?; - write!(f, " UserIDs: {}", uids)?; + writeln!(f, " UserIDs: {}", uids)?; Ok(()) } }