Add DANE OPENPGPKEY support
This commit is contained in:
parent
360303d72f
commit
6c27d799e3
13 changed files with 264 additions and 6 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -1,5 +1,18 @@
|
|||
# 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 -->
|
||||
## Version 0.3.1
|
||||
|
||||
|
|
76
README.md
76
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
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
__version__ = '0.3.1'
|
||||
__version__ = '0.4.0'
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
89
easywks/dnsd.py
Normal file
89
easywks/dnsd.py
Normal 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()
|
|
@ -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):
|
||||
|
|
|
@ -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.')
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
[Unit]
|
||||
Description=OpenPGP WKS for Human Beings - DANE DNS Server
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/easywks dnsd
|
||||
Restart=on-failure
|
||||
User=easywks
|
||||
Group=easywks
|
||||
WorkingDirectory=/var/lib/easywks
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
|
@ -10,4 +10,7 @@ lmtpd:
|
|||
httpd:
|
||||
host: "::"
|
||||
port: 80
|
||||
dnsd:
|
||||
host: "::"
|
||||
port: 53
|
||||
domains: {}
|
||||
|
|
2
setup.py
2
setup.py
|
@ -16,8 +16,10 @@ setup(
|
|||
install_requires=[
|
||||
'aiosmtpd',
|
||||
'bottle',
|
||||
'dnspython',
|
||||
'PyYAML',
|
||||
'PGPy',
|
||||
'Twisted',
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
|
|
Loading…
Reference in a new issue