Compare commits

..

No commits in common. "main" and "v0.1.11" have entirely different histories.

31 changed files with 212 additions and 1200 deletions

View file

@ -1,38 +0,0 @@
---
on:
push:
tags:
- "v*"
jobs:
build_wheel:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Build Python wheel
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
python3 setup.py egg_info bdist_wheel
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-wheel-package-upload@v3
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
build_debian:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-python-debian-package@v5
with:
python_module: easywks
package_name: easywks
package_root: package/debian/easywks
package_output_path: package/debian
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-debian-package-upload@v2
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
deb: "package/debian/*.deb"

View file

@ -1,136 +0,0 @@
---
on: push
jobs:
test:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Run unit tests
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
python3 -m coverage combine
python3 -m coverage report --rcfile=setup.cfg
codestyle:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: pycodestyle
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
pycodestyle easywks
easywksserver_gpgwksclient:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Integration Test against gpg-wks-client
run: |
apt update; apt install --yes gnupg2 socat ca-certificates python3-pip
echo "openpgpkey" > /etc/hostname
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
pip3 install --break-system-packages -e .[test]
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org'
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
update-ca-certificates
mkdir -p /tmp/easywks
cat > /tmp/easywks.yml <<EOF
directory: /tmp/easywks
httpd:
host: 127.0.0.1
port: 8080
mailing_method: stdout
domains:
example.org:
submission_address: webkey@example.org
policy_flags:
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat
EOF
easywks --config /tmp/easywks.yml init
easywks --config /tmp/easywks.yml webserver &
socat OPENSSL-LISTEN:443,fork,reuseaddr,verify=0,cert=/etc/ssl/cert.pem,key=/etc/ssl/key.pem TCP:127.0.0.1:8080 &
sleep 3
install -m 0700 -d /tmp/gpg /tmp/cleangpg
export GNUPGHOME=/tmp/gpg
test/genkey.sh alice@example.org
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
/usr/lib/gnupg/gpg-wks-client --supported alice@example.org
/usr/lib/gnupg/gpg-wks-client --check webkey@example.org
PUBREQ="$(/usr/lib/gnupg/gpg-wks-client --create "${FINGERPRINT}" alice@example.org)"
CONFREQ="$(echo "${PUBREQ}" | easywks --config /tmp/easywks.yml process)"
CONFRESP="$(echo "${CONFREQ}" | /usr/lib/gnupg/gpg-wks-client --receive --verbose)"
PUBRESP="$(echo "${CONFRESP}" | easywks --config /tmp/easywks.yml process)"
echo "${PUBRESP}" | gpg --batch --decrypt
/usr/lib/gnupg/gpg-wks-client --check alice@example.org
export GNUPGHOME=/tmp/gpg
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
kill %2 || true
kill %1 || true
easywksserver_easywksclient:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Integration Test against easywks-client
run: |
# General system setup
useradd -d /home/alice -m alice
useradd -d /home/webkey -m webkey
echo alice:supersecurepassword | chpasswd
echo "postfix postfix/mailname string example.org" | debconf-set-selections
echo "postfix postfix/main_mailer_type string 'Local only'" | debconf-set-selections
apt update; apt install --yes gnupg2 ca-certificates python3-pip apache2 dovecot-imapd postfix expect
echo "openpgpkey" > /etc/hostname
echo "127.0.0.1 openpgpkey.example.org openpgpkey example.org" > /etc/hosts
pip3 install --break-system-packages -e .[test]
openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/key.pem -out /etc/ssl/cert.pem -sha256 -days 365 -nodes -subj '/CN=openpgpkey.example.org' -addext 'subjectAltName=DNS:openpgpkey.example.org,DNS:example.org'
cp /etc/ssl/cert.pem /usr/local/share/ca-certificates/local.crt
update-ca-certificates
# Setup Apache
a2enmod ssl proxy_http rewrite
rm /etc/apache2/sites-enabled/000-default.conf
cp test/apache.conf /etc/apache2/sites-enabled/easywks.conf
apache2ctl start
mkdir -p /var/www/html/.well-known/autoconfig/mail/
cp test/config-v1.1.xml /var/www/html/.well-known/autoconfig/mail/config-v1.1.xml
# Setup Dovecot
cp test/dovecot.conf /etc/dovecot/conf.d/99-local.conf
dovecot -F &
# Setup Postfix
/usr/lib/postfix/configure-instance.sh -
cp test/transport /etc/postfix/transport
postmap /etc/postfix/transport
postconf smtpd_tls_cert_file=/etc/ssl/cert.pem
postconf smtpd_tls_key_file=/etc/ssl/key.pem
postconf transport_maps=hash:/etc/postfix/transport
postconf smtpd_sasl_type=dovecot
postconf smtpd_sasl_path=private/auth
postconf smtpd_sasl_auth_enable=yes
/usr/sbin/postmulti -i - -p start
# Setup EasyWKS
mkdir -p /tmp/easywks
cp test/easywks.yml /tmp/easywks.yml
easywks --config /tmp/easywks.yml init
easywks --config /tmp/easywks.yml webserver &
easywks --config /tmp/easywks.yml lmtpd &
sleep 3
# Run the test
install -m 0700 -d /tmp/gpg /tmp/cleangpg
export GNUPGHOME=/tmp/gpg
test/genkey.sh alice@example.org
export FINGERPRINT="$(gpg --with-colons --fingerprint alice@example.org | grep -A1 ^pub | grep ^fpr | cut -d: -f10)"
test/expect
gpg --auto-key-locate=clear,wkd,nodefault --locate-keys alice@example.org
# Teardown
apache2ctl stop
doveadm stop
/usr/sbin/postmulti -i - -p stop
kill %1 || true
kill %2 || true
sleep 5 # wait for daemons to terminate

