zeyple.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8 -*-
  3. import sys
  4. import os
  5. import logging
  6. import email
  7. import email.mime.multipart
  8. import email.mime.application
  9. import email.encoders
  10. import smtplib
  11. import copy
  12. from io import BytesIO
  13. try:
  14. from configparser import SafeConfigParser # Python 3
  15. except ImportError:
  16. from ConfigParser import SafeConfigParser # Python 2
  17. import gpgme
  18. # Boiler plate to avoid dependency on six
  19. # BBB: Python 2.7 support
  20. PY3K = sys.version_info > (3, 0)
  21. def message_from_binary(message):
  22. if PY3K:
  23. return email.message_from_bytes(message)
  24. else:
  25. return email.message_from_string(message)
  26. def as_binary_string(email):
  27. if PY3K:
  28. return email.as_bytes()
  29. else:
  30. return email.as_string()
  31. def encode_string(string):
  32. if isinstance(string, bytes):
  33. return string
  34. else:
  35. return string.encode('utf-8')
  36. __title__ = 'Zeyple'
  37. __version__ = '1.2.0'
  38. __author__ = 'Cédric Félizard'
  39. __license__ = 'AGPLv3+'
  40. __copyright__ = 'Copyright 2012-2016 Cédric Félizard'
  41. class Zeyple:
  42. """Zeyple Encrypts Your Precious Log Emails"""
  43. def __init__(self, config_fname='zeyple.conf'):
  44. self.config = self.load_configuration(config_fname)
  45. log_file = self.config.get('zeyple', 'log_file')
  46. logging.basicConfig(
  47. filename=log_file, level=logging.DEBUG,
  48. format='%(asctime)s %(process)s %(levelname)s %(message)s'
  49. )
  50. logging.info("Zeyple ready to encrypt outgoing emails")
  51. def load_configuration(self, filename):
  52. """Reads and parses the config file"""
  53. config = SafeConfigParser()
  54. config.read([
  55. os.path.join('/etc/', filename),
  56. filename,
  57. ])
  58. if not config.sections():
  59. raise IOError('Cannot open config file.')
  60. return config
  61. @property
  62. def gpg(self):
  63. protocol = gpgme.PROTOCOL_OpenPGP
  64. if self.config.has_option('gpg', 'executable'):
  65. executable = self.config.get('gpg', 'executable')
  66. else:
  67. executable = None # Default value
  68. home_dir = self.config.get('gpg', 'home')
  69. ctx = gpgme.Context()
  70. ctx.set_engine_info(protocol, executable, home_dir)
  71. ctx.armor = True
  72. return ctx
  73. def process_message(self, message_data, recipients):
  74. """Encrypts the message with recipient keys"""
  75. message_data = encode_string(message_data)
  76. in_message = message_from_binary(message_data)
  77. logging.info(
  78. "Processing outgoing message %s", in_message['Message-id'])
  79. if not recipients:
  80. logging.warn("Cannot find any recipients, ignoring")
  81. sent_messages = []
  82. for recipient in recipients:
  83. logging.info("Recipient: %s", recipient)
  84. key_id = self._user_key(recipient)
  85. logging.info("Key ID: %s", key_id)
  86. if key_id:
  87. out_message = self._encrypt_message(in_message, key_id)
  88. # Delete Content-Transfer-Encoding if present to default to
  89. # "7bit" otherwise Thunderbird seems to hang in some cases.
  90. del out_message["Content-Transfer-Encoding"]
  91. else:
  92. logging.warn("No keys found, message will be sent unencrypted")
  93. out_message = copy.copy(in_message)
  94. self._add_zeyple_header(out_message)
  95. self._send_message(out_message, recipient)
  96. sent_messages.append(out_message)
  97. return sent_messages
  98. def _get_version_part(self):
  99. ret = email.mime.application.MIMEApplication(
  100. 'Version: 1\n',
  101. 'pgp-encrypted',
  102. email.encoders.encode_noop,
  103. )
  104. ret.add_header(
  105. 'Content-Description',
  106. "PGP/MIME version identification",
  107. )
  108. return ret
  109. def _get_encrypted_part(self, payload):
  110. ret = email.mime.application.MIMEApplication(
  111. payload,
  112. 'octet-stream',
  113. email.encoders.encode_noop,
  114. name="encrypted.asc",
  115. )
  116. ret.add_header('Content-Description', "OpenPGP encrypted message")
  117. ret.add_header(
  118. 'Content-Disposition',
  119. 'inline',
  120. filename='encrypted.asc',
  121. )
  122. return ret
  123. def _encrypt_message(self, in_message, key_id):
  124. if in_message.is_multipart():
  125. # get the body (after the first \n\n)
  126. payload = in_message.as_string().split("\n\n", 1)[1].strip()
  127. # prepend the Content-Type including the boundary
  128. content_type = "Content-Type: " + in_message["Content-Type"]
  129. payload = content_type + "\n\n" + payload
  130. message = email.message.Message()
  131. message.set_payload(payload)
  132. payload = message.get_payload()
  133. else:
  134. payload = in_message.get_payload()
  135. payload = encode_string(payload)
  136. quoted_printable = email.charset.Charset('ascii')
  137. quoted_printable.body_encoding = email.charset.QP
  138. message = email.mime.nonmultipart.MIMENonMultipart(
  139. 'text', 'plain', charset='utf-8'
  140. )
  141. message.set_payload(payload, charset=quoted_printable)
  142. mixed = email.mime.multipart.MIMEMultipart(
  143. 'mixed',
  144. None,
  145. [message],
  146. )
  147. # remove superfluous header
  148. del mixed['MIME-Version']
  149. payload = as_binary_string(mixed)
  150. encrypted_payload = self._encrypt_payload(payload, [key_id])
  151. version = self._get_version_part()
  152. encrypted = self._get_encrypted_part(encrypted_payload)
  153. out_message = copy.copy(in_message)
  154. out_message.preamble = "This is an OpenPGP/MIME encrypted " \
  155. "message (RFC 4880 and 3156)"
  156. if 'Content-Type' not in out_message:
  157. out_message['Content-Type'] = 'multipart/encrypted'
  158. else:
  159. out_message.replace_header(
  160. 'Content-Type',
  161. 'multipart/encrypted',
  162. )
  163. out_message.set_param('protocol', 'application/pgp-encrypted')
  164. out_message.set_payload([version, encrypted])
  165. return out_message
  166. def _encrypt_payload(self, payload, key_ids):
  167. """Encrypts the payload with the given keys"""
  168. payload = encode_string(payload)
  169. plaintext = BytesIO(payload)
  170. ciphertext = BytesIO()
  171. self.gpg.armor = True
  172. recipient = [self.gpg.get_key(key_id) for key_id in key_ids]
  173. self.gpg.encrypt(recipient, gpgme.ENCRYPT_ALWAYS_TRUST,
  174. plaintext, ciphertext)
  175. return ciphertext.getvalue()
  176. def _user_key(self, email):
  177. """Returns the GPG key for the given email address"""
  178. logging.info("Trying to encrypt for %s", email)
  179. keys = [key for key in self.gpg.keylist(email)]
  180. if keys:
  181. key = keys.pop() # NOTE: looks like keys[0] is the master key
  182. key_id = key.subkeys[0].keyid
  183. return key_id
  184. return None
  185. def _add_zeyple_header(self, message):
  186. if self.config.has_option('zeyple', 'add_header') and \
  187. self.config.getboolean('zeyple', 'add_header'):
  188. message.add_header(
  189. 'X-Zeyple',
  190. "processed by {0} v{1}".format(__title__, __version__)
  191. )
  192. def _send_message(self, message, recipient):
  193. """Sends the given message through the SMTP relay"""
  194. logging.info("Sending message %s", message['Message-id'])
  195. smtp = smtplib.SMTP(self.config.get('relay', 'host'),
  196. self.config.get('relay', 'port'))
  197. smtp.sendmail(message['From'], recipient, message.as_string())
  198. smtp.quit()
  199. logging.info("Message %s sent", message['Message-id'])
  200. if __name__ == '__main__':
  201. recipients = sys.argv[1:]
  202. # BBB: Python 2.7 support
  203. binary_stdin = sys.stdin.buffer if PY3K else sys.stdin
  204. message = binary_stdin.read()
  205. zeyple = Zeyple()
  206. zeyple.process_message(message, recipients)