diff --git a/CHANGELOG.md b/CHANGELOG.md index 9232720..9013efb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # EasyWKS Changelog + +## Version 0.4.0 + +Feature release + +### Changes + + +- Add authoritative DNS server providing DANE OPENPGPKEY (TYPE61) DNS records + + + + ## Version 0.3.1 diff --git a/README.md b/README.md index 4d55e0b..e6dd73e 100644 --- a/README.md +++ b/README.md @@ -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 -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 @@ -50,6 +50,8 @@ gpg-wks-server to EasyWKS. - PyYAML - bottle.py - PGPy +- dnspython (for DANE support) +- Twisted (for DANE support) ## License @@ -116,6 +118,11 @@ 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. @@ -252,6 +259,73 @@ 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: + +```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 The file `client.py` contains a self-contained WKS client, which diff --git a/easywks/__init__.py b/easywks/__init__.py index 81cfc8c..652a8f4 100644 --- a/easywks/__init__.py +++ b/easywks/__init__.py @@ -1,2 +1,2 @@ -__version__ = '0.3.1' +__version__ = '0.4.0' diff --git a/easywks/config.py b/easywks/config.py index af469af..15fcdb7 100644 --- a/easywks/config.py +++ b/easywks/config.py @@ -92,6 +92,15 @@ 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): @@ -119,6 +128,24 @@ def _validate_policy_flags(value): 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): if not isinstance(value, dict): return f'must be a map, got {type(value)}' @@ -215,7 +242,8 @@ class _GlobalConfig(_Config): self.__domains[domain] = _Config( 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): @@ -258,6 +286,10 @@ 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), ) diff --git a/easywks/dnsd.py b/easywks/dnsd.py new file mode 100644 index 0000000..113e796 --- /dev/null +++ b/easywks/dnsd.py @@ -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() diff --git a/easywks/files.py b/easywks/files.py index 8da7dfb..e2c49a7 100644 --- a/easywks/files.py +++ b/easywks/files.py @@ -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 +from .util import hash_user_id, armor_keys, split_revoked, dane_digest 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) _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) def read_public_key(domain, user): @@ -77,11 +80,25 @@ def read_hashed_public_key(domain, hu): 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, joined, binary=True) def read_pending_key(domain, nonce): diff --git a/easywks/main.py b/easywks/main.py index eb55233..026a9fe 100644 --- a/easywks/main.py +++ b/easywks/main.py @@ -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 .httpd import run_server from .lmtpd import run_lmtpd +from .dnsd import run_dnsd 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.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.') diff --git a/easywks/util.py b/easywks/util.py index 4f8ee7b..a0322a5 100644 --- a/easywks/util.py +++ b/easywks/util.py @@ -33,6 +33,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)) diff --git a/package/debian/easywks/DEBIAN/control b/package/debian/easywks/DEBIAN/control index 9493f87..74adfcd 100644 --- a/package/debian/easywks/DEBIAN/control +++ b/package/debian/easywks/DEBIAN/control @@ -4,7 +4,7 @@ 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 diff --git a/package/debian/easywks/etc/easywks.yml b/package/debian/easywks/etc/easywks.yml index 6e4ca46..f9da339 100644 --- a/package/debian/easywks/etc/easywks.yml +++ b/package/debian/easywks/etc/easywks.yml @@ -7,7 +7,7 @@ # 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. @@ -46,6 +46,11 @@ 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. diff --git a/package/debian/easywks/lib/systemd/system/easywks-dnsd.service b/package/debian/easywks/lib/systemd/system/easywks-dnsd.service new file mode 100644 index 0000000..c59fa6f --- /dev/null +++ b/package/debian/easywks/lib/systemd/system/easywks-dnsd.service @@ -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 diff --git a/package/docker/easywks.yml b/package/docker/easywks.yml index 359af34..99ab8b0 100644 --- a/package/docker/easywks.yml +++ b/package/docker/easywks.yml @@ -10,4 +10,7 @@ lmtpd: httpd: host: "::" port: 80 +dnsd: + host: "::" + port: 53 domains: {} diff --git a/setup.py b/setup.py index 4517a8e..ab63387 100644 --- a/setup.py +++ b/setup.py @@ -16,8 +16,10 @@ setup( install_requires=[ 'aiosmtpd', 'bottle', + 'dnspython', 'PyYAML', 'PGPy', + 'Twisted', ], entry_points={ 'console_scripts': [