Compare commits

..

No commits in common. "main" and "v0.1.8" have entirely different histories.
main ... v0.1.8

31 changed files with 204 additions and 1966 deletions

View file

@ -1,38 +0,0 @@
---
on:
push:
tags:
- "v*"
jobs:
build_wheel:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Build Python wheel
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
python3 setup.py egg_info bdist_wheel
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-wheel-package-upload@v3
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
build_debian:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-python-debian-package@v5
with:
python_module: easywks
package_name: easywks
package_root: package/debian/easywks
package_output_path: package/debian
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-debian-package-upload@v2
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
deb: "package/debian/*.deb"

View file

@ -1,136 +0,0 @@
---
on: push
jobs:
test:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Run unit tests
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
python3 -m coverage combine
python3 -m coverage report --rcfile=setup.cfg
codestyle:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: pycodestyle
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
pycodestyle easywks
easywksserver_gpgwksclient:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Integration Test against gpg-wks-client
run: |
apt update; apt install --yes gnupg2 socat ca-certificates python3-pip
echo "openpgpkey" > /etc/hostname
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
pip3 install --break-system-packages -e .[test]
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org'
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
update-ca-certificates
mkdir -p /tmp/easywks
cat > /tmp/easywks.yml <<EOF
directory: /tmp/easywks
httpd:
host: 127.0.0.1
port: 8080
mailing_method: stdout
domains:
example.org:
submission_address: webkey@example.org
policy_flags:
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat
EOF
easywks --config /tmp/easywks.yml init
easywks --config /tmp/easywks.yml webserver &
socat OPENSSL-LISTEN:443,fork,reuseaddr,verify=0,cert=/etc/ssl/cert.pem,key=/etc/ssl/key.pem TCP:127.0.0.1:8080 &
sleep 3
install -m 0700 -d /tmp/gpg /tmp/cleangpg
export GNUPGHOME=/tmp/gpg
test/genkey.sh alice@example.org
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
/usr/lib/gnupg/gpg-wks-client --supported alice@example.org
/usr/lib/gnupg/gpg-wks-client --check webkey@example.org
PUBREQ="$(/usr/lib/gnupg/gpg-wks-client --create "${FINGERPRINT}" alice@example.org)"
CONFREQ="$(echo "${PUBREQ}" | easywks --config /tmp/easywks.yml process)"
CONFRESP="$(echo "${CONFREQ}" | /usr/lib/gnupg/gpg-wks-client --receive --verbose)"
PUBRESP="$(echo "${CONFRESP}" | easywks --config /tmp/easywks.yml process)"
echo "${PUBRESP}" | gpg --batch --decrypt
/usr/lib/gnupg/gpg-wks-client --check alice@example.org
export GNUPGHOME=/tmp/gpg
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
kill %2 || true
kill %1 || true
easywksserver_easywksclient:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Integration Test against easywks-client
run: |
# General system setup
useradd -d /home/alice -m alice
useradd -d /home/webkey -m webkey
echo alice:supersecurepassword | chpasswd
echo "postfix postfix/mailname string example.org" | debconf-set-selections
echo "postfix postfix/main_mailer_type string 'Local only'" | debconf-set-selections
apt update; apt install --yes gnupg2 ca-certificates python3-pip apache2 dovecot-imapd postfix expect
echo "openpgpkey" > /etc/hostname
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
pip3 install --break-system-packages -e .[test]
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org' -addext 'subjectAltName=DNS:openpgpkey.example.org,DNS:example.org'
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
update-ca-certificates
# Setup Apache
a2enmod ssl proxy_http rewrite
rm /etc/apache2/sites-enabled/000-default.conf
cp test/apache.conf /etc/apache2/sites-enabled/easywks.conf
apache2ctl start
mkdir -p /var/www/html/.well-known/autoconfig/mail/
cp test/config-v1.1.xml /var/www/html/.well-known/autoconfig/mail/config-v1.1.xml
# Setup Dovecot
cp test/dovecot.conf /etc/dovecot/conf.d/99-local.conf
dovecot -F &
# Setup Postfix
/usr/lib/postfix/configure-instance.sh -
cp test/transport /etc/postfix/transport
postmap /etc/postfix/transport
postconf smtpd_tls_cert_file=/etc/ssl/cert.pem
postconf smtpd_tls_key_file=/etc/ssl/key.pem
postconf transport_maps=hash:/etc/postfix/transport
postconf smtpd_sasl_type=dovecot
postconf smtpd_sasl_path=private/auth
postconf smtpd_sasl_auth_enable=yes
/usr/sbin/postmulti -i - -p start
# Setup EasyWKS
mkdir -p /tmp/easywks
cp test/easywks.yml /tmp/easywks.yml
easywks --config /tmp/easywks.yml init
easywks --config /tmp/easywks.yml webserver &
easywks --config /tmp/easywks.yml lmtpd &
sleep 3
# Run the test
install -m 0700 -d /tmp/gpg /tmp/cleangpg
export GNUPGHOME=/tmp/gpg
test/genkey.sh alice@example.org
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
test/expect
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
# Teardown
apache2ctl stop
doveadm stop
/usr/sbin/postmulti -i - -p stop
kill %1 || true
kill %2 || true
sleep 5 # wait for daemons to terminate

104
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,104 @@
---
image: python:3.9-bullseye
stages:
- test
- build
- deploy
before_script:
- pip3 install coverage pycodestyle
- export EASYWKS_VERSION=$(python -c 'import easywks; print(easywks.__version__)')
test:
stage: test
script:
- pip3 install -e .
- python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
- python3 -m coverage combine
- python3 -m coverage report --rcfile=setup.cfg
codestyle:
stage: test
script:
- pip3 install -e .
- pycodestyle easywks
# currently not working for some reason
#build_docker:
# stage: build
# script:
# - apt update && apt install --yes docker.io
# - docker build -t "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" -f package/docker/Dockerfile .
# - docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
# - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.gitlab.com
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA"
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
# only:
# - staging
# - tags
build_wheel:
stage: build
script:
- python3 setup.py egg_info bdist_wheel
- cd dist
- sha256sum *.whl > SHA256SUMS
artifacts:
paths:
- "dist/*.whl"
- dist/SHA256SUMS
only:
- tags
build_debian:
stage: build
script:
- apt update && apt install --yes lintian rsync sudo
- echo -n > package/debian/easywks/usr/share/doc/easywks/changelog
- |
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
echo "easywks (${version}-1); urgency=medium\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/easywks/usr/share/doc/easywks/changelog
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
done
- gzip -9n package/debian/easywks/usr/share/doc/easywks/changelog
- python3 setup.py egg_info install --root=package/debian/easywks/ --prefix=/usr --optimize=1
- cd package/debian
- sed -re "s/__EASYWKS_VERSION__/${EASYWKS_VERSION}/g" -i easywks/DEBIAN/control
- mkdir -p easywks/usr/lib/python3/dist-packages/
- rsync -a easywks/usr/lib/python3.9/site-packages/ easywks/usr/lib/python3/dist-packages/
- rm -rf easywks/usr/lib/python3.9/site-packages
- find easywks/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
- find easywks/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
- find easywks/usr/lib/python3/dist-packages -name '*.pyo' -exec rm {} \;
- sed -re 's$#!/usr/local/bin/python3$#!/usr/bin/python3$' -i easywks/usr/bin/easywks
- find easywks -type f -exec chmod 0644 {} \;
- find easywks -type d -exec chmod 755 {} \;
- chmod +x easywks/usr/bin/easywks easywks/DEBIAN/postinst easywks/DEBIAN/prerm easywks/DEBIAN/postrm
- dpkg-deb --build easywks
- mv easywks.deb "easywks_${EASYWKS_VERSION}-1_all.deb"
- sudo -u nobody lintian "easywks_${EASYWKS_VERSION}-1_all.deb"
- sha256sum *.deb > SHA256SUMS
artifacts:
paths:
- "package/debian/*.deb"
- package/debian/SHA256SUMS
only:
- tags
release:
stage: deploy
script:
- python3 package/release.py
only:
- tags

View file

@ -1,182 +1,5 @@
# EasyWKS Changelog
<!-- BEGIN RELEASE v0.4.6 -->
## Version 0.4.6
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.4.6 -->
- Fix: Don't put multiple keys into a DANE record
<!-- END CHANGES 0.4.6-->
<!-- END RELEASE v0.4.6 -->
<!-- BEGIN RELEASE v0.4.5 -->
## Version 0.4.5
Migrate from Woodpecker CI to Forgejo Actions
### Changes
<!-- BEGIN CHANGES 0.4.5 -->
- Migrate from Woodpecker CI to Forgejo Actions
<!-- END CHANGES 0.4.5-->
<!-- END RELEASE v0.4.5 -->
<!-- BEGIN RELEASE v0.4.4 -->
## Version 0.4.4
smtpd: Log errors to stdout rather than SMTP session
### Changes
<!-- BEGIN CHANGES 0.4.4 -->
- smtpd: Log errors to stdout rather than SMTP session
<!-- END CHANGES 0.4.4-->
<!-- END RELEASE v0.4.4 -->
<!-- BEGIN RELEASE v0.4.3 -->
## Version 0.4.3
Migrate from Gitlab to Forgejo
### Changes
<!-- BEGIN CHANGES 0.4.3 -->
- Migrate from Gitlab to Forgejo
<!-- END CHANGES 0.4.3-->
<!-- END RELEASE v0.4.3 -->
<!-- BEGIN RELEASE v0.4.2 -->
## Version 0.4.2
Minor feature release
### Changes
<!-- BEGIN CHANGES 0.4.2 -->
- Add option to provide DNS NOTIFY to DANE zone replicas
<!-- END CHANGES 0.4.2-->
<!-- END RELEASE v0.4.2 -->
<!-- BEGIN RELEASE v0.4.1 -->
## Version 0.4.1
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.4.1 -->
- Don't refuse DNS IXFRs and respond with full AXFR
<!-- END CHANGES 0.4.1-->
<!-- END RELEASE v0.4.1 -->
<!-- BEGIN RELEASE v0.4.0 -->
## Version 0.4.0
Feature release
### Changes
<!-- BEGIN CHANGES 0.4.0 -->
- Add authoritative DNS server providing DANE OPENPGPKEY (TYPE61) DNS records
<!-- END CHANGES 0.4.0-->
<!-- END RELEASE v0.4.0 -->
<!-- BEGIN RELEASE v0.3.1 -->
## Version 0.3.1
Feature release
### Changes
<!-- BEGIN CHANGES 0.3.1 -->
- Implement standard and EasyWKS-specific policy flags
- **Deprecation**: The top level option `permit_unsigned_response` is deprecated
and will be removed in a future release. Use the per-domain policy flag
`me.s3lph.easywks_permit-unsigned-response` instead.
<!-- END CHANGES 0.3.1 -->
<!-- END RELEASE v0.3.1 -->
<!-- BEGIN RELEASE v0.3.0 -->
## Version 0.3.0
Feature release
### Changes
<!-- BEGIN CHANGES 0.3.0 -->
- Set CORS headers on HTTP responses
- Implement direct WKD URLs
- Allow submitting additional revoked keys with the submission request
<!-- END CHANGES 0.3.0 -->
<!-- END RELEASE v0.3.0 -->
<!-- BEGIN RELEASE v0.2.0 -->
## Version 0.2.0
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.2.0 -->
- Release pipeline runs integration test against gpg-wks-client
- Fix minor incompatibilities with gpg-wks-client
- Fix per-domain configuration (e.g. submission-address was not loaded)
<!-- END CHANGES 0.2.0 -->
<!-- END RELEASE v0.2.0 -->
<!-- BEGIN RELEASE v0.1.11 -->
## Version 0.1.11
Feature release
### Changes
<!-- BEGIN CHANGES 0.1.11 -->
- Add easywks import CLI command
<!-- END CHANGES 0.1.11 -->
<!-- END RELEASE v0.1.11 -->
<!-- BEGIN RELEASE v0.1.10 -->
## Version 0.1.10
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.1.10 -->
- RFC 3156 compliance: Don't base64-encode PGP/MIME messages
<!-- END CHANGES 0.1.10 -->
<!-- END RELEASE v0.1.10 -->
<!-- BEGIN RELEASE v0.1.9 -->
## Version 0.1.9
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.1.9 -->
- Proper handling of "Auto-Submitted: no" mail header
- Fix signature verification of responses signed with a subkey
<!-- END CHANGES 0.1.9 -->
<!-- END RELEASE v0.1.9 -->
<!-- BEGIN RELEASE v0.1.8 -->
## Version 0.1.8

