Compare commits

...

33 commits

Author SHA1 Message Date
dadcfb558e
feat: add logo
All checks were successful
/ test (push) Successful in 1m19s
/ codestyle (push) Successful in 1m34s
/ easywksserver_gpgwksclient (push) Successful in 1m40s
/ easywksserver_easywksclient (push) Successful in 4m46s
2024-06-22 03:06:55 +02:00
7de4943100
chore: replace deprecated datetime.utcnow 2024-06-22 03:06:15 +02:00
dadbcdbf3f
feat: add integration test between easywks server and client 2024-06-22 03:05:37 +02:00
3174aff2bc
chore: update client.py for python 3.12 utcnow deprecation
All checks were successful
/ test (push) Successful in 1m2s
/ codestyle (push) Successful in 1m0s
/ easywksserver_gpgwksclient (push) Successful in 1m19s
2024-05-31 21:14:23 +02:00
d9fa1733a8
fix: type confusion in previous bugfix
All checks were successful
/ test (push) Successful in 1m15s
/ codestyle (push) Successful in 1m13s
/ easywksserver_gpgwksclient (push) Successful in 1m35s
/ build_wheel (push) Successful in 2m16s
/ build_debian (push) Successful in 2m36s
2024-05-31 16:13:57 +02:00
b3e66b91c3
fix: dont put multiple keys into one dane record
Some checks failed
/ test (push) Successful in 1m1s
/ codestyle (push) Successful in 1m38s
/ easywksserver_gpgwksclient (push) Failing after 1m21s
2024-05-31 16:11:04 +02:00
19cde32909
feat: migrate from woodpecker to forgejo actions
All checks were successful
/ test (push) Successful in 1m21s
/ codestyle (push) Successful in 1m25s
/ easywksserver_gpgwksclient (push) Successful in 1m43s
/ build_wheel (push) Successful in 1m47s
/ build_debian (push) Successful in 2m41s
2023-12-19 07:31:47 +01:00
32855f2472
fix(smtpd): Log errors to stdout rather than SMTP session
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-10-17 22:08:09 +02:00
d6357c9bac
fix: debian package ci
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline was successful
2023-07-30 01:24:58 +02:00
aee85f2ff1
fix: debian package ci
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2023-07-30 01:19:52 +02:00
df55e7da4c
fix: ci
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/tag/woodpecker Pipeline failed
2023-07-30 01:11:35 +02:00
6736c3fa2e
fix: compatibility with latest version of pgpy
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-30 01:01:17 +02:00
a114f24314
fix: pycodestyle
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-30 00:50:41 +02:00
c013284979
fix: ci
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2023-07-30 00:49:17 +02:00
s3lph
ba0070e1ac chore: migrate from gitlab-ci to woodpecker 2023-07-30 00:48:06 +02:00
s3lph
f6a7c9628b feat: provide dns notify on zone updates 2023-04-04 22:59:39 +02:00
s3lph
625088abcf fix: dont refuse ixfr and soa queries 2023-04-04 22:01:48 +02:00
s3lph
6c27d799e3 Add DANE OPENPGPKEY support 2023-04-04 20:15:46 +02:00
s3lph
360303d72f Release v0.3.1 2023-01-31 21:01:52 +01:00
s3lph
41de3b5704 feat: implement policy_flags and apply these policies to submission requests 2023-01-31 20:47:29 +01:00
s3lph
9182582589 fix(client): Choose correct fingerprint for pubkey 2023-01-31 02:07:33 +01:00
s3lph
082d7d93d4 Fix failing tests 2023-01-31 01:36:38 +01:00
s3lph
62fd2d52da Fix non-starting httpd 2023-01-31 01:27:57 +01:00
s3lph
f00ffb7dd2 Implement revoked key submission in client 2023-01-31 01:23:16 +01:00
s3lph
68ac57c4ce codestyle 2023-01-31 01:15:27 +01:00
s3lph
4596ed2b31 Release 0.3.0 2023-01-31 00:54:01 +01:00
s3lph
4e1465cdb2 feat: allow submitting additional revoked keys with the submission request 2023-01-31 00:49:16 +01:00
s3lph
fddbea70d9 Implement direct WKD URLs 2023-01-30 21:27:30 +01:00
s3lph
35f38ef188 fix(httpd): Set CORS headers on HTTP responses 2023-01-30 04:14:13 +01:00
s3lph
01f694e98e Release v0.2.0 2022-12-31 03:39:11 +01:00
s3lph
b4a120b008 Release v0.2 2022-12-31 03:19:53 +01:00
s3lph
ea451639e6 fix(config): per-domain configuration (e.g. submission-address) was not loaded. 2022-12-21 03:12:48 +01:00
s3lph
b396a2c01c feat(testing): add integration test against gpg-wks-client 2022-12-21 03:10:11 +01:00
31 changed files with 1200 additions and 212 deletions

