Skip to content

Commit 1a1e95b

Browse files
committed
fixes #8298 -- correctly generate content-type header in PKCS#7 SMIME
1 parent 008e69d commit 1a1e95b

3 files changed

Lines changed: 118 additions & 43 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: 79 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,15 @@ 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 (data_with_header, data_without_header) =
139+
if options.contains(pkcs7_options.getattr(crate::intern!(py, "Binary"))?)? {
140+
(Cow::Borrowed(raw_data), Cow::Borrowed(raw_data))
141+
} else {
142+
smime_canonicalize(
143+
raw_data,
144+
options.contains(pkcs7_options.getattr(crate::intern!(py, "Text"))?)?,
145+
)
146+
};
146147

147148
let content_type_bytes = asn1::write_single(&PKCS7_DATA_OID)?;
148149
let signing_time_bytes = asn1::write_single(&x509::certificate::time_from_chrono(
@@ -179,7 +180,7 @@ fn sign_and_serialize<'p>(
179180
{
180181
(
181182
None,
182-
x509::sign::sign_data(py, py_private_key, py_hash_alg, &data)?,
183+
x509::sign::sign_data(py, py_private_key, py_hash_alg, &data_with_header)?,
183184
)
184185
} else {
185186
let mut authenticated_attrs = vec![];
@@ -197,7 +198,8 @@ fn sign_and_serialize<'p>(
197198
])),
198199
});
199200

200-
let digest = asn1::write_single(&x509::ocsp::hash_data(py, py_hash_alg, &data)?)?;
201+
let digest =
202+
asn1::write_single(&x509::ocsp::hash_data(py, py_hash_alg, &data_with_header)?)?;
201203
// Gross hack: copy to PyBytes to extend the lifetime to 'p
202204
let digest_bytes = pyo3::types::PyBytes::new(py, &digest);
203205
authenticated_attrs.push(x509::csr::Attribute {
@@ -263,7 +265,7 @@ fn sign_and_serialize<'p>(
263265
if options.contains(pkcs7_options.getattr(crate::intern!(py, "DetachedSignature"))?)? {
264266
None
265267
} else {
266-
data_tlv_bytes = asn1::write_single(&data.deref())?;
268+
data_tlv_bytes = asn1::write_single(&data_with_header.deref())?;
267269
Some(asn1::parse_single(&data_tlv_bytes).unwrap())
268270
};
269271

@@ -305,9 +307,10 @@ fn sign_and_serialize<'p>(
305307
.import("cryptography.hazmat.primitives.serialization.pkcs7")?
306308
.getattr(crate::intern!(py, "_smime_encode"))?
307309
.call1((
308-
pyo3::types::PyBytes::new(py, &data),
310+
pyo3::types::PyBytes::new(py, &data_without_header),
309311
pyo3::types::PyBytes::new(py, &content_info_bytes),
310312
mic_algs,
313+
options.contains(pkcs7_options.getattr(crate::intern!(py, "Text"))?)?,
311314
))?
312315
.extract()?)
313316
} else {
@@ -316,28 +319,37 @@ fn sign_and_serialize<'p>(
316319
}
317320
}
318321

319-
fn smime_canonicalize(data: &[u8], text_mode: bool) -> Cow<'_, [u8]> {
320-
let mut new_data = vec![];
322+
fn smime_canonicalize(data: &[u8], text_mode: bool) -> (Cow<'_, [u8]>, Cow<'_, [u8]>) {
323+
let mut new_data_with_header = vec![];
324+
let mut new_data_without_header = vec![];
321325
if text_mode {
322-
new_data.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
326+
new_data_with_header.extend_from_slice(b"Content-Type: text/plain\r\n\r\n");
323327
}
324328

325329
let mut last_idx = 0;
326330
for (i, c) in data.iter().copied().enumerate() {
327331
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');
332+
new_data_with_header.extend_from_slice(&data[last_idx..i]);
333+
new_data_with_header.push(b'\r');
334+
new_data_with_header.push(b'\n');
335+
336+
new_data_without_header.extend_from_slice(&data[last_idx..i]);
337+
new_data_without_header.push(b'\r');
338+
new_data_without_header.push(b'\n');
331339
last_idx = i + 1;
332340
}
333341
}
334342
// If there's stuff in new_data, that means we need to copy the rest of
335343
// data over.
336-
if !new_data.is_empty() {
337-
new_data.extend_from_slice(&data[last_idx..]);
338-
Cow::Owned(new_data)
344+
if !new_data_with_header.is_empty() {
345+
new_data_with_header.extend_from_slice(&data[last_idx..]);
346+
new_data_without_header.extend_from_slice(&data[last_idx..]);
347+
(
348+
Cow::Owned(new_data_with_header),
349+
Cow::Owned(new_data_without_header),
350+
)
339351
} else {
340-
Cow::Borrowed(data)
352+
(Cow::Borrowed(data), Cow::Borrowed(data))
341353
}
342354
}
343355

@@ -358,27 +370,60 @@ mod tests {
358370

359371
#[test]
360372
fn test_smime_canonicalize() {
361-
for (input, text_mode, expected, expected_is_borrowed) in [
373+
for (
374+
input,
375+
text_mode,
376+
expected_with_header,
377+
expected_without_header,
378+
expected_is_borrowed,
379+
) in [
362380
// 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),
381+
(b"" as &[u8], false, b"" as &[u8], b"" as &[u8], true),
382+
(b"\n", false, b"\r\n", b"\r\n", false),
383+
(b"abc", false, b"abc", b"abc", true),
384+
(
385+
b"abc\r\ndef\n",
386+
false,
387+
b"abc\r\ndef\r\n",
388+
b"abc\r\ndef\r\n",
389+
false,
390+
),
391+
(b"abc\r\n", false, b"abc\r\n", b"abc\r\n", true),
392+
(
393+
b"abc\ndef\n",
394+
false,
395+
b"abc\r\ndef\r\n",
396+
b"abc\r\ndef\r\n",
397+
false,
398+
),
369399
// 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),
400+
(b"", true, b"Content-Type: text/plain\r\n\r\n", b"", false),
401+
(
402+
b"abc",
403+
true,
404+
b"Content-Type: text/plain\r\n\r\nabc",
405+
b"abc",
406+
false,
407+
),
372408
(
373409
b"abc\n",
374410
true,
375411
b"Content-Type: text/plain\r\n\r\nabc\r\n",
412+
b"abc\r\n",
376413
false,
377414
),
378415
] {
379-
let result = smime_canonicalize(input, text_mode);
380-
assert_eq!(result.deref(), expected);
381-
assert_eq!(matches!(result, Cow::Borrowed(_)), expected_is_borrowed);
416+
let (result_with_header, result_without_header) = smime_canonicalize(input, text_mode);
417+
assert_eq!(result_with_header.deref(), expected_with_header);
418+
assert_eq!(result_without_header.deref(), expected_without_header);
419+
assert_eq!(
420+
matches!(result_with_header, Cow::Borrowed(_)),
421+
expected_is_borrowed
422+
);
423+
assert_eq!(
424+
matches!(result_without_header, Cow::Borrowed(_)),
425+
expected_is_borrowed
426+
);
382427
}
383428
}
384429
}

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)