Add DANE OPENPGPKEY support

This commit is contained in:
s3lph 2023-04-04 20:15:46 +02:00
parent 360303d72f
commit 6c27d799e3
13 changed files with 264 additions and 6 deletions

View file

@ -1,5 +1,18 @@
# EasyWKS Changelog # EasyWKS Changelog
<!-- 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 --> <!-- BEGIN RELEASE v0.3.1 -->
## Version 0.3.1 ## Version 0.3.1

View file

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

View file

@ -1,2 +1,2 @@
__version__ = '0.3.1' __version__ = '0.4.0'

View file

@ -92,6 +92,15 @@ def _validate_lmtpd_config(value):
return f'port must be a int, got {type(value["port"])}' return f'port must be a int, got {type(value["port"])}'
def _validate_dnsd_config(value):
if not isinstance(value, dict):
return f'must be a map, got {type(value)}'
if not isinstance(value['host'], str):
return f'host must be a str, got {type(value["host"])}'
if not isinstance(value['port'], int):
return f'port must be a int, got {type(value["port"])}'
def _validate_policy_flags(value): def _validate_policy_flags(value):
alphabet = string.ascii_lowercase + string.digits + '-._' alphabet = string.ascii_lowercase + string.digits + '-._'
if not isinstance(value, dict): if not isinstance(value, dict):
@ -119,6 +128,24 @@ def _validate_policy_flags(value):
return f'invalid type {v.__class__.__name__} for flag {flag}' 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.']
def _validate_responses(value): def _validate_responses(value):
if not isinstance(value, dict): if not isinstance(value, dict):
return f'must be a map, got {type(value)}' return f'must be a map, got {type(value)}'
@ -215,7 +242,8 @@ class _GlobalConfig(_Config):
self.__domains[domain] = _Config( self.__domains[domain] = _Config(
submission_address=_ConfigOption('submission_address', str, f'gpgwks@{domain}'), submission_address=_ConfigOption('submission_address', str, f'gpgwks@{domain}'),
passphrase=_ConfigOption('passphrase', str, ''), passphrase=_ConfigOption('passphrase', str, ''),
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags) policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags),
dane=_ConfigOption('dane', dict, {}, validator=_validate_dane)
) )
def __getitem__(self, item): def __getitem__(self, item):
@ -258,6 +286,10 @@ Config = _GlobalConfig(
'host': 'localhost', 'host': 'localhost',
'port': 25, 'port': 25,
}, validator=_validate_lmtpd_config), }, validator=_validate_lmtpd_config),
dnsd=_ConfigOption('dnsd', dict, {
'host': '::1',
'port': 10053,
}, validator=_validate_dnsd_config),
responses=_ConfigOption('responses', dict, {}, validator=_validate_responses), responses=_ConfigOption('responses', dict, {}, validator=_validate_responses),
) )

89
easywks/dnsd.py Normal file
View file