161
README.md
View file

@ -4,10 +4,12 @@
---
This is a work-in-progress project. See ROADMAP.md for details
## What is WKD/WKS?
Due to all the issues involved with the PGP key servers we're using today, GnuPG introduced a feature named [**Web Key
Directory**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
Discovery**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
decentralized and federated approach, where each mail domain is responsible for hosting its users public keys on an
HTTPS web directory. For example, in order to retrieve the key for `john.doe@example.org`, they key can be located at
@ -50,8 +52,6 @@ gpg-wks-server to EasyWKS.
- PyYAML
- bottle.py
- PGPy
- dnspython (for DANE support)
- Twisted (for DANE support)
## License
@ -75,8 +75,8 @@ Configuration is done in `/etc/easywks.yml` (or any other place as specified by
```yaml
---
# EasyWKS works inside this directory. Its PGP keys as well as all
# the submitted and published keys are stored here.
# EasyWKS works inside this directory. Its PGP keys as well# as all
# the submitted and published keys are stored here.
directory: /var/lib/easywks
# Number of seconds after which a pending submission request is
@ -118,11 +118,6 @@ lmtpd:
host: "::1"
port: 8024
# Configure the authoritative DNS server for DANE zones
dnsd:
host: "::1"
port: 8053
# You can override the mail response templates with your own text.
# The following templates can be overridden:
# - "header": Placed in front of every message.
@ -134,7 +129,7 @@ dnsd:
# - {domain}: The email domain for with the request is processed.
# - {sender}: The submitter's mail address.
# - {submission}: The submission address.
# When overriding the "error" template, there's an additional
# When overriding the "error" template, theres an additional
# placeholder you can use:
# - {error}: The error message.
#responses:
@ -259,148 +254,6 @@ gpgwks@example.org lmtp:localhost:10024
webkey@example.com lmtp:localhost:10024
```
### DANE DNS Setup
Apart from WKD, EasyWKS can also serve PGP keys using RFC7929 DNS records ("OPENPGPKEY" or "TYPE61" records). However,
since EasyWKS does not implement DNSSEC signing, it cannot do this alone. The authoritative DNS server in EasyWKS only
responds to AXFR zone transfer requests. In order for DANE lookups to work, the zones must be replicated (AXFR'd) by an
authoritative secondary nameserver that signs the zones itself.
#### EasyWKS DNS Server
Configure EasyWKS to run the DNS server, e.g. using the following systemd unit:
```unit file (systemd)
[Unit]
Description=OpenPGP WKS for Human Beings - DANE DNS Server
[Service]
Type=simple
ExecStart=/path/to/easywks dnsd
Restart=on-failure
User=webkey
Group=webkey
WorkingDirectory=/var/lib/easywks
[Install]
WantedBy=multi-user.target
```
If you're using EasyWKS' DANE feature, it is highly recommended to configure the SOA and NS records for each domain
you're serving. Generally you want to add NS records for all nameservers that will be serving your zone, and at least
set the MNAME and RNAME components of the SOA record. You can also configure EasyWKS to provide zone update
notifications whenever a key is modified:
```yaml
domains:
example.org:
ns:
- ns1.example.org.
- ns2.example.org.
- ns1.example.com.
- ns2.example.com.
notify:
- "2001:db8::53@10053"
soa:
mname: ns1.example.org.
rname: dnsadmin.example.org.
refresh: 300
retry: 60
expire: 1209600
minimal: 300
```
#### Knot
Knot is an authoritative nameserver that supports signing a replicated zone by the secondary (replicating) nameserver.
To configure Knot to transfer the zone from EasyWKS, set up an EasyWKS remote and use it as the replication master for
DANE zones. DNSSEC signing must be enabled as well. If you want Knot to be notified of zone changes, set up a notify
ACL too:
```yaml
acl:
- id: acl-easywks.example.org
address: [::1]
action: notify
remote:
- id: remote-easywks.example.org
address: [::1]@10053
zone:
- domain: _openpgpkey.example.org
master: remote-easywks.example.org
acl: acl-easywks.example.org
dnssec-signing: on
dnssec-policy: ...
```
## EasyWKS Client
The file `client.py` contains a self-contained WKS client, which
prompts you for your email address and IMAP/SMTP/POP3 password, and
then attempts to figure out the mail servers via common
autoconfiguration methods. Afterwards it will attempt a WKS key submission:
```console?prompt=$, 
$ ./client.py
Enter email: john.doe@example.org
Chose A58D3221F8079F35FF084890505A563492A56583
Enter IMAP/POP3/SMTP password (will not echo): ********
Autoconfigured incoming server: imaps://john.doe@example.org@imap.example.org:993
Autoconfigured outgoing server: smtp+starttls://john.doe@example.org@smtp.example.org:587
Please confirm: [Y/n] y
Retrieved submission key
Retrieved key to publish
Created encrypted message
Sending submission request
Awaiting response
Received confirmation request
Nonce: 95184efbc5d2f75ed4b56162
Creating confirmation response. GnuPG may prompt you for your passphrase.
Sending confirmation response
Awaiting publish response
Decrypting WKS response. GnuPG may prompt you for your passphrase.
Hi there!
This is the EasyWKS system at example.org
Your key has been published to the Web Key Directory.
You can test WKD key retrieval e.g. with:
gpg --auto-key-locate=wkd,nodefault --locate-key john.doe@example.org
For more information on WKD and WKS see:
https://gnupg.org/faq/wkd.html
https://gnupg.org/faq/wks.html
Regards
EasyWKS
--
Dance like nobody is watching.
Encrypt live everybody is.
```
## Manual Key Import
In addition to WKS, EasyWKS also provides a command line interface for
importing keys from standard input. This feature is mainly intended
to be used for technical email accounts where using WKS might prove to
be difficult:
```console?prompt=$, 
$ cat pubkey.asc | easywks import
Skipping foreign email john.doe@notmydepartment.org
Imported key A58D3221F8079F35FF084890505A563492A56583 for email john.doe@example.org
```
[wkd]: https://wiki.gnupg.org/WKD
[wks]: https://wiki.gnupg.org/WKS
[ietf]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-12
[ietf]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-12

646
client.py
View file

@ -1,646 +0,0 @@
#!/usr/bin/env python3
import abc
import time
from getpass import getpass
from datetime import datetime, timezone
import time
import imaplib
import poplib
import smtplib
import subprocess
import urllib.error
import urllib.parse
import urllib.request
import email
from email.mime.application import MIMEApplication
from email.mime.text import MIMEText
from email.parser import BytesParser
from email.utils import format_datetime
from xml.etree import ElementTree
def _get_pgp_message(message: email.message.Message) -> bytes:
pgp = None
for part in message.walk():
if part.is_multipart():
continue
p = part.get_content()
if isinstance(p, str):
p = p.encode()
if b'BEGIN PGP MESSAGE' in p:
if pgp is not None:
raise ValueError('More than one encrypted message part')
pgp = p
if pgp is None:
raise ValueError('No encrypted message part')
return pgp
class MailServerConfig(abc.ABC):
def __init__(self, proto, hostname, port, tls, username, password):
self.proto = proto
self.hostname = hostname
self.port = port
self.tls = tls
self.username = username
self.password = password
def __repr__(self):
proto = self.proto + ('s' if self.tls == 'SSL' else '') + ('+starttls' if self.tls == 'STARTTLS' else '')
return f'{proto}://{self.username}@{self.hostname}:{self.port}'
def __eq__(self, other):
if not isinstance(other, MailServerConfig):
return False
return self.proto == other.proto \
and self.hostname == other.hostname \
and self.port == other.port \
and self.tls == other.tls \
and self.username == other.username \
and self.password == other.password
def __lt__(self, other):
if not isinstance(other, MailServerConfig):
raise TypeError(f'Cannot compare {type(self)} to {type(other)}')
if self == other:
return False
# SMTP not comparable to IMAP or POP3
if (self.proto == 'smtp' or other.proto == 'smtp') and self.proto != other.proto:
raise TypeError(f'Cannot compare {type(self)} to {type(other)}')
# IMAP < POP3
if self.proto == 'imap' and other.proto == 'pop3':
return True
# SSL < STARTTLS < plain
if (self.tls == 'SSL' and other.tls != 'SSL') or (self.tls == 'STARTTLS' and other.tls == 'plain'):
return True
# full email < local part
if '@' in self.username and '@' not in other.username:
return True
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
pass
class SmtpServerConfig(MailServerConfig):
def __init__(self, hostname, port, tls, username, password):
super().__init__('smtp', hostname, port, tls, username, password)
self._smtp = None
def __enter__(self):
cls = smtplib.SMTP if self.tls != 'SSL' else smtplib.SMTP_SSL
self._smtp = cls(self.hostname, self.port)
smtp = self._smtp.__enter__()
if self.tls == 'STARTTLS':
smtp.starttls()
smtp.login(self.username, self.password)
return smtp
def __exit__(self, exc_type, exc_val, exc_tb):
ret = self._smtp.__exit__(exc_type, exc_val, exc_tb)
self._smtp = None
return ret
def send_message(self, msg):
if self._smtp is None:
raise RuntimeError('SMTP connection is not established')
self._smtp.send_message(msg)
class IncomingServerConfig(MailServerConfig, abc.ABC):
def __init__(self, proto, hostname, port, tls, username, password):
super().__init__(proto, hostname, port, tls, username, password)
def __enter__(self):
pass
def __exit__(self, exc_type, exc_val, exc_tb):
pass
def get_new_messages(self):
pass
class ImapServerConfig(IncomingServerConfig):
def __init__(self, hostname, port, tls, username, password):
super().__init__('imap', hostname, port, tls, username, password)
self._imap = None
self._uidnext = None
def __enter__(self):
cls = imaplib.IMAP4 if self.tls != 'SSL' else imaplib.IMAP4_SSL
self._imap = cls(self.hostname, self.port)
imap = self._imap.__enter__()
if self.tls == 'STARTTLS':
imap.starttls()
imap.login(self.username, self.password)
imap.select('INBOX', readonly=True)
self._uidnext = int(imap.response('UIDNEXT')[1][0].decode())
return imap
def __exit__(self, exc_type, exc_val, exc_tb):
ret = self._imap.__exit__(exc_type, exc_val, exc_tb)
self._imap = None
self._uidnext = None
return ret
def get_new_messages(self):
if self._imap is None or self._uidnext is None:
raise RuntimeError('IMAP connection is not established')
self._imap.select('INBOX', readonly=True)
u = int(self._imap.response('UIDNEXT')[1][0].decode())
if u > self._uidnext:
messages = self._imap.uid('fetch', f'{self._uidnext}:{u - 1}', '(RFC822)')
for message in messages[1]:
if not isinstance(message, tuple) or b'RFC822' not in message[0]:
# Not an email message
continue
yield message[1]
self._uidnext = u
class Pop3ServerConfig(IncomingServerConfig):
def __init__(self, hostname, port, tls, username, password):
super().__init__('pop3', hostname, port, tls, username, password)
self._pop3: poplib.POP3 = None
self._messagelist = None
def __enter__(self):
cls = poplib.POP3 if self.tls != 'SSL' else poplib.POP3_SSL
self._pop3 = cls(self.hostname, self.port)
if self.tls == 'STARTTLS':
self._pop3.stls()
self._pop3.user(self.username)
self._pop3.pass_(self.password)
self._messagelist = set(self._pop3.list()[1])
return self._pop3
def __exit__(self, exc_type, exc_val, exc_tb):
self._messagelist = None
self._pop3.close()
self._pop3 = None
def get_new_messages(self):
if self._pop3 is None or self._messagelist is None:
raise RuntimeError('POP3 connection is not established')
ml = set(self._pop3.list()[1])
for msg in ml.difference(self._messagelist):
msgid, _ = msg.split(' ', 1)
message = '\r\n'.join(self._pop3.retr(msgid)[1])
yield message[1]
self._messagelist = ml
def default_port(proto: str, tls: str):
if proto == 'imap':
return 993 if tls == 'SSL' else 143
if proto == 'pop3':
return 995 if tls == 'SSL' else 110
if proto == 'smtp':
return 465 if tls == 'SSL' else 587
raise ValueError(f'Unknown protocol: {proto}')
def template_username(template: str, address: str, userinputs):
user, domain = address.split('@', 1)
template = template \
.replace('%EMAILADDRESS%', address) \
.replace('%EMAILLOCALPART%', user) \
.replace('%EMAILDOMAIN%', domain)
for key in userinputs:
if key not in template:
continue
label, value = userinputs[key]
if value is None:
value = getpass(f'Autoconfiguration field "{label}" (will not echo): ')
userinputs[key] = label, value
template = template.replace(key, value)
return template
def parse_xml_mailserver(xmle, address: str, password: str, userinputs):
proto = xmle.get('type')
hostname = xmle.find('./hostname').text
tls = xmle.find('./socketType').text
port = int(xmle.find('./port').text or default_port(proto, tls))
username = template_username(xmle.find('./username').text, address, userinputs)
pw = xmle.find('./password')
if pw:
pw = template_username(pw.text, address, userinputs)
else:
pw = password
if proto == 'smtp':
return SmtpServerConfig(hostname, port, tls, username, pw)
elif proto == 'imap':
return ImapServerConfig(hostname, port, tls, username, pw)
elif proto == 'pop3':
return Pop3ServerConfig(hostname, port, tls, username, pw)
def parse_thunderbird_autoconfig(xml: str, address: str, password: str):
user, domain = address.split('@', 1)
root = ElementTree.fromstring(xml)
userinputs = {}
for ui in root.findall('.//userinput'):
k = ui.get('key')
label = ui.get('label')
userinputs[k] = (label, None)
incoming = root.findall(f"./emailProvider/domain[.='{domain}']/../incomingServer")
outgoing = root.findall(f"./emailProvider/domain[.='{domain}']/../outgoingServer")
iconf = [parse_xml_mailserver(i, address, password, userinputs) for i in incoming]
oconf = [parse_xml_mailserver(o, address, password, userinputs) for o in outgoing]
return iconf, oconf
def tb_wellknown_autoconfig(address: str, password: str):
user, domain = address.split('@', 1)
subdomain = f'autoconfig.{domain}'
sdurl = urllib.parse.urlunsplit(('http', subdomain, 'mail/config-v1.1.xml', f'emailaddress={address}', ''))
mdurl = urllib.parse.urlunsplit(('http', domain, '.well-known/autoconfig/mail/config-v1.1.xml',
f'emailaddress={address}', ''))
try:
with urllib.request.urlopen(sdurl) as sdresponse:
return parse_thunderbird_autoconfig(sdresponse.read().decode(), address, password)
except urllib.error.URLError:
try:
with urllib.request.urlopen(mdurl) as mdresponse:
return parse_thunderbird_autoconfig(mdresponse.read().decode(), address, password)
except urllib.error.URLError:
return None
def tb_ispdb_autoconfig(address: str, password: str):
user, domain = address.split('@', 1)
ispdb = f'https://autoconfig.thunderbird.net/v1.1/{domain}'
try:
with urllib.request.urlopen(ispdb) as response:
return parse_thunderbird_autoconfig(response.read().decode(), address, password)
except urllib.error.URLError:
return None
def manual_config(address: str, password: str):
print('Autoconfiguration has failed. Please enter the mail server settings manually.')
host = input('SMTP hostname: ')
tls = input('TLS ("plain", "SSL" or "STARTLS (default: STARTTLS): ') or 'STARTTLS'
p = default_port('smtp', tls)
port = int(input(f'SMTP port (default: {p}): ') or str(p))
outconfig = SmtpServerConfig(host, port, tls, address, password)
proto = input('Mail Retrieval protocol ("imap" or "pop3", default: "imap"): ') or 'imap'
host = input(f'{proto.upper()} hostname: ')
tls = input('TLS ("plain", "SSL" or "STARTLS (default: SSL): ') or 'SSL'
p = default_port(proto, tls)
port = int(input(f'{proto.upper()} port (default: {p}): ') or str(p))
if proto == 'imap':
inconfig = ImapServerConfig(host, port, tls, address, password)
else:
inconfig = Pop3ServerConfig(host, port, tls, address, password)
return [inconfig], [outconfig]
def parse_dns_srv_record(record, service: str, address: str, password: str):
user, domain = address.split('@', 1)
if service == '_submission':
cls = SmtpServerConfig
elif service == '_imaps' or service == '_imap':
cls = ImapServerConfig
elif service == '_pop3s' or service == '_pop3':
cls = Pop3ServerConfig
else:
raise ValueError(service)
if service.endswith('s'):
tls = ['SSL']
else:
tls = ['STARTTLS', 'plain']
configs = []
for t in tls:
configs.append(cls(record.target, record.port, t, address, password))
configs.append(cls(record.target, record.port, t, user, password))
return configs
def rfc6186_autoconfig(address: str, password: str):
try:
import dns.resolver
import dns.rdtypes.IN.SRV
except ImportError:
print('"dnspython" dependency missing. Skipping RFC 6186 autoconfig')
return None
user, domain = address.split('@', 1)
smtpdomain = f'_submission._tcp.{domain}.'
imapsdomain = f'_imaps._tcp.{domain}.'
imapdomain = f'_imap._tcp.{domain}.'
pop3sdomain = f'_pop3s._tcp.{domain}.'
pop3domain = f'_pop3._tcp.{domain}.'
osrv = []
isrv = []
try:
smtpanswer: dns.resolver.Answer = dns.resolver.resolve(smtpdomain, dns.rdatatype.SRV)
except dns.exception.DNSException:
return None
for srv in smtpanswer:
if srv.rdtype != dns.rdatatype.SRV or srv.rdclass != dns.rdataclass.IN:
continue
osrv.append((smtpanswer.canonical_name.labels[0].decode(), srv))
for sd in [imapsdomain, imapdomain, pop3sdomain, pop3domain]:
try:
answer: dns.resolver.Answer = dns.resolver.resolve(sd, dns.rdatatype.SRV)
for srv in answer:
if srv.rdtype != dns.rdatatype.SRV or srv.rdclass != dns.rdataclass.IN:
continue
if srv.port == 0 and str(srv.target) == '.':
continue
isrv.append((answer.canonical_name.labels[0].decode(), srv))
except dns.exception.DNSException:
continue
incoming = []
outgoing = []
for (iqname, irec) in isrv:
incoming.extend(parse_dns_srv_record(irec, iqname, address, password))
for (oqname, orec) in isrv:
outgoing.extend(parse_dns_srv_record(orec, oqname, address, password))
return incoming, outgoing
def _parse_confirmation_request(address, fingerprint, encrypted):
gpg = subprocess.Popen(['/usr/bin/gpg', '--decrypt'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gpg.stdin.write(encrypted)
gpg.stdin.close()
gpg.wait()
if gpg.returncode != 0:
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
decrypted = gpg.stdout.read().decode()
rdict = {}
for line in decrypted.splitlines():
if ':' not in line:
continue
key, value = line.split(':', 1)
rdict[key.strip()] = value.strip()
if rdict.get('type', '') != 'confirmation-request':
raise ValueError('Invalid confirmation request: "type" missing or not "confirmation-request"')
if 'sender' not in rdict or 'address' not in rdict or 'fingerprint' not in rdict or 'nonce' not in rdict:
raise ValueError('Invalid confirmation request: a mandatory item is missing from the request')
if rdict['address'] != address:
raise ValueError(f'Confirmation address "{rdict["address"]}" does not match my address "{address}"')
if rdict['fingerprint'].replace(' ', '') != fingerprint.replace(' ', ''):
raise ValueError(
f'Confirmation fingerprint "{rdict["fingerprint"]}" does not match my fingerprint "{fingerprint}"')
print(f'Nonce: {rdict["nonce"]}')
return rdict['sender'], rdict['nonce']
def _create_submission_request(address: str, submission_address: str, fingerprint: str, revoked_fingerprints):
gpg = subprocess.Popen([
'/usr/bin/gpg', '--locate-keys', '--with-colons', submission_address
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gpg.wait()
if gpg.returncode != 0:
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
print('Retrieved submission key')
gpg = subprocess.Popen([
'/usr/bin/gpg', '--armor',
'--export-options', 'export-minimal',
'--export', fingerprint
] + revoked_fingerprints, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gpg.wait()
if gpg.returncode != 0:
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
print('Retrieved key to publish')
pubkey = gpg.stdout.read()
gpg = subprocess.Popen([
'/usr/bin/gpg', '--armor', '--with-colons',
'--encrypt', '--trust-model', 'always',
'--local-user', address,
'--recipient', address,
'--recipient', submission_address
], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gpg.stdin.write(pubkey)
gpg.stdin.close()
try:
gpg.wait(timeout=2)
except subprocess.TimeoutExpired:
gpg.kill()
print(f'gpg subprocess timed out; stderr: {gpg.stderr.read()}')
raise RuntimeError(f'gpg subprocess timed out; stderr: {gpg.stderr.read()}')
if gpg.returncode != 0:
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
print('Created encrypted message')
encrypted = gpg.stdout.read().decode()
mail = MIMEText(encrypted, _subtype='plain')
mail['Subject'] = 'WKS submission request'
mail['To'] = submission_address
mail['From'] = address
mail['Date'] = format_datetime(datetime.now(timezone.utc))
return mail
def _create_confirmation_response(address: str, submission: str, nonce: str, fp: str, content_subtype: str):
response_template = '\r\n'.join([
'type: confirmation-response',
f'sender: {address}',
f'nonce: {nonce}',
''
])
payload = MIMEText(response_template, _subtype='plain')
gpg = subprocess.Popen([
'/usr/bin/gpg', '--armor', '--with-colons',
'--encrypt', '--sign', '--trust-model', 'always',
'--local-user', fp,
'--recipient', address,
'--recipient', submission
], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gpg.stdin.write(payload.as_string(policy=email.policy.default).encode())
gpg.stdin.close()
gpg.wait()
if gpg.returncode != 0:
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
encrypted = gpg.stdout.read().decode()
mail = MIMEApplication(encrypted, _subtype=content_subtype)
mail['Subject'] = 'WKS confirmation response'
mail['To'] = submission
mail['From'] = address
mail['Date'] = format_datetime(datetime.now(timezone.utc))
return mail
def handle_incoming_message(address, fingerprint, rfc822, smtp_config: SmtpServerConfig):
msg: email.message.EmailMessage = BytesParser(policy=email.policy.default).parsebytes(rfc822)
if msg.get('wks-phase', '') == 'done':
pgp = _get_pgp_message(msg)
if pgp is None:
print('WKS key submission successful. Congratulations!')
return True
print('Decrypting WKS response. GnuPG may prompt you for your passphrase.')
gpg = subprocess.Popen(['/usr/bin/gpg', '--batch', '--decrypt', '--skip-verify'],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
gpg.stdin.write(pgp)
gpg.stdin.close()
gpg.wait()
if gpg.returncode != 0:
print('It seems WKS submission was successful, however decryption of the submission response failed: ' +
gpg.stderr.read().decode())
return True
decrypted = email.parser.BytesParser(policy=email.policy.default).parsebytes(gpg.stdout.read())
body = decrypted.get_body(preferencelist=('plain',))
if body is None:
print('WKS key submission successful. Congratulations!')
else:
print(body.get_content())
return True
if msg.get('wks-phase', '') == 'error':
body = msg.get_body(preferencelist=('plain',))
if body is None:
print('WKS key submission failed. However, the WKS server did not return an error description.')
else:
print(body.get_content())
return True
for leaf in msg.walk():
if leaf.get_content_type() not in ['application/vnd.gnupg.wkd', 'application/vnd.gnupg.wks']:
continue
print('Received confirmation request')
try:
submission, nonce = _parse_confirmation_request(address, fingerprint, leaf.get_content())
except BaseException as e:
print(f'Parsing failed: {e}')
continue
print('Creating confirmation response. GnuPG may prompt you for your passphrase.')
response = _create_confirmation_response(address, submission, nonce, fingerprint, leaf.get_content_subtype())
print('Sending confirmation response')
with smtp_config:
smtp_config.send_message(response)
print('Awaiting publish response')
return False
def _gpg_get_uid_fp(address: str):
gpg = subprocess.Popen([
'/usr/bin/gpg', '--with-colons', '--list-keys', address
], stdout=subprocess.PIPE)
gpg.wait()
if gpg.returncode != 0:
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
keylist = gpg.stdout.read().decode()
pubs = []
revoked = []
fprs = []
for line in keylist.splitlines():
if line.startswith('pub:'):
pub = line.split(':')[4]
r = line.split(':')[1] == 'r'
pubs.append(pub)
revoked.append(r)
elif line.startswith('fpr:'):
fpr = line.split(':')[9]
fprs.append(fpr)
valid = {next((f for f in fprs if f.endswith(pub))): pub for i, pub in enumerate(pubs) if not revoked[i]}
revoked = {next((f for f in fprs if f.endswith(pub))): pub for i, pub in enumerate(pubs) if revoked[i]}
if len(valid) == 0:
raise ValueError(f'No valid key found for {address}.')
elif len(valid) > 1:
print(f'Found multiple keys for {address}, please choose:')
fpridx = list(valid.keys())
for i, f in enumerate(fpridx, start=1):
print(f'{i}: {f}')
i = int(input('Enter number: ')) - 1
fpr = fpridx[i]
else:
fpr = list(valid.keys())[0]
if len(revoked) > 0:
print(f'There are revoked keys for {address}. Please choose which to upload (separate multiple by spaces): ')
revidx = list(revoked.keys())
for i, f in enumerate(revidx, start=1):
print(f'{i}: {f}')
rids = [int(i)-1 for i in input('Enter number(s): ').split()]
rfprs = [revidx[i] for i in rids]
else:
rfprs = []
return fpr, rfprs
def _get_submission_address(address: str):
_, domain = address.split('@', 1)
advanced_url = f'https://openpgpkey.{domain}/.well-known/openpgpkey/{domain}/submission-address'
direct_url = f'https://{domain}/.well-known/openpgpkey/submission-address'
try:
with urllib.request.urlopen(advanced_url) as response:
return response.read().decode().strip()
except urllib.error.URLError:
pass
with urllib.request.urlopen(direct_url) as response:
return response.read().decode().strip()
def main():
ad = input('Enter email: ')
sa = None
try:
sa = _get_submission_address(ad)
except urllib.error.URLError:
print('No WKS submission address found. Does your provider support WKS?')
exit(1)
fp, rfprs = _gpg_get_uid_fp(ad)
print(f'Chose {fp}')
for rfpr in rfprs:
print(f'Chose revoked key {rfpr}')
pw = getpass('Enter IMAP/POP3/SMTP password (will not echo): ')
for fn in [tb_wellknown_autoconfig, rfc6186_autoconfig, tb_ispdb_autoconfig, manual_config]:
autoconf = fn(ad, pw)
if autoconf is not None:
break
if autoconf is None:
raise RuntimeError('No autoconfig available')
incoming, outgoing = autoconf
# Find the first working server configurations
incoming_server = None
outgoing_server = None
for i in sorted(incoming):
try:
with i:
incoming_server = i
break
except:
continue
if incoming_server is None:
raise RuntimeError('No working IMAP/POP3 server found through autoconfiguration. Please specify manually.')
print(f'Autoconfigured incoming server: {incoming_server}')
for i in sorted(outgoing):
try:
with i:
outgoing_server = i
break
except:
continue
if outgoing_server is None:
raise RuntimeError('No working SMTP server found through autoconfiguration. Please specify manually.')
print(f'Autoconfigured outgoing server: {outgoing_server}')
confirm = input('Please confirm: [Y/n] ')
if confirm.lower() not in ['', 'y', 'yes']:
print('Aborted')
exit(1)
with incoming_server:
now = time.monotonic()
done = False
request = _create_submission_request(ad, sa, fp, rfprs)
print('Sending submission request')
with outgoing_server:
outgoing_server.send_message(request)
print('Awaiting response')
while not done and time.monotonic() - now < 300:
time.sleep(5)
for message in incoming_server.get_new_messages():
done = handle_incoming_message(ad, fp, message, outgoing_server)
if done:
break
if __name__ == '__main__':
main()

View file

@ -1,2 +1,2 @@
__version__ = '0.4.6'
__version__ = '0.1.8'

View file

@ -3,33 +3,6 @@ import string
import yaml
POLICY_MAILBOX_ONLY = 'mailbox-only'
POLICY_AUTH_SUBMIT = 'auth-submit'
POLICY_PROTOCOL_VERSION = 'protocol-version'
STANDARD_POLICY_FLAGS = {
POLICY_MAILBOX_ONLY: bool,
# 'dane-only': bool, # deprecated
POLICY_AUTH_SUBMIT: bool,
POLICY_PROTOCOL_VERSION: int,
}
EASYWKS_POLICY_PREFIX = 'me.s3lph.easywks_'
EWP_PERMIT_UNSIGNED_RESPONSE = EASYWKS_POLICY_PREFIX + 'permit-unsigned-response'
EWP_STRIP_UNVERIFIED_UIDS = EASYWKS_POLICY_PREFIX + 'strip-unverified-uids'
EWP_STRIP_UA_UIDS = EASYWKS_POLICY_PREFIX + 'strip-ua-uids'
EWP_STRIP_3RDPARTY_SIGNATURES = EASYWKS_POLICY_PREFIX + 'strip-3rdparty-signatures'
EWP_MAX_REVOKED_KEYS = EASYWKS_POLICY_PREFIX + 'max-revoked-keys'
EWP_MINIMIZE_REVOKED_KEYS = EASYWKS_POLICY_PREFIX + 'minimize-revoked-keys'
EASYWKS_POLICY_FLAGS = {
EWP_PERMIT_UNSIGNED_RESPONSE: bool,
EWP_STRIP_UNVERIFIED_UIDS: bool,
EWP_STRIP_UA_UIDS: bool,
EWP_STRIP_3RDPARTY_SIGNATURES: bool,
EWP_MAX_REVOKED_KEYS: int,
EWP_MINIMIZE_REVOKED_KEYS: bool,
}
def _validate_mailing_method(value):
methods = ['stdout', 'smtp']
if value not in methods:
@ -92,15 +65,6 @@ def _validate_lmtpd_config(value):
return f'port must be a int, got {type(value["port"])}'
def _validate_dnsd_config(value):
if not isinstance(value, dict):
return f'must be a map, got {type(value)}'
if not isinstance(value['host'], str):
return f'host must be a str, got {type(value["host"])}'
if not isinstance(value['port'], int):
return f'port must be a int, got {type(value["port"])}'
def _validate_policy_flags(value):
alphabet = string.ascii_lowercase + string.digits + '-._'
if not isinstance(value, dict):
@ -113,46 +77,6 @@ def _validate_policy_flags(value):
for c in flag:
if c not in alphabet:
return f'has invalid key {flag}'
if '_' in flag:
if flag.startswith(EASYWKS_POLICY_PREFIX):
cls = EASYWKS_POLICY_FLAGS.get(flag)
if flag not in EASYWKS_POLICY_FLAGS:
return f'unknown policy flag {flag}'
if not isinstance(v, cls):
return f'invalid type {v.__class__.__name__} for flag {flag}'
else:
cls = STANDARD_POLICY_FLAGS.get(flag)
if flag not in STANDARD_POLICY_FLAGS:
return f'unknown policy flag {flag}'
if not isinstance(v, cls):
return f'invalid type {v.__class__.__name__} for flag {flag}'
def _validate_dane(value):
if not isinstance(value, dict):
return f'must be a map, got {type(value)}'
if 'soa' in value:
pass
else:
value['soa'] = {}
if 'ns' in value:
ns = value['ns']
if not isinstance(ns, list):
return f'ns must map to a list, got {type(ns)}'
for k in ns:
if not isinstance(k, str):
return f'ns items must be strings, got {type(k)}'
else:
value['ns'] = ['localhost.']
if 'notify' in value:
notify = value['notify']
if not isinstance(notify, list):
return f'notify must map to a list, got {type(notify)}'
for k in notify:
if not isinstance(k, str):
return f'notify items must be strings, got {type(k)}'
else:
value['notify'] = []
def _validate_responses(value):
@ -249,10 +173,9 @@ class _GlobalConfig(_Config):
def __make_domain(self, domain):
self.__domains[domain] = _Config(
submission_address=_ConfigOption('submission_address', str, f'gpgwks@{domain}'),
submission_address=_ConfigOption('address', str, f'gpgwks@{domain}'),
passphrase=_ConfigOption('passphrase', str, ''),
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags),
dane=_ConfigOption('dane', dict, {}, validator=_validate_dane)
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags)
)
def __getitem__(self, item):
@ -271,14 +194,13 @@ class _GlobalConfig(_Config):
for domain, dconf in conf['domains'].items():
self.__make_domain(domain)
for co in self.__domains[domain]._options.values():
co.load(dconf)
co.load(conf)
Config = _GlobalConfig(
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method),
# TODO: permit_unsigned_response is deprecated, but for now retained for backwards compatibility
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
httpd=_ConfigOption('httpd', dict, {
'host': 'localhost',
@ -295,10 +217,6 @@ Config = _GlobalConfig(
'host': 'localhost',
'port': 25,
}, validator=_validate_lmtpd_config),
dnsd=_ConfigOption('dnsd', dict, {
'host': '::1',
'port': 10053,
}, validator=_validate_dnsd_config),
responses=_ConfigOption('responses', dict, {}, validator=_validate_responses),
)

View file

@ -1,90 +0,0 @@
from datetime import datetime
from twisted.internet import reactor, defer
from twisted.names import dns, server, common, error
from twisted.python import util as tputil, failure
from .config import Config
from .files import read_dane_public_keys
class Record_OPENPGPKEY(tputil.FancyEqMixin, tputil.FancyStrMixin):
TYPE = 61
fancybasename = 'OPENPGPKEY'
compareAttributes = ('data',)
showAttributes = ('data',)
def __init__(self, data=None, ttl=0):
self.data = data
self.ttl = ttl
def encode(self, strio, compDict=None):
strio.write(self.data)
def decode(self, strio, length=None):
self.data = strio.read(length)
def __hash__(self):
return hash(self.data)
class DnsServer(common.ResolverBase):
def __init__(self):
super().__init__()
self.zones = {}
self._load()
def _load(self):
for domain in Config.domains:
origin = dns.domainString(f'_openpgpkey.{domain}')
self.zones[origin] = domain
def _make_soa(self, name):
domain = self.zones[name]
now = int(datetime.utcnow().timestamp()) // 60
soa = dns.Record_SOA(mname=Config[domain].dane['soa'].get('mname', 'localhost.'),
rname=Config[domain].dane['soa'].get('rname',
Config[domain].submission_address.replace('@', '.')),
serial=now,
refresh=Config[domain].dane['soa'].get('refresh', 300),
retry=Config[domain].dane['soa'].get('retry', 60),
expire=Config[domain].dane['soa'].get('expire', 2419200),
minimum=Config[domain].dane['soa'].get('minimum', 300))
return dns.RRHeader(name, dns.SOA, payload=soa, auth=True)
def _make_ns(self, name):
domain = self.zones[name]
return [
dns.RRHeader(name, dns.NS, payload=dns.Record_NS(host), auth=True)
for host in Config[domain].dane['ns']
]
def _lookup(self, name, cls, type, timeout):
if name not in self.zones:
return defer.fail(failure.Failure(dns.AuthoritativeDomainError(name)))
if type == dns.SOA or type == dns.OPT:
return defer.succeed(([self._make_soa(name)], [], []))
if type != dns.AXFR and type != dns.IXFR:
return defer.fail(failure.Failure(error.DNSQueryRefusedError(name)))
domain = self.zones[name]
results = []
soa = self._make_soa(name)
ns = self._make_ns(name)
results.append(soa)
results.extend(ns)
for digest, key in read_dane_public_keys(domain).items():
fqdn = f'{digest}._openpgpkey.{domain}'
record = Record_OPENPGPKEY(key)
results.append(dns.RRHeader(dns.domainString(fqdn), record.TYPE, payload=record, auth=True))
results.append(soa)
return defer.succeed((results, [], []))
# noinspection PyUnresolvedReferences
# The "reactor" interface is created dynamically, so the listenTCP and run methods only become available during runtime.
def run_dnsd(args):
auth = DnsServer()
factory = server.DNSServerFactory(authorities=[auth])
reactor.listenTCP(Config.dnsd['port'], factory, interface=Config.dnsd['host'])
reactor.run()

View file

@ -8,7 +8,7 @@ from pgpy import PGPKey
from .config import Config
from .crypto import create_pgp_key, privkey_to_pubkey
from .util import hash_user_id, armor_keys, split_revoked, dane_digest, dane_notify
from .util import hash_user_id
def _locked_read(file: str, binary: bool = False):
@ -36,15 +36,10 @@ def make_submission_address_file(domain: str):
def make_policy_file(domain: str):
content = f'submission-address: {Config[domain].submission_address}\n'
for flag, value in Config[domain].policy_flags.items():
if isinstance(value, bool):
if not value:
continue
else:
content += flag + '\n'
elif value is None:
content += flag + '\n'
else:
if not value or len(value) == 0:
content += f'{flag}: {value}\n'
else:
content += flag + '\n'
return content
@ -58,62 +53,42 @@ def init_working_directory():
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
_locked_write(os.path.join(wdir, domain, 'submission-address'), make_submission_address_file(domain))
_locked_write(os.path.join(wdir, domain, 'policy'), make_policy_file(domain))
os.makedirs(os.path.join(wdir, domain, 'dane'), exist_ok=True)
# Create PGP key if it doesn't exist yet
create_pgp_key(domain)
# Export submission key to hu dir
key = privkey_to_pubkey(domain)
uid = hash_user_id(Config[domain].submission_address)
_locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True)
digest = dane_digest(Config[domain].submission_address)
_locked_write(os.path.join(wdir, domain, 'dane', digest), bytes(key), binary=True)
dane_notify(domain)
def read_public_key(domain, user):
return read_hashed_public_key(domain, hash_user_id(user))
hu = hash_user_id(user)
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
return key
def read_hashed_public_key(domain, hu):
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
_, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True))
key, revoked = split_revoked(keys.values())
return key, revoked
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
return key
def read_dane_public_keys(domain):
path: str = os.path.join(Config.working_directory, domain, 'dane')
dane_keys = {}
for fname in os.listdir(path):
if len(fname) != 56:
continue
keyfile = os.path.join(path, fname)
dane_keys[fname] = _locked_read(keyfile, binary=True)
return dane_keys
def write_public_key(domain, user, key, revoked):
def write_public_key(domain, user, key):
hu = hash_user_id(user)
dane = dane_digest(user)
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
danefile = os.path.join(Config.working_directory, domain, 'dane', dane)
joined = bytes(key) + b''.join([bytes(k) for k in revoked])
_locked_write(keyfile, joined, binary=True)
_locked_write(danefile, bytes(key), binary=True)
dane_notify(domain)
_locked_write(keyfile, bytes(key), binary=True)
def read_pending_key(domain, nonce):
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
_, keys = PGPKey.from_blob(_locked_read(keyfile))
key, revoked = split_revoked(keys.values())
return key[0], revoked
key, _ = PGPKey.from_blob(_locked_read(keyfile))
return key
def write_pending_key(domain, nonce, key, revoked_keys):
def write_pending_key(domain, nonce, key):
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
armored = armor_keys([key] + revoked_keys)
_locked_write(keyfile, armored)
_locked_write(keyfile, str(key))
def remove_pending_key(domain, nonce):
@ -121,7 +96,7 @@ def remove_pending_key(domain, nonce):
os.unlink(keyfile)
def clean_stale_requests(args):
def clean_stale_requests():
stale = (datetime.utcnow() - timedelta(seconds=Config.pending_lifetime)).timestamp()
for domain in Config.domains:
pending_dir = os.path.join(Config.working_directory, domain, 'pending')

View file

@ -3,55 +3,27 @@ from .config import Config
from .files import read_hashed_public_key, make_submission_address_file, make_policy_file
from .util import hash_user_id
from bottle import get, run, response, request, HTTPError
CORS = ('Access-Control-Allow-Origin', '*')
def abort(code, text):
err = HTTPError(code, text)
err.add_header(*CORS)
return err
def get_domain_header():
domain = request.get_header('host')
if len(domain) != 1 or domain[0] is None:
abort(400, 'Bad Request')
return domain[0]
from bottle import get, run, abort, response, request
@get('/.well-known/openpgpkey/<domain>/submission-address')
def advanced_submission_address(domain: str):
def submission_address(domain: str):
if domain not in Config.domains:
abort(404, 'Not Found')
response.add_header('Content-Type', 'text/plain')
response.add_header(*CORS)
return make_submission_address_file(domain)
@get('/.well-known/openpgpkey/submission-address')
def direct_submission_address():
return advanced_submission_address(get_domain_header())
@get('/.well-known/openpgpkey/<domain>/policy')
def advanced_policy(domain: str):
def policy(domain: str):
if domain not in Config.domains:
abort(404, 'Not Found')
response.add_header('Content-Type', 'text/plain')
response.add_header(*CORS)
return make_policy_file(domain)
@get('/.well-known/openpgpkey/policy')
def direct_policy():
return advanced_policy(get_domain_header())
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
def advanced_hu(domain: str, userhash: str):
def hu(domain: str, userhash: str):
if domain not in Config.domains:
abort(404, 'Not Found')
if Config.httpd['require_user_urlparam']:
@ -59,22 +31,16 @@ def advanced_hu(domain: str, userhash: str):
if not userid or hash_user_id(userid) != userhash:
abort(404, 'Not Found')
try:
pubkey, revoked = read_hashed_public_key(domain, userhash)
pubkey = read_hashed_public_key(domain, userhash)
response.add_header('Content-Type', 'application/octet-stream')
response.add_header(*CORS)
return bytes(pubkey[0]) + b''.join([bytes(k) for k in revoked])
return bytes(pubkey)
except FileNotFoundError:
abort(404, 'Not Found')
@get('/.well-known/openpgpkey/hu/<userhash>')
def direct_hu(userhash: str):
return advanced_hu(get_domain_header(), userhash)
def run_server(args):
def run_server():
run(host=Config.httpd['host'], port=Config.httpd['port'])
if __name__ == '__main__':
run_server(None)
run_server()

View file

@ -19,9 +19,9 @@ class LmtpMailServer:
process_mail(message)
except EasyWksError as e:
return f'550 {e}'
except Exception as e:
traceback.print_exc()
return f'550 Error during message processing: {e}'
except BaseException:
tb = traceback.format_exc()
return f'550 Error during message processing: {tb}'
return '250 Message successfully handled'
@ -34,7 +34,7 @@ class LmtpdController(Controller):
return LMTP(handler=self.handler, ident=f'EasyWKS {version}', loop=self.loop)
def run_lmtpd(args):
def run_lmtpd():
controller = LmtpdController(handler=LmtpMailServer(), hostname=Config.lmtpd['host'], port=Config.lmtpd['port'])
controller.start()
asyncio.get_event_loop().run_forever()

View file

@ -1,10 +1,9 @@
from .config import Config
from .files import init_working_directory, clean_stale_requests
from .process import process_mail_from_stdin, process_key_from_stdin
from .process import process_mail_from_stdin
from .httpd import run_server
from .lmtpd import run_lmtpd
from .dnsd import run_dnsd
import sys
@ -33,14 +32,6 @@ def parse_arguments():
server = sp.add_parser('lmtpd', help='Run a LMTP server to receive mails from your MTA. Also see process.')
server.set_defaults(fn=run_lmtpd)
server = sp.add_parser('dnsd', help='Run an authoritative DNS server to provide DANE TYPE61 zones.')
server.set_defaults(fn=run_dnsd)
imp = sp.add_parser('import', help='Import a public key from stdin directly into the WKD without WKS verification.')
imp.add_argument('--uid', '-u', type=str, action='append',
help='Limit import to a subset of the key\'s UIDs. Can be provided multiple times.')
imp.set_defaults(fn=process_key_from_stdin)
return ap.parse_args(sys.argv[1:])
@ -53,7 +44,7 @@ def main():
Config.load_config(conf)
init_working_directory()
if args.fn:
args.fn(args)
args.fn()
if __name__ == '__main__':

View file

@ -1,23 +1,19 @@
import sys
from typing import Any, List, Dict, Tuple
from typing import List, Dict
from .crypto import pgp_decrypt
from .mailing import get_mailing_method
from .config import Config, \
POLICY_MAILBOX_ONLY, EWP_MAX_REVOKED_KEYS, EWP_STRIP_UNVERIFIED_UIDS, EWP_STRIP_3RDPARTY_SIGNATURES, \
EWP_STRIP_UA_UIDS, EWP_MINIMIZE_REVOKED_KEYS, EWP_PERMIT_UNSIGNED_RESPONSE, POLICY_AUTH_SUBMIT
from .config import Config
from .files import read_pending_key, write_public_key, remove_pending_key, write_pending_key
from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError, \
from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError,\
XLOOP_HEADER
from .types import fingerprint
from .util import split_revoked
from email.message import MIMEPart, Message
from email.parser import BytesParser
from email.policy import default
from email.utils import getaddresses
from pgpy import PGPMessage, PGPKey, PGPUID, PGPSignature
from pgpy import PGPMessage, PGPKey, PGPUID
from pgpy.errors import PGPError
@ -49,43 +45,30 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
return pgp
def _get_pgp_publickeys(parts: List[MIMEPart]) -> Tuple[PGPKey, List[PGPKey]]:
pubkeys: Dict[str, PGPKey] = {}
def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
pubkey = None
for part in parts:
try:
_, keys = PGPKey.from_blob(part.get_content())
for (_, public), key in keys.items():
if not public:
continue
fpr = fingerprint(key)
if fpr in pubkeys:
raise EasyWksError(f'Key with fingerprint {fpr} appears multiple times in submission request.')
pubkeys[fpr] = key
key, _ = PGPKey.from_blob(part.get_content())
if key.is_public:
if pubkey:
raise EasyWksError('More than one PGP public key in message. Only submit a single key at once.')
pubkey = key
except PGPError:
pass
if len(pubkeys) == 0:
if not pubkey:
raise EasyWksError('No PGP public key found in the encrypted message part.')
key, revoked_keys = split_revoked(pubkeys.values())
if len(key) < 1:
raise EasyWksError('All of the submitted keys appear to be revoked.')
elif len(key) > 1:
fprs = ' '.join([fingerprint(k) for k in key])
raise EasyWksError(f'More than one non-revoked key was submitted: {fprs}')
return key[0], revoked_keys
return pubkey
def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str):
payload = BytesParser(policy=default).parsebytes(pgp.message)
leafs = _get_mime_leafs(payload)
valid_key, revoked_keys = _get_pgp_publickeys(leafs)
sender_uid: PGPUID = valid_key.get_uid(sender)
pubkey = _get_pgp_publickey(leafs)
sender_uid: PGPUID = pubkey.get_uid(sender)
if sender_uid is None or sender_uid.email != sender:
raise EasyWksError(f'Key has no UID that matches {sender}')
for key in revoked_keys:
sender_uid: PGPUID = key.get_uid(sender)
if sender_uid is None or sender_uid.email != sender:
raise EasyWksError(f'Revoked key {fingerprint(key)} has no UID that matches {sender}')
return SubmissionRequest(sender, submission, valid_key, revoked_keys)
return SubmissionRequest(sender, submission, pubkey)
def _find_confirmation_response(parts: List[MIMEPart]) -> str:
@ -136,62 +119,6 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
# There is no API for directly removing a PGPUID or PGPSignature object
# noinspection PyProtectedMember
def _apply_submission_policy(request: SubmissionRequest, policy: Dict[str, Any]):
# Policy: Only permit a certain amount of revoked keys
maxrevoke = policy.get(EWP_MAX_REVOKED_KEYS, -1)
if maxrevoke > -1 and len(request.revoked_keys) > maxrevoke:
raise EasyWksError(f'Submission request contains {len(request.revoked_keys)} revoked keys. '
f'Policy permits not more than {maxrevoke}')
uid: PGPUID
revfprs = [fingerprint(request.key)] + [fingerprint(rk) for rk in request.revoked_keys]
for key in [request.key] + request.revoked_keys:
# Policy: Strip user attribute (image) UIDs
if policy.get(POLICY_MAILBOX_ONLY, False) or policy.get(EWP_STRIP_UA_UIDS, False):
for uid in list(key.userattributes):
uid._parent = None
key._uids.remove(uid)
# Policy: Reject keys as invalid if they contain UIDs with non-empty name or comment parts
if policy.get(POLICY_MAILBOX_ONLY, False):
for uid in list(key.userids):
if uid.email == '' or uid.name != '' or uid.comment != '':
raise EasyWksError('This WKS server only accepts UIDs without name and comment parts')
# Policy: Strip all UIDs except the one being verified
if policy.get(EWP_STRIP_UNVERIFIED_UIDS, False):
for uid in list(key.userids):
if uid.email != request.submitter_address:
uid._parent = None
key._uids.remove(uid)
# Policy: Strip all 3rd party signatures from they key
if policy.get(EWP_STRIP_3RDPARTY_SIGNATURES, False):
for uid in list(key.userids):
for sig in list(uid.third_party_certifications):
# Keep signatures signed by the revoked keys
sig: PGPSignature
if sig.signer_fingerprint not in revfprs:
uid._signatures.remove(sig)
# Policy: Produce minimal transportable keys
if policy.get(EWP_MINIMIZE_REVOKED_KEYS, False):
for key in request.revoked_keys:
for uid in list(key.userids):
# Delete all but the submitter UIDs, and all 3rd party signatures
if uid.email != request.submitter_address:
uid._parent = None
key._uids.remove(uid)
else:
for sig in list(uid.third_party_certifications):
uid._signatures.remove(sig)
# Delete UAs
for uid in list(key.userattributes):
uid._parent = None
key._uids.remove(uid)
# Delete subkeys
for subkey in key._children.values():
subkey._parent = None
key._children.clear()
def process_mail(mail: bytes):
try:
msg: Message = BytesParser(policy=default).parsebytes(mail)
@ -201,7 +128,7 @@ def process_mail(mail: bytes):
raise EasyWksError('Sender mail is not a valid mail address')
if sender_domain not in Config.domains:
raise EasyWksError(f'Domain {sender_domain} not supported')
if msg.get('x-loop', '') == XLOOP_HEADER or msg.get('auto-submitted', 'no') != 'no':
if msg.get('x-loop', '') == XLOOP_HEADER or 'auto-submitted' in msg:
# Mail has somehow looped back to us, discard
return
submission_address: str = Config[sender_domain].submission_address
@ -224,61 +151,27 @@ def process_mail(mail: bytes):
if confirmation_response is not None:
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
try:
key, revoked_keys = read_pending_key(sender_domain, request.nonce)
key = read_pending_key(sender_domain, request.nonce)
except FileNotFoundError:
raise EasyWksError('There is no submission request for this email address, or it has expired. '
'Please resubmit your submission request.')
# TODO: Config.permit_unsigned_response is deprecated, but for now retained for backwards compatibility
if not Config[sender_domain].policy_flags.get(EWP_PERMIT_UNSIGNED_RESPONSE, False) and \
not Config.permit_unsigned_response:
# this throws an error if signature verification fails
request.verify_signature(key)
# this throws an error if signature verification fails
request.verify_signature(key)
response: PublishResponse = request.get_publish_response(key)
write_public_key(sender_domain, sender_mail, key, revoked_keys)
rmsg = response.create_signed_message()
write_public_key(sender_domain, sender_mail, key)
remove_pending_key(sender_domain, request.nonce)
else:
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
policy = Config[sender_domain].policy_flags
_apply_submission_policy(request, policy)
if policy.get(POLICY_AUTH_SUBMIT, False):
response = PublishResponse(request.submitter_address, request.submission_address, request.key)
write_public_key(sender_domain, sender_mail, request.key, request.revoked_keys)
else:
response: ConfirmationRequest = request.confirmation_request()
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys)
rmsg = response.create_signed_message()
response: ConfirmationRequest = request.confirmation_request()
rmsg = response.create_signed_message()
write_pending_key(sender_domain, response.nonce, request.key)
except EasyWksError as e:
rmsg = e.create_message(sender_mail, submission_address)
method = get_mailing_method(Config.mailing_method)
method(rmsg)
def process_mail_from_stdin(args):
def process_mail_from_stdin():
mail = sys.stdin.read().encode()
process_mail(mail)
def process_key_from_stdin(args):
try:
pubkey, _ = PGPKey.from_blob(sys.stdin.read())
except PGPError:
raise EasyWksError('Input is not a valid public key.')
if not pubkey.is_public:
raise EasyWksError('Input is not a valid public key.')
for uid in pubkey.userids:
# Skip user attributes (e.g. photo ids)
if not uid.is_uid or len(uid.email) == 0:
continue
# If a UID filter was provided on the command line, apply it
if args.uid is not None and len(args.uid) > 0 and uid.email not in args.uid:
print(f'Skipping ignored email {uid.email}')
continue
local, domain = uid.email.split('@', 1)
# Skip keys we're not responsible for
if domain not in Config.domains:
print(f'Skipping foreign email {uid.email}')
continue
# All checks passed, importing key
write_public_key(domain, uid.email, pubkey, [])
print(f'Imported key {fingerprint(pubkey)} for email {uid.email}')

View file

@ -1,8 +1,5 @@
from typing import List
from datetime import datetime, timezone
from email.encoders import encode_noop
from datetime import datetime
from email.policy import default
from email.utils import format_datetime
from email.mime.multipart import MIMEMultipart
@ -10,10 +7,10 @@ from email.mime.application import MIMEApplication
from email.mime.text import MIMEText
from pgpy import PGPKey, PGPMessage, PGPUID
from pgpy.errors import PGPError
from pgpy.types import SignatureVerification
from .crypto import pgp_sign
from .config import render_message
from .config import Config, render_message
from .util import create_nonce, fingerprint
@ -22,11 +19,10 @@ XLOOP_HEADER = 'EasyWKS'
class SubmissionRequest:
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, revoked_keys: List[PGPKey]):
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
self._submitter_addr = submitter_addr
self._submission_addr = submission_addr
self._key = key
self._revoked_keys = revoked_keys
def confirmation_request(self) -> 'ConfirmationRequest':
return ConfirmationRequest(self._submitter_addr, self. _submission_addr, self._key)
@ -43,10 +39,6 @@ class SubmissionRequest:
def key(self):
return self._key
@property
def revoked_keys(self):
return list(self._revoked_keys)
class ConfirmationRequest:
@ -96,18 +88,15 @@ class ConfirmationRequest:
encrypted = self._key.encrypt(to_encrypt)
mpenc = MIMEApplication(str(encrypted), _subtype='vnd.gnupg.wks')
mixed = MIMEMultipart(_subtype='mixed', _subparts=[mpplain, mpenc])
to_sign = PGPMessage.new(mixed.as_string(policy=default).replace('\n', '\r\n'))
to_sign = PGPMessage.new(mixed.as_string(policy=default))
sig = pgp_sign(self.domain, to_sign)
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature', name='signature.asc', _encoder=encode_noop)
mpsig['Content-Description'] = 'OpenPGP digital signature'
mpsig['Content-Disposition'] = 'attachment; filename="signature"'
email = MIMEMultipart(_subtype=f'signed', _subparts=[mixed, mpsig], policy=default,
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature')
email = MIMEMultipart(_subtype='signed', _subparts=[mixed, mpsig], policy=default,
protocol='application/pgp-signature')
email.set_param('micalg', f'pgp-{str(sig.hash_algorithm).lower()}', requote=False)
email['Subject'] = 'Confirm your key publication'
email['To'] = self._submitter_addr
email['From'] = self._submission_addr
email['Date'] = format_datetime(datetime.now(timezone.utc))
email['Date'] = format_datetime(datetime.utcnow())
email['Wks-Draft-Version'] = '3'
email['Wks-Phase'] = 'confirm'
email['X-Loop'] = XLOOP_HEADER
@ -145,18 +134,21 @@ class ConfirmationResponse:
def verify_signature(self, key: PGPKey):
if not self._msg.is_signed:
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
'gpg-wks-client for submitting your response, please update said tool or try '
'responding manually.')
if not Config.permit_unsigned_response:
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
'gpg-wks-client for submitting your response, please update said tool or try '
'responding manually.')
else:
# Unsigned, but permitted
return
uid: PGPUID = key.get_uid(self._submitter_addr)
if uid is None or uid.email != self._submitter_addr:
raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key')
try:
# Should raise an error when verification fails, but add the boolean check as a additional protection
if not key.verify(self._msg):
raise EasyWksError(f'PGP signature could not be verified')
except PGPError as e:
raise EasyWksError(f'PGP signature could not be verified: {e}')
verification: SignatureVerification = key.verify(self._msg)
for verified, by, sig, subject in verification.good_signatures:
if fingerprint(key) == fingerprint(by):
return
raise EasyWksError('PGP signature could not be verified')
class PublishResponse:
@ -190,16 +182,16 @@ class PublishResponse:
submission=self.submission_address)
mpplain = MIMEText(mail_text, _subtype='plain')
to_encrypt = PGPMessage.new(mpplain.as_string(policy=default))
to_encrypt |= pgp_sign(self.domain, to_encrypt)
encrypted: PGPMessage = self.key.encrypt(to_encrypt)
payload = MIMEApplication(str(encrypted), _subtype='octet-stream', _encoder=encode_noop)
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted', _encoder=encode_noop)
encrypted |= pgp_sign(self.domain, encrypted)
payload = MIMEApplication(str(encrypted), _subtype='octet-stream')
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted')
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
protocol='application/pgp-encrypted')
email['Subject'] = 'Your key has been published'
email['To'] = self.submitter_address
email['From'] = self.submission_address
email['Date'] = format_datetime(datetime.now(timezone.utc))
email['Date'] = format_datetime(datetime.utcnow())
email['Wks-Draft-Version'] = '3'
email['Wks-Phase'] = 'done'
email['X-Loop'] = XLOOP_HEADER
@ -227,7 +219,7 @@ class EasyWksError(BaseException):
email['Subject'] = 'An error has occurred while processing your request'
email['From'] = submission_addr
email['To'] = submitter_addr
email['Date'] = format_datetime(datetime.now(timezone.utc))
email['Date'] = format_datetime(datetime.utcnow())
email['Wks-Draft-Version'] = '3'
email['Wks-Phase'] = 'error'
email['X-Loop'] = XLOOP_HEADER