115
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,115 @@
---
image: python:3.9-bullseye
stages:
- test
- build
- deploy
- upload
before_script:
- pip3 install coverage pycodestyle
- export EASYWKS_VERSION=$(python -c 'import easywks; print(easywks.__version__)')
test:
stage: test
script:
- pip3 install -e .
- python3 -m coverage run --rcfile=setup.cfg -m unittest discover easywks
- python3 -m coverage combine
- python3 -m coverage report --rcfile=setup.cfg
codestyle:
stage: test
script:
- pip3 install -e .
- pycodestyle easywks
# currently not working for some reason
#build_docker:
# stage: build
# script:
# - apt update && apt install --yes docker.io
# - docker build -t "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" -f package/docker/Dockerfile .
# - docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker tag "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
# - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD registry.gitlab.com
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_SHA"
# - docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_REF_NAME"
# - if [[ -n "$CI_COMMIT_TAG" ]]; then docker push "registry.gitlab.com/s3lph/easywks:$CI_COMMIT_TAG"; fi
# only:
# - staging
# - tags
build_wheel:
stage: build
script:
- python3 setup.py egg_info bdist_wheel
- cd dist
- sha256sum *.whl > SHA256SUMS
artifacts:
paths:
- "dist/*.whl"
- dist/SHA256SUMS
only:
- tags
build_debian:
stage: build
script:
- apt update && apt install --yes lintian rsync sudo
- echo -n > package/debian/easywks/usr/share/doc/easywks/changelog
- |
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
echo "easywks (${version}-1); urgency=medium\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES ${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES ${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g' >> package/debian/easywks/usr/share/doc/easywks/changelog
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/easywks/usr/share/doc/easywks/changelog
done
- gzip -9n package/debian/easywks/usr/share/doc/easywks/changelog
- python3 setup.py egg_info install --root=package/debian/easywks/ --prefix=/usr --optimize=1
- cd package/debian
- sed -re "s/__EASYWKS_VERSION__/${EASYWKS_VERSION}/g" -i easywks/DEBIAN/control
- mkdir -p easywks/usr/lib/python3/dist-packages/
- rsync -a easywks/usr/lib/python3.9/site-packages/ easywks/usr/lib/python3/dist-packages/
- rm -rf easywks/usr/lib/python3.9/site-packages
- find easywks/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
- find easywks/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
- find easywks/usr/lib/python3/dist-packages -name '*.pyo' -exec rm {} \;
- sed -re 's$#!/usr/local/bin/python3$#!/usr/bin/python3$' -i easywks/usr/bin/easywks
- find easywks -type f -exec chmod 0644 {} \;
- find easywks -type d -exec chmod 755 {} \;
- chmod +x easywks/usr/bin/easywks easywks/DEBIAN/postinst easywks/DEBIAN/prerm easywks/DEBIAN/postrm
- dpkg-deb --build easywks
- mv easywks.deb "easywks_${EASYWKS_VERSION}-1_all.deb"
- sudo -u nobody lintian "easywks_${EASYWKS_VERSION}-1_all.deb"
- sha256sum *.deb > SHA256SUMS
artifacts:
paths:
- "package/debian/*.deb"
- package/debian/SHA256SUMS
only:
- tags
release:
stage: deploy
script:
- python3 package/release.py
only:
- tags
repo:
stage: upload
trigger: s3lph/custom-packages
variables:
MULTIPROJECT_TRIGGER_JOBNAME: easywks
only:
- tags

