Merge branch 'malte/start_cli' into 'main'

Second part of milestone 3

See merge request husk-project/husk-milter!2
This commit is contained in:
Malte Meiboom
2026-05-27 09:46:09 +00:00
48 changed files with 2018 additions and 716 deletions
-9
View File
@@ -1,17 +1,8 @@
stages:
- test
- e2e
test-job:
stage: test
image: localhost:5000/huskbuilder:latest
script:
- cargo test
e2e-job:
stage: e2e
image: localhost:5000/mta:latest
script:
- echo "new"
- bash /usr/bin/prepare.sh $CI_COMMIT_BRANCH && bash /opt/husk/husk-milter/e2e-tests/runner.sh
Generated
+617 -382
View File
File diff suppressed because it is too large Load Diff
+8 -6
View File
@@ -8,21 +8,23 @@ authors = [ "Malte Meiboom <malte@netropol.de>" ]
readme = "README.md"
keywords = ["cryptography", "openpgp", "pgp", "encryption", "email"]
categories = ["cryptography", "email"]
rust-version = "1.79"
rust-version = "1.85"
[dependencies]
aho-corasick = "1.1.4"
anyhow = "1.0.100"
bytes = "1.11.1"
clap = { version = "4", features = ["derive"] }
indymilter = "0.3.0"
log = "0.4.27"
log = "0.4.30"
log4rs = "1.3.0"
sequoia-cert-store = "0.7.1"
sequoia-cert-store = "0.7.3"
sequoia-directories = "0.1.0"
sequoia-net = "0.30.0"
sequoia-openpgp = "2.1.0"
sequoia-net = "0.30.1"
sequoia-openpgp = "2.3.0"
sequoia-wot = "0.15.0"
serde = "1.0.228"
serde_derive = "1.0.228"
thiserror = "2.0.17"
tokio = { version = "1.47.1", features = [ "tokio-macros", "rt-multi-thread", "signal" ] }
tokio = { version = "1.52.3", features = [ "tokio-macros", "rt-multi-thread", "signal" ] }
toml = "0.9.8"
+12 -71
View File
@@ -8,82 +8,23 @@ Husk is written in Rust and uses Sequoia PGP for encryption and certificate hand
## Status
Milestone 2 is reached:
Milestone 3 is reached and with it Husk has aquired a set of new features:
Husk can encrypt passing mails for simple cases, meaning:
- Online sources (keyservers, WKD, DANE) are now queried for missing
certificates.
- Introducers are now available: certificates are only used if they are
certified by an introducer and match its list of configured domains.
- Locals are available: These are certificates hand picked and added
by the administrator.
- for all recipients of an email, a valid certificate is locally available.
- if that is not the case, Husk will not modify the email.
- Husk will not query keyservers (or other sources) for certificates - this is part of milestone 3.
## Documentation
Please be patient.
The documentation (with install instructions) can be found [here](https:://www.husk-project.org/documentation).
## Build
Make sure that you have a toolchain for Rust installed.
Building:
```
$ git clone https://gitlab.com/husk-project/husk-milter.git
$ cd husk-milter
$ cargo build --release
```
This creates the binary `./target/release/husk`.
## Installation
To use with `postfix`:
- configure husk, there is an example configuration file in `./config/config.toml`.
```
#
# Husk config file
#
connection = "localhost:3000"
logfile_config = "./config/log4rs.yml"
```
Husk uses `log4rs` for logging. There is an example configuration in the
`./config` subdirectory as well.
- Configure `postfix`:
Make sure that the `main.cf` configuration file contains a line like:
```
smtpd_milters = inet:localhost:3000
```
`smtpd_milters` is used to register milters with postfix, there might be
more than one. The string `inet:localhost:3000` corresponds to the
`connection` setting in `config.toml`.
You can add the line
```
non_smtpd_milters = $smtpd_milters
```
directly after the line containing `smtpd_milters`, so that local calls to `sendmail` will also invoke `Husk`.
- Start the milter:
Assuming you are in the root directory of the project:
```
$ ./target/release/husk ./config/config.toml
```
- Restart postfix:
In case you are using systemd:
```
# systemctl restart postfix.service
```
- Send a mail and check the logfile.
## Website
This project has a [website](https:://www.husk-project.org/) with more background
information.
## Funding
+21 -2
View File
@@ -5,6 +5,25 @@ 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" ]
keyservers = [ "hkp://example.com" ]
[logging.appenders.console]
kind = "console"
[logging.appenders.console.encoder]
pattern = "{d(%Y-%m-%d %H:%M:%S)(local)} {h({l})} [{t}] {m}{n}"
[logging.appenders.file]
kind = "file"
path = "/tmp/husk.log"
[logging.appenders.file.encoder]
pattern = "{d(%Y-%m-%d %H:%M:%S)(local)} {h({l})} [{t}] {m}{n}"
[logging.root]
level = "debug"
appenders = ["file"]
[logging.loggers."husk"]
level = "debug"
keyservers = [ "hkps://keys.openpgp.org/", "hkps://mail-api.proton.me", "hkps://keys.mailvelope.com", "hkps://keyserver.ubuntu.com", "hkps://sks.pod01.fleetstreetops.com" ]
+1 -1
View File
@@ -13,4 +13,4 @@ appenders:
root:
level: debug
appenders:
- file_appender
- stdout_appender
+4 -1
View File
@@ -52,10 +52,13 @@ RUN apt-get update -yqq && \
openssh-client \
sudo \
postfix \
postfix-pcre \
rsyslog \
postgresql \
openssl \
sq
sq \
bind9-host \
inotify-tools
RUN adduser --shell /bin/bash --disabled-password --gecos "" hockeypuck
+2 -2
View File
@@ -41,9 +41,9 @@ myhostname = example.com
myorigin = example.com
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
mydestination = $myhostname, localhost.localdomain, localhost
mydestination = $myhostname, localhost.localdomain, localhost, example.com, example.org
#relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 192.168.1.0/24
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 0.0.0.0/0
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
+21 -1
View File
@@ -4,4 +4,24 @@ sequoia_home = "/tmp/sq_home"
logfile_config = "./log4rs.yml"
keyservers = [ "hkps://example.com" ]
keyservers = [ "hkp://example.com" ]
[logging.appenders.console]
kind = "console"
[logging.appenders.console.encoder]
pattern = "{d(%Y-%m-%d %H:%M:%S)(local)} {h({l})} [{t}] {m}{n}"
[logging.appenders.file]
kind = "file"
path = "/tmp/husk.log"
[logging.appenders.file.encoder]
pattern = "{d(%Y-%m-%d %H:%M:%S)(local)} {h({l})} [{t}] {m}{n}"
[logging.root]
level = "debug"
appenders = ["file"]
[logging.loggers."husk"]
level = "debug"
@@ -35,7 +35,7 @@ webroot="/var/www/"
bind="$IP:11371"
[hockeypuck.hkps]
bind="$IP:443"
bind="$IP:4443"
logRequestDetails=false
cert="$CERTS/$DOMAIN.crt"
key="$CERTS/$DOMAIN.key"
+2 -3
View File
@@ -24,8 +24,8 @@ 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
sq network keyserver publish --server hkp://example.com --cert $CAROL_FPR
sq network keyserver publish --server hkp://example.com --cert $DAVE_FPR
# create store
export SEQUOIA_HOME=$SEQUOIA_TARGET
@@ -41,4 +41,3 @@ sq pki link authorize --unconstrained --cert $CA_FPR --all --domain example.com
# 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
+20 -8
View File
@@ -3,29 +3,32 @@
BRANCH=$1
## create domain
echo "127.0.0.1 " $(hostname) > /etc/hosts
echo "127.0.0.1 " $(hostname) >> /etc/hosts
echo "127.0.0.2 example.com" >> /etc/hosts
echo "127.0.0.3 example.org" >> /etc/hosts
## build milter
if [ -f /opt/husk/mapped/Cargo.toml ] ; then
HUSK_BIN=/opt/husk/mapped/target/release/husk
else
HUSK_BIN=/opt/husk/mapped/target/debug/husk
HUSK_DIR=/opt/husk/mapped/
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
cargo build
HUSK_BIN=/opt/husk/husk-milter/target/release/husk
HUSK_BIN=/opt/husk/husk-milter/target/debug/husk
HUSK_DIR=/opt/husk/husk-milter/
fi
## start rsyslogd
/usr/sbin/rsyslogd -n -iNONE &
## start postfix
sudo postfix start
sudo postfix start
## start and prepare postgres
DOMAINS="example.com"
@@ -52,5 +55,14 @@ done
bash /usr/bin/gen_sq_home.sh
## start milter
cd /opt/husk
$HUSK_BIN config.toml &
cd $HUSK_DIR/config
$HUSK_BIN daemon start --config config.toml &
## extract SEQUOIA_HOME from the configuration
SEQUOIA_HOME=$(grep "sequoia_home" config.toml | cut -d '=' -f 2 | tr -d ' ')
cat << EO_ENV > /tmp/env.sh
export HUSK_BIN=$HUSK_BIN
export HUSK_DIR=$HUSK_DIR
export SEQUOIA_HOME=$SEQUOIA_HOME
EO_ENV
+5 -4
View File
@@ -1,19 +1,20 @@
#!/usr/bin/bash
echo "test message" | sendmail -t root@example.com
adduser --shell /bin/bash --disabled-password --gecos "" mtatest
echo "test message" | sendmail -t mtatest@example.com
sleep 1
## simple test if mail passes
if [ -f /var/mail/root ] ; then
if [ -f /var/mail/mtatest ] ; then
echo "success"
rm /var/mail/root
rm /var/mail/mtatest
else
echo "failure"
exit 1
fi
## test encryption
## bob@example.com has an authenticated certificate in
## bob@example.com has an authenticated certificate in
## the cert store
echo "test message" | sendmail -t bob@example.com
sleep 1
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/bash
# create a unix (shell) user
create_unix_user() { # name
adduser --quiet --shell /bin/bash --disabled-password --gecos "" $1 > /dev/null 2>&1
}
# delete a unix user, plus remove $HOME and mails
delete_unix_user() { # name
deluser --remove-home $1
}
# send a mail and wait for delivery
send_test_mail() { # email_address
USER=$(echo $1 | cut -f 1 -d '@')
# create an empty mailbox file
touch /var/mail/$USER
chown $USER:mail /var/mail/$USER
chmod 600 /var/mail/$USER
echo "test mail" | sendmail $1
inotifywait -qq -t 3 -e close_write /var/mail/$USER
}
# test header
test_header() { # text
echo "##############################################"
echo $1
echo "Workdir: $WORKDIR"
echo "##############################################"
}
# Generate a key outside the cert store. The key will not be
# authenticated.
gen_key() { # email
FILE=$(echo $1 | cut -f 1 -d '@')
FPR=$(sq key generate --home none --shared-key --without-password \
--email $1 \
--output $WORKDIR/${FILE}_sk.pgp \
--rev-cert $WORKDIR/${FILE}.rev |& grep "Fingerprint:" | cut -d ':' -f 2)
echo $FPR
}
expect_mail() { # unix_user mode
if [ -f /var/mail/$1 ] ; then
case $2 in
encrypted)
if grep -q "BEGIN PGP MESSAGE" /var/mail/$1 ; then
R=0
else
echo "expected encrypted mail for $1" >&2
R=1
fi
;;
unencrypted)
if grep -q "BEGIN PGP MESSAGE" /var/mail/$1 ; then
echo "expected unencrypted mail for $1" >&2
R=1
else
R=0
fi
;;
delivered)
R=0
;;
esac
else
echo "no mail for $1" >&2
R=1
fi
echo $R
}
+3 -3
View File
@@ -1,10 +1,10 @@
#!/usr/bin/bash
cargo build --release
cargo build
docker run -ti \
--rm \
--mount type=bind,src=$(pwd),dst=/opt/husk/mapped/ \
localhost/mta \
bash -c "/usr/bin/prepare.sh && bash /opt/husk/mapped/e2e-tests/runner.sh"
localhost/mta:latest \
bash -c "/usr/bin/prepare.sh && bash /opt/husk/mapped/e2e-tests/runner.sh && bash -i"
+17 -3
View File
@@ -1,12 +1,26 @@
#!/usr/bin/bash
. /tmp/env.sh
echo "bin:" $HUSK_BIN
echo "home:" $SEQUOIA_HOME
cd $(dirname $0)
for TEST in ./tests/* ; do
# create a new WORKDIR
export WORKDIR=$(mktemp -d)
if bash $TEST ; then
echo "success"
echo "SUCCESS"
else
echo "failure"
exit 1
echo "FAILURE"
# post mortem, leave WORKDIR intact for inspection
inotifywait -qq -t 3 -e close_write /tmp/husk.log
cat /tmp/husk.log
exit 1
fi
# remove WORKDIR
rm -rf $WORKDIR
done
+7 -10
View File
@@ -1,18 +1,15 @@
#!/usr/bin/bash
echo "Basic: Test if MTA is working"
# source some functions
. common.sh
adduser --shell /bin/bash --disabled-password --gecos "" mtatest
echo "test mail" | sendmail mtatest@example.com
test_header "Basic: Test if MTA is working"
sleep 1
create_unix_user mtatest
send_test_mail mtatest@example.com
if [ -f /var/mail/mtatest ] ; then
RESULT_CODE=0
else
RESULT_CODE=1
fi
RESULT_CODE=$(expect_mail mtatest delivered)
deluser --remove-home mtatest
delete_unix_user mtatest
exit $RESULT_CODE
+8 -17
View File
@@ -1,25 +1,16 @@
#!/usr/bin/bash
echo "Simple: Test encryption for available certificate"
# source some functions
. common.sh
test_header "Simple: Test encryption for available certificate"
# There is an authenticated certificate for bob in the cert store
adduser --shell /bin/bash --disabled-password --gecos "" bob
echo "test mail" | sendmail bob@example.com
create_unix_user bob
send_test_mail bob@example.com
sleep 1
RESULT_CODE=$(expect_mail bob encrypted)
if [ -f /var/mail/bob ] ; then
if grep -q "BEGIN PGP MESSAGE" /var/mail/bob ; then
RESULT_CODE=0
else
echo "no encryption detected"
RESULT_CODE=1
fi
else
echo "no mail detected"
RESULT_CODE=1
fi
deluser --remove-home bob
delete_unix_user bob
exit $RESULT_CODE
+8 -17
View File
@@ -1,25 +1,16 @@
#!/usr/bin/bash
echo "Simple: Test encryption for unavailable certificate"
# source some functions
. common.sh
test_header "Simple: Test encryption for unavailable certificate"
# There is no authenticated certificate for alice in the cert store
adduser --shell /bin/bash --disabled-password --gecos "" alice
echo "test mail" | sendmail alice@example.com
create_unix_user alice
send_test_mail alice@example.com
sleep 1
RESULT_CODE=$(expect_mail alice unencrypted)
if [ -f /var/mail/alice ] ; then
if grep -q "BEGIN PGP MESSAGE" /var/mail/alice ; then
echo "encryption detected - that is wrong"
RESULT_CODE=1
else
RESULT_CODE=0
fi
else
echo "no mail detected"
RESULT_CODE=1
fi
deluser --remove-home alice
delete_unix_user alice
exit $RESULT_CODE
+8 -17
View File
@@ -1,26 +1,17 @@
#!/usr/bin/bash
echo "Test encryption for certificate an a keyserver"
# source some functions
. common.sh
test_header "Test encryption for certificate an a keyserver"
# There is a certificate for carol on the keyserver
# it's certified by an introducer
adduser --shell /bin/bash --disabled-password --gecos "" carol
echo "test mail" | sendmail carol@example.com
create_unix_user carol
send_test_mail carol@example.com
sleep 1
RESULT_CODE=$(expect_mail carol encrypted)
if [ -f /var/mail/carol ] ; then
if grep -q "BEGIN PGP MESSAGE" /var/mail/carol ; then
RESULT_CODE=0
else
echo "no encryption detected"
RESULT_CODE=1
fi
else
echo "no mail detected"
RESULT_CODE=1
fi
deluser --remove-home carol
delete_unix_user carol
exit $RESULT_CODE
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/bash
# source some functions
. common.sh
test_header "Add introducer"
ELLEN_FPR=$(sq key generate --shared-key --without-password --name "Ellen" --email "ellen@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2)
cd $HUSK_DIR
$HUSK_BIN introducer add --cert $ELLEN_FPR --domains "example.org" --config config/config.toml
$HUSK_BIN introducer list --config config/config.toml
create_unix_user fred
FRED_FPR=$(sq key generate --shared-key --without-password --name "Fred" --email "fred@example.org" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2)
sleep 1
sq pki link retract --cert $FRED_FPR --all
sq pki vouch add --certifier $ELLEN_FPR --cert $FRED_FPR --email "fred@example.org"
send_test_mail fred@example.org
RESULT_CODE=$(expect_mail fred encrypted)
if [ "$RESULT_CODE" == "1" ] ; then
exit 1
fi
test_header "Remove introducer"
$HUSK_BIN introducer remove --cert $ELLEN_FPR --config config/config.toml
$HUSK_BIN introducer list --config config/config.toml
rm /var/mail/fred
send_test_mail fred@example.org
RESULT_CODE=$(expect_mail fred unencrypted)
delete_unix_user fred
exit $RESULT_CODE
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/bash
# source some functions
. common.sh
export SEQUOIA_HOME=$(mktemp -d)
test_header "Import introducer from file"
# create ellen
ELLEN_FPR=$(sq key generate --shared-key --without-password --name "Ellen" --email "ellen@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2)
sq cert export --cert $ELLEN_FPR > /tmp/ellen_pk.pgp
cd $HUSK_DIR
$HUSK_BIN introducer add --cert-file /tmp/ellen_pk.pgp --domains example.org --config config/config.toml
LIST=$($HUSK_BIN introducer list --config config/config.toml)
echo $LIST | grep -q 'ellen@example.com'
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/bash
# source some functions
. common.sh
test_header "List locals"
GINA_FPR=$(sq key generate --shared-key --without-password --name "Gina" --email "gina@example.com" 2>&1 | grep "Fingerprint:" | cut -d ':' -f 2)
cd $HUSK_DIR
$HUSK_BIN locals list --config config/config.toml | grep $GINA_FPR
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/bash
# source some functions
. common.sh
test_header "Add locals"
sq key generate --shared-key --without-password --email 'hubert@example.com' --output /tmp/hubert.pgp --rev-cert /tmp/hubert_rev.pgp
cd $HUSK_DIR
$HUSK_BIN locals add --cert-file /tmp/hubert.pgp --config config/config.toml
if ! $HUSK_BIN locals list --config config/config.toml | grep -q "hubert@example.com" ; then
echo "adding failed"
exit 1
fi
# send a mail, husk should encrypt it
echo "send mail to hubert@example.com"
create_unix_user hubert
send_test_mail hubert@example.com
if [ -f /var/mail/hubert ] ; then
if grep -q "BEGIN PGP MESSAGE" /var/mail/hubert ; then
RESULT_CODE=0
else
echo "no encryption detected"
delete_unix_user hubert
exit 1
fi
# remove mail
rm /var/mail/hubert
else
echo "no mail detected"
delete_unix_user hubert
exit 1
fi
sleep 1
# remove hubert as local
HUBERT_FPR=$(sq cert list --cert-email hubert@example.com 2>/dev/null | head -n 1 | cut -d ' ' -f 3)
$HUSK_BIN locals remove --cert $HUBERT_FPR --config config/config.toml
echo "send mail to hubert@example.com"
send_test_mail hubert@example.com
if [ -f /var/mail/hubert ] ; then
if grep -q "BEGIN PGP MESSAGE" /var/mail/hubert ; then
echo "encryption detected where there should be none"
RESULT_CODE=1
else
RESULT_CODE=0
fi
else
echo "no mail detected"
RESULT_CODE=1
fi
delete_unix_user hubert
exit $RESULT_CODE
+181
View File
@@ -0,0 +1,181 @@
use clap::Parser;
use clap::Subcommand;
#[derive(Parser, Debug)]
#[clap(
name = "Husk milter",
about = "Encrypting milter.",
arg_required_else_help = true,
)]
pub struct CliArgs {
#[clap(
long, short,
global = true,
help = "Configuration file.",
)]
pub config: Option<String>,
#[clap(
long,
global = true,
help = "Certification store (SEQUOIA_HOME).",
)]
pub home: Option<String>,
#[clap(subcommand)]
pub subcommand: HuskSubcommands,
}
// Subcommands Level 1
#[derive(Debug, Subcommand)]
pub enum HuskSubcommands {
Daemon(DaemonCommand),
Introducer(IntroducerCommand),
Locals(LocalsCommand),
}
// Daemon subcommands
#[derive(Parser, Debug)]
#[clap(
name = "daemon",
about = "Manage milter daemon.",
subcommand_required = true,
arg_required_else_help = true,
disable_colored_help = true,
disable_version_flag = true,
)]
pub struct DaemonCommand {
#[clap(subcommand)]
pub subcommand: DaemonSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum DaemonSubcommand {
Start,
Stop,
Status
}
// Introducer subcommands
#[derive(Parser, Debug)]
#[clap(
name = "introducer",
about = "Manage introducers.",
subcommand_required = true,
arg_required_else_help = true,
disable_colored_help = true,
disable_version_flag = true,
)]
pub struct IntroducerCommand {
#[clap(subcommand)]
pub subcommand: IntroducerSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum IntroducerSubcommand {
Add(IntroducerAddCommand),
Remove(IntroducerRemoveCommand),
List
}
#[derive(Parser, Debug)]
#[clap(
name = "introducer",
about = "Add introducers.",
)]
pub struct IntroducerAddCommand {
#[clap(
long = "cert",
help = "Certificate to declare as introducer.",
conflicts_with = "cert_file"
)]
pub cert: Option<String>,
#[clap(
long = "cert-file",
help = "Import certificate from <FILE> and declare it as introducer.",
value_name = "FILE",
conflicts_with = "cert"
)]
pub cert_file: Option<String>,
#[clap(
long = "domains",
help = "Domains this certificate introduces.",
required = true,
num_args = 1..,
value_delimiter = ' '
)]
pub domains: Vec<String>,
}
#[derive(Parser, Debug)]
#[clap(
name = "introducer",
about = "Remove introducers.",
)]
pub struct IntroducerRemoveCommand {
#[clap(
long = "cert",
help = "Certificate to be removed as introducer."
)]
pub cert: String,
#[clap(
long = "demote",
help = "Remove introducing capabilities, but keep certificate authenticated.",
default_value_t = false,
)]
pub demote: bool,
}
// Local subcommands
#[derive(Parser, Debug)]
#[clap(
name = "local",
about = "Manage manually added certificates.",
subcommand_required = true,
arg_required_else_help = true,
disable_colored_help = true,
disable_version_flag = true,
)]
pub struct LocalsCommand {
#[clap(subcommand)]
pub subcommand: LocalsSubcommand,
}
#[derive(Debug, Subcommand)]
pub enum LocalsSubcommand {
Add(LocalsAddCommand),
Remove(LocalsRemoveCommand),
List
}
#[derive(Parser, Debug)]
#[clap(
name = "locals_add",
about = "Add an authenticated certificate.",
)]
pub struct LocalsAddCommand {
#[clap(
long,
help = "Import a certificate from <FILE>.",
value_name = "FILE",
)]
pub cert_file: String,
}
#[derive(Parser, Debug)]
#[clap(
name = "locals_remove",
about = "Disable a Certificate.",
)]
pub struct LocalsRemoveCommand {
#[clap(
long = "cert",
help = "Certificate to be removed."
)]
pub cert: String,
}
+1
View File
@@ -0,0 +1 @@
pub mod cli_args;
+40
View File
@@ -0,0 +1,40 @@
use anyhow::Result;
use sequoia_openpgp::Fingerprint;
use crate::commands;
use crate::cli::cli_args::{CliArgs, HuskSubcommands};
use crate::config::HuskConfigContainer;
pub mod daemon;
pub mod introducer;
pub mod locals;
#[derive(thiserror::Error, Debug)]
pub enum CommandError {
#[error("Certificate not found: {0}")]
CertNotFound(Fingerprint),
#[error("Certificate not usable: {0}")]
CertNotUsable(Fingerprint),
#[error("Certificate {0} is not alive (is expired or created in the future)")]
CertNotAlive(Fingerprint),
#[error("Certificate is revoked: {0}")]
CertRevoked(Fingerprint),
}
pub async fn dispatch(cli: CliArgs, config: HuskConfigContainer) -> Result<()> {
match cli.subcommand {
HuskSubcommands::Daemon(subcmd) => {
commands::daemon::dispatch(subcmd, config).await?;
},
HuskSubcommands::Introducer(subcmd) => {
commands::introducer::dispatch(subcmd, config).await?;
},
HuskSubcommands::Locals(subcmd) => {
commands::locals::dispatch(subcmd, config).await?;
},
}
Ok(())
}
+21
View File
@@ -0,0 +1,21 @@
use anyhow::Result;
use crate::cli::cli_args::{DaemonCommand, DaemonSubcommand};
use crate::config::HuskConfigContainer;
use crate::Daemon;
pub async fn dispatch(cmd: DaemonCommand, config: HuskConfigContainer) -> Result<()> {
match cmd.subcommand {
DaemonSubcommand::Start => {
Daemon::run(config).await?;
},
DaemonSubcommand::Stop => {
println!("stop");
},
DaemonSubcommand::Status => {
println!("status");
}
}
Ok(())
}
+34
View File
@@ -0,0 +1,34 @@
//! introducer subcommand
use anyhow::Result;
use crate::cli::cli_args::{IntroducerCommand, IntroducerSubcommand};
use crate::config::HuskConfigContainer;
use crate::types::husk_context::HuskContext;
use crate::common::crypto;
pub mod add;
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?;
},
IntroducerSubcommand::Remove(rm_cmd) => {
remove::dispatch(rm_cmd, config).await?;
},
IntroducerSubcommand::List => {
let context = HuskContext::new(&config.into())?;
for introducer in crypto::get_introducers(
&context.cert_store,
&context.policy,
context.local_trust_root.fingerprint()) {
println!("{}", introducer);
}
},
}
Ok(())
}
+58
View File
@@ -0,0 +1,58 @@
//! introducer add subcommand
use anyhow::Result;
use sequoia_cert_store::Store;
use sequoia_openpgp::cert::ValidCert;
use sequoia_openpgp::cert;
use sequoia_openpgp::parse::Parse;
use sequoia_openpgp::{Fingerprint, KeyHandle};
use sequoia_openpgp::types::RevocationStatus;
use crate::cli::cli_args::IntroducerAddCommand;
use crate::commands::CommandError;
use crate::common::crypto;
use crate::config::HuskConfigContainer;
use crate::types::husk_context::HuskContext;
use crate::types::introducer::Introducer;
pub async fn dispatch(cmd: IntroducerAddCommand, config: HuskConfigContainer) -> Result<()> {
let context = HuskContext::new(&config.into())?;
let policy = &context.policy;
let cert_store = &context.cert_store;
if let Some(cert_file) = cmd.cert_file {
let c = cert::Cert::from_file(cert_file)?;
let vc = c.with_policy(policy, None)?;
// guards
if vc.alive().is_err() {
return Err(CommandError::CertNotAlive(vc.fingerprint()).into());
}
if matches!(vc.revocation_status(), RevocationStatus::Revoked(_)) {
return Err(CommandError::CertRevoked(vc.fingerprint()).into());
}
if !crypto::can_certify(&vc) {
return Err(CommandError::CertNotUsable(vc.fingerprint()).into());
}
Introducer::create(&context, &vc, cmd.domains)?;
} else if let Some(cert) = cmd.cert {
let fpr = Fingerprint::from_hex(cert.as_str())?;
let certs = cert_store.lookup_by_cert(&KeyHandle::from(&fpr))
.map_err(|_| CommandError::CertNotFound(fpr.clone()))?;
let certs: Vec<ValidCert> = 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::CertNotUsable(fpr).into());
}
}
Ok(())
}
+40
View File
@@ -0,0 +1,40 @@
//! introducer remove subcommand
use anyhow::Result;
use sequoia_openpgp::{Fingerprint, KeyHandle};
use sequoia_openpgp::cert::ValidCert;
use sequoia_cert_store::Store;
use crate::cli::cli_args::IntroducerRemoveCommand;
use crate::commands::CommandError;
use crate::config::HuskConfigContainer;
use crate::types::husk_context::HuskContext;
use crate::types::introducer::Introducer;
pub async fn dispatch(cmd: IntroducerRemoveCommand, config: HuskConfigContainer) -> Result<()> {
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::from(&fpr))
.map_err(|_| CommandError::CertNotFound(fpr.clone()))?;
// Filter out valid certificates. If the certificate is not
// valid according to the policy, it is already no introducer.
let certs: Vec<ValidCert> = certs.iter()
.filter_map(|c|
c.with_policy(policy, None).ok()
)
.collect();
if let Some(cert) = certs.first() {
Introducer::remove(&context, cert, cmd.demote)?;
} else {
return Err(CommandError::CertNotUsable(fpr).into());
}
Ok(())
}
+36
View File
@@ -0,0 +1,36 @@
//! locals subcommand
use anyhow::Result;
use crate::cli::cli_args::{LocalsCommand, LocalsSubcommand};
use crate::common::crypto;
use crate::config::HuskConfigContainer;
use crate::types::husk_context::HuskContext;
pub mod add;
pub mod remove;
pub async fn dispatch(cmd: LocalsCommand, config: HuskConfigContainer) -> Result<()> {
match cmd.subcommand {
LocalsSubcommand::Add(add_cmd) => {
add::dispatch(add_cmd, config).await?;
},
LocalsSubcommand::Remove(rm_cmd) => {
remove::dispatch(rm_cmd, config).await?;
},
LocalsSubcommand::List => {
let context = HuskContext::new(&config.into())?;
println!("list locals");
for local in crypto::get_locals(
&context.cert_store,
&context.policy,
context.local_trust_root.fingerprint()) {
println!("{}", local);
}
},
}
Ok(())
}
+42
View File
@@ -0,0 +1,42 @@
use anyhow::Result;
use sequoia_openpgp::cert;
use sequoia_openpgp::parse::Parse;
use sequoia_openpgp::types::RevocationStatus;
use crate::cli::cli_args::LocalsAddCommand;
use crate::config::HuskConfigContainer;
use crate::types::husk_context::HuskContext;
use crate::commands::CommandError;
use crate::common::crypto;
pub async fn dispatch(cmd: LocalsAddCommand, config: HuskConfigContainer) -> Result<()> {
let context = HuskContext::new(&config.into())?;
let policy = &context.policy;
let c = cert::Cert::from_file(cmd.cert_file)?;
let vc = c.with_policy(policy, None)?;
// guards
if vc.alive().is_err() {
return Err(CommandError::CertNotAlive(vc.fingerprint()).into());
}
if matches!(vc.revocation_status(), RevocationStatus::Revoked(_)) {
return Err(CommandError::CertRevoked(vc.fingerprint()).into());
}
if !crypto::can_encrypt(&vc) {
return Err(CommandError::CertNotUsable(vc.fingerprint()).into());
}
if vc.fingerprint() == context.local_trust_root.fingerprint() {
// XXX: find a better error
return Err(CommandError::CertNotUsable(vc.fingerprint()).into());
}
crypto::authenticate(
&context,
&vc,
crypto::Role::Local,
true,
None
)
}
+50
View File
@@ -0,0 +1,50 @@
//! locals remove subcommand
use anyhow::Result;
use sequoia_openpgp::Fingerprint;
use sequoia_openpgp::KeyHandle;
use sequoia_openpgp::cert::ValidCert;
use sequoia_cert_store::Store;
use crate::cli::cli_args::LocalsRemoveCommand;
use crate::common::crypto;
use crate::config::HuskConfigContainer;
use crate::commands::CommandError;
use crate::types::husk_context::HuskContext;
pub async fn dispatch(cmd: LocalsRemoveCommand, config: HuskConfigContainer) -> Result<()> {
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::from(&fpr))
.map_err(|_| CommandError::CertNotFound(fpr.clone()))?;
// Filter out valid certificates. If the certificate is not
// valid according to the policy, it is already no local.
let certs: Vec<ValidCert> = certs.iter()
.filter_map(|c|
c.with_policy(policy, None).ok()
)
.collect();
if let Some(cert) = certs.first() {
// filter out the trust root
if cert.fingerprint() == context.local_trust_root.fingerprint() {
return Err(CommandError::CertNotUsable(fpr).into());
}
crypto::authenticate(
&context,
cert,
crypto::Role::Local,
false,
None)
} else {
Err(CommandError::CertNotUsable(fpr).into())
}
}
+243 -62
View File
@@ -1,33 +1,36 @@
//
// Husk milter
//
// cryptographic functions
//
//! cryptographic functions
use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::hash_map::Entry::{Occupied, Vacant};
use anyhow;
use sequoia_openpgp::cert::ValidCert;
use tokio::task::JoinSet;
use bytes::Bytes;
use sequoia_net::{KeyServer, wkd, dane};
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, LiteralWriter};
use sequoia_openpgp::{Fingerprint, Cert, Packet};
use sequoia_openpgp::cert::raw::RawCertParser;
use sequoia_openpgp::types::SignatureType;
use sequoia_cert_store::{Store, CertStore, LazyCert, StoreUpdate};
use sequoia_wot::{self as wot};
use sequoia_cert_store::store::MergePublicCollectStats;
use sequoia_wot::{self as wot, CertSynopsis};
use wot::store::Store as _;
use wot::{Depth, Path};
use crate::types::errors::HuskError;
use crate::types::husk_context::HuskContext;
use crate::types::introducer::{self, Introducer};
use crate::types::introducer::Introducer;
use crate::types::recipient::Recipient;
use crate::mail;
use crate::common::escape;
/// Returns the local trust root from the cert_store. If the trust root
/// cannot be found, it returns an error.
@@ -46,10 +49,7 @@ pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result<Cert> {
Ok(mut parser) => {
match parser.next() {
Some(Ok(cert)) => {
match Cert::from_bytes(cert.as_bytes()) {
Ok(c) => Some(c),
Err(_) => None,
}
Cert::from_bytes(cert.as_bytes()).ok()
},
Some(Err(_))
| None => None
@@ -72,7 +72,7 @@ pub fn get_local_trust_root(cert_store: &CertStore) -> anyhow::Result<Cert> {
pub fn get_local_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
-> Vec<Arc<LazyCert<'hc>>> {
log::debug!("local lookup for {}", email);
log::debug!("local lookup for {}", email);
let cert_store = &context.cert_store;
let trust_roots = vec![(context.local_trust_root.fingerprint(), wot::FULLY_TRUSTED)];
@@ -89,9 +89,9 @@ pub fn get_local_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
for (fpr, some_userid) in bindings {
if let Some(userid) = some_userid {
let paths = n.authenticate(userid, &fpr, wot::FULLY_TRUSTED);
if paths.len() > 0 {
log::debug!("{} authenticated!!!", email);
for (path, _) in paths.iter() {
for (path, _) in paths.iter() {
if path.amount() >= wot::FULLY_TRUSTED {
log::debug!("{} authenticated!!!", email);
if let Ok(cert) = cert_store.lookup_by_cert_fpr(&path.target().fingerprint()) {
result.push(cert);
}
@@ -107,14 +107,14 @@ 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)
/// 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<Vec<Cert>> {
log::debug!("remote lookup for {}", email);
log::debug!("remote lookup for {}", email);
let mut jobs = JoinSet::new();
let mut jobs = JoinSet::new();
let http_client = sequoia_net::reqwest::Client::builder()
.user_agent(USER_AGENT)
.connect_timeout(CONNECT_TIMEOUT)
@@ -125,55 +125,53 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
let collect_certs = |certs: Vec<Result<Cert, anyhow::Error>>, 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;
}
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;
}
}
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());
});
}
}
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(cert.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 {
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 = collect_certs(certs, e, &p);
}
partial_result
});
@@ -191,8 +189,8 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
}
partial_result
});
// DANE
// DANE
let e = email.to_string();
let p = StandardPolicy::new();
jobs.spawn(async move {
@@ -215,13 +213,13 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
log::debug!("found cert {}", fpr);
match set.entry(fpr.clone()) {
Occupied(entry) => {
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) => {
Vacant(_entry) => {
set.insert(fpr.clone(), c);
},
}
@@ -231,7 +229,7 @@ pub async fn lookup_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
Ok(result)
}
pub async fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
-> Vec<Arc<LazyCert<'hc>>> {
@@ -239,14 +237,14 @@ pub async fn get_certificates<'hc>(context: &HuskContext<'hc>, email: &str)
log::debug!("get certificate for {}", email);
let locals = get_local_certificates(context, email);
if locals.len() > 0 {
return locals;
if !locals.is_empty() {
return locals;
}
let mut result = Vec::new();
for introducer in &context.introducers {
log::debug!("consider introducer {:?}",
log::debug!("consider introducer {:?}",
introducer.cert.userids().map(|u| u.userid()).collect::<Vec<_>>());
if introducer.can_introduce(format!("<{}>", email).as_str()) {
log::debug!(" introducer regex matches!");
@@ -312,6 +310,55 @@ pub fn get_introducers<'c>(cert_store: &CertStore<'c>, policy: &StandardPolicy,
result
}
/// 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> {
let mut set = HashMap::new();
let mut introducers = HashSet::new();
let mut targets = Vec::new();
let local_root_fpr = local_trust_root.clone();
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)
.build();
n.certified_userids().iter().for_each(|(fpr, user_id)| {
let paths = n.authenticate(user_id, fpr, wot::FULLY_TRUSTED);
paths.iter().for_each(|(path, amount)| {
if *amount == wot::FULLY_TRUSTED {
if path.residual_depth() == Depth::Limit(0) {
// A leaf, which is not an introducer itself.
targets.push(path.clone());
} else {
// Introducer encountered. Do not add the local trust
// root to the introducers HashSet, otherwise all local
// certificates would be viewed as introduced.
if path.target().fingerprint() != local_root_fpr {
introducers.insert(path.target().fingerprint());
}
}
}
});
});
for p in targets {
let cert_synopsis = p.target().clone();
if let Vacant(_) = set.entry(cert_synopsis.fingerprint()) {
// Only consider non introduced certificates.
if !p.certifications().any(|c|
introducers.contains(&c.issuer().fingerprint())) {
set.insert(cert_synopsis.fingerprint(), cert_synopsis);
}
}
}
set.into_values().collect()
}
/// Checks if a message contains the OpenPGP artifacts of an encrypted message.
pub fn is_encrypted(body: &Bytes) -> bool {
@@ -347,7 +394,6 @@ pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec<Recipie
let mut keys = Vec::new();
// XXX: handle the case where a certificate misses an encryption subkey
for rcpt in recipients {
if rcpt.can_encrypt() {
for cert in &rcpt.certs {
@@ -363,7 +409,7 @@ pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec<Recipie
}
}
}
if keys.len() > 0 {
if !keys.is_empty() {
let mut sink = Vec::new();
let message = Message::new(&mut sink);
let message = Armorer::new(message).build()?;
@@ -378,14 +424,149 @@ pub fn encrypt(context: &HuskContext<'_>, body: &Bytes, recipients: &Vec<Recipie
}
}
/// Roles for authenticate()
#[derive(PartialEq)]
pub enum Role {
Local,
Introducer,
}
/// Authenticate a certificate.
///
/// If `activate` is true, the certificate becomes an introducer for
/// `domains`. Passing `None` or an empty list in this case is an error.
/// If `activate` is false, create a certifiaction with minimal trust_roots
/// amount and no depth. In this case `domains` is ignored.
pub fn authenticate(context: &HuskContext, cert: &ValidCert, role: Role, activate: bool, domains: Option<Vec<String>>)
-> anyhow::Result<()> {
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(HuskError::DamagedLocalTrustRoot(
"missing certification capability".to_string())
.into());
}
};
let mut root_signer = root_signing_key
.clone()
.parts_into_secret()?
.into_keypair()?;
// Create the SignatureBuilder with a default preconfiguration.
let mut builder =
SignatureBuilder::new(SignatureType::GenericCertification)
.set_signature_creation_time(std::time::SystemTime::now())?
.set_exportable_certification(false)?
.set_revocable(true)?;
// set no expiration date
if activate {
// set the trust level and depth for an active local/introducer.
if role == Role::Local {
builder = builder.set_trust_signature(0, 120)?;
} else {
// Introducer
builder = builder.set_trust_signature(1, 120)?;
// Add domains
if let Some(domains) = domains {
if domains.is_empty() {
// An introducer without domains would be unlimited.
return Err(HuskError::UnlimitedIntroducer.into());
}
for domain in domains {
if !mail::is_domain(domain.as_str()) {
return Err(HuskError::InvalidDomainName(domain).into());
}
builder = builder.add_regular_expression(format!("<[^>]+[@.]{}>$",
escape::regex_ctrl_escape(domain.as_str())?
))?;
}
} else {
// An introducer without domains would be unlimited.
return Err(HuskError::UnlimitedIntroducer.into());
}
}
} else {
// deactive the certificate
builder = builder.set_trust_signature(0, 1)?;
}
// 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(HuskError::NoUserIDs(cert.fingerprint().to_hex()).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(())
}
/// Returns true if `cert` can be used for certifications.
pub fn can_certify(cert: &ValidCert) -> bool {
cert.keys()
.for_certification()
.alive()
.supported()
.revoked(false)
.count() > 0
}
/// Returns true if `cert` can be used for encryption.
pub fn can_encrypt(cert: &ValidCert) -> bool {
cert.keys()
.for_transport_encryption()
.alive()
.supported()
.revoked(false)
.count() > 0
}
#[cfg(test)]
pub mod tests {
use bytes::Bytes;
#[test]
pub fn test_is_encrypted() {
use crate::crypto::is_encrypted;
use super::*;
// cleartext
let body = b"Hello world";
+75
View File
@@ -0,0 +1,75 @@
//! Functions to escape/unescape control characters
use aho_corasick::AhoCorasick;
use anyhow::Result;
const REGEX_CONTROL_CHARS: &[&str]
= &[ "\\", ".", "|", "(", ")", "*", "+", "?", "^", "$", "[", "]" ];
const REGEX_ESCAPED_CHARS: &[&str]
= &[ "\\\\", "\\.", "\\|", "\\(", "\\)", "\\*", "\\+", "\\?", "\\^", "\\$", "\\[", "\\]" ];
/// Escape characters in `value` which have a special meaning in regular
/// expressions.
pub fn regex_ctrl_escape(value: &str) -> Result<String> {
let r = AhoCorasick::new(REGEX_CONTROL_CHARS)?;
Ok(r.replace_all(value, REGEX_ESCAPED_CHARS))
}
/// Unescape characters in `value` which have a special meaning in regular
/// expressions. This is the reverse of `regex_ctrl_escape`.
pub fn regex_ctrl_unescape(value: &str) -> Result<String> {
let r = AhoCorasick::new(REGEX_ESCAPED_CHARS)?;
Ok(r.replace_all(value, REGEX_CONTROL_CHARS))
}
/// Create a regular expression for a domain name, including escaping
/// control characters.
pub fn regex_domain_escape(value: &str) -> Result<String> {
regex_ctrl_escape(format!("<[^>]+[@.]{}>$", value).as_str())
}
/// Unescape a regular expression made by `regex_domain_escape`.
pub fn regex_domain_unescape(value: &str) -> Result<String> {
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 {
use super::*;
#[test]
fn regex_escape_test() -> Result<()> {
let original = "(\\.|[.abc])";
let escaped = regex_ctrl_escape(original)?;
let unescaped = regex_ctrl_unescape(&escaped)?;
assert_eq!("\\(\\\\\\.\\|\\[\\.abc\\]\\)", escaped);
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(())
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod crypto;
pub mod escape;
+31 -16
View File
@@ -1,48 +1,63 @@
//
// Parsing the config file
//
//! Parsing the config file
use anyhow;
use std::fs;
use std::sync::{Arc, Mutex};
use serde_derive::Deserialize;
use toml;
use log4rs;
use crate::types::errors::HuskError;
use crate::types::defaults::CONSOLE_LOGGING;
#[derive(Deserialize, Debug, Clone)]
pub struct HuskConfig {
pub connection: String,
pub sequoia_home: String,
logfile_config: Option<String>,
pub keyservers: Option<Vec<String>>,
logging: Option<toml::Value>,
}
impl HuskConfig {
pub fn load(path: &String) -> anyhow::Result<Self> {
pub fn load(path: &str, home: Option<String>) -> anyhow::Result<Self> {
match fs::read_to_string(path) {
Ok(data) => {
match toml::from_str(data.as_str()) {
Ok(configuration) => Ok(configuration),
match toml::from_str::<HuskConfig>(data.as_str()) {
Ok(mut configuration) => {
if let Some(home) = home {
configuration.sequoia_home = home;
}
Ok(configuration)
},
Err(e) => {
Err(HuskError::ConfigFileParseError(e.to_string()).into())
}
}
},
Err(e) => {
Err(HuskError::ConfigFileOpenError(e.to_string()).into())
Err(HuskError::ConfigFileOpenError(format!(
"{} - {}",
path,
e.to_string())).into())
}
}
}
pub fn logfile_config(&self) -> String {
match &self.logfile_config {
Some(c) => c.clone(),
None => {
eprintln!("Using logging configuration from 'log4rs.yml'");
"log4rs.yml".to_string()
},
}
pub fn init_logging(&self) -> anyhow::Result<()> {
let config = match toml::to_string(&self.logging) {
Ok(c) => c,
Err(e) => {
eprintln!("use default logging, because {}", e);
eprintln!("{:?}", self.logging);
CONSOLE_LOGGING.to_string()
}
};
let raw_config = toml::from_str::<log4rs::config::RawConfig>(config.as_str())?;
log4rs::init_raw_config(raw_config)?;
Ok(())
}
pub fn get_keyservers(&self) -> Vec<String> {
+11 -16
View File
@@ -1,8 +1,4 @@
//
// Husk milter
//
// the actual daemon
//
//! the actual daemon
use anyhow;
use bytes::Bytes;
@@ -15,19 +11,17 @@ use indymilter::{
Callbacks,
Context,
EomContext,
Macros,
NegotiateContext,
ProtoOpts,
SocketInfo,
Status,
Config,
ContextActions,
};
use crate::types::husk_context::HuskContext;
use crate::config::{HuskConfig, HuskConfigContainer};
use crate::mail;
use crate::crypto;
use crate::common::crypto;
use crate::types::mail_context::ProtectionPossibility;
pub struct Daemon { }
@@ -96,7 +90,7 @@ impl Daemon {
match HuskContext::new(&config) {
Ok(context) => {
log::debug!("found introducers: {:?}", context.introducers);
cx.data = Some(context).take();
cx.data = Some(context);
Status::Continue
},
Err(e) => {
@@ -140,13 +134,12 @@ impl Daemon {
break;
}
}
if rcpt.is_none() {
Status::Tempfail
} else {
context.add_recipient(rcpt.unwrap()).await;
if let Some(rcpt) = rcpt {
context.add_recipient(rcpt).await;
Status::Continue
} else {
Status::Tempfail
}
} else {
Status::Tempfail
}
@@ -215,7 +208,7 @@ impl Daemon {
if protection == ProtectionPossibility::Full {
// if an error occures the body is not exchanged
match crypto::encrypt(&context, &body, &context.mail.recipients) {
match crypto::encrypt(context, &body, &context.mail.recipients) {
Ok(encrypted) => {
if cx.actions.replace_body(&encrypted).await.is_err() {
log::error!("Cannot exchange body");
@@ -228,6 +221,7 @@ impl Daemon {
}
}
log::logger().flush();
Status::Continue
}
@@ -249,8 +243,9 @@ impl Daemon {
Status::Continue
}
/*
fn print_macros(macros: &Macros) {
println!(" macros: {:?}", macros.to_hash_map());
}
*/
}
+17 -5
View File
@@ -1,8 +1,4 @@
//
// Husk milter
//
// mail functions
//
//! mail functions
use std::ffi::CString;
use sequoia_openpgp::packet::UserID;
@@ -20,3 +16,19 @@ pub fn to_email(rcpt: &CString) -> Option<String> {
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())
}
+20 -19
View File
@@ -1,23 +1,33 @@
//
// Husk milter
//
//! Husk milter
use std::{env, process};
use std::process;
use std::sync::{Arc, Mutex};
use clap::Parser;
use types::defaults;
pub mod cli;
use cli::cli_args::CliArgs;
pub mod commands;
pub mod config;
use config::{HuskConfig, HuskConfigContainer};
pub mod types;
pub mod crypto;
pub mod mail;
pub mod daemon;
use daemon::Daemon;
pub mod common;
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
let args = CliArgs::parse();
let config_file = env::args().nth(1).expect("config file missing");
let husk_config = match HuskConfig::load(&config_file) {
let config_file = match &args.config {
Some(c) => c.as_str(),
None => defaults::CONFIG_FILE_LOCATION
};
// XXX: Improve the way cli parameters are passed into the configuration.
let husk_config = match HuskConfig::load(config_file, args.home.clone()) {
Ok(config) => config,
Err(e) => {
eprintln!("{:?}", e);
@@ -25,18 +35,9 @@ async fn main() {
}
};
log4rs::init_file(husk_config.logfile_config(), Default::default()).unwrap();
log::info!("starting...");
husk_config.init_logging()?;
let config_container: HuskConfigContainer = Arc::new(Mutex::new(husk_config));
match Daemon::run(config_container).await {
Ok(_) => {
println!("exiting...");
},
Err(e) => {
println!("exit with {:?}", e);
}
}
commands::dispatch(args, config_container).await
}
+24
View File
@@ -0,0 +1,24 @@
//! Defaults
pub const CONFIG_FILE_LOCATION: &'static str = "/etc/husk/config.toml";
/// Default logging configuration.
pub const CONSOLE_LOGGING: &'static str = "
[appenders.console]
kind = \"console\"
[appenders.console.encoder]
pattern = \"{d(%Y-%m-%d %H:%M:%S)(local)} {h({l})} [{t}] {m}{n}\"
[[appenders.console.filters]]
kind = \"threshold\"
level = \"debug\"
[root]
level = \"info\"
appenders = [\"console\"]
[loggers.\"husk\"]
level = \"debug\"
";
+10 -6
View File
@@ -1,8 +1,4 @@
//
// Husk milter
//
// Error typ
//
//! Husk Error type
use thiserror::Error;
@@ -12,12 +8,20 @@ pub enum HuskError {
ConfigFileOpenError(String),
#[error("Error while parsing config file: {0}")]
ConfigFileParseError(String),
#[error("Cannot find local trust root")]
#[error("Cannot find the local trust root")]
NoLocalTrustRoot,
#[error("The local trust root is damaged - {0}")]
DamagedLocalTrustRoot(String),
#[error("Cannot access cert store")]
NoCertStore,
#[error("No encryption keys found")]
NoEncryptionKeys,
#[error("Error while looking up email {0}: {1}")]
KeyLookupError(String, String),
#[error("Unlimited introducer")]
UnlimitedIntroducer,
#[error("Invalid domain name '{0}'")]
InvalidDomainName(String),
#[error("Certificate has no user ids - {0}")]
NoUserIDs(String),
}
+24 -14
View File
@@ -1,17 +1,22 @@
//
// Husk milter
//
// Context
//
//! HuskContext
//!
//! Keep data in `HuskContext` while processing emails. The HuskContext
//! lives for a complete session with the MTA. A session might contain
//! more than one email (the session is reused).
//!
//! The context of a single mail is stored in a `MailContext`. The
//! `HuskContext` has a reference to the `MailContext` of the current
//! email.
use anyhow;
use sequoia_openpgp::policy::StandardPolicy;
use std::path::PathBuf;
use sequoia_openpgp::Cert;
use sequoia_cert_store::{CertStore, StoreUpdate};
use sequoia_cert_store::CertStore;
use sequoia_directories::Home;
use crate::{config::HuskConfig, crypto};
use crate::config::HuskConfig;
use crate::common::crypto;
use crate::types::mail_context::MailContext;
use crate::types::introducer::Introducer;
@@ -27,7 +32,7 @@ pub struct HuskContext<'hc> {
impl<'hc> HuskContext<'hc> {
/// Create a new HuskContext instance
/// Create a new HuskContext instance.
pub fn new(config: &HuskConfig) -> anyhow::Result<HuskContext<'hc>> {
let sequoia_home = Home::new(PathBuf::from(&config.sequoia_home))?;
@@ -49,17 +54,22 @@ impl<'hc> HuskContext<'hc> {
})
}
pub fn set_sender(&mut self, sender: String) {
self.mail.set_sender(sender);
}
/// If the `HuskContext` is reused for another email, `reset_mail`
/// creates the neccessary context for it.
pub fn reset_mail(&mut self) {
self.mail = MailContext::new();
}
/// Store the sender of an email in the current `MailContext`.
pub fn set_sender(&mut self, sender: String) {
self.mail.set_sender(sender);
}
/// Add a recipient to the current `MailContext`, try to fetch the
/// corresponding certificate(s).
pub async fn add_recipient(&mut self, rcpt: String) {
let certs = crypto::get_certificates(&self, rcpt.as_str()).await;
let certs = crypto::get_certificates(self, rcpt.as_str()).await;
self.mail.add_recipient(rcpt, certs)
}
+71 -7
View File
@@ -1,12 +1,20 @@
//
// husk-milter
//
// introducers
//
//! introducers
use anyhow::Result;
use sequoia_wot as wot;
use wot::{CertSynopsis, Certification};
use sequoia_openpgp::{policy::StandardPolicy, Cert, KeyID};
use sequoia_openpgp::{cert::ValidCert, policy::StandardPolicy};
use sequoia_openpgp::Cert;
use crate::types::husk_context::HuskContext;
use crate::common::crypto;
#[derive(thiserror::Error, Debug)]
pub enum IntroducerError {
#[error("Unsuitable certificate")]
UnsuitableCertificate,
}
#[derive(Debug, Clone)]
pub struct Introducer {
@@ -16,6 +24,10 @@ pub struct Introducer {
impl Introducer {
/// Can this introducer potentially introduce `email`?
///
/// This check matches the email with the list of domains of the
/// introducer.
pub fn can_introduce(&self, email: &str) -> bool {
if let Some(regexset) = self.certification.regular_expressions() {
if regexset.matches_everything() {
@@ -30,6 +42,7 @@ impl Introducer {
}
}
/// Has this introducer certified cert?
pub fn is_introducing(&self, cert: &Cert) -> bool {
let policy = StandardPolicy::new();
@@ -44,7 +57,7 @@ impl Introducer {
u.certifications().for_each(|s| {
if s.signature_alive(None, None).is_ok() {
s.issuers().for_each(|i| {
if i == &my_keyid { result = true; }
if i == &my_keyid { result = true; }
});
}
})
@@ -53,5 +66,56 @@ impl Introducer {
result
}
/// Create an introducer.
pub fn create(context: &HuskContext, cert: &ValidCert, domains: Vec<String>)
-> 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());
}
crypto::authenticate(
context,
cert,
crypto::Role::Introducer,
true,
Some(domains))
}
/// Remove an introducer.
pub fn remove(context: &HuskContext, cert: &ValidCert, _demote: bool)
-> Result<()> {
// Check if the local trust root certificate is used as
// introducer, which would be an error.
if cert.fingerprint() == context.local_trust_root.fingerprint() {
return Err(IntroducerError::UnsuitableCertificate.into());
}
crypto::authenticate(
context,
cert,
crypto::Role::Introducer,
false,
None)
}
}
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.userids()
.map(|u| String::from_utf8_lossy(u.userid().value()).to_string())
.collect::<Vec<String>>()
.join(", ");
writeln!(f, "Introducer: {}", self.cert.fingerprint())?;
writeln!(f, " UserIDs: {}", uids)?;
Ok(())
}
}
+11 -5
View File
@@ -1,8 +1,7 @@
//
// Husk milter
//
// Mail Context
//
//! Mail Context
//!
//! As a mail gets processed by the milter, the `MailContext` is used
//! to keep the bits and pieces together.
use std::sync::Arc;
use bytes::{Bytes, BytesMut};
@@ -66,6 +65,13 @@ impl<'mc> MailContext<'mc> {
}
/// Default Mailcontext
impl<'mc> Default for MailContext<'mc> {
fn default() -> Self {
Self::new()
}
}
impl From<&MailContext<'_>> for ProtectionPossibility {
fn from(mail_context: &MailContext<'_>) -> ProtectionPossibility {
let mut can_encrypt = false;
+2
View File
@@ -1,5 +1,7 @@
pub mod defaults;
pub mod husk_context;
pub mod mail_context;
pub mod errors;
pub mod recipient;
pub mod introducer;
+4 -8
View File
@@ -1,8 +1,4 @@
//
// Husk milter
//
// Recipient
//
//! Recipient
use anyhow;
use std::sync::Arc;
@@ -17,8 +13,8 @@ pub struct Recipient<'r> {
impl From<String> for Recipient<'_> {
fn from(email: String) -> Self {
Recipient {
email: email,
Recipient {
email,
certs: Vec::new()
}
}
@@ -40,7 +36,7 @@ impl Recipient<'_> {
/// check if this Recipient can encrypt.
// XXX: currently this check is naive
pub fn can_encrypt(&self) -> bool {
if self.certs.len() == 0 {
if self.certs.is_empty() {
return false;
}