View file

@ -0,0 +1,38 @@
---
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"

136
.forgejo/workflows/test.yml Normal file
View file

@ -0,0 +1,136 @@
---
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

View file

@ -1,115 +0,0 @@
---
image: python:3.9-bullseye
stages:
- test
- build
- deploy
- upload
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
repo:
stage: upload
trigger: s3lph/custom-packages
variables:
MULTIPROJECT_TRIGGER_JOBNAME: easywks
only:
- tags

View file

@ -1,5 +1,142 @@
# 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

View file

@ -7,7 +7,7 @@
## 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
Discovery**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
Directory**][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,6 +50,8 @@ gpg-wks-server to EasyWKS.
- PyYAML
- bottle.py
- PGPy
- dnspython (for DANE support)
- Twisted (for DANE support)
## License
@ -116,6 +118,11 @@ 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.
@ -252,6 +259,84 @@ 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

View file

@ -3,7 +3,8 @@
import abc
import time
from getpass import getpass
from datetime import datetime
from datetime import datetime, timezone
import time
import imaplib
import poplib
import smtplib
@ -397,7 +398,7 @@ def _parse_confirmation_request(address, fingerprint, encrypted):
return rdict['sender'], rdict['nonce']
def _create_submission_request(address: str, fingerprint: str, submission_address: str):
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)
@ -409,7 +410,7 @@ def _create_submission_request(address: str, fingerprint: str, submission_addres
'/usr/bin/gpg', '--armor',
'--export-options', 'export-minimal',
'--export', fingerprint
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
] + 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()}')
@ -438,7 +439,7 @@ def _create_submission_request(address: str, fingerprint: str, submission_addres
mail['Subject'] = 'WKS submission request'
mail['To'] = submission_address
mail['From'] = address
mail['Date'] = format_datetime(datetime.utcnow())
mail['Date'] = format_datetime(datetime.now(timezone.utc))
return mail
@ -467,7 +468,7 @@ def _create_confirmation_response(address: str, submission: str, nonce: str, fp:
mail['Subject'] = 'WKS confirmation response'
mail['To'] = submission
mail['From'] = address
mail['Date'] = format_datetime(datetime.utcnow())
mail['Date'] = format_datetime(datetime.now(timezone.utc))
return mail
@ -529,26 +530,40 @@ def _gpg_get_uid_fp(address: str):
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)
if len(pubs) == 0:
raise ValueError(f'No key found for {address}.')
elif len(pubs) > 1:
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:')
for i, pub in enumerate(pubs, start=1):
print(f'{i}: {pub}')
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:
i = 0
pub = pubs[i]
fpr = next(filter(lambda x: x.endswith(pub), fprs))
return fpr
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):
@ -572,8 +587,10 @@ def main():
except urllib.error.URLError:
print('No WKS submission address found. Does your provider support WKS?')
exit(1)
fp = _gpg_get_uid_fp(ad)
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)
@ -589,6 +606,7 @@ def main():
try:
with i:
incoming_server = i
break
except:
continue
if incoming_server is None:
@ -598,6 +616,7 @@ def main():
try:
with i:
outgoing_server = i
break
except:
continue
if outgoing_server is None:
@ -608,14 +627,14 @@ def main():
print('Aborted')
exit(1)
with incoming_server:
now = datetime.utcnow()
now = time.monotonic()
done = False
request = _create_submission_request(ad, fp, sa)
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 (datetime.utcnow() - now).total_seconds() < 300:
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)

