202 lines
7.1 KiB
Python
Executable file
202 lines
7.1 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_string().encode()
|
|
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
|
|
quopri_decoded_message = email.message_from_bytes(decoded_bytes)
|
|
if quopri_decoded_message.is_multipart():
|
|
for part in quopri_decoded_message.walk():
|
|
if not part.is_multipart():
|
|
if part.get("Content-Transfer-Encoding") == "base64":
|
|
b64_str = part.get_payload().encode()
|
|
decoded_b64_str = part.get_payload(decode=True).encode()
|
|
decoded_bytes = decoded_bytes.replace(
|
|
b64_str,
|
|
decoded_b64_str
|
|
)
|
|
else:
|
|
# TODO
|
|
pass
|
|
|
|
decoded_bytes = decoded_bytes.replace(
|
|
b'Content-Transfer-Encoding: base64',
|
|
b'Content-Transfer-Encoding: 7bit'
|
|
)
|
|
|
|
return email.message_from_bytes(decoded_bytes)
|
|
|
|
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()
|
|
|