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.5.1
current_version = 0.6.0
commit = True
tag = True

Expand Down
11 changes: 11 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
History
-------

0.6.0 (2019-05-08)
+++++++++++++++++++++++

Includes backwards-incompatible changes to data model ``DteDataL2``.

* (PR #38, 2019-05-08) dte.data_models: alter field ``DteDataL2.signature_x509_cert_pem``
* (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

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.5.1'
__version__ = '0.6.0'
47 changes: 33 additions & 14 deletions cl_sii/dte/data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

import cl_sii.contribuyente.constants
import cl_sii.rut.constants
from cl_sii.libs import encoding_utils
from cl_sii.libs import tz_utils
from cl_sii.rut import Rut

from . import constants
Expand Down Expand Up @@ -95,6 +95,13 @@ def validate_non_empty_bytes(value: bytes) -> None:
raise ValueError("Bytes value length (stripped) is 0.")


def validate_correct_tz(value: datetime, tz: tz_utils.PytzTimezone) -> None:
if not tz_utils.dt_is_aware(value):
raise ValueError("Value must be a timezone-aware datetime.", value)
if value.tzinfo.zone != tz.zone: # type: ignore
raise ValueError(f"Timezone of datetime value must be '{tz.zone!s}'.", value)


@dataclasses.dataclass(frozen=True)
class DteNaturalKey:

Expand Down Expand Up @@ -318,6 +325,16 @@ class DteDataL2(DteDataL1):

"""

###########################################################################
# constants
###########################################################################

DATETIME_FIELDS_TZ = tz_utils.TZ_CL_SANTIAGO

###########################################################################
# fields
###########################################################################

emisor_razon_social: str = dc_field()
"""
"Razón social" (legal name) of the "emisor" of the DTE.
Expand All @@ -333,7 +350,7 @@ class DteDataL2(DteDataL1):
"Fecha de vencimiento (pago)" of the DTE.
"""

firma_documento_dt_naive: Optional[datetime] = dc_field(default=None)
firma_documento_dt: Optional[datetime] = dc_field(default=None)
"""
Datetime on which the "documento" was digitally signed.
"""
Expand All @@ -343,11 +360,13 @@ class DteDataL2(DteDataL1):
DTE's digital signature's value (raw bytes, without base64 encoding).
"""

signature_x509_cert_pem: Optional[bytes] = dc_field(default=None)
signature_x509_cert_der: Optional[bytes] = dc_field(default=None)
"""
DTE's digital signature's PEM-encoded X.509 cert.
DTE's digital signature's DER-encoded X.509 cert.

PEM-encoded implies base64-encoded.
.. seealso::
Functions :func:`cl_sii.libs.crypto_utils.load_der_x509_cert`
and :func:`cl_sii.libs.crypto_utils.x509_cert_der_to_pem`.
"""

emisor_giro: Optional[str] = dc_field(default=None)
Expand Down Expand Up @@ -386,22 +405,22 @@ def __post_init__(self) -> None:
if not isinstance(self.fecha_vencimiento_date, date):
raise TypeError("Inappropriate type of 'fecha_vencimiento_date'.")

if self.firma_documento_dt_naive is not None:
if not isinstance(self.firma_documento_dt_naive, datetime):
raise TypeError("Inappropriate type of 'firma_documento_dt_naive'.")
if self.firma_documento_dt is not None:
if not isinstance(self.firma_documento_dt, datetime):
raise TypeError("Inappropriate type of 'firma_documento_dt'.")
validate_correct_tz(self.firma_documento_dt, self.DATETIME_FIELDS_TZ)

if self.signature_value is not None:
if not isinstance(self.signature_value, bytes):
raise TypeError("Inappropriate type of 'signature_value'.")
validate_clean_bytes(self.signature_value)
validate_non_empty_bytes(self.signature_value)

if self.signature_x509_cert_pem is not None:
if not isinstance(self.signature_x509_cert_pem, bytes):
raise TypeError("Inappropriate type of 'signature_x509_cert_pem'.")
validate_clean_bytes(self.signature_x509_cert_pem)
validate_non_empty_bytes(self.signature_x509_cert_pem)
encoding_utils.validate_base64(self.signature_x509_cert_pem)
if self.signature_x509_cert_der is not None:
if not isinstance(self.signature_x509_cert_der, bytes):
raise TypeError("Inappropriate type of 'signature_x509_cert_der'.")
validate_clean_bytes(self.signature_x509_cert_der)
validate_non_empty_bytes(self.signature_x509_cert_der)

if self.emisor_giro is not None:
if not isinstance(self.emisor_giro, str):
Expand Down
11 changes: 7 additions & 4 deletions cl_sii/dte/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from typing import Tuple

from cl_sii.libs import encoding_utils
from cl_sii.libs import tz_utils
from cl_sii.libs import xml_utils
from cl_sii.libs.xml_utils import XmlElement, XmlElementTree
from cl_sii.rut import Rut
Expand Down Expand Up @@ -447,11 +448,13 @@ def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2:

monto_total_value = int(monto_total_em.text.strip())

tmst_firma_value = datetime.fromisoformat(tmst_firma_em.text)
tmst_firma_value = tz_utils.convert_naive_dt_to_tz_aware(
dt=datetime.fromisoformat(tmst_firma_em.text),
tz=data_models.DteDataL2.DATETIME_FIELDS_TZ)

signature_signature_value = encoding_utils.decode_base64_strict(
signature_signature_value_em.text.strip())
signature_key_info_x509_cert_pem = encoding_utils.clean_base64(
signature_key_info_x509_cert_der = encoding_utils.decode_base64_strict(
signature_key_info_x509_cert_em.text.strip())

return data_models.DteDataL2(
Expand All @@ -464,9 +467,9 @@ def parse_dte_xml(xml_doc: XmlElement) -> data_models.DteDataL2:
emisor_razon_social=emisor_razon_social_value,
receptor_razon_social=receptor_razon_social_value,
fecha_vencimiento_date=fecha_vencimiento_value,
firma_documento_dt_naive=tmst_firma_value,
firma_documento_dt=tmst_firma_value,
signature_value=signature_signature_value,
signature_x509_cert_pem=signature_key_info_x509_cert_pem,
signature_x509_cert_der=signature_key_info_x509_cert_der,
emisor_giro=emisor_giro_value,
emisor_email=emisor_email_value,
receptor_email=receptor_email_value,
Expand Down
106 changes: 105 additions & 1 deletion cl_sii/libs/crypto_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,43 @@
"""
Crypto utils
============


DER and PEM
-----------

Best answer to the
`StackOverflow question <https://stackoverflow.com/a/22743616>`_
"What are the differences between .pem, .cer and .der?".

Best answer to the
`ServerFault question <https://https://serverfault.com/a/9717>`_.
"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
Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down
64 changes: 59 additions & 5 deletions cl_sii/libs/tz_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
"""
Timezone utils
==============


Naive and aware
---------------

These concept are defined in Python standard library module datetime
`docs <https://docs.python.org/3/library/datetime.html#module-datetime>`_.

"""
from datetime import datetime
from typing import Union

Expand All @@ -14,8 +26,12 @@
]


UTC = pytz.UTC # type: PytzTimezone
TIMEZONE_CL_SANTIAGO = pytz.timezone('America/Santiago') # type: PytzTimezone
TZ_UTC = pytz.UTC # type: PytzTimezone
TZ_CL_SANTIAGO = pytz.timezone('America/Santiago') # type: PytzTimezone

# TODO: remove
UTC = TZ_UTC
TIMEZONE_CL_SANTIAGO = TZ_CL_SANTIAGO


def get_now_tz_aware() -> datetime:
Expand All @@ -31,7 +47,7 @@ def get_now_tz_aware() -> datetime:
# - `pytz.UTC.localize(datetime.utcnow())`

# source: 'django.utils.timezone.now' @ Django 2.1.3
return datetime.utcnow().replace(tzinfo=UTC)
return datetime.utcnow().replace(tzinfo=TZ_UTC)


def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime:
Expand All @@ -44,13 +60,13 @@ def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime:
>>> dt_naive.isoformat()
'2018-10-23T01:54:13'

>>> dt_tz_aware_1 = convert_naive_dt_to_tz_aware(dt_naive, UTC)
>>> dt_tz_aware_1 = convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC)
>>> dt_tz_aware_1
datetime.datetime(2018, 10, 23, 1, 54, 13, tzinfo=<UTC>)
>>> dt_tz_aware_1.isoformat()
'2018-10-23T04:54:13+00:00'

>>> dt_tz_aware_2 = convert_naive_dt_to_tz_aware(dt_naive, TIMEZONE_CL_SANTIAGO)
>>> dt_tz_aware_2 = convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO)
>>> dt_tz_aware_2
datetime.datetime(2018, 10, 23, 1, 54, 13, tzinfo=<DstTzInfo 'America/Santiago'
-03-1 day, 21:00:00 DST>)
Expand All @@ -64,3 +80,41 @@ def convert_naive_dt_to_tz_aware(dt: datetime, tz: PytzTimezone) -> datetime:
"""
dt_tz_aware = tz.localize(dt) # type: datetime
return dt_tz_aware


def dt_is_aware(value: datetime) -> bool:
"""
Return whether datetime ``value`` is "aware".

>>> dt_naive = datetime(2018, 10, 23, 1, 54, 13)
>>> dt_is_aware(dt_naive)
False
>>> dt_is_aware(convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC))
True
>>> dt_is_aware(convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO))
True

"""
if not isinstance(value, datetime):
raise TypeError
# source: 'django.utils.timezone.is_aware' @ Django 2.1.7
return value.utcoffset() is not None


def dt_is_naive(value: datetime) -> bool:
"""
Return whether datetime ``value`` is "naive".

>>> dt_naive = datetime(2018, 10, 23, 1, 54, 13)
>>> dt_is_naive(dt_naive)
True
>>> dt_is_naive(convert_naive_dt_to_tz_aware(dt_naive, TZ_UTC))
False
>>> dt_is_naive(convert_naive_dt_to_tz_aware(dt_naive, TZ_CL_SANTIAGO))
False

"""
if not isinstance(value, datetime):
raise TypeError
# source: 'django.utils.timezone.is_naive' @ Django 2.1.7
return value.utcoffset() is None
2 changes: 1 addition & 1 deletion cl_sii/rcv/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class _RcvCsvDialect(csv.Dialect):
class RcvCsvRowSchema(marshmallow.Schema):

EXPECTED_INPUT_FIELDS = tuple(_RCV_CSV_EXPECTED_FIELD_NAMES) + (_CSV_ROW_DICT_EXTRA_FIELDS_KEY, ) # type: ignore # noqa: E501
FIELD_FECHA_RECEPCION_DATETIME_TZ = tz_utils.TIMEZONE_CL_SANTIAGO
FIELD_FECHA_RECEPCION_DATETIME_TZ = tz_utils.TZ_CL_SANTIAGO

class Meta:
strict = True
Expand Down
Loading