View file

@ -1,142 +1,5 @@
# EasyWKS Changelog
<!-- BEGIN RELEASE v0.4.6 -->
## Version 0.4.6
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.4.6 -->
- Fix: Don't put multiple keys into a DANE record
<!-- END CHANGES 0.4.6-->
<!-- END RELEASE v0.4.6 -->
<!-- BEGIN RELEASE v0.4.5 -->
## Version 0.4.5
Migrate from Woodpecker CI to Forgejo Actions
### Changes
<!-- BEGIN CHANGES 0.4.5 -->
- Migrate from Woodpecker CI to Forgejo Actions
<!-- END CHANGES 0.4.5-->
<!-- END RELEASE v0.4.5 -->
<!-- BEGIN RELEASE v0.4.4 -->
## Version 0.4.4
smtpd: Log errors to stdout rather than SMTP session
### Changes
<!-- BEGIN CHANGES 0.4.4 -->
- smtpd: Log errors to stdout rather than SMTP session
<!-- END CHANGES 0.4.4-->
<!-- END RELEASE v0.4.4 -->
<!-- BEGIN RELEASE v0.4.3 -->
## Version 0.4.3
Migrate from Gitlab to Forgejo
### Changes
<!-- BEGIN CHANGES 0.4.3 -->
- Migrate from Gitlab to Forgejo
<!-- END CHANGES 0.4.3-->
<!-- END RELEASE v0.4.3 -->
<!-- BEGIN RELEASE v0.4.2 -->
## Version 0.4.2
Minor feature release
### Changes
<!-- BEGIN CHANGES 0.4.2 -->
- Add option to provide DNS NOTIFY to DANE zone replicas
<!-- END CHANGES 0.4.2-->
<!-- END RELEASE v0.4.2 -->
<!-- BEGIN RELEASE v0.4.1 -->
## Version 0.4.1
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.4.1 -->
- Don't refuse DNS IXFRs and respond with full AXFR
<!-- END CHANGES 0.4.1-->
<!-- END RELEASE v0.4.1 -->
<!-- BEGIN RELEASE v0.4.0 -->
## Version 0.4.0
Feature release
### Changes
<!-- BEGIN CHANGES 0.4.0 -->
- Add authoritative DNS server providing DANE OPENPGPKEY (TYPE61) DNS records
<!-- END CHANGES 0.4.0-->
<!-- END RELEASE v0.4.0 -->
<!-- BEGIN RELEASE v0.3.1 -->
## Version 0.3.1
Feature release
### Changes
<!-- BEGIN CHANGES 0.3.1 -->
- Implement standard and EasyWKS-specific policy flags
- **Deprecation**: The top level option `permit_unsigned_response` is deprecated
and will be removed in a future release. Use the per-domain policy flag
`me.s3lph.easywks_permit-unsigned-response` instead.
<!-- END CHANGES 0.3.1 -->
<!-- END RELEASE v0.3.1 -->
<!-- BEGIN RELEASE v0.3.0 -->
## Version 0.3.0
Feature release
### Changes
<!-- BEGIN CHANGES 0.3.0 -->
- Set CORS headers on HTTP responses
- Implement direct WKD URLs
- Allow submitting additional revoked keys with the submission request
<!-- END CHANGES 0.3.0 -->
<!-- END RELEASE v0.3.0 -->
<!-- BEGIN RELEASE v0.2.0 -->
## Version 0.2.0
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.2.0 -->
- Release pipeline runs integration test against gpg-wks-client
- Fix minor incompatibilities with gpg-wks-client
- Fix per-domain configuration (e.g. submission-address was not loaded)
<!-- END CHANGES 0.2.0 -->
<!-- END RELEASE v0.2.0 -->
<!-- BEGIN RELEASE v0.1.11 -->
## Version 0.1.11