View file

@ -1,19 +1,9 @@
from typing import Iterable, List, Tuple
import base64
import hashlib
import secrets
import string
import textwrap
import logging
from twisted.names import dns
from twisted.internet import reactor, defer
from pgpy import PGPKey
from pgpy.constants import SignatureType
from .config import Config
def _zrtp_base32(sha1: bytes) -> str:
@ -38,12 +28,6 @@ def hash_user_id(uid: str) -> str:
return _zrtp_base32(digest)
def dane_digest(uid: str) -> str:
if '@' in uid:
uid, _ = uid.split('@', 1)
return hashlib.sha256(uid.encode('utf-8')).hexdigest()[:56]
def create_nonce(n: int = 32) -> str:
alphabet = string.ascii_letters + string.digits
nonce = ''.join(secrets.choice(alphabet) for _ in range(n))
@ -52,64 +36,3 @@ def create_nonce(n: int = 32) -> str:
def fingerprint(key: PGPKey) -> str:
return key.fingerprint.upper().replace(' ', '')
def crc24(data: bytes) -> bytes:
# https://www.rfc-editor.org/rfc/rfc4880#section-6.1
crc = 0xB704CE
for b in data:
crc ^= (b << 16)
for _ in range(8):
crc <<= 1
if crc & 0x1000000:
crc ^= 0x1864CFB
return bytes([(crc & 0xff0000) >> 16, (crc & 0xff00) >> 8, (crc & 0xff)])
def armor_keys(keys: List[PGPKey]) -> str:
joined = b''.join([bytes(k) for k in keys])
armored = base64.b64encode(joined).decode()
wrapped = '\n'.join(textwrap.wrap(armored, 64))
checksum = base64.b64encode(crc24(joined)).decode()
armored = '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n' +\
wrapped + '\n=' + checksum +\
'\n-----END PGP PUBLIC KEY BLOCK-----\n'
return armored
def split_revoked(keys: Iterable[PGPKey]) -> Tuple[List[PGPKey], List[PGPKey]]:
revoked_keys = set()
for key in keys:
if len(list(key.revocation_signatures)) == 0:
continue
for rsig in key.revocation_signatures:
if rsig.type == SignatureType.KeyRevocation:
revoked_keys.add(key)
break
key = [k for k in keys if k not in revoked_keys]
return key, list(revoked_keys)
def dane_notify(domain: str):
secondaries = Config[domain].dane.get('notify', [])
if len(secondaries) == 0:
return
origin = dns.domainString(f'_openpgpkey.{domain}')
# this is ugly, but has to do for now
for host in secondaries:
try:
if '@' in host:
addr, port = host.split('@', 1)
port = int(port)
else:
addr = host
port = 53
# Bind a v4 or v6 UDP client socket
proto = dns.DNSDatagramProtocol(controller=None)
reactor.listenUDP(0, proto, interface='::' if ':' in addr else '0.0.0.0')
# Assemble and send NOTIFY message
m = dns.Message(proto.pickID(), opCode=dns.OP_NOTIFY, auth=1)
m.queries = [dns.Query(origin, dns.SOA, dns.IN)]
proto.writeMessage(m, (addr, port))
except Exception:
logging.exception(f'An error occurred while attempting to notify {host}')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="275.00003"
height="275.00003"
viewBox="0 0 72.760425 72.760425"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
sodipodi:docname="easywks.svg"
inkscape:export-filename="easywks.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="1.7928421"
inkscape:cx="63.028417"
inkscape:cy="98.725927"
inkscape:window-width="1920"
inkscape:window-height="1060"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="-13.229167"
originy="-13.229167"
spacingx="1.3229167"
spacingy="1.3229167"
empcolor="#3f3fff"
empopacity="0.25098039"
color="#3f3fff"
opacity="0.1254902"
empspacing="5"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-13.229167,-13.229167)">
<path
id="path2"
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 84.666667,85.989583 44.317708,45.640625 39.6875,50.270833 46.302083,56.885417 47.625,58.208333 h 5.291667 V 63.5 l 1.322916,1.322917 h 2.645834 v 2.645833 l 1.322916,1.322917 h 6.614584 v 6.614583 l 1.322916,1.322917 h 1.322917 v 1.322916 L 68.791667,79.375 h 3.96875 v 3.96875 l 2.645833,2.645833 z" />
<path
id="path10"
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 46.302083,43.65625 -1.984375,1.984375 40.348959,40.348958 1.322916,-1.322916 V 83.34375 Z" />
<path
id="path9"
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 47.625,42.333333 -1.322917,1.322917 39.6875,39.6875 v -2.645833 z" />
<path
id="path8"
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 85.989583,79.375 48.286458,41.671875 47.625,42.333333 85.989583,80.697917 Z" />
<path
id="path7"
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 50.270833,39.6875 48.286458,41.671875 85.989583,79.375 v -3.96875 z" />
<path
id="path1"
style="fill:#7a7a7a;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0499999"
d="m 33.072917,13.229167 a 19.84375,19.84375 0 0 0 -19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,-19.84375 19.84375,19.84375 0 0 0 -19.84375,-19.84375 z m -6.619751,6.624402 a 6.6095872,6.6045904 0 0 1 6.609932,6.604764 6.6095872,6.6045904 0 0 1 -6.609932,6.604765 6.6095872,6.6045904 0 0 1 -6.609416,-6.604765 6.6095872,6.6045904 0 0 1 6.609416,-6.604764 z" />
<path
style="fill:#2ce000;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 20.835939,55.562502 h 15.213543 v 15.213541 h 7.60677 L 28.442711,85.989585 13.229168,70.776043 h 7.606771 z"
id="path11" />
<path
style="fill:#ffa708;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 78.382819,43.65625 H 63.169276 V 28.442709 h -7.60677 L 70.776047,13.229167 85.98959,28.442709 h -7.606771 z"
id="path11-5" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -1,2 +1 @@
/etc/easywks.yml
/etc/cron.d/easywks

