From 30b1d29274cde42e7d1a0f8e1496db329b568630 Mon Sep 17 00:00:00 2001 From: Malte Meiboom Date: Fri, 23 Jan 2026 12:57:26 +0100 Subject: [PATCH] Start milestone 3 - Add functionality to query online sources (Keyservers, etc) for certificates. - Use introducers to determine which UserIDs should be fetched from online soources. Check certifications before importing certifiactes into the local cert store. - start docker image for local end-to-end testing. --- Cargo.toml | 1 + config/config.toml | 2 + docker/MTAContainerFile | 96 +++++++++ docker/README | 31 +++ docker/mta_provision/etc/postfix/main.cf | 53 +++++ docker/mta_provision/etc/sudoers.d/user_sudo | 1 + docker/mta_provision/opt/husk/config.toml | 7 + docker/mta_provision/opt/husk/log4rs.yml | 17 ++ docker/mta_provision/opt/husk/mapped/.keep | 0 .../mta_provision/usr/bin/gen_hockeypuck.sh | 17 ++ .../usr/bin/gen_hockeypuck_conf.sh | 49 +++++ docker/mta_provision/usr/bin/gen_sq_home.sh | 44 +++++ docker/mta_provision/usr/bin/gen_tls_cert.sh | 23 +++ docker/mta_provision/usr/bin/prepare.sh | 56 ++++++ docker/mta_provision/usr/bin/test.sh | 17 ++ docker/mta_provision/usr/bin/test_1.sh | 49 +++++ src/config.rs | 10 + src/crypto.rs | 184 +++++++++++++++++- src/daemon/mod.rs | 2 +- src/types/errors.rs | 2 + src/types/husk_context.rs | 6 +- src/types/introducer.rs | 24 +++ src/types/mail_context.rs | 4 +- 23 files changed, 685 insertions(+), 10 deletions(-) create mode 100644 docker/MTAContainerFile create mode 100644 docker/README create mode 100644 docker/mta_provision/etc/postfix/main.cf create mode 100644 docker/mta_provision/etc/sudoers.d/user_sudo create mode 100644 docker/mta_provision/opt/husk/config.toml create mode 100644 docker/mta_provision/opt/husk/log4rs.yml create mode 100644 docker/mta_provision/opt/husk/mapped/.keep create mode 100755 docker/mta_provision/usr/bin/gen_hockeypuck.sh create mode 100755 docker/mta_provision/usr/bin/gen_hockeypuck_conf.sh create mode 100644 docker/mta_provision/usr/bin/gen_sq_home.sh create mode 100755 docker/mta_provision/usr/bin/gen_tls_cert.sh create mode 100755 docker/mta_provision/usr/bin/prepare.sh create mode 100644 docker/mta_provision/usr/bin/test.sh create mode 100644 docker/mta_provision/usr/bin/test_1.sh diff --git a/Cargo.toml b/Cargo.toml index 41d11bc..3433fcd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ log = "0.4.27" log4rs = "1.3.0" sequoia-cert-store = "0.7.1" sequoia-directories = "0.1.0" +sequoia-net = "0.30.0" sequoia-openpgp = "2.1.0" sequoia-wot = "0.15.0" serde = "1.0.228" diff --git a/config/config.toml b/config/config.toml index 9246d5d..c2d21ae 100644 --- a/config/config.toml +++ b/config/config.toml @@ -6,3 +6,5 @@ connection = "localhost:3000" sequoia_home = "/tmp/sq_home" logfile_config = "./config/log4rs.yml" + +keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ] diff --git a/docker/MTAContainerFile b/docker/MTAContainerFile new file mode 100644 index 0000000..833059f --- /dev/null +++ b/docker/MTAContainerFile @@ -0,0 +1,96 @@ +## +# build e2e tests +## + +## +# build hockeypuck (keyserver), as it's not a debian package +## +FROM golang:1.24 AS gobuilder + +## install build dependencies +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update -yqq && \ + apt-get install -yqq \ + git \ + make + +WORKDIR /hockeypuck + +RUN git clone https://github.com/hockeypuck/hockeypuck /hockeypuck +RUN make build + +# +# the e2e tests need a rust toolchain +# + +FROM docker.io/library/rust:latest + +ENV DEBIAN_FRONTEND=noninteractive + +## ensure language settings +RUN apt-get update -yqq && apt-get install -y locales && rm -rf /var/lib/apt/lists/* \ + && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8 +ENV LANG=en_US.utf8 + +## install build dependencies +RUN apt-get update -yqq && \ + apt-get install -yqq \ + clang \ + git \ + libclang-dev \ + libsqlite3-dev \ + libssl-dev \ + llvm \ + nettle-dev \ + pkg-config + +## +RUN apt-get update -yqq && \ + apt-get install -yqq adduser \ + passwd \ + bash \ + openssh-client \ + sudo \ + postfix \ + rsyslog \ + postgresql \ + openssl \ + sq + +RUN adduser --shell /bin/bash --disabled-password --gecos "" user && \ + mkdir -p /home/user/ && \ + usermod -a -G sudo user && \ + chown -R user:user /home/user + +RUN adduser --shell /bin/bash --disabled-password --gecos "" alice && \ + mkdir -p /home/alice/ && \ + chown -R alice:alice /home/alice + +RUN adduser --shell /bin/bash --disabled-password --gecos "" bob && \ + mkdir -p /home/bob/ && \ + chown -R bob:bob /home/bob + +RUN adduser --shell /bin/bash --disabled-password --gecos "" carol && \ + mkdir -p /home/carol/ && \ + chown -R carol:carol /home/carol + +RUN adduser --shell /bin/bash --disabled-password --gecos "" dave && \ + mkdir -p /home/dave/ && \ + chown -R dave:dave /home/dave + +RUN adduser --shell /bin/bash --disabled-password --gecos "" hockeypuck + +COPY mta_provision/ / + +# copy hockeypuck +COPY --from=gobuilder /hockeypuck/bin/hockeypuck /usr/bin/hockeypuck +COPY --from=gobuilder /hockeypuck/contrib/templates/* /var/www/templates/ + +RUN /usr/bin/gen_hockeypuck.sh example.com 127.0.0.2 + +# tls certificate for example.com +RUN /usr/bin/gen_tls_cert.sh example.com && \ + chown hockeypuck /etc/local_certs/example.com.key + +RUN update-ca-certificates + diff --git a/docker/README b/docker/README new file mode 100644 index 0000000..2a68266 --- /dev/null +++ b/docker/README @@ -0,0 +1,31 @@ +# Content + +This directory contains docker images and related provisioning files for setting +up test environments. + +## MTA +- docker container file: MTAContainerFile +- provisioning: inside mta_provision/ + +### Build +``` +$ docker build -f MTAContainerFile -t localhost/mta . +``` + +### Use + +Check the image interactively +``` +$ docker run -ti --rm localhost/mta bash -i +``` + +Use locally, no git clone and cargo build. This use case assumes that a binary +has been locally build and can be mapped into the docker image. + +The mapped binary will be started, alongside `postfix` and `hockeypuck`. + +``` +$ docker run -ti --rm \ + --mount type=bind,src=$(pwd)/target/release,dst=/opt/husk/mapped/ + localhost/mta bash -c "/usr/bin/prepare.sh && bash -i" +``` diff --git a/docker/mta_provision/etc/postfix/main.cf b/docker/mta_provision/etc/postfix/main.cf new file mode 100644 index 0000000..d511997 --- /dev/null +++ b/docker/mta_provision/etc/postfix/main.cf @@ -0,0 +1,53 @@ +# +# minimal postfix configuration with enabled milter +# + +# Debian specific: Specifying a file name will cause the first +# line of that file to be used as the name. The Debian default +# is /etc/mailname. +#myorigin = /etc/mailname + +smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU) +biff = no + +# appending .domain is the MUA's job. +append_dot_mydomain = no + +# Uncomment the next line to generate "delayed mail" warnings +#delay_warning_time = 4h + +#readme_directory = /usr/share/doc/postfix + +# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 3.6 on +# fresh installs. +compatibility_level = 3.6 + +# Per Debian policy, default to /etc/postfix for Cyrus SASL config +cyrus_sasl_config_path = /etc/postfix/sasl + + +# TLS parameters +smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem +smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key +smtpd_tls_security_level=may + +smtp_tls_CApath=/etc/ssl/certs +smtp_tls_security_level=may +smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache + + +smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination +myhostname = example.com +myorigin = example.com +alias_maps = hash:/etc/aliases +alias_database = hash:/etc/aliases +mydestination = $myhostname, localhost.localdomain, localhost +#relayhost = +mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 192.168.1.0/24 +mailbox_size_limit = 0 +recipient_delimiter = + +inet_interfaces = all +inet_protocols = all + +smtpd_milters = inet:localhost:3000 +non_smtpd_milters = $smtpd_milters diff --git a/docker/mta_provision/etc/sudoers.d/user_sudo b/docker/mta_provision/etc/sudoers.d/user_sudo new file mode 100644 index 0000000..ca3bbc0 --- /dev/null +++ b/docker/mta_provision/etc/sudoers.d/user_sudo @@ -0,0 +1 @@ +user ALL=(ALL) NOPASSWD:ALL diff --git a/docker/mta_provision/opt/husk/config.toml b/docker/mta_provision/opt/husk/config.toml new file mode 100644 index 0000000..0f983d9 --- /dev/null +++ b/docker/mta_provision/opt/husk/config.toml @@ -0,0 +1,7 @@ +connection = "localhost:3000" + +sequoia_home = "/tmp/sq_home" + +logfile_config = "./log4rs.yml" + +keyservers = [ "hkps://example.com" ] diff --git a/docker/mta_provision/opt/husk/log4rs.yml b/docker/mta_provision/opt/husk/log4rs.yml new file mode 100644 index 0000000..f40e4d6 --- /dev/null +++ b/docker/mta_provision/opt/husk/log4rs.yml @@ -0,0 +1,17 @@ +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: + - file_appender + diff --git a/docker/mta_provision/opt/husk/mapped/.keep b/docker/mta_provision/opt/husk/mapped/.keep new file mode 100644 index 0000000..e69de29 diff --git a/docker/mta_provision/usr/bin/gen_hockeypuck.sh b/docker/mta_provision/usr/bin/gen_hockeypuck.sh new file mode 100755 index 0000000..17917e7 --- /dev/null +++ b/docker/mta_provision/usr/bin/gen_hockeypuck.sh @@ -0,0 +1,17 @@ +#!/usr/bin/bash + +DOMAIN=$1 +IP=$2 + +if [ "$DOMAIN" == "" ] ; then + echo "USAGE: $0 DOMAIN IP" + exit 1 +fi + +if [ "$IP" == "" ] ; then + echo "USAGE: $0 DOMAIN IP" + exit 1 +fi + +/usr/bin/gen_tls_cert.sh $DOMAIN +/usr/bin/gen_hockeypuck_conf.sh $DOMAIN $IP diff --git a/docker/mta_provision/usr/bin/gen_hockeypuck_conf.sh b/docker/mta_provision/usr/bin/gen_hockeypuck_conf.sh new file mode 100755 index 0000000..fc3dffc --- /dev/null +++ b/docker/mta_provision/usr/bin/gen_hockeypuck_conf.sh @@ -0,0 +1,49 @@ +#!/usr/bin/bash + +DOMAIN=$1 +IP=$2 +CERTS=/etc/local_certs +DIR=/etc/hockeypuck.d +CONF_NAME=hockeypuck_ + +if [ "$DOMAIN" == "" ] ; then + echo "USAGE: $0 DOMAIN IP" + exit 1 +fi + +if [ "$IP" == "" ] ; then + echo "USAGE: $0 DOMAIN IP" + exit 1 +fi + +if [ ! -d $DIR ] ; then + mkdir -p $DIR +fi + +DB_NAME=$(echo $DOMAIN | tr -d '.-') + +cat << EO_CONF > $DIR/$CONF_NAME$DOMAIN.conf +[hockeypuck] +loglevel="INFO" +logfile="/tmp/hockeypuck_$DOMAIN.log" +indexTemplate="/var/www/templates/index.html.tmpl" +vindexTemplate="/var/www/templates/index.html.tmpl" +statsTemplate="/var/www/templates/stats.html.tmpl" +webroot="/var/www/" + +[hockeypuck.hkp] +bind="$IP:11371" + +[hockeypuck.hkps] +bind="$IP:443" +logRequestDetails=false +cert="$CERTS/$DOMAIN.crt" +key="$CERTS/$DOMAIN.key" + +[hockeypuck.openpgp.db] +driver="postgres-jsonb" +dsn="database=hkp_$DB_NAME host=/var/run/postgresql port=5432 sslmode=disable" + +[hockeypuck.conflux.recon.leveldb] +path="/tmp/${DOMAIN}_recon.db" +EO_CONF diff --git a/docker/mta_provision/usr/bin/gen_sq_home.sh b/docker/mta_provision/usr/bin/gen_sq_home.sh new file mode 100644 index 0000000..2791391 --- /dev/null +++ b/docker/mta_provision/usr/bin/gen_sq_home.sh @@ -0,0 +1,44 @@ +#!/usr/bin/bash + +export SEQUOIA_HOME=$(mktemp -d) +export TMP=$(mktemp -d) +export SEQUOIA_TARGET=/tmp/sq_home + +mkdir -p $SEQUOIA_TARGET + +# create keys +ALICE_FPR=$(sq key generate --own-key --without-password --name "Alice" --email "alice@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2) +BOB_FPR=$(sq key generate --own-key --without-password --name "Bob" --email "bob@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2) +CAROL_FPR=$(sq key generate --own-key --without-password --name "Carol" --email "carol@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2) +DAVE_FPR=$(sq key generate --own-key --without-password --name "Dave" --email "dave@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2) + +CA_FPR=$(sq key generate --own-key --without-password --name "CA" --email "ca@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2) + +# vouch for bob +sq pki vouch add --certifier $CA_FPR --cert $BOB_FPR --email "bob@example.com" +# vouch for carol +sq pki vouch add --certifier $CA_FPR --cert $CAROL_FPR --email "carol@example.com" + +# export +sq key export --cert $ALICE_FPR > $TMP/alice_sk.pgp +sq key export --cert $CA_FPR > $TMP/ca_sk.pgp +# only certs +sq cert export --cert $BOB_FPR > $TMP/bob_pk.pgp +sq network keyserver publish --server hkps://example.com --cert $CAROL_FPR +sq network keyserver publish --server hkps://example.com --cert $DAVE_FPR + +# create store +export SEQUOIA_HOME=$SEQUOIA_TARGET +sq key import $TMP/alice_sk.pgp +sq key import $TMP/ca_sk.pgp +sq cert import $TMP/bob_pk.pgp + +sq pki link authorize --unconstrained --cert $CA_FPR --all --domain example.com + +# Outcome: +# ca@example.com is authenticated with an unlimited trust depth and a restriction for domain example.com +# alice@example.com has a local key, but it is not authenticated +# bob@example.com has a local cert, which is certified by ca@example.com +# carol@example.com is published on a keyserver and certified by ca@example.com +# dave@example.com is published on a keyserver but uncertified + diff --git a/docker/mta_provision/usr/bin/gen_tls_cert.sh b/docker/mta_provision/usr/bin/gen_tls_cert.sh new file mode 100755 index 0000000..2fbcd4c --- /dev/null +++ b/docker/mta_provision/usr/bin/gen_tls_cert.sh @@ -0,0 +1,23 @@ +#!/usr/bin/bash + +DOMAIN=$1 +DIR=/etc/local_certs +USER=hockeypuck + +if [ "$DOMAIN" == "" ] ; then + echo "USAGE: $0 DOMAIN" + exit 1 +fi + +if [ ! -d $DIR ] ; then + mkdir -p $DIR +fi + +echo "create key material for $DOMAIN ..." +openssl req -newkey rsa:2048 -keyout $DIR/$DOMAIN.key -nodes -x509 -days 365 -out $DIR/$DOMAIN.crt -subj "/C=DE/ST=HH/L=Earth/O=CompanyName/OU=IT/CN=$DOMAIN/emailAddress=email@example.com" +chown $USER:$USER $DIR/$DOMAIN.key + +cp $DIR/$DOMAIN.crt /usr/share/ca-certificates/ +echo $DOMAIN.crt >> /etc/ca-certificates.conf + +# dont forget to run `update-ca-certificates` after all DOMAINs are done diff --git a/docker/mta_provision/usr/bin/prepare.sh b/docker/mta_provision/usr/bin/prepare.sh new file mode 100755 index 0000000..d64bc1e --- /dev/null +++ b/docker/mta_provision/usr/bin/prepare.sh @@ -0,0 +1,56 @@ +#!/usr/bin/bash + +BRANCH=$1 + +## create domain +echo "127.0.0.1 " $(hostname) > /etc/hosts +echo "127.0.0.2 example.com" >> /etc/hosts + +## build milter +if [ -f /opt/husk/mapped/husk ] ; then + HUSK_BIN=/opt/husk/mapped/husk +else + cd /opt/husk + git clone https://gitlab.com/husk-project/husk-milter.git + cd husk-milter + if [ "$BRANCH" != "" ] ; then + git checkout $BRANCH + fi + cargo build --release + + HUSK_BIN=/opt/husk/husk-milter/target/release/husk +fi + +## start rsyslogd +/usr/sbin/rsyslogd -n -iNONE & + +## start postfix +sudo postfix start + +## start and prepare postgres +DOMAINS="example.com" +sudo /etc/init.d/postgresql start +echo "create user hockeypuck;" | sudo -u postgres psql -d template1 -U postgres +for domain in $DOMAINS ; do + DB_NAME=$(echo $domain | tr -d '.-') + echo " + create database hkp_$DB_NAME with owner 'hockeypuck'; + GRANT ALL PRIVILEGES ON DATABASE hkp_$DB_NAME to hockeypuck; + " | sudo -u postgres psql -d template1 -U postgres +done + +# +# start hockeypuck +# +echo "start hockeypuck" +for domain in $DOMAINS ; do + echo " $domain..." + sudo -u hockeypuck hockeypuck --config /etc/hockeypuck.d/hockeypuck_$domain.conf 2>&1 > /tmp/hockeypuck_$domain.out & +done + +## create sequoia_home +bash /usr/bin/gen_sq_home.sh + +## start milter +cd /opt/husk +$HUSK_BIN config.toml & diff --git a/docker/mta_provision/usr/bin/test.sh b/docker/mta_provision/usr/bin/test.sh new file mode 100644 index 0000000..1a23c22 --- /dev/null +++ b/docker/mta_provision/usr/bin/test.sh @@ -0,0 +1,17 @@ +#!/usr/bin/bash + +TEST_DIR=$1 + +if [ "$TEST_DIR" == "" ] ; then + echo "USAGE: $0 DIRECTORY_WITH_TESTS" + exit 1 +fi + +for TEST in $TEST_DIR/* ; do + if bash $TEST ; then + echo "$TEST: success" + else + echo "$TEST: failure" + exit 1 + fi +done diff --git a/docker/mta_provision/usr/bin/test_1.sh b/docker/mta_provision/usr/bin/test_1.sh new file mode 100644 index 0000000..d09759a --- /dev/null +++ b/docker/mta_provision/usr/bin/test_1.sh @@ -0,0 +1,49 @@ +#!/usr/bin/bash + +echo "test message" | sendmail -t root@example.com +sleep 1 + +## simple test if mail passes +if [ -f /var/mail/root ] ; then + echo "success" + rm /var/mail/root +else + echo "failure" + exit 1 +fi + +## test encryption +## bob@example.com has an authenticated certificate in +## the cert store +echo "test message" | sendmail -t bob@example.com +sleep 1 +if [ -f /var/mail/bob ] ; then + if grep -q "BEGIN PGP MESSAGE" /var/mail/bob ; then + echo "encryption test: success" + else + echo "encryption test: failure" + exit 1 + fi +else + echo "failure: mail didn't pass" + exit 1 +fi + +## test encryption 2 +## alice@example.com has a certificate in the cert store, but +## it is not authenticated -> this should fail +echo "test message" | sendmail -t alice@example.com +sleep 1 +if [ -f /var/mail/alice ] ; then + if grep -q "BEGIN PGP MESSAGE" /var/mail/alice ; then + echo "encryption test 2: failure" + exit 1 + else + echo "encryption test 2: success" + fi +else + echo "failure: mail didn't pass" + exit 1 +fi + + diff --git a/src/config.rs b/src/config.rs index 13741d0..6e4eb0c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub struct HuskConfig { pub connection: String, pub sequoia_home: String, logfile_config: Option, + pub keyservers: Option>, } impl HuskConfig { @@ -43,6 +44,15 @@ impl HuskConfig { }, } } + + pub fn get_keyservers(&self) -> Vec { + if let Some(kss) = &self.keyservers { + kss.clone() + } else { + Vec::new() + } + } + } pub type HuskConfigContainer = Arc>; diff --git a/src/crypto.rs b/src/crypto.rs index 55e1cac..a1d9824 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -6,22 +6,27 @@ use std::io::Write; use std::sync::Arc; +use std::time::Duration; +use std::collections::HashMap; +use std::collections::hash_map::Entry::{Occupied, Vacant}; use anyhow; +use tokio::task::JoinSet; use bytes::Bytes; +use sequoia_net::{KeyServer, wkd, dane}; use sequoia_openpgp::parse::{PacketParser, PacketParserResult, Parse}; use sequoia_openpgp::policy::StandardPolicy; use sequoia_openpgp::serialize::stream::{Message, Armorer, Encryptor, LiteralWriter}; use sequoia_openpgp::{Fingerprint, Cert, Packet}; use sequoia_openpgp::cert::raw::RawCertParser; -use sequoia_cert_store::{Store, CertStore, LazyCert}; +use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate}; use sequoia_wot::{self as wot}; use wot::store::Store as _; use wot::{Depth, Path}; use crate::types::errors::HuskError; use crate::types::husk_context::HuskContext; -use crate::types::introducer::Introducer; +use crate::types::introducer::{self, Introducer}; use crate::types::recipient::Recipient; /// Returns the local trust root from the cert_store. If the trust root @@ -61,12 +66,14 @@ pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result { } -/// Returns all certificates with an authenticated binding to user id 'email'. +/// Returns all local certificates with an authenticated binding to user id 'email'. /// The certificates are not checked for usefulness (for instance, they might /// miss an encryption subkey). -pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) +pub fn get_local_certificates<'hc>(context: &HuskContext<'hc>, email: &str) -> Vec>> { + log::debug!("local lookup for {}", email); + let cert_store = &context.cert_store; let trust_roots = vec![(context.local_trust_root.fingerprint(), wot::FULLY_TRUSTED)]; let wot_store = wot::store::CertStore::from_store(cert_store, &context.policy, None); @@ -95,6 +102,175 @@ pub fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) result } + +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, +/// dane). +pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str) + -> anyhow::Result> { + + log::debug!("remote lookup for {}", email); + + let mut jobs = JoinSet::new(); + let http_client = sequoia_net::reqwest::Client::builder() + .user_agent(USER_AGENT) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build()?; + + // local function to check certificates for usability + let collect_certs = |certs: Vec>, email: String, policy: &StandardPolicy| { + let mut result = Vec::new(); + + for cert in certs { + if let Ok(c) = cert { + if let Ok(vc) = c.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; + } + } + } + + if userid_found { + // check if the certificate can be used (for encryption). + vc.keys() + .supported() + .alive() + .revoked(false) + .for_transport_encryption() + .for_each(|_| { + // XXX: cert gets returned for each transport key + // once would be enough + result.push(c.clone()); + }); + } + } + } + } + result + }; + + // Keyservers + for keyserver in &context.keyservers { + + log::debug!("querying {}", keyserver); + + if let Ok(ks) = KeyServer::with_client(keyserver.as_str(), http_client.clone()) { + + let e = email.to_string(); + let p = StandardPolicy::new(); + + 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); + } + partial_result + }); + } + } + + // WKD + let e = email.to_string(); + let p = StandardPolicy::new(); + jobs.spawn(async move { + let mut partial_result = Vec::new(); + + if let Ok(certs) = wkd::get(&http_client.clone(), e.as_str()).await { + partial_result = collect_certs(certs, e, &p); + } + partial_result + }); + + // DANE + let e = email.to_string(); + let p = StandardPolicy::new(); + jobs.spawn(async move { + let mut partial_result = Vec::new(); + + if let Ok(certs) = dane::get(e.as_str()).await { + partial_result = collect_certs(certs, e, &p); + } + partial_result + }); + + // join the partial results. As the same certificate might be returned by + // several sources, dedup and merge the certificates. + let mut set: HashMap = HashMap::new(); + let results = jobs.join_all().await; + results.into_iter().flatten() + .for_each(|c| { + let fpr = c.fingerprint(); + + log::debug!("found cert {}", fpr); + + match set.entry(fpr.clone()) { + Occupied(entry) => { + let existing_cert = entry.into_mut(); + if let Ok(merged_cert) = existing_cert.clone().merge_public(c) { + *existing_cert = merged_cert; + } + }, + Vacant(_entry) => { + set.insert(fpr.clone(), c); + }, + } + }); + + let result = set.into_values().collect(); + Ok(result) + +} + + +pub async fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str) + -> Vec>> { + + log::debug!("get certificate for {}", email); + + let locals = get_local_certificates(context, email); + if locals.len() > 0 { + return locals; + } + + let mut result = Vec::new(); + + for introducer in &context.introducers { + log::debug!("consider introducer {:?}", + introducer.cert.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 { + Ok(certs) => { + for cert in certs { + if introducer.is_introducing(&cert) { + let lazy_cert = Arc::new(LazyCert::from_cert(cert)); + if context.cert_store.update(lazy_cert.clone()).is_ok() { + result.push(lazy_cert); + } + } + } + }, + Err(e) => { + log::error!("{}", HuskError::KeyLookupError(email.to_string(), e.to_string())); + } + } + } + } + + result +} + /// Returns all introducers available in cert_store. pub fn get_introducers<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy, local_trust_root: Fingerprint) -> Vec { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index eb82b9e..5a94a23 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -143,7 +143,7 @@ impl Daemon { if rcpt.is_none() { Status::Tempfail } else { - context.add_recipient(rcpt.unwrap()).unwrap(); + context.add_recipient(rcpt.unwrap()).await; Status::Continue } diff --git a/src/types/errors.rs b/src/types/errors.rs index fce16a4..ce92b0b 100644 --- a/src/types/errors.rs +++ b/src/types/errors.rs @@ -18,4 +18,6 @@ pub enum HuskError { NoCertStore, #[error("No encryption keys found")] NoEncryptionKeys, + #[error("Error while looking up email {0}: {1}")] + KeyLookupError(String, String), } diff --git a/src/types/husk_context.rs b/src/types/husk_context.rs index 5e6e7e6..b3a7ae6 100644 --- a/src/types/husk_context.rs +++ b/src/types/husk_context.rs @@ -21,6 +21,7 @@ pub struct HuskContext<'hc> { pub cert_store: CertStore<'hc>, pub local_trust_root: Cert, pub introducers: Vec, + pub keyservers: Vec, pub mail: MailContext<'hc>, } @@ -43,6 +44,7 @@ impl<'hc> HuskContext<'hc> { cert_store, local_trust_root, introducers, + keyservers: config.get_keyservers(), mail: MailContext::new(), }) } @@ -55,9 +57,9 @@ impl<'hc> HuskContext<'hc> { self.mail = MailContext::new(); } - pub fn add_recipient(&mut self, rcpt: String) -> anyhow::Result<()> { + pub async fn add_recipient(&mut self, rcpt: String) { - let certs = crypto::get_certificates(&self, rcpt.as_str()); + let certs = crypto::get_certificates(&self, rcpt.as_str()).await; self.mail.add_recipient(rcpt, certs) } diff --git a/src/types/introducer.rs b/src/types/introducer.rs index f194199..4c0862f 100644 --- a/src/types/introducer.rs +++ b/src/types/introducer.rs @@ -29,5 +29,29 @@ impl Introducer { false } } + + pub fn is_introducing(&self, cert: &Cert) -> bool { + + let policy = StandardPolicy::new(); + let mut result = false; + + let my_keyid = self.cert.keyid(); + + if let Ok(vc) = cert.with_policy(&policy, None) { + vc.userids() + .revoked(false) + .for_each(|u| { + u.certifications().for_each(|s| { + if s.signature_alive(None, None).is_ok() { + s.issuers().for_each(|i| { + if i == &my_keyid { result = true; } + }); + } + }) + }); + } + + result + } } diff --git a/src/types/mail_context.rs b/src/types/mail_context.rs index d4ecf9a..2e7556a 100644 --- a/src/types/mail_context.rs +++ b/src/types/mail_context.rs @@ -44,13 +44,11 @@ impl<'mc> MailContext<'mc> { self.sender = Some(sender); } - pub fn add_recipient(&mut self, rcpt: String, certs: Vec>>) -> anyhow::Result<()> { + pub fn add_recipient(&mut self, rcpt: String, certs: Vec>>) { let mut recipient: Recipient = rcpt.into(); recipient.certs = certs; self.recipients.push(recipient); - - Ok(()) } pub fn add_chunk(&mut self, chunk: Bytes) {