Compare commits
No commits in common. "main" and "v0.1.1" have entirely different histories.
35 changed files with 379 additions and 2363 deletions
|
@ -1,38 +0,0 @@
|
||||||
---
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
build_wheel:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
- name: Build Python wheel
|
|
||||||
run: |
|
|
||||||
apt update; apt install -y python3-pip
|
|
||||||
pip3 install --break-system-packages -e .[test]
|
|
||||||
python3 setup.py egg_info bdist_wheel
|
|
||||||
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-wheel-package-upload@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.API_USERNAME }}
|
|
||||||
password: ${{ secrets.API_PASSWORD }}
|
|
||||||
|
|
||||||
build_debian:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-python-debian-package@v5
|
|
||||||
with:
|
|
||||||
python_module: easywks
|
|
||||||
package_name: easywks
|
|
||||||
package_root: package/debian/easywks
|
|
||||||
package_output_path: package/debian
|
|
||||||
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-debian-package-upload@v2
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.API_USERNAME }}
|
|
||||||
password: ${{ secrets.API_PASSWORD }}
|
|
||||||
deb: "package/debian/*.deb"
|
|
|
@ -1,136 +0,0 @@
|
||||||
---
|
|
||||||
|
|
||||||
on: push
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
test:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
- name: Run unit tests
|
|
||||||
run: |
|
|
||||||
apt update; apt install -y python3-pip
|
|
||||||
pip3 install --break-system-packages -e .[test]
|
|
||||||
python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
|
|
||||||
python3 -m coverage combine
|
|
||||||
python3 -m coverage report --rcfile=setup.cfg
|
|
||||||
|
|
||||||
codestyle:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
- name: pycodestyle
|
|
||||||
run: |
|
|
||||||
apt update; apt install -y python3-pip
|
|
||||||
pip3 install --break-system-packages -e .[test]
|
|
||||||
pycodestyle easywks
|
|
||||||
|
|
||||||
easywksserver_gpgwksclient:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
- name: Integration Test against gpg-wks-client
|
|
||||||
run: |
|
|
||||||
apt update; apt install --yes gnupg2 socat ca-certificates python3-pip
|
|
||||||
echo "openpgpkey" > /etc/hostname
|
|
||||||
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
|
|
||||||
pip3 install --break-system-packages -e .[test]
|
|
||||||
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org'
|
|
||||||
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
|
|
||||||
update-ca-certificates
|
|
||||||
mkdir -p /tmp/easywks
|
|
||||||
cat > /tmp/easywks.yml <<EOF
|
|
||||||
directory: /tmp/easywks
|
|
||||||
httpd:
|
|
||||||
host: 127.0.0.1
|
|
||||||
port: 8080
|
|
||||||
mailing_method: stdout
|
|
||||||
domains:
|
|
||||||
example.org:
|
|
||||||
submission_address: webkey@example.org
|
|
||||||
policy_flags:
|
|
||||||
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat
|
|
||||||
EOF
|
|
||||||
easywks --config /tmp/easywks.yml init
|
|
||||||
easywks --config /tmp/easywks.yml webserver &
|
|
||||||
socat OPENSSL-LISTEN:443,fork,reuseaddr,verify=0,cert=/etc/ssl/cert.pem,key=/etc/ssl/key.pem TCP:127.0.0.1:8080 &
|
|
||||||
sleep 3
|
|
||||||
install -m 0700 -d /tmp/gpg /tmp/cleangpg
|
|
||||||
export GNUPGHOME=/tmp/gpg
|
|
||||||
test/genkey.sh alice@example.org
|
|
||||||
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
|
|
||||||
/usr/lib/gnupg/gpg-wks-client --supported alice@example.org
|
|
||||||
/usr/lib/gnupg/gpg-wks-client --check webkey@example.org
|
|
||||||
PUBREQ="$(/usr/lib/gnupg/gpg-wks-client --create "${FINGERPRINT}" alice@example.org)"
|
|
||||||
CONFREQ="$(echo "${PUBREQ}" | easywks --config /tmp/easywks.yml process)"
|
|
||||||
CONFRESP="$(echo "${CONFREQ}" | /usr/lib/gnupg/gpg-wks-client --receive --verbose)"
|
|
||||||
PUBRESP="$(echo "${CONFRESP}" | easywks --config /tmp/easywks.yml process)"
|
|
||||||
echo "${PUBRESP}" | gpg --batch --decrypt
|
|
||||||
/usr/lib/gnupg/gpg-wks-client --check alice@example.org
|
|
||||||
export GNUPGHOME=/tmp/gpg
|
|
||||||
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
|
|
||||||
kill %2 || true
|
|
||||||
kill %1 || true
|
|
||||||
|
|
||||||
easywksserver_easywksclient:
|
|
||||||
runs-on: docker
|
|
||||||
steps:
|
|
||||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
|
||||||
- name: Integration Test against easywks-client
|
|
||||||
run: |
|
|
||||||
# General system setup
|
|
||||||
useradd -d /home/alice -m alice
|
|
||||||
useradd -d /home/webkey -m webkey
|
|
||||||
echo alice:supersecurepassword | chpasswd
|
|
||||||
echo "postfix postfix/mailname string example.org" | debconf-set-selections
|
|
||||||
echo "postfix postfix/main_mailer_type string 'Local only'" | debconf-set-selections
|
|
||||||
apt update; apt install --yes gnupg2 ca-certificates python3-pip apache2 dovecot-imapd postfix expect
|
|
||||||
echo "openpgpkey" > /etc/hostname
|
|
||||||
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
|
|
||||||
pip3 install --break-system-packages -e .[test]
|
|
||||||
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org' -addext 'subjectAltName=DNS:openpgpkey.example.org,DNS:example.org'
|
|
||||||
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
|
|
||||||
update-ca-certificates
|
|
||||||
# Setup Apache
|
|
||||||
a2enmod ssl proxy_http rewrite
|
|
||||||
rm /etc/apache2/sites-enabled/000-default.conf
|
|
||||||
cp test/apache.conf /etc/apache2/sites-enabled/easywks.conf
|
|
||||||
apache2ctl start
|
|
||||||
mkdir -p /var/www/html/.well-known/autoconfig/mail/
|
|
||||||
cp test/config-v1.1.xml /var/www/html/.well-known/autoconfig/mail/config-v1.1.xml
|
|
||||||
# Setup Dovecot
|
|
||||||
cp test/dovecot.conf /etc/dovecot/conf.d/99-local.conf
|
|
||||||
dovecot -F &
|
|
||||||
# Setup Postfix
|
|
||||||
/usr/lib/postfix/configure-instance.sh -
|
|
||||||
cp test/transport /etc/postfix/transport
|
|
||||||
postmap /etc/postfix/transport
|
|
||||||
postconf smtpd_tls_cert_file=/etc/ssl/cert.pem
|
|
||||||
postconf smtpd_tls_key_file=/etc/ssl/key.pem
|
|
||||||
postconf transport_maps=hash:/etc/postfix/transport
|
|
||||||
postconf smtpd_sasl_type=dovecot
|
|
||||||
postconf smtpd_sasl_path=private/auth
|
|
||||||
postconf smtpd_sasl_auth_enable=yes
|
|
||||||
/usr/sbin/postmulti -i - -p start
|
|
||||||
# Setup EasyWKS
|
|
||||||
mkdir -p /tmp/easywks
|
|
||||||
cp test/easywks.yml /tmp/easywks.yml
|
|
||||||
easywks --config /tmp/easywks.yml init
|
|
||||||
easywks --config /tmp/easywks.yml webserver &
|
|
||||||
easywks --config /tmp/easywks.yml lmtpd &
|
|
||||||
sleep 3
|
|
||||||
# Run the test
|
|
||||||
install -m 0700 -d /tmp/gpg /tmp/cleangpg
|
|
||||||
export GNUPGHOME=/tmp/gpg
|
|
||||||
test/genkey.sh alice@example.org
|
|
||||||
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
|
|
||||||
test/expect
|
|
||||||
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
|
|
||||||
# Teardown
|
|
||||||
apache2ctl stop
|
|
||||||
doveadm stop
|
|
||||||
/usr/sbin/postmulti -i - -p stop
|
|
||||||
kill %1 || true
|
|
||||||
kill %2 || true
|
|
||||||
sleep 5 # wait for daemons to terminate
|
|
104
.gitlab-ci.yml
Normal file
104
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
---
|
||||||
|
image: python:3.9-bullseye
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
- pip3 install coverage pycodestyle
|
||||||
|
- export EASYWKS_VERSION=$(python -c 'import easywks; print(easywks.__version__)')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- pip3 install -e .
|
||||||
|
- python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
|
||||||
|
- python3 -m coverage combine
|
||||||
|
- python3 -m coverage report --rcfile=setup.cfg
|
||||||
|
|
||||||
|
codestyle:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- pip3 install -e .
|
||||||
|
- pycodestyle easywks
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# currently not working for some reason
|
||||||
|
#build_docker:
|
||||||
|
# stage: build
|
||||||
|
# script:
|
||||||
|
# - apt update && apt install --yes docker.io
|
||||||
|
# - docker build -t "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" -f package/docker/Dockerfile .
|
||||||
|
# - docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
|
||||||
|
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
|
||||||
|
# - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.gitlab.com
|
||||||
|
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA"
|
||||||
|
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
|
||||||
|
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
|
||||||
|
# only:
|
||||||
|
# - staging
|
||||||
|
# - tags
|
||||||
|
|
||||||
|
build_wheel:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- python3 setup.py egg_info bdist_wheel
|
||||||
|
- cd dist
|
||||||
|
- sha256sum *.whl > SHA256SUMS
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- "dist/*.whl"
|
||||||
|
- dist/SHA256SUMS
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
|
||||||
|
build_debian:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- apt update && apt install --yes lintian rsync sudo
|
||||||
|
- echo -n > package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
- |
|
||||||
|
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
|
||||||
|
echo "easywks (${version}-1); urgency=medium\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
done
|
||||||
|
- gzip -9n package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
- python3 setup.py egg_info install --root=package/debian/easywks/ --prefix=/usr --optimize=1
|
||||||
|
- cd package/debian
|
||||||
|
- sed -re "s/__EASYWKS_VERSION__/${EASYWKS_VERSION}/g" -i easywks/DEBIAN/control
|
||||||
|
- mkdir -p easywks/usr/lib/python3/dist-packages/
|
||||||
|
- rsync -a easywks/usr/lib/python3.9/site-packages/ easywks/usr/lib/python3/dist-packages/
|
||||||
|
- rm -rf easywks/usr/lib/python3.9/site-packages
|
||||||
|
- find easywks/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
|
||||||
|
- find easywks/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
|
||||||
|
- find easywks/usr/lib/python3/dist-packages -name '*.pyo' -exec rm {} \;
|
||||||
|
- sed -re 's$#!/usr/local/bin/python3$#!/usr/bin/python3$' -i easywks/usr/bin/easywks
|
||||||
|
- find easywks -type f -exec chmod 0644 {} \;
|
||||||
|
- find easywks -type d -exec chmod 755 {} \;
|
||||||
|
- chmod +x easywks/usr/bin/easywks easywks/DEBIAN/postinst easywks/DEBIAN/prerm easywks/DEBIAN/postrm
|
||||||
|
- dpkg-deb --build easywks
|
||||||
|
- mv easywks.deb "easywks_${EASYWKS_VERSION}-1_all.deb"
|
||||||
|
- sudo -u nobody lintian "easywks_${EASYWKS_VERSION}-1_all.deb"
|
||||||
|
- sha256sum *.deb > SHA256SUMS
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- "package/debian/*.deb"
|
||||||
|
- package/debian/SHA256SUMS
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
|
||||||
|
|
||||||
|
release:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- python3 package/release.py
|
||||||
|
only:
|
||||||
|
- tags
|
276
CHANGELOG.md
276
CHANGELOG.md
|
@ -1,278 +1,4 @@
|
||||||
# EasyWKS Changelog
|
# Matemat Changelog
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.6 -->
|
|
||||||
## Version 0.4.6
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.6 -->
|
|
||||||
- Fix: Don't put multiple keys into a DANE record
|
|
||||||
<!-- END CHANGES 0.4.6-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.6 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.5 -->
|
|
||||||
## Version 0.4.5
|
|
||||||
|
|
||||||
Migrate from Woodpecker CI to Forgejo Actions
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.5 -->
|
|
||||||
- Migrate from Woodpecker CI to Forgejo Actions
|
|
||||||
<!-- END CHANGES 0.4.5-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.5 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.4 -->
|
|
||||||
## Version 0.4.4
|
|
||||||
|
|
||||||
smtpd: Log errors to stdout rather than SMTP session
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.4 -->
|
|
||||||
- smtpd: Log errors to stdout rather than SMTP session
|
|
||||||
<!-- END CHANGES 0.4.4-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.4 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.3 -->
|
|
||||||
## Version 0.4.3
|
|
||||||
|
|
||||||
Migrate from Gitlab to Forgejo
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.3 -->
|
|
||||||
- Migrate from Gitlab to Forgejo
|
|
||||||
<!-- END CHANGES 0.4.3-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.3 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.2 -->
|
|
||||||
## Version 0.4.2
|
|
||||||
|
|
||||||
Minor feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.2 -->
|
|
||||||
- Add option to provide DNS NOTIFY to DANE zone replicas
|
|
||||||
<!-- END CHANGES 0.4.2-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.2 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.1 -->
|
|
||||||
## Version 0.4.1
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.1 -->
|
|
||||||
- Don't refuse DNS IXFRs and respond with full AXFR
|
|
||||||
<!-- END CHANGES 0.4.1-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.1 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.0 -->
|
|
||||||
## Version 0.4.0
|
|
||||||
|
|
||||||
Feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.0 -->
|
|
||||||
- Add authoritative DNS server providing DANE OPENPGPKEY (TYPE61) DNS records
|
|
||||||
<!-- END CHANGES 0.4.0-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.0 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.3.1 -->
|
|
||||||
## Version 0.3.1
|
|
||||||
|
|
||||||
Feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.3.1 -->
|
|
||||||
- Implement standard and EasyWKS-specific policy flags
|
|
||||||
- **Deprecation**: The top level option `permit_unsigned_response` is deprecated
|
|
||||||
and will be removed in a future release. Use the per-domain policy flag
|
|
||||||
`me.s3lph.easywks_permit-unsigned-response` instead.
|
|
||||||
<!-- END CHANGES 0.3.1 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.3.1 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.3.0 -->
|
|
||||||
## Version 0.3.0
|
|
||||||
|
|
||||||
Feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.3.0 -->
|
|
||||||
- Set CORS headers on HTTP responses
|
|
||||||
- Implement direct WKD URLs
|
|
||||||
- Allow submitting additional revoked keys with the submission request
|
|
||||||
<!-- END CHANGES 0.3.0 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.3.0 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.2.0 -->
|
|
||||||
## Version 0.2.0
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.2.0 -->
|
|
||||||
- Release pipeline runs integration test against gpg-wks-client
|
|
||||||
- Fix minor incompatibilities with gpg-wks-client
|
|
||||||
- Fix per-domain configuration (e.g. submission-address was not loaded)
|
|
||||||
<!-- END CHANGES 0.2.0 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.2.0 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.11 -->
|
|
||||||
## Version 0.1.11
|
|
||||||
|
|
||||||
Feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.11 -->
|
|
||||||
- Add easywks import CLI command
|
|
||||||
<!-- END CHANGES 0.1.11 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.11 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.10 -->
|
|
||||||
## Version 0.1.10
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.10 -->
|
|
||||||
- RFC 3156 compliance: Don't base64-encode PGP/MIME messages
|
|
||||||
<!-- END CHANGES 0.1.10 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.10 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.9 -->
|
|
||||||
## Version 0.1.9
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.9 -->
|
|
||||||
- Proper handling of "Auto-Submitted: no" mail header
|
|
||||||
- Fix signature verification of responses signed with a subkey
|
|
||||||
<!-- END CHANGES 0.1.9 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.9 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.8 -->
|
|
||||||
## Version 0.1.8
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.8 -->
|
|
||||||
- Remove LMTP Recipient check as well, leads to trouble with postfix aliasing.
|
|
||||||
<!-- END CHANGES 0.1.8 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.8 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.7 -->
|
|
||||||
## Version 0.1.7
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.7 -->
|
|
||||||
- Add file locking in order to avoid races between LMTP/process and HTTP.
|
|
||||||
<!-- END CHANGES 0.1.7 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.7 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.6 -->
|
|
||||||
## Version 0.1.6
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.6 -->
|
|
||||||
- Remove LMTP Envelope-Sender check.
|
|
||||||
- Debian package now includes an "easywks clean" cronjob.
|
|
||||||
<!-- END CHANGES 0.1.6 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.6 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.5 -->
|
|
||||||
## Version 0.1.5
|
|
||||||
|
|
||||||
The messages sent by EasyWKS can now be customized.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.5 -->
|
|
||||||
- Add `responses` config option.
|
|
||||||
<!-- END CHANGES 0.1.5 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.5 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.4 -->
|
|
||||||
## Version 0.1.4
|
|
||||||
|
|
||||||
Fix HTTP server, compatibility with older HTTP clients
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.4 -->
|
|
||||||
- Fix config loading bug in webserver.
|
|
||||||
- Add `require_user_urlparam` config option that makes the `?l=<user>` query optional.
|
|
||||||
<!-- END CHANGES 0.1.4 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.4 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.3 -->
|
|
||||||
## Version 0.1.3
|
|
||||||
|
|
||||||
Compatibility with gpg-wks-client.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.3 -->
|
|
||||||
- Webserver config is now under a `httpd` key, to be more in line with
|
|
||||||
lmtpd and smtp.
|
|
||||||
- Add a `permit_unsigned_response` boolean key that, if set to true,
|
|
||||||
instructs EasyWKS to accept confirmation responses even if they are
|
|
||||||
unsigned, in order to be compatible with version -00 of the draft
|
|
||||||
standard, and thus e.g. gpg-wks-client.
|
|
||||||
- Change the detection logic between submission requests and
|
|
||||||
confirmation requests from PGP signature checking to attempting to
|
|
||||||
parse the message as a submission response first.
|
|
||||||
- Add a `__main__` module so that easywks can be invoked as a Python
|
|
||||||
module.
|
|
||||||
<!-- END CHANGES 0.1.3 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.3 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.2 -->
|
|
||||||
## Version 0.1.2
|
|
||||||
|
|
||||||
Fix even morecompatibility issues with aiosmtpd from Debian repo.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.1.2 -->
|
|
||||||
- Fix #1: lmtpd not working with aiosmtpd 1.2.2
|
|
||||||
<!-- END CHANGES 0.1.2 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.1.2 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.1 -->
|
<!-- BEGIN RELEASE v0.1.1 -->
|
||||||
## Version 0.1.1
|
## Version 0.1.1
|
||||||
|
|
218
README.md
218
README.md
|
@ -4,10 +4,12 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
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
|
||||||
Directory**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
|
Discovery**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
|
||||||
decentralized and federated approach, where each mail domain is responsible for hosting its users public keys on an
|
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
|
||||||
|
|
||||||
|
@ -50,8 +52,6 @@ gpg-wks-server to EasyWKS.
|
||||||
- PyYAML
|
- PyYAML
|
||||||
- bottle.py
|
- bottle.py
|
||||||
- PGPy
|
- PGPy
|
||||||
- dnspython (for DANE support)
|
|
||||||
- Twisted (for DANE support)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
@ -75,32 +75,18 @@ 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
|
||||||
# the submitted and published keys are stored here.
|
# as all the submitted and published keys are stored here.
|
||||||
directory: /var/lib/easywks
|
directory: /var/lib/easywks
|
||||||
|
# Number of seconds after which a pending submission request
|
||||||
# Number of seconds after which a pending submission request is
|
# is considered stale and should be removed by easywks clean.
|
||||||
# considered stale and should be removed by easywks clean.
|
|
||||||
pending_lifetime: 604800
|
pending_lifetime: 604800
|
||||||
|
# Port configuration for the webserver. Put this behind a
|
||||||
# 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.
|
|
||||||
permit_unsigned_response: false
|
|
||||||
|
|
||||||
# Port configuration for the webserver. Put this behind an
|
|
||||||
# HTTPS-terminating reverse proxy!
|
# HTTPS-terminating reverse proxy!
|
||||||
httpd:
|
host: 127.0.0.1
|
||||||
host: 127.0.0.1
|
port: 8080
|
||||||
port: 8080
|
|
||||||
# Some older HTTP clients omit the ?l=<userid> query suffix. Set
|
|
||||||
# this to false in order to permit such clients to retrieve keys.
|
|
||||||
#require_user_urlparam: true
|
|
||||||
|
|
||||||
# Defaults to stdout, supported: stdout, smtp
|
# Defaults to stdout, supported: stdout, smtp
|
||||||
mailing_method: smtp
|
mailing_method: smtp
|
||||||
|
|
||||||
# Configure smtp client options
|
# Configure smtp client options
|
||||||
smtp:
|
smtp:
|
||||||
# Connect to this SMTP server to send a mail.
|
# Connect to this SMTP server to send a mail.
|
||||||
|
@ -112,48 +98,20 @@ smtp:
|
||||||
# Omit username/password if authentication is not needed.
|
# Omit username/password if authentication is not needed.
|
||||||
username: webkey
|
username: webkey
|
||||||
password: SuperS3curePassword123
|
password: SuperS3curePassword123
|
||||||
|
|
||||||
# Configure the LMTP server
|
# Configure the LMTP server
|
||||||
lmtpd:
|
lmtpds:
|
||||||
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.
|
|
||||||
# The following templates can be overridden:
|
|
||||||
# - "header": Placed in front of every message.
|
|
||||||
# - "footer": Appended to every message.
|
|
||||||
# - "confirm": Sent with the confirmation request.
|
|
||||||
# - "done": Sent after a key was published.
|
|
||||||
# - "error": Sent when an error occurs.
|
|
||||||
# The following placeholders can be used (enclosed in curly braces):
|
|
||||||
# - {domain}: The email domain for with the request is processed.
|
|
||||||
# - {sender}: The submitter's mail address.
|
|
||||||
# - {submission}: The submission address.
|
|
||||||
# When overriding the "error" template, there's an additional
|
|
||||||
# placeholder you can use:
|
|
||||||
# - {error}: The error message.
|
|
||||||
#responses:
|
|
||||||
# error: |
|
|
||||||
# An error has occurred while processing your request:
|
|
||||||
#
|
|
||||||
# {error}
|
|
||||||
#
|
|
||||||
# If this error persists, please contact admin@example.org for help.
|
|
||||||
|
|
||||||
# Every domain served by EasyWKS must be listed here
|
# Every domain served by EasyWKS must be listed here
|
||||||
domains:
|
domains:
|
||||||
example.org:
|
example.org:
|
||||||
# Users send their requests to this address. It's up to you to
|
# Users send their requests to this address. It's up to
|
||||||
# make sure that the mails sent their get handed to EasyWKS.
|
# you to make sure that the mails sent their get handed
|
||||||
|
# to EasyWKS.
|
||||||
submission_address: webkey@example.org
|
submission_address: webkey@example.org
|
||||||
# If you want the PGP key for this domain to be password-
|
# If you want the PGP key for this domain to be
|
||||||
# protected, or if you're supplying your own password-protected
|
# password-protected, or if you're supplying your own
|
||||||
# key, set the passphrase here:
|
# password-protected key, set the passphrase here:
|
||||||
passphrase: "Correct Horse Battery Staple"
|
passphrase: "Correct Horse Battery Staple"
|
||||||
# Defaults are gpgwks@<domain> and no password protection.
|
# Defaults are gpgwks@<domain> and no password protection.
|
||||||
example.com: {}
|
example.com: {}
|
||||||
|
@ -259,148 +217,6 @@ 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
|
4
ROADMAP.md
Normal file
4
ROADMAP.md
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
# EasyWKS Roadmap
|
||||||
|
|
||||||
|
- [ ] Figure out whether file locking in the working directory is necessary to avoid races.
|
||||||
|
- [ ] Testing, testing, testing!
|
646
client.py
646
client.py
|
@ -1,646 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import abc
|
|
||||||
import time
|
|
||||||
from getpass import getpass
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
import time
|
|
||||||
import imaplib
|
|
||||||
import poplib
|
|
||||||
import smtplib
|
|
||||||
import subprocess
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
import email
|
|
||||||
from email.mime.application import MIMEApplication
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.parser import BytesParser
|
|
||||||
from email.utils import format_datetime
|
|
||||||
from xml.etree import ElementTree
|
|
||||||
|
|
||||||
|
|
||||||
def _get_pgp_message(message: email.message.Message) -> bytes:
|
|
||||||
pgp = None
|
|
||||||
for part in message.walk():
|
|
||||||
if part.is_multipart():
|
|
||||||
continue
|
|
||||||
p = part.get_content()
|
|
||||||
if isinstance(p, str):
|
|
||||||
p = p.encode()
|
|
||||||
if b'BEGIN PGP MESSAGE' in p:
|
|
||||||
if pgp is not None:
|
|
||||||
raise ValueError('More than one encrypted message part')
|
|
||||||
pgp = p
|
|
||||||
if pgp is None:
|
|
||||||
raise ValueError('No encrypted message part')
|
|
||||||
return pgp
|
|
||||||
|
|
||||||
|
|
||||||
class MailServerConfig(abc.ABC):
|
|
||||||
|
|
||||||
def __init__(self, proto, hostname, port, tls, username, password):
|
|
||||||
self.proto = proto
|
|
||||||
self.hostname = hostname
|
|
||||||
self.port = port
|
|
||||||
self.tls = tls
|
|
||||||
self.username = username
|
|
||||||
self.password = password
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
proto = self.proto + ('s' if self.tls == 'SSL' else '') + ('+starttls' if self.tls == 'STARTTLS' else '')
|
|
||||||
return f'{proto}://{self.username}@{self.hostname}:{self.port}'
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
if not isinstance(other, MailServerConfig):
|
|
||||||
return False
|
|
||||||
return self.proto == other.proto \
|
|
||||||
and self.hostname == other.hostname \
|
|
||||||
and self.port == other.port \
|
|
||||||
and self.tls == other.tls \
|
|
||||||
and self.username == other.username \
|
|
||||||
and self.password == other.password
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
if not isinstance(other, MailServerConfig):
|
|
||||||
raise TypeError(f'Cannot compare {type(self)} to {type(other)}')
|
|
||||||
if self == other:
|
|
||||||
return False
|
|
||||||
# SMTP not comparable to IMAP or POP3
|
|
||||||
if (self.proto == 'smtp' or other.proto == 'smtp') and self.proto != other.proto:
|
|
||||||
raise TypeError(f'Cannot compare {type(self)} to {type(other)}')
|
|
||||||
# IMAP < POP3
|
|
||||||
if self.proto == 'imap' and other.proto == 'pop3':
|
|
||||||
return True
|
|
||||||
# SSL < STARTTLS < plain
|
|
||||||
if (self.tls == 'SSL' and other.tls != 'SSL') or (self.tls == 'STARTTLS' and other.tls == 'plain'):
|
|
||||||
return True
|
|
||||||
# full email < local part
|
|
||||||
if '@' in self.username and '@' not in other.username:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class SmtpServerConfig(MailServerConfig):
|
|
||||||
|
|
||||||
def __init__(self, hostname, port, tls, username, password):
|
|
||||||
super().__init__('smtp', hostname, port, tls, username, password)
|
|
||||||
self._smtp = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
cls = smtplib.SMTP if self.tls != 'SSL' else smtplib.SMTP_SSL
|
|
||||||
self._smtp = cls(self.hostname, self.port)
|
|
||||||
smtp = self._smtp.__enter__()
|
|
||||||
if self.tls == 'STARTTLS':
|
|
||||||
smtp.starttls()
|
|
||||||
smtp.login(self.username, self.password)
|
|
||||||
return smtp
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
ret = self._smtp.__exit__(exc_type, exc_val, exc_tb)
|
|
||||||
self._smtp = None
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def send_message(self, msg):
|
|
||||||
if self._smtp is None:
|
|
||||||
raise RuntimeError('SMTP connection is not established')
|
|
||||||
self._smtp.send_message(msg)
|
|
||||||
|
|
||||||
|
|
||||||
class IncomingServerConfig(MailServerConfig, abc.ABC):
|
|
||||||
|
|
||||||
def __init__(self, proto, hostname, port, tls, username, password):
|
|
||||||
super().__init__(proto, hostname, port, tls, username, password)
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_new_messages(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ImapServerConfig(IncomingServerConfig):
|
|
||||||
|
|
||||||
def __init__(self, hostname, port, tls, username, password):
|
|
||||||
super().__init__('imap', hostname, port, tls, username, password)
|
|
||||||
self._imap = None
|
|
||||||
self._uidnext = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
cls = imaplib.IMAP4 if self.tls != 'SSL' else imaplib.IMAP4_SSL
|
|
||||||
self._imap = cls(self.hostname, self.port)
|
|
||||||
imap = self._imap.__enter__()
|
|
||||||
if self.tls == 'STARTTLS':
|
|
||||||
imap.starttls()
|
|
||||||
imap.login(self.username, self.password)
|
|
||||||
imap.select('INBOX', readonly=True)
|
|
||||||
self._uidnext = int(imap.response('UIDNEXT')[1][0].decode())
|
|
||||||
return imap
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
ret = self._imap.__exit__(exc_type, exc_val, exc_tb)
|
|
||||||
self._imap = None
|
|
||||||
self._uidnext = None
|
|
||||||
return ret
|
|
||||||
|
|
||||||
def get_new_messages(self):
|
|
||||||
if self._imap is None or self._uidnext is None:
|
|
||||||
raise RuntimeError('IMAP connection is not established')
|
|
||||||
self._imap.select('INBOX', readonly=True)
|
|
||||||
u = int(self._imap.response('UIDNEXT')[1][0].decode())
|
|
||||||
if u > self._uidnext:
|
|
||||||
messages = self._imap.uid('fetch', f'{self._uidnext}:{u - 1}', '(RFC822)')
|
|
||||||
for message in messages[1]:
|
|
||||||
if not isinstance(message, tuple) or b'RFC822' not in message[0]:
|
|
||||||
# Not an email message
|
|
||||||
continue
|
|
||||||
yield message[1]
|
|
||||||
self._uidnext = u
|
|
||||||
|
|
||||||
|
|
||||||
class Pop3ServerConfig(IncomingServerConfig):
|
|
||||||
|
|
||||||
def __init__(self, hostname, port, tls, username, password):
|
|
||||||
super().__init__('pop3', hostname, port, tls, username, password)
|
|
||||||
self._pop3: poplib.POP3 = None
|
|
||||||
self._messagelist = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
cls = poplib.POP3 if self.tls != 'SSL' else poplib.POP3_SSL
|
|
||||||
self._pop3 = cls(self.hostname, self.port)
|
|
||||||
if self.tls == 'STARTTLS':
|
|
||||||
self._pop3.stls()
|
|
||||||
self._pop3.user(self.username)
|
|
||||||
self._pop3.pass_(self.password)
|
|
||||||
self._messagelist = set(self._pop3.list()[1])
|
|
||||||
return self._pop3
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
self._messagelist = None
|
|
||||||
self._pop3.close()
|
|
||||||
self._pop3 = None
|
|
||||||
|
|
||||||
def get_new_messages(self):
|
|
||||||
if self._pop3 is None or self._messagelist is None:
|
|
||||||
raise RuntimeError('POP3 connection is not established')
|
|
||||||
ml = set(self._pop3.list()[1])
|
|
||||||
for msg in ml.difference(self._messagelist):
|
|
||||||
msgid, _ = msg.split(' ', 1)
|
|
||||||
message = '\r\n'.join(self._pop3.retr(msgid)[1])
|
|
||||||
yield message[1]
|
|
||||||
self._messagelist = ml
|
|
||||||
|
|
||||||
|
|
||||||
def default_port(proto: str, tls: str):
|
|
||||||
if proto == 'imap':
|
|
||||||
return 993 if tls == 'SSL' else 143
|
|
||||||
if proto == 'pop3':
|
|
||||||
return 995 if tls == 'SSL' else 110
|
|
||||||
if proto == 'smtp':
|
|
||||||
return 465 if tls == 'SSL' else 587
|
|
||||||
raise ValueError(f'Unknown protocol: {proto}')
|
|
||||||
|
|
||||||
|
|
||||||
def template_username(template: str, address: str, userinputs):
|
|
||||||
user, domain = address.split('@', 1)
|
|
||||||
template = template \
|
|
||||||
.replace('%EMAILADDRESS%', address) \
|
|
||||||
.replace('%EMAILLOCALPART%', user) \
|
|
||||||
.replace('%EMAILDOMAIN%', domain)
|
|
||||||
for key in userinputs:
|
|
||||||
if key not in template:
|
|
||||||
continue
|
|
||||||
label, value = userinputs[key]
|
|
||||||
if value is None:
|
|
||||||
value = getpass(f'Autoconfiguration field "{label}" (will not echo): ')
|
|
||||||
userinputs[key] = label, value
|
|
||||||
template = template.replace(key, value)
|
|
||||||
return template
|
|
||||||
|
|
||||||
|
|
||||||
def parse_xml_mailserver(xmle, address: str, password: str, userinputs):
|
|
||||||
proto = xmle.get('type')
|
|
||||||
hostname = xmle.find('./hostname').text
|
|
||||||
tls = xmle.find('./socketType').text
|
|
||||||
port = int(xmle.find('./port').text or default_port(proto, tls))
|
|
||||||
username = template_username(xmle.find('./username').text, address, userinputs)
|
|
||||||
pw = xmle.find('./password')
|
|
||||||
if pw:
|
|
||||||
pw = template_username(pw.text, address, userinputs)
|
|
||||||
else:
|
|
||||||
pw = password
|
|
||||||
if proto == 'smtp':
|
|
||||||
return SmtpServerConfig(hostname, port, tls, username, pw)
|
|
||||||
elif proto == 'imap':
|
|
||||||
return ImapServerConfig(hostname, port, tls, username, pw)
|
|
||||||
elif proto == 'pop3':
|
|
||||||
return Pop3ServerConfig(hostname, port, tls, username, pw)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_thunderbird_autoconfig(xml: str, address: str, password: str):
|
|
||||||
user, domain = address.split('@', 1)
|
|
||||||
root = ElementTree.fromstring(xml)
|
|
||||||
userinputs = {}
|
|
||||||
for ui in root.findall('.//userinput'):
|
|
||||||
k = ui.get('key')
|
|
||||||
label = ui.get('label')
|
|
||||||
userinputs[k] = (label, None)
|
|
||||||
incoming = root.findall(f"./emailProvider/domain[.='{domain}']/../incomingServer")
|
|
||||||
outgoing = root.findall(f"./emailProvider/domain[.='{domain}']/../outgoingServer")
|
|
||||||
iconf = [parse_xml_mailserver(i, address, password, userinputs) for i in incoming]
|
|
||||||
oconf = [parse_xml_mailserver(o, address, password, userinputs) for o in outgoing]
|
|
||||||
return iconf, oconf
|
|
||||||
|
|
||||||
|
|
||||||
def tb_wellknown_autoconfig(address: str, password: str):
|
|
||||||
user, domain = address.split('@', 1)
|
|
||||||
subdomain = f'autoconfig.{domain}'
|
|
||||||
sdurl = urllib.parse.urlunsplit(('http', subdomain, 'mail/config-v1.1.xml', f'emailaddress={address}', ''))
|
|
||||||
mdurl = urllib.parse.urlunsplit(('http', domain, '.well-known/autoconfig/mail/config-v1.1.xml',
|
|
||||||
f'emailaddress={address}', ''))
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(sdurl) as sdresponse:
|
|
||||||
return parse_thunderbird_autoconfig(sdresponse.read().decode(), address, password)
|
|
||||||
except urllib.error.URLError:
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(mdurl) as mdresponse:
|
|
||||||
return parse_thunderbird_autoconfig(mdresponse.read().decode(), address, password)
|
|
||||||
except urllib.error.URLError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def tb_ispdb_autoconfig(address: str, password: str):
|
|
||||||
user, domain = address.split('@', 1)
|
|
||||||
ispdb = f'https://autoconfig.thunderbird.net/v1.1/{domain}'
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(ispdb) as response:
|
|
||||||
return parse_thunderbird_autoconfig(response.read().decode(), address, password)
|
|
||||||
except urllib.error.URLError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def manual_config(address: str, password: str):
|
|
||||||
print('Autoconfiguration has failed. Please enter the mail server settings manually.')
|
|
||||||
host = input('SMTP hostname: ')
|
|
||||||
tls = input('TLS ("plain", "SSL" or "STARTLS (default: STARTTLS): ') or 'STARTTLS'
|
|
||||||
p = default_port('smtp', tls)
|
|
||||||
port = int(input(f'SMTP port (default: {p}): ') or str(p))
|
|
||||||
outconfig = SmtpServerConfig(host, port, tls, address, password)
|
|
||||||
proto = input('Mail Retrieval protocol ("imap" or "pop3", default: "imap"): ') or 'imap'
|
|
||||||
host = input(f'{proto.upper()} hostname: ')
|
|
||||||
tls = input('TLS ("plain", "SSL" or "STARTLS (default: SSL): ') or 'SSL'
|
|
||||||
p = default_port(proto, tls)
|
|
||||||
port = int(input(f'{proto.upper()} port (default: {p}): ') or str(p))
|
|
||||||
if proto == 'imap':
|
|
||||||
inconfig = ImapServerConfig(host, port, tls, address, password)
|
|
||||||
else:
|
|
||||||
inconfig = Pop3ServerConfig(host, port, tls, address, password)
|
|
||||||
return [inconfig], [outconfig]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_dns_srv_record(record, service: str, address: str, password: str):
|
|
||||||
user, domain = address.split('@', 1)
|
|
||||||
if service == '_submission':
|
|
||||||
cls = SmtpServerConfig
|
|
||||||
elif service == '_imaps' or service == '_imap':
|
|
||||||
cls = ImapServerConfig
|
|
||||||
elif service == '_pop3s' or service == '_pop3':
|
|
||||||
cls = Pop3ServerConfig
|
|
||||||
else:
|
|
||||||
raise ValueError(service)
|
|
||||||
if service.endswith('s'):
|
|
||||||
tls = ['SSL']
|
|
||||||
else:
|
|
||||||
tls = ['STARTTLS', 'plain']
|
|
||||||
configs = []
|
|
||||||
for t in tls:
|
|
||||||
configs.append(cls(record.target, record.port, t, address, password))
|
|
||||||
configs.append(cls(record.target, record.port, t, user, password))
|
|
||||||
return configs
|
|
||||||
|
|
||||||
|
|
||||||
def rfc6186_autoconfig(address: str, password: str):
|
|
||||||
try:
|
|
||||||
import dns.resolver
|
|
||||||
import dns.rdtypes.IN.SRV
|
|
||||||
except ImportError:
|
|
||||||
print('"dnspython" dependency missing. Skipping RFC 6186 autoconfig')
|
|
||||||
return None
|
|
||||||
user, domain = address.split('@', 1)
|
|
||||||
smtpdomain = f'_submission._tcp.{domain}.'
|
|
||||||
imapsdomain = f'_imaps._tcp.{domain}.'
|
|
||||||
imapdomain = f'_imap._tcp.{domain}.'
|
|
||||||
pop3sdomain = f'_pop3s._tcp.{domain}.'
|
|
||||||
pop3domain = f'_pop3._tcp.{domain}.'
|
|
||||||
osrv = []
|
|
||||||
isrv = []
|
|
||||||
try:
|
|
||||||
smtpanswer: dns.resolver.Answer = dns.resolver.resolve(smtpdomain, dns.rdatatype.SRV)
|
|
||||||
except dns.exception.DNSException:
|
|
||||||
return None
|
|
||||||
for srv in smtpanswer:
|
|
||||||
if srv.rdtype != dns.rdatatype.SRV or srv.rdclass != dns.rdataclass.IN:
|
|
||||||
continue
|
|
||||||
osrv.append((smtpanswer.canonical_name.labels[0].decode(), srv))
|
|
||||||
for sd in [imapsdomain, imapdomain, pop3sdomain, pop3domain]:
|
|
||||||
try:
|
|
||||||
answer: dns.resolver.Answer = dns.resolver.resolve(sd, dns.rdatatype.SRV)
|
|
||||||
for srv in answer:
|
|
||||||
if srv.rdtype != dns.rdatatype.SRV or srv.rdclass != dns.rdataclass.IN:
|
|
||||||
continue
|
|
||||||
if srv.port == 0 and str(srv.target) == '.':
|
|
||||||
continue
|
|
||||||
isrv.append((answer.canonical_name.labels[0].decode(), srv))
|
|
||||||
except dns.exception.DNSException:
|
|
||||||
continue
|
|
||||||
incoming = []
|
|
||||||
outgoing = []
|
|
||||||
for (iqname, irec) in isrv:
|
|
||||||
incoming.extend(parse_dns_srv_record(irec, iqname, address, password))
|
|
||||||
for (oqname, orec) in isrv:
|
|
||||||
outgoing.extend(parse_dns_srv_record(orec, oqname, address, password))
|
|
||||||
return incoming, outgoing
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_confirmation_request(address, fingerprint, encrypted):
|
|
||||||
gpg = subprocess.Popen(['/usr/bin/gpg', '--decrypt'],
|
|
||||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
gpg.stdin.write(encrypted)
|
|
||||||
gpg.stdin.close()
|
|
||||||
gpg.wait()
|
|
||||||
if gpg.returncode != 0:
|
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
|
||||||
decrypted = gpg.stdout.read().decode()
|
|
||||||
rdict = {}
|
|
||||||
for line in decrypted.splitlines():
|
|
||||||
if ':' not in line:
|
|
||||||
continue
|
|
||||||
key, value = line.split(':', 1)
|
|
||||||
rdict[key.strip()] = value.strip()
|
|
||||||
if rdict.get('type', '') != 'confirmation-request':
|
|
||||||
raise ValueError('Invalid confirmation request: "type" missing or not "confirmation-request"')
|
|
||||||
if 'sender' not in rdict or 'address' not in rdict or 'fingerprint' not in rdict or 'nonce' not in rdict:
|
|
||||||
raise ValueError('Invalid confirmation request: a mandatory item is missing from the request')
|
|
||||||
if rdict['address'] != address:
|
|
||||||
raise ValueError(f'Confirmation address "{rdict["address"]}" does not match my address "{address}"')
|
|
||||||
if rdict['fingerprint'].replace(' ', '') != fingerprint.replace(' ', ''):
|
|
||||||
raise ValueError(
|
|
||||||
f'Confirmation fingerprint "{rdict["fingerprint"]}" does not match my fingerprint "{fingerprint}"')
|
|
||||||
print(f'Nonce: {rdict["nonce"]}')
|
|
||||||
return rdict['sender'], rdict['nonce']
|
|
||||||
|
|
||||||
|
|
||||||
def _create_submission_request(address: str, submission_address: str, fingerprint: str, revoked_fingerprints):
|
|
||||||
gpg = subprocess.Popen([
|
|
||||||
'/usr/bin/gpg', '--locate-keys', '--with-colons', submission_address
|
|
||||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
gpg.wait()
|
|
||||||
if gpg.returncode != 0:
|
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
|
||||||
print('Retrieved submission key')
|
|
||||||
gpg = subprocess.Popen([
|
|
||||||
'/usr/bin/gpg', '--armor',
|
|
||||||
'--export-options', 'export-minimal',
|
|
||||||
'--export', fingerprint
|
|
||||||
] + revoked_fingerprints, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
gpg.wait()
|
|
||||||
if gpg.returncode != 0:
|
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
|
||||||
print('Retrieved key to publish')
|
|
||||||
pubkey = gpg.stdout.read()
|
|
||||||
gpg = subprocess.Popen([
|
|
||||||
'/usr/bin/gpg', '--armor', '--with-colons',
|
|
||||||
'--encrypt', '--trust-model', 'always',
|
|
||||||
'--local-user', address,
|
|
||||||
'--recipient', address,
|
|
||||||
'--recipient', submission_address
|
|
||||||
], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
gpg.stdin.write(pubkey)
|
|
||||||
gpg.stdin.close()
|
|
||||||
try:
|
|
||||||
gpg.wait(timeout=2)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
gpg.kill()
|
|
||||||
print(f'gpg subprocess timed out; stderr: {gpg.stderr.read()}')
|
|
||||||
raise RuntimeError(f'gpg subprocess timed out; stderr: {gpg.stderr.read()}')
|
|
||||||
if gpg.returncode != 0:
|
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
|
||||||
print('Created encrypted message')
|
|
||||||
encrypted = gpg.stdout.read().decode()
|
|
||||||
mail = MIMEText(encrypted, _subtype='plain')
|
|
||||||
mail['Subject'] = 'WKS submission request'
|
|
||||||
mail['To'] = submission_address
|
|
||||||
mail['From'] = address
|
|
||||||
mail['Date'] = format_datetime(datetime.now(timezone.utc))
|
|
||||||
return mail
|
|
||||||
|
|
||||||
|
|
||||||
def _create_confirmation_response(address: str, submission: str, nonce: str, fp: str, content_subtype: str):
|
|
||||||
response_template = '\r\n'.join([
|
|
||||||
'type: confirmation-response',
|
|
||||||
f'sender: {address}',
|
|
||||||
f'nonce: {nonce}',
|
|
||||||
''
|
|
||||||
])
|
|
||||||
payload = MIMEText(response_template, _subtype='plain')
|
|
||||||
gpg = subprocess.Popen([
|
|
||||||
'/usr/bin/gpg', '--armor', '--with-colons',
|
|
||||||
'--encrypt', '--sign', '--trust-model', 'always',
|
|
||||||
'--local-user', fp,
|
|
||||||
'--recipient', address,
|
|
||||||
'--recipient', submission
|
|
||||||
], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
gpg.stdin.write(payload.as_string(policy=email.policy.default).encode())
|
|
||||||
gpg.stdin.close()
|
|
||||||
gpg.wait()
|
|
||||||
if gpg.returncode != 0:
|
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
|
||||||
encrypted = gpg.stdout.read().decode()
|
|
||||||
mail = MIMEApplication(encrypted, _subtype=content_subtype)
|
|
||||||
mail['Subject'] = 'WKS confirmation response'
|
|
||||||
mail['To'] = submission
|
|
||||||
mail['From'] = address
|
|
||||||
mail['Date'] = format_datetime(datetime.now(timezone.utc))
|
|
||||||
return mail
|
|
||||||
|
|
||||||
|
|
||||||
def handle_incoming_message(address, fingerprint, rfc822, smtp_config: SmtpServerConfig):
|
|
||||||
msg: email.message.EmailMessage = BytesParser(policy=email.policy.default).parsebytes(rfc822)
|
|
||||||
if msg.get('wks-phase', '') == 'done':
|
|
||||||
pgp = _get_pgp_message(msg)
|
|
||||||
if pgp is None:
|
|
||||||
print('WKS key submission successful. Congratulations!')
|
|
||||||
return True
|
|
||||||
print('Decrypting WKS response. GnuPG may prompt you for your passphrase.')
|
|
||||||
gpg = subprocess.Popen(['/usr/bin/gpg', '--batch', '--decrypt', '--skip-verify'],
|
|
||||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
||||||
gpg.stdin.write(pgp)
|
|
||||||
gpg.stdin.close()
|
|
||||||
gpg.wait()
|
|
||||||
if gpg.returncode != 0:
|
|
||||||
print('It seems WKS submission was successful, however decryption of the submission response failed: ' +
|
|
||||||
gpg.stderr.read().decode())
|
|
||||||
return True
|
|
||||||
decrypted = email.parser.BytesParser(policy=email.policy.default).parsebytes(gpg.stdout.read())
|
|
||||||
body = decrypted.get_body(preferencelist=('plain',))
|
|
||||||
if body is None:
|
|
||||||
print('WKS key submission successful. Congratulations!')
|
|
||||||
else:
|
|
||||||
print(body.get_content())
|
|
||||||
return True
|
|
||||||
if msg.get('wks-phase', '') == 'error':
|
|
||||||
body = msg.get_body(preferencelist=('plain',))
|
|
||||||
if body is None:
|
|
||||||
print('WKS key submission failed. However, the WKS server did not return an error description.')
|
|
||||||
else:
|
|
||||||
print(body.get_content())
|
|
||||||
return True
|
|
||||||
for leaf in msg.walk():
|
|
||||||
if leaf.get_content_type() not in ['application/vnd.gnupg.wkd', 'application/vnd.gnupg.wks']:
|
|
||||||
continue
|
|
||||||
print('Received confirmation request')
|
|
||||||
try:
|
|
||||||
submission, nonce = _parse_confirmation_request(address, fingerprint, leaf.get_content())
|
|
||||||
except BaseException as e:
|
|
||||||
print(f'Parsing failed: {e}')
|
|
||||||
continue
|
|
||||||
print('Creating confirmation response. GnuPG may prompt you for your passphrase.')
|
|
||||||
response = _create_confirmation_response(address, submission, nonce, fingerprint, leaf.get_content_subtype())
|
|
||||||
print('Sending confirmation response')
|
|
||||||
with smtp_config:
|
|
||||||
smtp_config.send_message(response)
|
|
||||||
print('Awaiting publish response')
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _gpg_get_uid_fp(address: str):
|
|
||||||
gpg = subprocess.Popen([
|
|
||||||
'/usr/bin/gpg', '--with-colons', '--list-keys', address
|
|
||||||
], stdout=subprocess.PIPE)
|
|
||||||
gpg.wait()
|
|
||||||
if gpg.returncode != 0:
|
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
|
||||||
keylist = gpg.stdout.read().decode()
|
|
||||||
pubs = []
|
|
||||||
revoked = []
|
|
||||||
fprs = []
|
|
||||||
for line in keylist.splitlines():
|
|
||||||
if line.startswith('pub:'):
|
|
||||||
pub = line.split(':')[4]
|
|
||||||
r = line.split(':')[1] == 'r'
|
|
||||||
pubs.append(pub)
|
|
||||||
revoked.append(r)
|
|
||||||
elif line.startswith('fpr:'):
|
|
||||||
fpr = line.split(':')[9]
|
|
||||||
fprs.append(fpr)
|
|
||||||
valid = {next((f for f in fprs if f.endswith(pub))): pub for i, pub in enumerate(pubs) if not revoked[i]}
|
|
||||||
revoked = {next((f for f in fprs if f.endswith(pub))): pub for i, pub in enumerate(pubs) if revoked[i]}
|
|
||||||
if len(valid) == 0:
|
|
||||||
raise ValueError(f'No valid key found for {address}.')
|
|
||||||
elif len(valid) > 1:
|
|
||||||
print(f'Found multiple keys for {address}, please choose:')
|
|
||||||
fpridx = list(valid.keys())
|
|
||||||
for i, f in enumerate(fpridx, start=1):
|
|
||||||
print(f'{i}: {f}')
|
|
||||||
i = int(input('Enter number: ')) - 1
|
|
||||||
fpr = fpridx[i]
|
|
||||||
else:
|
|
||||||
fpr = list(valid.keys())[0]
|
|
||||||
if len(revoked) > 0:
|
|
||||||
print(f'There are revoked keys for {address}. Please choose which to upload (separate multiple by spaces): ')
|
|
||||||
revidx = list(revoked.keys())
|
|
||||||
for i, f in enumerate(revidx, start=1):
|
|
||||||
print(f'{i}: {f}')
|
|
||||||
rids = [int(i)-1 for i in input('Enter number(s): ').split()]
|
|
||||||
rfprs = [revidx[i] for i in rids]
|
|
||||||
else:
|
|
||||||
rfprs = []
|
|
||||||
return fpr, rfprs
|
|
||||||
|
|
||||||
|
|
||||||
def _get_submission_address(address: str):
|
|
||||||
_, domain = address.split('@', 1)
|
|
||||||
advanced_url = f'https://openpgpkey.{domain}/.well-known/openpgpkey/{domain}/submission-address'
|
|
||||||
direct_url = f'https://{domain}/.well-known/openpgpkey/submission-address'
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(advanced_url) as response:
|
|
||||||
return response.read().decode().strip()
|
|
||||||
except urllib.error.URLError:
|
|
||||||
pass
|
|
||||||
with urllib.request.urlopen(direct_url) as response:
|
|
||||||
return response.read().decode().strip()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
ad = input('Enter email: ')
|
|
||||||
sa = None
|
|
||||||
try:
|
|
||||||
sa = _get_submission_address(ad)
|
|
||||||
except urllib.error.URLError:
|
|
||||||
print('No WKS submission address found. Does your provider support WKS?')
|
|
||||||
exit(1)
|
|
||||||
fp, rfprs = _gpg_get_uid_fp(ad)
|
|
||||||
print(f'Chose {fp}')
|
|
||||||
for rfpr in rfprs:
|
|
||||||
print(f'Chose revoked key {rfpr}')
|
|
||||||
pw = getpass('Enter IMAP/POP3/SMTP password (will not echo): ')
|
|
||||||
for fn in [tb_wellknown_autoconfig, rfc6186_autoconfig, tb_ispdb_autoconfig, manual_config]:
|
|
||||||
autoconf = fn(ad, pw)
|
|
||||||
if autoconf is not None:
|
|
||||||
break
|
|
||||||
if autoconf is None:
|
|
||||||
raise RuntimeError('No autoconfig available')
|
|
||||||
incoming, outgoing = autoconf
|
|
||||||
# Find the first working server configurations
|
|
||||||
incoming_server = None
|
|
||||||
outgoing_server = None
|
|
||||||
for i in sorted(incoming):
|
|
||||||
try:
|
|
||||||
with i:
|
|
||||||
incoming_server = i
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
if incoming_server is None:
|
|
||||||
raise RuntimeError('No working IMAP/POP3 server found through autoconfiguration. Please specify manually.')
|
|
||||||
print(f'Autoconfigured incoming server: {incoming_server}')
|
|
||||||
for i in sorted(outgoing):
|
|
||||||
try:
|
|
||||||
with i:
|
|
||||||
outgoing_server = i
|
|
||||||
break
|
|
||||||
except:
|
|
||||||
continue
|
|
||||||
if outgoing_server is None:
|
|
||||||
raise RuntimeError('No working SMTP server found through autoconfiguration. Please specify manually.')
|
|
||||||
print(f'Autoconfigured outgoing server: {outgoing_server}')
|
|
||||||
confirm = input('Please confirm: [Y/n] ')
|
|
||||||
if confirm.lower() not in ['', 'y', 'yes']:
|
|
||||||
print('Aborted')
|
|
||||||
exit(1)
|
|
||||||
with incoming_server:
|
|
||||||
now = time.monotonic()
|
|
||||||
done = False
|
|
||||||
request = _create_submission_request(ad, sa, fp, rfprs)
|
|
||||||
print('Sending submission request')
|
|
||||||
with outgoing_server:
|
|
||||||
outgoing_server.send_message(request)
|
|
||||||
print('Awaiting response')
|
|
||||||
while not done and time.monotonic() - now < 300:
|
|
||||||
time.sleep(5)
|
|
||||||
for message in incoming_server.get_new_messages():
|
|
||||||
done = handle_incoming_message(ad, fp, message, outgoing_server)
|
|
||||||
if done:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
__version__ = '0.4.6'
|
__version__ = '0.1'
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
|
|
||||||
from .main import main
|
|
||||||
|
|
||||||
main()
|
|
|
@ -3,33 +3,6 @@ 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:
|
||||||
|
@ -63,26 +36,6 @@ def _validate_smtp_config(value):
|
||||||
value['password'] = None
|
value['password'] = None
|
||||||
|
|
||||||
|
|
||||||
def _validate_httpd_config(value):
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return f'must be a map, got {type(value)}'
|
|
||||||
if 'host' in value:
|
|
||||||
if not isinstance(value['host'], str):
|
|
||||||
return f'host must be a str, got {type(value["host"])}'
|
|
||||||
else:
|
|
||||||
value['host'] = 'localhost'
|
|
||||||
if 'port' in value:
|
|
||||||
if not isinstance(value['port'], int):
|
|
||||||
return f'port must be a int, got {type(value["port"])}'
|
|
||||||
else:
|
|
||||||
value['port'] = 8080
|
|
||||||
if 'require_user_urlparam' in value:
|
|
||||||
if not isinstance(value['require_user_urlparam'], bool):
|
|
||||||
return f'port must be a bool, got {type(value["require_user_urlparam"])}'
|
|
||||||
else:
|
|
||||||
value['require_user_urlparam'] = True
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_lmtpd_config(value):
|
def _validate_lmtpd_config(value):
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return f'must be a map, got {type(value)}'
|
return f'must be a map, got {type(value)}'
|
||||||
|
@ -92,15 +45,6 @@ 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):
|
||||||
|
@ -113,98 +57,6 @@ 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):
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
return f'must be a map, got {type(value)}'
|
|
||||||
if 'header' not in value:
|
|
||||||
value['header'] = '''Hi there!
|
|
||||||
|
|
||||||
This is the EasyWKS system at {domain}.
|
|
||||||
'''
|
|
||||||
if 'footer' not in value:
|
|
||||||
value['footer'] = '''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.
|
|
||||||
'''
|
|
||||||
if 'confirm' not in value:
|
|
||||||
value['confirm'] = '''You appear to have submitted your key for publication in the Web Key
|
|
||||||
Directory. There's one more step you need to complete. If you did not
|
|
||||||
request this, you can simply ignore this message.
|
|
||||||
|
|
||||||
If your email client doesn't automatically complete this challenge, you
|
|
||||||
can perform this step manually: Please verify that you can decrypt the
|
|
||||||
second part of this message and that the fingerprint listed in the
|
|
||||||
encrypted part matches your key. If everything looks ok, please reply
|
|
||||||
to this message with an **encrypted and signed PGP/MIME message** with
|
|
||||||
the following content (without the <> brackets)
|
|
||||||
|
|
||||||
type: confirmation-response
|
|
||||||
sender: <your email address>
|
|
||||||
nonce: <copy the nonce from the encrypted part of this message>
|
|
||||||
'''
|
|
||||||
if 'done' not in value:
|
|
||||||
value['done'] = '''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 {sender}
|
|
||||||
'''
|
|
||||||
if 'error' not in value:
|
|
||||||
value['error'] = '''An error has occurred while processing your request:
|
|
||||||
|
|
||||||
{error}
|
|
||||||
|
|
||||||
If this error persists, please contact your administrator for help.'''
|
|
||||||
|
|
||||||
|
|
||||||
class _ConfigOption:
|
class _ConfigOption:
|
||||||
|
@ -249,10 +101,9 @@ class _GlobalConfig(_Config):
|
||||||
|
|
||||||
def __make_domain(self, domain):
|
def __make_domain(self, domain):
|
||||||
self.__domains[domain] = _Config(
|
self.__domains[domain] = _Config(
|
||||||
submission_address=_ConfigOption('submission_address', str, f'gpgwks@{domain}'),
|
submission_address=_ConfigOption('address', str, f'gpgwks@{domain}'),
|
||||||
passphrase=_ConfigOption('passphrase', str, ''),
|
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):
|
||||||
|
@ -271,20 +122,15 @@ 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(dconf)
|
co.load(conf)
|
||||||
|
|
||||||
|
|
||||||
Config = _GlobalConfig(
|
Config = _GlobalConfig(
|
||||||
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
|
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
|
||||||
|
host=_ConfigOption('host', str, '127.0.0.1'),
|
||||||
|
port=_ConfigOption('port', int, 8080),
|
||||||
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),
|
|
||||||
httpd=_ConfigOption('httpd', dict, {
|
|
||||||
'host': 'localhost',
|
|
||||||
'port': 8080,
|
|
||||||
'require_user_urlparam': True
|
|
||||||
}, validator=_validate_httpd_config),
|
|
||||||
smtp=_ConfigOption('smtp', dict, {
|
smtp=_ConfigOption('smtp', dict, {
|
||||||
'host': 'localhost',
|
'host': 'localhost',
|
||||||
'port': 25,
|
'port': 25,
|
||||||
|
@ -295,16 +141,4 @@ 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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_message(key, **kwargs):
|
|
||||||
header = Config.responses['header'].format(**kwargs)
|
|
||||||
content = Config.responses[key].format(**kwargs)
|
|
||||||
footer = Config.responses['footer'].format(**kwargs)
|
|
||||||
return f'{header}\n{content}\n{footer}'
|
|
||||||
|
|
|
@ -1,90 +0,0 @@
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from twisted.internet import reactor, defer
|
|
||||||
from twisted.names import dns, server, common, error
|
|
||||||
from twisted.python import util as tputil, failure
|
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
from .files import read_dane_public_keys
|
|
||||||
|
|
||||||
|
|
||||||
class Record_OPENPGPKEY(tputil.FancyEqMixin, tputil.FancyStrMixin):
|
|
||||||
TYPE = 61
|
|
||||||
fancybasename = 'OPENPGPKEY'
|
|
||||||
compareAttributes = ('data',)
|
|
||||||
showAttributes = ('data',)
|
|
||||||
|
|
||||||
def __init__(self, data=None, ttl=0):
|
|
||||||
self.data = data
|
|
||||||
self.ttl = ttl
|
|
||||||
|
|
||||||
def encode(self, strio, compDict=None):
|
|
||||||
strio.write(self.data)
|
|
||||||
|
|
||||||
def decode(self, strio, length=None):
|
|
||||||
self.data = strio.read(length)
|
|
||||||
|
|
||||||
def __hash__(self):
|
|
||||||
return hash(self.data)
|
|
||||||
|
|
||||||
|
|
||||||
class DnsServer(common.ResolverBase):
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.zones = {}
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def _load(self):
|
|
||||||
for domain in Config.domains:
|
|
||||||
origin = dns.domainString(f'_openpgpkey.{domain}')
|
|
||||||
self.zones[origin] = domain
|
|
||||||
|
|
||||||
def _make_soa(self, name):
|
|
||||||
domain = self.zones[name]
|
|
||||||
now = int(datetime.utcnow().timestamp()) // 60
|
|
||||||
soa = dns.Record_SOA(mname=Config[domain].dane['soa'].get('mname', 'localhost.'),
|
|
||||||
rname=Config[domain].dane['soa'].get('rname',
|
|
||||||
Config[domain].submission_address.replace('@', '.')),
|
|
||||||
serial=now,
|
|
||||||
refresh=Config[domain].dane['soa'].get('refresh', 300),
|
|
||||||
retry=Config[domain].dane['soa'].get('retry', 60),
|
|
||||||
expire=Config[domain].dane['soa'].get('expire', 2419200),
|
|
||||||
minimum=Config[domain].dane['soa'].get('minimum', 300))
|
|
||||||
return dns.RRHeader(name, dns.SOA, payload=soa, auth=True)
|
|
||||||
|
|
||||||
def _make_ns(self, name):
|
|
||||||
domain = self.zones[name]
|
|
||||||
return [
|
|
||||||
dns.RRHeader(name, dns.NS, payload=dns.Record_NS(host), auth=True)
|
|
||||||
for host in Config[domain].dane['ns']
|
|
||||||
]
|
|
||||||
|
|
||||||
def _lookup(self, name, cls, type, timeout):
|
|
||||||
if name not in self.zones:
|
|
||||||
return defer.fail(failure.Failure(dns.AuthoritativeDomainError(name)))
|
|
||||||
if type == dns.SOA or type == dns.OPT:
|
|
||||||
return defer.succeed(([self._make_soa(name)], [], []))
|
|
||||||
if type != dns.AXFR and type != dns.IXFR:
|
|
||||||
return defer.fail(failure.Failure(error.DNSQueryRefusedError(name)))
|
|
||||||
domain = self.zones[name]
|
|
||||||
results = []
|
|
||||||
soa = self._make_soa(name)
|
|
||||||
ns = self._make_ns(name)
|
|
||||||
results.append(soa)
|
|
||||||
results.extend(ns)
|
|
||||||
for digest, key in read_dane_public_keys(domain).items():
|
|
||||||
fqdn = f'{digest}._openpgpkey.{domain}'
|
|
||||||
record = Record_OPENPGPKEY(key)
|
|
||||||
results.append(dns.RRHeader(dns.domainString(fqdn), record.TYPE, payload=record, auth=True))
|
|
||||||
results.append(soa)
|
|
||||||
return defer.succeed((results, [], []))
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
# The "reactor" interface is created dynamically, so the listenTCP and run methods only become available during runtime.
|
|
||||||
def run_dnsd(args):
|
|
||||||
auth = DnsServer()
|
|
||||||
factory = server.DNSServerFactory(authorities=[auth])
|
|
||||||
reactor.listenTCP(Config.dnsd['port'], factory, interface=Config.dnsd['host'])
|
|
||||||
reactor.run()
|
|
|
@ -1,6 +1,5 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import fcntl
|
|
||||||
import stat
|
import stat
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
@ -8,25 +7,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, armor_keys, split_revoked, dane_digest, dane_notify
|
from .util import hash_user_id
|
||||||
|
|
||||||
|
|
||||||
def _locked_read(file: str, binary: bool = False):
|
|
||||||
with open(file, 'r' + 'b' * binary) as f:
|
|
||||||
fcntl.lockf(f, fcntl.LOCK_SH)
|
|
||||||
content = f.read()
|
|
||||||
fcntl.lockf(f, fcntl.LOCK_UN)
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
def _locked_write(file: str, content, binary: bool = False):
|
|
||||||
with open(file, 'a' + 'b' * binary) as f:
|
|
||||||
fcntl.lockf(f, fcntl.LOCK_EX)
|
|
||||||
f.seek(0)
|
|
||||||
f.truncate()
|
|
||||||
f.write(content)
|
|
||||||
fcntl.lockf(f, fcntl.LOCK_UN)
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
def make_submission_address_file(domain: str):
|
def make_submission_address_file(domain: str):
|
||||||
|
@ -36,15 +17,10 @@ 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 isinstance(value, bool):
|
if not value or len(value) == 0:
|
||||||
if not value:
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
content += flag + '\n'
|
|
||||||
elif value is None:
|
|
||||||
content += flag + '\n'
|
|
||||||
else:
|
|
||||||
content += f'{flag}: {value}\n'
|
content += f'{flag}: {value}\n'
|
||||||
|
else:
|
||||||
|
content += flag + '\n'
|
||||||
return content
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
@ -56,64 +32,43 @@ def init_working_directory():
|
||||||
# Create necessary files and directories
|
# Create necessary files and directories
|
||||||
os.makedirs(os.path.join(wdir, domain, 'hu'), exist_ok=True)
|
os.makedirs(os.path.join(wdir, domain, 'hu'), exist_ok=True)
|
||||||
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))
|
with open(os.path.join(wdir, domain, 'submission-address'), 'w') as saf:
|
||||||
_locked_write(os.path.join(wdir, domain, 'policy'), make_policy_file(domain))
|
saf.write(make_submission_address_file(domain))
|
||||||
os.makedirs(os.path.join(wdir, domain, 'dane'), exist_ok=True)
|
with open(os.path.join(wdir, domain, 'policy'), 'w') as polf:
|
||||||
|
polf.write(make_policy_file(domain))
|
||||||
# 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)
|
with open(os.path.join(wdir, domain, 'hu', uid), 'wb') as hu:
|
||||||
digest = dane_digest(Config[domain].submission_address)
|
hu.write(bytes(key))
|
||||||
_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):
|
||||||
return read_hashed_public_key(domain, hash_user_id(user))
|
|
||||||
|
|
||||||
|
|
||||||
def read_hashed_public_key(domain, hu):
|
|
||||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
|
||||||
_, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True))
|
|
||||||
key, revoked = split_revoked(keys.values())
|
|
||||||
return key, revoked
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
danefile = os.path.join(Config.working_directory, domain, 'dane', dane)
|
key, _ = PGPKey.from_file(keyfile)
|
||||||
joined = bytes(key) + b''.join([bytes(k) for k in revoked])
|
return key
|
||||||
_locked_write(keyfile, joined, binary=True)
|
|
||||||
_locked_write(danefile, bytes(key), binary=True)
|
|
||||||
dane_notify(domain)
|
def write_public_key(domain, user, key):
|
||||||
|
hu = hash_user_id(user)
|
||||||
|
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||||
|
with open(keyfile, 'wb') as f:
|
||||||
|
f.write(bytes(key))
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
_, keys = PGPKey.from_blob(_locked_read(keyfile))
|
key, _ = PGPKey.from_file(keyfile)
|
||||||
key, revoked = split_revoked(keys.values())
|
return key
|
||||||
return key[0], revoked
|
|
||||||
|
|
||||||
|
|
||||||
def write_pending_key(domain, nonce, key, revoked_keys):
|
def write_pending_key(domain, nonce, key):
|
||||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||||
armored = armor_keys([key] + revoked_keys)
|
with open(keyfile, 'w') as f:
|
||||||
_locked_write(keyfile, armored)
|
f.write(str(key))
|
||||||
|
|
||||||
|
|
||||||
def remove_pending_key(domain, nonce):
|
def remove_pending_key(domain, nonce):
|
||||||
|
@ -121,7 +76,7 @@ def remove_pending_key(domain, nonce):
|
||||||
os.unlink(keyfile)
|
os.unlink(keyfile)
|
||||||
|
|
||||||
|
|
||||||
def clean_stale_requests(args):
|
def clean_stale_requests():
|
||||||
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')
|
||||||
|
|
|
@ -1,80 +0,0 @@
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
from .files import read_hashed_public_key, make_submission_address_file, make_policy_file
|
|
||||||
from .util import hash_user_id
|
|
||||||
|
|
||||||
from bottle import get, run, response, request, HTTPError
|
|
||||||
|
|
||||||
|
|
||||||
CORS = ('Access-Control-Allow-Origin', '*')
|
|
||||||
|
|
||||||
|
|
||||||
def abort(code, text):
|
|
||||||
err = HTTPError(code, text)
|
|
||||||
err.add_header(*CORS)
|
|
||||||
return err
|
|
||||||
|
|
||||||
|
|
||||||
def get_domain_header():
|
|
||||||
domain = request.get_header('host')
|
|
||||||
if len(domain) != 1 or domain[0] is None:
|
|
||||||
abort(400, 'Bad Request')
|
|
||||||
return domain[0]
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
|
||||||
def advanced_submission_address(domain: str):
|
|
||||||
if domain not in Config.domains:
|
|
||||||
abort(404, 'Not Found')
|
|
||||||
response.add_header('Content-Type', 'text/plain')
|
|
||||||
response.add_header(*CORS)
|
|
||||||
return make_submission_address_file(domain)
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/submission-address')
|
|
||||||
def direct_submission_address():
|
|
||||||
return advanced_submission_address(get_domain_header())
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/<domain>/policy')
|
|
||||||
def advanced_policy(domain: str):
|
|
||||||
if domain not in Config.domains:
|
|
||||||
abort(404, 'Not Found')
|
|
||||||
response.add_header('Content-Type', 'text/plain')
|
|
||||||
response.add_header(*CORS)
|
|
||||||
return make_policy_file(domain)
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/policy')
|
|
||||||
def direct_policy():
|
|
||||||
return advanced_policy(get_domain_header())
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
|
|
||||||
def advanced_hu(domain: str, userhash: str):
|
|
||||||
if domain not in Config.domains:
|
|
||||||
abort(404, 'Not Found')
|
|
||||||
if Config.httpd['require_user_urlparam']:
|
|
||||||
userid = request.query.l
|
|
||||||
if not userid or hash_user_id(userid) != userhash:
|
|
||||||
abort(404, 'Not Found')
|
|
||||||
try:
|
|
||||||
pubkey, revoked = read_hashed_public_key(domain, userhash)
|
|
||||||
response.add_header('Content-Type', 'application/octet-stream')
|
|
||||||
response.add_header(*CORS)
|
|
||||||
return bytes(pubkey[0]) + b''.join([bytes(k) for k in revoked])
|
|
||||||
except FileNotFoundError:
|
|
||||||
abort(404, 'Not Found')
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/hu/<userhash>')
|
|
||||||
def direct_hu(userhash: str):
|
|
||||||
return advanced_hu(get_domain_header(), userhash)
|
|
||||||
|
|
||||||
|
|
||||||
def run_server(args):
|
|
||||||
run(host=Config.httpd['host'], port=Config.httpd['port'])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
run_server(None)
|
|
|
@ -13,15 +13,29 @@ from .types import EasyWksError
|
||||||
|
|
||||||
class LmtpMailServer:
|
class LmtpMailServer:
|
||||||
|
|
||||||
|
async def handle_MAIL(self, server, session, envelope, address, mail_options):
|
||||||
|
_, domain = address.split('@', 1)
|
||||||
|
if domain not in Config.domains:
|
||||||
|
return '550 Not accepting mails from this domain'
|
||||||
|
envelope.mail_from = address
|
||||||
|
return '250 OK'
|
||||||
|
|
||||||
|
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||||
|
for domain in Config.domains:
|
||||||
|
if Config[domain].submission_address == address:
|
||||||
|
envelope.rcpt_tos.append(address)
|
||||||
|
return '250 OK'
|
||||||
|
return '550 Not responsible for this address'
|
||||||
|
|
||||||
async def handle_DATA(self, server, session, envelope):
|
async def handle_DATA(self, server, session, envelope):
|
||||||
message = envelope.content
|
message = envelope.content
|
||||||
try:
|
try:
|
||||||
process_mail(message)
|
process_mail(message)
|
||||||
except EasyWksError as e:
|
except EasyWksError as e:
|
||||||
return f'550 {e}'
|
return f'550 {e}'
|
||||||
except Exception as e:
|
except BaseException:
|
||||||
traceback.print_exc()
|
tb = traceback.format_exc()
|
||||||
return f'550 Error during message processing: {e}'
|
return f'550 Error during message processing: {tb}'
|
||||||
return '250 Message successfully handled'
|
return '250 Message successfully handled'
|
||||||
|
|
||||||
|
|
||||||
|
@ -31,10 +45,10 @@ class LmtpdController(Controller):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def factory(self):
|
def factory(self):
|
||||||
return LMTP(handler=self.handler, ident=f'EasyWKS {version}', loop=self.loop)
|
return LMTP(self.handler, ident=f'EasyWKS {version}', **self.SMTP_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def run_lmtpd(args):
|
def run_lmtpd():
|
||||||
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,10 +1,9 @@
|
||||||
|
|
||||||
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, process_key_from_stdin
|
from .process import process_mail_from_stdin
|
||||||
from .httpd import run_server
|
from .server import run_server
|
||||||
from .lmtpd import run_lmtpd
|
from .lmtpd import run_lmtpd
|
||||||
from .dnsd import run_dnsd
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
@ -33,27 +32,19 @@ 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:])
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_arguments()
|
args = parse_arguments()
|
||||||
if isinstance(args.config, list):
|
if args.config is list:
|
||||||
conf = args.config[0]
|
conf = args.config[0]
|
||||||
else:
|
else:
|
||||||
conf = args.config
|
conf = args.config
|
||||||
Config.load_config(conf)
|
Config.load_config(conf)
|
||||||
init_working_directory()
|
init_working_directory()
|
||||||
if args.fn:
|
if args.fn:
|
||||||
args.fn(args)
|
args.fn()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -1,23 +1,19 @@
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, List, Dict, Tuple
|
from typing import List, Dict
|
||||||
|
|
||||||
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, PGPSignature
|
from pgpy import PGPMessage, PGPKey, PGPUID
|
||||||
from pgpy.errors import PGPError
|
from pgpy.errors import PGPError
|
||||||
|
|
||||||
|
|
||||||
|
@ -49,43 +45,30 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
|
||||||
return pgp
|
return pgp
|
||||||
|
|
||||||
|
|
||||||
def _get_pgp_publickeys(parts: List[MIMEPart]) -> Tuple[PGPKey, List[PGPKey]]:
|
def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
|
||||||
pubkeys: Dict[str, PGPKey] = {}
|
pubkey = None
|
||||||
for part in parts:
|
for part in parts:
|
||||||
try:
|
try:
|
||||||
_, keys = PGPKey.from_blob(part.get_content())
|
key, _ = PGPKey.from_blob(part.get_content())
|
||||||
for (_, public), key in keys.items():
|
if key.is_public:
|
||||||
if not public:
|
if pubkey:
|
||||||
continue
|
raise EasyWksError('More than one PGP public key in message')
|
||||||
fpr = fingerprint(key)
|
pubkey = 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 len(pubkeys) == 0:
|
if not pubkey:
|
||||||
raise EasyWksError('No PGP public key found in the encrypted message part.')
|
raise EasyWksError('No PGP public key in message')
|
||||||
key, revoked_keys = split_revoked(pubkeys.values())
|
return pubkey
|
||||||
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)
|
||||||
valid_key, revoked_keys = _get_pgp_publickeys(leafs)
|
pubkey = _get_pgp_publickey(leafs)
|
||||||
sender_uid: PGPUID = valid_key.get_uid(sender)
|
sender_uid: PGPUID = pubkey.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}')
|
||||||
for key in revoked_keys:
|
return SubmissionRequest(sender, submission, pubkey)
|
||||||
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:
|
||||||
|
@ -107,6 +90,8 @@ def _find_confirmation_response(parts: List[MIMEPart]) -> str:
|
||||||
if 'confirmation-response' in c:
|
if 'confirmation-response' in c:
|
||||||
response = c
|
response = c
|
||||||
continue
|
continue
|
||||||
|
if not response:
|
||||||
|
raise EasyWksError('No confirmation response found in message')
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@ -120,78 +105,15 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
|
||||||
continue
|
continue
|
||||||
key, value = line.split(':', 1)
|
key, value = line.split(':', 1)
|
||||||
rdict[key.strip()] = value.strip()
|
rdict[key.strip()] = value.strip()
|
||||||
# "from" was renamed to "sender" in draft-koch-openpgp-webkey-service-01. In addition, gpg-wks-client, which still
|
|
||||||
# implements -00, also violates this standard and uses "address" for the sender and "sender" for the submission
|
|
||||||
# address.
|
|
||||||
if 'from' in rdict:
|
|
||||||
rdict['sender'] = rdict['from']
|
|
||||||
if 'address' in rdict:
|
|
||||||
rdict['sender'] = rdict['address']
|
|
||||||
if rdict.get('type', '') != 'confirmation-response':
|
if rdict.get('type', '') != 'confirmation-response':
|
||||||
raise EasyWksError('Invalid confirmation response: "type" missing or not "confirmation-response"')
|
raise EasyWksError('Invalid confirmation response: "type" missing or not "confirmation-response"')
|
||||||
if 'sender' not in rdict or 'nonce' not in rdict:
|
if 'sender' not in rdict or 'nonce' not in rdict:
|
||||||
raise EasyWksError('Invalid confirmation response: "sender" or "nonce" missing from confirmation response')
|
raise EasyWksError('Invalid confirmation response: "sender" or "nonce" missing from confirmation response')
|
||||||
if rdict['sender'] != sender:
|
if rdict['sender'] != sender:
|
||||||
raise EasyWksError(f'Confirmation sender "{rdict["sender"]}" does not match message sender "{sender}"')
|
raise EasyWksError('Confirmation sender does not match message sender')
|
||||||
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)
|
||||||
|
@ -201,7 +123,7 @@ def process_mail(mail: bytes):
|
||||||
raise EasyWksError('Sender mail is not a valid mail address')
|
raise EasyWksError('Sender mail is not a valid mail address')
|
||||||
if sender_domain not in Config.domains:
|
if sender_domain not in Config.domains:
|
||||||
raise EasyWksError(f'Domain {sender_domain} not supported')
|
raise EasyWksError(f'Domain {sender_domain} not supported')
|
||||||
if msg.get('x-loop', '') == XLOOP_HEADER or msg.get('auto-submitted', 'no') != 'no':
|
if msg.get('x-loop', '') == XLOOP_HEADER or 'auto-submitted' in msg:
|
||||||
# Mail has somehow looped back to us, discard
|
# Mail has somehow looped back to us, discard
|
||||||
return
|
return
|
||||||
submission_address: str = Config[sender_domain].submission_address
|
submission_address: str = Config[sender_domain].submission_address
|
||||||
|
@ -216,69 +138,29 @@ def process_mail(mail: bytes):
|
||||||
leafs = _get_mime_leafs(msg)
|
leafs = _get_mime_leafs(msg)
|
||||||
pgp: PGPMessage = _get_pgp_message(leafs)
|
pgp: PGPMessage = _get_pgp_message(leafs)
|
||||||
decrypted = pgp_decrypt(sender_domain, pgp)
|
decrypted = pgp_decrypt(sender_domain, pgp)
|
||||||
payload = BytesParser(policy=default).parsebytes(decrypted.message)
|
if decrypted.is_signed:
|
||||||
parts = _get_mime_leafs(payload)
|
|
||||||
# First attempt to find a confirmation response. It's identifiable much easier due to the
|
|
||||||
# "type: confirmation-response" part in the message.
|
|
||||||
confirmation_response = _find_confirmation_response(parts)
|
|
||||||
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, revoked_keys = read_pending_key(sender_domain, request.nonce)
|
key = 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
|
# this throws an error if signature verification fails
|
||||||
if not Config[sender_domain].policy_flags.get(EWP_PERMIT_UNSIGNED_RESPONSE, False) and \
|
response: PublishResponse = request.verify_signature(key)
|
||||||
not Config.permit_unsigned_response:
|
rmsg = response.create_signed_message()
|
||||||
# this throws an error if signature verification fails
|
write_public_key(sender_domain, sender_mail, key)
|
||||||
request.verify_signature(key)
|
|
||||||
response: PublishResponse = request.get_publish_response(key)
|
|
||||||
write_public_key(sender_domain, sender_mail, key, revoked_keys)
|
|
||||||
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
|
response: ConfirmationRequest = request.confirmation_request()
|
||||||
_apply_submission_policy(request, policy)
|
rmsg = response.create_signed_message()
|
||||||
if policy.get(POLICY_AUTH_SUBMIT, False):
|
write_pending_key(sender_domain, response.nonce, request.key)
|
||||||
response = PublishResponse(request.submitter_address, request.submission_address, request.key)
|
|
||||||
write_public_key(sender_domain, sender_mail, request.key, request.revoked_keys)
|
|
||||||
else:
|
|
||||||
response: ConfirmationRequest = request.confirmation_request()
|
|
||||||
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys)
|
|
||||||
rmsg = response.create_signed_message()
|
|
||||||
except EasyWksError as e:
|
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(args):
|
def process_mail_from_stdin():
|
||||||
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}')
|
|
||||||
|
|
46
easywks/server.py
Normal file
46
easywks/server.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .files import read_public_key, make_submission_address_file, make_policy_file
|
||||||
|
from .util import hash_user_id
|
||||||
|
|
||||||
|
from bottle import get, run, abort, response, request
|
||||||
|
|
||||||
|
|
||||||
|
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
||||||
|
def submission_address(domain: str):
|
||||||
|
if domain not in Config.domains:
|
||||||
|
abort(404, 'Not Found')
|
||||||
|
response.add_header('Content-Type', 'text/plain')
|
||||||
|
return make_submission_address_file(domain)
|
||||||
|
|
||||||
|
|
||||||
|
@get('/.well-known/openpgpkey/<domain>/policy')
|
||||||
|
def policy(domain: str):
|
||||||
|
if domain not in Config.domains:
|
||||||
|
abort(404, 'Not Found')
|
||||||
|
response.add_header('Content-Type', 'text/plain')
|
||||||
|
return make_policy_file(domain)
|
||||||
|
|
||||||
|
|
||||||
|
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
|
||||||
|
def hu(domain: str, userhash: str):
|
||||||
|
if domain not in Config.domains:
|
||||||
|
abort(404, 'Not Found')
|
||||||
|
userid = request.query.l
|
||||||
|
print(userid, userhash, hash_user_id(userid))
|
||||||
|
if not userid or hash_user_id(userid) != userhash:
|
||||||
|
abort(404, 'Not Found')
|
||||||
|
try:
|
||||||
|
pubkey = read_public_key(domain, userid)
|
||||||
|
response.add_header('Content-Type', 'application/octet-stream')
|
||||||
|
return bytes(pubkey)
|
||||||
|
except FileNotFoundError:
|
||||||
|
abort(404, 'Not Found')
|
||||||
|
|
||||||
|
|
||||||
|
def run_server():
|
||||||
|
run(host=Config.host, port=Config.port)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
run_server()
|
155
easywks/types.py
155
easywks/types.py
|
@ -1,8 +1,5 @@
|
||||||
|
|
||||||
from typing import List
|
from datetime import datetime
|
||||||
|
|
||||||
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,10 +7,9 @@ from email.mime.application import MIMEApplication
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
from pgpy import PGPKey, PGPMessage, PGPUID
|
from pgpy import PGPKey, PGPMessage, PGPUID
|
||||||
from pgpy.errors import PGPError
|
from pgpy.types import SignatureVerification
|
||||||
|
|
||||||
from .crypto import pgp_sign
|
from .crypto import pgp_sign
|
||||||
from .config import render_message
|
|
||||||
from .util import create_nonce, fingerprint
|
from .util import create_nonce, fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,11 +18,10 @@ XLOOP_HEADER = 'EasyWKS'
|
||||||
|
|
||||||
class SubmissionRequest:
|
class SubmissionRequest:
|
||||||
|
|
||||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, revoked_keys: List[PGPKey]):
|
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
|
||||||
self._submitter_addr = submitter_addr
|
self._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)
|
||||||
|
@ -43,13 +38,42 @@ 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:
|
||||||
|
|
||||||
|
MAIL_TEXT = '''Hi there!
|
||||||
|
|
||||||
|
This is the EasyWKS system at {domain}.
|
||||||
|
|
||||||
|
You appear to have submitted your key for publication in the Web Key
|
||||||
|
Directory. There's one more step you need to complete. If you did not
|
||||||
|
request this, you can simply ignore this message.
|
||||||
|
|
||||||
|
If your email client doesn't automatically complete this challenge, you
|
||||||
|
can perform this step manually: Please verify that you can decrypt the
|
||||||
|
second part of this message and that the fingerprint listed in the
|
||||||
|
encrypted part matches your key. If everything looks ok, please reply
|
||||||
|
to this message with an **encrypted and signed PGP/MIME message** with
|
||||||
|
the following content (without the <> brackets)
|
||||||
|
|
||||||
|
type: confirmation-response
|
||||||
|
sender: <your email address>
|
||||||
|
nonce: <copy the nonce from the encrypted part of this message>
|
||||||
|
|
||||||
|
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.
|
||||||
|
'''
|
||||||
|
|
||||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, nonce: str = None):
|
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, nonce: str = None):
|
||||||
self._domain = submitter_addr.split('@')[1]
|
self._domain = submitter_addr.split('@')[1]
|
||||||
self._submitter_addr = submitter_addr
|
self._submitter_addr = submitter_addr
|
||||||
|
@ -78,11 +102,7 @@ class ConfirmationRequest:
|
||||||
return self._nonce
|
return self._nonce
|
||||||
|
|
||||||
def create_signed_message(self):
|
def create_signed_message(self):
|
||||||
mail_text = render_message('confirm',
|
mpplain = MIMEText(ConfirmationRequest.MAIL_TEXT.format(domain=self.domain), _subtype='plain')
|
||||||
domain=self.domain,
|
|
||||||
sender=self.submitter_address,
|
|
||||||
submission=self.submission_address)
|
|
||||||
mpplain = MIMEText(mail_text, _subtype='plain')
|
|
||||||
ps = '\r\n'.join([
|
ps = '\r\n'.join([
|
||||||
'type: confirmation-request',
|
'type: confirmation-request',
|
||||||
f'sender: {self._submission_addr}',
|
f'sender: {self._submission_addr}',
|
||||||
|
@ -96,18 +116,15 @@ 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).replace('\n', '\r\n'))
|
to_sign = PGPMessage.new(mixed.as_string(policy=default))
|
||||||
sig = pgp_sign(self.domain, to_sign)
|
sig = pgp_sign(self.domain, to_sign)
|
||||||
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature', name='signature.asc', _encoder=encode_noop)
|
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature')
|
||||||
mpsig['Content-Description'] = 'OpenPGP digital signature'
|
email = MIMEMultipart(_subtype='signed', _subparts=[mixed, mpsig], policy=default,
|
||||||
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.now(timezone.utc))
|
email['Date'] = format_datetime(datetime.utcnow())
|
||||||
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
|
||||||
|
@ -140,26 +157,39 @@ class ConfirmationResponse:
|
||||||
def nonce(self):
|
def nonce(self):
|
||||||
return self._nonce
|
return self._nonce
|
||||||
|
|
||||||
def get_publish_response(self, key: PGPKey) -> 'PublishResponse':
|
def verify_signature(self, key: PGPKey) -> 'PublishResponse':
|
||||||
return PublishResponse(self._submitter_addr, self._submission_addr, key)
|
|
||||||
|
|
||||||
def verify_signature(self, key: PGPKey):
|
|
||||||
if not self._msg.is_signed:
|
|
||||||
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
|
|
||||||
'gpg-wks-client for submitting your response, please update said tool or try '
|
|
||||||
'responding manually.')
|
|
||||||
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')
|
||||||
try:
|
verification: SignatureVerification = key.verify(self._msg)
|
||||||
# Should raise an error when verification fails, but add the boolean check as a additional protection
|
for verified, by, sig, subject in verification.good_signatures:
|
||||||
if not key.verify(self._msg):
|
if fingerprint(key) == fingerprint(by):
|
||||||
raise EasyWksError(f'PGP signature could not be verified')
|
return PublishResponse(self._submitter_addr, self._submission_addr, key)
|
||||||
except PGPError as e:
|
raise EasyWksError('PGP Signature could not be verified')
|
||||||
raise EasyWksError(f'PGP signature could not be verified: {e}')
|
|
||||||
|
|
||||||
|
|
||||||
class PublishResponse:
|
class PublishResponse:
|
||||||
|
MAIL_TEXT = '''Hi there!
|
||||||
|
|
||||||
|
This is the EasyWKS system at {domain}.
|
||||||
|
|
||||||
|
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 {uid}
|
||||||
|
|
||||||
|
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.
|
||||||
|
'''
|
||||||
|
|
||||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
|
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
|
||||||
self._domain = submitter_addr.split('@')[1]
|
self._domain = submitter_addr.split('@')[1]
|
||||||
|
@ -184,22 +214,19 @@ class PublishResponse:
|
||||||
return self._domain
|
return self._domain
|
||||||
|
|
||||||
def create_signed_message(self):
|
def create_signed_message(self):
|
||||||
mail_text = render_message('done',
|
mpplain = MIMEText(ConfirmationRequest.MAIL_TEXT.format(domain=self.domain, uid=self.submitter_address),
|
||||||
domain=self.domain,
|
_subtype='plain')
|
||||||
sender=self.submitter_address,
|
|
||||||
submission=self.submission_address)
|
|
||||||
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)
|
||||||
payload = MIMEApplication(str(encrypted), _subtype='octet-stream', _encoder=encode_noop)
|
encrypted |= pgp_sign(self.domain, encrypted)
|
||||||
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted', _encoder=encode_noop)
|
payload = MIMEApplication(str(encrypted), _subtype='octet-stream')
|
||||||
|
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted')
|
||||||
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
|
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.now(timezone.utc))
|
email['Date'] = format_datetime(datetime.utcnow())
|
||||||
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
|
||||||
|
@ -209,6 +236,30 @@ class PublishResponse:
|
||||||
|
|
||||||
class EasyWksError(BaseException):
|
class EasyWksError(BaseException):
|
||||||
|
|
||||||
|
MAIL_TEXT = '''Hi there!
|
||||||
|
|
||||||
|
This is the EasyWKS system at {domain}.
|
||||||
|
|
||||||
|
An error has occurred while processing your request.
|
||||||
|
|
||||||
|
{message}
|
||||||
|
|
||||||
|
If this error persists, please contact your administrator for help.
|
||||||
|
|
||||||
|
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.
|
||||||
|
'''
|
||||||
|
|
||||||
def __init__(self, msg: str, ):
|
def __init__(self, msg: str, ):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._msg = msg
|
self._msg = msg
|
||||||
|
@ -218,16 +269,12 @@ class EasyWksError(BaseException):
|
||||||
|
|
||||||
def create_message(self, submitter_addr: str, submission_addr: str) -> MIMEText:
|
def create_message(self, submitter_addr: str, submission_addr: str) -> MIMEText:
|
||||||
domain = submission_addr.split('@', 1)[1]
|
domain = submission_addr.split('@', 1)[1]
|
||||||
mail_text = render_message('error',
|
payload = EasyWksError.MAIL_TEXT.format(domain=domain, message=self._msg)
|
||||||
domain=domain,
|
email = MIMEText(payload)
|
||||||
sender=submitter_addr,
|
|
||||||
submission=submission_addr,
|
|
||||||
error=self._msg)
|
|
||||||
email = MIMEText(mail_text)
|
|
||||||
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.now(timezone.utc))
|
email['Date'] = format_datetime(datetime.utcnow())
|
||||||
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,19 +1,9 @@
|
||||||
|
|
||||||
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:
|
||||||
|
@ -38,12 +28,6 @@ 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))
|
||||||
|
@ -52,64 +36,3 @@ 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
BIN
logo/easywks.png
Binary file not shown.
Before Width: | Height: | Size: 13 KiB |
|
@ -1,96 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
|
|
||||||
<svg
|
|
||||||
width="275.00003"
|
|
||||||
height="275.00003"
|
|
||||||
viewBox="0 0 72.760425 72.760425"
|
|
||||||
version="1.1"
|
|
||||||
id="svg1"
|
|
||||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
|
||||||
sodipodi:docname="easywks.svg"
|
|
||||||
inkscape:export-filename="easywks.png"
|
|
||||||
inkscape:export-xdpi="96"
|
|
||||||
inkscape:export-ydpi="96"
|
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
xmlns:svg="http://www.w3.org/2000/svg">
|
|
||||||
<sodipodi:namedview
|
|
||||||
id="namedview1"
|
|
||||||
pagecolor="#ffffff"
|
|
||||||
bordercolor="#999999"
|
|
||||||
borderopacity="1"
|
|
||||||
inkscape:showpageshadow="0"
|
|
||||||
inkscape:pageopacity="0"
|
|
||||||
inkscape:pagecheckerboard="0"
|
|
||||||
inkscape:deskcolor="#d1d1d1"
|
|
||||||
inkscape:document-units="mm"
|
|
||||||
showgrid="true"
|
|
||||||
inkscape:zoom="1.7928421"
|
|
||||||
inkscape:cx="63.028417"
|
|
||||||
inkscape:cy="98.725927"
|
|
||||||
inkscape:window-width="1920"
|
|
||||||
inkscape:window-height="1060"
|
|
||||||
inkscape:window-x="0"
|
|
||||||
inkscape:window-y="0"
|
|
||||||
inkscape:window-maximized="0"
|
|
||||||
inkscape:current-layer="layer1">
|
|
||||||
<inkscape:grid
|
|
||||||
id="grid1"
|
|
||||||
units="px"
|
|
||||||
originx="-13.229167"
|
|
||||||
originy="-13.229167"
|
|
||||||
spacingx="1.3229167"
|
|
||||||
spacingy="1.3229167"
|
|
||||||
empcolor="#3f3fff"
|
|
||||||
empopacity="0.25098039"
|
|
||||||
color="#3f3fff"
|
|
||||||
opacity="0.1254902"
|
|
||||||
empspacing="5"
|
|
||||||
dotted="false"
|
|
||||||
gridanglex="30"
|
|
||||||
gridanglez="30"
|
|
||||||
visible="true" />
|
|
||||||
</sodipodi:namedview>
|
|
||||||
<defs
|
|
||||||
id="defs1" />
|
|
||||||
<g
|
|
||||||
inkscape:label="Layer 1"
|
|
||||||
inkscape:groupmode="layer"
|
|
||||||
id="layer1"
|
|
||||||
transform="translate(-13.229167,-13.229167)">
|
|
||||||
<path
|
|
||||||
id="path2"
|
|
||||||
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="M 84.666667,85.989583 44.317708,45.640625 39.6875,50.270833 46.302083,56.885417 47.625,58.208333 h 5.291667 V 63.5 l 1.322916,1.322917 h 2.645834 v 2.645833 l 1.322916,1.322917 h 6.614584 v 6.614583 l 1.322916,1.322917 h 1.322917 v 1.322916 L 68.791667,79.375 h 3.96875 v 3.96875 l 2.645833,2.645833 z" />
|
|
||||||
<path
|
|
||||||
id="path10"
|
|
||||||
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 46.302083,43.65625 -1.984375,1.984375 40.348959,40.348958 1.322916,-1.322916 V 83.34375 Z" />
|
|
||||||
<path
|
|
||||||
id="path9"
|
|
||||||
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 47.625,42.333333 -1.322917,1.322917 39.6875,39.6875 v -2.645833 z" />
|
|
||||||
<path
|
|
||||||
id="path8"
|
|
||||||
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="M 85.989583,79.375 48.286458,41.671875 47.625,42.333333 85.989583,80.697917 Z" />
|
|
||||||
<path
|
|
||||||
id="path7"
|
|
||||||
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="M 50.270833,39.6875 48.286458,41.671875 85.989583,79.375 v -3.96875 z" />
|
|
||||||
<path
|
|
||||||
id="path1"
|
|
||||||
style="fill:#7a7a7a;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0499999"
|
|
||||||
d="m 33.072917,13.229167 a 19.84375,19.84375 0 0 0 -19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,-19.84375 19.84375,19.84375 0 0 0 -19.84375,-19.84375 z m -6.619751,6.624402 a 6.6095872,6.6045904 0 0 1 6.609932,6.604764 6.6095872,6.6045904 0 0 1 -6.609932,6.604765 6.6095872,6.6045904 0 0 1 -6.609416,-6.604765 6.6095872,6.6045904 0 0 1 6.609416,-6.604764 z" />
|
|
||||||
<path
|
|
||||||
style="fill:#2ce000;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="m 20.835939,55.562502 h 15.213543 v 15.213541 h 7.60677 L 28.442711,85.989585 13.229168,70.776043 h 7.606771 z"
|
|
||||||
id="path11" />
|
|
||||||
<path
|
|
||||||
style="fill:#ffa708;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
|
||||||
d="M 78.382819,43.65625 H 63.169276 V 28.442709 h -7.60677 L 70.776047,13.229167 85.98959,28.442709 h -7.606771 z"
|
|
||||||
id="path11-5" />
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 4.4 KiB |
|
@ -1,2 +1 @@
|
||||||
/etc/easywks.yml
|
/etc/easywks.yml
|
||||||
/etc/cron.d/easywks
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
Package: easywks
|
Package: easywks
|
||||||
Version: __VERSION__
|
Version: __EASYWKS_VERSION__
|
||||||
Maintainer: s3lph <1375407-s3lph@users.noreply.gitlab.com>
|
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
|
||||||
Section: web
|
Section: web
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd, python3-dnspython, python3-twisted
|
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd
|
||||||
Description: OpenPGP WKS for Human Beings
|
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
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
# Part of the easywks package
|
|
||||||
|
|
||||||
# Clean up stale pending requests once in a while
|
|
||||||
0 2 * * * easywks /usr/bin/easywks clean
|
|
|
@ -1,34 +1,16 @@
|
||||||
---
|
---
|
||||||
# EasyWKS works inside this directory. Its PGP keys as well as all the
|
# EasyWKS works inside this directory. Its PGP keys as well
|
||||||
# submitted and published keys are stored here.
|
# as all the submitted and published keys are stored here.
|
||||||
#directory: /var/lib/easywks
|
#directory: /var/lib/easywks
|
||||||
|
# Number of seconds after which a pending submission request
|
||||||
# Number of seconds after which a pending submission request is
|
# is 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
|
|
||||||
# older version of the WKS standard where signing the confirmation
|
|
||||||
# response is only recommended, but not required. Set this option to
|
|
||||||
# true if you want to accept such unsigned responses.
|
|
||||||
#
|
|
||||||
# This option is deprecated and will be removed in a future release.
|
|
||||||
# It is replaced by the me.s3lph.easywks_permit-unsigned-response
|
|
||||||
# per-domain policy flag.
|
|
||||||
#permit_unsigned_response: false
|
|
||||||
|
|
||||||
# Port configuration for the webserver. Put this behind a
|
# Port configuration for the webserver. Put this behind a
|
||||||
# HTTPS-terminating reverse proxy!
|
# HTTPS-terminating reverse proxy!
|
||||||
httpd:
|
host: "::1"
|
||||||
host: "::1"
|
port: 8080
|
||||||
port: 8080
|
|
||||||
# Some older HTTP clients omit the ?l=<userid> query suffix. Set
|
|
||||||
# this to false in order to permit such clients to retrieve keys.
|
|
||||||
#require_user_urlparam: true
|
|
||||||
|
|
||||||
# Defaults to stdout, supported: stdout, smtp
|
# Defaults to stdout, supported: stdout, smtp
|
||||||
mailing_method: smtp
|
mailing_method: smtp
|
||||||
|
|
||||||
# Configure smtp client options
|
# Configure smtp client options
|
||||||
smtp:
|
smtp:
|
||||||
# Connect to this SMTP server to send a mail.
|
# Connect to this SMTP server to send a mail.
|
||||||
|
@ -40,95 +22,19 @@ smtp:
|
||||||
# Omit username/password if authentication is not needed.
|
# Omit username/password if authentication is not needed.
|
||||||
#username: webkey
|
#username: webkey
|
||||||
#password: SuperS3curePassword123
|
#password: SuperS3curePassword123
|
||||||
|
|
||||||
# Configure the LMTP server
|
# Configure the LMTP server
|
||||||
lmtpd:
|
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.
|
|
||||||
# The following templates can be overridden:
|
|
||||||
# - "header": Placed in front of every message.
|
|
||||||
# - "footer": Appended to every message.
|
|
||||||
# - "confirm": Sent with the confirmation request.
|
|
||||||
# - "done": Sent after a key was published.
|
|
||||||
# - "error": Sent when an error occurs.
|
|
||||||
# The following placeholders can be used (enclosed in curly braces):
|
|
||||||
# - {domain}: The email domain for with the request is processed.
|
|
||||||
# - {sender}: The submitter's mail address.
|
|
||||||
# - {submission}: The submission address.
|
|
||||||
# When overriding the "error" template, theres an additional
|
|
||||||
# placeholder you can use:
|
|
||||||
# - {error}: The error message.
|
|
||||||
#responses:
|
|
||||||
# error: |
|
|
||||||
# An error has occurred while processing your request:
|
|
||||||
#
|
|
||||||
# {error}
|
|
||||||
#
|
|
||||||
# If this error persists, please contact admin@example.org for help.
|
|
||||||
|
|
||||||
# Every domain served by EasyWKS must be listed here
|
# Every domain served by EasyWKS must be listed here
|
||||||
domains:
|
domains:
|
||||||
# Defaults are gpgwks@<domain> and no password protection.
|
# Defaults are gpgwks@<domain> and no password protection.
|
||||||
example.org:
|
example.org:
|
||||||
# Users send their requests to this address. It's up to you to
|
# Users send their requests to this address. It's up to
|
||||||
# make sure that the mails sent their get handed to EasyWKS.
|
# you to make sure that the mails sent their get handed
|
||||||
|
# to EasyWKS.
|
||||||
submission_address: webkey@example.org
|
submission_address: webkey@example.org
|
||||||
# If you want the PGP key for this domain to be password-protected,
|
# If you want the PGP key for this domain to be
|
||||||
# or if you're supplying your own password-protected key, set the
|
# password-protected, or if you're supplying your own
|
||||||
# passphrase here:
|
# password-protected key, set the passphrase here:
|
||||||
#passphrase: "Correct Horse Battery Staple"
|
#passphrase: "Correct Horse Battery Staple"
|
||||||
# Policy flags control behavior of the submission process for this
|
|
||||||
# domain. Supports most of the standard policy flags (see
|
|
||||||
# https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-15#section-4.5
|
|
||||||
# for details) as well as some EasyWKS-namespaced flags.
|
|
||||||
policy_flags:
|
|
||||||
|
|
||||||
# The mail server provider does only accept keys with only a
|
|
||||||
# mailbox in the User ID. In particular User IDs with a real name
|
|
||||||
# in addition to the mailbox will be rejected as invalid.
|
|
||||||
#mailbox-only: false
|
|
||||||
|
|
||||||
# The submission of the mail to the server is done using an
|
|
||||||
# authenticated connection. Thus the submitted key will be
|
|
||||||
# published immediately without any confirmation request.
|
|
||||||
#auth-submit: false
|
|
||||||
|
|
||||||
# This keyword can be used to explicitly claim the support of a
|
|
||||||
# specific version of the Web Key Directory update protocol. This
|
|
||||||
# is in general not needed but implementations may have
|
|
||||||
# workarounds for providers which only support an old protocol
|
|
||||||
# version. If these providers update to a newer version they
|
|
||||||
# should add this keyword so that the implementation can disable
|
|
||||||
# the workaround. The value is an integer corresponding to the
|
|
||||||
# respective draft revision number.
|
|
||||||
#protocol-version: null
|
|
||||||
|
|
||||||
# Some clients (including recent versions of gpg-wks-client
|
|
||||||
# follow an older version of the WKS standard where signing the
|
|
||||||
# confirmation response is only recommended, but not required.
|
|
||||||
# Set this option to true if you want to accept such unsigned
|
|
||||||
# responses.
|
|
||||||
#me.s3lph.easywks_permit-unsigned-response: false
|
|
||||||
|
|
||||||
# Remove all UIDs except the one being verified.
|
|
||||||
#me.s3lph.easywks_strip-unverified-uids: false
|
|
||||||
|
|
||||||
# Remove user attribute (i.e. photo) UIDs.
|
|
||||||
#me.s3lph.easywks_strip-ua-uids: false
|
|
||||||
|
|
||||||
# Remove all third party certifications fom the submitted keys.
|
|
||||||
# Certifications issued by revoked keys submitted in the same
|
|
||||||
# submission request are exempt from this policy.
|
|
||||||
#me.s3lph.easywks_strip-3rdparty-signatures: false
|
|
||||||
|
|
||||||
# Maximal number of revoked keys that can be submitted alongside
|
|
||||||
# a valid key. If this flag is absent or has value -1, an
|
|
||||||
# unlimited number of revoked keys is permitted.
|
|
||||||
#me.s3lph.easywks_max-revoked-keys: -1
|
|
|
@ -1,13 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=OpenPGP WKS for Human Beings - DANE DNS Server
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/easywks dnsd
|
|
||||||
Restart=on-failure
|
|
||||||
User=easywks
|
|
||||||
Group=easywks
|
|
||||||
WorkingDirectory=/var/lib/easywks
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
|
@ -7,10 +7,6 @@ smtp:
|
||||||
lmtpd:
|
lmtpd:
|
||||||
host: "::"
|
host: "::"
|
||||||
port: 24
|
port: 24
|
||||||
httpd:
|
host: "::"
|
||||||
host: "::"
|
port: 80
|
||||||
port: 80
|
|
||||||
dnsd:
|
|
||||||
host: "::"
|
|
||||||
port: 53
|
|
||||||
domains: {}
|
domains: {}
|
||||||
|
|
13
setup.py
13
setup.py
|
@ -7,27 +7,18 @@ setup(
|
||||||
name='easywks',
|
name='easywks',
|
||||||
version=__version__,
|
version=__version__,
|
||||||
author='s3lph',
|
author='s3lph',
|
||||||
author_email='s3lph@kabelsalat.ch',
|
author_email='account-gitlab-ideynizv@kernelpanic.lol',
|
||||||
description='OpenPGP WKS for Human Beings',
|
description='OpenPGP WKS for Human Beings',
|
||||||
license='MIT',
|
license='MIT',
|
||||||
keywords='pgp,wks',
|
keywords='pgp,wks',
|
||||||
url='https://git.kabelsalat.ch/s3lph/easywks',
|
url='https://gitlab.com/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'
|
||||||
|
|
|
@ -1,24 +0,0 @@
|
||||||
ServerName example.org
|
|
||||||
|
|
||||||
<VirtualHost *:80>
|
|
||||||
ServerName example.org
|
|
||||||
ServerAlias openpgpkey.example.org
|
|
||||||
ServerAlias openpgpkey
|
|
||||||
DocumentRoot /var/www/html
|
|
||||||
RewriteEngine On
|
|
||||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI}
|
|
||||||
</VirtualHost>
|
|
||||||
|
|
||||||
<VirtualHost *:443>
|
|
||||||
ServerName example.org
|
|
||||||
ServerAlias openpgpkey.example.org
|
|
||||||
ServerAlias openpgpkey
|
|
||||||
DocumentRoot /var/www/html
|
|
||||||
|
|
||||||
SSLEngine On
|
|
||||||
SSLCertificateFile /etc/ssl/cert.pem
|
|
||||||
SSLCertificateKeyFile /etc/ssl/key.pem
|
|
||||||
|
|
||||||
ProxyPass /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
|
|
||||||
ProxyPassReverse /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
|
|
||||||
</VirtualHost>
|
|
|
@ -1,22 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<clientConfig version="1.1">
|
|
||||||
<emailProvider id="example.org">
|
|
||||||
<domain>example.org</domain>
|
|
||||||
<displayName>EasyWKS Example</displayName>
|
|
||||||
<displayShortName>Example</displayShortName>
|
|
||||||
<incomingServer type="imap">
|
|
||||||
<hostname>example.org</hostname>
|
|
||||||
<port>993</port>
|
|
||||||
<socketType>SSL</socketType>
|
|
||||||
<authentication>password-cleartext</authentication>
|
|
||||||
<username>%EMAILLOCALPART%</username>
|
|
||||||
</incomingServer>
|
|
||||||
<outgoingServer type="smtp">
|
|
||||||
<hostname>example.org</hostname>
|
|
||||||
<port>25</port>
|
|
||||||
<socketType>STARTTLS</socketType>
|
|
||||||
<authentication>password-cleartext</authentication>
|
|
||||||
<username>%EMAILLOCALPART%</username>
|
|
||||||
</outgoingServer>
|
|
||||||
</emailProvider>
|
|
||||||
</clientConfig>
|
|
|
@ -1,8 +0,0 @@
|
||||||
service auth {
|
|
||||||
unix_listener /var/spool/postfix/private/auth {
|
|
||||||
mode = 0666
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ssl_cert = </etc/ssl/cert.pem
|
|
||||||
ssl_key = </etc/ssl/key.pem
|
|
||||||
log_path = /dev/stderr
|
|
|
@ -1,13 +0,0 @@
|
||||||
directory: /tmp/easywks
|
|
||||||
httpd:
|
|
||||||
host: 127.0.0.1
|
|
||||||
port: 8080
|
|
||||||
lmtpd:
|
|
||||||
host: 127.0.0.1
|
|
||||||
port: 8024
|
|
||||||
mailing_method: smtp
|
|
||||||
domains:
|
|
||||||
example.org:
|
|
||||||
submission_address: webkey@example.org
|
|
||||||
policy_flags:
|
|
||||||
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat
|
|
19
test/expect
19
test/expect
|
@ -1,19 +0,0 @@
|
||||||
#!/usr/bin/expect -f
|
|
||||||
spawn ./client.py
|
|
||||||
expect "Enter email: "
|
|
||||||
send "alice@example.org\n"
|
|
||||||
expect "Chose $env(FINGERPRINT)"
|
|
||||||
expect "Enter IMAP/POP3/SMTP password (will not echo): "
|
|
||||||
send "supersecurepassword\n"
|
|
||||||
expect "Autoconfigured incoming server"
|
|
||||||
expect "Autoconfigured outgoing server"
|
|
||||||
expect "Please confirm: \[Y/n\] "
|
|
||||||
send "y\n"
|
|
||||||
expect "Sending submission request"
|
|
||||||
expect "Awaiting response"
|
|
||||||
expect "Received confirmation request"
|
|
||||||
expect "Creating confirmation response."
|
|
||||||
expect "Sending confirmation response"
|
|
||||||
expect "Awaiting publish response"
|
|
||||||
expect "Your key has been published to the Web Key Directory."
|
|
||||||
expect eof
|
|
|
@ -1,24 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
cat >/tmp/keygen <<EOF
|
|
||||||
%no-protection
|
|
||||||
%no-ask-passphrase
|
|
||||||
%transient-key
|
|
||||||
Key-Type: EDDSA
|
|
||||||
Key-Curve: ed25519
|
|
||||||
Subkey-Type: ECDH
|
|
||||||
Subkey-Curve: cv25519
|
|
||||||
Expire-Date: 0
|
|
||||||
Name-Real: EasyWKS Test User
|
|
||||||
Name-Comment: TEST KEY DO NOT USE
|
|
||||||
Name-Email: ${1}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
gpg --batch --full-gen-key /tmp/keygen
|
|
||||||
for uid in $@; do
|
|
||||||
gpg --batch --quick-add-uid "${1}" "EasyWKS Test User (TEST KEY DO NOT USE) <${uid}>"
|
|
||||||
done
|
|
||||||
gpg --export --armor "${1}" > "/tmp/${1}.asc"
|
|
||||||
for uid in $@; do
|
|
||||||
gpg --export --armor "${uid}" > "/tmp/${uid}.asc"
|
|
||||||
done
|
|
|
@ -1 +0,0 @@
|
||||||
webkey@example.org lmtp:[127.0.0.1]:8024
|
|
Loading…
Reference in a new issue