Initial commit
This commit is contained in:
commit
ac660802a4
14 changed files with 995 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
**/.idea/
|
||||
*.iml
|
||||
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
**/*.egg-info/
|
||||
*.coverage
|
||||
**/.mypy_cache/
|
16
LICENSE
Normal file
16
LICENSE
Normal file
|
@ -0,0 +1,16 @@
|
|||
Copyright 2021 s3lph
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or
|
||||
substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
|
||||
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
169
README.md
Normal file
169
README.md
Normal file
|
@ -0,0 +1,169 @@
|
|||
# EasyWKS
|
||||
|
||||
### OpenPGP WKS for Human Beings
|
||||
|
||||
---
|
||||
|
||||
This is a work-in-progress project. See ROADMAP.md for details
|
||||
|
||||
## What is WKD/WKS?
|
||||
|
||||
Due to all the issues involved with the PGP key servers we're using today, GnuPG introduced a feature named [**Web Key
|
||||
Discovery**][wkd] (WKD): Instead of searching for keys on the usual key servers, WKD is taking a **fully**
|
||||
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://openpgpkey.example.org/.well-known/openpgpkey/example.org/hu/iy9q119eutrkn8s1mk4r39qejnbu3n5q?l=john.doe
|
||||
|
||||
In order to get the keys there, GnuPG developed an email-based protocol named [**Web Key Service**][wks] (WKS), which
|
||||
lets users publish their public keys once they have proven ownership of the key using an email-based challenge-responce
|
||||
mechanism.
|
||||
|
||||
At the time, WKS and WKD aren't yet part of the OpenPGP standard, but there's an [IETF Draft RFC][ietf] that's being
|
||||
updated from time to time.
|
||||
|
||||
## Why EasyWKS?
|
||||
|
||||
I've experienced the WKS standard to be extremely cumbersome to conform to. The tools shipped with GnuPG,
|
||||
`gpg-wks-server` and `gpg-wks-client` are usable well enough as long as you have shell access on a mailserver and pipe
|
||||
the output of `gpg-wks-client` into `sendmail`. However, I'm usually doing my email via SMTP using a variety of
|
||||
clients.
|
||||
|
||||
No matter which client I tried, I did not manage to get them to send mails in the format required by `gpg-wks-server`.
|
||||
Even pasting the output of `gpg-wks-client` into `openssl s_client` after manually performing SMTP auth proved to be
|
||||
difficult in case any line of the PGP-encrypted message happened to start with `Q` or `R`.
|
||||
|
||||
So I decided to write a WKS server that's much more lenient regarding the exact format of the mails it receives.
|
||||
Instead of enforcing the strict format mandated by the standard, EasyWKS only requires:
|
||||
|
||||
- For the initial submission request: An unsigned & encrypted PGP/MIME message anywhere in the MIME tree, and the
|
||||
ASCII-armored PGP-Key anywhere inside the encrypted message.
|
||||
- For the confirmation response: A signed & encrypted PGP/MIME message anywhere in the MIME tree, and the
|
||||
confirmation response anywhere inside the encrypted message.
|
||||
|
||||
This makes EasyWKS usable with every mail client, no matter whether WKS support is built-in or not.
|
||||
|
||||
EasyWKS aims to be a drop-in replacement for gpg-wks-server; see "Bootstrapping" below to learn how to migrate from
|
||||
gpg-wks-server to EasyWKS.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.6 or newer
|
||||
- PyYAML
|
||||
- bottle.py
|
||||
- PGPy
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Setup
|
||||
|
||||
### Installation
|
||||
|
||||
TODO
|
||||
|
||||
Create a cronjob, e.g. in `/etc/cron.d/easywks`
|
||||
|
||||
```crontab
|
||||
0 3 * * * webkey /path/to/easywks clean
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Configuration is done in `/etc/easywks.yml` (or any other place as specified by `--config`)
|
||||
|
||||
```yaml
|
||||
---
|
||||
# EasyWKS works inside this directory. Its PGP keys as well
|
||||
# as all the submitted and published keys are stored here.
|
||||
directory: /var/lib/easywks
|
||||
# Number of seconds after which a pending submission request
|
||||
# is considered stale and should be removed by easywks clean.
|
||||
pending_lifetime: 604800
|
||||
# Port configuration for the webserver. Put this behind a
|
||||
# HTTPS-terminating reverse proxy!
|
||||
host: 127.0.0.1
|
||||
port: 8080
|
||||
# Every domain served by EasyWKS must be listed here
|
||||
domains:
|
||||
example.org:
|
||||
# Users send their requests to this address. It's up to
|
||||
# you to make sure that the mails sent their get handed
|
||||
# to EasyWKS.
|
||||
submission_address: webkey@example.org
|
||||
# If you want the PGP key for this domain to be
|
||||
# password-protected, or if you're supplying your own
|
||||
# password-protected key, set the passphrase here:
|
||||
passphrase: "Correct Horse Battery Staple"
|
||||
# Defaults are gpgwks@<domain> and no password protection.
|
||||
example.com: {}
|
||||
```
|
||||
|
||||
### Bootstrapping
|
||||
|
||||
Run `easywks init` (as the correct user) to initialize all files and directories. This will also generate a PGP key for
|
||||
each domain, stored in `<workdir>/<domain>/key.pgp`. If you want to, you can replace this key by a key you generated
|
||||
yourself. If your key is password-protected, you have to supply the passphrase in the config file or remove the
|
||||
password protection.
|
||||
|
||||
If you are migrating from gpg-wks-server to EasyWKS, you can point EasyWKS to gpg-wks-server's working directory
|
||||
(usually `/var/lib/gnupg/wks`). EasyWKS uses the same directory layout and file formats as gpg-wks-server, so it should
|
||||
be able to take over where gpg-wks-server stopped. The only thing you need to do is to export the private keys from the
|
||||
GnuPG keyring and write them to their domain's `key.pgp`.
|
||||
|
||||
### Webserver Setup
|
||||
|
||||
There are generally two ways to get WKD working:
|
||||
|
||||
- The gpg-wks-server approach, i.e. regularly copying the `hu` directories from EasyWKS' working directory to the
|
||||
webroot.
|
||||
- Running the EasyWKS web server behind a Reverse Proxy, e.g. using the follwing systemd unit:
|
||||
|
||||
```unit file (systemd)
|
||||
[Unit]
|
||||
Description=OpenPGP WKS for Human Beings
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/path/to/easywks webserver
|
||||
Restart=on-failure
|
||||
User=webkey
|
||||
Group=webkey
|
||||
WorkingDirectory=/var/lib/easywks
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Once this service is up and running, you can configure your webserver to proxy requests to EasyWKS. E.g. with Apache2:
|
||||
|
||||
```
|
||||
ProxyPass /.well-known/openpgpkey/ http://127.0.0.1:8080/.well-known/openpgpkey/
|
||||
ProxyPassReverse /.well-known/openpgpkey/ http://127.0.0.1:8080/.well-known/openpgpkey/
|
||||
```
|
||||
|
||||
Note that the webserver built into EasyWKS validates that the `?l=<uid>` matches the uid hash (the last URL component).
|
||||
|
||||
### MTA Setup
|
||||
|
||||
#### Postfix
|
||||
|
||||
Create a small wrapper script, e.g. `/usr/local/bin/postfix-easywks`:
|
||||
|
||||
```shell
|
||||
#!/bin/bash
|
||||
/path/to/easywks process | /usr/sbin/sendmail -t
|
||||
```
|
||||
|
||||
Add an entry in `/etc/postfix/master.cf`:
|
||||
|
||||
```
|
||||
webkey unix - n n - - pipe
|
||||
flags=DRhu user=webkey argv=/usr/local/bin/postfix-easywks
|
||||
```
|
||||
|
||||
|
||||
[wkd]: https://wiki.gnupg.org/WKD
|
||||
[wks]: https://wiki.gnupg.org/WKS
|
||||
[ietf]: https://datatracker.ietf.org/doc/html/draft-koch-openpgp-webkey-service-12
|
6
ROADMAP.md
Normal file
6
ROADMAP.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
# EasyWKS Roadmap
|
||||
|
||||
- [ ] Don't rely on sendmail to send out responses. Use e.g. `smtplib`.
|
||||
- [ ] Get rid of postfix-easywks wrapper script.
|
||||
- [ ] LMTP server mode, e.g. using the `smtpd` module.
|
||||
- [ ] Figure out whether file locking in the working directory is necessary to avoid races.
|
2
easywks/__init__.py
Normal file
2
easywks/__init__.py
Normal file
|
@ -0,0 +1,2 @@
|
|||
|
||||
__version__ = '0.1'
|
91
easywks/config.py
Normal file
91
easywks/config.py
Normal file
|
@ -0,0 +1,91 @@
|
|||
|
||||
import string
|
||||
import yaml
|
||||
|
||||
|
||||
def _validate_policy_flags(value):
|
||||
alphabet = string.ascii_lowercase + string.digits + '-._'
|
||||
if not isinstance(value, dict):
|
||||
return 'policy_flags must be a map'
|
||||
for flag, v in value.items():
|
||||
if not isinstance(flag, str) or len(flag) == 0:
|
||||
return 'policy_flags has non-string or empty members'
|
||||
if flag[0] not in string.ascii_lowercase:
|
||||
return 'policy_flags must start with a lowercase letter'
|
||||
for c in flag:
|
||||
if c not in alphabet:
|
||||
return f'policy_flags has invalid key {flag}'
|
||||
|
||||
|
||||
class _ConfigOption:
|
||||
|
||||
def __init__(self, key, typ, default, validator=None):
|
||||
self._key = key
|
||||
self._typ = typ
|
||||
self._default = default
|
||||
self._validator = validator
|
||||
self._value = default
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
def load(self, conf: dict):
|
||||
self._value = conf.get(self._key, self._default)
|
||||
if not isinstance(self._value, self._typ):
|
||||
raise TypeError(f'Option {self._key} must be {self._typ}, got {type(self._value)}')
|
||||
if self._validator:
|
||||
response = self._validator(self._value)
|
||||
if response:
|
||||
raise ValueError(f'Invalid value for option {self._key}: {response}')
|
||||
|
||||
|
||||
class _Config:
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._options = kwargs
|
||||
|
||||
def __getattr__(self, item):
|
||||
if item in self._options:
|
||||
return self._options[item].value
|
||||
raise AttributeError()
|
||||
|
||||
|
||||
class _GlobalConfig(_Config):
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.__domains = {}
|
||||
|
||||
def __make_domain(self, domain):
|
||||
self.__domains[domain] = _Config(
|
||||
submission_address=_ConfigOption('address', str, f'gpgwks@{domain}'),
|
||||
passphrase=_ConfigOption('passphrase', str, ''),
|
||||
policy_flags=_ConfigOption('policy_flags', dict, {}, validator=_validate_policy_flags)
|
||||
)
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self.__domains.get(item)
|
||||
|
||||
@property
|
||||
def domains(self) -> list:
|
||||
return list(self.__domains.keys())
|
||||
|
||||
def load_config(self, path: str = '/etc/easywks.yml'):
|
||||
with open(path, 'r') as f:
|
||||
conf = yaml.safe_load(f)
|
||||
for co in self._options.values():
|
||||
co.load(conf)
|
||||
self.__domains = {}
|
||||
for domain, dconf in conf['domains'].items():
|
||||
self.__make_domain(domain)
|
||||
for co in self.__domains[domain]._options.values():
|
||||
co.load(conf)
|
||||
|
||||
|
||||
Config = _GlobalConfig(
|
||||
working_directory=_ConfigOption('directory', str, '/var/lib/easywks'),
|
||||
host=_ConfigOption('host', str, '127.0.0.1'),
|
||||
port=_ConfigOption('port', int, 8080),
|
||||
pending_lifetime=_ConfigOption('pending_lifetime', int, 604800)
|
||||
)
|
69
easywks/crypto.py
Normal file
69
easywks/crypto.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
import os
|
||||
import stat
|
||||
|
||||
from pgpy import PGPKey, PGPUID, PGPMessage, PGPSignature
|
||||
from pgpy.constants import PubKeyAlgorithm, KeyFlags, SymmetricKeyAlgorithm, HashAlgorithm, CompressionAlgorithm
|
||||
|
||||
from .config import Config
|
||||
|
||||
|
||||
def _keyfile(domain: str) -> str:
|
||||
return os.path.join(Config.working_directory, domain, 'key.pgp')
|
||||
|
||||
|
||||
class PrivateKey:
|
||||
|
||||
def __init__(self, domain: str):
|
||||
key, _ = PGPKey.from_file(_keyfile(domain))
|
||||
self.key: PGPKey = key
|
||||
self.passphrase: str = Config[domain].passphrase
|
||||
self.context = None
|
||||
|
||||
def __enter__(self):
|
||||
if self.key.is_protected:
|
||||
self.context = self.key.unlock(self.passphrase)
|
||||
self.context.__enter__()
|
||||
return self.key
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
if self.context:
|
||||
self.context.__exit__(exc_type, exc_val, exc_tb)
|
||||
self.context = None
|
||||
|
||||
|
||||
def create_pgp_key(domain: str):
|
||||
keyfile = _keyfile(domain)
|
||||
if os.path.exists(keyfile):
|
||||
return
|
||||
# Generate RSA key and UID
|
||||
key = PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 3072)
|
||||
subkey = PGPKey.new(PubKeyAlgorithm.RSAEncryptOrSign, 3072)
|
||||
uid = PGPUID.new(pn='', comment='', email=Config[domain].submission_address)
|
||||
key.add_uid(uid, selfsign=True, primary=True, key_expiration=None,
|
||||
usage={KeyFlags.Sign, KeyFlags.Certify},
|
||||
hashes=[HashAlgorithm.SHA512, HashAlgorithm.SHA256],
|
||||
ciphers=[SymmetricKeyAlgorithm.AES256, SymmetricKeyAlgorithm.Camellia256],
|
||||
compression=[CompressionAlgorithm.BZ2, CompressionAlgorithm.Uncompressed])
|
||||
key.add_subkey(subkey, usage={KeyFlags.EncryptCommunications})
|
||||
passphrase = Config[domain].passphrase
|
||||
if passphrase is not None and len(passphrase) > 0:
|
||||
key.protect(passphrase, SymmetricKeyAlgorithm.AES256, HashAlgorithm.SHA256)
|
||||
with open(keyfile, 'wb') as f:
|
||||
os.chmod(keyfile, stat.S_IRUSR | stat.S_IWUSR)
|
||||
f.write(bytes(key))
|
||||
|
||||
|
||||
def privkey_to_pubkey(domain: str) -> PGPKey:
|
||||
keyfile = _keyfile(domain)
|
||||
key, _ = PGPKey.from_file(keyfile)
|
||||
return key.pubkey
|
||||
|
||||
|
||||
def pgp_decrypt(domain: str, message: PGPMessage) -> PGPMessage:
|
||||
with PrivateKey(domain) as key:
|
||||
return key.decrypt(message)
|
||||
|
||||
|
||||
def pgp_sign(domain: str, message: PGPMessage) -> PGPSignature:
|
||||
with PrivateKey(domain) as key:
|
||||
return key.sign(message)
|
90
easywks/files.py
Normal file
90
easywks/files.py
Normal file
|
@ -0,0 +1,90 @@
|
|||
|
||||
import os
|
||||
import stat
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pgpy import PGPKey
|
||||
|
||||
from .config import Config
|
||||
from .crypto import create_pgp_key, privkey_to_pubkey
|
||||
from .util import hash_user_id
|
||||
|
||||
|
||||
def make_submission_address_file(domain: str):
|
||||
return Config[domain].submission_address + '\n'
|
||||
|
||||
|
||||
def make_policy_file(domain: str):
|
||||
content = f'submission-address: {Config[domain].submission_address}\n'
|
||||
for flag, value in Config[domain].policy_flags.items():
|
||||
if not value or len(value) == 0:
|
||||
content += f'{flag}: {value}\n'
|
||||
else:
|
||||
content += flag + '\n'
|
||||
return content
|
||||
|
||||
|
||||
def init_working_directory():
|
||||
wdir = Config.working_directory
|
||||
os.makedirs(wdir, exist_ok=True)
|
||||
os.chmod(wdir, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
||||
for domain in Config.domains:
|
||||
# Create necessary files and directories
|
||||
os.makedirs(os.path.join(wdir, domain, 'hu'), exist_ok=True)
|
||||
os.makedirs(os.path.join(wdir, domain, 'pending'), exist_ok=True)
|
||||
with open(os.path.join(wdir, domain, 'submission-address'), 'w') as saf:
|
||||
saf.write(make_submission_address_file(domain))
|
||||
with open(os.path.join(wdir, domain, 'policy'), 'w') as polf:
|
||||
polf.write(make_policy_file(domain))
|
||||
# Create PGP key if it doesn't exist yet
|
||||
create_pgp_key(domain)
|
||||
# Export submission key to hu dir
|
||||
key = privkey_to_pubkey(domain)
|
||||
uid = hash_user_id(Config[domain].submission_address)
|
||||
with open(os.path.join(wdir, domain, 'hu', uid), 'wb') as hu:
|
||||
hu.write(bytes(key))
|
||||
|
||||
|
||||
def read_public_key(domain, user):
|
||||
hu = hash_user_id(user)
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||
key, _ = PGPKey.from_file(keyfile)
|
||||
return key
|
||||
|
||||
|
||||
def write_public_key(domain, user, key):
|
||||
hu = hash_user_id(user)
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'hu', hu)
|
||||
with open(keyfile, 'wb') as f:
|
||||
f.write(bytes(key))
|
||||
|
||||
|
||||
def read_pending_key(domain, nonce):
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||
key, _ = PGPKey.from_file(keyfile)
|
||||
return key
|
||||
|
||||
|
||||
def write_pending_key(domain, nonce, key):
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||
with open(keyfile, 'w') as f:
|
||||
f.write(str(key))
|
||||
|
||||
|
||||
def remove_pending_key(domain, nonce):
|
||||
keyfile = os.path.join(Config.working_directory, domain, 'pending', nonce)
|
||||
os.unlink(keyfile)
|
||||
|
||||
|
||||
def clean_stale_requests():
|
||||
stale = (datetime.utcnow() - timedelta(seconds=Config.pending_lifetime)).timestamp()
|
||||
for domain in Config.domains:
|
||||
pending_dir = os.path.join(Config.working_directory, domain, 'pending')
|
||||
for file in os.listdir(pending_dir):
|
||||
try:
|
||||
absfile = os.path.join(pending_dir, file)
|
||||
if os.stat(absfile).st_mtime < stale:
|
||||
os.unlink(absfile)
|
||||
except BaseException as e:
|
||||
print(e)
|
||||
continue
|
43
easywks/main.py
Normal file
43
easywks/main.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
|
||||
from .config import Config
|
||||
from .files import init_working_directory, clean_stale_requests
|
||||
from .process import process_mail
|
||||
from .server import run_server
|
||||
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
ap = argparse.ArgumentParser(description='OpenPGP WKS for Human Beings')
|
||||
ap.add_argument('--config', '-c', metavar='/path/to/config.yml', type=str, nargs=1, default='/etc/easywks.yaml')
|
||||
sp = ap.add_subparsers(description='EasyWKS understands the following commands:', required=True)
|
||||
|
||||
init = sp.add_parser('init', help='Initialize the EasyWKS working directory and generate the PGP Key'
|
||||
'Also called automatically by the other commands.')
|
||||
init.set_defaults(fn=None)
|
||||
|
||||
clean = sp.add_parser('clean', help='Clean up stale pending requests. Call this in a cronjob.')
|
||||
clean.set_defaults(fn=clean_stale_requests)
|
||||
|
||||
process = sp.add_parser('process', help='Read an incoming mail from stdin and write the response to stdout. '
|
||||
'Hook this up to your MTA.')
|
||||
process.set_defaults(fn=process_mail)
|
||||
|
||||
server = sp.add_parser('webserver', help='Run a WKD web server. Put this behind a HTTPS-terminating reverse proxy.')
|
||||
server.set_defaults(fn=run_server)
|
||||
|
||||
return ap.parse_args(sys.argv[1:])
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
Config.load_config(args.config[0])
|
||||
init_working_directory()
|
||||
if args.fn:
|
||||
args.fn()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
154
easywks/process.py
Normal file
154
easywks/process.py
Normal file
|
@ -0,0 +1,154 @@
|
|||
import sys
|
||||
from typing import List, Dict
|
||||
|
||||
from .crypto import pgp_decrypt
|
||||
from .util import xloop_header
|
||||
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
|
||||
|
||||
|
||||
from email.message import MIMEPart, Message
|
||||
from email.parser import BytesParser
|
||||
from email.policy import default, SMTP
|
||||
from email.utils import getaddresses
|
||||
|
||||
from pgpy import PGPMessage, PGPKey, PGPUID
|
||||
from pgpy.errors import PGPError
|
||||
|
||||
|
||||
def _get_mime_leafs(msg: Message) -> List[MIMEPart]:
|
||||
stack = [msg]
|
||||
leafs = []
|
||||
while len(stack) > 0:
|
||||
node = stack.pop()
|
||||
if node.is_multipart():
|
||||
stack.extend(node.get_payload())
|
||||
else:
|
||||
leafs.append(node)
|
||||
return leafs
|
||||
|
||||
|
||||
def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
|
||||
pgp = None
|
||||
for part in parts:
|
||||
try:
|
||||
p: PGPMessage = PGPMessage.from_blob(part.get_content())
|
||||
except ValueError:
|
||||
continue
|
||||
if p.is_encrypted:
|
||||
if pgp is not None:
|
||||
raise ValueError('More than one encrypted message part')
|
||||
pgp = p
|
||||
if pgp is None:
|
||||
raise ValueError('No encrypted message part')
|
||||
return pgp
|
||||
|
||||
|
||||
def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
|
||||
pubkey = None
|
||||
for part in parts:
|
||||
try:
|
||||
key, _ = PGPKey.from_blob(part.get_content())
|
||||
if key.is_public:
|
||||
if pubkey:
|
||||
raise ValueError('More than one key in message')
|
||||
pubkey = key
|
||||
except PGPError:
|
||||
pass
|
||||
if not pubkey:
|
||||
raise ValueError('No pubkey in message')
|
||||
return pubkey
|
||||
|
||||
|
||||
def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str):
|
||||
payload = BytesParser(policy=default).parsebytes(pgp.message)
|
||||
leafs = _get_mime_leafs(payload)
|
||||
pubkey = _get_pgp_publickey(leafs)
|
||||
sender_uid: PGPUID = pubkey.get_uid(sender)
|
||||
if sender_uid is None or sender_uid.email != sender:
|
||||
raise ValueError(f'Key has no UID that matches {sender}')
|
||||
return SubmissionRequest(sender, submission, pubkey)
|
||||
|
||||
|
||||
def _find_confirmation_response(parts: List[MIMEPart]) -> str:
|
||||
response = None
|
||||
for part in parts:
|
||||
if part.get('type', '') == 'confirmation-response':
|
||||
# the message wasn't a MIME message; return content as-is
|
||||
return str(part)
|
||||
c = part.get_content()
|
||||
if isinstance(c, bytes):
|
||||
try:
|
||||
c = c.decode()
|
||||
except UnicodeDecodeError:
|
||||
# obviously not our part
|
||||
continue
|
||||
if not isinstance(c, str):
|
||||
# not our part either
|
||||
continue
|
||||
if 'confirmation-response' in c:
|
||||
response = c
|
||||
continue
|
||||
if not response:
|
||||
raise ValueError('No confirmation response found in message')
|
||||
return response
|
||||
|
||||
|
||||
def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
|
||||
payload = BytesParser(policy=default).parsebytes(pgp.message)
|
||||
parts = _get_mime_leafs(payload)
|
||||
response = _find_confirmation_response(parts)
|
||||
rdict: Dict[str, str] = {}
|
||||
for line in response.splitlines():
|
||||
if ':' not in line:
|
||||
continue
|
||||
key, value = line.split(':', 1)
|
||||
rdict[key.strip()] = value.strip()
|
||||
if 'sender' not in rdict or 'nonce' not in rdict or rdict.get('type', '') != 'confirmation-response':
|
||||
raise ValueError('Message is not a valid confirmation response')
|
||||
if rdict['sender'] != sender:
|
||||
raise ValueError('Confirmation sender does not match message sender')
|
||||
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
|
||||
|
||||
|
||||
def process_mail(mail: bytes = None):
|
||||
if not mail:
|
||||
mail = sys.stdin.read().encode()
|
||||
msg: Message = BytesParser(policy=default).parsebytes(mail)
|
||||
_, sender_mail = getaddresses([msg['from']])[0]
|
||||
local, sender_domain = sender_mail.split('@')
|
||||
if sender_domain not in Config.domains:
|
||||
raise KeyError(f'Domain {sender_domain} not supported')
|
||||
if msg.get('x-loop', '') == xloop_header(sender_domain) or 'auto-submitted' in msg:
|
||||
# Mail has somehow looped back to us, discard
|
||||
return
|
||||
submission_address: str = Config[sender_domain].submission_address
|
||||
rcpt = getaddresses(msg.get_all('to', []) + msg.get_all('cc', []))
|
||||
if len(rcpt) != 1:
|
||||
raise ValueError('Message has more than one recipients')
|
||||
_, rcpt_mail = rcpt[0]
|
||||
if rcpt_mail != submission_address:
|
||||
raise ValueError(f'Message not addressed to submission address {submission_address} for domain {sender_domain}')
|
||||
leafs = _get_mime_leafs(msg)
|
||||
pgp: PGPMessage = _get_pgp_message(leafs)
|
||||
decrypted = pgp_decrypt(sender_domain, pgp)
|
||||
if decrypted.is_signed:
|
||||
request: ConfirmationResponse = _parse_confirmation_response(decrypted, submission_address, sender_mail)
|
||||
try:
|
||||
key = read_pending_key(sender_domain, request.nonce)
|
||||
except FileNotFoundError:
|
||||
# silently ignore non-existing requests
|
||||
return
|
||||
# this throws an error if signature verification fails
|
||||
response: PublishResponse = request.verify_signature(key)
|
||||
rmsg = response.create_signed_message().as_string(policy=SMTP)
|
||||
write_public_key(sender_domain, sender_mail, key)
|
||||
remove_pending_key(sender_domain, request.nonce)
|
||||
print(rmsg)
|
||||
else:
|
||||
request: SubmissionRequest = _parse_submission_request(decrypted, submission_address, sender_mail)
|
||||
response: ConfirmationRequest = request.confirmation_request()
|
||||
rmsg = response.create_signed_message().as_string(policy=SMTP)
|
||||
write_pending_key(sender_domain, response.nonce, request.key)
|
||||
print(rmsg)
|
46
easywks/server.py
Normal file
46
easywks/server.py
Normal file
|
@ -0,0 +1,46 @@
|
|||
|
||||
from .config import Config
|
||||
from .files import read_public_key, make_submission_address_file, make_policy_file
|
||||
from .util import hash_user_id
|
||||
|
||||
from bottle import get, run, abort, response, request
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/submission-address')
|
||||
def submission_address(domain: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
response.add_header('Content-Type', 'text/plain')
|
||||
return make_submission_address_file(domain)
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/policy')
|
||||
def policy(domain: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
response.add_header('Content-Type', 'text/plain')
|
||||
return make_policy_file(domain)
|
||||
|
||||
|
||||
@get('/.well-known/openpgpkey/<domain>/hu/<userhash>')
|
||||
def hu(domain: str, userhash: str):
|
||||
if domain not in Config.domains:
|
||||
abort(404, 'Not Found')
|
||||
userid = request.query.l
|
||||
print(userid, userhash, hash_user_id(userid))
|
||||
if not userid or hash_user_id(userid) != userhash:
|
||||
abort(404, 'Not Found')
|
||||
try:
|
||||
pubkey = read_public_key(domain, userid)
|
||||
response.add_header('Content-Type', 'application/octet-stream')
|
||||
return bytes(pubkey)
|
||||
except FileNotFoundError:
|
||||
abort(404, 'Not Found')
|
||||
|
||||
|
||||
def run_server():
|
||||
run(host=Config.host, port=Config.port)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_server()
|
231
easywks/types.py
Normal file
231
easywks/types.py
Normal file
|
@ -0,0 +1,231 @@
|
|||
|
||||
from datetime import datetime
|
||||
from email.policy import default
|
||||
from email.utils import format_datetime
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.application import MIMEApplication
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
from pgpy import PGPKey, PGPMessage, PGPUID
|
||||
from pgpy.types import SignatureVerification
|
||||
|
||||
from .crypto import pgp_sign
|
||||
from .util import create_nonce, fingerprint, xloop_header
|
||||
|
||||
|
||||
class SubmissionRequest:
|
||||
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
|
||||
self._submitter_addr = submitter_addr
|
||||
self._submission_addr = submission_addr
|
||||
self._key = key
|
||||
|
||||
def confirmation_request(self) -> 'ConfirmationRequest':
|
||||
return ConfirmationRequest(self._submitter_addr, self. _submission_addr, self._key)
|
||||
|
||||
@property
|
||||
def submitter_address(self):
|
||||
return self._submitter_addr
|
||||
|
||||
@property
|
||||
def submission_address(self):
|
||||
return self._submission_addr
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return self._key
|
||||
|
||||
|
||||
class ConfirmationRequest:
|
||||
|
||||
MAIL_TEXT = '''Hi there!
|
||||
|
||||
This is the EasyWKS system at {domain}.
|
||||
|
||||
You appear to have submitted your key for publication in the Web Key
|
||||
Directory. There's one more step you need to complete. If you did not
|
||||
request this, you can simply ignore this message.
|
||||
|
||||
If your email client doesn't automatically complete this challenge, you
|
||||
can perform this step manually: Please verify that you can decrypt the
|
||||
second part of this message and that the fingerprint listed in the
|
||||
encrypted part matches your key. If everything looks ok, please reply
|
||||
to this message with an **encrypted and signed PGP/MIME message** with
|
||||
the following content (without the <> brackets)
|
||||
|
||||
type: confirmation-response
|
||||
sender: <your email address>
|
||||
nonce: <copy the nonce from the encrypted part of this message>
|
||||
|
||||
For more information on WKD and WKS see:
|
||||
|
||||
https://gnupg.org/faq/wkd.html
|
||||
https://gnupg.org/faq/wks.html
|
||||
|
||||
|
||||
Regards
|
||||
EasyWKS
|
||||
|
||||
--
|
||||
Dance like nobody is watching.
|
||||
Encrypt live everybody is.
|
||||
'''
|
||||
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey, nonce: str = None):
|
||||
self._domain = submitter_addr.split('@')[1]
|
||||
self._submitter_addr = submitter_addr
|
||||
self._submission_addr = submission_addr
|
||||
self._key = key
|
||||
self._nonce = nonce or create_nonce()
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
return self._domain
|
||||
|
||||
@property
|
||||
def submitter_address(self):
|
||||
return self._submitter_addr
|
||||
|
||||
@property
|
||||
def submission_address(self):
|
||||
return self._submission_addr
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return self._key
|
||||
|
||||
@property
|
||||
def nonce(self):
|
||||
return self._nonce
|
||||
|
||||
def create_signed_message(self):
|
||||
mpplain = MIMEText(ConfirmationRequest.MAIL_TEXT.format(domain=self.domain), _subtype='plain')
|
||||
ps = '\r\n'.join([
|
||||
'type: confirmation-request',
|
||||
f'sender: {self._submission_addr}',
|
||||
f'address: {self._submitter_addr}',
|
||||
f'fingerprint: {fingerprint(self._key)}',
|
||||
f'nonce: {self._nonce}',
|
||||
''
|
||||
])
|
||||
payload = MIMEText(ps, _subtype='plain')
|
||||
to_encrypt = PGPMessage.new(payload.as_string(policy=default))
|
||||
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))
|
||||
sig = pgp_sign(self.domain, to_sign)
|
||||
mpsig = MIMEApplication(str(sig), _subtype='pgp-signature')
|
||||
email = MIMEMultipart(_subtype='signed', _subparts=[mixed, mpsig], policy=default,
|
||||
protocol='application/pgp-signature')
|
||||
email['Subject'] = 'Confirm your key publication'
|
||||
email['To'] = self._submitter_addr
|
||||
email['From'] = self._submission_addr
|
||||
email['Date'] = format_datetime(datetime.utcnow())
|
||||
email['Wks-Draft-Version'] = '3'
|
||||
email['Wks-Phase'] = 'confirm'
|
||||
email['X-Loop'] = xloop_header(self.domain)
|
||||
email['Auto-Submitted'] = 'auto-replied'
|
||||
return email
|
||||
|
||||
|
||||
class ConfirmationResponse:
|
||||
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, nonce: str, msg: PGPMessage):
|
||||
self._domain = submitter_addr.split('@')[1]
|
||||
self._submitter_addr = submitter_addr
|
||||
self._submission_addr = submission_addr
|
||||
self._nonce = nonce
|
||||
self._msg = msg
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
return self._domain
|
||||
|
||||
@property
|
||||
def submitter_address(self):
|
||||
return self._submitter_addr
|
||||
|
||||
@property
|
||||
def submission_address(self):
|
||||
return self._submission_addr
|
||||
|
||||
@property
|
||||
def nonce(self):
|
||||
return self._nonce
|
||||
|
||||
def verify_signature(self, key: PGPKey) -> 'PublishResponse':
|
||||
uid: PGPUID = key.get_uid(self._submitter_addr)
|
||||
if uid is None or uid.email != self._submitter_addr:
|
||||
raise ValueError(f'UID {self._submitter_addr} not found in PGP key')
|
||||
verification: SignatureVerification = key.verify(self._msg)
|
||||
for verified, by, sig, subject in verification.good_signatures:
|
||||
if fingerprint(key) == fingerprint(by):
|
||||
return PublishResponse(self._submitter_addr, self._submission_addr, key)
|
||||
raise ValueError('Signature could not be verified')
|
||||
|
||||
|
||||
class PublishResponse:
|
||||
MAIL_TEXT = '''Hi there!
|
||||
|
||||
This is the EasyWKS system at {domain}.
|
||||
|
||||
Your key has been published to the Web Key Directory.
|
||||
You can test WKD key retrieval e.g. with:
|
||||
|
||||
gpg --auto-key-locate=wkd,nodefault --locate-key {uid}
|
||||
|
||||
For more information on WKD and WKS see:
|
||||
|
||||
https://gnupg.org/faq/wkd.html
|
||||
https://gnupg.org/faq/wks.html
|
||||
|
||||
Regards
|
||||
EasyWKS
|
||||
|
||||
--
|
||||
Dance like nobody is watching.
|
||||
Encrypt live everybody is.
|
||||
'''
|
||||
|
||||
def __init__(self, submitter_addr: str, submission_addr: str, key: PGPKey):
|
||||
self._domain = submitter_addr.split('@')[1]
|
||||
self._submitter_addr = submitter_addr
|
||||
self._submission_addr = submission_addr
|
||||
self._key = key
|
||||
|
||||
@property
|
||||
def submitter_address(self):
|
||||
return self._submitter_addr
|
||||
|
||||
@property
|
||||
def submission_address(self):
|
||||
return self._submission_addr
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
return self._key
|
||||
|
||||
@property
|
||||
def domain(self):
|
||||
return self._domain
|
||||
|
||||
def create_signed_message(self):
|
||||
mpplain = MIMEText(ConfirmationRequest.MAIL_TEXT.format(domain=self.domain, uid=self.submitter_address),
|
||||
_subtype='plain')
|
||||
to_encrypt = PGPMessage.new(mpplain.as_string(policy=default))
|
||||
encrypted: PGPMessage = self.key.encrypt(to_encrypt)
|
||||
encrypted |= pgp_sign(self.domain, encrypted)
|
||||
payload = MIMEApplication(str(encrypted), _subtype='octet-stream')
|
||||
mpenc = MIMEApplication(payload.as_string(policy=default), _subtype='pgp-encrypted')
|
||||
email = MIMEMultipart(_subtype='encrypted', _subparts=[mpenc], policy=default,
|
||||
protocol='application/pgp-encrypted')
|
||||
email['Subject'] = 'Your key has been published'
|
||||
email['To'] = self.submitter_address
|
||||
email['From'] = self.submission_address
|
||||
email['Date'] = format_datetime(datetime.utcnow())
|
||||
email['Wks-Draft-Version'] = '3'
|
||||
email['Wks-Phase'] = 'done'
|
||||
email['X-Loop'] = xloop_header(self.domain)
|
||||
email['Auto-Submitted'] = 'auto-replied'
|
||||
return email
|
44
easywks/util.py
Normal file
44
easywks/util.py
Normal file
|
@ -0,0 +1,44 @@
|
|||
|
||||
import hashlib
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from pgpy import PGPKey
|
||||
|
||||
|
||||
def _zrtp_base32(sha1: bytes) -> str:
|
||||
# https://datatracker.ietf.org/doc/html/rfc6189#section-5.1.6
|
||||
alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769'
|
||||
encoded = ''
|
||||
bits = int.from_bytes(sha1, 'big')
|
||||
shift = 155
|
||||
for j in range(32):
|
||||
n = (bits >> shift) & 31
|
||||
encoded += alphabet[n]
|
||||
shift -= 5
|
||||
return encoded
|
||||
|
||||
|
||||
def hash_user_id(uid: str) -> str:
|
||||
if '@' in uid:
|
||||
uid, _ = uid.split('@', 1)
|
||||
# Only ASCII must be lowercased...
|
||||
lower = ''.join(c.lower() if c in string.ascii_uppercase else c for c in uid)
|
||||
digest = hashlib.sha1(lower.encode()).digest()
|
||||
return _zrtp_base32(digest)
|
||||
|
||||
|
||||
def create_nonce(n: int = 32) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
nonce = ''.join(secrets.choice(alphabet) for _ in range(n))
|
||||
return nonce
|
||||
|
||||
|
||||
def fingerprint(key: PGPKey) -> str:
|
||||
return key.fingerprint.upper().replace(' ', '')
|
||||
|
||||
|
||||
def xloop_header(domain: str) -> str:
|
||||
components = list(reversed(domain.split('.')))
|
||||
components.append('easywks')
|
||||
return '.'.join(components)
|
26
setup.py
Normal file
26
setup.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from setuptools import setup, find_packages
|
||||
|
||||
from easywks import __version__
|
||||
|
||||
|
||||
setup(
|
||||
name='easywks',
|
||||
version=__version__,
|
||||
author='s3lph',
|
||||
author_email='account-gitlab-ideynizv@kernelpanic.lol',
|
||||
description='OpenPGP WKS for Human Beings',
|
||||
license='MIT',
|
||||
keywords='pgp,wks',
|
||||
url='https://gitlab.com/s3lph/easywks',
|
||||
packages=find_packages(exclude=['*.test']),
|
||||
install_requires=[
|
||||
'bottle',
|
||||
'PyYAML',
|
||||
'PGPy',
|
||||
],
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'easywks = easywks.main:main'
|
||||
]
|
||||
},
|
||||
)
|
Loading…
Reference in a new issue