From ee0d8039a319127df7ede09913f92533fa548426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Tue, 7 May 2019 21:19:52 -0400 Subject: [PATCH] libs.crypto_utils: add functions To load an X.509 certificate from DER-encoded certificate data, and convert between DER-encoded and PEM-encoded certificate data. New functions: - `load_der_x509_cert` - `x509_cert_der_to_pem` - `x509_cert_pem_to_der` --- cl_sii/libs/crypto_utils.py | 106 +++++++++++++++++++++++- tests/test_libs_crypto_utils.py | 140 +++++++++++++++++++++++++++++--- 2 files changed, 233 insertions(+), 13 deletions(-) diff --git a/cl_sii/libs/crypto_utils.py b/cl_sii/libs/crypto_utils.py index 00452e1d..e9dee5b6 100644 --- a/cl_sii/libs/crypto_utils.py +++ b/cl_sii/libs/crypto_utils.py @@ -1,3 +1,43 @@ +""" +Crypto utils +============ + + +DER and PEM +----------- + +Best answer to the +`StackOverflow question `_ +"What are the differences between .pem, .cer and .der?". + +Best answer to the +`ServerFault question `_. +"What is a Pem file and how does it differ from other OpenSSL Generated Key File Formats?". + + +DER +-------- + +DER stands for "Distinguished Encoding Rules". + +> A way to encode ASN.1 syntax in binary. + +> The parent format of PEM. It's useful to think of it as a binary version +> of the base64-encoded PEM file. + +PEM +-------- + +PEM stands for "Privacy Enhanced Mail". + +> A failed method for secure email but the container format it used lives on, +> and is a base64 translation of the x509 ASN.1 keys. + +> In the case that it encodes a certificate it would simply contain the +> base64 encoding of the DER certificate [plus the header and footer]. + +""" +import base64 from typing import Union import cryptography.x509 @@ -6,10 +46,35 @@ from cryptography.x509 import Certificate as X509Cert from OpenSSL.crypto import X509 as _X509CertOpenSsl # noqa: F401 +from . import encoding_utils + + +def load_der_x509_cert(der_value: bytes) -> X509Cert: + """ + Load an X.509 certificate from DER-encoded certificate data. + + :raises TypeError: + :raises ValueError: + + """ + if not isinstance(der_value, bytes): + raise TypeError("Value must be bytes.") + + try: + x509_cert = cryptography.x509.load_der_x509_certificate( + data=der_value, + backend=_crypto_x509_backend) + except ValueError: + # e.g. + # "Unable to load certificate" + raise + + return x509_cert + def load_pem_x509_cert(pem_value: Union[str, bytes]) -> X509Cert: """ - Load an X.509 certificate from a PEM-formatted value. + Load an X.509 certificate from PEM-encoded certificate data. .. seealso:: https://cryptography.io/en/latest/faq/#why-can-t-i-import-my-pem-file @@ -39,6 +104,45 @@ def load_pem_x509_cert(pem_value: Union[str, bytes]) -> X509Cert: return x509_cert +def x509_cert_der_to_pem(der_value: bytes) -> bytes: + """ + Convert an X.509 certificate DER-encoded data to PEM-encoded. + + .. warning:: + It does not validate that ``der_value`` corresponds to an X.509 cert. + + :raises TypeError: + + """ + if not isinstance(der_value, bytes): + raise TypeError("Value must be bytes.") + + pem_value = base64.standard_b64encode(der_value) + mod_pem_value = add_pem_cert_header_footer(pem_value) + + return mod_pem_value.strip() + + +def x509_cert_pem_to_der(pem_value: bytes) -> bytes: + """ + Convert an X.509 certificate PEM-encoded data to DER-encoded. + + .. warning:: + It does not validate that ``pem_value`` corresponds to an X.509 cert. + + :raises TypeError: + :raises ValueError: + + """ + if not isinstance(pem_value, bytes): + raise TypeError("Value must be bytes.") + + mod_pem_value = remove_pem_cert_header_footer(pem_value) + der_value = encoding_utils.decode_base64_strict(mod_pem_value) + + return der_value.strip() + + def add_pem_cert_header_footer(pem_cert: bytes) -> bytes: """ Add certificate PEM header and footer (if not already present). diff --git a/tests/test_libs_crypto_utils.py b/tests/test_libs_crypto_utils.py index 4fe317f7..304e7b84 100644 --- a/tests/test_libs_crypto_utils.py +++ b/tests/test_libs_crypto_utils.py @@ -6,7 +6,9 @@ from cryptography.x509 import oid from cl_sii.libs.crypto_utils import ( # noqa: F401 - X509Cert, add_pem_cert_header_footer, load_pem_x509_cert, remove_pem_cert_header_footer, + X509Cert, add_pem_cert_header_footer, load_der_x509_cert, load_pem_x509_cert, + remove_pem_cert_header_footer, + x509_cert_der_to_pem, x509_cert_pem_to_der, ) from . import utils @@ -40,11 +42,11 @@ def test_remove_pem_cert_header_footer(self) -> None: class LoadPemX509CertTest(unittest.TestCase): - def test_load_pem_x509_cert_ok(self) -> None: - cert_pem_bytes = utils.read_test_file_bytes( - 'test_data/crypto/wildcard-google-com-cert.pem') + def test_load_der_x509_cert_ok(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.der') - x509_cert = load_pem_x509_cert(cert_pem_bytes) + x509_cert = load_der_x509_cert(cert_der_bytes) self.assertIsInstance(x509_cert, X509Cert) @@ -217,11 +219,11 @@ def test_load_pem_x509_cert_ok(self) -> None: self.assertIs(crl_distribution_points_ext.value._distribution_points[0].reasons, None) self.assertIs(crl_distribution_points_ext.value._distribution_points[0].relative_name, None) - def test_load_pem_x509_cert_ok_cert_real_dte(self) -> None: - cert_pem_bytes = utils.read_test_file_bytes( - 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem') + def test_load_der_x509_cert_ok_cert_real_dte(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') - x509_cert = load_pem_x509_cert(cert_pem_bytes) + x509_cert = load_der_x509_cert(cert_der_bytes) self.assertIsInstance(x509_cert, X509Cert) @@ -439,10 +441,11 @@ def test_load_pem_x509_cert_ok_cert_real_dte(self) -> None: self.assertEqual(some_microsoft_ext.critical, False) self.assertTrue(isinstance(some_microsoft_ext.value.value, bytes)) - def test_load_pem_x509_cert_ok_prueba_sii(self) -> None: - cert_pem_bytes = utils.read_test_file_bytes('test_data/sii-crypto/prueba-sii-cert.pem') + def test_load_der_x509_cert_ok_prueba_sii(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.der') - x509_cert = load_pem_x509_cert(cert_pem_bytes) + x509_cert = load_der_x509_cert(cert_der_bytes) self.assertIsInstance(x509_cert, X509Cert) @@ -615,6 +618,54 @@ def test_load_pem_x509_cert_ok_prueba_sii(self) -> None: self.assertIs(crl_distribution_points_ext.value._distribution_points[0].reasons, None) self.assertIs(crl_distribution_points_ext.value._distribution_points[0].relative_name, None) + def test_load_der_x509_cert_fail_type_error(self) -> None: + with self.assertRaises(TypeError) as cm: + load_der_x509_cert(1) + self.assertEqual(cm.exception.args, ("Value must be bytes.", )) + + def test_load_der_x509_cert_fail_value_error(self) -> None: + with self.assertRaises(ValueError) as cm: + load_der_x509_cert(b'hello') + self.assertEqual( + cm.exception.args, + ("Unable to load certificate", )) + + def test_load_pem_x509_cert_ok(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.pem') + + x509_cert_from_der = load_der_x509_cert(cert_der_bytes) + x509_cert_from_pem = load_pem_x509_cert(cert_pem_bytes) + + self.assertIsInstance(x509_cert_from_pem, X509Cert) + self.assertEqual(x509_cert_from_der, x509_cert_from_pem) + + def test_load_pem_x509_cert_ok_cert_real_dte(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem') + + x509_cert_from_der = load_der_x509_cert(cert_der_bytes) + x509_cert_from_pem = load_pem_x509_cert(cert_pem_bytes) + + self.assertIsInstance(x509_cert_from_pem, X509Cert) + self.assertEqual(x509_cert_from_der, x509_cert_from_pem) + + def test_load_pem_x509_cert_ok_prueba_sii(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.pem') + + x509_cert_from_der = load_der_x509_cert(cert_der_bytes) + x509_cert_from_pem = load_pem_x509_cert(cert_pem_bytes) + + self.assertIsInstance(x509_cert_from_pem, X509Cert) + self.assertEqual(x509_cert_from_der, x509_cert_from_pem) + def test_load_pem_x509_cert_ok_str_ascii(self) -> None: cert_pem_str_ascii = utils.read_test_file_str_ascii( 'test_data/crypto/wildcard-google-com-cert.pem') @@ -642,3 +693,68 @@ def test_load_pem_x509_cert_fail_value_error(self) -> None: ("Unable to load certificate. See " "https://cryptography.io/en/latest/faq/#why-can-t-i-import-my-pem-file " "for more details.", )) + + def test_x509_cert_der_to_pem_pem_to_der_ok_1(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.pem') + + # note: we test the function with a double call because the input PEM data + # may have different line lengths and different line separators. + self.assertEqual( + x509_cert_pem_to_der(x509_cert_der_to_pem(cert_der_bytes)), + x509_cert_pem_to_der(cert_pem_bytes)) + + def test_x509_cert_der_to_pem_pem_to_der_ok_2(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem') + + # note: we test the function with a double call because the input PEM data + # may have different line lengths and different line separators. + self.assertEqual( + x509_cert_pem_to_der(x509_cert_der_to_pem(cert_der_bytes)), + x509_cert_pem_to_der(cert_pem_bytes)) + + def test_x509_cert_der_to_pem_pem_to_der_ok_3(self) -> None: + cert_der_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.der') + cert_pem_bytes = utils.read_test_file_bytes( + 'test_data/sii-crypto/prueba-sii-cert.pem') + + # note: we test the function with a double call because the input PEM data + # may have different line lengths and different line separators. + self.assertEqual( + x509_cert_pem_to_der(x509_cert_der_to_pem(cert_der_bytes)), + x509_cert_pem_to_der(cert_pem_bytes)) + + def test_x509_cert_der_to_pem_type_error(self) -> None: + with self.assertRaises(TypeError) as cm: + x509_cert_der_to_pem(1) + self.assertEqual(cm.exception.args, ("Value must be bytes.", )) + + def test_x509_cert_pem_to_der_type_error(self) -> None: + with self.assertRaises(TypeError) as cm: + x509_cert_pem_to_der(1) + self.assertEqual(cm.exception.args, ("Value must be bytes.", )) + + def test_x509_cert_pem_to_der_valuetype_error(self) -> None: + with self.assertRaises(ValueError) as cm: + x509_cert_pem_to_der(b'hello') + self.assertEqual( + cm.exception.args, + ( + "Input is not a valid base64 value.", + "Invalid base64-encoded string: number of data characters (5) cannot be 1 more " + "than a multiple of 4", + )) + + def test_add_pem_cert_header_footer(self) -> None: + # TODO: implement for 'add_pem_cert_header_footer' + pass + + def test_remove_pem_cert_header_footer(self) -> None: + # TODO: implement for 'remove_pem_cert_header_footer' + pass