Compare commits
No commits in common. "main" and "v0.1.11" have entirely different histories.
31 changed files with 212 additions and 1200 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
|
|
115
.gitlab-ci.yml
Normal file
115
.gitlab-ci.yml
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
---
|
||||||
|
image: python:3.9-bullseye
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
- upload
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
- pip3 install coverage pycodestyle
|
||||||
|
- export EASYWKS_VERSION=$(python -c 'import easywks; print(easywks.__version__)')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- pip3 install -e .
|
||||||
|
- python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
|
||||||
|
- python3 -m coverage combine
|
||||||
|
- python3 -m coverage report --rcfile=setup.cfg
|
||||||
|
|
||||||
|
codestyle:
|
||||||
|
stage: test
|
||||||
|
script:
|
||||||
|
- pip3 install -e .
|
||||||
|
- pycodestyle easywks
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# currently not working for some reason
|
||||||
|
#build_docker:
|
||||||
|
# stage: build
|
||||||
|
# script:
|
||||||
|
# - apt update && apt install --yes docker.io
|
||||||
|
# - docker build -t "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" -f package/docker/Dockerfile .
|
||||||
|
# - docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
|
||||||
|
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
|
||||||
|
# - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.gitlab.com
|
||||||
|
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA"
|
||||||
|
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
|
||||||
|
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
|
||||||
|
# only:
|
||||||
|
# - staging
|
||||||
|
# - tags
|
||||||
|
|
||||||
|
build_wheel:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- python3 setup.py egg_info bdist_wheel
|
||||||
|
- cd dist
|
||||||
|
- sha256sum *.whl > SHA256SUMS
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- "dist/*.whl"
|
||||||
|
- dist/SHA256SUMS
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
|
||||||
|
build_debian:
|
||||||
|
stage: build
|
||||||
|
script:
|
||||||
|
- apt update && apt install --yes lintian rsync sudo
|
||||||
|
- echo -n > package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
- |
|
||||||
|
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
|
||||||
|
echo "easywks (${version}-1); urgency=medium\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
done
|
||||||
|
- gzip -9n package/debian/easywks/usr/share/doc/easywks/changelog
|
||||||
|
- python3 setup.py egg_info install --root=package/debian/easywks/ --prefix=/usr --optimize=1
|
||||||
|
- cd package/debian
|
||||||
|
- sed -re "s/__EASYWKS_VERSION__/${EASYWKS_VERSION}/g" -i easywks/DEBIAN/control
|
||||||
|
- mkdir -p easywks/usr/lib/python3/dist-packages/
|
||||||
|
- rsync -a easywks/usr/lib/python3.9/site-packages/ easywks/usr/lib/python3/dist-packages/
|
||||||
|
- rm -rf easywks/usr/lib/python3.9/site-packages
|
||||||
|
- find easywks/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
|
||||||
|
- find easywks/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
|
||||||
|
- find easywks/usr/lib/python3/dist-packages -name '*.pyo' -exec rm {} \;
|
||||||
|
- sed -re 's$#!/usr/local/bin/python3$#!/usr/bin/python3$' -i easywks/usr/bin/easywks
|
||||||
|
- find easywks -type f -exec chmod 0644 {} \;
|
||||||
|
- find easywks -type d -exec chmod 755 {} \;
|
||||||
|
- chmod +x easywks/usr/bin/easywks easywks/DEBIAN/postinst easywks/DEBIAN/prerm easywks/DEBIAN/postrm
|
||||||
|
- dpkg-deb --build easywks
|
||||||
|
- mv easywks.deb "easywks_${EASYWKS_VERSION}-1_all.deb"
|
||||||
|
- sudo -u nobody lintian "easywks_${EASYWKS_VERSION}-1_all.deb"
|
||||||
|
- sha256sum *.deb > SHA256SUMS
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- "package/debian/*.deb"
|
||||||
|
- package/debian/SHA256SUMS
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
|
||||||
|
|
||||||
|
release:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- python3 package/release.py
|
||||||
|
only:
|
||||||
|
- tags
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
repo:
|
||||||
|
stage: upload
|
||||||
|
trigger: s3lph/custom-packages
|
||||||
|
variables:
|
||||||
|
MULTIPROJECT_TRIGGER_JOBNAME: easywks
|
||||||
|
only:
|
||||||
|
- tags
|
137
CHANGELOG.md
137
CHANGELOG.md
|
@ -1,142 +1,5 @@
|
||||||
# EasyWKS Changelog
|
# EasyWKS Changelog
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.6 -->
|
|
||||||
## Version 0.4.6
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.6 -->
|
|
||||||
- Fix: Don't put multiple keys into a DANE record
|
|
||||||
<!-- END CHANGES 0.4.6-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.6 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.5 -->
|
|
||||||
## Version 0.4.5
|
|
||||||
|
|
||||||
Migrate from Woodpecker CI to Forgejo Actions
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.5 -->
|
|
||||||
- Migrate from Woodpecker CI to Forgejo Actions
|
|
||||||
<!-- END CHANGES 0.4.5-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.5 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.4 -->
|
|
||||||
## Version 0.4.4
|
|
||||||
|
|
||||||
smtpd: Log errors to stdout rather than SMTP session
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.4 -->
|
|
||||||
- smtpd: Log errors to stdout rather than SMTP session
|
|
||||||
<!-- END CHANGES 0.4.4-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.4 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.3 -->
|
|
||||||
## Version 0.4.3
|
|
||||||
|
|
||||||
Migrate from Gitlab to Forgejo
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.3 -->
|
|
||||||
- Migrate from Gitlab to Forgejo
|
|
||||||
<!-- END CHANGES 0.4.3-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.3 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.2 -->
|
|
||||||
## Version 0.4.2
|
|
||||||
|
|
||||||
Minor feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.2 -->
|
|
||||||
- Add option to provide DNS NOTIFY to DANE zone replicas
|
|
||||||
<!-- END CHANGES 0.4.2-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.2 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.1 -->
|
|
||||||
## Version 0.4.1
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.1 -->
|
|
||||||
- Don't refuse DNS IXFRs and respond with full AXFR
|
|
||||||
<!-- END CHANGES 0.4.1-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.1 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.4.0 -->
|
|
||||||
## Version 0.4.0
|
|
||||||
|
|
||||||
Feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.4.0 -->
|
|
||||||
- Add authoritative DNS server providing DANE OPENPGPKEY (TYPE61) DNS records
|
|
||||||
<!-- END CHANGES 0.4.0-->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.4.0 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.3.1 -->
|
|
||||||
## Version 0.3.1
|
|
||||||
|
|
||||||
Feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.3.1 -->
|
|
||||||
- Implement standard and EasyWKS-specific policy flags
|
|
||||||
- **Deprecation**: The top level option `permit_unsigned_response` is deprecated
|
|
||||||
and will be removed in a future release. Use the per-domain policy flag
|
|
||||||
`me.s3lph.easywks_permit-unsigned-response` instead.
|
|
||||||
<!-- END CHANGES 0.3.1 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.3.1 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.3.0 -->
|
|
||||||
## Version 0.3.0
|
|
||||||
|
|
||||||
Feature release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.3.0 -->
|
|
||||||
- Set CORS headers on HTTP responses
|
|
||||||
- Implement direct WKD URLs
|
|
||||||
- Allow submitting additional revoked keys with the submission request
|
|
||||||
<!-- END CHANGES 0.3.0 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.3.0 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.2.0 -->
|
|
||||||
## Version 0.2.0
|
|
||||||
|
|
||||||
Bugfix release
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
<!-- BEGIN CHANGES 0.2.0 -->
|
|
||||||
- Release pipeline runs integration test against gpg-wks-client
|
|
||||||
- Fix minor incompatibilities with gpg-wks-client
|
|
||||||
- Fix per-domain configuration (e.g. submission-address was not loaded)
|
|
||||||
<!-- END CHANGES 0.2.0 -->
|
|
||||||
|
|
||||||
<!-- END RELEASE v0.2.0 -->
|
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.1.11 -->
|
<!-- BEGIN RELEASE v0.1.11 -->
|
||||||
## Version 0.1.11
|
## Version 0.1.11
|
||||||
|
|
||||||
|
|
87
README.md
87
README.md
|
@ -7,7 +7,7 @@
|
||||||
## 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 +50,6 @@ gpg-wks-server to EasyWKS.
|
||||||
- PyYAML
|
- PyYAML
|
||||||
- bottle.py
|
- bottle.py
|
||||||
- PGPy
|
- PGPy
|
||||||
- dnspython (for DANE support)
|
|
||||||
- Twisted (for DANE support)
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
@ -118,11 +116,6 @@ lmtpd:
|
||||||
host: "::1"
|
host: "::1"
|
||||||
port: 8024
|
port: 8024
|
||||||
|
|
||||||
# Configure the authoritative DNS server for DANE zones
|
|
||||||
dnsd:
|
|
||||||
host: "::1"
|
|
||||||
port: 8053
|
|
||||||
|
|
||||||
# You can override the mail response templates with your own text.
|
# You can override the mail response templates with your own text.
|
||||||
# The following templates can be overridden:
|
# The following templates can be overridden:
|
||||||
# - "header": Placed in front of every message.
|
# - "header": Placed in front of every message.
|
||||||
|
@ -259,84 +252,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
|
## EasyWKS Client
|
||||||
|
|
||||||
The file `client.py` contains a self-contained WKS client, which
|
The file `client.py` contains a self-contained WKS client, which
|
||||||
|
|
55
client.py
55
client.py
|
@ -3,8 +3,7 @@
|
||||||
import abc
|
import abc
|
||||||
import time
|
import time
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
import time
|
|
||||||
import imaplib
|
import imaplib
|
||||||
import poplib
|
import poplib
|
||||||
import smtplib
|
import smtplib
|
||||||
|
@ -398,7 +397,7 @@ def _parse_confirmation_request(address, fingerprint, encrypted):
|
||||||
return rdict['sender'], rdict['nonce']
|
return rdict['sender'], rdict['nonce']
|
||||||
|
|
||||||
|
|
||||||
def _create_submission_request(address: str, submission_address: str, fingerprint: str, revoked_fingerprints):
|
def _create_submission_request(address: str, fingerprint: str, submission_address: str):
|
||||||
gpg = subprocess.Popen([
|
gpg = subprocess.Popen([
|
||||||
'/usr/bin/gpg', '--locate-keys', '--with-colons', submission_address
|
'/usr/bin/gpg', '--locate-keys', '--with-colons', submission_address
|
||||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
@ -410,7 +409,7 @@ def _create_submission_request(address: str, submission_address: str, fingerprin
|
||||||
'/usr/bin/gpg', '--armor',
|
'/usr/bin/gpg', '--armor',
|
||||||
'--export-options', 'export-minimal',
|
'--export-options', 'export-minimal',
|
||||||
'--export', fingerprint
|
'--export', fingerprint
|
||||||
] + revoked_fingerprints, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
gpg.wait()
|
gpg.wait()
|
||||||
if gpg.returncode != 0:
|
if gpg.returncode != 0:
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||||
|
@ -439,7 +438,7 @@ def _create_submission_request(address: str, submission_address: str, fingerprin
|
||||||
mail['Subject'] = 'WKS submission request'
|
mail['Subject'] = 'WKS submission request'
|
||||||
mail['To'] = submission_address
|
mail['To'] = submission_address
|
||||||
mail['From'] = address
|
mail['From'] = address
|
||||||
mail['Date'] = format_datetime(datetime.now(timezone.utc))
|
mail['Date'] = format_datetime(datetime.utcnow())
|
||||||
return mail
|
return mail
|
||||||
|
|
||||||
|
|
||||||
|
@ -468,7 +467,7 @@ def _create_confirmation_response(address: str, submission: str, nonce: str, fp:
|
||||||
mail['Subject'] = 'WKS confirmation response'
|
mail['Subject'] = 'WKS confirmation response'
|
||||||
mail['To'] = submission
|
mail['To'] = submission
|
||||||
mail['From'] = address
|
mail['From'] = address
|
||||||
mail['Date'] = format_datetime(datetime.now(timezone.utc))
|
mail['Date'] = format_datetime(datetime.utcnow())
|
||||||
return mail
|
return mail
|
||||||
|
|
||||||
|
|
||||||
|
@ -530,40 +529,26 @@ def _gpg_get_uid_fp(address: str):
|
||||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||||
keylist = gpg.stdout.read().decode()
|
keylist = gpg.stdout.read().decode()
|
||||||
pubs = []
|
pubs = []
|
||||||
revoked = []
|
|
||||||
fprs = []
|
fprs = []
|
||||||
for line in keylist.splitlines():
|
for line in keylist.splitlines():
|
||||||
if line.startswith('pub:'):
|
if line.startswith('pub:'):
|
||||||
pub = line.split(':')[4]
|
pub = line.split(':')[4]
|
||||||
r = line.split(':')[1] == 'r'
|
|
||||||
pubs.append(pub)
|
pubs.append(pub)
|
||||||
revoked.append(r)
|
|
||||||
elif line.startswith('fpr:'):
|
elif line.startswith('fpr:'):
|
||||||
fpr = line.split(':')[9]
|
fpr = line.split(':')[9]
|
||||||
fprs.append(fpr)
|
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]}
|
if len(pubs) == 0:
|
||||||
revoked = {next((f for f in fprs if f.endswith(pub))): pub for i, pub in enumerate(pubs) if revoked[i]}
|
raise ValueError(f'No key found for {address}.')
|
||||||
if len(valid) == 0:
|
elif len(pubs) > 1:
|
||||||
raise ValueError(f'No valid key found for {address}.')
|
|
||||||
elif len(valid) > 1:
|
|
||||||
print(f'Found multiple keys for {address}, please choose:')
|
print(f'Found multiple keys for {address}, please choose:')
|
||||||
fpridx = list(valid.keys())
|
for i, pub in enumerate(pubs, start=1):
|
||||||
for i, f in enumerate(fpridx, start=1):
|
print(f'{i}: {pub}')
|
||||||
print(f'{i}: {f}')
|
|
||||||
i = int(input('Enter number: ')) - 1
|
i = int(input('Enter number: ')) - 1
|
||||||
fpr = fpridx[i]
|
|
||||||
else:
|
else:
|
||||||
fpr = list(valid.keys())[0]
|
i = 0
|
||||||
if len(revoked) > 0:
|
pub = pubs[i]
|
||||||
print(f'There are revoked keys for {address}. Please choose which to upload (separate multiple by spaces): ')
|
fpr = next(filter(lambda x: x.endswith(pub), fprs))
|
||||||
revidx = list(revoked.keys())
|
return fpr
|
||||||
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):
|
def _get_submission_address(address: str):
|
||||||
|
@ -587,10 +572,8 @@ def main():
|
||||||
except urllib.error.URLError:
|
except urllib.error.URLError:
|
||||||
print('No WKS submission address found. Does your provider support WKS?')
|
print('No WKS submission address found. Does your provider support WKS?')
|
||||||
exit(1)
|
exit(1)
|
||||||
fp, rfprs = _gpg_get_uid_fp(ad)
|
fp = _gpg_get_uid_fp(ad)
|
||||||
print(f'Chose {fp}')
|
print(f'Chose {fp}')
|
||||||
for rfpr in rfprs:
|
|
||||||
print(f'Chose revoked key {rfpr}')
|
|
||||||
pw = getpass('Enter IMAP/POP3/SMTP password (will not echo): ')
|
pw = getpass('Enter IMAP/POP3/SMTP password (will not echo): ')
|
||||||
for fn in [tb_wellknown_autoconfig, rfc6186_autoconfig, tb_ispdb_autoconfig, manual_config]:
|
for fn in [tb_wellknown_autoconfig, rfc6186_autoconfig, tb_ispdb_autoconfig, manual_config]:
|
||||||
autoconf = fn(ad, pw)
|
autoconf = fn(ad, pw)
|
||||||
|
@ -606,7 +589,6 @@ def main():
|
||||||
try:
|
try:
|
||||||
with i:
|
with i:
|
||||||
incoming_server = i
|
incoming_server = i
|
||||||
break
|
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
if incoming_server is None:
|
if incoming_server is None:
|
||||||
|
@ -616,7 +598,6 @@ def main():
|
||||||
try:
|
try:
|
||||||
with i:
|
with i:
|
||||||
outgoing_server = i
|
outgoing_server = i
|
||||||
break
|
|
||||||
except:
|
except:
|
||||||
continue
|
continue
|
||||||
if outgoing_server is None:
|
if outgoing_server is None:
|
||||||
|
@ -627,14 +608,14 @@ def main():
|
||||||
print('Aborted')
|
print('Aborted')
|
||||||
exit(1)
|
exit(1)
|
||||||
with incoming_server:
|
with incoming_server:
|
||||||
now = time.monotonic()
|
now = datetime.utcnow()
|
||||||
done = False
|
done = False
|
||||||
request = _create_submission_request(ad, sa, fp, rfprs)
|
request = _create_submission_request(ad, fp, sa)
|
||||||
print('Sending submission request')
|
print('Sending submission request')
|
||||||
with outgoing_server:
|
with outgoing_server:
|
||||||
outgoing_server.send_message(request)
|
outgoing_server.send_message(request)
|
||||||
print('Awaiting response')
|
print('Awaiting response')
|
||||||
while not done and time.monotonic() - now < 300:
|
while not done and (datetime.utcnow() - now).total_seconds() < 300:
|
||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
for message in incoming_server.get_new_messages():
|
for message in incoming_server.get_new_messages():
|
||||||
done = handle_incoming_message(ad, fp, message, outgoing_server)
|
done = handle_incoming_message(ad, fp, message, outgoing_server)
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
__version__ = '0.4.6'
|
__version__ = '0.1.11'
|
||||||
|
|
|
@ -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:
|
||||||
|
@ -92,15 +65,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,46 +77,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):
|
def _validate_responses(value):
|
||||||
|
@ -249,10 +173,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,14 +194,13 @@ 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'),
|
||||||
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
|
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
|
||||||
mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method),
|
mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method),
|
||||||
# TODO: permit_unsigned_response is deprecated, but for now retained for backwards compatibility
|
|
||||||
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
|
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
|
||||||
httpd=_ConfigOption('httpd', dict, {
|
httpd=_ConfigOption('httpd', dict, {
|
||||||
'host': 'localhost',
|
'host': 'localhost',
|
||||||
|
@ -295,10 +217,6 @@ Config = _GlobalConfig(
|
||||||
'host': 'localhost',
|
'host': 'localhost',
|
||||||
'port': 25,
|
'port': 25,
|
||||||
}, validator=_validate_lmtpd_config),
|
}, validator=_validate_lmtpd_config),
|
||||||
dnsd=_ConfigOption('dnsd', dict, {
|
|
||||||
'host': '::1',
|
|
||||||
'port': 10053,
|
|
||||||
}, validator=_validate_dnsd_config),
|
|
||||||
responses=_ConfigOption('responses', dict, {}, validator=_validate_responses),
|
responses=_ConfigOption('responses', dict, {}, validator=_validate_responses),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
|
|
@ -8,7 +8,7 @@ from pgpy import PGPKey
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .crypto import create_pgp_key, privkey_to_pubkey
|
from .crypto import create_pgp_key, privkey_to_pubkey
|
||||||
from .util import hash_user_id, armor_keys, split_revoked, dane_digest, dane_notify
|
from .util import hash_user_id
|
||||||
|
|
||||||
|
|
||||||
def _locked_read(file: str, binary: bool = False):
|
def _locked_read(file: str, binary: bool = False):
|
||||||
|
@ -36,15 +36,10 @@ def make_submission_address_file(domain: str):
|
||||||
def make_policy_file(domain: str):
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -58,62 +53,42 @@ def init_working_directory():
|
||||||
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
|
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
|
||||||
_locked_write(os.path.join(wdir, domain, 'submission-address'), make_submission_address_file(domain))
|
_locked_write(os.path.join(wdir, domain, 'submission-address'), make_submission_address_file(domain))
|
||||||
_locked_write(os.path.join(wdir, domain, 'policy'), make_policy_file(domain))
|
_locked_write(os.path.join(wdir, domain, 'policy'), make_policy_file(domain))
|
||||||
os.makedirs(os.path.join(wdir, domain, 'dane'), exist_ok=True)
|
|
||||||
# Create PGP key if it doesn't exist yet
|
# Create PGP key if it doesn't exist yet
|
||||||
create_pgp_key(domain)
|
create_pgp_key(domain)
|
||||||
# Export submission key to hu dir
|
# Export submission key to hu dir
|
||||||
key = privkey_to_pubkey(domain)
|
key = privkey_to_pubkey(domain)
|
||||||
uid = hash_user_id(Config[domain].submission_address)
|
uid = hash_user_id(Config[domain].submission_address)
|
||||||
_locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True)
|
_locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True)
|
||||||
digest = dane_digest(Config[domain].submission_address)
|
|
||||||
_locked_write(os.path.join(wdir, domain, 'dane', digest), bytes(key), binary=True)
|
|
||||||
dane_notify(domain)
|
|
||||||
|
|
||||||
|
|
||||||
def read_public_key(domain, user):
|
def read_public_key(domain, user):
|
||||||
return read_hashed_public_key(domain, hash_user_id(user))
|
hu = hash_user_id(user)
|
||||||
|
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||||
|
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
def read_hashed_public_key(domain, hu):
|
def read_hashed_public_key(domain, hu):
|
||||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||||
_, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True))
|
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
|
||||||
key, revoked = split_revoked(keys.values())
|
return key
|
||||||
return key, revoked
|
|
||||||
|
|
||||||
|
|
||||||
def read_dane_public_keys(domain):
|
def write_public_key(domain, user, key):
|
||||||
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)
|
_locked_write(keyfile, bytes(key), binary=True)
|
||||||
joined = bytes(key) + b''.join([bytes(k) for k in revoked])
|
|
||||||
_locked_write(keyfile, joined, binary=True)
|
|
||||||
_locked_write(danefile, bytes(key), binary=True)
|
|
||||||
dane_notify(domain)
|
|
||||||
|
|
||||||
|
|
||||||
def read_pending_key(domain, nonce):
|
def read_pending_key(domain, nonce):
|
||||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||||
_, keys = PGPKey.from_blob(_locked_read(keyfile))
|
key, _ = PGPKey.from_blob(_locked_read(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)
|
_locked_write(keyfile, str(key))
|
||||||
_locked_write(keyfile, armored)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_pending_key(domain, nonce):
|
def remove_pending_key(domain, nonce):
|
||||||
|
|
|
@ -3,55 +3,27 @@ from .config import Config
|
||||||
from .files import read_hashed_public_key, make_submission_address_file, make_policy_file
|
from .files import read_hashed_public_key, make_submission_address_file, make_policy_file
|
||||||
from .util import hash_user_id
|
from .util import hash_user_id
|
||||||
|
|
||||||
from bottle import get, run, response, request, HTTPError
|
from bottle import get, run, abort, response, request
|
||||||
|
|
||||||
|
|
||||||
CORS = ('Access-Control-Allow-Origin', '*')
|
|
||||||
|
|
||||||
|
|
||||||
def abort(code, text):
|
|
||||||
err = HTTPError(code, text)
|
|
||||||
err.add_header(*CORS)
|
|
||||||
return err
|
|
||||||
|
|
||||||
|
|
||||||
def get_domain_header():
|
|
||||||
domain = request.get_header('host')
|
|
||||||
if len(domain) != 1 or domain[0] is None:
|
|
||||||
abort(400, 'Bad Request')
|
|
||||||
return domain[0]
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
||||||
def advanced_submission_address(domain: str):
|
def submission_address(domain: str):
|
||||||
if domain not in Config.domains:
|
if domain not in Config.domains:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
response.add_header('Content-Type', 'text/plain')
|
response.add_header('Content-Type', 'text/plain')
|
||||||
response.add_header(*CORS)
|
|
||||||
return make_submission_address_file(domain)
|
return make_submission_address_file(domain)
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/submission-address')
|
|
||||||
def direct_submission_address():
|
|
||||||
return advanced_submission_address(get_domain_header())
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/<domain>/policy')
|
@get('/.well-known/openpgpkey/<domain>/policy')
|
||||||
def advanced_policy(domain: str):
|
def policy(domain: str):
|
||||||
if domain not in Config.domains:
|
if domain not in Config.domains:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
response.add_header('Content-Type', 'text/plain')
|
response.add_header('Content-Type', 'text/plain')
|
||||||
response.add_header(*CORS)
|
|
||||||
return make_policy_file(domain)
|
return make_policy_file(domain)
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/policy')
|
|
||||||
def direct_policy():
|
|
||||||
return advanced_policy(get_domain_header())
|
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
|
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
|
||||||
def advanced_hu(domain: str, userhash: str):
|
def hu(domain: str, userhash: str):
|
||||||
if domain not in Config.domains:
|
if domain not in Config.domains:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
if Config.httpd['require_user_urlparam']:
|
if Config.httpd['require_user_urlparam']:
|
||||||
|
@ -59,22 +31,16 @@ def advanced_hu(domain: str, userhash: str):
|
||||||
if not userid or hash_user_id(userid) != userhash:
|
if not userid or hash_user_id(userid) != userhash:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
try:
|
try:
|
||||||
pubkey, revoked = read_hashed_public_key(domain, userhash)
|
pubkey = read_hashed_public_key(domain, userhash)
|
||||||
response.add_header('Content-Type', 'application/octet-stream')
|
response.add_header('Content-Type', 'application/octet-stream')
|
||||||
response.add_header(*CORS)
|
return bytes(pubkey)
|
||||||
return bytes(pubkey[0]) + b''.join([bytes(k) for k in revoked])
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
abort(404, 'Not Found')
|
abort(404, 'Not Found')
|
||||||
|
|
||||||
|
|
||||||
@get('/.well-known/openpgpkey/hu/<userhash>')
|
|
||||||
def direct_hu(userhash: str):
|
|
||||||
return advanced_hu(get_domain_header(), userhash)
|
|
||||||
|
|
||||||
|
|
||||||
def run_server(args):
|
def run_server(args):
|
||||||
run(host=Config.httpd['host'], port=Config.httpd['port'])
|
run(host=Config.httpd['host'], port=Config.httpd['port'])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
run_server(None)
|
run_server()
|
||||||
|
|
|
@ -19,9 +19,9 @@ class LmtpMailServer:
|
||||||
process_mail(message)
|
process_mail(message)
|
||||||
except EasyWksError as e:
|
except EasyWksError as e:
|
||||||
return f'550 {e}'
|
return f'550 {e}'
|
||||||
except 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'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ 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, process_key_from_stdin
|
||||||
from .httpd import run_server
|
from .httpd import run_server
|
||||||
from .lmtpd import run_lmtpd
|
from .lmtpd import run_lmtpd
|
||||||
from .dnsd import run_dnsd
|
|
||||||
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
@ -33,9 +32,6 @@ def parse_arguments():
|
||||||
server = sp.add_parser('lmtpd', help='Run a LMTP server to receive mails from your MTA. Also see process.')
|
server = 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 = 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',
|
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.')
|
help='Limit import to a subset of the key\'s UIDs. Can be provided multiple times.')
|
||||||
|
|
|
@ -1,23 +1,20 @@
|
||||||
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 .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 +46,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. Only submit a single key at once.')
|
||||||
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 found in the encrypted message part.')
|
||||||
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:
|
||||||
|
@ -136,62 +120,6 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
|
||||||
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
|
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
|
||||||
|
|
||||||
|
|
||||||
# There is no API for directly removing a PGPUID or PGPSignature object
|
|
||||||
# noinspection PyProtectedMember
|
|
||||||
def _apply_submission_policy(request: SubmissionRequest, policy: Dict[str, Any]):
|
|
||||||
# Policy: Only permit a certain amount of revoked keys
|
|
||||||
maxrevoke = policy.get(EWP_MAX_REVOKED_KEYS, -1)
|
|
||||||
if maxrevoke > -1 and len(request.revoked_keys) > maxrevoke:
|
|
||||||
raise EasyWksError(f'Submission request contains {len(request.revoked_keys)} revoked keys. '
|
|
||||||
f'Policy permits not more than {maxrevoke}')
|
|
||||||
uid: PGPUID
|
|
||||||
revfprs = [fingerprint(request.key)] + [fingerprint(rk) for rk in request.revoked_keys]
|
|
||||||
for key in [request.key] + request.revoked_keys:
|
|
||||||
# Policy: Strip user attribute (image) UIDs
|
|
||||||
if policy.get(POLICY_MAILBOX_ONLY, False) or policy.get(EWP_STRIP_UA_UIDS, False):
|
|
||||||
for uid in list(key.userattributes):
|
|
||||||
uid._parent = None
|
|
||||||
key._uids.remove(uid)
|
|
||||||
# Policy: Reject keys as invalid if they contain UIDs with non-empty name or comment parts
|
|
||||||
if policy.get(POLICY_MAILBOX_ONLY, False):
|
|
||||||
for uid in list(key.userids):
|
|
||||||
if uid.email == '' or uid.name != '' or uid.comment != '':
|
|
||||||
raise EasyWksError('This WKS server only accepts UIDs without name and comment parts')
|
|
||||||
# Policy: Strip all UIDs except the one being verified
|
|
||||||
if policy.get(EWP_STRIP_UNVERIFIED_UIDS, False):
|
|
||||||
for uid in list(key.userids):
|
|
||||||
if uid.email != request.submitter_address:
|
|
||||||
uid._parent = None
|
|
||||||
key._uids.remove(uid)
|
|
||||||
# Policy: Strip all 3rd party signatures from they key
|
|
||||||
if policy.get(EWP_STRIP_3RDPARTY_SIGNATURES, False):
|
|
||||||
for uid in list(key.userids):
|
|
||||||
for sig in list(uid.third_party_certifications):
|
|
||||||
# Keep signatures signed by the revoked keys
|
|
||||||
sig: PGPSignature
|
|
||||||
if sig.signer_fingerprint not in revfprs:
|
|
||||||
uid._signatures.remove(sig)
|
|
||||||
# Policy: Produce minimal transportable keys
|
|
||||||
if policy.get(EWP_MINIMIZE_REVOKED_KEYS, False):
|
|
||||||
for key in request.revoked_keys:
|
|
||||||
for uid in list(key.userids):
|
|
||||||
# Delete all but the submitter UIDs, and all 3rd party signatures
|
|
||||||
if uid.email != request.submitter_address:
|
|
||||||
uid._parent = None
|
|
||||||
key._uids.remove(uid)
|
|
||||||
else:
|
|
||||||
for sig in list(uid.third_party_certifications):
|
|
||||||
uid._signatures.remove(sig)
|
|
||||||
# Delete UAs
|
|
||||||
for uid in list(key.userattributes):
|
|
||||||
uid._parent = None
|
|
||||||
key._uids.remove(uid)
|
|
||||||
# Delete subkeys
|
|
||||||
for subkey in key._children.values():
|
|
||||||
subkey._parent = None
|
|
||||||
key._children.clear()
|
|
||||||
|
|
||||||
|
|
||||||
def process_mail(mail: bytes):
|
def process_mail(mail: bytes):
|
||||||
try:
|
try:
|
||||||
msg: Message = BytesParser(policy=default).parsebytes(mail)
|
msg: Message = BytesParser(policy=default).parsebytes(mail)
|
||||||
|
@ -224,29 +152,21 @@ def process_mail(mail: bytes):
|
||||||
if confirmation_response is not None:
|
if confirmation_response is not None:
|
||||||
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
|
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
|
||||||
try:
|
try:
|
||||||
key, 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
|
|
||||||
if not Config[sender_domain].policy_flags.get(EWP_PERMIT_UNSIGNED_RESPONSE, False) and \
|
|
||||||
not Config.permit_unsigned_response:
|
|
||||||
# this throws an error if signature verification fails
|
# this throws an error if signature verification fails
|
||||||
request.verify_signature(key)
|
request.verify_signature(key)
|
||||||
response: PublishResponse = request.get_publish_response(key)
|
response: PublishResponse = request.get_publish_response(key)
|
||||||
write_public_key(sender_domain, sender_mail, key, revoked_keys)
|
rmsg = response.create_signed_message()
|
||||||
|
write_public_key(sender_domain, sender_mail, key)
|
||||||
remove_pending_key(sender_domain, request.nonce)
|
remove_pending_key(sender_domain, request.nonce)
|
||||||
else:
|
else:
|
||||||
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
|
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
|
||||||
policy = Config[sender_domain].policy_flags
|
|
||||||
_apply_submission_policy(request, policy)
|
|
||||||
if policy.get(POLICY_AUTH_SUBMIT, False):
|
|
||||||
response = PublishResponse(request.submitter_address, request.submission_address, request.key)
|
|
||||||
write_public_key(sender_domain, sender_mail, request.key, request.revoked_keys)
|
|
||||||
else:
|
|
||||||
response: ConfirmationRequest = request.confirmation_request()
|
response: ConfirmationRequest = request.confirmation_request()
|
||||||
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys)
|
|
||||||
rmsg = response.create_signed_message()
|
rmsg = response.create_signed_message()
|
||||||
|
write_pending_key(sender_domain, response.nonce, request.key)
|
||||||
except EasyWksError as e:
|
except EasyWksError as e:
|
||||||
rmsg = e.create_message(sender_mail, submission_address)
|
rmsg = e.create_message(sender_mail, submission_address)
|
||||||
method = get_mailing_method(Config.mailing_method)
|
method = get_mailing_method(Config.mailing_method)
|
||||||
|
@ -280,5 +200,5 @@ def process_key_from_stdin(args):
|
||||||
print(f'Skipping foreign email {uid.email}')
|
print(f'Skipping foreign email {uid.email}')
|
||||||
continue
|
continue
|
||||||
# All checks passed, importing key
|
# All checks passed, importing key
|
||||||
write_public_key(domain, uid.email, pubkey, [])
|
write_public_key(domain, uid.email, pubkey)
|
||||||
print(f'Imported key {fingerprint(pubkey)} for email {uid.email}')
|
print(f'Imported key {fingerprint(pubkey)} for email {uid.email}')
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
|
|
||||||
from typing import List
|
from datetime import datetime
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from email.encoders import encode_noop
|
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
|
||||||
|
@ -13,7 +11,7 @@ from pgpy import PGPKey, PGPMessage, PGPUID
|
||||||
from pgpy.errors import PGPError
|
from pgpy.errors import PGPError
|
||||||
|
|
||||||
from .crypto import pgp_sign
|
from .crypto import pgp_sign
|
||||||
from .config import render_message
|
from .config import Config, render_message
|
||||||
from .util import create_nonce, fingerprint
|
from .util import create_nonce, fingerprint
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,11 +20,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,10 +40,6 @@ 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:
|
||||||
|
|
||||||
|
@ -96,18 +89,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
|
||||||
|
@ -145,9 +135,13 @@ class ConfirmationResponse:
|
||||||
|
|
||||||
def verify_signature(self, key: PGPKey):
|
def verify_signature(self, key: PGPKey):
|
||||||
if not self._msg.is_signed:
|
if not self._msg.is_signed:
|
||||||
|
if not Config.permit_unsigned_response:
|
||||||
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
|
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
|
||||||
'gpg-wks-client for submitting your response, please update said tool or try '
|
'gpg-wks-client for submitting your response, please update said tool or try '
|
||||||
'responding manually.')
|
'responding manually.')
|
||||||
|
else:
|
||||||
|
# Unsigned, but permitted
|
||||||
|
return
|
||||||
uid: PGPUID = key.get_uid(self._submitter_addr)
|
uid: PGPUID = key.get_uid(self._submitter_addr)
|
||||||
if uid is None or uid.email != self._submitter_addr:
|
if uid is None or uid.email != self._submitter_addr:
|
||||||
raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key')
|
raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key')
|
||||||
|
@ -190,8 +184,8 @@ class PublishResponse:
|
||||||
submission=self.submission_address)
|
submission=self.submission_address)
|
||||||
mpplain = MIMEText(mail_text, _subtype='plain')
|
mpplain = MIMEText(mail_text, _subtype='plain')
|
||||||
to_encrypt = PGPMessage.new(mpplain.as_string(policy=default))
|
to_encrypt = PGPMessage.new(mpplain.as_string(policy=default))
|
||||||
to_encrypt |= pgp_sign(self.domain, to_encrypt)
|
|
||||||
encrypted: PGPMessage = self.key.encrypt(to_encrypt)
|
encrypted: PGPMessage = self.key.encrypt(to_encrypt)
|
||||||
|
encrypted |= pgp_sign(self.domain, encrypted)
|
||||||
payload = MIMEApplication(str(encrypted), _subtype='octet-stream', _encoder=encode_noop)
|
payload = MIMEApplication(str(encrypted), _subtype='octet-stream', _encoder=encode_noop)
|
||||||
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted', _encoder=encode_noop)
|
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted', _encoder=encode_noop)
|
||||||
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
|
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
|
||||||
|
@ -199,7 +193,7 @@ class PublishResponse:
|
||||||
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
|
||||||
|
@ -227,7 +221,7 @@ class EasyWksError(BaseException):
|
||||||
email['Subject'] = 'An error has occurred while processing your request'
|
email['Subject'] = 'An error has occurred while processing your request'
|
||||||
email['From'] = submission_addr
|
email['From'] = submission_addr
|
||||||
email['To'] = submitter_addr
|
email['To'] = submitter_addr
|
||||||
email['Date'] = format_datetime(datetime.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 <1375407-s3lph@users.noreply.gitlab.com>
|
||||||
Section: web
|
Section: web
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Architecture: all
|
Architecture: all
|
||||||
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd, 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
|
||||||
|
|
|
@ -7,14 +7,10 @@
|
||||||
# considered stale and should be removed by easywks clean.
|
# considered stale and should be removed by easywks clean.
|
||||||
#pending_lifetime: 604800
|
#pending_lifetime: 604800
|
||||||
|
|
||||||
# Some clients including recent versions of gpg-wks-client follow an
|
# Some clients (including recent versions of gpg-wks-client follow an
|
||||||
# older version of the WKS standard where signing the confirmation
|
# older version of the WKS standard where signing the confirmation
|
||||||
# response is only recommended, but not required. Set this option to
|
# response is only recommended, but not required. Set this option to
|
||||||
# true if you want to accept such unsigned responses.
|
# true if you want to accept such unsigned responses.
|
||||||
#
|
|
||||||
# This option is deprecated and will be removed in a future release.
|
|
||||||
# It is replaced by the me.s3lph.easywks_permit-unsigned-response
|
|
||||||
# per-domain policy flag.
|
|
||||||
#permit_unsigned_response: false
|
#permit_unsigned_response: false
|
||||||
|
|
||||||
# Port configuration for the webserver. Put this behind a
|
# Port configuration for the webserver. Put this behind a
|
||||||
|
@ -46,11 +42,6 @@ lmtpd:
|
||||||
host: "::1"
|
host: "::1"
|
||||||
port: 8024
|
port: 8024
|
||||||
|
|
||||||
# Configure the authoritative DNS server for DANE zones
|
|
||||||
dnsd:
|
|
||||||
host: "::1"
|
|
||||||
port: 8053
|
|
||||||
|
|
||||||
# You can override the mail response templates with your own text.
|
# You can override the mail response templates with your own text.
|
||||||
# The following templates can be overridden:
|
# The following templates can be overridden:
|
||||||
# - "header": Placed in front of every message.
|
# - "header": Placed in front of every message.
|
||||||
|
@ -84,51 +75,3 @@ domains:
|
||||||
# or if you're supplying your own password-protected key, set the
|
# or if you're supplying your own password-protected key, set the
|
||||||
# passphrase here:
|
# passphrase here:
|
||||||
#passphrase: "Correct Horse Battery Staple"
|
#passphrase: "Correct Horse Battery Staple"
|
||||||
# Policy flags control behavior of the submission process for this
|
|
||||||
# domain. Supports most of the standard policy flags (see
|
|
||||||
# https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-15#section-4.5
|
|
||||||
# for details) as well as some EasyWKS-namespaced flags.
|
|
||||||
policy_flags:
|
|
||||||
|
|
||||||
# The mail server provider does only accept keys with only a
|
|
||||||
# mailbox in the User ID. In particular User IDs with a real name
|
|
||||||
# in addition to the mailbox will be rejected as invalid.
|
|
||||||
#mailbox-only: false
|
|
||||||
|
|
||||||
# The submission of the mail to the server is done using an
|
|
||||||
# authenticated connection. Thus the submitted key will be
|
|
||||||
# published immediately without any confirmation request.
|
|
||||||
#auth-submit: false
|
|
||||||
|
|
||||||
# This keyword can be used to explicitly claim the support of a
|
|
||||||
# specific version of the Web Key Directory update protocol. This
|
|
||||||
# is in general not needed but implementations may have
|
|
||||||
# workarounds for providers which only support an old protocol
|
|
||||||
# version. If these providers update to a newer version they
|
|
||||||
# should add this keyword so that the implementation can disable
|
|
||||||
# the workaround. The value is an integer corresponding to the
|
|
||||||
# respective draft revision number.
|
|
||||||
#protocol-version: null
|
|
||||||
|
|
||||||
# Some clients (including recent versions of gpg-wks-client
|
|
||||||
# follow an older version of the WKS standard where signing the
|
|
||||||
# confirmation response is only recommended, but not required.
|
|
||||||
# Set this option to true if you want to accept such unsigned
|
|
||||||
# responses.
|
|
||||||
#me.s3lph.easywks_permit-unsigned-response: false
|
|
||||||
|
|
||||||
# Remove all UIDs except the one being verified.
|
|
||||||
#me.s3lph.easywks_strip-unverified-uids: false
|
|
||||||
|
|
||||||
# Remove user attribute (i.e. photo) UIDs.
|
|
||||||
#me.s3lph.easywks_strip-ua-uids: false
|
|
||||||
|
|
||||||
# Remove all third party certifications fom the submitted keys.
|
|
||||||
# Certifications issued by revoked keys submitted in the same
|
|
||||||
# submission request are exempt from this policy.
|
|
||||||
#me.s3lph.easywks_strip-3rdparty-signatures: false
|
|
||||||
|
|
||||||
# Maximal number of revoked keys that can be submitted alongside
|
|
||||||
# a valid key. If this flag is absent or has value -1, an
|
|
||||||
# unlimited number of revoked keys is permitted.
|
|
||||||
#me.s3lph.easywks_max-revoked-keys: -1
|
|
|
@ -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
|
|
|
@ -10,7 +10,4 @@ lmtpd:
|
||||||
httpd:
|
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