Skip to content

Commit

Permalink
Merge pull request #39 from fyndata/develop
Browse files Browse the repository at this point in the history
Release v0.6.0
  • Loading branch information
glarrain committed May 8, 2019
2 parents 570c121 + 3269597 commit 4789ec0
Show file tree
Hide file tree
Showing 17 changed files with 427 additions and 53 deletions.
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

0 comments on commit 4789ec0

Please sign in to comment.