View file

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

View file

@ -3,6 +3,33 @@ 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:
@ -65,6 +92,15 @@ 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):
@ -77,6 +113,46 @@ 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):
@ -173,9 +249,10 @@ class _GlobalConfig(_Config):
def __make_domain(self, domain):
self.__domains[domain] = _Config(
submission_address=_ConfigOption('address', str, f'gpgwks@{domain}'),
submission_address=_ConfigOption('submission_address', str, f'gpgwks@{domain}'),
passphrase=_ConfigOption('passphrase', str, ''),
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags)
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags),
dane=_ConfigOption('dane', dict, {}, validator=_validate_dane)
)
def __getitem__(self, item):
@ -194,13 +271,14 @@ class _GlobalConfig(_Config):
for domain, dconf in conf['domains'].items():
self.__make_domain(domain)
for co in self.__domains[domain]._options.values():
co.load(conf)
co.load(dconf)
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',
@ -217,6 +295,10 @@ 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),
)

90
easywks/dnsd.py Normal file
View file

@ -0,0 +1,90 @@
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
from .util import hash_user_id, armor_keys, split_revoked, dane_digest, dane_notify
def _locked_read(file: str, binary: bool = False):
@ -36,10 +36,15 @@ 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 not value or len(value) == 0:
content += f'{flag}: {value}\n'
else:
if isinstance(value, bool):
if not value:
continue
else:
content += flag + '\n'
elif value is None:
content += flag + '\n'
else:
content += f'{flag}: {value}\n'
return content
@ -53,42 +58,62 @@ 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):
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
return read_hashed_public_key(domain, hash_user_id(user))
def read_hashed_public_key(domain, hu):
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
return key
_, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True))
key, revoked = split_revoked(keys.values())
return key, revoked
def write_public_key(domain, user, 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):
hu = hash_user_id(user)
dane = dane_digest(user)
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
_locked_write(keyfile, bytes(key), binary=True)
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)
def read_pending_key(domain, nonce):
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
key, _ = PGPKey.from_blob(_locked_read(keyfile))
return key
_, keys = PGPKey.from_blob(_locked_read(keyfile))
key, revoked = split_revoked(keys.values())
return key[0], revoked
def write_pending_key(domain, nonce, key):
def write_pending_key(domain, nonce, key, revoked_keys):
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
_locked_write(keyfile, str(key))
armored = armor_keys([key] + revoked_keys)
_locked_write(keyfile, armored)
def remove_pending_key(domain, nonce):

View file

@ -3,27 +3,55 @@ 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, abort, response, request
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]
@get('/.well-known/openpgpkey/<domain>/submission-address')
def submission_address(domain: str):
def advanced_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 policy(domain: str):
def advanced_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 hu(domain: str, userhash: str):
def advanced_hu(domain: str, userhash: str):
if domain not in Config.domains:
abort(404, 'Not Found')
if Config.httpd['require_user_urlparam']:
@ -31,16 +59,22 @@ def hu(domain: str, userhash: str):
if not userid or hash_user_id(userid) != userhash:
abort(404, 'Not Found')
try:
pubkey = read_hashed_public_key(domain, userhash)
pubkey, revoked = read_hashed_public_key(domain, userhash)
response.add_header('Content-Type', 'application/octet-stream')
return bytes(pubkey)
response.add_header(*CORS)
return bytes(pubkey[0]) + b''.join([bytes(k) for k in revoked])
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):
run(host=Config.httpd['host'], port=Config.httpd['port'])
if __name__ == '__main__':
run_server()
run_server(None)

View file

@ -19,9 +19,9 @@ class LmtpMailServer:
process_mail(message)
except EasyWksError as e:
return f'550 {e}'
except BaseException:
tb = traceback.format_exc()
return f'550 Error during message processing: {tb}'
except Exception as e:
traceback.print_exc()
return f'550 Error during message processing: {e}'
return '250 Message successfully handled'

View file

@ -4,6 +4,7 @@ from .files import init_working_directory, clean_stale_requests
from .process import process_mail_from_stdin, process_key_from_stdin
from .httpd import run_server
from .lmtpd import run_lmtpd
from .dnsd import run_dnsd
import sys
@ -32,6 +33,9 @@ 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.')

View file

@ -1,20 +1,23 @@
import sys
from typing import List, Dict
from typing import Any, List, Dict, Tuple
from .crypto import pgp_decrypt
from .mailing import get_mailing_method
from .config import Config
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 .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
from pgpy import PGPMessage, PGPKey, PGPUID, PGPSignature
from pgpy.errors import PGPError
@ -46,30 +49,43 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
return pgp
def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
pubkey = None
def _get_pgp_publickeys(parts: List[MIMEPart]) -> Tuple[PGPKey, List[PGPKey]]:
pubkeys: Dict[str, PGPKey] = {}
for part in parts:
try:
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
_, 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
except PGPError:
pass
if not pubkey:
if len(pubkeys) == 0:
raise EasyWksError('No PGP public key found in the encrypted message part.')
return pubkey
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
def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str):
payload = BytesParser(policy=default).parsebytes(pgp.message)
leafs = _get_mime_leafs(payload)
pubkey = _get_pgp_publickey(leafs)
sender_uid: PGPUID = pubkey.get_uid(sender)
valid_key, revoked_keys = _get_pgp_publickeys(leafs)
sender_uid: PGPUID = valid_key.get_uid(sender)
if sender_uid is None or sender_uid.email != sender:
raise EasyWksError(f'Key has no UID that matches {sender}')
return SubmissionRequest(sender, submission, pubkey)
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)
def _find_confirmation_response(parts: List[MIMEPart]) -> str:
@ -120,6 +136,62 @@ 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)
@ -152,21 +224,29 @@ def process_mail(mail: bytes):
if confirmation_response is not None:
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
try:
key = read_pending_key(sender_domain, request.nonce)
key, revoked_keys = 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.')
# this throws an error if signature verification fails
request.verify_signature(key)
# 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)
response: PublishResponse = request.get_publish_response(key)
rmsg = response.create_signed_message()
write_public_key(sender_domain, sender_mail, key)
write_public_key(sender_domain, sender_mail, key, revoked_keys)
remove_pending_key(sender_domain, request.nonce)
else:
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
response: ConfirmationRequest = request.confirmation_request()
rmsg = response.create_signed_message()
write_pending_key(sender_domain, response.nonce, request.key)
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()
except EasyWksError as e:
rmsg = e.create_message(sender_mail, submission_address)
method = get_mailing_method(Config.mailing_method)
@ -200,5 +280,5 @@ def process_key_from_stdin(args):
print(f'Skipping foreign email {uid.email}')
continue
# All checks passed, importing key
write_public_key(domain, uid.email, pubkey)
write_public_key(domain, uid.email, pubkey, [])
print(f'Imported key {fingerprint(pubkey)} for email {uid.email}')

View file

