mailinabox/tools/encryption-milter.py

192 lines
5.5 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import io
import re
import urllib.request, urllib.error
import tempfile
import shutil
import libmilter
import gnupg
import dns.resolver
from hashlib import sha224
# Start logging to syslog.
from syslog import syslog, openlog, LOG_MAIL
openlog('encryption-milter', facility=LOG_MAIL)
# Replace process title so it looks nicer in top.
try:
import setproctitle
setproctitle.setproctitle("encryption-milter")
except:
pass
# Globals for DNS resolving. See:
# http://tools.ietf.org/html/draft-ietf-dane-openpgpkey-00
# http://tools.ietf.org/html/draft-ietf-dane-openpgpkey-usage-00
# https://github.com/letoams/openpgpkey-milter/
# I have not tested that this works at all.
resolver = dns.resolver.get_default_resolver()
#resolver.nameservers = [server]
openpgp_rtype = 65280 # draft value - changes when RFC
class EncryptionError(Exception):
pass
class EncryptionMilter(libmilter.ForkMixin, libmilter.MilterProtocol):
def __init__(self, opts=0, protos=0):
libmilter.MilterProtocol.__init__(self, opts, protos)
libmilter.ForkMixin.__init__(self)
self.R = [] # list of recipient keys
self.fp = io.BytesIO() # storage for incoming body
def log(self, msg):
print(msg)
syslog('encryption-milter: ' + msg)
def rcpt(self, rcpt_to, cmdDict):
# Turn recipients into keys. If we don't have a key available,
# then reject the message.
try:
self.R.extend(self.get_pgp_keys(rcpt_to))
return libmilter.CONTINUE
except EncryptionError as e:
self.log(str(e))
self.setReply(b'554', b'5.7.1', str(e).encode("utf8"))
return libmilter.REJECT
@libmilter.noReply
def header(self, header, value, cmdDict):
self.fp.write(header)
self.fp.write(b': ')
self.fp.write(value)
self.fp.write(b'\n')
return libmilter.CONTINUE
@libmilter.noReply
def eoh(self, cmdDict):
self.fp.write(b'\n')
return libmilter.CONTINUE
@libmilter.noReply
def body(self, chunk, cmdDict):
self.fp.write(chunk)
return libmilter.CONTINUE
def eob(self, cmdDict):
msg = self.fp.getvalue()
gpgdir = tempfile.mkdtemp()
gpg = gnupg.GPG(gnupghome=gpgdir)
gpg.decode_errors = "ignore"
try:
# Add keys.
for key in self.R:
gpg.import_keys(key)
# Target message encryption to all imported keys.
fingerprints = ','.join(ikey['fingerprint'] for ikey in gpg.list_keys())
# Encrypt message.
enc_msg = gpg.encrypt(msg, fingerprints, always_trust=True)
if enc_msg.data == '':
# gpg binary and pythong wrapper is bad at giving us an error message
raise Exception('Encryption failed for an unknown reason. GPG failed.')
# Rewrite the message.
self.addHeader(b'X-OpenPGPKey', b'Encrypted to key(s): ' + fingerprints.encode("ascii"))
self.chgHeader(b'Subject', b'[pgp encrypted message]')
self.replBody(enc_msg.data)
return libmilter.CONTINUE
except ValueError: #Exception as e:
# Exceptions are thrown on things that would be temporary failures.
# But by now it's too late to tell the user there was a problem?
self.log(str(e))
self.setReply(b'554', b'5.7.1', str(e).encode("utf8"))
return libmilter.REJECT
finally:
shutil.rmtree(gpgdir)
def get_pgp_keys(self, email_addr):
keys = self.import_keys_from_keybase(email_addr)
if keys: return keys
keys = self.import_keys_from_dns(email_addr)
if keys: return keys
raise EncryptionError(email_addr.decode("utf8", "replace") + " does not have a known encryption key.")
def import_keys_from_keybase(self, email_addr):
# Extract the keybase username from the email address.
m = re.search(rb"\+keybase=(.*)@", email_addr)
if not m: return None
keybase_username = m.group(1)
# Query keybase.
try:
req = urllib.request.urlopen("https://keybase.io/%s/key.asc" % keybase_username.decode("ascii", "error"), timeout=20, cadefault=True)
openpgpkey = req.read()
except Exception as e:
if isinstance(e, urllib.error.HTTPError) and e.code == 404:
e = "User not found."
raise EncryptionError("Error getting public key for %s at Keybase.io: %s" % (keybase_username.decode("utf8", "replace"), str(e)))
# Return the key.
self.log("got keybase.io key for %s" % keybase_username.decode("utf8", "replace"))
return [openpgpkey]
def import_keys_from_dns(self, email_addr):
(username, domainname) = email_addr.split(b'@')
qname = '%s._openpgpkey.%s' % (sha224(username).hexdigest(), domainname)
try:
response = dns.resolver.query(qname, openpgp_rtype)
except dns.resolver.NoNameservers:
# could not connect to nameserver
raise EncryptionError("Could not connect to nameserver.")
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# host did not have an answer for this query; not sure what the
# difference is between the two exceptions
return None
if len(result) == 0:
# empty answer? probably not possible...
return None
# Return all keys found in DNS.
return [str(value) for value in result]
def runMilter():
# Adapted from the python-libmilter example at
# https://github.com/crustymonkey/python-libmilter/blob/master/examples/testmilter.py
import signal, traceback
# Create the milter. Use the ForkFactor to handle each mail in a separate process.
f = libmilter.ForkFactory('inet:127.0.0.1:8892', EncryptionMilter,
libmilter.SMFIF_ADDHDRS | libmilter.SMFIF_CHGHDRS | libmilter.SMFIF_CHGBODY)
# Add a signal handler to cleanly exit.
def sigHandler(num, frame):
f.close()
sys.exit(0)
signal.signal(signal.SIGINT, sigHandler)
# Start the milter.
try:
f.run()
except Exception as e:
f.close()
raise
if __name__ == '__main__':
runMilter()