# HG changeset patch # User Paul Boddie # Date 1418738855 -3600 # Node ID fab1e2c9a70104f4a02b08d60eb30b80eafbbdc6 # Parent f07cd72fbecebbfd264954a9ec52f188a3661747 An experiment with signing outgoing messages. diff -r f07cd72fbece -r fab1e2c9a701 GPGUtils.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/GPGUtils.py Tue Dec 16 15:07:35 2014 +0100 @@ -0,0 +1,376 @@ +#!/usr/bin/env python + +""" +GPG utilities derived from the MoinMessage library. + +Copyright (C) 2012, 2013, 2014 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from email.encoders import encode_noop +from email.generator import Generator +from email.mime.multipart import MIMEMultipart +from email.mime.application import MIMEApplication +from email.mime.base import MIMEBase +from subprocess import Popen, PIPE +from tempfile import mkstemp +import os + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +class GPGError(Exception): + pass + +class GPGDecodingError(Exception): + pass + +class GPGMissingPart(GPGDecodingError): + pass + +class GPGBadContent(GPGDecodingError): + pass + +class GPG: + + "A wrapper around the gpg command using a particular configuration." + + def __init__(self, homedir=None): + self.conf_args = [] + + if homedir: + self.conf_args += ["--homedir", homedir] + + self.errors = None + + def run(self, args, text=None): + + """ + Invoke gpg with the given 'args', supplying the given 'text' to the + command directly or, if 'text' is omitted, using a file provided as part + of the 'args' if appropriate. + + Failure to complete the operation will result in a GPGError being + raised. + """ + + cmd = Popen(["gpg"] + self.conf_args + list(args), stdin=PIPE, stdout=PIPE, stderr=PIPE) + + # Attempt to write input to the command and to read output from the + # command. + + text, self.errors = cmd.communicate(text) + + # Test for a zero result. + + if not cmd.returncode: + return text + else: + raise GPGError, self.errors + + def verifyMessageText(self, signature, content): + + "Using the given 'signature', verify the given message 'content'." + + # Write the detached signature and content to files. + + signature_fd, signature_filename = mkstemp() + content_fd, content_filename = mkstemp() + + try: + signature_fp = os.fdopen(signature_fd, "w") + content_fp = os.fdopen(content_fd, "w") + try: + signature_fp.write(signature) + content_fp.write(content) + finally: + signature_fp.close() + content_fp.close() + + # Verify the message text. + + text = self.run(["--status-fd", "1", "--verify", signature_filename, content_filename]) + + # Return the details of the signing key. + + identity = None + fingerprint = None + + for line in text.split("\n"): + try: + prefix, msgtype, digest, details = line.strip().split(" ", 3) + except ValueError: + continue + + # Return the fingerprint and identity details. + + if msgtype == "GOODSIG": + identity = details + elif msgtype == "VALIDSIG": + fingerprint = digest + + if identity and fingerprint: + return fingerprint, identity + + return None + + finally: + os.remove(signature_filename) + os.remove(content_filename) + + def verifyMessage(self, message): + + """ + Verify the given RFC 3156 'message', returning a tuple of the form + (fingerprint, identity, content). + """ + + content, signature = getContentAndSignature(message) + + # Verify the message format. + + if signature.get_content_type() != "application/pgp-signature": + raise GPGBadContent + + # Verify the message. + + fingerprint, identity = self.verifyMessageText(signature.get_payload(decode=True), as_string(content)) + return fingerprint, identity, content + + def signMessage(self, message, keyid): + + """ + Return a signed version of 'message' using the given 'keyid'. + """ + + # Sign the container's representation. + + signature = self.run(["--armor", "-u", keyid, "--detach-sig"], as_string(message)) + + # Make the container for the message. + + signed_message = MIMEMultipart("signed", protocol="application/pgp-signature") + signed_message.attach(message) + + signature_part = MIMEBase("application", "pgp-signature") + signature_part.set_payload(signature) + signed_message.attach(signature_part) + + return signed_message + + def decryptMessageText(self, message): + + "Return a decrypted version of 'message'." + + return self.run(["--decrypt"], message) + + def decryptMessage(self, message): + + """ + Decrypt the given RFC 3156 'message', returning the message text. + """ + + try: + declaration, content = message.get_payload() + except ValueError: + raise GPGMissingPart + + # Verify the message format. + + if content.get_content_type() != "application/octet-stream": + raise GPGBadContent + + # Return the decrypted message text. + + return self.decryptMessageText(content.get_payload(decode=True)) + + def encryptMessage(self, message, keyid): + + """ + Return an encrypted version of 'message' using the given 'keyid'. + """ + + text = as_string(message) + encrypted = self.run(["--armor", "-r", keyid, "--encrypt", "--trust-model", "always"], text) + + # Make the container for the message. + + encrypted_message = MIMEMultipart("encrypted", protocol="application/pgp-encrypted") + + # For encrypted content, add the declaration and content. + + declaration = MIMEBase("application", "pgp-encrypted") + declaration.set_payload("Version: 1") + encrypted_message.attach(declaration) + + content = MIMEApplication(encrypted, "octet-stream", encode_noop) + encrypted_message.attach(content) + + return encrypted_message + + def importKeys(self, text): + + """ + Import the keys provided by the given 'text'. + """ + + self.run(["--import"], text) + + def exportKey(self, keyid): + + """ + Return the "armoured" public key text for 'keyid' as a message part with + a suitable media type. + See: https://tools.ietf.org/html/rfc3156#section-7 + """ + + text = self.run(["--armor", "--export", keyid]) + return MIMEApplication(text, "pgp-keys", encode_noop) + + def listKeys(self, keyid=None): + + """ + Return a list of key details for keys on the keychain, selecting only + one specific key if 'keyid' is specified. + """ + + text = self.run(["--list-keys", "--with-colons", "--with-fingerprint"] + + (keyid and ["0x%s" % keyid] or [])) + return self._getKeysFromResult(text) + + def listSignatures(self, keyid=None): + + """ + Return a list of key and signature details for keys on the keychain, + selecting only one specific key if 'keyid' is specified. + """ + + text = self.run(["--list-sigs", "--with-colons", "--with-fingerprint"] + + (keyid and ["0x%s" % keyid] or [])) + return self._getKeysFromResult(text) + + def getKeysFromMessagePart(self, part): + + """ + Process an application/pgp-keys message 'part', returning a list of + key details. + """ + + return self.getKeysFromString(part.get_payload(decode=True)) + + def getKeysFromString(self, s): + + """ + Return a list of key details extracted from the given key block string + 's'. Signature information is also included through the use of the gpg + verbose option. + """ + + text = self.run(["--with-colons", "--with-fingerprint", "-v"], s) + return self._getKeysFromResult(text) + + def _getKeysFromResult(self, text): + + """ + Return a list of key details extracted from the given command result + 'text'. + """ + + keys = [] + for line in text.split("\n"): + try: + recordtype, trust, keylength, algorithm, keyid, cdate, expdate, serial, ownertrust, _rest = line.split(":", 9) + except ValueError: + continue + + if recordtype == "pub": + userid, _rest = _rest.split(":", 1) + keys.append({ + "type" : recordtype, "trust" : trust, "keylength" : keylength, + "algorithm" : algorithm, "keyid" : keyid, "cdate" : cdate, + "expdate" : expdate, "userid" : userid, "ownertrust" : ownertrust, + "fingerprint" : None, "subkeys" : [], "signatures" : [] + }) + elif recordtype == "sub" and keys: + keys[-1]["subkeys"].append({ + "trust" : trust, "keylength" : keylength, "algorithm" : algorithm, + "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, + "ownertrust" : ownertrust + }) + elif recordtype == "fpr" and keys: + fingerprint, _rest = _rest.split(":", 1) + keys[-1]["fingerprint"] = fingerprint + elif recordtype == "sig" and keys: + userid, _rest = _rest.split(":", 1) + keys[-1]["signatures"].append({ + "keyid" : keyid, "cdate" : cdate, "expdate" : expdate, + "userid" : userid + }) + + return keys + +# Message serialisation functions, working around email module problems. + +def as_string(message): + + """ + Return the string representation of 'message', attempting to preserve the + precise original formatting. + """ + + out = StringIO() + generator = Generator(out, False, 0) # disable reformatting measures + generator.flatten(message) + return out.getvalue() + +# Message decoding functions. + +# Detect PGP/GPG-encoded payloads. +# See: http://tools.ietf.org/html/rfc3156 + +def is_signed(message): + mimetype = message.get_content_type() + encoding = message.get_content_charset() + + return mimetype == "multipart/signed" and \ + message.get_param("protocol") == "application/pgp-signature" + +def is_encrypted(message): + mimetype = message.get_content_type() + encoding = message.get_content_charset() + + return mimetype == "multipart/encrypted" and \ + message.get_param("protocol") == "application/pgp-encrypted" + +def getContentAndSignature(message): + + """ + Return the content and signature parts of the given RFC 3156 'message'. + + NOTE: RFC 3156 states that signed messages should employ a detached + NOTE: signature but then shows "BEGIN PGP MESSAGE" for signatures + NOTE: instead of "BEGIN PGP SIGNATURE". + NOTE: The "micalg" parameter is currently not supported. + """ + + try: + content, signature = message.get_payload() + return content, signature + except ValueError: + raise GPGMissingPart + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r f07cd72fbece -r fab1e2c9a701 conf/postfix/master.cf.items-gpg_sign --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/conf/postfix/master.cf.items-gpg_sign Tue Dec 16 15:07:35 2014 +0100 @@ -0,0 +1,5 @@ +smtp inet n - - - - smtpd + -o content_filter=gpg_sign: +gpg_sign unix - n n - - pipe + flags=FR user=imip-agent argv=/var/lib/imip-agent/gpg_sign.py + calendar@example.com ${original_recipient} diff -r f07cd72fbece -r fab1e2c9a701 gpg_sign.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gpg_sign.py Tue Dec 16 15:07:35 2014 +0100 @@ -0,0 +1,52 @@ +#!/usr/bin/env python + +from GPGUtils import GPG, as_string +from email import message_from_file +from email.mime.message import MIMEMessage +from subprocess import Popen, PIPE +import sys + +# Postfix exit codes. + +EX_TEMPFAIL = 75 + +try: + # Obtain an identity. + + gpg = GPG() + identity = sys.argv[1] + recipients = sys.argv[2:] + original = message_from_file(sys.stdin) + + # Wrap the message in its own container. + + msg = MIMEMessage(original) + + # Sign the message. + + msg = gpg.signMessage(msg, identity) + msg["From"] = identity + msg["To"] = original["To"] + msg["Subject"] = original["Subject"] + + # Submit to Postfix without causing routing loops. + + sendmail = Popen(["/usr/sbin/sendmail", "-G", "-i"] + recipients, shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE) + sendmail.stdin.write(as_string(msg)) + out, err = sendmail.communicate() + retcode = sendmail.wait() + + sys.exit(retcode) + +except Exception, exc: + if "-v" in sys.argv[1:]: + raise + type, value, tb = sys.exc_info() + print >>sys.stderr, "Exception %s at %d" % (exc, tb.tb_lineno) + #import traceback + #traceback.print_exc(file=open("/tmp/mail.log", "a")) + sys.exit(EX_TEMPFAIL) + +sys.exit(0) + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r f07cd72fbece -r fab1e2c9a701 tools/install.sh --- a/tools/install.sh Tue Dec 16 01:04:09 2014 +0100 +++ b/tools/install.sh Tue Dec 16 15:07:35 2014 +0100 @@ -1,7 +1,7 @@ #!/bin/sh -AGENTS="imip_person.py imip_person_outgoing.py imip_resource.py" -MODULES="markup.py imip_store.py vCalendar.py vContent.py vRecurrence.py" +AGENTS="gpg_sign.py imip_person.py imip_person_outgoing.py imip_resource.py" +MODULES="GPGUtils.py markup.py imip_store.py vCalendar.py vContent.py vRecurrence.py" INSTALL_DIR=/var/lib/imip-agent WEB_INSTALL_DIR=/var/www/imip-agent