Skip to content

bpo-39131 email: add easier support for generating multipart/signed messages #17695

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions Doc/library/email.mime.rst
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,60 @@ Here are the classes:
.. versionchanged:: 3.6
Added *policy* keyword-only parameter.

.. currentmodule:: email.mime.signed

.. class:: MIMEMultipartSigned(_subtype='signed', boundary=None, \
_subparts=None, *, policy=compat32, \
sign_fun=None, **_params)

Module: :mod:`email.mime.signed`

A subclass of :class:`~email.mime.base.MIMEMultipart`, this class
can be used for constructing multipart/signed messages conforming to
:RFC:`1847`, and its more specific sucessors :RFC:`3156` (OpenPGP)
and :RFC:`8551` (S/MIME).

The main difference with a regular MIMEMultipart instance is that
the constructor for this class accepts a *sign_fun* argument, which
should be a callable that does the actual signing after the message
components have been serialized, but before the complete message is
rendered.

This callable gets called with two arguments: the
MIMEMultipartSigned object, and a list of serialized
messages. Depending on the chosen generator, these will be `string`
or `bytes`. Note that the caller should take care of setting all
the MIME parameters that are defined in the relevant RFCs.

The remainder of the parameters are passed to
:class:`~email.mime.base.MIMEMultipart` untouched.

Example usage::

from email.mime.signed import MIMEMultipartSigned
from email.mime.text import MIMEText
from email.mime.application import MIMEApplication
from email.encoders import encode_7or8bit
from email.policy import SMTP
import hashlib

def bogus_signer(msg, msgtexts):
if len(msgtexts) != 2:
raise ValueError("msgtexts should contain 2 items, got %d", len(msgtexts))
if not isinstance(msgtexts[0], bytes):
log.warning("sign_fun probably called from msg.as_string")
return
digest = hashlib.sha256(msgtexts[0]).hexdigest().encode('ascii')
msgtexts[1] = msgtexts[1].replace(b'DIGEST_PLACEHOLDER', digest)

main = MIMEMultipartSigned(sign_fun=bogus_signer, policy=SMTP, protocol="bogus")
main.attach(MIMEText('Hello audience. Please sign below the fold.\n---\u2702---'))
main.attach(MIMEApplication("Bogus hash: DIGEST_PLACEHOLDER",
"bogus-signature",
encode_7or8bit))
print(main.as_bytes())


.. currentmodule:: email.mime.application

