249 lines
9 KiB
Python
Executable file
249 lines
9 KiB
Python
Executable file
#!/usr/bin/python3
|
|
|
|
# COPYRIGHT 2024 revsuine <pid1@revsuine.xyz>
|
|
# Copyright 2019 Julian Andres Klode <jak@jak-linux.org>
|
|
#
|
|
# Licensed under the GNU General Public License version 3, which is available
|
|
# at https://www.gnu.org/licenses/gpl-3.0.txt
|
|
|
|
"""
|
|
SOURCE: Based on the following:
|
|
https://github.com/julian-klode/ansible.jak-linux.org/blob/49cd62c6fa2109678c751ae5c2a7e696dd761e8e/roles/mailserver/files/usr/local/lib/dovecot-sieve-filters/gpgmymail
|
|
https://blog.jak-linux.org/2019/06/13/encrypted-email-storage/
|
|
|
|
You may also want to reference the following resources:
|
|
https://www.grepular.com/Automatically_Encrypting_all_Incoming_Email
|
|
https://perot.me/encrypt-specific-incoming-emails-using-dovecot-and-sieve
|
|
|
|
---
|
|
|
|
Encrypt/Decrypt GPG/MIME messages.
|
|
|
|
This tool can encrypt and decrypt emails using PGP/MIME. Decryption only
|
|
works well for emails created with this tool. When encrypting, the tool
|
|
preserves all headers in the original email in the encrypted part, and
|
|
copies relevant headers to the output. When decrypting, any headers are
|
|
ignored, and only the encrypted headers are restored.
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
import email.encoders
|
|
import email.message
|
|
import email.mime.application
|
|
import email.mime.multipart
|
|
import email.mime.message
|
|
import typing
|
|
# for decode_email:
|
|
import quopri
|
|
|
|
# see: https://gnupg.readthedocs.io/en/latest/
|
|
import gnupg
|
|
|
|
# constants
|
|
DEFAULT_ENCODING='utf-8' # default is latin-1 which fails w some unicode chars
|
|
|
|
def is_message_encrypted(message: email.message.Message) -> bool:
|
|
"""Determines whether or not an email message is encrypted.
|
|
|
|
Currently just does it by checking the content type header:
|
|
https://stackoverflow.com/questions/18819126/checking-encryption-status-of-email"""
|
|
|
|
return message.get_content_subtype() == "encrypted"
|
|
|
|
def decode_email(message: email.message.Message) -> email.message.Message:
|
|
"""Turn a quoted-printable or base64 encoded email into a 7or8bit encoded
|
|
email
|
|
|
|
:param message: email.message.Message to be decoded
|
|
:return: decoded email.message.Message"""
|
|
# this is a kinda hacky way to do this by manipulating the message as a
|
|
# string but i couldn't get it to work any other way
|
|
# decoding needed:
|
|
# as_string() gives us str, encode() gives us bytes
|
|
decoded_bytes = message.as_bytes()
|
|
decoded_bytes = quopri.decodestring(decoded_bytes)
|
|
|
|
# replace any instances of the Content-Transfer-Encoding header
|
|
# quopri version, we do base64 version down there
|
|
decoded_bytes = decoded_bytes.replace(
|
|
b'Content-Transfer-Encoding: quoted-printable',
|
|
b'Content-Transfer-Encoding: 7bit'
|
|
)
|
|
|
|
# REALLY hacky but i had issues with the more sensible ways to do this.
|
|
# iterates through a Message object to find CTEs of base64
|
|
# gets the b64 payload and the decoded payload
|
|
# then find and replaces in decoded_bytes the b64 payload
|
|
# with the decoded payload
|
|
# lol
|
|
|
|
def decode_b64_part(
|
|
part: email.message.Message,
|
|
decoded_bytes: bytes,
|
|
most_recent_boundary: str = None
|
|
) -> bytes:
|
|
"""
|
|
change decoded_bytes such that part is decoded if base64 (unchanged if
|
|
not)
|
|
|
|
see usage below for examples
|
|
|
|
:param part: email.message.Message to be decoded
|
|
:param decoded_bytes: the email as a bytes object (ie not a string with
|
|
encoding), will have modified version returned
|
|
:param most_recent_boundary: str of the most recent boundary so we
|
|
don't overwrite this
|
|
:return: bytes of decoded_bytes with part decoded if base64
|
|
"""
|
|
if part.get("Content-Transfer-Encoding") == "base64":
|
|
b64_str = part.get_payload()
|
|
# remove the boundary as we don't want to change this
|
|
if most_recent_boundary:
|
|
b64_str = b64_str.replace(most_recent_boundary, "")
|
|
# sometimes we have leftover hyphens from a boundary, so strip:
|
|
# hyphens not in base64 so we know not to use them
|
|
# strip whitespace first
|
|
b64_str = b64_str.strip()
|
|
b64_str = b64_str.strip('-')
|
|
b64_str = b64_str.encode() # turn into bytes-like object
|
|
# this will also decode the boundary so there'll be some nonsese
|
|
# chars at end of email but it's nbd
|
|
decoded_b64_str = part.get_payload(decode=True)
|
|
return decoded_bytes.replace(
|
|
b64_str,
|
|
decoded_b64_str
|
|
)
|
|
|
|
return decoded_bytes
|
|
|
|
quopri_decoded_message = email.message_from_bytes(decoded_bytes)
|
|
if quopri_decoded_message.is_multipart():
|
|
most_recent_boundary = None
|
|
for part in quopri_decoded_message.walk():
|
|
# multipart and has boundary (not None)
|
|
if part.is_multipart() and part.get_boundary():
|
|
most_recent_boundary = part.get_boundary()
|
|
else:
|
|
decoded_bytes = decode_b64_part(
|
|
part,
|
|
decoded_bytes,
|
|
most_recent_boundary
|
|
)
|
|
else:
|
|
decoded_bytes = decode_b64_part(
|
|
quopri_decoded_message,
|
|
decoded_bytes,
|
|
None
|
|
)
|
|
|
|
decoded_bytes = decoded_bytes.replace(
|
|
b'Content-Transfer-Encoding: base64',
|
|
b'Content-Transfer-Encoding: 7bit'
|
|
)
|
|
|
|
# if i do message_from_bytes it bizarrely changes it back to base64?
|
|
# utf-8 has encoding issues so do latin1
|
|
return email.message_from_string(decoded_bytes.decode("latin1"))
|
|
|
|
def encrypt(
|
|
message: email.message.Message,
|
|
recipients: typing.List[str],
|
|
*,
|
|
unconditionally_encrypt: bool = False,
|
|
encoding: str = DEFAULT_ENCODING
|
|
) -> str:
|
|
"""Encrypt given message
|
|
|
|
:param message: an email.message.Message object to be encrypted
|
|
:param recipients: a List of recipients
|
|
:param unconditionally_encrypt: if True, will encrypt no matter what. If
|
|
False (default), will NOT encrypt if any of the following conditions are
|
|
met:
|
|
- The message is already encrypted
|
|
|
|
:return: The encrypted email as a string"""
|
|
|
|
# exclusion criteria:
|
|
# some mail clients like Thunderbird don't like twice-encrypted emails,
|
|
# so we return the message as-is if it's already encrypted
|
|
if is_message_encrypted(message) and not unconditionally_encrypt:
|
|
return message.as_string()
|
|
|
|
# make necessary changes to message
|
|
message = decode_email(message)
|
|
|
|
gpg = gnupg.GPG()
|
|
gpg.encoding = encoding
|
|
encrypted_content = gpg.encrypt(message.as_string(), recipients, armor=True)
|
|
if not encrypted_content:
|
|
raise ValueError(encrypted_content.status)
|
|
|
|
# Build the parts
|
|
enc = email.mime.application.MIMEApplication(
|
|
_data=str(encrypted_content).encode(),
|
|
_subtype="octet-stream",
|
|
_encoder=email.encoders.encode_7or8bit
|
|
)
|
|
|
|
control = email.mime.application.MIMEApplication(
|
|
_data=b'Version: 1\n',
|
|
_subtype='pgp-encrypted; name="msg.asc"',
|
|
_encoder=email.encoders.encode_7or8bit
|
|
)
|
|
control['Content-Disposition'] = 'inline; filename="msg.asc"'
|
|
|
|
# Put the parts together
|
|
encmsg = email.mime.multipart.MIMEMultipart(
|
|
'encrypted',
|
|
protocol='application/pgp-encrypted'
|
|
)
|
|
encmsg.attach(control)
|
|
encmsg.attach(enc)
|
|
|
|
# Copy headers
|
|
headers_not_to_override = {key.lower() for key in encmsg.keys()}
|
|
|
|
for key, value in message.items():
|
|
if key.lower() not in headers_not_to_override:
|
|
encmsg[key] = value
|
|
|
|
return encmsg.as_string()
|
|
|
|
def decrypt(message: email.message.Message, *, encoding: str = DEFAULT_ENCODING) -> str:
|
|
"""Decrypt the given message"""
|
|
gpg = gnupg.GPG()
|
|
gpg.encoding = encoding
|
|
return str(gpg.decrypt(message.as_string()))
|
|
|
|
def main() -> None:
|
|
"""Program entry"""
|
|
parser = argparse.ArgumentParser(
|
|
description="Encrypt/decrypt mail using GPG/MIME. Takes an email from "
|
|
"stdin and outputs to stdout."
|
|
)
|
|
parser.add_argument('-d', '--decrypt', action="store_true",
|
|
help="Decrypt rather than encrypt")
|
|
parser.add_argument('--encoding', action="store", default=DEFAULT_ENCODING,
|
|
required=False,
|
|
help="Encoding to use for the gnupg.GPG object")
|
|
parser.add_argument('--unconditional', action="store_true",
|
|
help="Encrypt mail unconditionally. By default, mail is not encrypted if it is already encrypted.")
|
|
parser.add_argument('recipient', nargs='*',
|
|
help="Key ID or email of keys to encrypt for")
|
|
args = parser.parse_args()
|
|
msg = email.message_from_file(sys.stdin)
|
|
|
|
if args.decrypt:
|
|
sys.stdout.write(decrypt(msg), encoding=args.encoding)
|
|
else:
|
|
sys.stdout.write(encrypt(
|
|
msg,
|
|
args.recipient,
|
|
unconditionally_encrypt=args.unconditional,
|
|
encoding=args.encoding
|
|
))
|
|
|
|
if __name__ == '__main__':
|
|
main()
|
|
|