From 00ca181741831700e3047f1d4c0528501b17b215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Tue, 28 May 2019 19:14:17 -0400 Subject: [PATCH 1/8] libs: add module `dataclass_utils` Include function and mixin for dataclass instances "deep comparison". --- cl_sii/libs/dataclass_utils.py | 106 +++++++++++++++++++ tests/test_libs_dataclass_utils.py | 164 +++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+) create mode 100644 cl_sii/libs/dataclass_utils.py create mode 100644 tests/test_libs_dataclass_utils.py diff --git a/cl_sii/libs/dataclass_utils.py b/cl_sii/libs/dataclass_utils.py new file mode 100644 index 00000000..4465da3c --- /dev/null +++ b/cl_sii/libs/dataclass_utils.py @@ -0,0 +1,106 @@ +""" +Dataclass utils +=============== + +Utils for std lib's :class:`dataclasses.Dataclass` classes and instances. + +""" +import dataclasses +import enum + + +@enum.unique +class DcDeepComparison(enum.IntEnum): + + """ + The possible results of a "deep comparison" between 2 dataclass instances. + + .. warning:: The type of both instances must be the same. + + For dataclass instances ``instance_1`` and ``instance_2``, the + enum member name should be interpreted as: + ``instance_1`` is ``enum_member_name`` of|to|with ``instance_2`` + e.g. ``instance_1`` is subset of ``instance_2``. + + .. note:: The enum members values are arbitrary. + + """ + + EQUAL = 0 + """ + For each dataclass attribute A and B have the same value. + """ + + SUBSET = 11 + """ + A and B are not equal, and A's value for each dataclass attribute whose + value is not None is equal to B's value for the same attribute. + """ + + SUPERSET = 12 + """ + A and B are not equal, and B's value for each dataclass attribute whose + value is not None is equal to A's value for the same attribute. + """ + + CONFLICTED = -1 + """ + For one or more dataclass attributes A and B have a different value. + """ + + +class DcDeepCompareMixin: + + """ + Mixin for dataclass instances "deep comparison". + """ + + def deep_compare_to(self, value: object) -> DcDeepComparison: + """ + Return result of a "deep comparison" against another dataclass instance. + """ + # note: 'is_dataclass' returns True if obj is a dataclass or an instance of a dataclass. + if not dataclasses.is_dataclass(self): + # TODO: would it be possible to run this check when the **class** is created? + raise Exception( + "Programming error. Only dataclasses may subclass 'DcDeepCompareMixin'.") + # note: 'is_dataclass' returns True if obj is a dataclass or an instance of a dataclass. + if not dataclasses.is_dataclass(value): + raise TypeError("Value must be a dataclass instance.") + + return _dc_deep_compare_to(self, value) + + +def dc_deep_compare(value_a: object, value_b: object) -> DcDeepComparison: + """ + Return result of a "deep comparison" between dataclass instances. + """ + # note: 'is_dataclass' returns True if obj is a dataclass or an instance of a dataclass. + if not dataclasses.is_dataclass(value_a) or not dataclasses.is_dataclass(value_b): + raise TypeError("Values must be dataclass instances.") + + return _dc_deep_compare_to(value_a, value_b) + + +def _dc_deep_compare_to(value_a: object, value_b: object) -> DcDeepComparison: + + if type(value_a) != type(value_b): + raise TypeError("Values to be compared must be of the same type.") + + if value_a == value_b: + return DcDeepComparison.EQUAL + + # Remove dataclass attributes whose value is None. + self_dict_clean = {k: v for k, v in dataclasses.asdict(value_a).items() if v is not None} + value_dict_clean = {k: v for k, v in dataclasses.asdict(value_b).items() if v is not None} + + if len(self_dict_clean) < len(value_dict_clean): + for k, v in self_dict_clean.items(): + if v != value_dict_clean[k]: + return DcDeepComparison.CONFLICTED + return DcDeepComparison.SUBSET + else: + for k, v in value_dict_clean.items(): + if v != self_dict_clean[k]: + return DcDeepComparison.CONFLICTED + return DcDeepComparison.SUPERSET diff --git a/tests/test_libs_dataclass_utils.py b/tests/test_libs_dataclass_utils.py new file mode 100644 index 00000000..77b622c9 --- /dev/null +++ b/tests/test_libs_dataclass_utils.py @@ -0,0 +1,164 @@ +import dataclasses +import unittest +from typing import Dict + +from cl_sii.libs.dataclass_utils import ( + DcDeepCompareMixin, DcDeepComparison, dc_deep_compare, _dc_deep_compare_to, +) + + +@dataclasses.dataclass +class DataclassA: + + field_1: int = dataclasses.field() + + +@dataclasses.dataclass +class DataclassBWithoutMixin: + + field_1: object = dataclasses.field() + field_2: Dict[str, object] = dataclasses.field() + field_3: tuple = dataclasses.field() + field_4: DataclassA = dataclasses.field() + field_5: str = dataclasses.field(default=None) + field_6: int = dataclasses.field(default=-1) + + +@dataclasses.dataclass +class DataclassBWithMixin(DataclassBWithoutMixin, DcDeepCompareMixin): + + pass + + +class NotADataclassWithMixin(DcDeepCompareMixin): + + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + +class DcDeepCompareTest(unittest.TestCase): + + def test__dc_deep_compare_ok(self) -> None: + value_a_1 = DataclassA(field_1=23) + value_a_2 = DataclassA(field_1='break typing hint, nobody cares') + + value_b_1 = DataclassBWithoutMixin( + field_1=-56, field_2=dict(x=True), field_3=('hello', None, True), field_4=value_a_1) + value_b_2 = DataclassBWithoutMixin( + field_1=-56, field_2=dict(a='b'), field_3=('hello', None, True), field_4=value_a_1) + value_b_3 = dataclasses.replace(value_b_1, field_5='some str') + value_b_4 = dataclasses.replace(value_b_2, field_5='non-default value') + value_b_5 = dataclasses.replace(value_b_2, field_4=value_a_2) + + self.assertEqual(_dc_deep_compare_to(value_b_1, value_b_1), DcDeepComparison.EQUAL) + self.assertEqual(_dc_deep_compare_to(value_b_1, value_b_2), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_1, value_b_3), DcDeepComparison.SUBSET) + self.assertEqual(_dc_deep_compare_to(value_b_1, value_b_4), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_1, value_b_5), DcDeepComparison.CONFLICTED) + + self.assertEqual(_dc_deep_compare_to(value_b_2, value_b_1), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_2, value_b_2), DcDeepComparison.EQUAL) + self.assertEqual(_dc_deep_compare_to(value_b_2, value_b_3), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_2, value_b_4), DcDeepComparison.SUBSET) + self.assertEqual(_dc_deep_compare_to(value_b_2, value_b_5), DcDeepComparison.CONFLICTED) + + self.assertEqual(_dc_deep_compare_to(value_b_3, value_b_1), DcDeepComparison.SUPERSET) + self.assertEqual(_dc_deep_compare_to(value_b_3, value_b_2), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_3, value_b_3), DcDeepComparison.EQUAL) + self.assertEqual(_dc_deep_compare_to(value_b_3, value_b_4), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_3, value_b_5), DcDeepComparison.CONFLICTED) + + self.assertEqual(_dc_deep_compare_to(value_b_4, value_b_1), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_4, value_b_2), DcDeepComparison.SUPERSET) + self.assertEqual(_dc_deep_compare_to(value_b_4, value_b_3), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_4, value_b_4), DcDeepComparison.EQUAL) + self.assertEqual(_dc_deep_compare_to(value_b_4, value_b_5), DcDeepComparison.CONFLICTED) + + self.assertEqual(_dc_deep_compare_to(value_b_5, value_b_1), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_5, value_b_2), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_5, value_b_3), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_5, value_b_4), DcDeepComparison.CONFLICTED) + self.assertEqual(_dc_deep_compare_to(value_b_5, value_b_5), DcDeepComparison.EQUAL) + + def test_func_ok(self) -> None: + self.assertEqual( + dc_deep_compare( + DataclassA(field_1=23), + DataclassA(field_1=23)), + DcDeepComparison.EQUAL) + self.assertEqual( + dc_deep_compare( + DataclassA(field_1=23), + DataclassA(field_1='break typing hint, nobody cares')), + DcDeepComparison.CONFLICTED) + + def test_mixin_ok(self) -> None: + value_a = DataclassBWithMixin( + field_1=-56, field_2=dict(a='b'), field_3=('hello', None, True), + field_4=DataclassA(field_1=23)) + value_b = dataclasses.replace(value_a, field_5='some str') + self.assertEqual( + value_a.deep_compare_to(value_b), + DcDeepComparison.SUBSET) + self.assertEqual( + value_b.deep_compare_to(value_a), + DcDeepComparison.SUPERSET) + + def test__dc_deep_compare_type_mismatch(self) -> None: + value_a = DataclassA(field_1=23) + value_b = DataclassBWithoutMixin( + field_1=-56, field_2=dict(a='b'), field_3=('hello', None, True), field_4=value_a) + + with self.assertRaises(TypeError) as cm: + _dc_deep_compare_to(value_b, value_a) + self.assertEqual(cm.exception.args, ("Values to be compared must be of the same type.", )) + with self.assertRaises(TypeError) as cm: + _dc_deep_compare_to(value_a, value_b) + self.assertEqual(cm.exception.args, ("Values to be compared must be of the same type.", )) + + def test_func_not_a_dataclass(self) -> None: + dc_value_a = DataclassA(field_1=23) + dc_value_b = DataclassBWithoutMixin( + field_1=-56, field_2=dict(a='b'), field_3=('hello', None, True), field_4=dc_value_a) + + with self.assertRaises(Exception) as cm: + dc_deep_compare(dict(), 123) + self.assertEqual(cm.exception.args, ("Values must be dataclass instances.", )) + + with self.assertRaises(TypeError) as cm: + dc_deep_compare(dc_value_a, None) + self.assertEqual(cm.exception.args, ("Values must be dataclass instances.", )) + with self.assertRaises(TypeError) as cm: + dc_deep_compare(dc_value_b, None) + self.assertEqual(cm.exception.args, ("Values must be dataclass instances.", )) + + with self.assertRaises(TypeError) as cm: + dc_deep_compare(dc_value_a, ('abc', 56, [])) + self.assertEqual(cm.exception.args, ("Values must be dataclass instances.", )) + with self.assertRaises(TypeError) as cm: + dc_deep_compare(dc_value_b, ('abc', 56, [])) + self.assertEqual(cm.exception.args, ("Values must be dataclass instances.", )) + + def test_mixin_self_not_a_dataclass(self) -> None: + value_1 = NotADataclassWithMixin(field_1='kjdhsf') + value_2 = NotADataclassWithMixin(field_1=None) + + with self.assertRaises(Exception) as cm: + value_1.deep_compare_to(value_2) + self.assertEqual( + cm.exception.args, + ("Programming error. Only dataclasses may subclass 'DcDeepCompareMixin'.", )) + + def test_mixin_value_not_a_dataclass(self) -> None: + value_a = DataclassA(field_1=23) + value_b = DataclassBWithMixin( + field_1=-56, field_2=dict(a='b'), field_3=('hello', None, True), field_4=value_a) + + with self.assertRaises(TypeError) as cm: + value_b.deep_compare_to(None) + self.assertEqual(cm.exception.args, ("Value must be a dataclass instance.", )) + + with self.assertRaises(TypeError) as cm: + value_b.deep_compare_to(('abc', 56, [])) + self.assertEqual(cm.exception.args, ("Value must be a dataclass instance.", )) From 63ddad4e4310e5d2a58abfdc913c092c8646225e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 29 May 2019 10:08:03 -0400 Subject: [PATCH 2/8] requirements: include 'extras' in 'test' --- requirements/test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements/test.txt b/requirements/test.txt index 46cecc12..dd072e18 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -1,5 +1,6 @@ # note: it is mandatory to register all dependencies of the required packages. -r base.txt +-r extras.txt # Required packages: codecov==2.0.15 From 94fee2c75ddcce23e57a0250dfcef4ada9cf0146 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 29 May 2019 10:10:07 -0400 Subject: [PATCH 3/8] config: update CircleCI following 63ddad4e --- .circleci/config.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4464db20..de5284db 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -28,7 +28,6 @@ jobs: pip install --upgrade pip pip install --upgrade setuptools wheel pip install -r requirements/test.txt - pip install -r requirements/extras.txt - run: name: run tests From 5529ff0b6e132a3d7bd81f53a3a38bffc4ab4673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 29 May 2019 10:12:07 -0400 Subject: [PATCH 4/8] config: update tox following 63ddad4e --- tox.ini | 1 - 1 file changed, 1 deletion(-) diff --git a/tox.ini b/tox.ini index f6e6abb1..e2329362 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,5 @@ setenv = commands = coverage run --rcfile=setup.cfg setup.py test deps = -r{toxinidir}/requirements/test.txt - -r{toxinidir}/requirements/extras.txt basepython = py37: python3.7 From d813799f3ca43a0d541310ba72c852d6113b4c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 10 Apr 2019 23:46:35 -0400 Subject: [PATCH 5/8] libs.xml_utils: add `verify_xml_signature` --- cl_sii/libs/xml_utils.py | 138 +++++++++++++++++++++- tests/test_data/xml/trivial-doc.xml | 24 ++++ tests/test_libs_xml_utils.py | 173 +++++++++++++++++++++++++++- 3 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 tests/test_data/xml/trivial-doc.xml diff --git a/cl_sii/libs/xml_utils.py b/cl_sii/libs/xml_utils.py index 6ed10205..606a62ac 100644 --- a/cl_sii/libs/xml_utils.py +++ b/cl_sii/libs/xml_utils.py @@ -19,13 +19,16 @@ """ +import io import logging import os -from typing import IO +from typing import IO, Tuple, Union import defusedxml import defusedxml.lxml import lxml.etree +import signxml +import signxml.exceptions import xml.parsers.expat import xml.parsers.expat.errors from lxml.etree import ElementBase as XmlElement # noqa: F401 @@ -33,6 +36,8 @@ from lxml.etree import _ElementTree as XmlElementTree # noqa: F401 from lxml.etree import XMLSchema as XmlSchema # noqa: F401 +from . import crypto_utils + logger = logging.getLogger(__name__) @@ -111,6 +116,29 @@ class XmlSchemaDocValidationError(Exception): """ +class XmlSignatureInvalid(Exception): + + """ + XML signature is invalid, for any reason. + """ + + +class XmlSignatureUnverified(XmlSignatureInvalid): + + """ + XML signature verification (i.e. digest validation) failed. + + This means the signature is not to be trusted. + """ + + +class XmlSignatureInvalidCertificate(XmlSignatureInvalid): + + """ + Certificate validation failed on XML signature processing. + """ + + ############################################################################### # functions ############################################################################### @@ -323,3 +351,111 @@ def write_xml_doc(xml_doc: XmlElement, output: IO[bytes]) -> None: # default: True. with_tail=True, ) + + +def verify_xml_signature( + xml_doc: XmlElement, + trusted_x509_cert: Union[crypto_utils.X509Cert, crypto_utils._X509CertOpenSsl] = None, +) -> Tuple[bytes, XmlElementTree, XmlElementTree]: + """ + Verify the XML signature in ``xml_doc``. + + .. note:: + XML document with more than one signature is not supported. + + If the inputs are ok but the XML signature does not verify, + raises :class:`XmlSignatureUnverified`. + + If ``trusted_x509_cert`` is None, it requires that the signature in + ``xml_doc`` includes a a valid X.509 **certificate chain** that + validates against the *known certificate authorities*. + + If ``trusted_x509_cert`` is given, it must be a **trusted** external + X.509 certificate, and the verification will be of whether the XML + signature in ``xml_doc`` was signed by ``trusted_x509_cert`` or not; + thus **it overrides** any X.509 certificate information included + in the signature. + + .. note:: + It is strongly recommended to validate ``xml_doc`` beforehand + (against the corresponding XML schema, using :func:`validate_xml_doc`). + + :param xml_doc: + :param trusted_x509_cert: a trusted external X.509 certificate, or None + :raises :class:`XmlSignatureInvalidCertificate`: + certificate validation failed + :raises :class:`XmlSignatureInvalid`: + signature is invalid + :raises :class:`XmlSchemaDocValidationError`: + XML doc is not valid + :raises :class:`ValueError`: + + """ + if not isinstance(xml_doc, XmlElement): + raise TypeError("'xml_doc' must be an XML document/element.") + + n_signatures = ( + len(xml_doc.findall('.//ds:Signature', namespaces=XML_DSIG_NS_MAP)) + + len(xml_doc.findall('.//dsig11:Signature', namespaces=XML_DSIG_NS_MAP)) + + len(xml_doc.findall('.//dsig2:Signature', namespaces=XML_DSIG_NS_MAP))) + + if n_signatures > 1: + raise NotImplementedError("XML document with more than one signature is not supported.") + + xml_verifier = signxml.XMLVerifier() + + if isinstance(trusted_x509_cert, crypto_utils._X509CertOpenSsl): + trusted_x509_cert_open_ssl = trusted_x509_cert + elif isinstance(trusted_x509_cert, crypto_utils.X509Cert): + trusted_x509_cert_open_ssl = crypto_utils._X509CertOpenSsl.from_cryptography( + trusted_x509_cert) + elif trusted_x509_cert is None: + trusted_x509_cert_open_ssl = None + else: + # A 'crypto_utils._X509CertOpenSsl' is ok but we prefer 'crypto_utils.X509Cert'. + raise TypeError("'trusted_x509_cert' must be a 'crypto_utils.X509Cert' instance, or None.") + + # warning: performance issue. + # note: 'signxml.XMLVerifier.verify()' calls 'signxml.util.XMLProcessor.get_root()', + # which converts the data to string, and then reparses it using the same function we use + # in 'parse_untrusted_xml()' ('defusedxml.lxml.fromstring'), but without all the precautions + # we have there. See: + # https://github.com/XML-Security/signxml/blob/v2.6.0/signxml/util/__init__.py#L141-L151 + # Considering that, we'd rather write to bytes ourselves and control the process. + f = io.BytesIO() + write_xml_doc(xml_doc, f) + tmp_bytes = f.getvalue() + + try: + # note: by passing 'x509_cert' we override any X.509 certificate information supplied + # by the signature itself. + result: signxml.VerifyResult = xml_verifier.verify( + data=tmp_bytes, require_x509=True, x509_cert=trusted_x509_cert_open_ssl) + + except signxml.exceptions.InvalidDigest as exc: + # warning: catch before 'InvalidSignature' (it is the parent of 'InvalidDigest'). + raise XmlSignatureUnverified(str(exc)) from exc + + except signxml.exceptions.InvalidCertificate as exc: + # warning: catch before 'InvalidSignature' (it is the parent of 'InvalidCertificate'). + raise XmlSignatureInvalidCertificate(str(exc)) from exc + + except signxml.exceptions.InvalidSignature as exc: + logger.exception( + "Unexpected exception (it should have been an instance of subclass of " + "'InvalidSignature'). Error: %s", + str(exc)) + raise XmlSignatureInvalid(str(exc)) from exc + + except signxml.exceptions.InvalidInput as exc: + raise ValueError("Invalid input.", str(exc)) from exc + + except lxml.etree.DocumentInvalid as exc: + # Simplest and safest way to get the error message (see 'validate_xml_doc()'). + # Error example: + # "Element '{http://www.w3.org/2000/09/xmldsig#}X509Certificate': '\nabc\n' is not a + # valid value of the atomic type 'xs:base64Binary'., line 30" + validation_error_msg = str(exc) + raise XmlSchemaDocValidationError(validation_error_msg) from exc + + return result.signed_data, result.signed_xml, result.signature_xml diff --git a/tests/test_data/xml/trivial-doc.xml b/tests/test_data/xml/trivial-doc.xml new file mode 100644 index 00000000..3de7f3fe --- /dev/null +++ b/tests/test_data/xml/trivial-doc.xml @@ -0,0 +1,24 @@ + + + + + 1 + 2008 + 141100 + + + + + 4 + 2011 + 59900 + + + + 68 + 2011 + 13600 + + + + diff --git a/tests/test_libs_xml_utils.py b/tests/test_libs_xml_utils.py index 33ec3037..d190f803 100644 --- a/tests/test_libs_xml_utils.py +++ b/tests/test_libs_xml_utils.py @@ -1,11 +1,15 @@ +import io import unittest import lxml.etree +from cl_sii.libs.crypto_utils import load_pem_x509_cert + from cl_sii.libs.xml_utils import XmlElement from cl_sii.libs.xml_utils import ( # noqa: F401 - XmlSyntaxError, XmlFeatureForbidden, - parse_untrusted_xml, read_xml_schema, validate_xml_doc, write_xml_doc, + XmlSyntaxError, XmlFeatureForbidden, XmlSchemaDocValidationError, + XmlSignatureInvalid, XmlSignatureInvalidCertificate, XmlSignatureUnverified, + parse_untrusted_xml, read_xml_schema, validate_xml_doc, verify_xml_signature, write_xml_doc, ) from .utils import read_test_file_bytes @@ -106,3 +110,168 @@ class FunctionWriteXmlDocTest(unittest.TestCase): # TODO: implement for function 'write_xml_doc'. Consider each of the "observations". pass + + +class FunctionVerifyXmlSignatureTest(unittest.TestCase): + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + + cls.any_x509_cert_pem_file = read_test_file_bytes( + 'test_data/crypto/wildcard-google-com-cert.pem') + + cls.xml_doc_cert_pem_bytes = read_test_file_bytes( + 'test_data/sii-crypto/DTE--76354771-K--33--170-cert.pem') + + cls.with_valid_signature = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned.xml') + cls.with_valid_signature_signed_data = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned-signed_data.xml') + cls.with_valid_signature_signed_xml = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned-signed_xml.xml') + cls.with_valid_signature_signature_xml = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned-signature_xml.xml') + + cls.trivial_without_signature = read_test_file_bytes( + 'test_data/xml/trivial-doc.xml') + cls.with_too_many_signatures = read_test_file_bytes( + 'test_data/sii-rtc/AEC--76354771-K--33--170--SEQ-2.xml') + cls.without_signature = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned-mod-removed-signature.xml') + cls.with_bad_cert = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned-mod-bad-cert.xml') + cls.with_bad_cert_no_base64 = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned-mod-bad-cert-no-base64.xml') + cls.with_signature_and_modified = read_test_file_bytes( + 'test_data/sii-dte/DTE--76354771-K--33--170--cleaned-mod-changed-monto.xml') + + def test_ok_external_trusted_cert(self) -> None: + xml_doc = parse_untrusted_xml(self.with_valid_signature) + cert = load_pem_x509_cert(self.xml_doc_cert_pem_bytes) + + signed_data, signed_xml, signature_xml = verify_xml_signature( + xml_doc, trusted_x509_cert=cert) + + self.assertEqual(signed_data, self.with_valid_signature_signed_data) + + f = io.BytesIO() + write_xml_doc(signed_xml, f) + signed_xml_bytes = f.getvalue() + self.assertEqual(signed_xml_bytes, self.with_valid_signature_signed_xml) + + f = io.BytesIO() + write_xml_doc(signature_xml, f) + signature_xml_bytes = f.getvalue() + self.assertEqual(signature_xml_bytes, self.with_valid_signature_signature_xml) + + def test_ok_cert_in_signature(self) -> None: + # TODO: implement! + + # xml_doc = parse_untrusted_xml(...) + # verify_xml_signature(xml_doc, trusted_x509_cert=None) + pass + + def test_fail_cert_type_error(self) -> None: + xml_doc = parse_untrusted_xml(self.with_valid_signature) + cert = self.any_x509_cert_pem_file + + with self.assertRaises(TypeError) as cm: + _ = verify_xml_signature(xml_doc, trusted_x509_cert=cert) + self.assertEqual( + cm.exception.args, + ("'trusted_x509_cert' must be a 'crypto_utils.X509Cert' instance, or None.", )) + + def test_fail_xml_doc_type_error(self) -> None: + cert = self.any_x509_cert_pem_file + + with self.assertRaises(TypeError) as cm: + _ = verify_xml_signature(xml_doc=object(), trusted_x509_cert=cert) + self.assertEqual( + cm.exception.args, + ("'xml_doc' must be an XML document/element.", )) + + def test_bad_cert_included(self) -> None: + # If the included certificate is bad, it does not matter, as long as it does not break XML. + xml_doc_with_bad_cert = parse_untrusted_xml(self.with_bad_cert) + xml_doc_with_bad_cert_no_base64 = parse_untrusted_xml(self.with_bad_cert_no_base64) + + cert = load_pem_x509_cert(self.xml_doc_cert_pem_bytes) + + verify_xml_signature(xml_doc_with_bad_cert, trusted_x509_cert=cert) + + with self.assertRaises(XmlSchemaDocValidationError) as cm: + verify_xml_signature(xml_doc_with_bad_cert_no_base64, trusted_x509_cert=cert) + self.assertEqual( + cm.exception.args, + ("Element '{http://www.w3.org/2000/09/xmldsig#}X509Certificate': '\nabc\n" + "' is not a valid value of the atomic type 'xs:base64Binary'., line 30", )) + + def test_fail_included_cert_not_from_a_known_ca(self) -> None: + xml_doc = parse_untrusted_xml(self.with_valid_signature) + + # Without cert: fails because the issuer of the cert in the signature is not a known CA. + with self.assertRaises(XmlSignatureInvalidCertificate) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=None) + self.assertEqual( + cm.exception.args, + ("[20, 0, 'unable to get local issuer certificate']", )) + + def test_fail_signed_data_modified(self) -> None: + xml_doc = parse_untrusted_xml(self.with_signature_and_modified) + cert = load_pem_x509_cert(self.xml_doc_cert_pem_bytes) + + with self.assertRaises(XmlSignatureUnverified) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=cert) + self.assertEqual(cm.exception.args, ("Digest mismatch for reference 0", )) + + def test_xml_doc_without_signature_1(self) -> None: + xml_doc = parse_untrusted_xml(self.without_signature) + + expected_exc_args = ( + 'Invalid input.', + 'Expected to find XML element Signature in {http://www.sii.cl/SiiDte}DTE') + + # Without cert: + with self.assertRaises(ValueError) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=None) + self.assertEqual(cm.exception.args, expected_exc_args) + + # With cert: + cert = load_pem_x509_cert(self.any_x509_cert_pem_file) + with self.assertRaises(ValueError) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=cert) + self.assertEqual(cm.exception.args, expected_exc_args) + + def test_fail_xml_doc_without_signature_2(self) -> None: + xml_doc = parse_untrusted_xml(self.trivial_without_signature) + + expected_exc_args = ( + 'Invalid input.', 'Expected to find XML element Signature in data') + + # Without cert: + with self.assertRaises(ValueError) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=None) + self.assertEqual(cm.exception.args, expected_exc_args) + + # With cert: + cert = load_pem_x509_cert(self.xml_doc_cert_pem_bytes) + with self.assertRaises(ValueError) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=cert) + self.assertEqual(cm.exception.args, expected_exc_args) + + def test_fail_xml_doc_with_too_many_signatures(self) -> None: + xml_doc = parse_untrusted_xml(self.with_too_many_signatures) + + expected_exc_args = ("XML document with more than one signature is not supported.", ) + + # Without cert: + with self.assertRaises(NotImplementedError) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=None) + self.assertEqual(cm.exception.args, expected_exc_args) + + # With cert: + cert = load_pem_x509_cert(self.xml_doc_cert_pem_bytes) + with self.assertRaises(NotImplementedError) as cm: + verify_xml_signature(xml_doc, trusted_x509_cert=cert) + self.assertEqual(cm.exception.args, expected_exc_args) From 449dd15a9c3acae8d70656675ce84e5c64cff852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 29 May 2019 11:07:53 -0400 Subject: [PATCH 6/8] requirements: update all (release) --- requirements/release.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements/release.txt b/requirements/release.txt index db36ef42..ea58d67a 100644 --- a/requirements/release.txt +++ b/requirements/release.txt @@ -6,7 +6,7 @@ bumpversion==0.5.3 setuptools==41.0.1 twine==1.13.0 -wheel==0.33.1 +wheel==0.33.4 # Packages dependencies: # - twine: @@ -18,6 +18,6 @@ wheel==0.33.1 # - tqdm pkginfo==1.5.0.1 readme-renderer==24.0 -requests==2.21.0 +requests==2.22.0 requests-toolbelt==0.9.1 -tqdm==4.31.1 +tqdm==4.32.1 From 65120e4434aab349dc5dec13223ecc340bdfe8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 29 May 2019 11:01:59 -0400 Subject: [PATCH 7/8] HISTORY: update for new version --- HISTORY.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index c4b30a84..0cdb2e16 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,12 @@ History ------- +0.6.4 (2019-05-29) ++++++++++++++++++++++++ + +* (PR #55, 2019-05-29) libs.xml_utils: add ``verify_xml_signature`` +* (PR #54, 2019-05-28) libs: add module ``dataclass_utils`` + 0.6.3 (2019-05-24) +++++++++++++++++++++++ @@ -32,7 +38,7 @@ Includes backwards-incompatible changes to data model ``DteDataL2``. * (PR #37, 2019-05-08) dte.data_models: alter field ``DteDataL2.firma_documento_dt_naive`` * (PR #36, 2019-05-08) libs.crypto_utils: add functions * (PR #35, 2019-05-07) libs.tz_utils: minor improvements -* (PR #34, 2019-05-06) docs: Fix `bumpversion` command +* (PR #34, 2019-05-06) docs: Fix ``bumpversion`` command 0.5.1 (2019-05-03) +++++++++++++++++++++++ From 2575cdcbd63d0a0bdbb338c9b8a22c34fdcc605c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Larra=C3=ADn?= Date: Wed, 29 May 2019 11:09:32 -0400 Subject: [PATCH 8/8] =?UTF-8?q?Bump=20version:=200.6.3=20=E2=86=92=200.6.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- cl_sii/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index cae36e13..c19bf5f4 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.6.3 +current_version = 0.6.4 commit = True tag = True diff --git a/cl_sii/__init__.py b/cl_sii/__init__.py index 6a73fc58..d899894a 100644 --- a/cl_sii/__init__.py +++ b/cl_sii/__init__.py @@ -5,4 +5,4 @@ """ -__version__ = '0.6.3' +__version__ = '0.6.4'