Skip to content
4 changes: 2 additions & 2 deletions Lib/email/_policybase.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from email import header
from email import charset as _charset
from email.utils import _has_surrogates
from email.header import _HeaderValueWithRaw

__all__ = [
'Policy',
Expand Down Expand Up @@ -316,8 +317,7 @@ def header_source_parse(self, sourcelines):

"""
name, value = sourcelines[0].split(':', 1)
value = ''.join((value, *sourcelines[1:])).lstrip(' \t\r\n')
return (name, value.rstrip('\r\n'))
return (name, _HeaderValueWithRaw(''.join((value, *sourcelines[1:]))))

def header_store_parse(self, name, value):
"""+
Expand Down
12 changes: 8 additions & 4 deletions Lib/email/feedparser.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from email._policybase import compat32
from collections import deque
from io import StringIO
from itertools import repeat, chain

NLCRE = re.compile(r'\r\n|\r|\n')
NLCRE_bol = re.compile(r'(\r\n|\r|\n)')
Expand Down Expand Up @@ -215,9 +216,10 @@ def _pop_message(self):
self._cur = None
return retval

def _parsegen(self):
def _parsegen(self, is_signed_data: bool = False):
# Create a new message and start by parsing headers.
self._new_message()
self._cur.set_is_signed_data(is_signed_data)
headers = []
# Collect the headers, searching for a line that doesn't match the RFC
# 2822 header or continuation pattern (including an empty line).
Expand Down Expand Up @@ -261,7 +263,7 @@ def _parsegen(self):
# nested messages. A blank line separates the subparts.
while True:
self._input.push_eof_matcher(NLCRE.match)
for retval in self._parsegen():
for retval in self._parsegen(is_signed_data):
if retval is NeedMoreData:
yield NeedMoreData
continue
Expand Down Expand Up @@ -295,14 +297,15 @@ def _parsegen(self):
if self._cur.get_content_maintype() == 'message':
# The message claims to be a message/* type, then what follows is
# another RFC 5322 message.
for retval in self._parsegen():
for retval in self._parsegen(is_signed_data):
if retval is NeedMoreData:
yield NeedMoreData
continue
break
self._pop_message()
return
if self._cur.get_content_maintype() == 'multipart':
is_subpart_signed_generator = chain([True], repeat(is_signed_data)) if self._cur.get_content_subtype() == "signed" else repeat(is_signed_data)
boundary = self._cur.get_boundary()
if boundary is None:
# The message /claims/ to be a multipart but it has not
Expand Down Expand Up @@ -383,7 +386,8 @@ def boundarymatch(line):
# Recurse to parse this subpart; the input stream points
# at the subpart's first line.
self._input.push_eof_matcher(boundarymatch)
for retval in self._parsegen():
# Subpart is signed data if it is descendant of 'multipart/signed' message.
for retval in self._parsegen(next(is_subpart_signed_generator)):
if retval is NeedMoreData:
yield NeedMoreData
continue
Expand Down
26 changes: 13 additions & 13 deletions Lib/email/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from io import StringIO, BytesIO
from email.utils import _has_surrogates
from email.errors import HeaderWriteError
from email.header import _HeaderValueWithRaw

UNDERSCORE = '_'
NL = '\n' # XXX: no longer used by the code below.
Expand Down Expand Up @@ -225,7 +226,12 @@ def _dispatch(self, msg):

def _write_headers(self, msg):
for h, v in msg.raw_items():
folded = self.policy.fold(h, v)
# For messages that are signed, the message headers are reconstructed
# from the raw form to conform to the opaque requirement from RFC3156 3.
if msg.is_signed_data() and isinstance(v, _HeaderValueWithRaw):
folded = f"{h}:{v._raw_value}"
else:
folded = self.policy.fold(h, v)
if self.policy.verify_generated_headers:
linesep = self.policy.linesep
if not folded.endswith(linesep):
Expand Down Expand Up @@ -324,17 +330,6 @@ def _handle_multipart(self, msg):
epilogue = msg.epilogue
self._write_lines(epilogue)

def _handle_multipart_signed(self, msg):
# The contents of signed parts has to stay unmodified in order to keep
# the signature intact per RFC1847 2.1, so we disable header wrapping.
# RDM: This isn't enough to completely preserve the part, but it helps.
p = self.policy
self.policy = p.clone(max_line_length=0)
try:
self._handle_multipart(msg)
finally:
self.policy = p

def _handle_message_delivery_status(self, msg):
# We can't just write the headers directly to self's file object
# because this will leave an extra newline between the last header
Expand Down Expand Up @@ -430,7 +425,12 @@ def _write_headers(self, msg):
# This is almost the same as the string version, except for handling
# strings with 8bit bytes.
for h, v in msg.raw_items():
folded = self.policy.fold_binary(h, v)
# For messages that are signed, the message headers are reconstructed
# from the raw form to conform to the opaque requirement from RFC3156 3.
if msg.is_signed_data() and isinstance(v, _HeaderValueWithRaw):
folded = self._encode(f"{h}:{v._raw_value}")
else:
folded = self.policy.fold_binary(h, v)
if self.policy.verify_generated_headers:
linesep = self.policy.linesep.encode()
if not folded.endswith(linesep):
Expand Down
9 changes: 9 additions & 0 deletions Lib/email/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,3 +580,12 @@ def is_onlyws(self):

def part_count(self):
return super().__len__()

class _HeaderValueWithRaw(str):
_raw_value: str

def __new__(cls, value):
useful_value = value.lstrip(' \t\r\n').rstrip('\r\n')
instance = super().__new__(cls, useful_value)
instance._raw_value = value
return instance
7 changes: 7 additions & 0 deletions Lib/email/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ def __init__(self, policy=compat32):
self.defects = []
# Default content type
self._default_type = 'text/plain'
self._is_signed_data = False

def __str__(self):
"""Return the entire formatted message as a string.
Expand Down Expand Up @@ -534,6 +535,12 @@ def raw_items(self):
"""
return iter(self._headers.copy())

def set_is_signed_data(self, is_signed_data):
self._is_signed_data = is_signed_data

def is_signed_data(self):
return self._is_signed_data

#
# Additional useful stuff
#
Expand Down
4 changes: 2 additions & 2 deletions Lib/email/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
validate_header_name
)
from email.utils import _has_surrogates
from email.header import _HeaderValueWithRaw
from email.headerregistry import HeaderRegistry as HeaderRegistry
from email.contentmanager import raw_data_manager
from email.message import EmailMessage
Expand Down Expand Up @@ -131,8 +132,7 @@ def header_source_parse(self, sourcelines):

