Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .bumpversion.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpversion]
current_version = 0.6.3
current_version = 0.6.4
commit = True
tag = True

Expand Down
1 change: 0 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
+++++++++++++++++++++++

Expand Down Expand Up @@ -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)
+++++++++++++++++++++++
Expand Down
2 changes: 1 addition & 1 deletion cl_sii/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@
"""


__version__ = '0.6.3'
__version__ = '0.6.4'
106 changes: 106 additions & 0 deletions cl_sii/libs/dataclass_utils.py
Original file line number Diff line number Diff line change
@@ -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
138 changes: 137 additions & 1 deletion cl_sii/libs/xml_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,25 @@


"""
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
# note: 'lxml.etree.ElementTree' is a **function**, not a class.
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__)

Expand Down Expand Up @@ -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
###############################################################################
Expand Down Expand Up @@ -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
6 changes: 3 additions & 3 deletions requirements/release.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
1 change: 1 addition & 0 deletions requirements/test.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
24 changes: 24 additions & 0 deletions tests/test_data/xml/trivial-doc.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0"?>
<data>
<!-- This comment is very important! -->
<country name="Liechtenstein">
<rank>1</rank>
<year>2008</year>
<gdppc>141100</gdppc>
<neighbor name="Austria" direction="E"/>
<neighbor name="Switzerland" direction="W"/>
</country>
<country name="Singapore">
<rank>4</rank>
<year>2011</year>
<gdppc>59900</gdppc>
<neighbor name="Malaysia" direction="N"/>
</country>
<country name="Panama">
<rank>68</rank>
<year>2011</year>
<gdppc>13600</gdppc>
<neighbor name="Costa Rica" direction="W"/>
<neighbor name="Colombia" direction="E"/>
</country>
</data>
Loading