Repository: pyca/cryptography

Issue:
Feature Request: A built-in method that uses the OpenSSL wrapper to generate a PKCS7 certificate from PEM certificates.
If you're filing a bug (as opposed to a feature request), please try the
following things:

* Check the FAQ to see if your issue is covered there:
  https://cryptography.io/en/latest/faq.html
* Upgrade to the latest version of ``setuptools`` and ``pip``
* Make sure you're on a supported version of OpenSSL
* Try with the latest version of ``cryptography``
* Be sure you have the required compilers (both a C compiler and Rust)
  installed if you aren't using the binary wheels.

If none of that works, please make sure to include the following information in
your bug report:

* Versions of Python, ``cryptography``, ``cffi``, ``pip``, and ``setuptools``
  you're using
* How you installed ``cryptography``
* Clear steps for reproducing your bug

Please do not report security issues on Github! Follow the instructions in our
documentation for reporting security issues:
https://cryptography.io/en/latest/security.html


As described above, OpenSSL has an API that generates a PKCS7 certificate from a PEM certificate. These certificates will contain the converted certificate with only the public key.

Use cases for such:

1. Many systems only accept PKCS7 certificate installation in their trust chain. 
2. PKCS7 certificates support also add a Certificate Revocation List. Since, cryptography already supports creating and loading CRLs, we can now take the next step and generate PKCS7 certificates from both.
3. Overall, good to have to expand the capabilities of cryptography.

These certificates will typically use the syntax 

openssl crl2pkcs7 -certfile <pem_path> -crl|-nocrl -out <p7b path>

The output certificate will have the signature - 

-------------BEGIN PKCS7---------------
-------------END PKCS7-----------------


Discussion:
If I understand correctly, this is a feature request to allow the serialization of X509 certificates into a PKCS7 structure without a signature. We already support PKCS7 but only as a signed structure (e.g. S/MIME), but the need is for a `p7b` in the Windows/Tomcat sense, correct?
That is correct. That is one of the requests.

The other is to also support passing in a CertificateRevocationList (which we can already generate from within cryptography).

OpenSSL uses this in the context of crl2pkcs7 where we can optionally pass in a revocation list to output a PKCS#7 degenerate "certificates only" structure. 

I understand the above may be a really involved request and I can send in a PR implementing the same.
I'm not entirely sure I follow the second request, but we'd be happy to look at PRs and discuss!
Will be sending in the PR by Thursday. I have identified the OpenSSL API function - pem_write_bio_stream under the pem.h library that encodes all the certificate, crl data to pkcs7. 
I need the same. Right now I'm using this as a hacky workaround.

```python
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import pkcs7
from cryptography.hazmat.backends.openssl.backend import backend

pkcs7_data = pkcs7.PKCS7SignatureBuilder()
pkcs7_data = pkcs7_data..set_data(b"")
pkcs7_data = pkcs7_data.add_certificate(cert)
with open(file, 'wb') as f:
    f.write(backend.pkcs7_sign(pkcs7_data, serialization.Encoding.PEM, []))
```

Resolving patch:
diff --git a/src/cryptography/hazmat/backends/openssl/backend.py b/src/cryptography/hazmat/backends/openssl/backend.py
--- a/src/cryptography/hazmat/backends/openssl/backend.py
+++ b/src/cryptography/hazmat/backends/openssl/backend.py
@@ -2336,6 +2336,53 @@ def _load_pkcs7_certificates(self, p7):
 
         return certs
 
+    def pkcs7_serialize_certificates(
+        self,
+        certs: typing.List[x509.Certificate],
+        encoding: serialization.Encoding,
+    ):
+        certs = list(certs)
+        if not certs or not all(
+            isinstance(cert, x509.Certificate) for cert in certs
+        ):
+            raise TypeError("certs must be a list of certs with length >= 1")
+
+        if encoding not in (
+            serialization.Encoding.PEM,
+            serialization.Encoding.DER,
+        ):
+            raise TypeError("encoding must DER or PEM from the Encoding enum")
+
+        certs_sk = self._lib.sk_X509_new_null()
+        certs_sk = self._ffi.gc(certs_sk, self._lib.sk_X509_free)
+        # This list is to keep the x509 values alive until end of function
+        ossl_certs = []
+        for cert in certs:
+            ossl_cert = self._cert2ossl(cert)
+            ossl_certs.append(ossl_cert)
+            res = self._lib.sk_X509_push(certs_sk, ossl_cert)
+            self.openssl_assert(res >= 1)
+        # We use PKCS7_sign here because it creates the PKCS7 and PKCS7_SIGNED
+        # structures for us rather than requiring manual assignment.
+        p7 = self._lib.PKCS7_sign(
+            self._ffi.NULL,
+            self._ffi.NULL,
+            certs_sk,
+            self._ffi.NULL,
+            self._lib.PKCS7_PARTIAL,
+        )
+        bio_out = self._create_mem_bio_gc()
+        if encoding is serialization.Encoding.PEM:
+            res = self._lib.PEM_write_bio_PKCS7_stream(
+                bio_out, p7, self._ffi.NULL, 0
+            )
+        else:
+            assert encoding is serialization.Encoding.DER
+            res = self._lib.i2d_PKCS7_bio(bio_out, p7)
+
+        self.openssl_assert(res == 1)
+        return self._read_mem_bio(bio_out)
+
     def pkcs7_sign(
         self,
         builder: pkcs7.PKCS7SignatureBuilder,
diff --git a/src/cryptography/hazmat/primitives/serialization/pkcs7.py b/src/cryptography/hazmat/primitives/serialization/pkcs7.py
--- a/src/cryptography/hazmat/primitives/serialization/pkcs7.py
+++ b/src/cryptography/hazmat/primitives/serialization/pkcs7.py
@@ -23,6 +23,15 @@ def load_der_pkcs7_certificates(data: bytes) -> typing.List[x509.Certificate]:
     return backend.load_der_pkcs7_certificates(data)
 
 
+def serialize_certificates(
+    certs: typing.List[x509.Certificate],
+    encoding: serialization.Encoding,
+) -> bytes:
+    from cryptography.hazmat.backends.openssl.backend import backend
+
+    return backend.pkcs7_serialize_certificates(certs, encoding)
+
+
 _ALLOWED_PKCS7_HASH_TYPES = typing.Union[
     hashes.SHA1,
     hashes.SHA224,


Test patch:
diff --git a/tests/hazmat/primitives/test_pkcs7.py b/tests/hazmat/primitives/test_pkcs7.py
--- a/tests/hazmat/primitives/test_pkcs7.py
+++ b/tests/hazmat/primitives/test_pkcs7.py
@@ -50,9 +50,16 @@ def test_load_pkcs7_pem(self, backend):
             x509.oid.NameOID.COMMON_NAME
         ) == [x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "ISRG Root X1")]
 