View file

@ -1,10 +1,10 @@
Package: easywks
Version: __VERSION__
Maintainer: s3lph <1375407-s3lph@users.noreply.gitlab.com>
Version: __EASYWKS_VERSION__
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
Section: web
Priority: optional
Architecture: all
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd, python3-dnspython, python3-twisted
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd
Description: OpenPGP WKS for Human Beings
EasyWKS is a drop-in replacement for gpg-wks-server that aims to be
much easyier to use manually, while maintaing compatibility with the

View file

@ -7,14 +7,10 @@
# considered stale and should be removed by easywks clean.
#pending_lifetime: 604800
# Some clients including recent versions of gpg-wks-client follow an
# Some clients (including recent versions of gpg-wks-client follow an
# older version of the WKS standard where signing the confirmation
# response is only recommended, but not required. Set this option to
# true if you want to accept such unsigned responses.
#
# This option is deprecated and will be removed in a future release.
# It is replaced by the me.s3lph.easywks_permit-unsigned-response
# per-domain policy flag.
#permit_unsigned_response: false
# Port configuration for the webserver. Put this behind a
@ -46,11 +42,6 @@ lmtpd:
host: "::1"
port: 8024
# Configure the authoritative DNS server for DANE zones
dnsd:
host: "::1"
port: 8053
# You can override the mail response templates with your own text.
# The following templates can be overridden:
# - "header": Placed in front of every message.
@ -83,52 +74,4 @@ domains:
# If you want the PGP key for this domain to be password-protected,
# or if you're supplying your own password-protected key, set the
# passphrase here:
#passphrase: "Correct Horse Battery Staple"
# Policy flags control behavior of the submission process for this
# domain. Supports most of the standard policy flags (see
# https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-15#section-4.5
# for details) as well as some EasyWKS-namespaced flags.
policy_flags:
# The mail server provider does only accept keys with only a
# mailbox in the User ID. In particular User IDs with a real name
# in addition to the mailbox will be rejected as invalid.
#mailbox-only: false
# The submission of the mail to the server is done using an
# authenticated connection. Thus the submitted key will be
# published immediately without any confirmation request.
#auth-submit: false
# This keyword can be used to explicitly claim the support of a
# specific version of the Web Key Directory update protocol. This
# is in general not needed but implementations may have
# workarounds for providers which only support an old protocol
# version. If these providers update to a newer version they
# should add this keyword so that the implementation can disable
# the workaround. The value is an integer corresponding to the
# respective draft revision number.
#protocol-version: null
# Some clients (including recent versions of gpg-wks-client
# follow an older version of the WKS standard where signing the
# confirmation response is only recommended, but not required.
# Set this option to true if you want to accept such unsigned
# responses.
#me.s3lph.easywks_permit-unsigned-response: false
# Remove all UIDs except the one being verified.
#me.s3lph.easywks_strip-unverified-uids: false
# Remove user attribute (i.e. photo) UIDs.
#me.s3lph.easywks_strip-ua-uids: false
# Remove all third party certifications fom the submitted keys.
# Certifications issued by revoked keys submitted in the same
# submission request are exempt from this policy.
#me.s3lph.easywks_strip-3rdparty-signatures: false
# Maximal number of revoked keys that can be submitted alongside
# a valid key. If this flag is absent or has value -1, an
# unlimited number of revoked keys is permitted.
#me.s3lph.easywks_max-revoked-keys: -1
#passphrase: "Correct Horse Battery Staple"

