-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
Conversation
// | ||
// AES-IV ::= OCTET STRING (SIZE(16)) | ||
#[defined_by(oid::AES_128_CBC_OID)] | ||
AesCbc(&'a [u8]), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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); |
There was a problem hiding this comment.
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
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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fixed!
ed60635
to
9b83b39
Compare
@@ -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. |
There was a problem hiding this comment.
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 🙂
There was a problem hiding this comment.
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
6a2e2ef
to
21c50ec
Compare
21c50ec
to
ddebb90
Compare
There was a problem hiding this 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.
data: bytes | None = None, | ||
recipients: list[x509.Certificate] | None = None, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed!
# 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 | ||
): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]), |
There was a problem hiding this comment.
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 :-)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed!
#[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]>, | ||
} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
src/rust/src/pkcs7.rs
Outdated
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)?; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
// 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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
ddebb90
to
d941be0
Compare
c37582e
to
7797423
Compare
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:
|
There was a problem hiding this 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.
[ | ||
opt not in [PKCS7Options.Text, PKCS7Options.Binary] | ||
for opt in options | ||
] |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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]), |
There was a problem hiding this comment.
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
7797423
to
0fe9bb6
Compare
src/rust/src/pkcs7.rs
Outdated
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)?; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
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
src/rust/src/pkcs7.rs
Outdated
submod.add_function(pyo3::wrap_pyfunction_bound!( | ||
encrypt_and_serialize, | ||
&submod | ||
)?)?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Merge conflict here, sorry!
There was a problem hiding this comment.
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) | ||
|
||
|
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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
.
0fe9bb6
to
9236f70
Compare
9236f70
to
74d899e
Compare
@alex I squashed the old commits into a single one, and then added two new commits with the changes since your last review |
There was a problem hiding this 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!
with open("msg.p7m", "wb") as f: | ||
f.write(enveloped) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this 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.
I pushed a commit with the doc and changelog entry
I'll do this next |
Let us know once you've confirmed and we'll do a final review. Thanks for your work on this! |
@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 Mail.app (no private key available)Thunderbird (no private key available)After importing the private key: Mail.app (private key in keychain)Thunderbird (private key in keychain) |
@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:
and
(src) |
That's what I wanted to hear, thanks! 😄 |
lol, very good cryptography design. |
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):
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.
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 testsThe 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: