Compare commits
41 commits
Author | SHA1 | Date | |
---|---|---|---|
dadcfb558e | |||
7de4943100 | |||
dadbcdbf3f | |||
3174aff2bc | |||
d9fa1733a8 | |||
b3e66b91c3 | |||
19cde32909 | |||
32855f2472 | |||
d6357c9bac | |||
aee85f2ff1 | |||
df55e7da4c | |||
6736c3fa2e | |||
a114f24314 | |||
c013284979 | |||
|
ba0070e1ac | ||
|
f6a7c9628b | ||
|
625088abcf | ||
|
6c27d799e3 | ||
|
360303d72f | ||
|
41de3b5704 | ||
|
9182582589 | ||
|
082d7d93d4 | ||
|
62fd2d52da | ||
|
f00ffb7dd2 | ||
|
68ac57c4ce | ||
|
4596ed2b31 | ||
|
4e1465cdb2 | ||
|
fddbea70d9 | ||
|
35f38ef188 | ||
|
01f694e98e | ||
|
b4a120b008 | ||
|
ea451639e6 | ||
|
b396a2c01c | ||
|
f4ea60c057 | ||
|
492b823215 | ||
|
0a337f90ee | ||
|
830819a6a5 | ||
|
ca7ddd205d | ||
|
c54d86412d | ||
|
7c8a5a377b | ||
|
b1f7d993c9 |
31 changed files with 1944 additions and 197 deletions
38
.forgejo/workflows/package.yml
Normal file
38
.forgejo/workflows/package.yml
Normal 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
136
.forgejo/workflows/test.yml
Normal 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
|
104
.gitlab-ci.yml
104
.gitlab-ci.yml
|
@ -1,104 +0,0 @@
|
||||||
---
|
|
||||||
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
|
|
163
CHANGELOG.md
163
CHANGELOG.md
|
@ -1,5 +1,168 @@
|
||||||
# EasyWKS Changelog
|
# 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 -->
|
<!-- BEGIN RELEASE v0.1.9 -->
|
||||||
## Version 0.1.9
|
## Version 0.1.9
|
||||||
|
|
||||||
|
|
157
README.md
157
README.md
|
@ -4,12 +4,10 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
This is a work-in-progress project. See ROADMAP.md for details
|
|
||||||
|
|
||||||
## What is WKD/WKS?
|
## 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
|
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
|
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
|
HTTPS web directory. For example, in order to retrieve the key for `john.doe@example.org`, they key can be located at
|
||||||
|
|
||||||
|
@ -52,6 +50,8 @@ gpg-wks-server to EasyWKS.
|
||||||
- PyYAML
|
- PyYAML
|
||||||
- bottle.py
|
- bottle.py
|
||||||
- PGPy
|
- PGPy
|
||||||
|
- dnspython (for DANE support)
|
||||||
|
- Twisted (for DANE support)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
@ -75,7 +75,7 @@ Configuration is done in `/etc/easywks.yml` (or any other place as specified by
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
# EasyWKS works inside this directory. Its PGP keys as well# as all
|
# EasyWKS works inside this directory. Its PGP keys as well as all
|
||||||
# the submitted and published keys are stored here.
|
# the submitted and published keys are stored here.
|
||||||
directory: /var/lib/easywks
|
directory: /var/lib/easywks
|
||||||
|
|
||||||
|
@ -118,6 +118,11 @@ lmtpd:
|
||||||
host: "::1"
|
host: "::1"
|
||||||
port: 8024
|
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.
|
# You can override the mail response templates with your own text.
|
||||||
# The following templates can be overridden:
|
# The following templates can be overridden:
|
||||||
# - "header": Placed in front of every message.
|
# - "header": Placed in front of every message.
|
||||||
|
@ -129,7 +134,7 @@ lmtpd:
|
||||||
# - {domain}: The email domain for with the request is processed.
|
# - {domain}: The email domain for with the request is processed.
|
||||||
# - {sender}: The submitter's mail address.
|
# - {sender}: The submitter's mail address.
|
||||||
# - {submission}: The submission address.
|
# - {submission}: The submission address.
|
||||||
# When overriding the "error" template, theres an additional
|
# When overriding the "error" template, there's an additional
|
||||||
# placeholder you can use:
|
# placeholder you can use:
|
||||||
# - {error}: The error message.
|
# - {error}: The error message.
|
||||||
#responses:
|
#responses:
|
||||||
|
@ -254,6 +259,148 @@ gpgwks@example.org lmtp:localhost:10024
|
||||||
webkey@example.com 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
|
[wkd]: https://wiki.gnupg.org/WKD
|
||||||
[wks]: https://wiki.gnupg.org/WKS
|
[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
Executable file
646
client.py
Executable file
|
@ -0,0 +1,646 @@
|
||||||
|
#!/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()
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
__version__ = '0.1.9'
|
__version__ = '0.4.6'
|
||||||
|
|
|
@ -3,6 +3,33 @@ import string
|
||||||
import yaml
|
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):
|
def _validate_mailing_method(value):
|
||||||
methods = ['stdout', 'smtp']
|
methods = ['stdout', 'smtp']
|
||||||
if value not in methods:
|
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"])}'
|
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):
|
def _validate_policy_flags(value):
|
||||||
alphabet = string.ascii_lowercase + string.digits + '-._'
|
alphabet = string.ascii_lowercase + string.digits + '-._'
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
|
@ -77,6 +113,46 @@ def _validate_policy_flags(value):
|
||||||
for c in flag:
|
for c in flag:
|
||||||
if c not in alphabet:
|
if c not in alphabet:
|
||||||
return f'has invalid key {flag}'
|
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):
|
def _validate_responses(value):
|
||||||
|
@ -173,9 +249,10 @@ class _GlobalConfig(_Config):
|
||||||
|
|
||||||
def __make_domain(self, domain):
|
def __make_domain(self, domain):
|
||||||
self.__domains[domain] = _Config(
|
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, ''),
|
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):
|
def __getitem__(self, item):
|
||||||
|
@ -194,13 +271,14 @@ class _GlobalConfig(_Config):
|
||||||
for domain, dconf in conf['domains'].items():
|
for domain, dconf in conf['domains'].items():
|
||||||
self.__make_domain(domain)
|
self.__make_domain(domain)
|
||||||
for co in self.__domains[domain]._options.values():
|
for co in self.__domains[domain]._options.values():
|
||||||
co.load(conf)
|
co.load(dconf)
|
||||||
|
|
||||||
|
|
||||||
Config = _GlobalConfig(
|
Config = _GlobalConfig(
|
||||||
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
|
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
|
||||||
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
|
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
|
||||||
mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method),
|
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),
|
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
|
||||||
httpd=_ConfigOption('httpd', dict, {
|
httpd=_ConfigOption('httpd', dict, {
|
||||||
'host': 'localhost',
|
'host': 'localhost',
|
||||||
|
@ -217,6 +295,10 @@ Config = _GlobalConfig(
|
||||||
'host': 'localhost',
|
'host': 'localhost',
|
||||||
'port': 25,
|
'port': 25,
|
||||||
}, validator=_validate_lmtpd_config),
|
}, validator=_validate_lmtpd_config),
|
||||||
|
dnsd=_ConfigOption('dnsd', dict, {
|
||||||
|
'host': '::1',
|
||||||
|
'port': 10053,
|
||||||
|
}, validator=_validate_dnsd_config),
|
||||||
responses=_ConfigOption('responses', dict, {}, validator=_validate_responses),
|
responses=_ConfigOption('responses', dict, {}, validator=_validate_responses),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
90
easywks/dnsd.py
Normal file
90
easywks/dnsd.py
Normal 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()
|
|
@ -8,7 +8,7 @@ from pgpy import PGPKey
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .crypto import create_pgp_key, privkey_to_pubkey
|
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):
|
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):
|
def make_policy_file(domain: str):
|
||||||
content = f'submission-address: {Config[domain].submission_address}\n'
|
content = f'submission-address: {Config[domain].submission_address}\n'
|
||||||
for flag, value in Config[domain].policy_flags.items():
|
for flag, value in Config[domain].policy_flags.items():
|
||||||
if not value or len(value) == 0:
|
if isinstance(value, bool):
|
||||||
content += f'{flag}: {value}\n'
|
if not value:
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
content += flag + '\n'
|
content += flag + '\n'
|
||||||
|
elif value is None:
|
||||||
|
content += flag + '\n'
|
||||||
|
else:
|
||||||
|
content += f'{flag}: {value}\n'
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@ -53,42 +58,62 @@ def init_working_directory():
|
||||||
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
|
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, 'submission-address'), make_submission_address_file(domain))
|
||||||
_locked_write(os.path.join(wdir, domain, 'policy'), make_policy_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 if it doesn't exist yet
|
||||||
create_pgp_key(domain)
|
create_pgp_key(domain)
|
||||||
# Export submission key to hu dir
|
# Export submission key to hu dir
|
||||||
key = privkey_to_pubkey(domain)
|
key = privkey_to_pubkey(domain)
|
||||||
uid = hash_user_id(Config[domain].submission_address)
|
uid = hash_user_id(Config[domain].submission_address)
|
||||||
_locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True)
|
_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):
|
def read_public_key(domain, user):
|
||||||
hu = hash_user_id(user)
|
return read_hashed_public_key(domain, 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):
|
def read_hashed_public_key(domain, hu):
|
||||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||||
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
|
_, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True))
|
||||||
return key
|
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)
|
hu = hash_user_id(user)
|
||||||
|
dane = dane_digest(user)
|
||||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
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):
|
def read_pending_key(domain, nonce):
|
||||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||||
key, _ = PGPKey.from_blob(_locked_read(keyfile))
|
_, keys = PGPKey.from_blob(_locked_read(keyfile))
|
||||||
return key
|
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)
|
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):
|
def remove_pending_key(domain, nonce):
|
||||||
|
@ -96,7 +121,7 @@ def remove_pending_key(domain, nonce):
|
||||||
os.unlink(keyfile)
|
os.unlink(keyfile)
|
||||||
|
|
||||||
|
|
||||||
def clean_stale_requests():
|
def clean_stale_requests(args):
|
||||||
stale = (datetime.utcnow() - timedelta(seconds=Config.pending_lifetime)).timestamp()
|
stale = (datetime.utcnow() - timedelta(seconds=Config.pending_lifetime)).timestamp()
|
||||||
for domain in Config.domains:
|
for domain in Config.domains:
|
||||||
pending_dir = os.path.join(Config.working_directory, domain, 'pending')
|
pending_dir = os.path.join(Config.working_directory, domain, 'pending')
|
||||||
|
|
|
@ -3,27 +3,55 @@ from .config import Config
|
||||||
from .files import read_hashed_public_key, make_submission_address_file, make_policy_file
|
from .files import read_hashed_public_key, make_submission_address_file, make_policy_file
|
||||||
from .util import hash_user_id
|
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')
|
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
||||||
def submission_address(domain: str):
|
def advanced_submission_address(domain: str):
|
||||||
if domain not in Config.domains:
|
if domain not in Config.domains:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
response.add_header('Content-Type', 'text/plain')
|
response.add_header('Content-Type', 'text/plain')
|
||||||
|
response.add_header(*CORS)
|
||||||
return make_submission_address_file(domain)
|
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')
|
@get('/.well-known/openpgpkey/<domain>/policy')
|
||||||
def policy(domain: str):
|
def advanced_policy(domain: str):
|
||||||
if domain not in Config.domains:
|
if domain not in Config.domains:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
response.add_header('Content-Type', 'text/plain')
|
response.add_header('Content-Type', 'text/plain')
|
||||||
|
response.add_header(*CORS)
|
||||||
return make_policy_file(domain)
|
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>')
|
@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:
|
if domain not in Config.domains:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
if Config.httpd['require_user_urlparam']:
|
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:
|
if not userid or hash_user_id(userid) != userhash:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
try:
|
try:
|
||||||
pubkey = read_hashed_public_key(domain, userhash)
|
pubkey, revoked = read_hashed_public_key(domain, userhash)
|
||||||
response.add_header('Content-Type', 'application/octet-stream')
|
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:
|
except FileNotFoundError:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
|
|
||||||
|
|
||||||
def run_server():
|
@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'])
|
run(host=Config.httpd['host'], port=Config.httpd['port'])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
run_server()
|
run_server(None)
|
||||||
|
|
|
@ -19,9 +19,9 @@ class LmtpMailServer:
|
||||||
process_mail(message)
|
process_mail(message)
|
||||||
except EasyWksError as e:
|
except EasyWksError as e:
|
||||||
return f'550 {e}'
|
return f'550 {e}'
|
||||||
except BaseException:
|
except Exception as e:
|
||||||
tb = traceback.format_exc()
|
traceback.print_exc()
|
||||||
return f'550 Error during message processing: {tb}'
|
return f'550 Error during message processing: {e}'
|
||||||
return '250 Message successfully handled'
|
return '250 Message successfully handled'
|
||||||
|
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ class LmtpdController(Controller):
|
||||||
return LMTP(handler=self.handler, ident=f'EasyWKS {version}', loop=self.loop)
|
return LMTP(handler=self.handler, ident=f'EasyWKS {version}', loop=self.loop)
|
||||||
|
|
||||||
|
|
||||||
def run_lmtpd():
|
def run_lmtpd(args):
|
||||||
controller = LmtpdController(handler=LmtpMailServer(), hostname=Config.lmtpd['host'], port=Config.lmtpd['port'])
|
controller = LmtpdController(handler=LmtpMailServer(), hostname=Config.lmtpd['host'], port=Config.lmtpd['port'])
|
||||||
controller.start()
|
controller.start()
|
||||||
asyncio.get_event_loop().run_forever()
|
asyncio.get_event_loop().run_forever()
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .files import init_working_directory, clean_stale_requests
|
from .files import init_working_directory, clean_stale_requests
|
||||||
from .process import process_mail_from_stdin
|
from .process import process_mail_from_stdin, process_key_from_stdin
|
||||||
from .httpd import run_server
|
from .httpd import run_server
|
||||||
from .lmtpd import run_lmtpd
|
from .lmtpd import run_lmtpd
|
||||||
|
from .dnsd import run_dnsd
|
||||||
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
@ -32,6 +33,14 @@ def parse_arguments():
|
||||||
server = sp.add_parser('lmtpd', help='Run a LMTP server to receive mails from your MTA. Also see process.')
|
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.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:])
|
return ap.parse_args(sys.argv[1:])
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,7 +53,7 @@ def main():
|
||||||
Config.load_config(conf)
|
Config.load_config(conf)
|
||||||
init_working_directory()
|
init_working_directory()
|
||||||
if args.fn:
|
if args.fn:
|
||||||
args.fn()
|
args.fn(args)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -1,19 +1,23 @@
|
||||||
import sys
|
import sys
|
||||||
from typing import List, Dict
|
from typing import Any, List, Dict, Tuple
|
||||||
|
|
||||||
from .crypto import pgp_decrypt
|
from .crypto import pgp_decrypt
|
||||||
from .mailing import get_mailing_method
|
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 .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
|
XLOOP_HEADER
|
||||||
|
from .types import fingerprint
|
||||||
|
from .util import split_revoked
|
||||||
|
|
||||||
from email.message import MIMEPart, Message
|
from email.message import MIMEPart, Message
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from email.policy import default
|
from email.policy import default
|
||||||
from email.utils import getaddresses
|
from email.utils import getaddresses
|
||||||
|
|
||||||
from pgpy import PGPMessage, PGPKey, PGPUID
|
from pgpy import PGPMessage, PGPKey, PGPUID, PGPSignature
|
||||||
from pgpy.errors import PGPError
|
from pgpy.errors import PGPError
|
||||||
|
|
||||||
|
|
||||||
|
@ -45,30 +49,43 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
|
||||||
return pgp
|
return pgp
|
||||||
|
|
||||||
|
|
||||||
def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
|
def _get_pgp_publickeys(parts: List[MIMEPart]) -> Tuple[PGPKey, List[PGPKey]]:
|
||||||
pubkey = None
|
pubkeys: Dict[str, PGPKey] = {}
|
||||||
for part in parts:
|
for part in parts:
|
||||||
try:
|
try:
|
||||||
key, _ = PGPKey.from_blob(part.get_content())
|
_, keys = PGPKey.from_blob(part.get_content())
|
||||||
if key.is_public:
|
for (_, public), key in keys.items():
|
||||||
if pubkey:
|
if not public:
|
||||||
raise EasyWksError('More than one PGP public key in message. Only submit a single key at once.')
|
continue
|
||||||
pubkey = key
|
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:
|
except PGPError:
|
||||||
pass
|
pass
|
||||||
if not pubkey:
|
if len(pubkeys) == 0:
|
||||||
raise EasyWksError('No PGP public key found in the encrypted message part.')
|
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):
|
def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str):
|
||||||
payload = BytesParser(policy=default).parsebytes(pgp.message)
|
payload = BytesParser(policy=default).parsebytes(pgp.message)
|
||||||
leafs = _get_mime_leafs(payload)
|
leafs = _get_mime_leafs(payload)
|
||||||
pubkey = _get_pgp_publickey(leafs)
|
valid_key, revoked_keys = _get_pgp_publickeys(leafs)
|
||||||
sender_uid: PGPUID = pubkey.get_uid(sender)
|
sender_uid: PGPUID = valid_key.get_uid(sender)
|
||||||
if sender_uid is None or sender_uid.email != sender:
|
if sender_uid is None or sender_uid.email != sender:
|
||||||
raise EasyWksError(f'Key has no UID that matches {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:
|
def _find_confirmation_response(parts: List[MIMEPart]) -> str:
|
||||||
|
@ -119,6 +136,62 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
|
||||||
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
|
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):
|
def process_mail(mail: bytes):
|
||||||
try:
|
try:
|
||||||
msg: Message = BytesParser(policy=default).parsebytes(mail)
|
msg: Message = BytesParser(policy=default).parsebytes(mail)
|
||||||
|
@ -151,27 +224,61 @@ def process_mail(mail: bytes):
|
||||||
if confirmation_response is not None:
|
if confirmation_response is not None:
|
||||||
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
|
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
|
||||||
try:
|
try:
|
||||||
key = read_pending_key(sender_domain, request.nonce)
|
key, revoked_keys = read_pending_key(sender_domain, request.nonce)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
raise EasyWksError('There is no submission request for this email address, or it has expired. '
|
raise EasyWksError('There is no submission request for this email address, or it has expired. '
|
||||||
'Please resubmit your submission request.')
|
'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
|
# this throws an error if signature verification fails
|
||||||
request.verify_signature(key)
|
request.verify_signature(key)
|
||||||
response: PublishResponse = request.get_publish_response(key)
|
response: PublishResponse = request.get_publish_response(key)
|
||||||
rmsg = response.create_signed_message()
|
write_public_key(sender_domain, sender_mail, key, revoked_keys)
|
||||||
write_public_key(sender_domain, sender_mail, key)
|
|
||||||
remove_pending_key(sender_domain, request.nonce)
|
remove_pending_key(sender_domain, request.nonce)
|
||||||
else:
|
else:
|
||||||
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
|
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()
|
response: ConfirmationRequest = request.confirmation_request()
|
||||||
|
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys)
|
||||||
rmsg = response.create_signed_message()
|
rmsg = response.create_signed_message()
|
||||||
write_pending_key(sender_domain, response.nonce, request.key)
|
|
||||||
except EasyWksError as e:
|
except EasyWksError as e:
|
||||||
rmsg = e.create_message(sender_mail, submission_address)
|
rmsg = e.create_message(sender_mail, submission_address)
|
||||||
method = get_mailing_method(Config.mailing_method)
|
method = get_mailing_method(Config.mailing_method)
|
||||||
method(rmsg)
|
method(rmsg)
|
||||||
|
|
||||||
|
|
||||||
def process_mail_from_stdin():
|
def process_mail_from_stdin(args):
|
||||||
mail = sys.stdin.read().encode()
|
mail = sys.stdin.read().encode()
|
||||||
process_mail(mail)
|
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}')
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
|
|
||||||
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.policy import default
|
||||||
from email.utils import format_datetime
|
from email.utils import format_datetime
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
@ -10,7 +13,7 @@ from pgpy import PGPKey, PGPMessage, PGPUID
|
||||||
from pgpy.errors import PGPError
|
from pgpy.errors import PGPError
|
||||||
|
|
||||||
from .crypto import pgp_sign
|
from .crypto import pgp_sign
|
||||||
from .config import Config, render_message
|
from .config import render_message
|
||||||
from .util import create_nonce, fingerprint
|
from .util import create_nonce, fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
@ -19,10 +22,11 @@ XLOOP_HEADER = 'EasyWKS'
|
||||||
|
|
||||||
class SubmissionRequest:
|
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._submitter_addr = submitter_addr
|
||||||
self._submission_addr = submission_addr
|
self._submission_addr = submission_addr
|
||||||
self._key = key
|
self._key = key
|
||||||
|
self._revoked_keys = revoked_keys
|
||||||
|
|
||||||
def confirmation_request(self) -> 'ConfirmationRequest':
|
def confirmation_request(self) -> 'ConfirmationRequest':
|
||||||
return ConfirmationRequest(self._submitter_addr, self. _submission_addr, self._key)
|
return ConfirmationRequest(self._submitter_addr, self. _submission_addr, self._key)
|
||||||
|
@ -39,6 +43,10 @@ class SubmissionRequest:
|
||||||
def key(self):
|
def key(self):
|
||||||
return self._key
|
return self._key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def revoked_keys(self):
|
||||||
|
return list(self._revoked_keys)
|
||||||
|
|
||||||
|
|
||||||
class ConfirmationRequest:
|
class ConfirmationRequest:
|
||||||
|
|
||||||
|
@ -88,15 +96,18 @@ class ConfirmationRequest:
|
||||||
encrypted = self._key.encrypt(to_encrypt)
|
encrypted = self._key.encrypt(to_encrypt)
|
||||||
mpenc = MIMEApplication(str(encrypted), _subtype='vnd.gnupg.wks')
|
mpenc = MIMEApplication(str(encrypted), _subtype='vnd.gnupg.wks')
|
||||||
mixed = MIMEMultipart(_subtype='mixed', _subparts=[mpplain, mpenc])
|
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)
|
sig = pgp_sign(self.domain, to_sign)
|
||||||
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature')
|
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature', name='signature.asc', _encoder=encode_noop)
|
||||||
email = MIMEMultipart(_subtype='signed', _subparts=[mixed, mpsig], policy=default,
|
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')
|
protocol='application/pgp-signature')
|
||||||
|
email.set_param('micalg', f'pgp-{str(sig.hash_algorithm).lower()}', requote=False)
|
||||||
email['Subject'] = 'Confirm your key publication'
|
email['Subject'] = 'Confirm your key publication'
|
||||||
email['To'] = self._submitter_addr
|
email['To'] = self._submitter_addr
|
||||||
email['From'] = self._submission_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-Draft-Version'] = '3'
|
||||||
email['Wks-Phase'] = 'confirm'
|
email['Wks-Phase'] = 'confirm'
|
||||||
email['X-Loop'] = XLOOP_HEADER
|
email['X-Loop'] = XLOOP_HEADER
|
||||||
|
@ -134,13 +145,9 @@ class ConfirmationResponse:
|
||||||
|
|
||||||
def verify_signature(self, key: PGPKey):
|
def verify_signature(self, key: PGPKey):
|
||||||
if not self._msg.is_signed:
|
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 '
|
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 '
|
'gpg-wks-client for submitting your response, please update said tool or try '
|
||||||
'responding manually.')
|
'responding manually.')
|
||||||
else:
|
|
||||||
# Unsigned, but permitted
|
|
||||||
return
|
|
||||||
uid: PGPUID = key.get_uid(self._submitter_addr)
|
uid: PGPUID = key.get_uid(self._submitter_addr)
|
||||||
if uid is None or uid.email != 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')
|
raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key')
|
||||||
|
@ -183,16 +190,16 @@ class PublishResponse:
|
||||||
submission=self.submission_address)
|
submission=self.submission_address)
|
||||||
mpplain = MIMEText(mail_text, _subtype='plain')
|
mpplain = MIMEText(mail_text, _subtype='plain')
|
||||||
to_encrypt = PGPMessage.new(mpplain.as_string(policy=default))
|
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: PGPMessage = self.key.encrypt(to_encrypt)
|
||||||
encrypted |= pgp_sign(self.domain, encrypted)
|
payload = MIMEApplication(str(encrypted), _subtype='octet-stream', _encoder=encode_noop)
|
||||||
payload = MIMEApplication(str(encrypted), _subtype='octet-stream')
|
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted', _encoder=encode_noop)
|
||||||
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted')
|
|
||||||
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
|
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
|
||||||
protocol='application/pgp-encrypted')
|
protocol='application/pgp-encrypted')
|
||||||
email['Subject'] = 'Your key has been published'
|
email['Subject'] = 'Your key has been published'
|
||||||
email['To'] = self.submitter_address
|
email['To'] = self.submitter_address
|
||||||
email['From'] = self.submission_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-Draft-Version'] = '3'
|
||||||
email['Wks-Phase'] = 'done'
|
email['Wks-Phase'] = 'done'
|
||||||
email['X-Loop'] = XLOOP_HEADER
|
email['X-Loop'] = XLOOP_HEADER
|
||||||
|
@ -220,7 +227,7 @@ class EasyWksError(BaseException):
|
||||||
email['Subject'] = 'An error has occurred while processing your request'
|
email['Subject'] = 'An error has occurred while processing your request'
|
||||||
email['From'] = submission_addr
|
email['From'] = submission_addr
|
||||||
email['To'] = submitter_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-Draft-Version'] = '3'
|
||||||
email['Wks-Phase'] = 'error'
|
email['Wks-Phase'] = 'error'
|
||||||
email['X-Loop'] = XLOOP_HEADER
|
email['X-Loop'] = XLOOP_HEADER
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
|
|
||||||
|
from typing import Iterable, List, Tuple
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
import textwrap
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from twisted.names import dns
|
||||||
|
from twisted.internet import reactor, defer
|
||||||
from pgpy import PGPKey
|
from pgpy import PGPKey
|
||||||
|
from pgpy.constants import SignatureType
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
|
||||||
|
|
||||||
def _zrtp_base32(sha1: bytes) -> str:
|
def _zrtp_base32(sha1: bytes) -> str:
|
||||||
|
@ -28,6 +38,12 @@ def hash_user_id(uid: str) -> str:
|
||||||
return _zrtp_base32(digest)
|
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:
|
def create_nonce(n: int = 32) -> str:
|
||||||
alphabet = string.ascii_letters + string.digits
|
alphabet = string.ascii_letters + string.digits
|
||||||
nonce = ''.join(secrets.choice(alphabet) for _ in range(n))
|
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:
|
def fingerprint(key: PGPKey) -> str:
|
||||||
return key.fingerprint.upper().replace(' ', '')
|
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
BIN
logo/easywks.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
96
logo/easywks.svg
Normal file
96
logo/easywks.svg
Normal 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 |
|
@ -1 +1,2 @@
|
||||||
/etc/easywks.yml
|
/etc/easywks.yml
|
||||||
|
/etc/cron.d/easywks
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
Package: easywks
|
Package: easywks
|
||||||
Version: __EASYWKS_VERSION__
|
Version: __VERSION__
|
||||||
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
|
Maintainer: s3lph <1375407-s3lph@users.noreply.gitlab.com>
|
||||||
Section: web
|
Section: web
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: all
|
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
|
Description: OpenPGP WKS for Human Beings
|
||||||
EasyWKS is a drop-in replacement for gpg-wks-server that aims to be
|
EasyWKS is a drop-in replacement for gpg-wks-server that aims to be
|
||||||
much easyier to use manually, while maintaing compatibility with the
|
much easyier to use manually, while maintaing compatibility with the
|
||||||
|
|
|
@ -7,10 +7,14 @@
|
||||||
# considered stale and should be removed by easywks clean.
|
# considered stale and should be removed by easywks clean.
|
||||||
#pending_lifetime: 604800
|
#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
|
# older version of the WKS standard where signing the confirmation
|
||||||
# response is only recommended, but not required. Set this option to
|
# response is only recommended, but not required. Set this option to
|
||||||
# true if you want to accept such unsigned responses.
|
# 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
|
#permit_unsigned_response: false
|
||||||
|
|
||||||
# Port configuration for the webserver. Put this behind a
|
# Port configuration for the webserver. Put this behind a
|
||||||
|
@ -42,6 +46,11 @@ lmtpd:
|
||||||
host: "::1"
|
host: "::1"
|
||||||
port: 8024
|
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.
|
# You can override the mail response templates with your own text.
|
||||||
# The following templates can be overridden:
|
# The following templates can be overridden:
|
||||||
# - "header": Placed in front of every message.
|
# - "header": Placed in front of every message.
|
||||||
|
@ -75,3 +84,51 @@ domains:
|
||||||
# or if you're supplying your own password-protected key, set the
|
# or if you're supplying your own password-protected key, set the
|
||||||
# passphrase here:
|
# 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
|
|
@ -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
|
|
@ -10,4 +10,7 @@ lmtpd:
|
||||||
httpd:
|
httpd:
|
||||||
host: "::"
|
host: "::"
|
||||||
port: 80
|
port: 80
|
||||||
|
dnsd:
|
||||||
|
host: "::"
|
||||||
|
port: 53
|
||||||
domains: {}
|
domains: {}
|
||||||
|
|
13
setup.py
13
setup.py
|
@ -7,18 +7,27 @@ setup(
|
||||||
name='easywks',
|
name='easywks',
|
||||||
version=__version__,
|
version=__version__,
|
||||||
author='s3lph',
|
author='s3lph',
|
||||||
author_email='account-gitlab-ideynizv@kernelpanic.lol',
|
author_email='s3lph@kabelsalat.ch',
|
||||||
description='OpenPGP WKS for Human Beings',
|
description='OpenPGP WKS for Human Beings',
|
||||||
license='MIT',
|
license='MIT',
|
||||||
keywords='pgp,wks',
|
keywords='pgp,wks',
|
||||||
url='https://gitlab.com/s3lph/easywks',
|
url='https://git.kabelsalat.ch/s3lph/easywks',
|
||||||
packages=find_packages(exclude=['*.test']),
|
packages=find_packages(exclude=['*.test']),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'aiosmtpd',
|
'aiosmtpd',
|
||||||
'bottle',
|
'bottle',
|
||||||
|
'dnspython',
|
||||||
'PyYAML',
|
'PyYAML',
|
||||||
'PGPy',
|
'PGPy',
|
||||||
|
'Twisted',
|
||||||
],
|
],
|
||||||
|
extras_require={
|
||||||
|
'test': [
|
||||||
|
'coverage',
|
||||||
|
'pycodestyle',
|
||||||
|
'twine'
|
||||||
|
]
|
||||||
|
},
|
||||||
entry_points={
|
entry_points={
|
||||||
'console_scripts': [
|
'console_scripts': [
|
||||||
'easywks = easywks.main:main'
|
'easywks = easywks.main:main'
|
||||||
|
|
24
test/apache.conf
Normal file
24
test/apache.conf
Normal 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
22
test/config-v1.1.xml
Normal 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
8
test/dovecot.conf
Normal 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
13
test/easywks.yml
Normal 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
19
test/expect
Executable 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
24
test/genkey.sh
Executable 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
1
test/transport
Normal file
|
@ -0,0 +1 @@
|
||||||
|
webkey@example.org lmtp:[127.0.0.1]:8024
|
Loading…
Reference in a new issue