Compare commits
51 commits
Author | SHA1 | Date | |
---|---|---|---|
dadcfb558e | |||
7de4943100 | |||
dadbcdbf3f | |||
3174aff2bc | |||
d9fa1733a8 | |||
b3e66b91c3 | |||
19cde32909 | |||
32855f2472 | |||
d6357c9bac | |||
aee85f2ff1 | |||
df55e7da4c | |||
6736c3fa2e | |||
a114f24314 | |||
c013284979 | |||
|
ba0070e1ac | ||
|
f6a7c9628b | ||
|
625088abcf | ||
|
6c27d799e3 | ||
|
360303d72f | ||
|
41de3b5704 | ||
|
9182582589 | ||
|
082d7d93d4 | ||
|
62fd2d52da | ||
|
f00ffb7dd2 | ||
|
68ac57c4ce | ||
|
4596ed2b31 | ||
|
4e1465cdb2 | ||
|
fddbea70d9 | ||
|
35f38ef188 | ||
|
01f694e98e | ||
|
b4a120b008 | ||
|
ea451639e6 | ||
|
b396a2c01c | ||
|
f4ea60c057 | ||
|
492b823215 | ||
|
0a337f90ee | ||
|
830819a6a5 | ||
|
ca7ddd205d | ||
|
c54d86412d | ||
|
7c8a5a377b | ||
|
b1f7d993c9 | ||
|
f081578c50 | ||
|
ebaac40ac2 | ||
|
daee1654d3 | ||
|
d68e826495 | ||
|
6947122520 | ||
|
f7b9598d58 | ||
|
8043db9a1b | ||
|
f9ddba422c | ||
|
adc699aeed | ||
|
c535ae8f1a |
34 changed files with 2239 additions and 353 deletions
38
.forgejo/workflows/package.yml
Normal file
38
.forgejo/workflows/package.yml
Normal file
|
@ -0,0 +1,38 @@
|
|||
---
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
|
||||
build_wheel:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- name: Build Python wheel
|
||||
run: |
|
||||
apt update; apt install -y python3-pip
|
||||
pip3 install --break-system-packages -e .[test]
|
||||
python3 setup.py egg_info bdist_wheel
|
||||
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-wheel-package-upload@v3
|
||||
with:
|
||||
username: ${{ secrets.API_USERNAME }}
|
||||
password: ${{ secrets.API_PASSWORD }}
|
||||
|
||||
build_debian:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-python-debian-package@v5
|
||||
with:
|
||||
python_module: easywks
|
||||
package_name: easywks
|
||||
package_root: package/debian/easywks
|
||||
package_output_path: package/debian
|
||||
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-debian-package-upload@v2
|
||||
with:
|
||||
username: ${{ secrets.API_USERNAME }}
|
||||
password: ${{ secrets.API_PASSWORD }}
|
||||
deb: "package/debian/*.deb"
|
136
.forgejo/workflows/test.yml
Normal file
136
.forgejo/workflows/test.yml
Normal file
|
@ -0,0 +1,136 @@
|
|||
---
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
|
||||
test:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
apt update; apt install -y python3-pip
|
||||
pip3 install --break-system-packages -e .[test]
|
||||
python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
|
||||
python3 -m coverage combine
|
||||
python3 -m coverage report --rcfile=setup.cfg
|
||||
|
||||
codestyle:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- name: pycodestyle
|
||||
run: |
|
||||
apt update; apt install -y python3-pip
|
||||
pip3 install --break-system-packages -e .[test]
|
||||
pycodestyle easywks
|
||||
|
||||
easywksserver_gpgwksclient:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- name: Integration Test against gpg-wks-client
|
||||
run: |
|
||||
apt update; apt install --yes gnupg2 socat ca-certificates python3-pip
|
||||
echo "openpgpkey" > /etc/hostname
|
||||
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
|
||||
pip3 install --break-system-packages -e .[test]
|
||||
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org'
|
||||
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
|
||||
update-ca-certificates
|
||||
mkdir -p /tmp/easywks
|
||||
cat > /tmp/easywks.yml <<EOF
|
||||
directory: /tmp/easywks
|
||||
httpd:
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
mailing_method: stdout
|
||||
domains:
|
||||
example.org:
|
||||
submission_address: webkey@example.org
|
||||
policy_flags:
|
||||
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat
|
||||
EOF
|
||||
easywks --config /tmp/easywks.yml init
|
||||
easywks --config /tmp/easywks.yml webserver &
|
||||
socat OPENSSL-LISTEN:443,fork,reuseaddr,verify=0,cert=/etc/ssl/cert.pem,key=/etc/ssl/key.pem TCP:127.0.0.1:8080 &
|
||||
sleep 3
|
||||
install -m 0700 -d /tmp/gpg /tmp/cleangpg
|
||||
export GNUPGHOME=/tmp/gpg
|
||||
test/genkey.sh alice@example.org
|
||||
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
|
||||
/usr/lib/gnupg/gpg-wks-client --supported alice@example.org
|
||||
/usr/lib/gnupg/gpg-wks-client --check webkey@example.org
|
||||
PUBREQ="$(/usr/lib/gnupg/gpg-wks-client --create "${FINGERPRINT}" alice@example.org)"
|
||||
CONFREQ="$(echo "${PUBREQ}" | easywks --config /tmp/easywks.yml process)"
|
||||
CONFRESP="$(echo "${CONFREQ}" | /usr/lib/gnupg/gpg-wks-client --receive --verbose)"
|
||||
PUBRESP="$(echo "${CONFRESP}" | easywks --config /tmp/easywks.yml process)"
|
||||
echo "${PUBRESP}" | gpg --batch --decrypt
|
||||
/usr/lib/gnupg/gpg-wks-client --check alice@example.org
|
||||
export GNUPGHOME=/tmp/gpg
|
||||
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
|
||||
kill %2 || true
|
||||
kill %1 || true
|
||||
|
||||
easywksserver_easywksclient:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||
- name: Integration Test against easywks-client
|
||||
run: |
|
||||
# General system setup
|
||||
useradd -d /home/alice -m alice
|
||||
useradd -d /home/webkey -m webkey
|
||||
echo alice:supersecurepassword | chpasswd
|
||||
echo "postfix postfix/mailname string example.org" | debconf-set-selections
|
||||
echo "postfix postfix/main_mailer_type string 'Local only'" | debconf-set-selections
|
||||
apt update; apt install --yes gnupg2 ca-certificates python3-pip apache2 dovecot-imapd postfix expect
|
||||
echo "openpgpkey" > /etc/hostname
|
||||
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
|
||||
pip3 install --break-system-packages -e .[test]
|
||||
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org' -addext 'subjectAltName=DNS:openpgpkey.example.org,DNS:example.org'
|
||||
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
|
||||
update-ca-certificates
|
||||
# Setup Apache
|
||||
a2enmod ssl proxy_http rewrite
|
||||
rm /etc/apache2/sites-enabled/000-default.conf
|
||||
cp test/apache.conf /etc/apache2/sites-enabled/easywks.conf
|
||||
apache2ctl start
|
||||
mkdir -p /var/www/html/.well-known/autoconfig/mail/
|
||||
cp test/config-v1.1.xml /var/www/html/.well-known/autoconfig/mail/config-v1.1.xml
|
||||
# Setup Dovecot
|
||||
cp test/dovecot.conf /etc/dovecot/conf.d/99-local.conf
|
||||
dovecot -F &
|
||||
# Setup Postfix
|
||||
/usr/lib/postfix/configure-instance.sh -
|
||||
cp test/transport /etc/postfix/transport
|
||||
postmap /etc/postfix/transport
|
||||
postconf smtpd_tls_cert_file=/etc/ssl/cert.pem
|
||||
postconf smtpd_tls_key_file=/etc/ssl/key.pem
|
||||
postconf transport_maps=hash:/etc/postfix/transport
|
||||
postconf smtpd_sasl_type=dovecot
|
||||
postconf smtpd_sasl_path=private/auth
|
||||
postconf smtpd_sasl_auth_enable=yes
|
||||
/usr/sbin/postmulti -i - -p start
|
||||
# Setup EasyWKS
|
||||
mkdir -p /tmp/easywks
|
||||
cp test/easywks.yml /tmp/easywks.yml
|
||||
easywks --config /tmp/easywks.yml init
|
||||
easywks --config /tmp/easywks.yml webserver &
|
||||
easywks --config /tmp/easywks.yml lmtpd &
|
||||
sleep 3
|
||||
# Run the test
|
||||
install -m 0700 -d /tmp/gpg /tmp/cleangpg
|
||||
export GNUPGHOME=/tmp/gpg
|
||||
test/genkey.sh alice@example.org
|
||||
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
|
||||
test/expect
|
||||
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
|
||||
# Teardown
|
||||
apache2ctl stop
|
||||
doveadm stop
|
||||
/usr/sbin/postmulti -i - -p stop
|
||||
kill %1 || true
|
||||
kill %2 || true
|
||||
sleep 5 # wait for daemons to terminate
|
104
.gitlab-ci.yml
104
.gitlab-ci.yml
|
@ -1,104 +0,0 @@
|
|||
---
|
||||
image: python:3.9-bullseye
|
||||
|
||||
stages:
|
||||
- test
|
||||
- build
|
||||
- deploy
|
||||
|
||||
|
||||
|
||||
before_script:
|
||||
- pip3 install coverage pycodestyle
|
||||
- export EASYWKS_VERSION=$(python -c 'import easywks; print(easywks.__version__)')
|
||||
|
||||
|
||||
|
||||
test:
|
||||
stage: test
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
|
||||
- python3 -m coverage combine
|
||||
- python3 -m coverage report --rcfile=setup.cfg
|
||||
|
||||
codestyle:
|
||||
stage: test
|
||||
script:
|
||||
- pip3 install -e .
|
||||
- pycodestyle easywks
|
||||
|
||||
|
||||
|
||||
# currently not working for some reason
|
||||
#build_docker:
|
||||
# stage: build
|
||||
# script:
|
||||
# - apt update && apt install --yes docker.io
|
||||
# - docker build -t "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" -f package/docker/Dockerfile .
|
||||
# - docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
|
||||
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
|
||||
# - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.gitlab.com
|
||||
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA"
|
||||
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
|
||||
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
|
||||
# only:
|
||||
# - staging
|
||||
# - tags
|
||||
|
||||
build_wheel:
|
||||
stage: build
|
||||
script:
|
||||
- python3 setup.py egg_info bdist_wheel
|
||||
- cd dist
|
||||
- sha256sum *.whl > SHA256SUMS
|
||||
artifacts:
|
||||
paths:
|
||||
- "dist/*.whl"
|
||||
- dist/SHA256SUMS
|
||||
only:
|
||||
- tags
|
||||
|
||||
build_debian:
|
||||
stage: build
|
||||
script:
|
||||
- apt update && apt install --yes lintian rsync sudo
|
||||
- echo -n > package/debian/easywks/usr/share/doc/easywks/changelog
|
||||
- |
|
||||
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
|
||||
echo "easywks (${version}-1); urgency=medium\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
|
||||
done
|
||||
- gzip -9n package/debian/easywks/usr/share/doc/easywks/changelog
|
||||
- python3 setup.py egg_info install --root=package/debian/easywks/ --prefix=/usr --optimize=1
|
||||
- cd package/debian
|
||||
- sed -re "s/__EASYWKS_VERSION__/${EASYWKS_VERSION}/g" -i easywks/DEBIAN/control
|
||||
- mkdir -p easywks/usr/lib/python3/dist-packages/
|
||||
- rsync -a easywks/usr/lib/python3.9/site-packages/ easywks/usr/lib/python3/dist-packages/
|
||||
- rm -rf easywks/usr/lib/python3.9/site-packages
|
||||
- find easywks/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
|
||||
- find easywks/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
|
||||
- find easywks/usr/lib/python3/dist-packages -name '*.pyo' -exec rm {} \;
|
||||
- sed -re 's$#!/usr/local/bin/python3$#!/usr/bin/python3$' -i easywks/usr/bin/easywks
|
||||
- find easywks -type f -exec chmod 0644 {} \;
|
||||
- find easywks -type d -exec chmod 755 {} \;
|
||||
- chmod +x easywks/usr/bin/easywks easywks/DEBIAN/postinst easywks/DEBIAN/prerm easywks/DEBIAN/postrm
|
||||
- dpkg-deb --build easywks
|
||||
- mv easywks.deb "easywks_${EASYWKS_VERSION}-1_all.deb"
|
||||
- sudo -u nobody lintian "easywks_${EASYWKS_VERSION}-1_all.deb"
|
||||
- sha256sum *.deb > SHA256SUMS
|
||||
artifacts:
|
||||
paths:
|
||||
- "package/debian/*.deb"
|
||||
- package/debian/SHA256SUMS
|
||||
only:
|
||||
- tags
|
||||
|
||||
|
||||
release:
|
||||
stage: deploy
|
||||
script:
|
||||
- python3 package/release.py
|
||||
only:
|
||||
- tags
|
238
CHANGELOG.md
238
CHANGELOG.md
|
@ -1,5 +1,243 @@
|
|||
# EasyWKS Changelog
|
||||
|
||||
<!-- BEGIN RELEASE v0.4.6 -->
|
||||
## Version 0.4.6
|
||||
|
||||
Bugfix release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.4.6 -->
|
||||
- Fix: Don't put multiple keys into a DANE record
|
||||
<!-- END CHANGES 0.4.6-->
|
||||
|
||||
<!-- END RELEASE v0.4.6 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.4.5 -->
|
||||
## Version 0.4.5
|
||||
|
||||
Migrate from Woodpecker CI to Forgejo Actions
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.4.5 -->
|
||||
- Migrate from Woodpecker CI to Forgejo Actions
|
||||
<!-- END CHANGES 0.4.5-->
|
||||
|
||||
<!-- END RELEASE v0.4.5 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.4.4 -->
|
||||
## Version 0.4.4
|
||||
|
||||
smtpd: Log errors to stdout rather than SMTP session
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.4.4 -->
|
||||
- smtpd: Log errors to stdout rather than SMTP session
|
||||
<!-- END CHANGES 0.4.4-->
|
||||
|
||||
<!-- END RELEASE v0.4.4 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.4.3 -->
|
||||
## Version 0.4.3
|
||||
|
||||
Migrate from Gitlab to Forgejo
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.4.3 -->
|
||||
- Migrate from Gitlab to Forgejo
|
||||
<!-- END CHANGES 0.4.3-->
|
||||
|
||||
<!-- END RELEASE v0.4.3 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.4.2 -->
|
||||
## Version 0.4.2
|
||||
|
||||
Minor feature release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.4.2 -->
|
||||
- Add option to provide DNS NOTIFY to DANE zone replicas
|
||||
<!-- END CHANGES 0.4.2-->
|
||||
|
||||
<!-- END RELEASE v0.4.2 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.4.1 -->
|
||||
## Version 0.4.1
|
||||
|
||||
Bugfix release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.4.1 -->
|
||||
- Don't refuse DNS IXFRs and respond with full AXFR
|
||||
<!-- END CHANGES 0.4.1-->
|
||||
|
||||
<!-- END RELEASE v0.4.1 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.4.0 -->
|
||||
## Version 0.4.0
|
||||
|
||||
Feature release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.4.0 -->
|
||||
- Add authoritative DNS server providing DANE OPENPGPKEY (TYPE61) DNS records
|
||||
<!-- END CHANGES 0.4.0-->
|
||||
|
||||
<!-- END RELEASE v0.4.0 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.3.1 -->
|
||||
## Version 0.3.1
|
||||
|
||||
Feature release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.3.1 -->
|
||||
- Implement standard and EasyWKS-specific policy flags
|
||||
- **Deprecation**: The top level option `permit_unsigned_response` is deprecated
|
||||
and will be removed in a future release. Use the per-domain policy flag
|
||||
`me.s3lph.easywks_permit-unsigned-response` instead.
|
||||
<!-- END CHANGES 0.3.1 -->
|
||||
|
||||
<!-- END RELEASE v0.3.1 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.3.0 -->
|
||||
## Version 0.3.0
|
||||
|
||||
Feature release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.3.0 -->
|
||||
- Set CORS headers on HTTP responses
|
||||
- Implement direct WKD URLs
|
||||
- Allow submitting additional revoked keys with the submission request
|
||||
<!-- END CHANGES 0.3.0 -->
|
||||
|
||||
<!-- END RELEASE v0.3.0 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.2.0 -->
|
||||
## Version 0.2.0
|
||||
|
||||
Bugfix release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.2.0 -->
|
||||
- Release pipeline runs integration test against gpg-wks-client
|
||||
- Fix minor incompatibilities with gpg-wks-client
|
||||
- Fix per-domain configuration (e.g. submission-address was not loaded)
|
||||
<!-- END CHANGES 0.2.0 -->
|
||||
|
||||
<!-- END RELEASE v0.2.0 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.11 -->
|
||||
## Version 0.1.11
|
||||
|
||||
Feature release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.11 -->
|
||||
- Add easywks import CLI command
|
||||
<!-- END CHANGES 0.1.11 -->
|
||||
|
||||
<!-- END RELEASE v0.1.11 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.10 -->
|
||||
## Version 0.1.10
|
||||
|
||||
Bugfix release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.10 -->
|
||||
- RFC 3156 compliance: Don't base64-encode PGP/MIME messages
|
||||
<!-- END CHANGES 0.1.10 -->
|
||||
|
||||
<!-- END RELEASE v0.1.10 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.9 -->
|
||||
## Version 0.1.9
|
||||
|
||||
Bugfix release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.9 -->
|
||||
- Proper handling of "Auto-Submitted: no" mail header
|
||||
- Fix signature verification of responses signed with a subkey
|
||||
<!-- END CHANGES 0.1.9 -->
|
||||
|
||||
<!-- END RELEASE v0.1.9 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.8 -->
|
||||
## Version 0.1.8
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.8 -->
|
||||
- Remove LMTP Recipient check as well, leads to trouble with postfix aliasing.
|
||||
<!-- END CHANGES 0.1.8 -->
|
||||
|
||||
<!-- END RELEASE v0.1.8 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.7 -->
|
||||
## Version 0.1.7
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.7 -->
|
||||
- Add file locking in order to avoid races between LMTP/process and HTTP.
|
||||
<!-- END CHANGES 0.1.7 -->
|
||||
|
||||
<!-- END RELEASE v0.1.7 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.6 -->
|
||||
## Version 0.1.6
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.6 -->
|
||||
- Remove LMTP Envelope-Sender check.
|
||||
- Debian package now includes an "easywks clean" cronjob.
|
||||
<!-- END CHANGES 0.1.6 -->
|
||||
|
||||
<!-- END RELEASE v0.1.6 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.5 -->
|
||||
## Version 0.1.5
|
||||
|
||||
The messages sent by EasyWKS can now be customized.
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.5 -->
|
||||
- Add `responses` config option.
|
||||
<!-- END CHANGES 0.1.5 -->
|
||||
|
||||
<!-- END RELEASE v0.1.5 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.4 -->
|
||||
## Version 0.1.4
|
||||
|
||||
Fix HTTP server, compatibility with older HTTP clients
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.1.4 -->
|
||||
- Fix config loading bug in webserver.
|
||||
- Add `require_user_urlparam` config option that makes the `?l=<user>` query optional.
|
||||
<!-- END CHANGES 0.1.4 -->
|
||||
|
||||
<!-- END RELEASE v0.1.4 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.1.3 -->
|
||||
## Version 0.1.3
|
||||
|
||||
|
|
188
README.md
188
README.md
|
@ -4,12 +4,10 @@
|
|||
|
||||
---
|
||||
|
||||
This is a work-in-progress project. See ROADMAP.md for details
|
||||
|
||||
## What is WKD/WKS?
|
||||
|
||||
Due to all the issues involved with the PGP key servers we're using today, GnuPG introduced a feature named [**Web Key
|
||||
Discovery**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
|
||||
Directory**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
|
||||
decentralized and federated approach, where each mail domain is responsible for hosting its users public keys on an
|
||||
HTTPS web directory. For example, in order to retrieve the key for `john.doe@example.org`, they key can be located at
|
||||
|
||||
|
@ -52,6 +50,8 @@ gpg-wks-server to EasyWKS.
|
|||
- PyYAML
|
||||
- bottle.py
|
||||
- PGPy
|
||||
- dnspython (for DANE support)
|
||||
- Twisted (for DANE support)
|
||||
|
||||
## License
|
||||
|
||||
|
@ -75,8 +75,8 @@ Configuration is done in `/etc/easywks.yml` (or any other place as specified by
|
|||
|
||||
```yaml
|
||||
---
|
||||
# EasyWKS works inside this directory. Its PGP keys as well# as all
|
||||
# the submitted and published keys are stored here.
|
||||
# EasyWKS works inside this directory. Its PGP keys as well as all
|
||||
# the submitted and published keys are stored here.
|
||||
directory: /var/lib/easywks
|
||||
|
||||
# Number of seconds after which a pending submission request is
|
||||
|
@ -94,6 +94,9 @@ permit_unsigned_response: false
|
|||
httpd:
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
# Some older HTTP clients omit the ?l=<userid> query suffix. Set
|
||||
# this to false in order to permit such clients to retrieve keys.
|
||||
#require_user_urlparam: true
|
||||
|
||||
# Defaults to stdout, supported: stdout, smtp
|
||||
mailing_method: smtp
|
||||
|
@ -109,10 +112,39 @@ smtp:
|
|||
# Omit username/password if authentication is not needed.
|
||||
username: webkey
|
||||
password: SuperS3curePassword123
|
||||
|
||||
# Configure the LMTP server
|
||||
lmtpds:
|
||||
lmtpd:
|
||||
host: "::1"
|
||||
port: 8024
|
||||
|
||||
# Configure the authoritative DNS server for DANE zones
|
||||
dnsd:
|
||||
host: "::1"
|
||||
port: 8053
|
||||
|
||||
# You can override the mail response templates with your own text.
|
||||
# The following templates can be overridden:
|
||||
# - "header": Placed in front of every message.
|
||||
# - "footer": Appended to every message.
|
||||
# - "confirm": Sent with the confirmation request.
|
||||
# - "done": Sent after a key was published.
|
||||
# - "error": Sent when an error occurs.
|
||||
# The following placeholders can be used (enclosed in curly braces):
|
||||
# - {domain}: The email domain for with the request is processed.
|
||||
# - {sender}: The submitter's mail address.
|
||||
# - {submission}: The submission address.
|
||||
# When overriding the "error" template, there's an additional
|
||||
# placeholder you can use:
|
||||
# - {error}: The error message.
|
||||
#responses:
|
||||
# error: |
|
||||
# An error has occurred while processing your request:
|
||||
#
|
||||
# {error}
|
||||
#
|
||||
# If this error persists, please contact admin@example.org for help.
|
||||
|
||||
# Every domain served by EasyWKS must be listed here
|
||||
domains:
|
||||
example.org:
|
||||
|
@ -227,6 +259,148 @@ gpgwks@example.org lmtp:localhost:10024
|
|||
webkey@example.com lmtp:localhost:10024
|
||||
```
|
||||
|
||||
### DANE DNS Setup
|
||||
|
||||
Apart from WKD, EasyWKS can also serve PGP keys using RFC7929 DNS records ("OPENPGPKEY" or "TYPE61" records). However,
|
||||
since EasyWKS does not implement DNSSEC signing, it cannot do this alone. The authoritative DNS server in EasyWKS only
|
||||
responds to AXFR zone transfer requests. In order for DANE lookups to work, the zones must be replicated (AXFR'd) by an
|
||||
authoritative secondary nameserver that signs the zones itself.
|
||||
|
||||
#### EasyWKS DNS Server
|
||||
|
||||
Configure EasyWKS to run the DNS server, e.g. using the following systemd unit:
|
||||
|
||||
```unit file (systemd)
|
||||
[Unit]
|
||||
Description=OpenPGP WKS for Human Beings - DANE DNS Server
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/path/to/easywks dnsd
|
||||
Restart=on-failure
|
||||
User=webkey
|
||||
Group=webkey
|
||||
WorkingDirectory=/var/lib/easywks
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
If you're using EasyWKS' DANE feature, it is highly recommended to configure the SOA and NS records for each domain
|
||||
you're serving. Generally you want to add NS records for all nameservers that will be serving your zone, and at least
|
||||
set the MNAME and RNAME components of the SOA record. You can also configure EasyWKS to provide zone update
|
||||
notifications whenever a key is modified:
|
||||
|
||||
```yaml
|
||||
domains:
|
||||
example.org:
|
||||
ns:
|
||||
- ns1.example.org.
|
||||
- ns2.example.org.
|
||||
- ns1.example.com.
|
||||
- ns2.example.com.
|
||||
notify:
|
||||
- "2001:db8::53@10053"
|
||||
soa:
|
||||
mname: ns1.example.org.
|
||||
rname: dnsadmin.example.org.
|
||||
refresh: 300
|
||||
retry: 60
|
||||
expire: 1209600
|
||||
minimal: 300
|
||||
```
|
||||
|
||||
#### Knot
|
||||
|
||||
Knot is an authoritative nameserver that supports signing a replicated zone by the secondary (replicating) nameserver.
|
||||
|
||||
To configure Knot to transfer the zone from EasyWKS, set up an EasyWKS remote and use it as the replication master for
|
||||
DANE zones. DNSSEC signing must be enabled as well. If you want Knot to be notified of zone changes, set up a notify
|
||||
ACL too:
|
||||
|
||||
```yaml
|
||||
|
||||
acl:
|
||||
- id: acl-easywks.example.org
|
||||
address: [::1]
|
||||
action: notify
|
||||
|
||||
remote:
|
||||
- id: remote-easywks.example.org
|
||||
address: [::1]@10053
|
||||
|
||||
zone:
|
||||
- domain: _openpgpkey.example.org
|
||||
master: remote-easywks.example.org
|
||||
acl: acl-easywks.example.org
|
||||
dnssec-signing: on
|
||||
dnssec-policy: ...
|
||||
```
|
||||
|
||||
## EasyWKS Client
|
||||
|
||||
The file `client.py` contains a self-contained WKS client, which
|
||||
prompts you for your email address and IMAP/SMTP/POP3 password, and
|
||||
then attempts to figure out the mail servers via common
|
||||
autoconfiguration methods. Afterwards it will attempt a WKS key submission:
|
||||
|
||||
```console?prompt=$,
|
||||
$ ./client.py
|
||||
Enter email: john.doe@example.org
|
||||
Chose A58D3221F8079F35FF084890505A563492A56583
|
||||
Enter IMAP/POP3/SMTP password (will not echo): ********
|
||||
Autoconfigured incoming server: imaps://john.doe@example.org@imap.example.org:993
|
||||
Autoconfigured outgoing server: smtp+starttls://john.doe@example.org@smtp.example.org:587
|
||||
Please confirm: [Y/n] y
|
||||
Retrieved submission key
|
||||
Retrieved key to publish
|
||||
Created encrypted message
|
||||
Sending submission request
|
||||
Awaiting response
|
||||
Received confirmation request
|
||||
Nonce: 95184efbc5d2f75ed4b56162
|
||||
Creating confirmation response. GnuPG may prompt you for your passphrase.
|
||||
Sending confirmation response
|
||||
Awaiting publish response
|
||||
Decrypting WKS response. GnuPG may prompt you for your passphrase.
|
||||
|
||||
Hi there!
|
||||
|
||||
This is the EasyWKS system at example.org
|
||||
|
||||
Your key has been published to the Web Key Directory.
|
||||
You can test WKD key retrieval e.g. with:
|
||||
|
||||
gpg --auto-key-locate=wkd,nodefault --locate-key john.doe@example.org
|
||||
|
||||
For more information on WKD and WKS see:
|
||||
|
||||
https://gnupg.org/faq/wkd.html
|
||||
https://gnupg.org/faq/wks.html
|
||||
|
||||
|
||||
Regards
|
||||
EasyWKS
|
||||
|
||||
--
|
||||
Dance like nobody is watching.
|
||||
Encrypt live everybody is.
|
||||
|
||||
```
|
||||
|
||||
## Manual Key Import
|
||||
|
||||
In addition to WKS, EasyWKS also provides a command line interface for
|
||||
importing keys from standard input. This feature is mainly intended
|
||||
to be used for technical email accounts where using WKS might prove to
|
||||
be difficult:
|
||||
|
||||
```console?prompt=$,
|
||||
$ cat pubkey.asc | easywks import
|
||||
Skipping foreign email john.doe@notmydepartment.org
|
||||
Imported key A58D3221F8079F35FF084890505A563492A56583 for email john.doe@example.org
|
||||
```
|
||||
|
||||
[wkd]: https://wiki.gnupg.org/WKD
|
||||
[wks]: https://wiki.gnupg.org/WKS
|
||||
[ietf]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-12
|
||||
[ietf]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-12
|
||||
|
|
|
@ -1,4 +0,0 @@
|
|||
# EasyWKS Roadmap
|
||||
|
||||
- [ ] Figure out whether file locking in the working directory is necessary to avoid races.
|
||||
- [ ] Testing, testing, testing!
|
646
client.py
Executable file
646
client.py
Executable file
|
@ -0,0 +1,646 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import abc
|
||||
import time
|
||||
from getpass import getpass
|
||||
from datetime import datetime, timezone
|
||||
import time
|
||||
import imaplib
|
||||
import poplib
|
||||
import smtplib
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import email
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.text import MIMEText
|
||||
from email.parser import BytesParser
|
||||
from email.utils import format_datetime
|
||||
from xml.etree import ElementTree
|
||||
|
||||
|
||||
def _get_pgp_message(message: email.message.Message) -> bytes:
|
||||
pgp = None
|
||||
for part in message.walk():
|
||||
if part.is_multipart():
|
||||
continue
|
||||
p = part.get_content()
|
||||
if isinstance(p, str):
|
||||
p = p.encode()
|
||||
if b'BEGIN PGP MESSAGE' in p:
|
||||
if pgp is not None:
|
||||
raise ValueError('More than one encrypted message part')
|
||||
pgp = p
|
||||
if pgp is None:
|
||||
raise ValueError('No encrypted message part')
|
||||
return pgp
|
||||
|
||||
|
||||
class MailServerConfig(abc.ABC):
|
||||
|
||||
def __init__(self, proto, hostname, port, tls, username, password):
|
||||
self.proto = proto
|
||||
self.hostname = hostname
|
||||
self.port = port
|
||||
self.tls = tls
|
||||
self.username = username
|
||||
self.password = password
|
||||
|
||||
def __repr__(self):
|
||||
proto = self.proto + ('s' if self.tls == 'SSL' else '') + ('+starttls' if self.tls == 'STARTTLS' else '')
|
||||
return f'{proto}://{self.username}@{self.hostname}:{self.port}'
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, MailServerConfig):
|
||||
return False
|
||||
return self.proto == other.proto \
|
||||
and self.hostname == other.hostname \
|
||||
and self.port == other.port \
|
||||
and self.tls == other.tls \
|
||||
and self.username == other.username \
|
||||
and self.password == other.password
|
||||
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, MailServerConfig):
|
||||
raise TypeError(f'Cannot compare {type(self)} to {type(other)}')
|
||||
if self == other:
|
||||
return False
|
||||
# SMTP not comparable to IMAP or POP3
|
||||
if (self.proto == 'smtp' or other.proto == 'smtp') and self.proto != other.proto:
|
||||
raise TypeError(f'Cannot compare {type(self)} to {type(other)}')
|
||||
# IMAP < POP3
|
||||
if self.proto == 'imap' and other.proto == 'pop3':
|
||||
return True
|
||||
# SSL < STARTTLS < plain
|
||||
if (self.tls == 'SSL' and other.tls != 'SSL') or (self.tls == 'STARTTLS' and other.tls == 'plain'):
|
||||
return True
|
||||
# full email < local part
|
||||
if '@' in self.username and '@' not in other.username:
|
||||
return True
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
|
||||
class SmtpServerConfig(MailServerConfig):
|
||||
|
||||
def __init__(self, hostname, port, tls, username, password):
|
||||
super().__init__('smtp', hostname, port, tls, username, password)
|
||||
self._smtp = None
|
||||
|
||||
def __enter__(self):
|
||||
cls = smtplib.SMTP if self.tls != 'SSL' else smtplib.SMTP_SSL
|
||||
self._smtp = cls(self.hostname, self.port)
|
||||
smtp = self._smtp.__enter__()
|
||||
if self.tls == 'STARTTLS':
|
||||
smtp.starttls()
|
||||
smtp.login(self.username, self.password)
|
||||
return smtp
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
ret = self._smtp.__exit__(exc_type, exc_val, exc_tb)
|
||||
self._smtp = None
|
||||
return ret
|
||||
|
||||
def send_message(self, msg):
|
||||
if self._smtp is None:
|
||||
raise RuntimeError('SMTP connection is not established')
|
||||
self._smtp.send_message(msg)
|
||||
|
||||
|
||||
class IncomingServerConfig(MailServerConfig, abc.ABC):
|
||||
|
||||
def __init__(self, proto, hostname, port, tls, username, password):
|
||||
super().__init__(proto, hostname, port, tls, username, password)
|
||||
|
||||
def __enter__(self):
|
||||
pass
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def get_new_messages(self):
|
||||
pass
|
||||
|
||||
|
||||
class ImapServerConfig(IncomingServerConfig):
|
||||
|
||||
def __init__(self, hostname, port, tls, username, password):
|
||||
super().__init__('imap', hostname, port, tls, username, password)
|
||||
self._imap = None
|
||||
self._uidnext = None
|
||||
|
||||
def __enter__(self):
|
||||
cls = imaplib.IMAP4 if self.tls != 'SSL' else imaplib.IMAP4_SSL
|
||||
self._imap = cls(self.hostname, self.port)
|
||||
imap = self._imap.__enter__()
|
||||
if self.tls == 'STARTTLS':
|
||||
imap.starttls()
|
||||
imap.login(self.username, self.password)
|
||||
imap.select('INBOX', readonly=True)
|
||||
self._uidnext = int(imap.response('UIDNEXT')[1][0].decode())
|
||||
return imap
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
ret = self._imap.__exit__(exc_type, exc_val, exc_tb)
|
||||
self._imap = None
|
||||
self._uidnext = None
|
||||
return ret
|
||||
|
||||
def get_new_messages(self):
|
||||
if self._imap is None or self._uidnext is None:
|
||||
raise RuntimeError('IMAP connection is not established')
|
||||
self._imap.select('INBOX', readonly=True)
|
||||
u = int(self._imap.response('UIDNEXT')[1][0].decode())
|
||||
if u > self._uidnext:
|
||||
messages = self._imap.uid('fetch', f'{self._uidnext}:{u - 1}', '(RFC822)')
|
||||
for message in messages[1]:
|
||||
if not isinstance(message, tuple) or b'RFC822' not in message[0]:
|
||||
# Not an email message
|
||||
continue
|
||||
yield message[1]
|
||||
self._uidnext = u
|
||||
|
||||
|
||||
class Pop3ServerConfig(IncomingServerConfig):
|
||||
|
||||
def __init__(self, hostname, port, tls, username, password):
|
||||
super().__init__('pop3', hostname, port, tls, username, password)
|
||||
self._pop3: poplib.POP3 = None
|
||||
self._messagelist = None
|
||||
|
||||
def __enter__(self):
|
||||
cls = poplib.POP3 if self.tls != 'SSL' else poplib.POP3_SSL
|
||||
self._pop3 = cls(self.hostname, self.port)
|
||||
if self.tls == 'STARTTLS':
|
||||
self._pop3.stls()
|
||||
self._pop3.user(self.username)
|
||||
self._pop3.pass_(self.password)
|
||||
self._messagelist = set(self._pop3.list()[1])
|
||||
return self._pop3
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self._messagelist = None
|
||||
self._pop3.close()
|
||||
self._pop3 = None
|
||||
|
||||
def get_new_messages(self):
|
||||
if self._pop3 is None or self._messagelist is None:
|
||||
raise RuntimeError('POP3 connection is not established')
|
||||
ml = set(self._pop3.list()[1])
|
||||
for msg in ml.difference(self._messagelist):
|
||||
msgid, _ = msg.split(' ', 1)
|
||||
message = '\r\n'.join(self._pop3.retr(msgid)[1])
|
||||
yield message[1]
|
||||
self._messagelist = ml
|
||||
|
||||
|
||||
def default_port(proto: str, tls: str):
|
||||
if proto == 'imap':
|
||||
return 993 if tls == 'SSL' else 143
|
||||
if proto == 'pop3':
|
||||
return 995 if tls == 'SSL' else 110
|
||||
if proto == 'smtp':
|
||||
return 465 if tls == 'SSL' else 587
|
||||
raise ValueError(f'Unknown protocol: {proto}')
|
||||
|
||||
|
||||
def template_username(template: str, address: str, userinputs):
|
||||
user, domain = address.split('@', 1)
|
||||
template = template \
|
||||
.replace('%EMAILADDRESS%', address) \
|
||||
.replace('%EMAILLOCALPART%', user) \
|
||||
.replace('%EMAILDOMAIN%', domain)
|
||||
for key in userinputs:
|
||||
if key not in template:
|
||||
continue
|
||||
label, value = userinputs[key]
|
||||
if value is None:
|
||||
value = getpass(f'Autoconfiguration field "{label}" (will not echo): ')
|
||||
userinputs[key] = label, value
|
||||
template = template.replace(key, value)
|
||||
return template
|
||||
|
||||
|
||||
def parse_xml_mailserver(xmle, address: str, password: str, userinputs):
|
||||
proto = xmle.get('type')
|
||||
hostname = xmle.find('./hostname').text
|
||||
tls = xmle.find('./socketType').text
|
||||
port = int(xmle.find('./port').text or default_port(proto, tls))
|
||||
username = template_username(xmle.find('./username').text, address, userinputs)
|
||||
pw = xmle.find('./password')
|
||||
if pw:
|
||||
pw = template_username(pw.text, address, userinputs)
|
||||
else:
|
||||
pw = password
|
||||
if proto == 'smtp':
|
||||
return SmtpServerConfig(hostname, port, tls, username, pw)
|
||||
elif proto == 'imap':
|
||||
return ImapServerConfig(hostname, port, tls, username, pw)
|
||||
elif proto == 'pop3':
|
||||
return Pop3ServerConfig(hostname, port, tls, username, pw)
|
||||
|
||||
|
||||
def parse_thunderbird_autoconfig(xml: str, address: str, password: str):
|
||||
user, domain = address.split('@', 1)
|
||||
root = ElementTree.fromstring(xml)
|
||||
userinputs = {}
|
||||
for ui in root.findall('.//userinput'):
|
||||
k = ui.get('key')
|
||||
label = ui.get('label')
|
||||
userinputs[k] = (label, None)
|
||||
incoming = root.findall(f"./emailProvider/domain[.='{domain}']/../incomingServer")
|
||||
outgoing = root.findall(f"./emailProvider/domain[.='{domain}']/../outgoingServer")
|
||||
iconf = [parse_xml_mailserver(i, address, password, userinputs) for i in incoming]
|
||||
oconf = [parse_xml_mailserver(o, address, password, userinputs) for o in outgoing]
|
||||
return iconf, oconf
|
||||
|
||||
|
||||
def tb_wellknown_autoconfig(address: str, password: str):
|
||||
user, domain = address.split('@', 1)
|
||||
subdomain = f'autoconfig.{domain}'
|
||||
sdurl = urllib.parse.urlunsplit(('http', subdomain, 'mail/config-v1.1.xml', f'emailaddress={address}', ''))
|
||||
mdurl = urllib.parse.urlunsplit(('http', domain, '.well-known/autoconfig/mail/config-v1.1.xml',
|
||||
f'emailaddress={address}', ''))
|
||||
try:
|
||||
with urllib.request.urlopen(sdurl) as sdresponse:
|
||||
return parse_thunderbird_autoconfig(sdresponse.read().decode(), address, password)
|
||||
except urllib.error.URLError:
|
||||
try:
|
||||
with urllib.request.urlopen(mdurl) as mdresponse:
|
||||
return parse_thunderbird_autoconfig(mdresponse.read().decode(), address, password)
|
||||
except urllib.error.URLError:
|
||||
return None
|
||||
|
||||
|
||||
def tb_ispdb_autoconfig(address: str, password: str):
|
||||
user, domain = address.split('@', 1)
|
||||
ispdb = f'https://autoconfig.thunderbird.net/v1.1/{domain}'
|
||||
try:
|
||||
with urllib.request.urlopen(ispdb) as response:
|
||||
return parse_thunderbird_autoconfig(response.read().decode(), address, password)
|
||||
except urllib.error.URLError:
|
||||
return None
|
||||
|
||||
|
||||
def manual_config(address: str, password: str):
|
||||
print('Autoconfiguration has failed. Please enter the mail server settings manually.')
|
||||
host = input('SMTP hostname: ')
|
||||
tls = input('TLS ("plain", "SSL" or "STARTLS (default: STARTTLS): ') or 'STARTTLS'
|
||||
p = default_port('smtp', tls)
|
||||
port = int(input(f'SMTP port (default: {p}): ') or str(p))
|
||||
outconfig = SmtpServerConfig(host, port, tls, address, password)
|
||||
proto = input('Mail Retrieval protocol ("imap" or "pop3", default: "imap"): ') or 'imap'
|
||||
host = input(f'{proto.upper()} hostname: ')
|
||||
tls = input('TLS ("plain", "SSL" or "STARTLS (default: SSL): ') or 'SSL'
|
||||
p = default_port(proto, tls)
|
||||
port = int(input(f'{proto.upper()} port (default: {p}): ') or str(p))
|
||||
if proto == 'imap':
|
||||
inconfig = ImapServerConfig(host, port, tls, address, password)
|
||||
else:
|
||||
inconfig = Pop3ServerConfig(host, port, tls, address, password)
|
||||
return [inconfig], [outconfig]
|
||||
|
||||
|
||||
def parse_dns_srv_record(record, service: str, address: str, password: str):
|
||||
user, domain = address.split('@', 1)
|
||||
if service == '_submission':
|
||||
cls = SmtpServerConfig
|
||||
elif service == '_imaps' or service == '_imap':
|
||||
cls = ImapServerConfig
|
||||
elif service == '_pop3s' or service == '_pop3':
|
||||
cls = Pop3ServerConfig
|
||||
else:
|
||||
raise ValueError(service)
|
||||
if service.endswith('s'):
|
||||
tls = ['SSL']
|
||||
else:
|
||||
tls = ['STARTTLS', 'plain']
|
||||
configs = []
|
||||
for t in tls:
|
||||
configs.append(cls(record.target, record.port, t, address, password))
|
||||
configs.append(cls(record.target, record.port, t, user, password))
|
||||
return configs
|
||||
|
||||
|
||||
def rfc6186_autoconfig(address: str, password: str):
|
||||
try:
|
||||
import dns.resolver
|
||||
import dns.rdtypes.IN.SRV
|
||||
except ImportError:
|
||||
print('"dnspython" dependency missing. Skipping RFC 6186 autoconfig')
|
||||
return None
|
||||
user, domain = address.split('@', 1)
|
||||
smtpdomain = f'_submission._tcp.{domain}.'
|
||||
imapsdomain = f'_imaps._tcp.{domain}.'
|
||||
imapdomain = f'_imap._tcp.{domain}.'
|
||||
pop3sdomain = f'_pop3s._tcp.{domain}.'
|
||||
pop3domain = f'_pop3._tcp.{domain}.'
|
||||
osrv = []
|
||||
isrv = []
|
||||
try:
|
||||
smtpanswer: dns.resolver.Answer = dns.resolver.resolve(smtpdomain, dns.rdatatype.SRV)
|
||||
except dns.exception.DNSException:
|
||||
return None
|
||||
for srv in smtpanswer:
|
||||
if srv.rdtype != dns.rdatatype.SRV or srv.rdclass != dns.rdataclass.IN:
|
||||
continue
|
||||
osrv.append((smtpanswer.canonical_name.labels[0].decode(), srv))
|
||||
for sd in [imapsdomain, imapdomain, pop3sdomain, pop3domain]:
|
||||
try:
|
||||
answer: dns.resolver.Answer = dns.resolver.resolve(sd, dns.rdatatype.SRV)
|
||||
for srv in answer:
|
||||
if srv.rdtype != dns.rdatatype.SRV or srv.rdclass != dns.rdataclass.IN:
|
||||
continue
|
||||
if srv.port == 0 and str(srv.target) == '.':
|
||||
continue
|
||||
isrv.append((answer.canonical_name.labels[0].decode(), srv))
|
||||
except dns.exception.DNSException:
|
||||
continue
|
||||
incoming = []
|
||||
outgoing = []
|
||||
for (iqname, irec) in isrv:
|
||||
incoming.extend(parse_dns_srv_record(irec, iqname, address, password))
|
||||
for (oqname, orec) in isrv:
|
||||
outgoing.extend(parse_dns_srv_record(orec, oqname, address, password))
|
||||
return incoming, outgoing
|
||||
|
||||
|
||||
def _parse_confirmation_request(address, fingerprint, encrypted):
|
||||
gpg = subprocess.Popen(['/usr/bin/gpg', '--decrypt'],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
gpg.stdin.write(encrypted)
|
||||
gpg.stdin.close()
|
||||
gpg.wait()
|
||||
if gpg.returncode != 0:
|
||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||
decrypted = gpg.stdout.read().decode()
|
||||
rdict = {}
|
||||
for line in decrypted.splitlines():
|
||||
if ':' not in line:
|
||||
continue
|
||||
key, value = line.split(':', 1)
|
||||
rdict[key.strip()] = value.strip()
|
||||
if rdict.get('type', '') != 'confirmation-request':
|
||||
raise ValueError('Invalid confirmation request: "type" missing or not "confirmation-request"')
|
||||
if 'sender' not in rdict or 'address' not in rdict or 'fingerprint' not in rdict or 'nonce' not in rdict:
|
||||
raise ValueError('Invalid confirmation request: a mandatory item is missing from the request')
|
||||
if rdict['address'] != address:
|
||||
raise ValueError(f'Confirmation address "{rdict["address"]}" does not match my address "{address}"')
|
||||
if rdict['fingerprint'].replace(' ', '') != fingerprint.replace(' ', ''):
|
||||
raise ValueError(
|
||||
f'Confirmation fingerprint "{rdict["fingerprint"]}" does not match my fingerprint "{fingerprint}"')
|
||||
print(f'Nonce: {rdict["nonce"]}')
|
||||
return rdict['sender'], rdict['nonce']
|
||||
|
||||
|
||||
def _create_submission_request(address: str, submission_address: str, fingerprint: str, revoked_fingerprints):
|
||||
gpg = subprocess.Popen([
|
||||
'/usr/bin/gpg', '--locate-keys', '--with-colons', submission_address
|
||||
], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
gpg.wait()
|
||||
if gpg.returncode != 0:
|
||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||
print('Retrieved submission key')
|
||||
gpg = subprocess.Popen([
|
||||
'/usr/bin/gpg', '--armor',
|
||||
'--export-options', 'export-minimal',
|
||||
'--export', fingerprint
|
||||
] + revoked_fingerprints, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
gpg.wait()
|
||||
if gpg.returncode != 0:
|
||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||
print('Retrieved key to publish')
|
||||
pubkey = gpg.stdout.read()
|
||||
gpg = subprocess.Popen([
|
||||
'/usr/bin/gpg', '--armor', '--with-colons',
|
||||
'--encrypt', '--trust-model', 'always',
|
||||
'--local-user', address,
|
||||
'--recipient', address,
|
||||
'--recipient', submission_address
|
||||
], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
gpg.stdin.write(pubkey)
|
||||
gpg.stdin.close()
|
||||
try:
|
||||
gpg.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
gpg.kill()
|
||||
print(f'gpg subprocess timed out; stderr: {gpg.stderr.read()}')
|
||||
raise RuntimeError(f'gpg subprocess timed out; stderr: {gpg.stderr.read()}')
|
||||
if gpg.returncode != 0:
|
||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||
print('Created encrypted message')
|
||||
encrypted = gpg.stdout.read().decode()
|
||||
mail = MIMEText(encrypted, _subtype='plain')
|
||||
mail['Subject'] = 'WKS submission request'
|
||||
mail['To'] = submission_address
|
||||
mail['From'] = address
|
||||
mail['Date'] = format_datetime(datetime.now(timezone.utc))
|
||||
return mail
|
||||
|
||||
|
||||
def _create_confirmation_response(address: str, submission: str, nonce: str, fp: str, content_subtype: str):
|
||||
response_template = '\r\n'.join([
|
||||
'type: confirmation-response',
|
||||
f'sender: {address}',
|
||||
f'nonce: {nonce}',
|
||||
''
|
||||
])
|
||||
payload = MIMEText(response_template, _subtype='plain')
|
||||
gpg = subprocess.Popen([
|
||||
'/usr/bin/gpg', '--armor', '--with-colons',
|
||||
'--encrypt', '--sign', '--trust-model', 'always',
|
||||
'--local-user', fp,
|
||||
'--recipient', address,
|
||||
'--recipient', submission
|
||||
], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
gpg.stdin.write(payload.as_string(policy=email.policy.default).encode())
|
||||
gpg.stdin.close()
|
||||
gpg.wait()
|
||||
if gpg.returncode != 0:
|
||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||
encrypted = gpg.stdout.read().decode()
|
||||
mail = MIMEApplication(encrypted, _subtype=content_subtype)
|
||||
mail['Subject'] = 'WKS confirmation response'
|
||||
mail['To'] = submission
|
||||
mail['From'] = address
|
||||
mail['Date'] = format_datetime(datetime.now(timezone.utc))
|
||||
return mail
|
||||
|
||||
|
||||
def handle_incoming_message(address, fingerprint, rfc822, smtp_config: SmtpServerConfig):
|
||||
msg: email.message.EmailMessage = BytesParser(policy=email.policy.default).parsebytes(rfc822)
|
||||
if msg.get('wks-phase', '') == 'done':
|
||||
pgp = _get_pgp_message(msg)
|
||||
if pgp is None:
|
||||
print('WKS key submission successful. Congratulations!')
|
||||
return True
|
||||
print('Decrypting WKS response. GnuPG may prompt you for your passphrase.')
|
||||
gpg = subprocess.Popen(['/usr/bin/gpg', '--batch', '--decrypt', '--skip-verify'],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
gpg.stdin.write(pgp)
|
||||
gpg.stdin.close()
|
||||
gpg.wait()
|
||||
if gpg.returncode != 0:
|
||||
print('It seems WKS submission was successful, however decryption of the submission response failed: ' +
|
||||
gpg.stderr.read().decode())
|
||||
return True
|
||||
decrypted = email.parser.BytesParser(policy=email.policy.default).parsebytes(gpg.stdout.read())
|
||||
body = decrypted.get_body(preferencelist=('plain',))
|
||||
if body is None:
|
||||
print('WKS key submission successful. Congratulations!')
|
||||
else:
|
||||
print(body.get_content())
|
||||
return True
|
||||
if msg.get('wks-phase', '') == 'error':
|
||||
body = msg.get_body(preferencelist=('plain',))
|
||||
if body is None:
|
||||
print('WKS key submission failed. However, the WKS server did not return an error description.')
|
||||
else:
|
||||
print(body.get_content())
|
||||
return True
|
||||
for leaf in msg.walk():
|
||||
if leaf.get_content_type() not in ['application/vnd.gnupg.wkd', 'application/vnd.gnupg.wks']:
|
||||
continue
|
||||
print('Received confirmation request')
|
||||
try:
|
||||
submission, nonce = _parse_confirmation_request(address, fingerprint, leaf.get_content())
|
||||
except BaseException as e:
|
||||
print(f'Parsing failed: {e}')
|
||||
continue
|
||||
print('Creating confirmation response. GnuPG may prompt you for your passphrase.')
|
||||
response = _create_confirmation_response(address, submission, nonce, fingerprint, leaf.get_content_subtype())
|
||||
print('Sending confirmation response')
|
||||
with smtp_config:
|
||||
smtp_config.send_message(response)
|
||||
print('Awaiting publish response')
|
||||
return False
|
||||
|
||||
|
||||
def _gpg_get_uid_fp(address: str):
|
||||
gpg = subprocess.Popen([
|
||||
'/usr/bin/gpg', '--with-colons', '--list-keys', address
|
||||
], stdout=subprocess.PIPE)
|
||||
gpg.wait()
|
||||
if gpg.returncode != 0:
|
||||
raise RuntimeError(f'gpg subprocess returned with non-zero exit code; stderr: {gpg.stderr.read()}')
|
||||
keylist = gpg.stdout.read().decode()
|
||||
pubs = []
|
||||
revoked = []
|
||||
fprs = []
|
||||
for line in keylist.splitlines():
|
||||
if line.startswith('pub:'):
|
||||
pub = line.split(':')[4]
|
||||
r = line.split(':')[1] == 'r'
|
||||
pubs.append(pub)
|
||||
revoked.append(r)
|
||||
elif line.startswith('fpr:'):
|
||||
fpr = line.split(':')[9]
|
||||
fprs.append(fpr)
|
||||
valid = {next((f for f in fprs if f.endswith(pub))): pub for i, pub in enumerate(pubs) if not revoked[i]}
|
||||
revoked = {next((f for f in fprs if f.endswith(pub))): pub for i, pub in enumerate(pubs) if revoked[i]}
|
||||
if len(valid) == 0:
|
||||
raise ValueError(f'No valid key found for {address}.')
|
||||
elif len(valid) > 1:
|
||||
print(f'Found multiple keys for {address}, please choose:')
|
||||
fpridx = list(valid.keys())
|
||||
for i, f in enumerate(fpridx, start=1):
|
||||
print(f'{i}: {f}')
|
||||
i = int(input('Enter number: ')) - 1
|
||||
fpr = fpridx[i]
|
||||
else:
|
||||
fpr = list(valid.keys())[0]
|
||||
if len(revoked) > 0:
|
||||
print(f'There are revoked keys for {address}. Please choose which to upload (separate multiple by spaces): ')
|
||||
revidx = list(revoked.keys())
|
||||
for i, f in enumerate(revidx, start=1):
|
||||
print(f'{i}: {f}')
|
||||
rids = [int(i)-1 for i in input('Enter number(s): ').split()]
|
||||
rfprs = [revidx[i] for i in rids]
|
||||
else:
|
||||
rfprs = []
|
||||
return fpr, rfprs
|
||||
|
||||
|
||||
def _get_submission_address(address: str):
|
||||
_, domain = address.split('@', 1)
|
||||
advanced_url = f'https://openpgpkey.{domain}/.well-known/openpgpkey/{domain}/submission-address'
|
||||
direct_url = f'https://{domain}/.well-known/openpgpkey/submission-address'
|
||||
try:
|
||||
with urllib.request.urlopen(advanced_url) as response:
|
||||
return response.read().decode().strip()
|
||||
except urllib.error.URLError:
|
||||
pass
|
||||
with urllib.request.urlopen(direct_url) as response:
|
||||
return response.read().decode().strip()
|
||||
|
||||
|
||||
def main():
|
||||
ad = input('Enter email: ')
|
||||
sa = None
|
||||
try:
|
||||
sa = _get_submission_address(ad)
|
||||
except urllib.error.URLError:
|
||||
print('No WKS submission address found. Does your provider support WKS?')
|
||||
exit(1)
|
||||
fp, rfprs = _gpg_get_uid_fp(ad)
|
||||
print(f'Chose {fp}')
|
||||
for rfpr in rfprs:
|
||||
print(f'Chose revoked key {rfpr}')
|
||||
pw = getpass('Enter IMAP/POP3/SMTP password (will not echo): ')
|
||||
for fn in [tb_wellknown_autoconfig, rfc6186_autoconfig, tb_ispdb_autoconfig, manual_config]:
|
||||
autoconf = fn(ad, pw)
|
||||
if autoconf is not None:
|
||||
break
|
||||
if autoconf is None:
|
||||
raise RuntimeError('No autoconfig available')
|
||||
incoming, outgoing = autoconf
|
||||
# Find the first working server configurations
|
||||
incoming_server = None
|
||||
outgoing_server = None
|
||||
for i in sorted(incoming):
|
||||
try:
|
||||
with i:
|
||||
incoming_server = i
|
||||
break
|
||||
except:
|
||||
continue
|
||||
if incoming_server is None:
|
||||
raise RuntimeError('No working IMAP/POP3 server found through autoconfiguration. Please specify manually.')
|
||||
print(f'Autoconfigured incoming server: {incoming_server}')
|
||||
for i in sorted(outgoing):
|
||||
try:
|
||||
with i:
|
||||
outgoing_server = i
|
||||
break
|
||||
except:
|
||||
continue
|
||||
if outgoing_server is None:
|
||||
raise RuntimeError('No working SMTP server found through autoconfiguration. Please specify manually.')
|
||||
print(f'Autoconfigured outgoing server: {outgoing_server}')
|
||||
confirm = input('Please confirm: [Y/n] ')
|
||||
if confirm.lower() not in ['', 'y', 'yes']:
|
||||
print('Aborted')
|
||||
exit(1)
|
||||
with incoming_server:
|
||||
now = time.monotonic()
|
||||
done = False
|
||||
request = _create_submission_request(ad, sa, fp, rfprs)
|
||||
print('Sending submission request')
|
||||
with outgoing_server:
|
||||
outgoing_server.send_message(request)
|
||||
print('Awaiting response')
|
||||
while not done and time.monotonic() - now < 300:
|
||||
time.sleep(5)
|
||||
for message in incoming_server.get_new_messages():
|
||||
done = handle_incoming_message(ad, fp, message, outgoing_server)
|
||||
if done:
|
||||
break
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
__version__ = '0.1.3'
|
||||
__version__ = '0.4.6'
|
||||
|
|
|
@ -3,6 +3,33 @@ import string
|
|||
import yaml
|
||||
|
||||
|
||||
POLICY_MAILBOX_ONLY = 'mailbox-only'
|
||||
POLICY_AUTH_SUBMIT = 'auth-submit'
|
||||
POLICY_PROTOCOL_VERSION = 'protocol-version'
|
||||
STANDARD_POLICY_FLAGS = {
|
||||
POLICY_MAILBOX_ONLY: bool,
|
||||
# 'dane-only': bool, # deprecated
|
||||
POLICY_AUTH_SUBMIT: bool,
|
||||
POLICY_PROTOCOL_VERSION: int,
|
||||
}
|
||||
|
||||
EASYWKS_POLICY_PREFIX = 'me.s3lph.easywks_'
|
||||
EWP_PERMIT_UNSIGNED_RESPONSE = EASYWKS_POLICY_PREFIX + 'permit-unsigned-response'
|
||||
EWP_STRIP_UNVERIFIED_UIDS = EASYWKS_POLICY_PREFIX + 'strip-unverified-uids'
|
||||
EWP_STRIP_UA_UIDS = EASYWKS_POLICY_PREFIX + 'strip-ua-uids'
|
||||
EWP_STRIP_3RDPARTY_SIGNATURES = EASYWKS_POLICY_PREFIX + 'strip-3rdparty-signatures'
|
||||
EWP_MAX_REVOKED_KEYS = EASYWKS_POLICY_PREFIX + 'max-revoked-keys'
|
||||
EWP_MINIMIZE_REVOKED_KEYS = EASYWKS_POLICY_PREFIX + 'minimize-revoked-keys'
|
||||
EASYWKS_POLICY_FLAGS = {
|
||||
EWP_PERMIT_UNSIGNED_RESPONSE: bool,
|
||||
EWP_STRIP_UNVERIFIED_UIDS: bool,
|
||||
EWP_STRIP_UA_UIDS: bool,
|
||||
EWP_STRIP_3RDPARTY_SIGNATURES: bool,
|
||||
EWP_MAX_REVOKED_KEYS: int,
|
||||
EWP_MINIMIZE_REVOKED_KEYS: bool,
|
||||
}
|
||||
|
||||
|
||||
def _validate_mailing_method(value):
|
||||
methods = ['stdout', 'smtp']
|
||||
if value not in methods:
|
||||
|
@ -37,6 +64,26 @@ def _validate_smtp_config(value):
|
|||
|
||||
|
||||
def _validate_httpd_config(value):
|
||||
if not isinstance(value, dict):
|
||||
return f'must be a map, got {type(value)}'
|
||||
if 'host' in value:
|
||||
if not isinstance(value['host'], str):
|
||||
return f'host must be a str, got {type(value["host"])}'
|
||||
else:
|
||||
value['host'] = 'localhost'
|
||||
if 'port' in value:
|
||||
if not isinstance(value['port'], int):
|
||||
return f'port must be a int, got {type(value["port"])}'
|
||||
else:
|
||||
value['port'] = 8080
|
||||
if 'require_user_urlparam' in value:
|
||||
if not isinstance(value['require_user_urlparam'], bool):
|
||||
return f'port must be a bool, got {type(value["require_user_urlparam"])}'
|
||||
else:
|
||||
value['require_user_urlparam'] = True
|
||||
|
||||
|
||||
def _validate_lmtpd_config(value):
|
||||
if not isinstance(value, dict):
|
||||
return f'must be a map, got {type(value)}'
|
||||
if not isinstance(value['host'], str):
|
||||
|
@ -45,7 +92,7 @@ def _validate_httpd_config(value):
|
|||
return f'port must be a int, got {type(value["port"])}'
|
||||
|
||||
|
||||
def _validate_lmtpd_config(value):
|
||||
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):
|
||||
|
@ -66,6 +113,98 @@ def _validate_policy_flags(value):
|
|||
for c in flag:
|
||||
if c not in alphabet:
|
||||
return f'has invalid key {flag}'
|
||||
if '_' in flag:
|
||||
if flag.startswith(EASYWKS_POLICY_PREFIX):
|
||||
cls = EASYWKS_POLICY_FLAGS.get(flag)
|
||||
if flag not in EASYWKS_POLICY_FLAGS:
|
||||
return f'unknown policy flag {flag}'
|
||||
if not isinstance(v, cls):
|
||||
return f'invalid type {v.__class__.__name__} for flag {flag}'
|
||||
else:
|
||||
cls = STANDARD_POLICY_FLAGS.get(flag)
|
||||
if flag not in STANDARD_POLICY_FLAGS:
|
||||
return f'unknown policy flag {flag}'
|
||||
if not isinstance(v, cls):
|
||||
return f'invalid type {v.__class__.__name__} for flag {flag}'
|
||||
|
||||
|
||||
def _validate_dane(value):
|
||||
if not isinstance(value, dict):
|
||||
return f'must be a map, got {type(value)}'
|
||||
if 'soa' in value:
|
||||
pass
|
||||
else:
|
||||
value['soa'] = {}
|
||||
if 'ns' in value:
|
||||
ns = value['ns']
|
||||
if not isinstance(ns, list):
|
||||
return f'ns must map to a list, got {type(ns)}'
|
||||
for k in ns:
|
||||
if not isinstance(k, str):
|
||||
return f'ns items must be strings, got {type(k)}'
|
||||
else:
|
||||
value['ns'] = ['localhost.']
|
||||
if 'notify' in value:
|
||||
notify = value['notify']
|
||||
if not isinstance(notify, list):
|
||||
return f'notify must map to a list, got {type(notify)}'
|
||||
for k in notify:
|
||||
if not isinstance(k, str):
|
||||
return f'notify items must be strings, got {type(k)}'
|
||||
else:
|
||||
value['notify'] = []
|
||||
|
||||
|
||||
def _validate_responses(value):
|
||||
if not isinstance(value, dict):
|
||||
return f'must be a map, got {type(value)}'
|
||||
if 'header' not in value:
|
||||
value['header'] = '''Hi there!
|
||||
|
||||
This is the EasyWKS system at {domain}.
|
||||
'''
|
||||
if 'footer' not in value:
|
||||
value['footer'] = '''For more information on WKD and WKS see:
|
||||
|
||||
https://gnupg.org/faq/wkd.html
|
||||
https://gnupg.org/faq/wks.html
|
||||
|
||||
|
||||
Regards
|
||||
EasyWKS
|
||||
|
||||
--
|
||||
Dance like nobody is watching.
|
||||
Encrypt live everybody is.
|
||||
'''
|
||||
if 'confirm' not in value:
|
||||
value['confirm'] = '''You appear to have submitted your key for publication in the Web Key
|
||||
Directory. There's one more step you need to complete. If you did not
|
||||
request this, you can simply ignore this message.
|
||||
|
||||
If your email client doesn't automatically complete this challenge, you
|
||||
can perform this step manually: Please verify that you can decrypt the
|
||||
second part of this message and that the fingerprint listed in the
|
||||
encrypted part matches your key. If everything looks ok, please reply
|
||||
to this message with an **encrypted and signed PGP/MIME message** with
|
||||
the following content (without the <> brackets)
|
||||
|
||||
type: confirmation-response
|
||||
sender: <your email address>
|
||||
nonce: <copy the nonce from the encrypted part of this message>
|
||||
'''
|
||||
if 'done' not in value:
|
||||
value['done'] = '''Your key has been published to the Web Key Directory.
|
||||
You can test WKD key retrieval e.g. with:
|
||||
|
||||
gpg --auto-key-locate=wkd,nodefault --locate-key {sender}
|
||||
'''
|
||||
if 'error' not in value:
|
||||
value['error'] = '''An error has occurred while processing your request:
|
||||
|
||||
{error}
|
||||
|
||||
If this error persists, please contact your administrator for help.'''
|
||||
|
||||
|
||||
class _ConfigOption:
|
||||
|
@ -110,9 +249,10 @@ class _GlobalConfig(_Config):
|
|||
|
||||
def __make_domain(self, domain):
|
||||
self.__domains[domain] = _Config(
|
||||
submission_address=_ConfigOption('address', str, f'gpgwks@{domain}'),
|
||||
submission_address=_ConfigOption('submission_address', str, f'gpgwks@{domain}'),
|
||||
passphrase=_ConfigOption('passphrase', str, ''),
|
||||
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags)
|
||||
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags),
|
||||
dane=_ConfigOption('dane', dict, {}, validator=_validate_dane)
|
||||
)
|
||||
|
||||
def __getitem__(self, item):
|
||||
|
@ -131,17 +271,19 @@ class _GlobalConfig(_Config):
|
|||
for domain, dconf in conf['domains'].items():
|
||||
self.__make_domain(domain)
|
||||
for co in self.__domains[domain]._options.values():
|
||||
co.load(conf)
|
||||
co.load(dconf)
|
||||
|
||||
|
||||
Config = _GlobalConfig(
|
||||
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
|
||||
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800),
|
||||
mailing_method=_ConfigOption('mailing_method', str, 'stdout', validator=_validate_mailing_method),
|
||||
# TODO: permit_unsigned_response is deprecated, but for now retained for backwards compatibility
|
||||
permit_unsigned_response=_ConfigOption('permit_unsigned_response', bool, False),
|
||||
httpd=_ConfigOption('httpd', dict, {
|
||||
'host': 'localhost',
|
||||
'port': 8080
|
||||
'port': 8080,
|
||||
'require_user_urlparam': True
|
||||
}, validator=_validate_httpd_config),
|
||||
smtp=_ConfigOption('smtp', dict, {
|
||||
'host': 'localhost',
|
||||
|
@ -153,4 +295,16 @@ Config = _GlobalConfig(
|
|||
'host': 'localhost',
|
||||
'port': 25,
|
||||
}, validator=_validate_lmtpd_config),
|
||||
dnsd=_ConfigOption('dnsd', dict, {
|
||||
'host': '::1',
|
||||
'port': 10053,
|
||||
}, validator=_validate_dnsd_config),
|
||||
responses=_ConfigOption('responses', dict, {}, validator=_validate_responses),
|
||||
)
|
||||
|
||||
|
||||
def render_message(key, **kwargs):
|
||||
header = Config.responses['header'].format(**kwargs)
|
||||
content = Config.responses[key].format(**kwargs)
|
||||
footer = Config.responses['footer'].format(**kwargs)
|
||||
return f'{header}\n{content}\n{footer}'
|
||||
|
|
90
easywks/dnsd.py
Normal file
90
easywks/dnsd.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
from datetime import datetime
|
||||
|
||||
from twisted.internet import reactor, defer
|
||||
from twisted.names import dns, server, common, error
|
||||
from twisted.python import util as tputil, failure
|
||||
|
||||
from .config import Config
|
||||
from .files import read_dane_public_keys
|
||||
|
||||
|
||||
class Record_OPENPGPKEY(tputil.FancyEqMixin, tputil.FancyStrMixin):
|
||||
TYPE = 61
|
||||
fancybasename = 'OPENPGPKEY'
|
||||
compareAttributes = ('data',)
|
||||
showAttributes = ('data',)
|
||||
|
||||
def __init__(self, data=None, ttl=0):
|
||||
self.data = data
|
||||
self.ttl = ttl
|
||||
|
||||
def encode(self, strio, compDict=None):
|
||||
strio.write(self.data)
|
||||
|
||||
def decode(self, strio, length=None):
|
||||
self.data = strio.read(length)
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.data)
|
||||
|
||||
|
||||
class DnsServer(common.ResolverBase):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.zones = {}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
for domain in Config.domains:
|
||||
origin = dns.domainString(f'_openpgpkey.{domain}')
|
||||
self.zones[origin] = domain
|
||||
|
||||
def _make_soa(self, name):
|
||||
domain = self.zones[name]
|
||||
now = int(datetime.utcnow().timestamp()) // 60
|
||||
soa = dns.Record_SOA(mname=Config[domain].dane['soa'].get('mname', 'localhost.'),
|
||||
rname=Config[domain].dane['soa'].get('rname',
|
||||
Config[domain].submission_address.replace('@', '.')),
|
||||
serial=now,
|
||||
refresh=Config[domain].dane['soa'].get('refresh', 300),
|
||||
retry=Config[domain].dane['soa'].get('retry', 60),
|
||||
expire=Config[domain].dane['soa'].get('expire', 2419200),
|
||||
minimum=Config[domain].dane['soa'].get('minimum', 300))
|
||||
return dns.RRHeader(name, dns.SOA, payload=soa, auth=True)
|
||||
|
||||
def _make_ns(self, name):
|
||||
domain = self.zones[name]
|
||||
return [
|
||||
dns.RRHeader(name, dns.NS, payload=dns.Record_NS(host), auth=True)
|
||||
for host in Config[domain].dane['ns']
|
||||
]
|
||||
|
||||
def _lookup(self, name, cls, type, timeout):
|
||||
if name not in self.zones:
|
||||
return defer.fail(failure.Failure(dns.AuthoritativeDomainError(name)))
|
||||
if type == dns.SOA or type == dns.OPT:
|
||||
return defer.succeed(([self._make_soa(name)], [], []))
|
||||
if type != dns.AXFR and type != dns.IXFR:
|
||||
return defer.fail(failure.Failure(error.DNSQueryRefusedError(name)))
|
||||
domain = self.zones[name]
|
||||
results = []
|
||||
soa = self._make_soa(name)
|
||||
ns = self._make_ns(name)
|
||||
results.append(soa)
|
||||
results.extend(ns)
|
||||
for digest, key in read_dane_public_keys(domain).items():
|
||||
fqdn = f'{digest}._openpgpkey.{domain}'
|
||||
record = Record_OPENPGPKEY(key)
|
||||
results.append(dns.RRHeader(dns.domainString(fqdn), record.TYPE, payload=record, auth=True))
|
||||
results.append(soa)
|
||||
return defer.succeed((results, [], []))
|
||||
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
# The "reactor" interface is created dynamically, so the listenTCP and run methods only become available during runtime.
|
||||
def run_dnsd(args):
|
||||
auth = DnsServer()
|
||||
factory = server.DNSServerFactory(authorities=[auth])
|
||||
reactor.listenTCP(Config.dnsd['port'], factory, interface=Config.dnsd['host'])
|
||||
reactor.run()
|
|
@ -1,5 +1,6 @@
|
|||
|
||||
import os
|
||||
import fcntl
|
||||
import stat
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
@ -7,7 +8,25 @@ from pgpy import PGPKey
|
|||
|
||||
from .config import Config
|
||||
from .crypto import create_pgp_key, privkey_to_pubkey
|
||||
from .util import hash_user_id
|
||||
from .util import hash_user_id, armor_keys, split_revoked, dane_digest, dane_notify
|
||||
|
||||
|
||||
def _locked_read(file: str, binary: bool = False):
|
||||
with open(file, 'r' + 'b' * binary) as f:
|
||||
fcntl.lockf(f, fcntl.LOCK_SH)
|
||||
content = f.read()
|
||||
fcntl.lockf(f, fcntl.LOCK_UN)
|
||||
return content
|
||||
|
||||
|
||||
def _locked_write(file: str, content, binary: bool = False):
|
||||
with open(file, 'a' + 'b' * binary) as f:
|
||||
fcntl.lockf(f, fcntl.LOCK_EX)
|
||||
f.seek(0)
|
||||
f.truncate()
|
||||
f.write(content)
|
||||
fcntl.lockf(f, fcntl.LOCK_UN)
|
||||
return content
|
||||
|
||||
|
||||
def make_submission_address_file(domain: str):
|
||||
|
@ -17,10 +36,15 @@ def make_submission_address_file(domain: str):
|
|||
def make_policy_file(domain: str):
|
||||
content = f'submission-address: {Config[domain].submission_address}\n'
|
||||
for flag, value in Config[domain].policy_flags.items():
|
||||
if not value or len(value) == 0:
|
||||
content += f'{flag}: {value}\n'
|
||||
else:
|
||||
if isinstance(value, bool):
|
||||
if not value:
|
||||
continue
|
||||
else:
|
||||
content += flag + '\n'
|
||||
elif value is None:
|
||||
content += flag + '\n'
|
||||
else:
|
||||
content += f'{flag}: {value}\n'
|
||||
return content
|
||||
|
||||
|
||||
|
@ -32,43 +56,64 @@ def init_working_directory():
|
|||
# Create necessary files and directories
|
||||
os.makedirs(os.path.join(wdir, domain, 'hu'), exist_ok=True)
|
||||
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
|
||||
with open(os.path.join(wdir, domain, 'submission-address'), 'w') as saf:
|
||||
saf.write(make_submission_address_file(domain))
|
||||
with open(os.path.join(wdir, domain, 'policy'), 'w') as polf:
|
||||
polf.write(make_policy_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))
|
||||
os.makedirs(os.path.join(wdir, domain, 'dane'), exist_ok=True)
|
||||
# Create PGP key if it doesn't exist yet
|
||||
create_pgp_key(domain)
|
||||
# Export submission key to hu dir
|
||||
key = privkey_to_pubkey(domain)
|
||||
uid = hash_user_id(Config[domain].submission_address)
|
||||
with open(os.path.join(wdir, domain, 'hu', uid), 'wb') as hu:
|
||||
hu.write(bytes(key))
|
||||
_locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True)
|
||||
digest = dane_digest(Config[domain].submission_address)
|
||||
_locked_write(os.path.join(wdir, domain, 'dane', digest), bytes(key), binary=True)
|
||||
dane_notify(domain)
|
||||
|
||||
|
||||
def read_public_key(domain, user):
|
||||
hu = hash_user_id(user)
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||
key, _ = PGPKey.from_file(keyfile)
|
||||
return key
|
||||
return read_hashed_public_key(domain, hash_user_id(user))
|
||||
|
||||
|
||||
def write_public_key(domain, user, key):
|
||||
hu = hash_user_id(user)
|
||||
def read_hashed_public_key(domain, hu):
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||
with open(keyfile, 'wb') as f:
|
||||
f.write(bytes(key))
|
||||
_, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True))
|
||||
key, revoked = split_revoked(keys.values())
|
||||
return key, revoked
|
||||
|
||||
|
||||
def read_dane_public_keys(domain):
|
||||
path: str = os.path.join(Config.working_directory, domain, 'dane')
|
||||
dane_keys = {}
|
||||
for fname in os.listdir(path):
|
||||
if len(fname) != 56:
|
||||
continue
|
||||
keyfile = os.path.join(path, fname)
|
||||
dane_keys[fname] = _locked_read(keyfile, binary=True)
|
||||
return dane_keys
|
||||
|
||||
|
||||
def write_public_key(domain, user, key, revoked):
|
||||
hu = hash_user_id(user)
|
||||
dane = dane_digest(user)
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||
danefile = os.path.join(Config.working_directory, domain, 'dane', dane)
|
||||
joined = bytes(key) + b''.join([bytes(k) for k in revoked])
|
||||
_locked_write(keyfile, joined, binary=True)
|
||||
_locked_write(danefile, bytes(key), binary=True)
|
||||
dane_notify(domain)
|
||||
|
||||
|
||||
def read_pending_key(domain, nonce):
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||
key, _ = PGPKey.from_file(keyfile)
|
||||
return key
|
||||
_, keys = PGPKey.from_blob(_locked_read(keyfile))
|
||||
key, revoked = split_revoked(keys.values())
|
||||
return key[0], revoked
|
||||
|
||||
|
||||
def write_pending_key(domain, nonce, key):
|
||||
def write_pending_key(domain, nonce, key, revoked_keys):
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||
with open(keyfile, 'w') as f:
|
||||
f.write(str(key))
|
||||
armored = armor_keys([key] + revoked_keys)
|
||||
_locked_write(keyfile, armored)
|
||||
|
||||
|
||||
def remove_pending_key(domain, nonce):
|
||||
|
@ -76,7 +121,7 @@ def remove_pending_key(domain, nonce):
|
|||
os.unlink(keyfile)
|
||||
|
||||
|
||||
def clean_stale_requests():
|
||||
def clean_stale_requests(args):
|
||||
stale = (datetime.utcnow() - timedelta(seconds=Config.pending_lifetime)).timestamp()
|
||||
for domain in Config.domains:
|
||||
pending_dir = os.path.join(Config.working_directory, domain, 'pending')
|
||||
|
|
80
easywks/httpd.py
Normal file
80
easywks/httpd.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
|
||||
from .config import Config
|
||||
from .files import read_hashed_public_key, make_submission_address_file, make_policy_file
|
||||
from .util import hash_user_id
|
||||
|
||||
from bottle import get, run, response, request, HTTPError
|
||||
|
||||
|
||||
CORS = ('Access-Control-Allow-Origin', '*')
|
||||
|
||||
|
||||
def abort(code, text):
|
||||
err = HTTPError(code, text)
|
||||
err.add_header(*CORS)
|
||||
return err
|
||||
|
||||
|
||||
def get_domain_header():
|
||||
domain = request.get_header('host')
|
||||
if len(domain) != 1 or domain[0] is None:
|
||||
abort(400, 'Bad Request')
|
||||
return domain[0]
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
||||
def advanced_submission_address(domain: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
response.add_header('Content-Type', 'text/plain')
|
||||
response.add_header(*CORS)
|
||||
return make_submission_address_file(domain)
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/submission-address')
|
||||
def direct_submission_address():
|
||||
return advanced_submission_address(get_domain_header())
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/policy')
|
||||
def advanced_policy(domain: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
response.add_header('Content-Type', 'text/plain')
|
||||
response.add_header(*CORS)
|
||||
return make_policy_file(domain)
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/policy')
|
||||
def direct_policy():
|
||||
return advanced_policy(get_domain_header())
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
|
||||
def advanced_hu(domain: str, userhash: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
if Config.httpd['require_user_urlparam']:
|
||||
userid = request.query.l
|
||||
if not userid or hash_user_id(userid) != userhash:
|
||||
abort(404, 'Not Found')
|
||||
try:
|
||||
pubkey, revoked = read_hashed_public_key(domain, userhash)
|
||||
response.add_header('Content-Type', 'application/octet-stream')
|
||||
response.add_header(*CORS)
|
||||
return bytes(pubkey[0]) + b''.join([bytes(k) for k in revoked])
|
||||
except FileNotFoundError:
|
||||
abort(404, 'Not Found')
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/hu/<userhash>')
|
||||
def direct_hu(userhash: str):
|
||||
return advanced_hu(get_domain_header(), userhash)
|
||||
|
||||
|
||||
def run_server(args):
|
||||
run(host=Config.httpd['host'], port=Config.httpd['port'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_server(None)
|
|
@ -13,29 +13,15 @@ from .types import EasyWksError
|
|||
|
||||
class LmtpMailServer:
|
||||
|
||||
async def handle_MAIL(self, server, session, envelope, address, mail_options):
|
||||
_, domain = address.split('@', 1)
|
||||
if domain not in Config.domains:
|
||||
return '550 Not accepting mails from this domain'
|
||||
envelope.mail_from = address
|
||||
return '250 OK'
|
||||
|
||||
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||
for domain in Config.domains:
|
||||
if Config[domain].submission_address == address:
|
||||
envelope.rcpt_tos.append(address)
|
||||
return '250 OK'
|
||||
return '550 Not responsible for this address'
|
||||
|
||||
async def handle_DATA(self, server, session, envelope):
|
||||
message = envelope.content
|
||||
try:
|
||||
process_mail(message)
|
||||
except EasyWksError as e:
|
||||
return f'550 {e}'
|
||||
except BaseException:
|
||||
tb = traceback.format_exc()
|
||||
return f'550 Error during message processing: {tb}'
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
return f'550 Error during message processing: {e}'
|
||||
return '250 Message successfully handled'
|
||||
|
||||
|
||||
|
@ -48,7 +34,7 @@ class LmtpdController(Controller):
|
|||
return LMTP(handler=self.handler, ident=f'EasyWKS {version}', loop=self.loop)
|
||||
|
||||
|
||||
def run_lmtpd():
|
||||
def run_lmtpd(args):
|
||||
controller = LmtpdController(handler=LmtpMailServer(), hostname=Config.lmtpd['host'], port=Config.lmtpd['port'])
|
||||
controller.start()
|
||||
asyncio.get_event_loop().run_forever()
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
|
||||
from .config import Config
|
||||
from .files import init_working_directory, clean_stale_requests
|
||||
from .process import process_mail_from_stdin
|
||||
from .server import run_server
|
||||
from .process import process_mail_from_stdin, process_key_from_stdin
|
||||
from .httpd import run_server
|
||||
from .lmtpd import run_lmtpd
|
||||
from .dnsd import run_dnsd
|
||||
|
||||
|
||||
import sys
|
||||
|
@ -32,6 +33,14 @@ def parse_arguments():
|
|||
server = sp.add_parser('lmtpd', help='Run a LMTP server to receive mails from your MTA. Also see process.')
|
||||
server.set_defaults(fn=run_lmtpd)
|
||||
|
||||
server = sp.add_parser('dnsd', help='Run an authoritative DNS server to provide DANE TYPE61 zones.')
|
||||
server.set_defaults(fn=run_dnsd)
|
||||
|
||||
imp = sp.add_parser('import', help='Import a public key from stdin directly into the WKD without WKS verification.')
|
||||
imp.add_argument('--uid', '-u', type=str, action='append',
|
||||
help='Limit import to a subset of the key\'s UIDs. Can be provided multiple times.')
|
||||
imp.set_defaults(fn=process_key_from_stdin)
|
||||
|
||||
return ap.parse_args(sys.argv[1:])
|
||||
|
||||
|
||||
|
@ -44,7 +53,7 @@ def main():
|
|||
Config.load_config(conf)
|
||||
init_working_directory()
|
||||
if args.fn:
|
||||
args.fn()
|
||||
args.fn(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
import sys
|
||||
from typing import List, Dict
|
||||
from typing import Any, List, Dict, Tuple
|
||||
|
||||
from .crypto import pgp_decrypt
|
||||
from .mailing import get_mailing_method
|
||||
from .config import Config
|
||||
from .config import Config, \
|
||||
POLICY_MAILBOX_ONLY, EWP_MAX_REVOKED_KEYS, EWP_STRIP_UNVERIFIED_UIDS, EWP_STRIP_3RDPARTY_SIGNATURES, \
|
||||
EWP_STRIP_UA_UIDS, EWP_MINIMIZE_REVOKED_KEYS, EWP_PERMIT_UNSIGNED_RESPONSE, POLICY_AUTH_SUBMIT
|
||||
from .files import read_pending_key, write_public_key, remove_pending_key, write_pending_key
|
||||
from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError,\
|
||||
from .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError, \
|
||||
XLOOP_HEADER
|
||||
from .types import fingerprint
|
||||
from .util import split_revoked
|
||||
|
||||
from email.message import MIMEPart, Message
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default
|
||||
from email.utils import getaddresses
|
||||
|
||||
from pgpy import PGPMessage, PGPKey, PGPUID
|
||||
from pgpy import PGPMessage, PGPKey, PGPUID, PGPSignature
|
||||
from pgpy.errors import PGPError
|
||||
|
||||
|
||||
|
@ -45,30 +49,43 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
|
|||
return pgp
|
||||
|
||||
|
||||
def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
|
||||
pubkey = None
|
||||
def _get_pgp_publickeys(parts: List[MIMEPart]) -> Tuple[PGPKey, List[PGPKey]]:
|
||||
pubkeys: Dict[str, PGPKey] = {}
|
||||
for part in parts:
|
||||
try:
|
||||
key, _ = PGPKey.from_blob(part.get_content())
|
||||
if key.is_public:
|
||||
if pubkey:
|
||||
raise EasyWksError('More than one PGP public key in message. Only submit a single key at once.')
|
||||
pubkey = key
|
||||
_, keys = PGPKey.from_blob(part.get_content())
|
||||
for (_, public), key in keys.items():
|
||||
if not public:
|
||||
continue
|
||||
fpr = fingerprint(key)
|
||||
if fpr in pubkeys:
|
||||
raise EasyWksError(f'Key with fingerprint {fpr} appears multiple times in submission request.')
|
||||
pubkeys[fpr] = key
|
||||
except PGPError:
|
||||
pass
|
||||
if not pubkey:
|
||||
if len(pubkeys) == 0:
|
||||
raise EasyWksError('No PGP public key found in the encrypted message part.')
|
||||
return pubkey
|
||||
key, revoked_keys = split_revoked(pubkeys.values())
|
||||
if len(key) < 1:
|
||||
raise EasyWksError('All of the submitted keys appear to be revoked.')
|
||||
elif len(key) > 1:
|
||||
fprs = ' '.join([fingerprint(k) for k in key])
|
||||
raise EasyWksError(f'More than one non-revoked key was submitted: {fprs}')
|
||||
return key[0], revoked_keys
|
||||
|
||||
|
||||
def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str):
|
||||
payload = BytesParser(policy=default).parsebytes(pgp.message)
|
||||
leafs = _get_mime_leafs(payload)
|
||||
pubkey = _get_pgp_publickey(leafs)
|
||||
sender_uid: PGPUID = pubkey.get_uid(sender)
|
||||
valid_key, revoked_keys = _get_pgp_publickeys(leafs)
|
||||
sender_uid: PGPUID = valid_key.get_uid(sender)
|
||||
if sender_uid is None or sender_uid.email != sender:
|
||||
raise EasyWksError(f'Key has no UID that matches {sender}')
|
||||
return SubmissionRequest(sender, submission, pubkey)
|
||||
for key in revoked_keys:
|
||||
sender_uid: PGPUID = key.get_uid(sender)
|
||||
if sender_uid is None or sender_uid.email != sender:
|
||||
raise EasyWksError(f'Revoked key {fingerprint(key)} has no UID that matches {sender}')
|
||||
return SubmissionRequest(sender, submission, valid_key, revoked_keys)
|
||||
|
||||
|
||||
def _find_confirmation_response(parts: List[MIMEPart]) -> str:
|
||||
|
@ -119,6 +136,62 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
|
|||
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
|
||||
|
||||
|
||||
# There is no API for directly removing a PGPUID or PGPSignature object
|
||||
# noinspection PyProtectedMember
|
||||
def _apply_submission_policy(request: SubmissionRequest, policy: Dict[str, Any]):
|
||||
# Policy: Only permit a certain amount of revoked keys
|
||||
maxrevoke = policy.get(EWP_MAX_REVOKED_KEYS, -1)
|
||||
if maxrevoke > -1 and len(request.revoked_keys) > maxrevoke:
|
||||
raise EasyWksError(f'Submission request contains {len(request.revoked_keys)} revoked keys. '
|
||||
f'Policy permits not more than {maxrevoke}')
|
||||
uid: PGPUID
|
||||
revfprs = [fingerprint(request.key)] + [fingerprint(rk) for rk in request.revoked_keys]
|
||||
for key in [request.key] + request.revoked_keys:
|
||||
# Policy: Strip user attribute (image) UIDs
|
||||
if policy.get(POLICY_MAILBOX_ONLY, False) or policy.get(EWP_STRIP_UA_UIDS, False):
|
||||
for uid in list(key.userattributes):
|
||||
uid._parent = None
|
||||
key._uids.remove(uid)
|
||||
# Policy: Reject keys as invalid if they contain UIDs with non-empty name or comment parts
|
||||
if policy.get(POLICY_MAILBOX_ONLY, False):
|
||||
for uid in list(key.userids):
|
||||
if uid.email == '' or uid.name != '' or uid.comment != '':
|
||||
raise EasyWksError('This WKS server only accepts UIDs without name and comment parts')
|
||||
# Policy: Strip all UIDs except the one being verified
|
||||
if policy.get(EWP_STRIP_UNVERIFIED_UIDS, False):
|
||||
for uid in list(key.userids):
|
||||
if uid.email != request.submitter_address:
|
||||
uid._parent = None
|
||||
key._uids.remove(uid)
|
||||
# Policy: Strip all 3rd party signatures from they key
|
||||
if policy.get(EWP_STRIP_3RDPARTY_SIGNATURES, False):
|
||||
for uid in list(key.userids):
|
||||
for sig in list(uid.third_party_certifications):
|
||||
# Keep signatures signed by the revoked keys
|
||||
sig: PGPSignature
|
||||
if sig.signer_fingerprint not in revfprs:
|
||||
uid._signatures.remove(sig)
|
||||
# Policy: Produce minimal transportable keys
|
||||
if policy.get(EWP_MINIMIZE_REVOKED_KEYS, False):
|
||||
for key in request.revoked_keys:
|
||||
for uid in list(key.userids):
|
||||
# Delete all but the submitter UIDs, and all 3rd party signatures
|
||||
if uid.email != request.submitter_address:
|
||||
uid._parent = None
|
||||
key._uids.remove(uid)
|
||||
else:
|
||||
for sig in list(uid.third_party_certifications):
|
||||
uid._signatures.remove(sig)
|
||||
# Delete UAs
|
||||
for uid in list(key.userattributes):
|
||||
uid._parent = None
|
||||
key._uids.remove(uid)
|
||||
# Delete subkeys
|
||||
for subkey in key._children.values():
|
||||
subkey._parent = None
|
||||
key._children.clear()
|
||||
|
||||
|
||||
def process_mail(mail: bytes):
|
||||
try:
|
||||
msg: Message = BytesParser(policy=default).parsebytes(mail)
|
||||
|
@ -128,7 +201,7 @@ def process_mail(mail: bytes):
|
|||
raise EasyWksError('Sender mail is not a valid mail address')
|
||||
if sender_domain not in Config.domains:
|
||||
raise EasyWksError(f'Domain {sender_domain} not supported')
|
||||
if msg.get('x-loop', '') == XLOOP_HEADER or 'auto-submitted' in msg:
|
||||
if msg.get('x-loop', '') == XLOOP_HEADER or msg.get('auto-submitted', 'no') != 'no':
|
||||
# Mail has somehow looped back to us, discard
|
||||
return
|
||||
submission_address: str = Config[sender_domain].submission_address
|
||||
|
@ -151,27 +224,61 @@ def process_mail(mail: bytes):
|
|||
if confirmation_response is not None:
|
||||
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
|
||||
try:
|
||||
key = read_pending_key(sender_domain, request.nonce)
|
||||
key, revoked_keys = read_pending_key(sender_domain, request.nonce)
|
||||
except FileNotFoundError:
|
||||
raise EasyWksError('There is no submission request for this email address, or it has expired. '
|
||||
'Please resubmit your submission request.')
|
||||
# this throws an error if signature verification fails
|
||||
request.verify_signature(key)
|
||||
# TODO: Config.permit_unsigned_response is deprecated, but for now retained for backwards compatibility
|
||||
if not Config[sender_domain].policy_flags.get(EWP_PERMIT_UNSIGNED_RESPONSE, False) and \
|
||||
not Config.permit_unsigned_response:
|
||||
# this throws an error if signature verification fails
|
||||
request.verify_signature(key)
|
||||
response: PublishResponse = request.get_publish_response(key)
|
||||
rmsg = response.create_signed_message()
|
||||
write_public_key(sender_domain, sender_mail, key)
|
||||
write_public_key(sender_domain, sender_mail, key, revoked_keys)
|
||||
remove_pending_key(sender_domain, request.nonce)
|
||||
else:
|
||||
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
|
||||
response: ConfirmationRequest = request.confirmation_request()
|
||||
rmsg = response.create_signed_message()
|
||||
write_pending_key(sender_domain, response.nonce, request.key)
|
||||
policy = Config[sender_domain].policy_flags
|
||||
_apply_submission_policy(request, policy)
|
||||
if policy.get(POLICY_AUTH_SUBMIT, False):
|
||||
response = PublishResponse(request.submitter_address, request.submission_address, request.key)
|
||||
write_public_key(sender_domain, sender_mail, request.key, request.revoked_keys)
|
||||
else:
|
||||
response: ConfirmationRequest = request.confirmation_request()
|
||||
write_pending_key(sender_domain, response.nonce, request.key, request.revoked_keys)
|
||||
rmsg = response.create_signed_message()
|
||||
except EasyWksError as e:
|
||||
rmsg = e.create_message(sender_mail, submission_address)
|
||||
method = get_mailing_method(Config.mailing_method)
|
||||
method(rmsg)
|
||||
|
||||
|
||||
def process_mail_from_stdin():
|
||||
def process_mail_from_stdin(args):
|
||||
mail = sys.stdin.read().encode()
|
||||
process_mail(mail)
|
||||
|
||||
|
||||
def process_key_from_stdin(args):
|
||||
try:
|
||||
pubkey, _ = PGPKey.from_blob(sys.stdin.read())
|
||||
except PGPError:
|
||||
raise EasyWksError('Input is not a valid public key.')
|
||||
if not pubkey.is_public:
|
||||
raise EasyWksError('Input is not a valid public key.')
|
||||
|
||||
for uid in pubkey.userids:
|
||||
# Skip user attributes (e.g. photo ids)
|
||||
if not uid.is_uid or len(uid.email) == 0:
|
||||
continue
|
||||
# If a UID filter was provided on the command line, apply it
|
||||
if args.uid is not None and len(args.uid) > 0 and uid.email not in args.uid:
|
||||
print(f'Skipping ignored email {uid.email}')
|
||||
continue
|
||||
local, domain = uid.email.split('@', 1)
|
||||
# Skip keys we're not responsible for
|
||||
if domain not in Config.domains:
|
||||
print(f'Skipping foreign email {uid.email}')
|
||||
continue
|
||||
# All checks passed, importing key
|
||||
write_public_key(domain, uid.email, pubkey, [])
|
||||
print(f'Imported key {fingerprint(pubkey)} for email {uid.email}')
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
|
||||
from .config import Config
|
||||
from .files import read_public_key, make_submission_address_file, make_policy_file
|
||||
from .util import hash_user_id
|
||||
|
||||
from bottle import get, run, abort, response, request
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
||||
def submission_address(domain: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
response.add_header('Content-Type', 'text/plain')
|
||||
return make_submission_address_file(domain)
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/policy')
|
||||
def policy(domain: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
response.add_header('Content-Type', 'text/plain')
|
||||
return make_policy_file(domain)
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
|
||||
def hu(domain: str, userhash: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
userid = request.query.l
|
||||
print(userid, userhash, hash_user_id(userid))
|
||||
if not userid or hash_user_id(userid) != userhash:
|
||||
abort(404, 'Not Found')
|
||||
try:
|
||||
pubkey = read_public_key(domain, userid)
|
||||
response.add_header('Content-Type', 'application/octet-stream')
|
||||
return bytes(pubkey)
|
||||
except FileNotFoundError:
|
||||
abort(404, 'Not Found')
|
||||
|
||||
|
||||
def run_server():
|
||||
run(host=Config.host, port=Config.port)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_server()
|
157
easywks/types.py
157
easywks/types.py
|
@ -1,5 +1,8 @@
|
|||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from email.encoders import encode_noop
|
||||
from email.policy import default
|
||||
from email.utils import format_datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
@ -7,10 +10,10 @@ from email.mime.application import MIMEApplication
|
|||
from email.mime.text import MIMEText
|
||||
|
||||
from pgpy import PGPKey, PGPMessage, PGPUID
|
||||
from pgpy.types import SignatureVerification
|
||||
from pgpy.errors import PGPError
|
||||
|
||||
from .crypto import pgp_sign
|
||||
from .config import Config
|
||||
from .config import render_message
|
||||
from .util import create_nonce, fingerprint
|
||||
|
||||
|
||||
|
@ -19,10 +22,11 @@ XLOOP_HEADER = 'EasyWKS'
|
|||
|
||||
class SubmissionRequest:
|
||||
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, revoked_keys: List[PGPKey]):
|
||||
self._submitter_addr = submitter_addr
|
||||
self._submission_addr = submission_addr
|
||||
self._key = key
|
||||
self._revoked_keys = revoked_keys
|
||||
|
||||
def confirmation_request(self) -> 'ConfirmationRequest':
|
||||
return ConfirmationRequest(self._submitter_addr, self. _submission_addr, self._key)
|
||||
|
@ -39,42 +43,13 @@ class SubmissionRequest:
|
|||
def key(self):
|
||||
return self._key
|
||||
|
||||
@property
|
||||
def revoked_keys(self):
|
||||
return list(self._revoked_keys)
|
||||
|
||||
|
||||
class ConfirmationRequest:
|
||||
|
||||
MAIL_TEXT = '''Hi there!
|
||||
|
||||
This is the EasyWKS system at {domain}.
|
||||
|
||||
You appear to have submitted your key for publication in the Web Key
|
||||
Directory. There's one more step you need to complete. If you did not
|
||||
request this, you can simply ignore this message.
|
||||
|
||||
If your email client doesn't automatically complete this challenge, you
|
||||
can perform this step manually: Please verify that you can decrypt the
|
||||
second part of this message and that the fingerprint listed in the
|
||||
encrypted part matches your key. If everything looks ok, please reply
|
||||
to this message with an **encrypted and signed PGP/MIME message** with
|
||||
the following content (without the <> brackets)
|
||||
|
||||
type: confirmation-response
|
||||
sender: <your email address>
|
||||
nonce: <copy the nonce from the encrypted part of this message>
|
||||
|
||||
For more information on WKD and WKS see:
|
||||
|
||||
https://gnupg.org/faq/wkd.html
|
||||
https://gnupg.org/faq/wks.html
|
||||
|
||||
|
||||
Regards
|
||||
EasyWKS
|
||||
|
||||
--
|
||||
Dance like nobody is watching.
|
||||
Encrypt live everybody is.
|
||||
'''
|
||||
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, nonce: str = None):
|
||||
self._domain = submitter_addr.split('@')[1]
|
||||
self._submitter_addr = submitter_addr
|
||||
|
@ -103,7 +78,11 @@ Encrypt live everybody is.
|
|||
return self._nonce
|
||||
|
||||
def create_signed_message(self):
|
||||
mpplain = MIMEText(ConfirmationRequest.MAIL_TEXT.format(domain=self.domain), _subtype='plain')
|
||||
mail_text = render_message('confirm',
|
||||
domain=self.domain,
|
||||
sender=self.submitter_address,
|
||||
submission=self.submission_address)
|
||||
mpplain = MIMEText(mail_text, _subtype='plain')
|
||||
ps = '\r\n'.join([
|
||||
'type: confirmation-request',
|
||||
f'sender: {self._submission_addr}',
|
||||
|
@ -117,15 +96,18 @@ Encrypt live everybody is.
|
|||
encrypted = self._key.encrypt(to_encrypt)
|
||||
mpenc = MIMEApplication(str(encrypted), _subtype='vnd.gnupg.wks')
|
||||
mixed = MIMEMultipart(_subtype='mixed', _subparts=[mpplain, mpenc])
|
||||
to_sign = PGPMessage.new(mixed.as_string(policy=default))
|
||||
to_sign = PGPMessage.new(mixed.as_string(policy=default).replace('\n', '\r\n'))
|
||||
sig = pgp_sign(self.domain, to_sign)
|
||||
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature')
|
||||
email = MIMEMultipart(_subtype='signed', _subparts=[mixed, mpsig], policy=default,
|
||||
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature', name='signature.asc', _encoder=encode_noop)
|
||||
mpsig['Content-Description'] = 'OpenPGP digital signature'
|
||||
mpsig['Content-Disposition'] = 'attachment; filename="signature"'
|
||||
email = MIMEMultipart(_subtype=f'signed', _subparts=[mixed, mpsig], policy=default,
|
||||
protocol='application/pgp-signature')
|
||||
email.set_param('micalg', f'pgp-{str(sig.hash_algorithm).lower()}', requote=False)
|
||||
email['Subject'] = 'Confirm your key publication'
|
||||
email['To'] = self._submitter_addr
|
||||
email['From'] = self._submission_addr
|
||||
email['Date'] = format_datetime(datetime.utcnow())
|
||||
email['Date'] = format_datetime(datetime.now(timezone.utc))
|
||||
email['Wks-Draft-Version'] = '3'
|
||||
email['Wks-Phase'] = 'confirm'
|
||||
email['X-Loop'] = XLOOP_HEADER
|
||||
|
@ -163,45 +145,21 @@ class ConfirmationResponse:
|
|||
|
||||
def verify_signature(self, key: PGPKey):
|
||||
if not self._msg.is_signed:
|
||||
if not Config.permit_unsigned_response:
|
||||
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
|
||||
'gpg-wks-client for submitting your response, please update said tool or try '
|
||||
'responding manually.')
|
||||
else:
|
||||
# Unsigned, but permitted
|
||||
return
|
||||
raise EasyWksError('The confirmation response is not signed. If you used an automated tool such as '
|
||||
'gpg-wks-client for submitting your response, please update said tool or try '
|
||||
'responding manually.')
|
||||
uid: PGPUID = key.get_uid(self._submitter_addr)
|
||||
if uid is None or uid.email != self._submitter_addr:
|
||||
raise EasyWksError(f'UID {self._submitter_addr} not found in PGP key')
|
||||
verification: SignatureVerification = key.verify(self._msg)
|
||||
for verified, by, sig, subject in verification.good_signatures:
|
||||
if fingerprint(key) == fingerprint(by):
|
||||
return
|
||||
raise EasyWksError('PGP signature could not be verified')
|
||||
try:
|
||||
# Should raise an error when verification fails, but add the boolean check as a additional protection
|
||||
if not key.verify(self._msg):
|
||||
raise EasyWksError(f'PGP signature could not be verified')
|
||||
except PGPError as e:
|
||||
raise EasyWksError(f'PGP signature could not be verified: {e}')
|
||||
|
||||
|
||||
class PublishResponse:
|
||||
MAIL_TEXT = '''Hi there!
|
||||
|
||||
This is the EasyWKS system at {domain}.
|
||||
|
||||
Your key has been published to the Web Key Directory.
|
||||
You can test WKD key retrieval e.g. with:
|
||||
|
||||
gpg --auto-key-locate=wkd,nodefault --locate-key {uid}
|
||||
|
||||
For more information on WKD and WKS see:
|
||||
|
||||
https://gnupg.org/faq/wkd.html
|
||||
https://gnupg.org/faq/wks.html
|
||||
|
||||
Regards
|
||||
EasyWKS
|
||||
|
||||
--
|
||||
Dance like nobody is watching.
|
||||
Encrypt live everybody is.
|
||||
'''
|
||||
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
|
||||
self._domain = submitter_addr.split('@')[1]
|
||||
|
@ -226,19 +184,22 @@ Encrypt live everybody is.
|
|||
return self._domain
|
||||
|
||||
def create_signed_message(self):
|
||||
mpplain = MIMEText(PublishResponse.MAIL_TEXT.format(domain=self.domain, uid=self.submitter_address),
|
||||
_subtype='plain')
|
||||
mail_text = render_message('done',
|
||||
domain=self.domain,
|
||||
sender=self.submitter_address,
|
||||
submission=self.submission_address)
|
||||
mpplain = MIMEText(mail_text, _subtype='plain')
|
||||
to_encrypt = PGPMessage.new(mpplain.as_string(policy=default))
|
||||
to_encrypt |= pgp_sign(self.domain, to_encrypt)
|
||||
encrypted: PGPMessage = self.key.encrypt(to_encrypt)
|
||||
encrypted |= pgp_sign(self.domain, encrypted)
|
||||
payload = MIMEApplication(str(encrypted), _subtype='octet-stream')
|
||||
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted')
|
||||
payload = MIMEApplication(str(encrypted), _subtype='octet-stream', _encoder=encode_noop)
|
||||
mpenc = MIMEApplication('Version: 1\r\n', _subtype='pgp-encrypted', _encoder=encode_noop)
|
||||
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc, payload], policy=default,
|
||||
protocol='application/pgp-encrypted')
|
||||
email['Subject'] = 'Your key has been published'
|
||||
email['To'] = self.submitter_address
|
||||
email['From'] = self.submission_address
|
||||
email['Date'] = format_datetime(datetime.utcnow())
|
||||
email['Date'] = format_datetime(datetime.now(timezone.utc))
|
||||
email['Wks-Draft-Version'] = '3'
|
||||
email['Wks-Phase'] = 'done'
|
||||
email['X-Loop'] = XLOOP_HEADER
|
||||
|
@ -248,30 +209,6 @@ Encrypt live everybody is.
|
|||
|
||||
class EasyWksError(BaseException):
|
||||
|
||||
MAIL_TEXT = '''Hi there!
|
||||
|
||||
This is the EasyWKS system at {domain}.
|
||||
|
||||
An error has occurred while processing your request.
|
||||
|
||||
{message}
|
||||
|
||||
If this error persists, please contact your administrator for help.
|
||||
|
||||
For more information on WKD and WKS see:
|
||||
|
||||
https://gnupg.org/faq/wkd.html
|
||||
https://gnupg.org/faq/wks.html
|
||||
|
||||
|
||||
Regards
|
||||
EasyWKS
|
||||
|
||||
--
|
||||
Dance like nobody is watching.
|
||||
Encrypt live everybody is.
|
||||
'''
|
||||
|
||||
def __init__(self, msg: str, ):
|
||||
super().__init__()
|
||||
self._msg = msg
|
||||
|
@ -281,12 +218,16 @@ Encrypt live everybody is.
|
|||
|
||||
def create_message(self, submitter_addr: str, submission_addr: str) -> MIMEText:
|
||||
domain = submission_addr.split('@', 1)[1]
|
||||
payload = EasyWksError.MAIL_TEXT.format(domain=domain, message=self._msg)
|
||||
email = MIMEText(payload)
|
||||
mail_text = render_message('error',
|
||||
domain=domain,
|
||||
sender=submitter_addr,
|
||||
submission=submission_addr,
|
||||
error=self._msg)
|
||||
email = MIMEText(mail_text)
|
||||
email['Subject'] = 'An error has occurred while processing your request'
|
||||
email['From'] = submission_addr
|
||||
email['To'] = submitter_addr
|
||||
email['Date'] = format_datetime(datetime.utcnow())
|
||||
email['Date'] = format_datetime(datetime.now(timezone.utc))
|
||||
email['Wks-Draft-Version'] = '3'
|
||||
email['Wks-Phase'] = 'error'
|
||||
email['X-Loop'] = XLOOP_HEADER
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
|
||||
from typing import Iterable, List, Tuple
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import string
|
||||
import textwrap
|
||||
import logging
|
||||
|
||||
from twisted.names import dns
|
||||
from twisted.internet import reactor, defer
|
||||
from pgpy import PGPKey
|
||||
from pgpy.constants import SignatureType
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
def _zrtp_base32(sha1: bytes) -> str:
|
||||
|
@ -28,6 +38,12 @@ def hash_user_id(uid: str) -> str:
|
|||
return _zrtp_base32(digest)
|
||||
|
||||
|
||||
def dane_digest(uid: str) -> str:
|
||||
if '@' in uid:
|
||||
uid, _ = uid.split('@', 1)
|
||||
return hashlib.sha256(uid.encode('utf-8')).hexdigest()[:56]
|
||||
|
||||
|
||||
def create_nonce(n: int = 32) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
nonce = ''.join(secrets.choice(alphabet) for _ in range(n))
|
||||
|
@ -36,3 +52,64 @@ def create_nonce(n: int = 32) -> str:
|
|||
|
||||
def fingerprint(key: PGPKey) -> str:
|
||||
return key.fingerprint.upper().replace(' ', '')
|
||||
|
||||
|
||||
def crc24(data: bytes) -> bytes:
|
||||
# https://www.rfc-editor.org/rfc/rfc4880#section-6.1
|
||||
crc = 0xB704CE
|
||||
for b in data:
|
||||
crc ^= (b << 16)
|
||||
for _ in range(8):
|
||||
crc <<= 1
|
||||
if crc & 0x1000000:
|
||||
crc ^= 0x1864CFB
|
||||
return bytes([(crc & 0xff0000) >> 16, (crc & 0xff00) >> 8, (crc & 0xff)])
|
||||
|
||||
|
||||
def armor_keys(keys: List[PGPKey]) -> str:
|
||||
joined = b''.join([bytes(k) for k in keys])
|
||||
armored = base64.b64encode(joined).decode()
|
||||
wrapped = '\n'.join(textwrap.wrap(armored, 64))
|
||||
checksum = base64.b64encode(crc24(joined)).decode()
|
||||
armored = '-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n' +\
|
||||
wrapped + '\n=' + checksum +\
|
||||
'\n-----END PGP PUBLIC KEY BLOCK-----\n'
|
||||
return armored
|
||||
|
||||
|
||||
def split_revoked(keys: Iterable[PGPKey]) -> Tuple[List[PGPKey], List[PGPKey]]:
|
||||
revoked_keys = set()
|
||||
for key in keys:
|
||||
if len(list(key.revocation_signatures)) == 0:
|
||||
continue
|
||||
for rsig in key.revocation_signatures:
|
||||
if rsig.type == SignatureType.KeyRevocation:
|
||||
revoked_keys.add(key)
|
||||
break
|
||||
key = [k for k in keys if k not in revoked_keys]
|
||||
return key, list(revoked_keys)
|
||||
|
||||
|
||||
def dane_notify(domain: str):
|
||||
secondaries = Config[domain].dane.get('notify', [])
|
||||
if len(secondaries) == 0:
|
||||
return
|
||||
origin = dns.domainString(f'_openpgpkey.{domain}')
|
||||
# this is ugly, but has to do for now
|
||||
for host in secondaries:
|
||||
try:
|
||||
if '@' in host:
|
||||
addr, port = host.split('@', 1)
|
||||
port = int(port)
|
||||
else:
|
||||
addr = host
|
||||
port = 53
|
||||
# Bind a v4 or v6 UDP client socket
|
||||
proto = dns.DNSDatagramProtocol(controller=None)
|
||||
reactor.listenUDP(0, proto, interface='::' if ':' in addr else '0.0.0.0')
|
||||
# Assemble and send NOTIFY message
|
||||
m = dns.Message(proto.pickID(), opCode=dns.OP_NOTIFY, auth=1)
|
||||
m.queries = [dns.Query(origin, dns.SOA, dns.IN)]
|
||||
proto.writeMessage(m, (addr, port))
|
||||
except Exception:
|
||||
logging.exception(f'An error occurred while attempting to notify {host}')
|
||||
|
|
BIN
logo/easywks.png
Normal file
BIN
logo/easywks.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
96
logo/easywks.svg
Normal file
96
logo/easywks.svg
Normal file
|
@ -0,0 +1,96 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="275.00003"
|
||||
height="275.00003"
|
||||
viewBox="0 0 72.760425 72.760425"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
|
||||
sodipodi:docname="easywks.svg"
|
||||
inkscape:export-filename="easywks.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#999999"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="true"
|
||||
inkscape:zoom="1.7928421"
|
||||
inkscape:cx="63.028417"
|
||||
inkscape:cy="98.725927"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1060"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1">
|
||||
<inkscape:grid
|
||||
id="grid1"
|
||||
units="px"
|
||||
originx="-13.229167"
|
||||
originy="-13.229167"
|
||||
spacingx="1.3229167"
|
||||
spacingy="1.3229167"
|
||||
empcolor="#3f3fff"
|
||||
empopacity="0.25098039"
|
||||
color="#3f3fff"
|
||||
opacity="0.1254902"
|
||||
empspacing="5"
|
||||
dotted="false"
|
||||
gridanglex="30"
|
||||
gridanglez="30"
|
||||
visible="true" />
|
||||
</sodipodi:namedview>
|
||||
<defs
|
||||
id="defs1" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-13.229167,-13.229167)">
|
||||
<path
|
||||
id="path2"
|
||||
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="M 84.666667,85.989583 44.317708,45.640625 39.6875,50.270833 46.302083,56.885417 47.625,58.208333 h 5.291667 V 63.5 l 1.322916,1.322917 h 2.645834 v 2.645833 l 1.322916,1.322917 h 6.614584 v 6.614583 l 1.322916,1.322917 h 1.322917 v 1.322916 L 68.791667,79.375 h 3.96875 v 3.96875 l 2.645833,2.645833 z" />
|
||||
<path
|
||||
id="path10"
|
||||
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 46.302083,43.65625 -1.984375,1.984375 40.348959,40.348958 1.322916,-1.322916 V 83.34375 Z" />
|
||||
<path
|
||||
id="path9"
|
||||
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 47.625,42.333333 -1.322917,1.322917 39.6875,39.6875 v -2.645833 z" />
|
||||
<path
|
||||
id="path8"
|
||||
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="M 85.989583,79.375 48.286458,41.671875 47.625,42.333333 85.989583,80.697917 Z" />
|
||||
<path
|
||||
id="path7"
|
||||
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="M 50.270833,39.6875 48.286458,41.671875 85.989583,79.375 v -3.96875 z" />
|
||||
<path
|
||||
id="path1"
|
||||
style="fill:#7a7a7a;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0499999"
|
||||
d="m 33.072917,13.229167 a 19.84375,19.84375 0 0 0 -19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,-19.84375 19.84375,19.84375 0 0 0 -19.84375,-19.84375 z m -6.619751,6.624402 a 6.6095872,6.6045904 0 0 1 6.609932,6.604764 6.6095872,6.6045904 0 0 1 -6.609932,6.604765 6.6095872,6.6045904 0 0 1 -6.609416,-6.604765 6.6095872,6.6045904 0 0 1 6.609416,-6.604764 z" />
|
||||
<path
|
||||
style="fill:#2ce000;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="m 20.835939,55.562502 h 15.213543 v 15.213541 h 7.60677 L 28.442711,85.989585 13.229168,70.776043 h 7.606771 z"
|
||||
id="path11" />
|
||||
<path
|
||||
style="fill:#ffa708;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
|
||||
d="M 78.382819,43.65625 H 63.169276 V 28.442709 h -7.60677 L 70.776047,13.229167 85.98959,28.442709 h -7.606771 z"
|
||||
id="path11-5" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.4 KiB |
|
@ -1 +1,2 @@
|
|||
/etc/easywks.yml
|
||||
/etc/cron.d/easywks
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
Package: easywks
|
||||
Version: __EASYWKS_VERSION__
|
||||
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
|
||||
Version: __VERSION__
|
||||
Maintainer: s3lph <1375407-s3lph@users.noreply.gitlab.com>
|
||||
Section: web
|
||||
Priority: optional
|
||||
Architecture: all
|
||||
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd
|
||||
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd, python3-dnspython, python3-twisted
|
||||
Description: OpenPGP WKS for Human Beings
|
||||
EasyWKS is a drop-in replacement for gpg-wks-server that aims to be
|
||||
much easyier to use manually, while maintaing compatibility with the
|
||||
|
|
4
package/debian/easywks/etc/cron.d/easywks
Normal file
4
package/debian/easywks/etc/cron.d/easywks
Normal file
|
@ -0,0 +1,4 @@
|
|||
# Part of the easywks package
|
||||
|
||||
# Clean up stale pending requests once in a while
|
||||
0 2 * * * easywks /usr/bin/easywks clean
|
|
@ -7,10 +7,14 @@
|
|||
# considered stale and should be removed by easywks clean.
|
||||
#pending_lifetime: 604800
|
||||
|
||||
# Some clients (including recent versions of gpg-wks-client follow an
|
||||
# Some clients including recent versions of gpg-wks-client follow an
|
||||
# older version of the WKS standard where signing the confirmation
|
||||
# response is only recommended, but not required. Set this option to
|
||||
# true if you want to accept such unsigned responses.
|
||||
#
|
||||
# This option is deprecated and will be removed in a future release.
|
||||
# It is replaced by the me.s3lph.easywks_permit-unsigned-response
|
||||
# per-domain policy flag.
|
||||
#permit_unsigned_response: false
|
||||
|
||||
# Port configuration for the webserver. Put this behind a
|
||||
|
@ -18,6 +22,9 @@
|
|||
httpd:
|
||||
host: "::1"
|
||||
port: 8080
|
||||
# Some older HTTP clients omit the ?l=<userid> query suffix. Set
|
||||
# this to false in order to permit such clients to retrieve keys.
|
||||
#require_user_urlparam: true
|
||||
|
||||
# Defaults to stdout, supported: stdout, smtp
|
||||
mailing_method: smtp
|
||||
|
@ -39,6 +46,33 @@ lmtpd:
|
|||
host: "::1"
|
||||
port: 8024
|
||||
|
||||
# Configure the authoritative DNS server for DANE zones
|
||||
dnsd:
|
||||
host: "::1"
|
||||
port: 8053
|
||||
|
||||
# You can override the mail response templates with your own text.
|
||||
# The following templates can be overridden:
|
||||
# - "header": Placed in front of every message.
|
||||
# - "footer": Appended to every message.
|
||||
# - "confirm": Sent with the confirmation request.
|
||||
# - "done": Sent after a key was published.
|
||||
# - "error": Sent when an error occurs.
|
||||
# The following placeholders can be used (enclosed in curly braces):
|
||||
# - {domain}: The email domain for with the request is processed.
|
||||
# - {sender}: The submitter's mail address.
|
||||
# - {submission}: The submission address.
|
||||
# When overriding the "error" template, theres an additional
|
||||
# placeholder you can use:
|
||||
# - {error}: The error message.
|
||||
#responses:
|
||||
# error: |
|
||||
# An error has occurred while processing your request:
|
||||
#
|
||||
# {error}
|
||||
#
|
||||
# If this error persists, please contact admin@example.org for help.
|
||||
|
||||
# Every domain served by EasyWKS must be listed here
|
||||
domains:
|
||||
# Defaults are gpgwks@<domain> and no password protection.
|
||||
|
@ -49,4 +83,52 @@ domains:
|
|||
# If you want the PGP key for this domain to be password-protected,
|
||||
# or if you're supplying your own password-protected key, set the
|
||||
# passphrase here:
|
||||
#passphrase: "Correct Horse Battery Staple"
|
||||
#passphrase: "Correct Horse Battery Staple"
|
||||
# Policy flags control behavior of the submission process for this
|
||||
# domain. Supports most of the standard policy flags (see
|
||||
# https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-15#section-4.5
|
||||
# for details) as well as some EasyWKS-namespaced flags.
|
||||
policy_flags:
|
||||
|
||||
# The mail server provider does only accept keys with only a
|
||||
# mailbox in the User ID. In particular User IDs with a real name
|
||||
# in addition to the mailbox will be rejected as invalid.
|
||||
#mailbox-only: false
|
||||
|
||||
# The submission of the mail to the server is done using an
|
||||
# authenticated connection. Thus the submitted key will be
|
||||
# published immediately without any confirmation request.
|
||||
#auth-submit: false
|
||||
|
||||
# This keyword can be used to explicitly claim the support of a
|
||||
# specific version of the Web Key Directory update protocol. This
|
||||
# is in general not needed but implementations may have
|
||||
# workarounds for providers which only support an old protocol
|
||||
# version. If these providers update to a newer version they
|
||||
# should add this keyword so that the implementation can disable
|
||||
# the workaround. The value is an integer corresponding to the
|
||||
# respective draft revision number.
|
||||
#protocol-version: null
|
||||
|
||||
# Some clients (including recent versions of gpg-wks-client
|
||||
# follow an older version of the WKS standard where signing the
|
||||
# confirmation response is only recommended, but not required.
|
||||
# Set this option to true if you want to accept such unsigned
|
||||
# responses.
|
||||
#me.s3lph.easywks_permit-unsigned-response: false
|
||||
|
||||
# Remove all UIDs except the one being verified.
|
||||
#me.s3lph.easywks_strip-unverified-uids: false
|
||||
|
||||
# Remove user attribute (i.e. photo) UIDs.
|
||||
#me.s3lph.easywks_strip-ua-uids: false
|
||||
|
||||
# Remove all third party certifications fom the submitted keys.
|
||||
# Certifications issued by revoked keys submitted in the same
|
||||
# submission request are exempt from this policy.
|
||||
#me.s3lph.easywks_strip-3rdparty-signatures: false
|
||||
|
||||
# Maximal number of revoked keys that can be submitted alongside
|
||||
# a valid key. If this flag is absent or has value -1, an
|
||||
# unlimited number of revoked keys is permitted.
|
||||
#me.s3lph.easywks_max-revoked-keys: -1
|
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=OpenPGP WKS for Human Beings - DANE DNS Server
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/easywks dnsd
|
||||
Restart=on-failure
|
||||
User=easywks
|
||||
Group=easywks
|
||||
WorkingDirectory=/var/lib/easywks
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -10,4 +10,7 @@ lmtpd:
|
|||
httpd:
|
||||
host: "::"
|
||||
port: 80
|
||||
dnsd:
|
||||
host: "::"
|
||||
port: 53
|
||||
domains: {}
|
||||
|
|
13
setup.py
13
setup.py
|
@ -7,18 +7,27 @@ setup(
|
|||
name='easywks',
|
||||
version=__version__,
|
||||
author='s3lph',
|
||||
author_email='account-gitlab-ideynizv@kernelpanic.lol',
|
||||
author_email='s3lph@kabelsalat.ch',
|
||||
description='OpenPGP WKS for Human Beings',
|
||||
license='MIT',
|
||||
keywords='pgp,wks',
|
||||
url='https://gitlab.com/s3lph/easywks',
|
||||
url='https://git.kabelsalat.ch/s3lph/easywks',
|
||||
packages=find_packages(exclude=['*.test']),
|
||||
install_requires=[
|
||||
'aiosmtpd',
|
||||
'bottle',
|
||||
'dnspython',
|
||||
'PyYAML',
|
||||
'PGPy',
|
||||
'Twisted',
|
||||
],
|
||||
extras_require={
|
||||
'test': [
|
||||
'coverage',
|
||||
'pycodestyle',
|
||||
'twine'
|
||||
]
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'easywks = easywks.main:main'
|
||||
|
|
24
test/apache.conf
Normal file
24
test/apache.conf
Normal file
|
@ -0,0 +1,24 @@
|
|||
ServerName example.org
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName example.org
|
||||
ServerAlias openpgpkey.example.org
|
||||
ServerAlias openpgpkey
|
||||
DocumentRoot /var/www/html
|
||||
RewriteEngine On
|
||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI}
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName example.org
|
||||
ServerAlias openpgpkey.example.org
|
||||
ServerAlias openpgpkey
|
||||
DocumentRoot /var/www/html
|
||||
|
||||
SSLEngine On
|
||||
SSLCertificateFile /etc/ssl/cert.pem
|
||||
SSLCertificateKeyFile /etc/ssl/key.pem
|
||||
|
||||
ProxyPass /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
|
||||
ProxyPassReverse /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
|
||||
</VirtualHost>
|
22
test/config-v1.1.xml
Normal file
22
test/config-v1.1.xml
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="example.org">
|
||||
<domain>example.org</domain>
|
||||
<displayName>EasyWKS Example</displayName>
|
||||
<displayShortName>Example</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>example.org</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILLOCALPART%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>example.org</hostname>
|
||||
<port>25</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILLOCALPART%</username>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
8
test/dovecot.conf
Normal file
8
test/dovecot.conf
Normal file
|
@ -0,0 +1,8 @@
|
|||
service auth {
|
||||
unix_listener /var/spool/postfix/private/auth {
|
||||
mode = 0666
|
||||
}
|
||||
}
|
||||
ssl_cert = </etc/ssl/cert.pem
|
||||
ssl_key = </etc/ssl/key.pem
|
||||
log_path = /dev/stderr
|
13
test/easywks.yml
Normal file
13
test/easywks.yml
Normal file
|
@ -0,0 +1,13 @@
|
|||
directory: /tmp/easywks
|
||||
httpd:
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
lmtpd:
|
||||
host: 127.0.0.1
|
||||
port: 8024
|
||||
mailing_method: smtp
|
||||
domains:
|
||||
example.org:
|
||||
submission_address: webkey@example.org
|
||||
policy_flags:
|
||||
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat
|
19
test/expect
Executable file
19
test/expect
Executable file
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/expect -f
|
||||
spawn ./client.py
|
||||
expect "Enter email: "
|
||||
send "alice@example.org\n"
|
||||
expect "Chose $env(FINGERPRINT)"
|
||||
expect "Enter IMAP/POP3/SMTP password (will not echo): "
|
||||
send "supersecurepassword\n"
|
||||
expect "Autoconfigured incoming server"
|
||||
expect "Autoconfigured outgoing server"
|
||||
expect "Please confirm: \[Y/n\] "
|
||||
send "y\n"
|
||||
expect "Sending submission request"
|
||||
expect "Awaiting response"
|
||||
expect "Received confirmation request"
|
||||
expect "Creating confirmation response."
|
||||
expect "Sending confirmation response"
|
||||
expect "Awaiting publish response"
|
||||
expect "Your key has been published to the Web Key Directory."
|
||||
expect eof
|
24
test/genkey.sh
Executable file
24
test/genkey.sh
Executable file
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
|
||||
cat >/tmp/keygen <<EOF
|
||||
%no-protection
|
||||
%no-ask-passphrase
|
||||
%transient-key
|
||||
Key-Type: EDDSA
|
||||
Key-Curve: ed25519
|
||||
Subkey-Type: ECDH
|
||||
Subkey-Curve: cv25519
|
||||
Expire-Date: 0
|
||||
Name-Real: EasyWKS Test User
|
||||
Name-Comment: TEST KEY DO NOT USE
|
||||
Name-Email: ${1}
|
||||
EOF
|
||||
|
||||
gpg --batch --full-gen-key /tmp/keygen
|
||||
for uid in $@; do
|
||||
gpg --batch --quick-add-uid "${1}" "EasyWKS Test User (TEST KEY DO NOT USE) <${uid}>"
|
||||
done
|
||||
gpg --export --armor "${1}" > "/tmp/${1}.asc"
|
||||
for uid in $@; do
|
||||
gpg --export --armor "${uid}" > "/tmp/${uid}.asc"
|
||||
done
|
1
test/transport
Normal file
1
test/transport
Normal file
|
@ -0,0 +1 @@
|
|||
webkey@example.org lmtp:[127.0.0.1]:8024
|
Loading…
Reference in a new issue