Skip to content

Commit a69fe98

Browse files
reaperhulkalex
andauthored
backport smime fix (#8390)
* fixes #8298 -- correctly generate content-type header in PKCS#7 SMIME (#8389) * add changelog * better changelog --------- Co-authored-by: Alex Gaynor <[email protected]>
1 parent d6951dc commit a69fe98

File tree

4 files changed

+128
-51
lines changed

4 files changed

+128
-51
lines changed

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Changelog
22
=========
33

4+
.. _v39-0-2:
5+
6+
39.0.2 - 2023-03-02
7+
~~~~~~~~~~~~~~~~~~~
8+
9+
* Fixed a bug where the content type header was not properly encoded for
10+
PKCS7 signatures when using the ``Text`` option and ``SMIME`` encoding.
11+
412
.. _v39-0-1:
513

614
39.0.1 - 2023-02-07

src/cryptography/hazmat/primitives/serialization/pkcs7.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import email.base64mime
66
import email.generator
77
import email.message
8+
import email.policy
89
import io
910
import typing
1011

@@ -177,7 +178,9 @@ def sign(
177178
return rust_pkcs7.sign_and_serialize(self, encoding, options)
178179

179180

180-
def _smime_encode(data: bytes, signature: bytes, micalg: str) -> bytes:
181+
def _smime_encode(
182+
data: bytes, signature: bytes, micalg: str, text_mode: bool
183+
) -> bytes:
181184
# This function works pretty hard to replicate what OpenSSL does
182185
# precisely. For good and for ill.
183186

@@ -192,9 +195,10 @@ def _smime_encode(data: bytes, signature: bytes, micalg: str) -> bytes:
192195

193196
m.preamble = "This is an S/MIME signed message\n"
194197

195-
msg_part = email.message.MIMEPart()
198+
msg_part = OpenSSLMimePart()
196199
msg_part.set_payload(data)
197-
msg_part.add_header("Content-Type", "text/plain")
200+
if text_mode:
201+
msg_part.add_header("Content-Type", "text/plain")
198202
m.attach(msg_part)
199203

200204
sig_part = email.message.MIMEPart()
@@ -213,7 +217,18 @@ def _smime_encode(data: bytes, signature: bytes, micalg: str) -> bytes:
213217

214218
fp = io.BytesIO()
215219
g = email.generator.BytesGenerator(
216-
fp, maxheaderlen=0, mangle_from_=False, policy=m.policy
220+
fp,
221+
maxheaderlen=0,
222+
mangle_from_=False,
223+
policy=m.policy.clone(linesep="\r\n"),
217224
)
218225
g.flatten(m)
219226
return fp.getvalue()
227+
228+
229+
class OpenSSLMimePart(email.message.MIMEPart):
230+
# A MIMEPart subclass that replicates OpenSSL's behavior of not including
231+
# a newline if there are no headers.
232+
def _write_headers(self, generator) -> None:
233+
if list(self.raw_items()):
234+
generator._write_headers(self)

src/rust/src/pkcs7.rs

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,13 @@ fn sign_and_serialize<'p>(
136136
.getattr(crate::intern!(py, "PKCS7Options"))?;
137137

138138
let raw_data = builder.getattr(crate::intern!(py, "_data"))?.extract()?;
139-
let data = if options.contains(pkcs7_options.getattr(crate::intern!(py, "Binary"))?)? {
140-
Cow::Borrowed(raw_data)
141-
} else {
142-
smime_canonicalize(
143-
raw_data,
144-
options.contains(pkcs7_options.getattr(crate::intern!(py, "Text"))?)?,
145-
)
146-
};
139+
let text_mode = options.contains(pkcs7_options.getattr(crate::intern!(py, "Text"))?)?;
140+
let (data_with_header, data_without_header) =
141+
if options.contains(pkcs7_options.getattr(crate::intern!(py, "Binary"))?)? {
142+
(Cow::Borrowed(raw_data), Cow::Borrowed(raw_data))
143+
} else {
144+
smime_canonicalize(raw_data, text_mode)
145+
};
147146

148147
let content_type_bytes = asn1::write_single(&PKCS7_DATA_OID)?;
149148
let signing_time_bytes = asn1::write_single(&x509::certificate::time_from_chrono(
@@ -180,7 +179,7 @@ fn sign_and_serialize<'p>(
180179
{
181180
(
182181
None,
183-
x509::sign::sign_data(py, py_private_key, py_hash_alg, &data)?,
182+
x509::sign::sign_data(py, py_private_key, py_hash_alg, &data_with_header)?,
184183
)
185184
} else {
186185
let mut authenticated_attrs = vec![];
@@ -198,7 +197,8 @@ fn sign_and_serialize<'p>(
198197
])),
199198
});
200199

