From 20c0388086b4eec91fdf1f9fd9535f4c741e4851 Mon Sep 17 00:00:00 2001 From: Paul Kehrer Date: Sat, 19 Sep 2020 18:07:26 -0500 Subject: [PATCH] smime signer support (#5465) * smime signer support * fix ed25519 check * change some wording * python 2.7... * review feedback * s/secure/signed * do some verification in the tests * review feedback * doc return value --- CHANGELOG.rst | 2 + docs/hazmat/primitives/index.rst | 1 + docs/hazmat/primitives/smime.rst | 128 +++++ src/_cffi_src/openssl/pkcs7.py | 1 + .../hazmat/backends/openssl/backend.py | 62 ++- src/cryptography/hazmat/primitives/smime.py | 109 ++++ tests/hazmat/primitives/test_smime.py | 518 ++++++++++++++++++ 7 files changed, 820 insertions(+), 1 deletion(-) create mode 100644 docs/hazmat/primitives/smime.rst create mode 100644 src/cryptography/hazmat/primitives/smime.py create mode 100644 tests/hazmat/primitives/test_smime.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 60d8b6182..1c8925803 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -10,6 +10,8 @@ Changelog * Support for OpenSSL 1.0.2 has been removed. Users on older version of OpenSSL will need to upgrade. +* Added basic support for SMIME signing via + :class:`~cryptography.hazmat.primitives.smime.SMIMESignatureBuilder`. .. _v3-1: diff --git a/docs/hazmat/primitives/index.rst b/docs/hazmat/primitives/index.rst index 72e5b26ce..bbe4418e7 100644 --- a/docs/hazmat/primitives/index.rst +++ b/docs/hazmat/primitives/index.rst @@ -14,5 +14,6 @@ Primitives mac/index cryptographic-hashes symmetric-encryption + smime padding twofactor diff --git a/docs/hazmat/primitives/smime.rst b/docs/hazmat/primitives/smime.rst new file mode 100644 index 000000000..556dd9b55 --- /dev/null +++ b/docs/hazmat/primitives/smime.rst @@ -0,0 +1,128 @@ +.. hazmat:: + +S/MIME +====== + +.. module:: cryptography.hazmat.primitives.smime + +.. testsetup:: + + ca_key = b""" + -----BEGIN PRIVATE KEY----- + MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgA8Zqz5vLeR0ePZUe + jBfdyMmnnI4U5uAJApWTsMn/RuWhRANCAAQY/8+7+Tm49d3D7sBAiwZ1BqtPzdgs + UiROH+AQRme1XxW5Yr07zwxvvhr3tKEPtLnLboazUPlsUb/Bgte+xfkF + -----END PRIVATE KEY----- + """.strip() + + ca_cert = b""" + -----BEGIN CERTIFICATE----- + MIIBUTCB96ADAgECAgIDCTAKBggqhkjOPQQDAjAnMQswCQYDVQQGEwJVUzEYMBYG + A1UEAwwPY3J5cHRvZ3JhcGh5IENBMB4XDTE3MDEwMTEyMDEwMFoXDTM4MTIzMTA4 + MzAwMFowJzELMAkGA1UEBhMCVVMxGDAWBgNVBAMMD2NyeXB0b2dyYXBoeSBDQTBZ + MBMGByqGSM49AgEGCCqGSM49AwEHA0IABBj/z7v5Obj13cPuwECLBnUGq0/N2CxS + JE4f4BBGZ7VfFblivTvPDG++Gve0oQ+0uctuhrNQ+WxRv8GC177F+QWjEzARMA8G + A1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIhANES742XWm64tkGnz8Dn + pG6u2lHkZFQr3oaVvPcemvlbAiEA0WGGzmYx5C9UvfXIK7NEziT4pQtyESE0uRVK + Xw4nMqk= + -----END CERTIFICATE----- + """.strip() + + +S/MIME provides a method to send and receive signed MIME messages. It is +commonly used in email. S/MIME has multiple versions, but this +module implements a subset of :rfc:`2632`, also known as S/MIME Version 3. + + +.. class:: SMIMESignatureBuilder + + .. versionadded:: 3.2 + + .. doctest:: + + >>> from cryptography.hazmat.primitives import hashes, serialization, smime + >>> from cryptography import x509 + >>> cert = x509.load_pem_x509_certificate(ca_cert) + >>> key = serialization.load_pem_private_key(ca_key, None) + >>> options = [smime.SMIMEOptions.DetachedSignature] + >>> smime.SMIMESignatureBuilder().set_data( + ... b"data to sign" + ... ).add_signer( + ... cert, key, hashes.SHA256() + ... ).sign( + ... serialization.Encoding.PEM, options + ... ) + b'...' + + .. method:: set_data(data) + + :param data: The data to be hashed and signed. + :type data: :term:`bytes-like` + + .. method:: add_signer(certificate, private_key, hash_algorithm) + + :param certificate: The :class:`~cryptography.x509.Certificate`. + + :param private_key: The + :class:`~cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey` or + :class:`~cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey` + associated with the certificate provided. + + :param hash_algorithm: The + :class:`~cryptography.hazmat.primitives.hashes.HashAlgorithm` that + will be used to generate the signature. This must be an instance of + :class:`~cryptography.hazmat.primitives.hashes.SHA1`, + :class:`~cryptography.hazmat.primitives.hashes.SHA224`, + :class:`~cryptography.hazmat.primitives.hashes.SHA256`, + :class:`~cryptography.hazmat.primitives.hashes.SHA384`, or + :class:`~cryptography.hazmat.primitives.hashes.SHA512`. + + .. method:: sign(encoding, options, backend=None) + + :param encoding: :attr:`~cryptography.hazmat.primitives.serialization.Encoding.PEM` + or :attr:`~cryptography.hazmat.primitives.serialization.Encoding.DER`. + + :param options: A list of :class:`~cryptography.hazmat.primitives.smime.SMIMEOptions`. + + :return bytes: The signed S/MIME message. + + :param backend: An optional backend. + + +.. class:: SMIMEOptions + + .. versionadded:: 3.2 + + An enumeration of options for S/MIME signature creation. + + .. attribute:: Text + + The text option adds ``text/plain`` headers to the S/MIME message when + serializing to + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.PEM`. + This option is disallowed with ``DER`` serialization. + + .. attribute:: Binary + + S/MIME signing normally converts line endings (LF to CRLF). When + passing this option the data will not be converted. + + .. attribute:: DetachedSignature + + Don't embed the signed data within the ASN.1. When signing with + :attr:`~cryptography.hazmat.primitives.serialization.Encoding.PEM` this + also results in the data being added as clear text before the + PEM encoded structure. + + .. attribute:: NoCapabilities + + S/MIME structures contain a ``MIMECapabilities`` section inside the + ``authenticatedAttributes``. Passing this as an option removes + ``MIMECapabilities``. + + .. attribute:: NoAttributes + + S/MIME structures contain an ``authenticatedAttributes`` section. + Passing this as an option removes that section. Note that if you + pass ``NoAttributes`` you can't pass ``NoCapabilities`` since + ``NoAttributes`` removes ``MIMECapabilities`` and more. diff --git a/src/_cffi_src/openssl/pkcs7.py b/src/_cffi_src/openssl/pkcs7.py index 72f9c2130..1878ec59d 100644 --- a/src/_cffi_src/openssl/pkcs7.py +++ b/src/_cffi_src/openssl/pkcs7.py @@ -67,6 +67,7 @@ int PKCS7_final(PKCS7 *, BIO *, int); https://github.com/pyca/cryptography/issues/5433 */ int PKCS7_verify(PKCS7 *, Cryptography_STACK_OF_X509 *, X509_STORE *, BIO *, BIO *, int); +PKCS7 *SMIME_read_PKCS7(BIO *, BIO **); int PKCS7_type_is_signed(PKCS7 *); int PKCS7_type_is_enveloped(PKCS7 *); diff --git a/src/cryptography/hazmat/backends/openssl/backend.py b/src/cryptography/hazmat/backends/openssl/backend.py index 97c7fd054..f7d6a47c7 100644 --- a/src/cryptography/hazmat/backends/openssl/backend.py +++ b/src/cryptography/hazmat/backends/openssl/backend.py @@ -115,7 +115,7 @@ from cryptography.hazmat.backends.openssl.x509 import ( _RevokedCertificate, ) from cryptography.hazmat.bindings.openssl import binding -from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives import hashes, serialization, smime from cryptography.hazmat.primitives.asymmetric import ( dsa, ec, @@ -2690,6 +2690,66 @@ class Backend(object): return certs + def smime_sign(self, builder, encoding, options): + bio = self._bytes_to_bio(builder._data) + init_flags = self._lib.PKCS7_PARTIAL + final_flags = 0 + + if smime.SMIMEOptions.DetachedSignature in options: + # Don't embed the data in the PKCS7 structure + init_flags |= self._lib.PKCS7_DETACHED + final_flags |= self._lib.PKCS7_DETACHED + + # This just inits a structure for us. However, there + # are flags we need to set, joy. + p7 = self._lib.PKCS7_sign( + self._ffi.NULL, + self._ffi.NULL, + self._ffi.NULL, + self._ffi.NULL, + init_flags, + ) + self.openssl_assert(p7 != self._ffi.NULL) + p7 = self._ffi.gc(p7, self._lib.PKCS7_free) + signer_flags = 0 + # These flags are configurable on a per-signature basis + # but we've deliberately chosen to make the API only allow + # setting it across all signatures for now. + if smime.SMIMEOptions.NoCapabilities in options: + signer_flags |= self._lib.PKCS7_NOSMIMECAP + elif smime.SMIMEOptions.NoAttributes in options: + signer_flags |= self._lib.PKCS7_NOATTR + for certificate, private_key, hash_algorithm in builder._signers: + md = self._evp_md_non_null_from_algorithm(hash_algorithm) + p7signerinfo = self._lib.PKCS7_sign_add_signer( + p7, certificate._x509, private_key._evp_pkey, md, signer_flags + ) + self.openssl_assert(p7signerinfo != self._ffi.NULL) + + for option in options: + # DetachedSignature, NoCapabilities, and NoAttributes are already + # handled so we just need to check these last two options. + if option is smime.SMIMEOptions.Text: + final_flags |= self._lib.PKCS7_TEXT + elif option is smime.SMIMEOptions.Binary: + final_flags |= self._lib.PKCS7_BINARY + + bio_out = self._create_mem_bio_gc() + if encoding is serialization.Encoding.PEM: + # This finalizes the structure + res = self._lib.SMIME_write_PKCS7( + bio_out, p7, bio.bio, final_flags + ) + else: + assert encoding is serialization.Encoding.DER + # We need to call finalize here becauase i2d_PKCS7_bio does not + # finalize. + res = self._lib.PKCS7_final(p7, bio.bio, final_flags) + self.openssl_assert(res == 1) + res = self._lib.i2d_PKCS7_bio(bio_out, p7) + self.openssl_assert(res == 1) + return self._read_mem_bio(bio_out) + class GetCipherByName(object): def __init__(self, fmt): diff --git a/src/cryptography/hazmat/primitives/smime.py b/src/cryptography/hazmat/primitives/smime.py new file mode 100644 index 000000000..538ba6a00 --- /dev/null +++ b/src/cryptography/hazmat/primitives/smime.py @@ -0,0 +1,109 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import absolute_import, division, print_function + +from enum import Enum + +from cryptography import x509 +from cryptography.hazmat.backends import _get_backend +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.utils import _check_byteslike + + +class SMIMESignatureBuilder(object): + def __init__(self, data=None, signers=[]): + self._data = data + self._signers = signers + + def set_data(self, data): + _check_byteslike("data", data) + if self._data is not None: + raise ValueError("data may only be set once") + + return SMIMESignatureBuilder(data, self._signers) + + def add_signer(self, certificate, private_key, hash_algorithm): + if not isinstance( + hash_algorithm, + ( + hashes.SHA1, + hashes.SHA224, + hashes.SHA256, + hashes.SHA384, + hashes.SHA512, + ), + ): + raise TypeError( + "hash_algorithm must be one of hashes.SHA1, SHA224, " + "SHA256, SHA384, or SHA512" + ) + if not isinstance(certificate, x509.Certificate): + raise TypeError("certificate must be a x509.Certificate") + + if not isinstance( + private_key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey) + ): + raise TypeError("Only RSA & EC keys are supported at this time.") + + return SMIMESignatureBuilder( + self._data, + self._signers + [(certificate, private_key, hash_algorithm)], + ) + + def sign(self, encoding, options, backend=None): + if len(self._signers) == 0: + raise ValueError("Must have at least one signer") + if self._data is None: + raise ValueError("You must add data to sign") + options = list(options) + if not all(isinstance(x, SMIMEOptions) for x in options): + raise ValueError("options must be from the SMIMEOptions enum") + if ( + encoding is not serialization.Encoding.PEM + and encoding is not serialization.Encoding.DER + ): + raise ValueError("Must be PEM or DER from the Encoding enum") + + # Text is a meaningless option unless it is accompanied by + # DetachedSignature + if ( + SMIMEOptions.Text in options + and SMIMEOptions.DetachedSignature not in options + ): + raise ValueError( + "When passing the Text option you must also pass " + "DetachedSignature" + ) + + if ( + SMIMEOptions.Text in options + and encoding is serialization.Encoding.DER + ): + raise ValueError( + "The Text option does nothing when serializing to DER" + ) + + # No attributes implies no capabilities so we'll error if you try to + # pass both. + if ( + SMIMEOptions.NoAttributes in options + and SMIMEOptions.NoCapabilities in options + ): + raise ValueError( + "NoAttributes is a superset of NoCapabilities. Do not pass " + "both values." + ) + + backend = _get_backend(backend) + return backend.smime_sign(self, encoding, options) + + +class SMIMEOptions(Enum): + Text = "Add text/plain MIME type" + Binary = "Don't translate input data into canonical MIME format" + DetachedSignature = "Don't embed data in the PKCS7 structure" + NoCapabilities = "Don't embed SMIME capabilities" + NoAttributes = "Don't embed authenticatedAttributes" diff --git a/tests/hazmat/primitives/test_smime.py b/tests/hazmat/primitives/test_smime.py new file mode 100644 index 000000000..c2ff275e0 --- /dev/null +++ b/tests/hazmat/primitives/test_smime.py @@ -0,0 +1,518 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. + +from __future__ import absolute_import, division, print_function + +import os + +import pytest + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization, smime +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from .utils import load_vectors_from_file + + +# We have no public verification API and won't be adding one until we get +# some requirements from users so this function exists to give us basic +# verification for the signing tests. +def _smime_verify(encoding, sig, msg, certs, options, backend): + sig_bio = backend._bytes_to_bio(sig) + if encoding is serialization.Encoding.DER: + p7 = backend._lib.d2i_PKCS7_bio(sig_bio.bio, backend._ffi.NULL) + else: + p7 = backend._lib.SMIME_read_PKCS7(sig_bio.bio, backend._ffi.NULL) + backend.openssl_assert(p7 != backend._ffi.NULL) + p7 = backend._ffi.gc(p7, backend._lib.PKCS7_free) + flags = 0 + for option in options: + if option is smime.SMIMEOptions.Text: + flags |= backend._lib.PKCS7_TEXT + store = backend._lib.X509_STORE_new() + backend.openssl_assert(store != backend._ffi.NULL) + store = backend._ffi.gc(store, backend._lib.X509_STORE_free) + for cert in certs: + res = backend._lib.X509_STORE_add_cert(store, cert._x509) + backend.openssl_assert(res == 1) + if msg is None: + res = backend._lib.PKCS7_verify( + p7, + backend._ffi.NULL, + store, + backend._ffi.NULL, + backend._ffi.NULL, + flags, + ) + else: + msg_bio = backend._bytes_to_bio(msg) + res = backend._lib.PKCS7_verify( + p7, backend._ffi.NULL, store, msg_bio.bio, backend._ffi.NULL, flags + ) + backend.openssl_assert(res == 1) + + +def _load_cert_key(): + key = load_vectors_from_file( + os.path.join("x509", "custom", "ca", "ca_key.pem"), + lambda pemfile: serialization.load_pem_private_key( + pemfile.read(), None + ), + mode="rb", + ) + cert = load_vectors_from_file( + os.path.join("x509", "custom", "ca", "ca.pem"), + loader=lambda pemfile: x509.load_pem_x509_certificate(pemfile.read()), + mode="rb", + ) + return cert, key + + +class TestSMIMEBuilder(object): + def test_invalid_data(self): + builder = smime.SMIMESignatureBuilder() + with pytest.raises(TypeError): + builder.set_data(u"not bytes") + + def test_set_data_twice(self): + builder = smime.SMIMESignatureBuilder().set_data(b"test") + with pytest.raises(ValueError): + builder.set_data(b"test") + + def test_sign_no_signer(self): + builder = smime.SMIMESignatureBuilder().set_data(b"test") + with pytest.raises(ValueError): + builder.sign(serialization.Encoding.PEM, []) + + def test_sign_no_data(self): + cert, key = _load_cert_key() + builder = smime.SMIMESignatureBuilder().add_signer( + cert, key, hashes.SHA256() + ) + with pytest.raises(ValueError): + builder.sign(serialization.Encoding.PEM, []) + + def test_unsupported_hash_alg(self): + cert, key = _load_cert_key() + with pytest.raises(TypeError): + smime.SMIMESignatureBuilder().add_signer( + cert, key, hashes.SHA512_256() + ) + + def test_not_a_cert(self): + cert, key = _load_cert_key() + with pytest.raises(TypeError): + smime.SMIMESignatureBuilder().add_signer( + b"notacert", key, hashes.SHA256() + ) + + @pytest.mark.supported( + only_if=lambda backend: backend.ed25519_supported(), + skip_message="Does not support ed25519.", + ) + def test_unsupported_key_type(self, backend): + cert, _ = _load_cert_key() + key = ed25519.Ed25519PrivateKey.generate() + with pytest.raises(TypeError): + smime.SMIMESignatureBuilder().add_signer( + cert, key, hashes.SHA256() + ) + + def test_sign_invalid_options(self): + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(b"test") + .add_signer(cert, key, hashes.SHA256()) + ) + with pytest.raises(ValueError): + builder.sign(serialization.Encoding.PEM, [b"invalid"]) + + def test_sign_invalid_encoding(self): + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(b"test") + .add_signer(cert, key, hashes.SHA256()) + ) + with pytest.raises(ValueError): + builder.sign(serialization.Encoding.Raw, []) + + def test_sign_invalid_options_text_no_detached(self): + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(b"test") + .add_signer(cert, key, hashes.SHA256()) + ) + options = [smime.SMIMEOptions.Text] + with pytest.raises(ValueError): + builder.sign(serialization.Encoding.PEM, options) + + def test_sign_invalid_options_text_der_encoding(self): + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(b"test") + .add_signer(cert, key, hashes.SHA256()) + ) + options = [ + smime.SMIMEOptions.Text, + smime.SMIMEOptions.DetachedSignature, + ] + with pytest.raises(ValueError): + builder.sign(serialization.Encoding.DER, options) + + def test_sign_invalid_options_no_attrs_and_no_caps(self): + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(b"test") + .add_signer(cert, key, hashes.SHA256()) + ) + options = [ + smime.SMIMEOptions.NoAttributes, + smime.SMIMEOptions.NoCapabilities, + ] + with pytest.raises(ValueError): + builder.sign(serialization.Encoding.PEM, options) + + def test_smime_sign_detached(self, backend): + data = b"hello world" + cert, key = _load_cert_key() + options = [smime.SMIMEOptions.DetachedSignature] + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + + sig = builder.sign(serialization.Encoding.PEM, options) + sig_binary = builder.sign(serialization.Encoding.DER, options) + # We don't have a generic ASN.1 parser available to us so we instead + # will assert on specific byte sequences being present based on the + # parameters chosen above. + assert b"sha-256" in sig + # Detached signature means that the signed data is *not* embedded into + # the PKCS7 structure itself, but is present in the PEM serialization + # as a separate section before the PKCS7 data. So we should expect to + # have data in sig but not in sig_binary + assert data in sig + _smime_verify( + serialization.Encoding.PEM, sig, data, [cert], options, backend + ) + assert data not in sig_binary + _smime_verify( + serialization.Encoding.DER, + sig_binary, + data, + [cert], + options, + backend, + ) + + def test_sign_byteslike(self): + data = bytearray(b"hello world") + cert, key = _load_cert_key() + options = [smime.SMIMEOptions.DetachedSignature] + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + + sig = builder.sign(serialization.Encoding.PEM, options) + assert bytes(data) in sig + + @pytest.mark.parametrize( + ("hash_alg", "expected_value"), + [ + (hashes.SHA1(), b"\x06\x05+\x0e\x03\x02\x1a"), + (hashes.SHA256(), b"\x06\t`\x86H\x01e\x03\x04\x02\x01"), + (hashes.SHA384(), b"\x06\t`\x86H\x01e\x03\x04\x02\x02"), + (hashes.SHA512(), b"\x06\t`\x86H\x01e\x03\x04\x02\x03"), + ], + ) + def test_smime_sign_alternate_digests_der( + self, hash_alg, expected_value, backend + ): + data = b"hello world" + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hash_alg) + ) + options = [] + sig = builder.sign(serialization.Encoding.DER, options) + assert expected_value in sig + _smime_verify( + serialization.Encoding.DER, sig, None, [cert], options, backend + ) + + @pytest.mark.parametrize( + ("hash_alg", "expected_value"), + [ + (hashes.SHA1(), b"sha1"), + (hashes.SHA256(), b"sha-256"), + (hashes.SHA384(), b"sha-384"), + (hashes.SHA512(), b"sha-512"), + ], + ) + def test_smime_sign_alternate_digests_detached_pem( + self, hash_alg, expected_value + ): + data = b"hello world" + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hash_alg) + ) + options = [smime.SMIMEOptions.DetachedSignature] + sig = builder.sign(serialization.Encoding.PEM, options) + # When in detached signature mode the hash algorithm is stored as a + # byte string like "sha-384". + assert expected_value in sig + + def test_smime_sign_attached(self, backend): + data = b"hello world" + cert, key = _load_cert_key() + options = [] + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + + sig_binary = builder.sign(serialization.Encoding.DER, options) + # When not passing detached signature the signed data is embedded into + # the PKCS7 structure itself + assert data in sig_binary + _smime_verify( + serialization.Encoding.DER, + sig_binary, + None, + [cert], + options, + backend, + ) + + def test_smime_sign_binary(self, backend): + data = b"hello\nworld" + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + options = [] + sig_no_binary = builder.sign(serialization.Encoding.DER, options) + sig_binary = builder.sign( + serialization.Encoding.DER, [smime.SMIMEOptions.Binary] + ) + # Binary prevents translation of LF to CR+LF (SMIME canonical form) + # so data should not be present in sig_no_binary, but should be present + # in sig_binary + assert data not in sig_no_binary + _smime_verify( + serialization.Encoding.DER, + sig_no_binary, + None, + [cert], + options, + backend, + ) + assert data in sig_binary + _smime_verify( + serialization.Encoding.DER, + sig_binary, + None, + [cert], + options, + backend, + ) + + def test_smime_sign_smime_canonicalization(self, backend): + data = b"hello\nworld" + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + + options = [] + sig_binary = builder.sign(serialization.Encoding.DER, options) + # LF gets converted to CR+LF (SMIME canonical form) + # so data should not be present in the sig + assert data not in sig_binary + assert b"hello\r\nworld" in sig_binary + _smime_verify( + serialization.Encoding.DER, + sig_binary, + None, + [cert], + options, + backend, + ) + + def test_smime_sign_text(self, backend): + data = b"hello world" + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + + options = [ + smime.SMIMEOptions.Text, + smime.SMIMEOptions.DetachedSignature, + ] + sig_pem = builder.sign(serialization.Encoding.PEM, options) + # The text option adds text/plain headers to the S/MIME message + # These headers are only relevant in PEM mode, not binary, which is + # just the PKCS7 structure itself. + assert b"text/plain" in sig_pem + # When passing the Text option the header is prepended so the actual + # signed data is this. + signed_data = b"Content-Type: text/plain\r\n\r\nhello world" + _smime_verify( + serialization.Encoding.PEM, + sig_pem, + signed_data, + [cert], + options, + backend, + ) + + def test_smime_sign_no_capabilities(self, backend): + data = b"hello world" + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + + options = [smime.SMIMEOptions.NoCapabilities] + sig_binary = builder.sign(serialization.Encoding.DER, options) + # NoCapabilities removes the SMIMECapabilities attribute from the + # PKCS7 structure. This is an ASN.1 sequence with the + # OID 1.2.840.113549.1.9.15. It does NOT remove all authenticated + # attributes, so we verify that by looking for the signingTime OID. + + # 1.2.840.113549.1.9.15 SMIMECapabilities as an ASN.1 DER encoded OID + assert b"\x06\t*\x86H\x86\xf7\r\x01\t\x0f" not in sig_binary + # 1.2.840.113549.1.9.5 signingTime as an ASN.1 DER encoded OID + assert b"\x06\t*\x86H\x86\xf7\r\x01\t\x05" in sig_binary + _smime_verify( + serialization.Encoding.DER, + sig_binary, + None, + [cert], + options, + backend, + ) + + def test_smime_sign_no_attributes(self, backend): + data = b"hello world" + cert, key = _load_cert_key() + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA256()) + ) + + options = [smime.SMIMEOptions.NoAttributes] + sig_binary = builder.sign(serialization.Encoding.DER, options) + # NoAttributes removes all authenticated attributes, so we shouldn't + # find SMIMECapabilities or signingTime. + + # 1.2.840.113549.1.9.15 SMIMECapabilities as an ASN.1 DER encoded OID + assert b"\x06\t*\x86H\x86\xf7\r\x01\t\x0f" not in sig_binary + # 1.2.840.113549.1.9.5 signingTime as an ASN.1 DER encoded OID + assert b"\x06\t*\x86H\x86\xf7\r\x01\t\x05" not in sig_binary + _smime_verify( + serialization.Encoding.DER, + sig_binary, + None, + [cert], + options, + backend, + ) + + def test_multiple_signers(self, backend): + data = b"hello world" + cert, key = _load_cert_key() + rsa_key = load_vectors_from_file( + os.path.join("x509", "custom", "ca", "rsa_key.pem"), + lambda pemfile: serialization.load_pem_private_key( + pemfile.read(), None + ), + mode="rb", + ) + rsa_cert = load_vectors_from_file( + os.path.join("x509", "custom", "ca", "rsa_ca.pem"), + loader=lambda pemfile: x509.load_pem_x509_certificate( + pemfile.read() + ), + mode="rb", + ) + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA512()) + .add_signer(rsa_cert, rsa_key, hashes.SHA512()) + ) + options = [] + sig = builder.sign(serialization.Encoding.DER, options) + # There should be three SHA512 OIDs in this structure + assert sig.count(b"\x06\t`\x86H\x01e\x03\x04\x02\x03") == 3 + _smime_verify( + serialization.Encoding.DER, + sig, + None, + [cert, rsa_cert], + options, + backend, + ) + + def test_multiple_signers_different_hash_algs(self, backend): + data = b"hello world" + cert, key = _load_cert_key() + rsa_key = load_vectors_from_file( + os.path.join("x509", "custom", "ca", "rsa_key.pem"), + lambda pemfile: serialization.load_pem_private_key( + pemfile.read(), None + ), + mode="rb", + ) + rsa_cert = load_vectors_from_file( + os.path.join("x509", "custom", "ca", "rsa_ca.pem"), + loader=lambda pemfile: x509.load_pem_x509_certificate( + pemfile.read() + ), + mode="rb", + ) + builder = ( + smime.SMIMESignatureBuilder() + .set_data(data) + .add_signer(cert, key, hashes.SHA384()) + .add_signer(rsa_cert, rsa_key, hashes.SHA512()) + ) + options = [] + sig = builder.sign(serialization.Encoding.DER, options) + # There should be two SHA384 and two SHA512 OIDs in this structure + assert sig.count(b"\x06\t`\x86H\x01e\x03\x04\x02\x02") == 2 + assert sig.count(b"\x06\t`\x86H\x01e\x03\x04\x02\x03") == 2 + _smime_verify( + serialization.Encoding.DER, + sig, + None, + [cert, rsa_cert], + options, + backend, + )