"""
name, value = sourcelines[0].split(':', 1)
value = ''.join((value, *sourcelines[1:])).lstrip(' \t\r\n')
return (name, value.rstrip('\r\n'))
return (name, _HeaderValueWithRaw(''.join((value, *sourcelines[1:]))))

def header_store_parse(self, name, value):
"""+
Expand Down
31 changes: 31 additions & 0 deletions Lib/test/test_email/data/msg_48.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
From: <foo@bar.baz>
To: <baz@bar.foo>
Subject: test
In-Reply-To: <somelonglonglonglonglongunwrappablemessageidtoreplyto@mail.foo.bar>
MIME-Version: 1.0
Content-Type: multipart/signed; boundary="borderline";
protocol="application/pgp-signature"; micalg=pgp-sha1

This is an OpenPGP/MIME signed message (RFC 2440 and 3156)
--borderline
Content-Type: text/plain
In-Reply-To:
<someotherlonglonglonglongunwrappablemessageidtoreplyto@mail.foo.bar>
MIME-Version: 1.0

This is the signed contents.

--borderline
Content-Type: application/pgp-signature; name="signature.asc"
Content-Description: OpenPGP digital signature
Content-Disposition: attachment; filename="signature.asc"

-----BEGIN PGP SIGNATURE-----
Version: GnuPG v2.0.6 (GNU/Linux)

iD8DBQFG03voRhp6o4m9dFsRApSZAKCCAN3IkJlVRg6NvAiMHlvvIuMGPQCeLZtj
FGwfnRHFBFO/S4/DKysm0lI=
=t7+s
-----END PGP SIGNATURE-----

--borderline--
28 changes: 28 additions & 0 deletions Lib/test/test_email/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -2953,6 +2953,10 @@ def test_message_signed_idempotent(self):
msg, text = self._msgobj('msg_45.txt')
self._idempotent(msg, text)

def test_prewrapped_header_message_signed_idempotent(self):
msg, text = self._msgobj('msg_48.txt')
self._idempotent(msg, text)

def test_content_type(self):
eq = self.assertEqual
# Get a message object and reset the seek pointer for other tests
Expand Down Expand Up @@ -5977,6 +5981,30 @@ def test_long_headers_flatten(self):
result = fp.getvalue()
self._signed_parts_eq(original, result)

def test_long_prewrapped_headers_as_string(self):
original, msg = self._msg_and_obj('msg_48.txt')
result = msg.as_string()
self._signed_parts_eq(original, result)

def test_long_prewrapped_headers_as_string_maxheaderlen(self):
original, msg = self._msg_and_obj('msg_48.txt')
result = msg.as_string(maxheaderlen=60)
self._signed_parts_eq(original, result)

def test_long_prewrapped_headers_flatten(self):
original, msg = self._msg_and_obj('msg_48.txt')
fp = StringIO()
Generator(fp).flatten(msg)
result = fp.getvalue()
self._signed_parts_eq(original, result)

def test_if_parts_are_correctly_marked_as_signed_data(self):
msg = self._msgobj('msg_48.txt')
self.assertFalse(msg.is_signed_data())
self.assertTrue(msg.get_payload(0).is_signed_data())
self.assertFalse(msg.get_payload(1).is_signed_data())


class TestHeaderRegistry(TestEmailBase):
# See issue gh-93010.
def test_HeaderRegistry(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Introduced structure for keeping raw header with whitespaces, to properly recreate headers for payloads that are PGP signed.
Raw header value overrides the default header folding of :meth:`email.policy.EmailPolicy.fold` and :meth:`email.policy.Compat32.fold`
in :meth:`email.generator.Generator.flatten` and bytes equivalents, preserving message and allowing for successfull signature validation.
Contributed by Michał Karol.
Loading