gpgmymail/gpgmymail

234 lines
8.4 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:
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
)
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()