Skip to content

Commit bc61b60

Browse files
authored
fixes #8298 -- correctly generate content-type header in PKCS#7 SMIME (#8389)
1 parent 008e69d commit bc61b60

3 files changed

Lines changed: 120 additions & 51 deletions

File tree

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

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

178179

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

@@ -191,9 +194,10 @@ def _smime_encode(data: bytes, signature: bytes, micalg: str) -> bytes:
191194

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

194-
msg_part = email.message.MIMEPart()
197+
msg_part = OpenSSLMimePart()
195198
msg_part.set_payload(data)
196-
msg_part.add_header("Content-Type", "text/plain")
199+
if text_mode:
200+
msg_part.add_header("Content-Type", "text/plain")
197201
m.attach(msg_part)
198202

199203
sig_part = email.message.MIMEPart()
@@ -212,7 +216,18 @@ def _smime_encode(data: bytes, signature: bytes, micalg: str) -> bytes:
212216

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

src/rust/src/pkcs7.rs

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

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

147146
let content_type_bytes = asn1::write_single(&PKCS7_DATA_OID)?;
148147
let signing_time_bytes = asn1::write_single(&x509::certificate::time_from_chrono(
@@ -179,7 +178,7 @@ fn sign_and_serialize<'p>(
179178
{
180179
(
181180
None,
182-
x509::sign::sign_data(py, py_private_key, py_hash_alg, &data)?,
181+
x509::sign::sign_data(py, py_private_key, py_hash_alg, &data_with_header)?,
183182
)
184183
} else {
185184
let mut authenticated_attrs = vec![];
@@ -197,7 +196,8 @@ fn sign_and_serialize<'p>(
197196
])),
198197
});
199198

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

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

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

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

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

@@ -358,27 +364,60 @@ mod tests {
358364

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

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)