192 lines
5.5 KiB
Python
Executable File
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()
|