201-
let digest = asn1::write_single(&x509::ocsp::hash_data(py, py_hash_alg, &data)?)?;
200+
let digest =
201+
asn1::write_single(&x509::ocsp::hash_data(py, py_hash_alg, &data_with_header)?)?;
202202
// Gross hack: copy to PyBytes to extend the lifetime to 'p
203203
let digest_bytes = pyo3::types::PyBytes::new(py, &digest);
204204
authenticated_attrs.push(x509::csr::Attribute {
@@ -264,7 +264,7 @@ fn sign_and_serialize<'p>(
264264
if options.contains(pkcs7_options.getattr(crate::intern!(py, "DetachedSignature"))?)? {
265265
None
266266
} else {
267-
data_tlv_bytes = asn1::write_single(&data.deref())?;
267+
data_tlv_bytes = asn1::write_single(&data_with_header.deref())?;
268268
Some(asn1::parse_single(&data_tlv_bytes).unwrap())
269269
};
270270

@@ -290,7 +290,7 @@ fn sign_and_serialize<'p>(
290290
content_type: PKCS7_SIGNED_DATA_OID,
291291
content: Some(asn1::parse_single(&signed_data_bytes).unwrap()),
292292
};
293-
let content_info_bytes = asn1::write_single(&content_info)?;
293+
let ci_bytes = asn1::write_single(&content_info)?;
294294

295295
let encoding_class = py
296296
.import("cryptography.hazmat.primitives.serialization")?
@@ -302,43 +302,49 @@ fn sign_and_serialize<'p>(
302302
.map(|d| OIDS_TO_MIC_NAME[&d.oid])
303303
.collect::<Vec<_>>()
304304
.join(",");
305-
Ok(py
305+
let smime_encode = py
306306
.import("cryptography.hazmat.primitives.serialization.pkcs7")?
307-
.getattr(crate::intern!(py, "_smime_encode"))?
308-
.call1((
309-
pyo3::types::PyBytes::new(py, &data),
310-
pyo3::types::PyBytes::new(py, &content_info_bytes),
311-
mic_algs,
312-
))?
307+
.getattr(crate::intern!(py, "_smime_encode"))?;
308+
Ok(smime_encode
309+
.call1((&*data_without_header, &*ci_bytes, mic_algs, text_mode))?
313310
.extract()?)
314311
} else {
315312
// Handles the DER, PEM, and error cases
316-
encode_der_data(py, "PKCS7".to_string(), content_info_bytes, encoding)
313+
encode_der_data(py, "PKCS7".to_string(), ci_bytes, encoding)
317314
}
318315
}
319316

