add a mandatory-pgp-encryption submission port
This commit is contained in:
parent
86ec0f6da7
commit
910b473ea7
|
@ -30,8 +30,10 @@ source setup/functions.sh # load our functions
|
|||
source /etc/mailinabox.conf # load global vars
|
||||
|
||||
# Install packages.
|
||||
# python-libmilter is needed by our encryption milter.
|
||||
|
||||
apt_install postfix postgrey postfix-pcre
|
||||
hide_output pip3 install git+https://github.com/mail-in-a-box/python-libmilter
|
||||
|
||||
# Basic Settings
|
||||
|
||||
|
@ -53,11 +55,20 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||
# c) Add a new cleanup service specific to the submission service ('authclean')
|
||||
# that filters out privacy-sensitive headers on mail being sent out by
|
||||
# authenticated users.
|
||||
# d) Create an alternative one running on port 10587 that requires that all recipients have findable
|
||||
# OpenPGP keys. Encrypts the message for the recipients using a milter on port 882. The milter
|
||||
# precedes the DKIM milter on 8891 so that the message isn't touched after DKIM signing. If the
|
||||
# encryption milter isn't running, reject the message so we dont send anything in the clear.
|
||||
tools/editconf.py /etc/postfix/master.cf -s -w \
|
||||
"submission=inet n - - - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3
|
||||
-o cleanup_service_name=authclean" \
|
||||
"10587=inet n - - - - smtpd
|
||||
-o syslog_name=postfix/submission-encrypted
|
||||
-o smtpd_tls_ciphers=high -o smtpd_tls_protocols=!SSLv2,!SSLv3
|
||||
-o cleanup_service_name=authclean
|
||||
-o smtpd_milters=inet:127.0.0.1:8892,inet:127.0.0.1:8891 -o milter_default_action=reject" \
|
||||
"authclean=unix n - - - 0 cleanup
|
||||
-o header_checks=pcre:/etc/postfix/outgoing_mail_header_filters"
|
||||
|
||||
|
@ -134,7 +145,8 @@ tools/editconf.py /etc/postfix/main.cf \
|
|||
|
||||
ufw_allow smtp
|
||||
ufw_allow submission
|
||||
ufw_allow 10587
|
||||
|
||||
# Restart services
|
||||
|
||||
restart_service postfix
|
||||
restart_service postfix
|
||||
|
|
|
@ -0,0 +1,191 @@
|
|||
#!/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()
|
Loading…
Reference in New Issue