View file

@ -7,7 +7,7 @@
## What is WKD/WKS?
Due to all the issues involved with the PGP key servers we're using today, GnuPG introduced a feature named [**Web Key
Directory**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
Discovery**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
decentralized and federated approach, where each mail domain is responsible for hosting its users public keys on an
HTTPS web directory. For example, in order to retrieve the key for `john.doe@example.org`, they key can be located at
@ -50,8 +50,6 @@ gpg-wks-server to EasyWKS.
- PyYAML
- bottle.py
- PGPy
- dnspython (for DANE support)
- Twisted (for DANE support)
## License
@ -118,11 +116,6 @@ 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.
@ -259,84 +252,6 @@ gpgwks@example.org lmtp:localhost:10024
webkey@example.com lmtp:localhost:10024
```
### DANE DNS Setup
Apart from WKD, EasyWKS can also serve PGP keys using RFC7929 DNS records ("OPENPGPKEY" or "TYPE61" records). However,
since EasyWKS does not implement DNSSEC signing, it cannot do this alone. The authoritative DNS server in EasyWKS only
responds to AXFR zone transfer requests. In order for DANE lookups to work, the zones must be replicated (AXFR'd) by an
authoritative secondary nameserver that signs the zones itself.
#### EasyWKS DNS Server
Configure EasyWKS to run the DNS server, e.g. using the following systemd unit:
```unit file (systemd)
[Unit]
Description=OpenPGP WKS for Human Beings - DANE DNS Server
[Service]
Type=simple
ExecStart=/path/to/easywks dnsd
Restart=on-failure
User=webkey
Group=webkey
WorkingDirectory=/var/lib/easywks
[Install]
WantedBy=multi-user.target
```
If you're using EasyWKS' DANE feature, it is highly recommended to configure the SOA and NS records for each domain
you're serving. Generally you want to add NS records for all nameservers that will be serving your zone, and at least
set the MNAME and RNAME components of the SOA record. You can also configure EasyWKS to provide zone update
notifications whenever a key is modified:
```yaml
domains:
example.org:
ns:
- ns1.example.org.
- ns2.example.org.
- ns1.example.com.
- ns2.example.com.
notify:
- "2001:db8::53@10053"
soa:
mname: ns1.example.org.
rname: dnsadmin.example.org.
refresh: 300
retry: 60
expire: 1209600
minimal: 300
```
#### Knot
Knot is an authoritative nameserver that supports signing a replicated zone by the secondary (replicating) nameserver.
To configure Knot to transfer the zone from EasyWKS, set up an EasyWKS remote and use it as the replication master for
DANE zones. DNSSEC signing must be enabled as well. If you want Knot to be notified of zone changes, set up a notify
ACL too:
```yaml
acl:
- id: acl-easywks.example.org
address: [::1]
action: notify
remote:
- id: remote-easywks.example.org
address: [::1]@10053
zone:
- domain: _openpgpkey.example.org
master: remote-easywks.example.org
acl: acl-easywks.example.org
dnssec-signing: on
dnssec-policy: ...
```
## EasyWKS Client
The file `client.py` contains a self-contained WKS client, which

View file

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

View file

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

View file

@ -3,33 +3,6 @@ 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:
@ -92,15 +65,6 @@ def _validate_lmtpd_config(value):
return f'port must be a int, got {type(value["port"])}'
def _validate_dnsd_config(value):
if not isinstance(value, dict):
return f'must be a map, got {type(value)}'
if not isinstance(value['host'], str):
return f'host must be a str, got {type(value["host"])}'
if not isinstance(value['port'], int):
return f'port must be a int, got {type(value["port"])}'
def _validate_policy_flags(value):
alphabet = string.ascii_lowercase + string.digits + '-._'
if not isinstance(value, dict):
@ -113,46 +77,6 @@ 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):
@ -249,10 +173,9 @@ class _GlobalConfig(_Config):
def __make_domain(self, domain):
self.__domains[domain] = _Config(
submission_address=_ConfigOption('submission_address', str, f'gpgwks@{domain}'),
submission_address=_ConfigOption('address', str, f'gpgwks@{domain}'),
passphrase=_ConfigOption('passphrase', str, ''),
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags),
dane=_ConfigOption('dane', dict, {}, validator=_validate_dane)
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags)
)
def __getitem__(self, item):
@ -271,14 +194,13 @@ class _GlobalConfig(_Config):
for domain, dconf in conf['domains'].items():
self.__make_domain(domain)
for co in self.__domains[domain]._options.values():
co.load(dconf)
co.load(conf)
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',
@ -295,10 +217,6 @@ 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),
)

View file

@ -1,90 +0,0 @@
from datetime import datetime
from twisted.internet import reactor, defer
from twisted.names import dns, server, common, error
from twisted.python import util as tputil, failure
from .config import Config
from .files import read_dane_public_keys
class Record_OPENPGPKEY(tputil.FancyEqMixin, tputil.FancyStrMixin):
TYPE = 61
fancybasename = 'OPENPGPKEY'
compareAttributes = ('data',)
showAttributes = ('data',)
def __init__(self, data=None, ttl=0):
self.data = data
self.ttl = ttl
def encode(self, strio, compDict=None):
strio.write(self.data)
def decode(self, strio, length=None):
self.data = strio.read(length)
def __hash__(self):
return hash(self.data)
class DnsServer(common.ResolverBase):
def __init__(self):
super().__init__()
self.zones = {}
self._load()
def _load(self):
for domain in Config.domains:
origin = dns.domainString(f'_openpgpkey.{domain}')
self.zones[origin] = domain
def _make_soa(self, name):
domain = self.zones[name]
now = int(datetime.utcnow().timestamp()) // 60
soa = dns.Record_SOA(mname=Config[domain].dane['soa'].get('mname', 'localhost.'),
rname=Config[domain].dane['soa'].get('rname',
Config[domain].submission_address.replace('@', '.')),
serial=now,
refresh=Config[domain].dane['soa'].get('refresh', 300),
retry=Config[domain].dane['soa'].get('retry', 60),
expire=Config[domain].dane['soa'].get('expire', 2419200),
minimum=Config[domain].dane['soa'].get('minimum', 300))
return dns.RRHeader(name, dns.SOA, payload=soa, auth=True)
def _make_ns(self, name):
domain = self.zones[name]
return [
dns.RRHeader(name, dns.NS, payload=dns.Record_NS(host), auth=True)
for host in Config[domain].dane['ns']
]
def _lookup(self, name, cls, type, timeout):
if name not in self.zones:
return defer.fail(failure.Failure(dns.AuthoritativeDomainError(name)))
if type == dns.SOA or type == dns.OPT:
return defer.succeed(([self._make_soa(name)], [], []))
if type != dns.AXFR and type != dns.IXFR:
return defer.fail(failure.Failure(error.DNSQueryRefusedError(name)))
domain = self.zones[name]
results = []
soa = self._make_soa(name)
ns = self._make_ns(name)
results.append(soa)
results.extend(ns)
for digest, key in read_dane_public_keys(domain).items():
fqdn = f'{digest}._openpgpkey.{domain}'
record = Record_OPENPGPKEY(key)
results.append(dns.RRHeader(dns.domainString(fqdn), record.TYPE, payload=record, auth=True))
results.append(soa)
return defer.succeed((results, [], []))
# noinspection PyUnresolvedReferences
# The "reactor" interface is created dynamically, so the listenTCP and run methods only become available during runtime.
def run_dnsd(args):
auth = DnsServer()
factory = server.DNSServerFactory(authorities=[auth])
reactor.listenTCP(Config.dnsd['port'], factory, interface=Config.dnsd['host'])
reactor.run()

View file

@ -8,7 +8,7 @@ from pgpy import PGPKey
from .config import Config
from .crypto import create_pgp_key, privkey_to_pubkey
from .util import hash_user_id, armor_keys, split_revoked, dane_digest, dane_notify
from .util import hash_user_id
def _locked_read(file: str, binary: bool = False):
@ -36,15 +36,10 @@ def make_submission_address_file(domain: str):
def make_policy_file(domain: str):
content = f'submission-address: {Config[domain].submission_address}\n'
for flag, value in Config[domain].policy_flags.items():
if isinstance(value, bool):
if not value:
continue
else:
content += flag + '\n'
elif value is None:
content += flag + '\n'
else:
if not value or len(value) == 0:
content += f'{flag}: {value}\n'
else:
content += flag + '\n'
return content
@ -58,62 +53,42 @@ def init_working_directory():
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
_locked_write(os.path.join(wdir, domain, 'submission-address'), make_submission_address_file(domain))
_locked_write(os.path.join(wdir, domain, 'policy'), make_policy_file(domain))
os.makedirs(os.path.join(wdir, domain, 'dane'), exist_ok=True)
# Create PGP key if it doesn't exist yet
create_pgp_key(domain)
# Export submission key to hu dir
key = privkey_to_pubkey(domain)
uid = hash_user_id(Config[domain].submission_address)
_locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True)
digest = dane_digest(Config[domain].submission_address)
_locked_write(os.path.join(wdir, domain, 'dane', digest), bytes(key), binary=True)
dane_notify(domain)
def read_public_key(domain, user):
return read_hashed_public_key(domain, hash_user_id(user))
hu = hash_user_id(user)
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
return key
def read_hashed_public_key(domain, hu):
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
_, keys = PGPKey.from_blob(_locked_read(keyfile, binary=True))
key, revoked = split_revoked(keys.values())
return key, revoked
key, _ = PGPKey.from_blob(_locked_read(keyfile, binary=True))
return key
def read_dane_public_keys(domain):
path: str = os.path.join(Config.working_directory, domain, 'dane')
dane_keys = {}
for fname in os.listdir(path):
if len(fname) != 56:
continue
keyfile = os.path.join(path, fname)
dane_keys[fname] = _locked_read(keyfile, binary=True)
return dane_keys
def write_public_key(domain, user, key, revoked):
def write_public_key(domain, user, key):
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)
_locked_write(keyfile, bytes(key), binary=True)
def read_pending_key(domain, nonce):
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
_, keys = PGPKey.from_blob(_locked_read(keyfile))
key, revoked = split_revoked(keys.values())
return key[0], revoked
key, _ = PGPKey.from_blob(_locked_read(keyfile))
return key
def write_pending_key(domain, nonce, key, revoked_keys):
def write_pending_key(domain, nonce, key):
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
armored = armor_keys([key] + revoked_keys)
_locked_write(keyfile, armored)
_locked_write(keyfile, str(key))
def remove_pending_key(domain, nonce):

View file

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

View file

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

View file

@ -4,7 +4,6 @@ from .files import init_working_directory, clean_stale_requests
from .process import process_mail_from_stdin, process_key_from_stdin
from .httpd import run_server
from .lmtpd import run_lmtpd
from .dnsd import run_dnsd
import sys
@ -33,9 +32,6 @@ def parse_arguments():
server = sp.add_parser('lmtpd', help='Run a LMTP server to receive mails from your MTA. Also see process.')
server.set_defaults(fn=run_lmtpd)
server = sp.add_parser('dnsd', help='Run an authoritative DNS server to provide DANE TYPE61 zones.')
server.set_defaults(fn=run_dnsd)
imp = sp.add_parser('import', help='Import a public key from stdin directly into the WKD without WKS verification.')
imp.add_argument('--uid', '-u', type=str, action='append',
help='Limit import to a subset of the key\'s UIDs. Can be provided multiple times.')

View file

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

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,96 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="275.00003"
height="275.00003"
viewBox="0 0 72.760425 72.760425"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25, custom)"
sodipodi:docname="easywks.svg"
inkscape:export-filename="easywks.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#999999"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="1.7928421"
inkscape:cx="63.028417"
inkscape:cy="98.725927"
inkscape:window-width="1920"
inkscape:window-height="1060"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="-13.229167"
originy="-13.229167"
spacingx="1.3229167"
spacingy="1.3229167"
empcolor="#3f3fff"
empopacity="0.25098039"
color="#3f3fff"
opacity="0.1254902"
empspacing="5"
dotted="false"
gridanglex="30"
gridanglez="30"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-13.229167,-13.229167)">
<path
id="path2"
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 84.666667,85.989583 44.317708,45.640625 39.6875,50.270833 46.302083,56.885417 47.625,58.208333 h 5.291667 V 63.5 l 1.322916,1.322917 h 2.645834 v 2.645833 l 1.322916,1.322917 h 6.614584 v 6.614583 l 1.322916,1.322917 h 1.322917 v 1.322916 L 68.791667,79.375 h 3.96875 v 3.96875 l 2.645833,2.645833 z" />
<path
id="path10"
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 46.302083,43.65625 -1.984375,1.984375 40.348959,40.348958 1.322916,-1.322916 V 83.34375 Z" />
<path
id="path9"
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 47.625,42.333333 -1.322917,1.322917 39.6875,39.6875 v -2.645833 z" />
<path
id="path8"
style="fill:#adadad;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 85.989583,79.375 48.286458,41.671875 47.625,42.333333 85.989583,80.697917 Z" />
<path
id="path7"
style="fill:#7a7a7a;fill-opacity:1;stroke:none;stroke-width:0.264583px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 50.270833,39.6875 48.286458,41.671875 85.989583,79.375 v -3.96875 z" />
<path
id="path1"
style="fill:#7a7a7a;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0499999"
d="m 33.072917,13.229167 a 19.84375,19.84375 0 0 0 -19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,19.84375 19.84375,19.84375 0 0 0 19.84375,-19.84375 19.84375,19.84375 0 0 0 -19.84375,-19.84375 z m -6.619751,6.624402 a 6.6095872,6.6045904 0 0 1 6.609932,6.604764 6.6095872,6.6045904 0 0 1 -6.609932,6.604765 6.6095872,6.6045904 0 0 1 -6.609416,-6.604765 6.6095872,6.6045904 0 0 1 6.609416,-6.604764 z" />
<path
style="fill:#2ce000;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="m 20.835939,55.562502 h 15.213543 v 15.213541 h 7.60677 L 28.442711,85.989585 13.229168,70.776043 h 7.606771 z"
id="path11" />
<path
style="fill:#ffa708;fill-opacity:1;stroke:none;stroke-width:0.30427px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
d="M 78.382819,43.65625 H 63.169276 V 28.442709 h -7.60677 L 70.776047,13.229167 85.98959,28.442709 h -7.606771 z"
id="path11-5" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

View file

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

View file

@ -1,10 +1,10 @@
Package: easywks
Version: __VERSION__
Version: __EASYWKS_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, python3-dnspython, python3-twisted
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd
Description: OpenPGP WKS for Human Beings
EasyWKS is a drop-in replacement for gpg-wks-server that aims to be
much easyier to use manually, while maintaing compatibility with the

View file

@ -7,14 +7,10 @@
# 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
@ -46,11 +42,6 @@ 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.
@ -84,51 +75,3 @@ domains:
# or if you're supplying your own password-protected key, set the
# passphrase here:
#passphrase: "Correct Horse Battery Staple"
# Policy flags control behavior of the submission process for this
# domain. Supports most of the standard policy flags (see
# https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-15#section-4.5
# for details) as well as some EasyWKS-namespaced flags.
policy_flags:
# The mail server provider does only accept keys with only a
# mailbox in the User ID. In particular User IDs with a real name
# in addition to the mailbox will be rejected as invalid.
#mailbox-only: false
# The submission of the mail to the server is done using an
# authenticated connection. Thus the submitted key will be
# published immediately without any confirmation request.
#auth-submit: false
# This keyword can be used to explicitly claim the support of a
# specific version of the Web Key Directory update protocol. This
# is in general not needed but implementations may have
# workarounds for providers which only support an old protocol
# version. If these providers update to a newer version they
# should add this keyword so that the implementation can disable
# the workaround. The value is an integer corresponding to the
# respective draft revision number.
#protocol-version: null
# Some clients (including recent versions of gpg-wks-client
# follow an older version of the WKS standard where signing the
# confirmation response is only recommended, but not required.
# Set this option to true if you want to accept such unsigned
# responses.
#me.s3lph.easywks_permit-unsigned-response: false
# Remove all UIDs except the one being verified.
#me.s3lph.easywks_strip-unverified-uids: false
# Remove user attribute (i.e. photo) UIDs.
#me.s3lph.easywks_strip-ua-uids: false
# Remove all third party certifications fom the submitted keys.
# Certifications issued by revoked keys submitted in the same
# submission request are exempt from this policy.
#me.s3lph.easywks_strip-3rdparty-signatures: false
# Maximal number of revoked keys that can be submitted alongside
# a valid key. If this flag is absent or has value -1, an
# unlimited number of revoked keys is permitted.
#me.s3lph.easywks_max-revoked-keys: -1

View file

@ -1,13 +0,0 @@
[Unit]
Description=OpenPGP WKS for Human Beings - DANE DNS Server
[Service]
Type=simple
ExecStart=/usr/bin/easywks dnsd
Restart=on-failure
User=easywks
Group=easywks
WorkingDirectory=/var/lib/easywks
[Install]
WantedBy=multi-user.target

View file

@ -10,7 +10,4 @@ lmtpd:
httpd:
host: "::"
port: 80
dnsd:
host: "::"
port: 53
domains: {}

View file

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

View file

@ -1,24 +0,0 @@
ServerName example.org
<VirtualHost *:80>
ServerName example.org
ServerAlias openpgpkey.example.org
ServerAlias openpgpkey
DocumentRoot /var/www/html
RewriteEngine On
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI}
</VirtualHost>
<VirtualHost *:443>
ServerName example.org
ServerAlias openpgpkey.example.org
ServerAlias openpgpkey
DocumentRoot /var/www/html
SSLEngine On
SSLCertificateFile /etc/ssl/cert.pem
SSLCertificateKeyFile /etc/ssl/key.pem
ProxyPass /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
ProxyPassReverse /.well-known/openpgpkey http://localhost:8080/.well-known/openpgpkey
</VirtualHost>

View file

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="example.org">
<domain>example.org</domain>
<displayName>EasyWKS Example</displayName>
<displayShortName>Example</displayShortName>
<incomingServer type="imap">
<hostname>example.org</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILLOCALPART%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>example.org</hostname>
<port>25</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILLOCALPART%</username>
</outgoingServer>
</emailProvider>
</clientConfig>

View file

@ -1,8 +0,0 @@
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0666
}
}
ssl_cert = </etc/ssl/cert.pem
ssl_key = </etc/ssl/key.pem
log_path = /dev/stderr

View file

@ -1,13 +0,0 @@
directory: /tmp/easywks
httpd:
host: 127.0.0.1
port: 8080
lmtpd:
host: 127.0.0.1
port: 8024
mailing_method: smtp
domains:
example.org:
submission_address: webkey@example.org
policy_flags:
me.s3lph.easywks_permit-unsigned-response: true # required for gpg-wks-client compat

View file

@ -1,19 +0,0 @@
#!/usr/bin/expect -f
spawn ./client.py
expect "Enter email: "
send "alice@example.org\n"
expect "Chose $env(FINGERPRINT)"
expect "Enter IMAP/POP3/SMTP password (will not echo): "
send "supersecurepassword\n"
expect "Autoconfigured incoming server"
expect "Autoconfigured outgoing server"
expect "Please confirm: \[Y/n\] "
send "y\n"
expect "Sending submission request"
expect "Awaiting response"
expect "Received confirmation request"
expect "Creating confirmation response."
expect "Sending confirmation response"
expect "Awaiting publish response"
expect "Your key has been published to the Web Key Directory."
expect eof

View file

@ -1,24 +0,0 @@
#!/bin/bash
cat >/tmp/keygen <<EOF
%no-protection
%no-ask-passphrase
%transient-key
Key-Type: EDDSA
Key-Curve: ed25519
Subkey-Type: ECDH
Subkey-Curve: cv25519
Expire-Date: 0
Name-Real: EasyWKS Test User
Name-Comment: TEST KEY DO NOT USE
Name-Email: ${1}
EOF
gpg --batch --full-gen-key /tmp/keygen
for uid in $@; do
gpg --batch --quick-add-uid "${1}" "EasyWKS Test User (TEST KEY DO NOT USE) <${uid}>"
done
gpg --export --armor "${1}" > "/tmp/${1}.asc"
for uid in $@; do
gpg --export --armor "${uid}" > "/tmp/${uid}.asc"
done

View file

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