320-
fn smime_canonicalize(data: &[u8], text_mode: bool) -> Cow<'_, [u8]> {
321-
let mut new_data = vec![];
317+
fn smime_canonicalize(data: &[u8], text_mode: bool) -> (Cow<'_, [u8]>, Cow<'_, [u8]>) {
318+
let mut new_data_with_header = vec![];
319+
let mut new_data_without_header = vec![];
322320
if text_mode {
323-
new_data.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
321+
new_data_with_header.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
324322
}
325323

326324
let mut last_idx = 0;
327325
for (i, c) in data.iter().copied().enumerate() {
328326
if c == b'\n' && (i == 0 || data[i - 1] != b'\r') {
329-
new_data.extend_from_slice(&data[last_idx..i]);
330-
new_data.push(b'\r');
331-
new_data.push(b'\n');
327+
new_data_with_header.extend_from_slice(&data[last_idx..i]);
328+
new_data_with_header.push(b'\r');
329+
new_data_with_header.push(b'\n');
330+
331+
new_data_without_header.extend_from_slice(&data[last_idx..i]);
332+
new_data_without_header.push(b'\r');
333+
new_data_without_header.push(b'\n');
332334
last_idx = i + 1;
333335
}
334336
}
335337
// If there's stuff in new_data, that means we need to copy the rest of
336338
// data over.
337-
if !new_data.is_empty() {
338-
new_data.extend_from_slice(&data[last_idx..]);
339-
Cow::Owned(new_data)
339+
if !new_data_with_header.is_empty() {
340+
new_data_with_header.extend_from_slice(&data[last_idx..]);
341+
new_data_without_header.extend_from_slice(&data[last_idx..]);
342+
(
343+
Cow::Owned(new_data_with_header),
344+
Cow::Owned(new_data_without_header),
345+
)
340346
} else {
341-
Cow::Borrowed(data)
347+
(Cow::Borrowed(data), Cow::Borrowed(data))
342348
}
343349
}
344350

@@ -359,27 +365,60 @@ mod tests {
359365

360366
#[test]
361367
fn test_smime_canonicalize() {
362-
for (input, text_mode, expected, expected_is_borrowed) in [
368+
for (
369+
input,
370+
text_mode,
371+
expected_with_header,
372+
expected_without_header,
373+
expected_is_borrowed,
374+
) in [
363375
// Values with text_mode=false
364-
(b"" as &[u8], false, b"" as &[u8], true),
365-
(b"\n", false, b"\r\n", false),
366-
(b"abc", false, b"abc", true),
367-
(b"abc\r\ndef\n", false, b"abc\r\ndef\r\n", false),
368-
(b"abc\r\n", false, b"abc\r\n", true),
369-
(b"abc\ndef\n", false, b"abc\r\ndef\r\n", false),
376+
(b"" as &[u8], false, b"" as &[u8], b"" as &[u8], true),
377+
(b"\n", false, b"\r\n", b"\r\n", false),
378+
(b"abc", false, b"abc", b"abc", true),
379+
(
380+
b"abc\r\ndef\n",
381+
false,
382+
b"abc\r\ndef\r\n",
383+
b"abc\r\ndef\r\n",
384+
false,
385+
),
386+
(b"abc\r\n", false, b"abc\r\n", b"abc\r\n", true),
387+
(
388+
b"abc\ndef\n",
389+
false,
390+
b"abc\r\ndef\r\n",
391+
b"abc\r\ndef\r\n",
392+
false,
393+
),
370394
// Values with text_mode=true
371-
(b"", true, b"Content-Type: text/plain\r\n\r\n", false),
372-
(b"abc", true, b"Content-Type: text/plain\r\n\r\nabc", false),
395+
(b"", true, b"Content-Type: text/plain\r\n\r\n", b"", false),
396+
(
397+
b"abc",
398+
true,
399+
b"Content-Type: text/plain\r\n\r\nabc",
400+
b"abc",
401+
false,
402+
),
373403
(
374404
b"abc\n",
375405
true,
376406
b"Content-Type: text/plain\r\n\r\nabc\r\n",
407+
b"abc\r\n",
377408
false,
378409
),
379410
] {
380-
let result = smime_canonicalize(input, text_mode);
381-
assert_eq!(result.deref(), expected);
382-
assert_eq!(matches!(result, Cow::Borrowed(_)), expected_is_borrowed);
411+
let (result_with_header, result_without_header) = smime_canonicalize(input, text_mode);
412+
assert_eq!(result_with_header.deref(), expected_with_header);
413+
assert_eq!(result_without_header.deref(), expected_without_header);
414+
assert_eq!(
415+
matches!(result_with_header, Cow::Borrowed(_)),
416+
expected_is_borrowed
417+
);
418+
assert_eq!(
419+
matches!(result_without_header, Cow::Borrowed(_)),
420+
expected_is_borrowed
421+
);
383422
}
384423
}
385424
}

tests/hazmat/primitives/test_pkcs7.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# for complete details.
44

55

6+
import email.parser
67
import os
78
import typing
89

@@ -289,6 +290,7 @@ def test_smime_sign_detached(self, backend):
289290

290291
sig = builder.sign(serialization.Encoding.SMIME, options)
291292
sig_binary = builder.sign(serialization.Encoding.DER, options)
293+
assert b"text/plain" not in sig
292294
# We don't have a generic ASN.1 parser available to us so we instead
293295
# will assert on specific byte sequences being present based on the
294296
# parameters chosen above.
@@ -298,8 +300,17 @@ def test_smime_sign_detached(self, backend):
298300
# as a separate section before the PKCS7 data. So we should expect to
299301
# have data in sig but not in sig_binary
300302
assert data in sig
303+
# Parse the message to get the signed data, which is the
304+
# first payload in the message
305+
message = email.parser.BytesParser().parsebytes(sig)
306+
signed_data = message.get_payload()[0].get_payload().encode()
301307
_pkcs7_verify(
302-
serialization.Encoding.SMIME, sig, data, [cert], options, backend
308+
serialization.Encoding.SMIME,
309+
sig,
310+
signed_data,
311+
[cert],
312+
options,
313+
backend,
303314
)
304315
assert data not in sig_binary
305316
_pkcs7_verify(
@@ -492,10 +503,14 @@ def test_sign_text(self, backend):
492503
# The text option adds text/plain headers to the S/MIME message
493504
# These headers are only relevant in SMIME mode, not binary, which is
494505
# just the PKCS7 structure itself.
495-
assert b"text/plain" in sig_pem
496-
# When passing the Text option the header is prepended so the actual
497-
# signed data is this.
498-
signed_data = b"Content-Type: text/plain\r\n\r\nhello world"
506+
assert sig_pem.count(b"text/plain") == 1
507+
assert b"Content-Type: text/plain\r\n\r\nhello world\r\n" in sig_pem
508+
# Parse the message to get the signed data, which is the
509+
# first payload in the message
510+
message = email.parser.BytesParser().parsebytes(sig_pem)
511+
signed_data = message.get_payload()[0].as_bytes(
512+
policy=message.policy.clone(linesep="\r\n")
513+
)
499514
_pkcs7_verify(
500515
serialization.Encoding.SMIME,
501516
sig_pem,

0 commit comments

Comments
 (0)