Skip to content
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

Add support for encrypting S/MIME messages #10889

Merged
merged 6 commits into from
Jul 18, 2024

Conversation

facutuesca
Copy link
Contributor

@facutuesca facutuesca commented Apr 25, 2024

I'm opening this PR with an initial implementation of S/MIME encryption, in order to better discuss the API design, the algorithms we want to support, and how we want to approach testing.

The target is a subset of S/MIME v3.2 (RFC5751):

  • Content encryption is done using AES-128-CBC
  • Key management is done with key transport: the symmetric encryption key used for the message is included and encrypted using the recipients' public keys.
  • The other two key management methods (previously-distributed keys and key agreement) are not supported.
  • The symmetric encryption key is encrypted using RSA (PKCS1 v1.5). That is, we only support recipients with RSA public keys, and we use PKCS1v15 padding.

Here are the openssl commands that can be used for testing, to compare our encrypted output against, or to decrypt our encrypted output.

# encrypt
openssl smime -encrypt  -aes-128-cbc -in msg.txt -out out.txt -outform PEM vectors/cryptography_vectors/x509/custom/ca/rsa_ca.pem
# decrypt
openssl smime -decrypt -recip vectors/cryptography_vectors/x509/custom/ca/rsa_ca.pem -inkey vectors/cryptography_vectors/x509/custom/ca/rsa_key.pem -in out.txt -inform pem

I added some tests for the unencrypted parts of the message, but complete testing would require that we parse and decrypt the messages. We could follow a similar approach as with testing S/MIME signing, where we call OpenSSL directly to parse and check our output during the tests

The tests now use OpenSSL's PKCS7_decrypt in order to see if our encrypted output can be correctly decrypted by OpenSSL.

cc @alex @reaperhulk @woodruffw

(the issue tracking this feature is #5488)

TODO:

  • Write the docs
  • Add to CHANGELOG

//
// AES-IV ::= OCTET STRING (SIZE(16))
#[defined_by(oid::AES_128_CBC_OID)]
AesCbc(&'a [u8]),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of curiosity: does rust-asn1 know to turn this &[u8] into the inner value of the OCTET STRING, or does this end up containing the raw TLV for the OCTET STRING? I suspect it's the former, but we should confirm.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

&[u8] in rust-asn1 are OCTET STRINGs, not raw TLVs.

Comment on lines 77 to 79
pub const AES_256_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 42);
pub const AES_192_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 22);
pub const AES_128_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 2);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just for cross-checking: confirmed these against their parent arc: https://oidref.com/2.16.840.1.101.3.4.1

Comment on lines 316 to 324
fp = io.BytesIO()
g = email.generator.BytesGenerator(
fp,
maxheaderlen=0,
mangle_from_=False,
policy=m.policy.clone(linesep="\n"),
)
g.flatten(m)
return fp.getvalue()
Copy link
Contributor

@woodruffw woodruffw Apr 25, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: you might be able to get away with just m.as_bytes(...) rather than jumping through a BytesIO + BytesGenerator here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed!

