Merge branch 'malte/polish' into 'main'

Polish the code

See merge request husk-project/husk-milter!10
This commit is contained in:
Malte Meiboom
2026-07-24 11:54:48 +00:00
15 changed files with 233 additions and 51 deletions
-1
View File
@@ -1,7 +1,6 @@
//! build
use anyhow::Result;
use anyhow;
use clap::CommandFactory;
use clap::ValueEnum;
use clap_complete::Shell;
+2
View File
@@ -29,6 +29,8 @@ pub enum CommandError {
CertRevoked(Fingerprint),
#[error("No signing key available")]
SigningKeyNotAvailable,
#[error("UserID contains ANSI control characters")]
ANSIControlCharacters,
}
pub async fn dispatch(cli: CliArgs, config: HuskConfigContainer) -> Result<()> {
+3 -2
View File
@@ -20,11 +20,12 @@ pub async fn dispatch(cmd: LocalsCommand, config: HuskConfigContainer) -> Result
LocalsSubcommand::List => {
let context = HuskContext::new(&config.into())?;
println!("list locals");
// XXX: sort the output
for local in crypto::get_locals(
&context.cert_store,
&context.policy,
context.local_trust_root.fingerprint()) {
context.local_trust_root.fingerprint(),
context.signing_key) {
println!("{}", local);
}
+14 -5
View File
@@ -7,6 +7,7 @@ use sequoia_openpgp::cert::CertBuilder;
use sequoia_directories::Home;
use crate::cli::cli_args::SignkeyCreateCommand;
use crate::commands::CommandError;
use crate::common::crypto;
use crate::config::{HuskConfig, HuskConfigContainer};
use crate::types::husk_context::HuskContext;
@@ -18,17 +19,25 @@ pub async fn dispatch(cmd: SignkeyCreateCommand, config: HuskConfigContainer)
let context = HuskContext::new(&config)?;
let sequoia_home = Home::new(PathBuf::from(&config.sequoia_home))?;
// XXX: cleanup the userid before using
if cmd.userid.chars().any(char::is_control) {
return Err(CommandError::ANSIControlCharacters.into());
}
let (cert, _) = CertBuilder::new()
.add_signing_subkey()
.add_userid(cmd.userid)
.generate()?;
if crypto::import_key(sequoia_home, &cert) {
let vc = cert.with_policy(&context.policy, None)?;
crypto::set_signing_key(&context, &vc)?;
match crypto::import_key(sequoia_home, &cert) {
Ok(_) => {
let vc = cert.with_policy(&context.policy, None)?;
crypto::set_signing_key(&context, &vc)?;
println!("New signing key: {}", vc.fingerprint());
println!("New signing key: {}", vc.fingerprint());
},
Err(e) => {
println!("Error: {}", e);
}
}
Ok(())
+63 -14
View File
@@ -113,6 +113,7 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
// local function to check certificates for usability
let collect_certs = |certs: Vec<Result<Cert, anyhow::Error>>, email: String, policy: &StandardPolicy| {
let mut result = Vec::new();
let mut seen: HashSet<Fingerprint> = HashSet::new();
for cert in certs.into_iter().flatten() {
if let Ok(vc) = cert.with_policy(policy, None) {
@@ -136,9 +137,11 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
.revoked(false)
.for_transport_encryption()
.for_each(|_| {
// XXX: cert gets returned for each transport key
// once would be enough
result.push(cert.clone());
let fpr = cert.fingerprint();
if ! seen.contains(&fpr) {
result.push(cert.clone());
seen.insert(fpr);
}
});
}
}
@@ -287,26 +290,48 @@ pub fn get_introducers<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy,
if depth > Depth::Limit(0) {
if let Some(p) = target {
if let Some(c) = p.certifications().last() {
result.push( Introducer {
cert: p.target().clone(),
certification: c.clone(),
});
if let Some(r) = c.regular_expressions() {
if ! r.matches_everything() {
result.push( Introducer {
cert: p.target().clone(),
certification: c.clone(),
});
}
}
}
}
}
});
result
// Certificates may have multiple authenticating pathes. Dedup.
let mut seen = HashSet::new();
result.into_iter()
.filter(|i|
if seen.contains(i) { false }
else {
seen.insert(i.clone());
true
}
)
.collect()
}
/// Returns all local certificates available in cert_store.
pub fn get_locals<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, local_trust_root: Fingerprint) -> Vec<CertSynopsis> {
pub fn get_locals<'c>(cert_store: &CertStore<'c>,
policy: &StandardPolicy,
local_trust_root: Fingerprint,
signing_key: Option<Key>) -> Vec<CertSynopsis> {
let mut set = HashMap::new();
let mut introducers = HashSet::new();
let mut targets = Vec::new();
let local_root_fpr = local_trust_root.clone();
let signing_fpr = if let Some(signing_key) = signing_key {
Some(signing_key.fingerprint().clone())
} else { None };
let trust_roots = vec![(local_trust_root, wot::FULLY_TRUSTED)];
let wot_store = wot::store::CertStore::from_store(cert_store, policy, None);
let n = wot::NetworkBuilder::rooted(&wot_store, &*trust_roots)
@@ -335,6 +360,12 @@ pub fn get_locals<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, local
for p in targets {
let cert_synopsis = p.target().clone();
if let Some(ref s) = signing_fpr {
if &cert_synopsis.fingerprint() == s {
continue;
}
}
if let Vacant(_) = set.entry(cert_synopsis.fingerprint()) {
// Only consider non introduced certificates.
if !p.certifications().any(|c|
@@ -444,7 +475,13 @@ pub fn authenticate(context: &HuskContext, cert: &ValidCert, role: Role, activat
/// Make `cert` the new signing key.
pub fn set_signing_key(context: &HuskContext, cert: &ValidCert) -> anyhow::Result<()> {
internal_authenticate(context, cert, Role::SigningKey, true, None, Some((HUSK_SIG_NOTATION.to_string(), "placeholder".to_string())))
internal_authenticate(
context,
cert,
Role::SigningKey,
true,
None,
Some((HUSK_SIG_NOTATION.to_string(), "placeholder".to_string())))
}
/// Internal authentication method.
@@ -521,6 +558,7 @@ fn internal_authenticate(context: &HuskContext, cert: &ValidCert, role: Role, ac
false)?;
}
// XXX Check if there are better ways then to use user ids.
// For a certification, user ids are needed. Build a list of
// valid (non self revoked) user ids.
let user_ids: Vec<_> = cert.userids()
@@ -588,7 +626,13 @@ pub fn can_sign(cert: &ValidCert) -> bool {
.count() > 0
}
// Get the signing key:
// - get all keys from the keystore.
// - get the corresponding certificates.
// - check the user ids of those certificates for characteristic notations.
// We start with keys as there will be way more certificates than keys, thus
// reducing the number of certificates we check.
// XXX find a more efficient way.
/// Get Husks signing key
pub fn get_signing_key(sequoia_home: Home, cert_store: &CertStore<'_>, policy: &StandardPolicy<'_>, local_trust_root: &Cert) -> anyhow::Result<Option<Key>> {
@@ -640,7 +684,8 @@ pub fn contains_signing_key(vc: &ValidCert, local_root_fpr: &Fingerprint) -> Opt
}
/// Get all keys from all backends
// XXX: Maybe the softkeys backend is sufficient
// XXX: Maybe the softkeys backend is sufficient. As we are using a custom
// SEQUOIA_HOME, the gpg-agent backend is disabled.
pub fn get_all_keys(sequoia_home: Home) -> Vec<Key> {
let mut result = Vec::new();
@@ -671,7 +716,7 @@ pub fn get_all_keys(sequoia_home: Home) -> Vec<Key> {
result
}
pub fn import_key(sequoia_home: Home, cert: &Cert) -> bool {
pub fn import_key(sequoia_home: Home, cert: &Cert) -> anyhow::Result<()> {
let mut imported = false;
std::thread::scope(|s| {
s.spawn(|| {
@@ -694,7 +739,11 @@ pub fn import_key(sequoia_home: Home, cert: &Cert) -> bool {
});
});
imported
if imported {
Ok(())
} else {
Err(HuskError::KeyImportFailed.into())
}
}
#[cfg(test)]
+2 -2
View File
@@ -59,8 +59,8 @@ impl ProcessDescr {
let mut descr_file = File::create(&location_path)?;
let content = ProcessDescr {
pid: pid as usize,
name: name,
started: started,
name,
started,
file_location: location,
};
descr_file.write_all(toml::to_string(&content)?.as_bytes())?;
+14 -9
View File
@@ -99,7 +99,7 @@ impl Daemon {
let mut sender: Option<String> = None;
for arg in args {
if let Some(s) = mail::to_email(&arg) {
if let Ok(s) = mail::to_email(&arg) {
sender = Some(s);
break;
}
@@ -135,7 +135,7 @@ impl Daemon {
let mut rcpt: Option<String> = None;
for arg in args {
if let Some(s) = mail::to_email(&arg) {
if let Ok(s) = mail::to_email(&arg) {
rcpt = Some(s);
break;
}
@@ -171,15 +171,20 @@ impl Daemon {
}
}
async fn handle_eoh(_cx: &mut Context<HuskContext<'_>>) -> Status {
async fn handle_eoh(cx: &mut Context<HuskContext<'_>>) -> Status {
log::debug!("EOH");
// XXX: check for early accepting
// if there are no recipients with authenticated certificates
// tell the MTA that this milter is done and the mail can be further
// processed.
Status::Continue
if let Some(ref mut context) = cx.data {
if context.mail.encryptable() {
Status::Continue
} else {
// We cannot encrypt the mail, signal the MTA that we
// are done.
Status::Accept
}
} else {
Status::Tempfail
}
}
async fn handle_body(cx: &mut Context<HuskContext<'_>>, chunk: Bytes) -> Status {
+14 -12
View File
@@ -19,19 +19,20 @@ use crate::types::errors::HuskError;
use crate::types::recipient::Recipient;
/// Parse the user id fields from a string.
pub fn to_userid(rcpt: &CString) -> Option<UserID> {
match rcpt.to_str() {
Ok(s) => Some(UserID::from(s)),
_ => None
}
pub fn to_userid(rcpt: &CString) -> Result<UserID> {
let rcpt = rcpt.to_str()?;
Ok(UserID::from(rcpt))
}
/// Get the email from the string representation of a user id.
pub fn to_email(rcpt: &CString) -> Option<String> {
if let Some(u) = to_userid(rcpt) {
if let Ok(Some(e)) = u.email() { Some(e.to_string()) }
else { None }
} else { None }
pub fn to_email(rcpt: &CString) -> Result<String> {
let u = to_userid(rcpt)?;
if let Some(e) = u.email()? {
Ok(e.to_string())
} else {
Err(HuskError::CannotConvertToEmail(u.to_string()).into())
}
}
/// Checks if the parameter has a valid email format.
@@ -78,7 +79,7 @@ pub async fn smtp_inject(from: String, to: &Vec<Recipient<'_>>, header: CHeader,
// convert addresses
let from_email = CString::new(from.clone())?;
let from_email = to_email(&from_email).unwrap(); // XXX
let from_email = to_email(&from_email)?;
let from_email: Address = from_email.try_into()?;
let mut rcpts: Vec<Address> = Vec::new();
@@ -87,7 +88,8 @@ pub async fn smtp_inject(from: String, to: &Vec<Recipient<'_>>, header: CHeader,
rcpts.push(rcpt.try_into()?);
}
let my_id = ClientId::Domain(defaults::HELO_REINJECT_ID.to_owned()); // TODO: add secret token?
// XXX: add secret token?
let my_id = ClientId::Domain(defaults::HELO_REINJECT_ID.to_owned());
joinset.spawn(async move {
// XXX
+6
View File
@@ -118,6 +118,12 @@ impl fmt::Display for CHeader {
}
}
impl Default for CHeader {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
pub mod tests {
use std::ffi::CString;
+6
View File
@@ -26,4 +26,10 @@ pub enum HuskError {
NoUserIDs(String),
#[error("Failing to send mail: {0}")]
SmtpInjectionFailure(String),
#[error("Cannot use certificate as key")]
CannotUseCertificateAsKey,
#[error("Key import failed")]
KeyImportFailed,
#[error("Cannot convert to email: {0}")]
CannotConvertToEmail(String),
}
+85 -3
View File
@@ -1,5 +1,9 @@
//! introducers
use std::hash::Hash;
use std::hash::Hasher;
use std::fmt::{Display, Formatter};
use anyhow::Result;
use sequoia_wot as wot;
@@ -105,13 +109,14 @@ impl Introducer {
}
}
use std::fmt::{Display, Formatter};
impl Display for Introducer {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
// XXX: This is unsafe as ANSI control chars are not handled.
let uids = self.cert.self_signed_userids()
.map(|u| String::from_utf8_lossy(u.userid().value()).to_string())
.map(|u| u.chars()
.map(|c| if c.is_control() { '_' } else { c })
.collect::<String>())
.collect::<Vec<String>>()
.join(", ");
@@ -119,7 +124,7 @@ impl Display for Introducer {
writeln!(f, " UserIDs: {}", uids)?;
if let Some(regex_set) = self.certification.regular_expressions() {
for regex in regex_set.as_bytes().into_iter() {
for regex in regex_set.as_bytes().iter() {
let regex = String::from_utf8_lossy(regex);
if let Ok(domain) = escape::regex_domain_unescape(regex.to_string().as_str()) {
writeln!(f," Domain: {}", domain)?;
@@ -132,3 +137,80 @@ impl Display for Introducer {
}
}
impl PartialEq for Introducer {
fn eq(&self, other: &Self) -> bool {
self.cert.fingerprint() == other.cert.fingerprint()
&& self.certification.regular_expressions() == other.certification.regular_expressions()
}
}
impl Eq for Introducer { }
impl Hash for Introducer {
fn hash<H: Hasher>(&self, state: &mut H) {
self.cert.fingerprint().hash(state);
if let Some(r) = self.certification.regular_expressions() {
r.as_bytes().hash(state);
}
}
}
#[cfg(test)]
pub mod tests {
use std::iter;
use std::time::SystemTime;
use std::hash::Hash;
use std::hash::Hasher;
use std::hash::DefaultHasher;
use sequoia_openpgp::Fingerprint;
use sequoia_wot::{CertSynopsis, Certification, RevocationStatus, UserIDSynopsis};
use super::Introducer;
#[test]
// XXX improve, add RegExes
fn introducer_equality() {
let alice_fpr: Fingerprint = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
.parse().expect("valid fingerprint");
let alice_uid = UserIDSynopsis::from("<alice@example.com>");
let alice = CertSynopsis::new(alice_fpr.clone(),
None, RevocationStatus::NotAsFarAsWeKnow,
iter::once(alice_uid.clone()));
let bob_fpr: Fingerprint = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
.parse().expect("valid fingerprint");
let bob_uid = UserIDSynopsis::from("<bob@example.com>");
let bob = CertSynopsis::new(bob_fpr,
None, RevocationStatus::NotAsFarAsWeKnow,
iter::once(bob_uid));
let alice_intro = Introducer {
cert: CertSynopsis::new(alice_fpr.clone(),
None, RevocationStatus::NotAsFarAsWeKnow,
iter::once(alice_uid.clone())),
certification: Certification::new(bob.clone(), Some(alice_uid.userid().clone()), alice.clone(), SystemTime::now()),
};
let alice_twin = Introducer {
cert: CertSynopsis::new(alice_fpr,
None, RevocationStatus::NotAsFarAsWeKnow,
iter::once(alice_uid.clone())),
certification: Certification::new(bob.clone(), Some(alice_uid.userid().clone()), alice.clone(), SystemTime::now()),
};
assert!(alice_intro == alice_twin);
let mut hasher = DefaultHasher::new();
alice_intro.hash(&mut hasher);
let alice_hash = hasher.finish();
let mut hasher = DefaultHasher::new();
alice_twin.hash(&mut hasher);
let alice_twin_hash = hasher.finish();
assert!(alice_hash == alice_twin_hash);
}
}
+10
View File
@@ -19,6 +19,8 @@ pub enum ProtectionPossibility<'mc> {
// no recipient has a cert
NoProtection,
// some recipients have a cert (but not all)
// The first param are encryptable recipients, the second the
// unencryptable.
Partial(Vec<Recipient<'mc>>, Vec<Recipient<'mc>>),
// all recipients have a cert
Full
@@ -147,6 +149,14 @@ impl<'mc> MailContext<'mc> {
outer
}
/// Fast check for recipients with certificates.
pub fn encryptable(&self) -> bool {
for rcpt in &self.recipients {
if rcpt.can_encrypt() { return true; }
}
false
}
}
/// Default Mailcontext
+6
View File
@@ -44,6 +44,12 @@ impl fmt::Display for Header<'_> {
}
}
impl<'h> Default for Header<'h> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
+6 -1
View File
@@ -25,7 +25,7 @@ impl<'m> MultiPart<'m> {
MultiPart {
header: Header::new(),
parts: Vec::new(),
boundary: boundary
boundary
}
}
@@ -88,6 +88,11 @@ impl fmt::Display for MultiPart<'_> {
}
}
impl<'m> Default for MultiPart<'m> {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
+2 -2
View File
@@ -15,14 +15,14 @@ impl<'p> Part<'p> {
pub fn with_body(body: Bytes) -> Self {
Part {
header: Header::new(),
body: body
body
}
}
pub fn from_body(body: Bytes) -> Self {
Part {
header: Header::new(),
body: body
body
}
}