.. class:: MIMEApplication(_data, _subtype='octet-stream', \
Expand Down
7 changes: 5 additions & 2 deletions Lib/email/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ def _handle_text(self, msg):
# Default body handler
_writeBody = _handle_text

def _handle_multipart(self, msg):
def _handle_multipart(self, msg, _sign_fun=None):
# The trick here is to write out each part separately, merge them all
# together, and then make sure that the boundary we've chosen isn't
# present in the payload.
Expand All @@ -271,6 +271,8 @@ def _handle_multipart(self, msg):
g = self.clone(s)
g.flatten(part, unixfrom=False, linesep=self._NL)
msgtexts.append(s.getvalue())
if _sign_fun:
_sign_fun(msg, msgtexts)
# BAW: What about boundaries that are wrapped in double-quotes?
boundary = msg.get_boundary()
if not boundary:
Expand Down Expand Up @@ -315,8 +317,9 @@ def _handle_multipart_signed(self, msg):
# RDM: This isn't enough to completely preserve the part, but it helps.
p = self.policy
self.policy = p.clone(max_line_length=0)
sign_fun = getattr(msg, 'sign_fun', None)
try:
self._handle_multipart(msg)
self._handle_multipart(msg, _sign_fun=sign_fun)
finally:
self.policy = p

Expand Down
26 changes: 26 additions & 0 deletions Lib/email/mime/signed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
__all__ = ['MIMEMultipartSigned']

from email.mime.multipart import MIMEMultipart


class MIMEMultipartSigned(MIMEMultipart):
"""Base class for MIME multipart/signed type messages."""

def __init__(self, _subtype='signed', boundary=None, _subparts=None,
*, policy=None, sign_fun=None,
**_params):
"""Creates a multipart/signed message.

This is very similar to the MIMEMultipart class, except that it
accepts a callable sign_fun that can be used to sign the payload of
the message.

This callable will be called with the multipart container object and
a list of flattened parts after these parts have been flattened,
allowing for single pass signing of messages.
"""
MIMEMultipart.__init__(self, _subtype, boundary=boundary,
_subparts=_subparts, policy=policy,
**_params)
if sign_fun:
self.sign_fun = sign_fun
63 changes: 63 additions & 0 deletions Lib/test/test_email/test_multipart_signed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import hashlib
import unittest

from email.encoders import encode_7or8bit
from email.mime.application import MIMEApplication
from email.mime.signed import MIMEMultipartSigned
from email.mime.text import MIMEText
from email.policy import SMTP


def bogus_signer(msg, msgtexts):
if len(msgtexts) != 2:
raise ValueError("msgtexts should contain 2 items, got %d", len(msgtexts))
if not isinstance(msgtexts[0], bytes):
log.warning("sign_fun probably called from msg.as_string")
return
digest = hashlib.sha256(msgtexts[0]).hexdigest().encode('ascii')
msgtexts[1] = msgtexts[1].replace(b'DIGEST_PLACEHOLDER', digest)


class TestMultipartSigned(unittest.TestCase):
def test_happy_flow(self):
main = MIMEMultipartSigned(sign_fun=bogus_signer, policy=SMTP, protocol="bogus", boundary="ZYXXYZ")
main.attach(MIMEText("Hello audience. Please sign below the fold.\n---\u2702---"))
signature_placeholder = "Bogus hash: DIGEST_PLACEHOLDER"
main.attach(MIMEApplication(signature_placeholder, "bogus-signature", encode_7or8bit))
serialized = main.as_bytes()
expected_serialized = (b'Content-Type: multipart/signed; protocol="bogus"; boundary="ZYXXYZ"\r\n'
b'MIME-Version: 1.0\r\n\r\n--ZYXXYZ\r\nContent-Type: text/plain; charset="utf-8"\r\n'
b'MIME-Version: 1.0\r\nContent-Transfer-Encoding: base64\r\n\r\n'
b'SGVsbG8gYXVkaWVuY2UuIFBsZWFzZSBzaWduIGJlbG93IHRoZSBmb2xkLgotLS3inIItLS0=\r\n'
b'\r\n--ZYXXYZ\r\nContent-Type: application/bogus-signature\r\nMIME-Version: 1.0\r\n'
b'Content-Transfer-Encoding: 7bit\r\n'
b'\r\nBogus hash: d93f584571f90aa80a49ab83a9b1b64cee8e8c70f147f0b2a8eef8fa6efbf435\r\n'
b'--ZYXXYZ--\r\n')
self.assertEqual(serialized, expected_serialized)

# check that the original message object did not get modified
new_signature_placeholder = main.get_payload()[1].get_payload()
self.assertEqual(new_signature_placeholder, signature_placeholder)

def test_no_sign_fun(self):
main = MIMEMultipartSigned(policy=SMTP, protocol="bogus", boundary="ZYXXYZ")
main.attach(MIMEText("Hello audience. Please sign below the fold.\n---\u2702---"))
main.attach(MIMEApplication("Bogus hash: DIGEST_PLACEHOLDER",
"bogus-signature",
encode_7or8bit))
serialized = main.as_bytes()
expected_serialized = (b'Content-Type: multipart/signed; protocol="bogus"; boundary="ZYXXYZ"\r\n'
b'MIME-Version: 1.0\r\n\r\n--ZYXXYZ\r\nContent-Type: text/plain; charset="utf-8"\r\n'
b'MIME-Version: 1.0\r\nContent-Transfer-Encoding: base64\r\n\r\n'
b'SGVsbG8gYXVkaWVuY2UuIFBsZWFzZSBzaWduIGJlbG93IHRoZSBmb2xkLgotLS3inIItLS0=\r\n\r\n'
b'--ZYXXYZ\r\nContent-Type: application/bogus-signature\r\nMIME-Version: 1.0\r\n'
b'Content-Transfer-Encoding: 7bit\r\n\r\n'
b'Bogus hash: DIGEST_PLACEHOLDER\r\n--ZYXXYZ--\r\n')
self.assertEqual(serialized, expected_serialized)

def test_no_payload_is_ok(self):
main = MIMEMultipartSigned(policy=SMTP, protocol="bogus", boundary="ZYXXYZ")
serialized = main.as_bytes()
expected_serialized = (b'Content-Type: multipart/signed; protocol="bogus"; boundary="ZYXXYZ"\r\n'
b'MIME-Version: 1.0\r\n\r\n--ZYXXYZ\r\n\r\n--ZYXXYZ--\r\n')
self.assertEqual(serialized, expected_serialized)