-    def test_load_pkcs7_der(self, backend):
-        certs = load_vectors_from_file(
+    @pytest.mark.parametrize(
+        "filepath",
+        [
+            os.path.join("pkcs7", "amazon-roots.der"),
             os.path.join("pkcs7", "amazon-roots.p7b"),
+        ],
+    )
+    def test_load_pkcs7_der(self, filepath, backend):
+        certs = load_vectors_from_file(
+            filepath,
             lambda derfile: pkcs7.load_der_pkcs7_certificates(derfile.read()),
             mode="rb",
         )
@@ -700,3 +707,78 @@ def test_add_multiple_additional_certs(self, backend):
         assert (
             sig.count(rsa_cert.public_bytes(serialization.Encoding.DER)) == 2
         )
+
+
+@pytest.mark.supported(
+    only_if=lambda backend: backend.pkcs7_supported(),
+    skip_message="Requires OpenSSL with PKCS7 support",
+)
+class TestPKCS7SerializeCerts:
+    @pytest.mark.parametrize(
+        ("encoding", "loader"),
+        [
+            (serialization.Encoding.PEM, pkcs7.load_pem_pkcs7_certificates),
+            (serialization.Encoding.DER, pkcs7.load_der_pkcs7_certificates),
+        ],
+    )
+    def test_roundtrip(self, encoding, loader, backend):
+        certs = load_vectors_from_file(
+            os.path.join("pkcs7", "amazon-roots.der"),
+            lambda derfile: pkcs7.load_der_pkcs7_certificates(derfile.read()),
+            mode="rb",
+        )
+        p7 = pkcs7.serialize_certificates(certs, encoding)
+        certs2 = loader(p7)
+        assert certs == certs2
+
+    def test_ordering(self, backend):
+        certs = load_vectors_from_file(
+            os.path.join("pkcs7", "amazon-roots.der"),
+            lambda derfile: pkcs7.load_der_pkcs7_certificates(derfile.read()),
+            mode="rb",
+        )
+        p7 = pkcs7.serialize_certificates(
+            list(reversed(certs)), serialization.Encoding.DER
+        )
+        certs2 = pkcs7.load_der_pkcs7_certificates(p7)
+        assert certs != certs2
+
+    def test_pem_matches_vector(self, backend):
+        p7_pem = load_vectors_from_file(
+            os.path.join("pkcs7", "isrg.pem"),
+            lambda p: p.read(),
+            mode="rb",
+        )
+        certs = pkcs7.load_pem_pkcs7_certificates(p7_pem)
+        p7 = pkcs7.serialize_certificates(certs, serialization.Encoding.PEM)
+        assert p7 == p7_pem
+
+    def test_der_matches_vector(self, backend):
+        p7_der = load_vectors_from_file(
+            os.path.join("pkcs7", "amazon-roots.der"),
+            lambda p: p.read(),
+            mode="rb",
+        )
+        certs = pkcs7.load_der_pkcs7_certificates(p7_der)
+        p7 = pkcs7.serialize_certificates(certs, serialization.Encoding.DER)
+        assert p7 == p7_der
+
+    def test_invalid_types(self):
+        certs = load_vectors_from_file(
+            os.path.join("pkcs7", "amazon-roots.der"),
+            lambda derfile: pkcs7.load_der_pkcs7_certificates(derfile.read()),
+            mode="rb",
+        )
+        with pytest.raises(TypeError):
+            pkcs7.serialize_certificates(
+                "not a list of certs",  # type: ignore[arg-type]
+                serialization.Encoding.PEM,
+            )
+
+        with pytest.raises(TypeError):
+            pkcs7.serialize_certificates([], serialization.Encoding.PEM)
+
+        with pytest.raises(TypeError):
+            pkcs7.serialize_certificates(
+                certs, "not an encoding"  # type: ignore[arg-type]
+            )