@@ -225,6 +307,26 @@ def _smime_encode(
return fp.getvalue()


def _smime_enveloped_encode(data: bytes) -> bytes:
# This function works pretty hard to replicate what OpenSSL does
# precisely. For good and for ill.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: might be good to include a URL or code reference for OpenSSL's construction of the encoding here, just in case this ever needs to be re-evaluated 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually a comment from the other encoder function that I used as a base for this one (_smime_signed_encode).

I think it doesn't make much sense here since we just add the headers, so I removed it

@facutuesca facutuesca marked this pull request as ready for review June 17, 2024 14:49
@facutuesca facutuesca requested review from alex and woodruffw June 17, 2024 14:49
Copy link
Member

@alex alex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a few comments. Deciding on a testing strategy for the encrypted components is probably the most important next step.

Comment on lines 184 to 185
data: bytes | None = None,
recipients: list[x509.Certificate] | None = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
data: bytes | None = None,
recipients: list[x509.Certificate] | None = None,
*,
_data: bytes | None = None,
_recipients: list[x509.Certificate] | None = None,

People keep assuming these are public, let's make that harder for them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

Comment on lines 247 to 252
# These options only make sense when signing, not encrypting
if (
PKCS7Options.NoAttributes in options
or PKCS7Options.NoCapabilities in options
or PKCS7Options.NoCerts in options
or PKCS7Options.DetachedSignature in options
):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we probably want to do an allow-list of sensible options, not the inverse.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

//
// AES-IV ::= OCTET STRING (SIZE(16))
#[defined_by(oid::AES_128_CBC_OID)]
AesCbc(&'a [u8]),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure if you rebase you'll find this was added in the meantime :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that Aes256Cbc was added, did you mean that one?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, missed that. I think you'll want to follow the style on that one -- using fixed size array and name including 128

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

Comment on lines 59 to 65
#[derive(asn1::Asn1Write)]
pub struct EncryptedContentInfo<'a> {
pub _content_type: asn1::ObjectIdentifier,
pub content_encryption_algorithm: common::AlgorithmIdentifier<'a>,
#[implicit(0)]
pub content: Option<&'a [u8]>,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note this is also defined in #11059

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed so that the field names match

Comment on lines 95 to 100
let padder = types::SYMMETRIC_PADDING_PKCS7
.get(py)?
.call1((128,))?
.call_method0(pyo3::intern!(py, "padder"))?;
let padded_content_start =
padder.call_method1(pyo3::intern!(py, "update"), (data_with_header,))?;
let padded_content_end = padder.call_method0(pyo3::intern!(py, "finalize"))?;
let padded_content = padded_content_start.add(padded_content_end)?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's now a rust pkcs7 padding API

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Since the new PKCS7PaddingContext::update method takes a CffiBuf, I had to use PyBytes::new_bound to go from Cow[u8] to PyBound<PyBytes> to CffiBuf, which means there is now an extra copy operation. Is there a better way of doing that?

Comment on lines +104 to +97
// The message is encrypted with AES-128-CBC, which the S/MIME v3.2 RFC
// specifies as MUST support (https://datatracker.ietf.org/doc/html/rfc5751#section-2.7)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Someone noted that there's an S/MIME 4.0 which supports AEADs. Is there a reason not to use them?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really, mostly scope (I limited this PR to a subset of S/MIME 3.2)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m fine with that unless 4.0 is broadly supported, at which point we should just do 4.0 only. Is there any reasonable way to survey current support?

Copy link
Contributor Author

@facutuesca facutuesca Jul 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure actually. A quick internet search doesn't reveal anything about usage of v3 vs v4. @woodruffw, do you know if there's a way?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A rough way to determine this would be just to see if the major S/MIME clients all support it. e.g. does Mail.app? Does Outlook? Gmail has S/MIME, what about that? Thunderbird?

Copy link
Contributor

@woodruffw woodruffw Jul 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a bit of searching for this:

  • I can't find references to AEAD cipher support (or other S/MIME 4.0 features) in the public docs for Mail.app, Outlook, or Gmail
  • Thunderbird appears to default to S/MIME 3.x, e.g. using AES-128-CBC by default. It's unclear whether they support AEADs for verifying.
  • Other parts of RFC 8551 appear to be unsupported by Thunderbird: https://bugzilla.mozilla.org/show_bug.cgi?id=1600776

Edit: here's a tracker for RFC 8551 in Thunderbird: https://bugzilla.mozilla.org/show_bug.cgi?id=1847703. TL;DR is that much of the RFC is not implemented yet, it seems.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a bit more searching: as best I can tell, all of the major mail clients handle S/MIME encryption by parroting whatever cipher selection they receive (with some enforcing a floor, e.g. no 3DES unless explicitly enabled). Thunderbird may support the SMIMECapabilities extension for improved negotiation, but not unless explicitly enabled through an about:config setting.

Support among other MUAs is similarly mixed or not present: https://security.stackexchange.com/questions/271353/any-client-still-supporting-the-s-mime-capabilities-extension-in-2023

TL;DR: It seems like MUAs are still pretty firmly on S/MIME 3.1 and 3.2.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment has a good summary as well: https://bugzilla.mozilla.org/show_bug.cgi?id=1847703#c7

@facutuesca facutuesca force-pushed the smime-encryption branch 3 times, most recently from c37582e to 7797423 Compare July 1, 2024 16:42
@facutuesca
Copy link
Contributor Author

@alex @woodruffw

Deciding on a testing strategy for the encrypted components is probably the most important next step

I added a binding for PKCS7_decrypt and changed the tests to check that the encrypted output can be correctly decrypted by OpenSSL.

There are 4 new commits since the last review round:

  • Use correct PKCS7/SMIME OID for enveloped data
    • This was a mistake I did while rebasing. We use envelopedData rather than encryptedData for S/MIME encryption.
  • Fail when passing Text and Binary options at the same time
    • OpenSSL allows passing both options at the same time, but ignores Text. We defensively fail here so that users know they're doing something weird/wrong.
  • Use PKCS7 tag for PEM-encoding encrypted messages
    • Originally I used the CMS tag for PEM encoded messages (-----BEGIN CMS-----), matching the output of openssl cms. However, this doesn't match our other APIs which use -----BEGIN PKCS7----- (which corresponds to openssl smime). Furthermore, if we wanted to test decryption of the former, we would need to add new bindings for cms.h functions and structures, whereas with PKCS7 we can reuse the existing bindings and be consistent with our other PCKS7 APIs.
  • Test S/MIME encryption by decrypting and comparing with plaintext
    • As mentioned before, it adds a binding for PKCS7_decrypt and uses it to test our encrypted output.

Copy link
Member

@alex alex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Started to review but realized there's going to be a few things that are no longer needed now that pkcs12 is merged.

Comment on lines 250 to 253
[
opt not in [PKCS7Options.Text, PKCS7Options.Binary]
for opt in options
]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for list comprehension, generator is fine

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

//
// AES-IV ::= OCTET STRING (SIZE(16))
#[defined_by(oid::AES_128_CBC_OID)]
AesCbc(&'a [u8]),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, missed that. I think you'll want to follow the style on that one -- using fixed size array and name including 128

src/rust/cryptography-x509/src/pkcs7.rs Show resolved Hide resolved
Comment on lines 109 to 113
let encryptor = cipher.call_method0(pyo3::intern!(py, "encryptor"))?;
let encrypted_content_start =
encryptor.call_method1(pyo3::intern!(py, "update"), (padded_content,))?;
let encrypted_content_end = encryptor.call_method0(pyo3::intern!(py, "finalize"))?;
let encrypted_content = encrypted_content_start.add(encrypted_content_end)?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've now got symmetric encryption repeated a few times. I'm going to refactor the duplication into a fucntion, then it can be used here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed, now this uses pkcs12::symmetric_encrypt too

submod.add_function(pyo3::wrap_pyfunction_bound!(
encrypt_and_serialize,
&submod
)?)?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge conflict here, sorry!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed!


return _read_mem_bio(out_bio, backend)


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does rust-openssl have teh functions we need? It might make mroe sense to expose a test API from there (see the asn1 testcertificate API for an example of this).

What do you think @reaperhulk ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I removed the decrypt (and helper) Python functions from here, and added a Rust pkcs7_decrypt to test_support.rs, similar to the new pkcs7_verify.

@facutuesca
Copy link
Contributor Author

@alex I squashed the old commits into a single one, and then added two new commits with the changes since your last review

@facutuesca facutuesca requested a review from alex July 17, 2024 09:07
Copy link
Member

@alex alex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One comment, and this needs docs and a changelog entry, but otherwise looks great. Thanks for your work here!

Comment on lines 1040 to 1041
with open("msg.p7m", "wb") as f:
f.write(enveloped)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this was debugging leftover?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, good catch! Fixed

self,
encoding: serialization.Encoding,
options: typing.Iterable[PKCS7Options],
backend: typing.Any = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we including this on new APIs?

Copy link
Contributor Author

@facutuesca facutuesca Jul 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my bad, it's a leftover from copy-pasting the sign() method. Removed

Copy link
Member

@reaperhulk reaperhulk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generally looks good now, so just one final question: have we verified that S/MIME encrypted messages can be successfully decrypted by one or more of the major clients? I see we test decryption in OpenSSL and we follow the spec, but it'd be good to have that final bit of validation.

@facutuesca
Copy link
Contributor Author

and this needs docs and a changelog entry,

I pushed a commit with the doc and changelog entry

have we verified that S/MIME encrypted messages can be successfully decrypted by one or more of the major clients?

I'll do this next

@alex
Copy link
Member

alex commented Jul 17, 2024

Let us know once you've confirmed and we'll do a final review. Thanks for your work on this!

@facutuesca
Copy link
Contributor Author

@alex @reaperhulk I verified that the output can be read by both Mail.app and Thunderbird. The way I did it was by saving the output of encrypt() to an .eml file, and importing that file in both applications. Before adding the corresponding certificate and private key to the settings/keychain, this is what you see:

Mail.app (no private key available)

macos-encrypted

Thunderbird (no private key available)

thunderbird-encrypted

After importing the private key:

Mail.app (private key in keychain)

macos-decrypted

Thunderbird (private key in keychain)

thunderbird-decrypted

@reaperhulk
Copy link
Member

@facutuesca Thanks! Apologies for one final question here: Users are likely to want to do both signing and encryption now that we have them -- are our current APIs composable to achieve that or is there some gross PKCS7 data structure we need to implement still?

@facutuesca
Copy link
Contributor Author

@facutuesca Thanks! Apologies for one final question here: Users are likely to want to do both signing and encryption now that we have them -- are our current APIs composable to achieve that or is there some gross PKCS7 data structure we need to implement still?

They are already composable: the S/MIME RFC specifies that creating signed+encrypted messages is just a matter of doing one operation on the output of the other:

The signed-only, enveloped-only, and compressed-only MIME formats can
be nested. This works because these formats are all MIME entities
that encapsulate other MIME entities.

and

It is possible to apply any of the signing, encrypting, and
compressing operations in any order. It is up to the implementer and
the user to choose.

(src)

@reaperhulk
Copy link
Member

That's what I wanted to hear, thanks! 😄

@reaperhulk reaperhulk merged commit 0faaffc into pyca:main Jul 18, 2024
57 checks passed
@facutuesca facutuesca deleted the smime-encryption branch July 18, 2024 15:53
@woodruffw
Copy link
Contributor

It is possible to apply any of the signing, encrypting, and
compressing operations in any order. It is up to the implementer and
the user to choose.

lol, very good cryptography design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

4 participants