View file

@ -1,13 +0,0 @@
[Unit]
Description=OpenPGP WKS for Human Beings - DANE DNS Server
[Service]
Type=simple
ExecStart=/usr/bin/easywks dnsd
Restart=on-failure
User=easywks
Group=easywks
WorkingDirectory=/var/lib/easywks
[Install]
WantedBy=multi-user.target

View file

@ -10,7 +10,4 @@ lmtpd:
httpd:
host: "::"
port: 80
dnsd:
host: "::"
port: 53
domains: {}

View file

@ -7,27 +7,18 @@ setup(
name='easywks',
version=__version__,
author='s3lph',
author_email='s3lph@kabelsalat.ch',
author_email='account-gitlab-ideynizv@kernelpanic.lol',
description='OpenPGP WKS for Human Beings',
license='MIT',
keywords='pgp,wks',
url='https://git.kabelsalat.ch/s3lph/easywks',
url='https://gitlab.com/s3lph/easywks',
packages=find_packages(exclude=['*.test']),
install_requires=[
'aiosmtpd',
'bottle',
'dnspython',
'PyYAML',
'PGPy',
'Twisted',
],
extras_require={
'test': [
'coverage',
'pycodestyle',
'twine'
]
},
entry_points={
'console_scripts': [
'easywks = easywks.main:main'

View file

@ -1,24 +0,0 @@
ServerName example.org
<VirtualHost *:80>
ServerName example.org
ServerAlias openpgpkey.example.org
ServerAlias openpgpkey
DocumentRoot /var/www/html
RewriteEngine On
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI}
</VirtualHost>
<VirtualHost *:443>
ServerName example.org
ServerAlias openpgpkey.example.org
ServerAlias openpgpkey
DocumentRoot /var/www/html
SSLEngine On
SSLCertificateFile /etc/ssl/cert.pem
SSLCertificateKeyFile /etc/ssl/key.pem
ProxyPass /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
ProxyPassReverse /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
</VirtualHost>

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="example.org">
<domain>example.org</domain>
<displayName>EasyWKS Example</displayName>
<displayShortName>Example</displayShortName>
<incomingServer type="imap">
<hostname>example.org</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILLOCALPART%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>example.org</hostname>
<port>25</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILLOCALPART%</username>
</outgoingServer>
</emailProvider>
</clientConfig>

View file

@ -1,8 +0,0 @@
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0666
}
}
ssl_cert = </etc/ssl/cert.pem
ssl_key = </etc/ssl/key.pem
log_path = /dev/stderr