@ -1,5 +1,7 @@
from datetime import datetime
from typing import List
from datetime import datetime, timezone
from email.encoders import encode_noop
from email.policy import default
from email.utils import format_datetime
@ -11,7 +13,7 @@ from pgpy import PGPKey, PGPMessage, PGPUID
from pgpy.errors import PGPError
from .crypto import pgp_sign
from .config import Config, render_message
from .config import render_message
from .util import create_nonce, fingerprint
@ -20,10 +22,11 @@ XLOOP_HEADER = 'EasyWKS'
class SubmissionRequest:
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, revoked_keys: List[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)
@ -40,6 +43,10 @@ class SubmissionRequest:
def key(self):
return self._key
@property
def revoked_keys(self):
return list(self._revoked_keys)
class ConfirmationRequest:
@ -89,15 +96,18 @@ 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))
to_sign = PGPMessage.new(mixed.as_string(policy=default).replace('\n', '\r\n'))
sig = pgp_sign(self.domain, to_sign)
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature')
email = MIMEMultipart(_subtype='signed', _subparts=[mixed, mpsig], policy=default,
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,
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.utcnow())
email['Date'] = format_datetime(datetime.now(timezone.utc))
email['Wks-Draft-Version'] = '3'
email['Wks-Phase'] = 'confirm'
email['X-Loop'] = XLOOP_HEADER
@ -135,13 +145,9 @@ class ConfirmationResponse:
def verify_signature(self, key: PGPKey):
if not self._msg.is_signed:
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
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.')
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')
@ -184,8 +190,8 @@ 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)
encrypted |= pgp_sign(self.domain, encrypted)
payload = MIMEApplication(str(encrypted), _subtype='octet-stream', _encoder=encode_noop)
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted', _encoder=encode_noop)
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
@ -193,7 +199,7 @@ class PublishResponse:
email['Subject'] = 'Your key has been published'
email['To'] = self.submitter_address
email['From'] = self.submission_address
email['Date'] = format_datetime(datetime.utcnow())
email['Date'] = format_datetime(datetime.now(timezone.utc))
email['Wks-Draft-Version'] = '3'
email['Wks-Phase'] = 'done'
email['X-Loop'] = XLOOP_HEADER
@ -221,7 +227,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.utcnow())
email['Date'] = format_datetime(datetime.now(timezone.utc))
email['Wks-Draft-Version'] = '3'
email['Wks-Phase'] = 'error'
email['X-Loop'] = XLOOP_HEADER

View file

@ -1,9 +1,19 @@
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:
@ -28,6 +38,12 @@ 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))
@ -36,3 +52,64 @@ 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}')

BIN
logo/easywks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

96
logo/easywks.svg Normal file
View file

@ -0,0 +1,96 @@
<?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>

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

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

View file

@ -1,10 +1,10 @@
Package: easywks
Version: __EASYWKS_VERSION__
Version: __VERSION__
Maintainer: s3lph <1375407-s3lph@users.noreply.gitlab.com>
Section: web
Priority: optional
Architecture: all
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd, python3-dnspython, python3-twisted
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,10 +7,14 @@
# 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
@ -42,6 +46,11 @@ 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.
@ -74,4 +83,52 @@ 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"
#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

View file

@ -0,0 +1,13 @@
[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,4 +10,7 @@ lmtpd:
httpd:
host: "::"
port: 80
dnsd:
host: "::"
port: 53
domains: {}

View file

@ -7,18 +7,27 @@ setup(
name='easywks',
version=__version__,
author='s3lph',
author_email='account-gitlab-ideynizv@kernelpanic.lol',
author_email='s3lph@kabelsalat.ch',
description='OpenPGP WKS for Human Beings',
license='MIT',
keywords='pgp,wks',
url='https://gitlab.com/s3lph/easywks',
url='https://git.kabelsalat.ch/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'

24
test/apache.conf Normal file
View file

@ -0,0 +1,24 @@
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>

22
test/config-v1.1.xml Normal file
View file

@ -0,0 +1,22 @@
<?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>

8
test/dovecot.conf Normal file
View file

@ -0,0 +1,8 @@
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

13
test/easywks.yml Normal file
View file

@ -0,0 +1,13 @@
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

19
test/expect Executable file
View file

@ -0,0 +1,19 @@
#!/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

24
test/genkey.sh Executable file
View file

@ -0,0 +1,24 @@
#!/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

1
test/transport Normal file
View file

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