@ -0,0 +1,89 @@
from datetime import datetime
import dns
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 type != dns.AXFR:
return defer.fail(failure.Failure(error.DNSQueryRefusedError(name)))
if name not in self.zones:
return defer.fail(failure.Failure(dns.AuthoritativeDomainError(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 .config import Config
from .crypto import create_pgp_key, privkey_to_pubkey from .crypto import create_pgp_key, privkey_to_pubkey
from .util import hash_user_id, armor_keys, split_revoked from .util import hash_user_id, armor_keys, split_revoked, dane_digest
def _locked_read(file: str, binary: bool = False): def _locked_read(file: str, binary: bool = False):
@ -58,12 +58,15 @@ def init_working_directory():
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True) os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
_locked_write(os.path.join(wdir, domain, 'submission-address'), make_submission_address_file(domain)) _locked_write(os.path.join(wdir, domain, 'submission-address'), make_submission_address_file(domain))
_locked_write(os.path.join(wdir, domain, 'policy'), make_policy_file(domain)) _locked_write(os.path.join(wdir, domain, 'policy'), make_policy_file(domain))
os.makedirs(os.path.join(wdir, domain, 'dane'), exist_ok=True)
# Create PGP key if it doesn't exist yet # Create PGP key if it doesn't exist yet
create_pgp_key(domain) create_pgp_key(domain)
# Export submission key to hu dir # Export submission key to hu dir
key = privkey_to_pubkey(domain) key = privkey_to_pubkey(domain)
uid = hash_user_id(Config[domain].submission_address) uid = hash_user_id(Config[domain].submission_address)
_locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True) _locked_write(os.path.join(wdir, domain, 'hu', uid), bytes(key), binary=True)
digest = dane_digest(Config[domain].submission_address)
_locked_write(os.path.join(wdir, domain, 'dane', digest), bytes(key), binary=True)
def read_public_key(domain, user): def read_public_key(domain, user):
@ -77,11 +80,25 @@ def read_hashed_public_key(domain, hu):
return key, revoked 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): def write_public_key(domain, user, key, revoked):
hu = hash_user_id(user) hu = hash_user_id(user)
dane = dane_digest(user)
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu) keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
danefile = os.path.join(Config.working_directory, domain, 'dane', dane)
joined = bytes(key) + b''.join([bytes(k) for k in revoked]) joined = bytes(key) + b''.join([bytes(k) for k in revoked])
_locked_write(keyfile, joined, binary=True) _locked_write(keyfile, joined, binary=True)
_locked_write(danefile, joined, binary=True)
def read_pending_key(domain, nonce): def read_pending_key(domain, nonce):

View file

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

View file

@ -33,6 +33,12 @@ def hash_user_id(uid: str) -> str:
return _zrtp_base32(digest) return _zrtp_base32(digest)
def dane_digest(uid: str) -> str:
if '@' in uid:
uid, _ = uid.split('@', 1)
return hashlib.sha256(uid.encode('utf-8')).hexdigest()[:56]
def create_nonce(n: int = 32) -> str: def create_nonce(n: int = 32) -> str:
alphabet = string.ascii_letters + string.digits alphabet = string.ascii_letters + string.digits
nonce = ''.join(secrets.choice(alphabet) for _ in range(n)) nonce = ''.join(secrets.choice(alphabet) for _ in range(n))

View file

@ -4,7 +4,7 @@ Maintainer: s3lph <1375407-s3lph@users.noreply.gitlab.com>
Section: web Section: web
Priority: optional Priority: optional
Architecture: all Architecture: all
Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd Depends: python3 (>= 3.6), python3-pgpy, python3-bottle, python3-yaml, python3-aiosmtpd, python3-dnspython, python3-twisted
Description: OpenPGP WKS for Human Beings Description: OpenPGP WKS for Human Beings
EasyWKS is a drop-in replacement for gpg-wks-server that aims to be EasyWKS is a drop-in replacement for gpg-wks-server that aims to be
much easyier to use manually, while maintaing compatibility with the much easyier to use manually, while maintaing compatibility with the

View file

@ -7,7 +7,7 @@
# considered stale and should be removed by easywks clean. # considered stale and should be removed by easywks clean.
#pending_lifetime: 604800 #pending_lifetime: 604800
# Some clients (including recent versions of gpg-wks-client follow an # Some clients including recent versions of gpg-wks-client follow an
# older version of the WKS standard where signing the confirmation # older version of the WKS standard where signing the confirmation
# response is only recommended, but not required. Set this option to # response is only recommended, but not required. Set this option to
# true if you want to accept such unsigned responses. # true if you want to accept such unsigned responses.
@ -46,6 +46,11 @@ lmtpd:
host: "::1" host: "::1"
port: 8024 port: 8024
# Configure the authoritative DNS server for DANE zones
dnsd:
host: "::1"
port: 8053
# You can override the mail response templates with your own text. # You can override the mail response templates with your own text.
# The following templates can be overridden: # The following templates can be overridden:
# - "header": Placed in front of every message. # - "header": Placed in front of every message.

View file

@ -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

View file

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

View file

@ -16,8 +16,10 @@ setup(
install_requires=[ install_requires=[
'aiosmtpd', 'aiosmtpd',
'bottle', 'bottle',
'dnspython',
'PyYAML', 'PyYAML',
'PGPy', 'PGPy',
'Twisted',
], ],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [