Add LMTP server

This commit is contained in:
s3lph 2021-09-26 17:11:05 +02:00
parent 6520ba29c5
commit 254cc4b1f4
8 changed files with 153 additions and 23 deletions

View file

@ -90,7 +90,7 @@ mailing_method: smtp
# Configure smtp client options
smtp:
# Connect to this SMTP server to send a mail.
hostname: localhost
host: localhost
port: 25
# if tls=True, starttls is ignored
tls: false
@ -98,13 +98,17 @@ smtp:
# Omit username/password if authentication is not needed.
username: webkey
password: SuperS3curePassword123
# Configure the LMTP server
lmtp:
host: "::1"
port: 8024
# 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
submission_address: webkey@example.com
# 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:
@ -135,7 +139,7 @@ There are generally two ways to get WKD working:
```unit file (systemd)
[Unit]
Description=OpenPGP WKS for Human Beings
Description=OpenPGP WKS for Human Beings - HTTP Server
[Service]
Type=simple
@ -162,6 +166,10 @@ Note that the webserver built into EasyWKS validates that the `?l=<uid>` matches
#### Postfix
You can either connect EasyWKS using a pipe transport or as a LMTP server.
##### Pipe Transport
Add an entry in `/etc/postfix/master.cf`:
```
@ -169,8 +177,45 @@ webkey unix - n n - - pipe
flags=DRhu user=webkey argv=/path/to/easywks process
```
Then tell postfix to forward mails to the submission addresses to said transport, e.g. in `/etc/postfix/transport`:
```
# The colon at the end is important, it lets postfix know
# that "webkey" is a transport, not a name
gpgwks@example.org webkey:
webkey@example.com webkey:
```
Configure EasyWKS to send outgoing mails via SMTP.
##### LMTP Server
Configure EasyWKS to run the LMTP server, e.g. using the following systemd unit:
```unit file (systemd)
[Unit]
Description=OpenPGP WKS for Human Beings - LMTP Server
[Service]
Type=simple
ExecStart=/path/to/easywks lmtpd
Restart=on-failure
User=webkey
Group=webkey
WorkingDirectory=/var/lib/easywks
[Install]
WantedBy=multi-user.target
```
Also configure EasyWKS to send outgoing mails via SMTP.
Then tell postfix to forward mails to the submission addresses to said transport, e.g. in `/etc/postfix/transport`:
```
gpgwks@example.org lmtp:localhost:10024
webkey@example.com lmtp:localhost:10024
```
[wkd]: https://wiki.gnupg.org/WKD
[wks]: https://wiki.gnupg.org/WKS

View file

@ -1,5 +1,4 @@
# EasyWKS Roadmap
- [ ] LMTP server mode, e.g. using the `smtpd` module.
- [ ] Figure out whether file locking in the working directory is necessary to avoid races.
- [ ] Testing, testing, testing!

View file

@ -36,6 +36,15 @@ def _validate_smtp_config(value):
value['password'] = None
def _validate_lmtpd_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):
@ -128,4 +137,8 @@ Config = _GlobalConfig(
'tls': False,
'starttls': False
}, validator=_validate_smtp_config),
lmtpd=_ConfigOption('lmtpd', dict, {
'host': 'localhost',
'port': 25,
}, validator=_validate_lmtpd_config),
)

54
easywks/lmtpd.py Normal file
View file

@ -0,0 +1,54 @@
import asyncio
import traceback
from aiosmtpd.controller import Controller
from aiosmtpd.lmtp import LMTP
from . import __version__ as version
from .config import Config
from .process import process_mail
from .types import EasyWksError
class LmtpMailServer:
async def handle_MAIL(self, server, session, envelope, address, mail_options):
_, domain = address.split('@', 1)
if domain not in Config.domains:
return '550 Not accepting mails from this domain'
envelope.mail_from = address
return '250 OK'
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
for domain in Config.domains:
if Config[domain].submission_address == address:
envelope.rcpt_tos.append(address)
return '250 OK'
return '550 Not responsible for this address'
async def handle_DATA(self, server, session, envelope):
message = envelope.content
try:
process_mail(message)
except EasyWksError as e:
return f'550 {e}'
except BaseException:
tb = traceback.format_exc()
return f'550 Error during message processing: {tb}'
return '250 Message successfully handled'
class LmtpdController(Controller):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def factory(self):
return LMTP(self.handler, ident=f'EasyWKS {version}', **self.SMTP_kwargs)
def run_lmtpd():
controller = LmtpdController(LmtpMailServer(), Config.lmtpd['host'], Config.lmtpd['port'])
controller.start()
asyncio.get_event_loop().run_forever()

View file

@ -3,6 +3,7 @@ from .config import Config
from .files import init_working_directory, clean_stale_requests
from .process import process_mail
from .server import run_server
from .lmtpd import run_lmtpd
import sys
@ -14,7 +15,7 @@ def parse_arguments():
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'
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)
@ -22,12 +23,15 @@ def parse_arguments():
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.')
'Hook this up to your MTA. Also see lmtpd.')
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)
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)
return ap.parse_args(sys.argv[1:])

View file

@ -6,8 +6,7 @@ from .mailing import get_mailing_method
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 .types import SubmissionRequest, ConfirmationResponse, PublishResponse, ConfirmationRequest, EasyWksError
from email.message import MIMEPart, Message
from email.parser import BytesParser
@ -39,10 +38,10 @@ def _get_pgp_message(parts: List[MIMEPart]) -> PGPMessage:
continue
if p.is_encrypted:
if pgp is not None:
raise ValueError('More than one encrypted message part')
raise EasyWksError('More than one encrypted message part')
pgp = p
if pgp is None:
raise ValueError('No encrypted message part')
raise EasyWksError('No encrypted message part')
return pgp
@ -53,12 +52,12 @@ def _get_pgp_publickey(parts: List[MIMEPart]) -> PGPKey:
key, _ = PGPKey.from_blob(part.get_content())
if key.is_public:
if pubkey:
raise ValueError('More than one key in message')
raise EasyWksError('More than one PGP public key in message')
pubkey = key
except PGPError:
pass
if not pubkey:
raise ValueError('No pubkey in message')
raise EasyWksError('No PGP public key pubkey in message')
return pubkey
@ -68,7 +67,7 @@ def _parse_submission_request(pgp: PGPMessage, submission: str, sender: str):
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}')
raise EasyWksError(f'Key has no UID that matches {sender}')
return SubmissionRequest(sender, submission, pubkey)
@ -92,7 +91,7 @@ def _find_confirmation_response(parts: List[MIMEPart]) -> str:
response = c
continue
if not response:
raise ValueError('No confirmation response found in message')
raise EasyWksError('No confirmation response found in message')
return response
@ -106,10 +105,12 @@ def _parse_confirmation_response(pgp: PGPMessage, submission: str, sender: str):
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.get('type', '') != 'confirmation-response':
raise EasyWksError('Invalid confirmation response: "type" missing or not "confirmation-response"')
if 'sender' not in rdict or 'nonce' not in rdict:
raise EasyWksError('Invalid confirmation response: "sender" or "nonce" missing from confirmation response')
if rdict['sender'] != sender:
raise ValueError('Confirmation sender does not match message sender')
raise EasyWksError('Confirmation sender does not match message sender')
return ConfirmationResponse(rdict['sender'], submission, rdict['nonce'], pgp)
@ -118,19 +119,22 @@ def process_mail(mail: bytes = None):
mail = sys.stdin.read().encode()
msg: Message = BytesParser(policy=default).parsebytes(mail)
_, sender_mail = getaddresses([msg['from']])[0]
local, sender_domain = sender_mail.split('@')
try:
local, sender_domain = sender_mail.split('@', 1)
except ValueError:
raise EasyWksError('Sender mail is not a valid mail address')
if sender_domain not in Config.domains:
raise KeyError(f'Domain {sender_domain} not supported')
raise EasyWksError(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')
raise EasyWksError('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}')
raise EasyWksError(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)

View file

@ -157,12 +157,12 @@ class ConfirmationResponse:
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')
raise EasyWksError(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')
raise EasyWksError('PGP Signature could not be verified')
class PublishResponse:
@ -229,3 +229,13 @@ Encrypt live everybody is.
email['X-Loop'] = xloop_header(self.domain)
email['Auto-Submitted'] = 'auto-replied'
return email
class EasyWksError(BaseException):
def __init__(self, msg):
super().__init__()
self._msg = msg
def __str__(self) -> str:
return self._msg

View file

@ -14,6 +14,7 @@ setup(
url='https://gitlab.com/s3lph/easywks',
packages=find_packages(exclude=['*.test']),
install_requires=[
'aiosmtpd',
'bottle',
'PyYAML',
'PGPy',