View file

@ -1,13 +0,0 @@
directory: /tmp/easywks
httpd:
host: 127.0.0.1
port: 8080
lmtpd:
host: 127.0.0.1
port: 8024
mailing_method: smtp
domains:
example.org:
submission_address: webkey@example.org
policy_flags:
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat

View file

@ -1,19 +0,0 @@
#!/usr/bin/expect -f
spawn ./client.py
expect "Enter email: "
send "alice@example.org\n"
expect "Chose $env(FINGERPRINT)"
expect "Enter IMAP/POP3/SMTP password (will not echo): "
send "supersecurepassword\n"
expect "Autoconfigured incoming server"
expect "Autoconfigured outgoing server"
expect "Please confirm: \[Y/n\] "
send "y\n"
expect "Sending submission request"
expect "Awaiting response"
expect "Received confirmation request"
expect "Creating confirmation response."
expect "Sending confirmation response"
expect "Awaiting publish response"
expect "Your key has been published to the Web Key Directory."
expect eof

View file

@ -1,24 +0,0 @@
#!/bin/bash
cat >/tmp/keygen <<EOF
%no-protection
%no-ask-passphrase
%transient-key
Key-Type: EDDSA
Key-Curve: ed25519
Subkey-Type: ECDH
Subkey-Curve: cv25519
Expire-Date: 0
Name-Real: EasyWKS Test User
Name-Comment: TEST KEY DO NOT USE
Name-Email: ${1}
EOF
gpg --batch --full-gen-key /tmp/keygen
for uid in $@; do
gpg --batch --quick-add-uid "${1}" "EasyWKS Test User (TEST KEY DO NOT USE) <${uid}>"
done
gpg --export --armor "${1}" > "/tmp/${1}.asc"
for uid in $@; do
gpg --export --armor "${uid}" > "/tmp/${uid}.asc"
done

View file

@ -1 +0,0 @@
webkey@example.org lmtp:[127.0.0.1]:8024