diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..feb1b19 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,22 @@ +[run] +branch = True +omit = + */site-packages/* + */tests/* + +[report] +exclude_lines = + + # Don't complain about missing debug-only code: + def __repr__ + def __str__ + + # Don't complain if tests don't hit defensive assertion code: + raise AssertionError + raise NotImplementedError + assert + + # Don't complain if non-runnable code isn't run: + if 0: + pass + if __name__ == .__main__.: diff --git a/.gitignore b/.gitignore index e5b783d..934b7fb 100644 --- a/.gitignore +++ b/.gitignore @@ -90,4 +90,5 @@ ENV/ # IDEA .idea -experiment.py \ No newline at end of file +.pytest_cache/ +.DS_Store \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index 692f2df..2170dff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,12 @@ +dist: xenial language: python python: - - '2.7' - - '3.4' - - '3.5' - - '3.6' + - '3.7' + - '3.8' install: - - python setup.py install + - pip install -e ".[tests]" script: - - python setup.py test \ No newline at end of file + - pytest --cov=pyas2lib --cov-config .coveragerc --black --pylava +after_success: + - pip install codecov + - codecov \ No newline at end of file diff --git a/AUTHORS.md b/AUTHORS.md index 031b8bf..ce5df28 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -1 +1,2 @@ -* Abhishek Ram @abhishek-ram \ No newline at end of file +* Abhishek Ram @abhishek-ram +* Chad Gates @chadgates \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d25d9c6..96c696b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,74 @@ # Release History -## 0.1.0 - dev +## 1.3.3 - 2021-01-17 +* Update the versions of asn1crypto, oscrypto and pyOpenSSL -> **Note:** -> -> This version is not yet released and is under active development. +## 1.3.2 - 2020-11-01 +* Use `signature_algo` attribute when detecting the signature algorithm +* Raise exception when unknown `digest_alg` is passed to the sign function +* Add proper support for handling binary messages +* Look for `Final-Recipient` if `Original-Recipient` is not present in the MDN +* Remove support for python 3.6 +* Fix linting and change the linter to pylava + +## 1.3.1 - 2020-04-12 +* Use correct format for setting dataclasses requirement for python 3.6 + +## 1.3.0 - 2020-04-05 +* Fix and update the SMIME capabilities in the Signed attributes of a signature +* Update the versions of crypto dependencies and related changes +* Use black and pylama as code formatter and linter +* Increase test coverage and add support for python 3.8 + +## 1.2.2 - 2019-06-26 +* Handle MDNNotfound correctly when parsing an mdn + +## 1.2.1 - 2019-06-25 +* Handle exceptions raised when parsing signed attributes in a signature https://github.com/abhishek-ram/django-pyas2/issues/13 +* Add more debug logs during build and parse +* Catch errors in MDN parsing and handle accordingly + +## 1.2.0 - 2019-06-12 + +* Use f-strings for string formatting. +* Use HTTP email policy for flattening email messages. +* Add proper support for other encryption algos. +* Use dataclasses for organization and partner. +* Remove support for python 3.5. +* Add utility function for extracting info from certificates. + +## 1.1.1 - 2019-06-03 + +* Remove leftover print statement. +* Add utility for extracting public certificate information. + +## 1.1.0 - 2019-04-30 + +* Handle cases where compression is done before signing. +* Add support for additional encryption algorithms. +* Use binary encoding for encryption and signatures. +* Look for `application/x-pkcs7-signature` when verifying signatures. +* Remove support for Python 2. + +## 1.0.3 - 2018-05-01 + +* Remove unnecessary conversions to bytes. + +## 1.0.2 - 2018-05-01 + +* Fix an issue with message decompression. +* Add optional callback for checking duplicate messages in parse +* Add test cases for decompression and duplicate errors + +## 1.0.1 - 2018-04-22 + +* Check for incorrect passphrase when loading the private key. +* Change field name from `as2_id` to `as2_name` in org and partner +* Change name of class from `MDN` to `Mdn` +* Fix couple of validation issues when loading partner +* Return the traceback along with the exception when parsing messages +* Fix the mechanism for loading and validation partner certs + +## 1.0.0 - 2018-02-15 + +* Initial release. diff --git a/README.md b/README.md index e9060a6..990cd01 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # pyas2-lib +[![pypi package](https://img.shields.io/pypi/v/pyas2lib.svg)](https://pypi.python.org/pypi/pyas2lib/) +[![Build Status](https://travis-ci.org/abhishek-ram/pyas2-lib.svg?branch=master)](https://travis-ci.org/abhishek-ram/pyas2-lib) +[![codecov](https://codecov.io/gh/abhishek-ram/pyas2-lib/branch/master/graph/badge.svg)](https://codecov.io/gh/abhishek-ram/pyas2-lib) + A pure python library for building and parsing message as part of the AS2 messaging protocol. The message definitions follow the AS2 version 1.2 as defined in the [RFC 4130][1].The library is intended to decouple the message construction/deconstruction from the web server/client implementation. The following functionality is part of this library: * Compress, Sign and Encrypt the payload to be transmitted. @@ -8,7 +12,85 @@ A pure python library for building and parsing message as part of the AS2 messag * Parsing a received MIME data and identifying if it as a Message or MDN. * Decompress, Decrypt and Verify Signature of the received payload. * Verify Signature of the received MDN and extract original message status. - + + +## Basic Usage + +Let us take a look at how we can use this library for building and parsing of AS2 Messages. + +### Setup + +* First we would need to setup an organization and a partner +```python +from pyas2lib.as2 import Organization, Partner + +my_org = Organization( + as2_name='my_unique_id', # Unique AS2 Id for this organization + sign_key=b'signature_key_bytes', # PEM/DER encoded private key for signature + sign_key_pass='password', # Password private key for signature + decrypt_key=b'decrypt_key_bytes', # PEM/DER encoded private key for decryption + decrypt_key_pass='password' # Password private key for decryption +) + +a_partner = Partner( + as2_name='partner_unique_id', # Unique AS2 Id of your partner + sign=True, # Set to true for signing the message + verify_cert=b'verify_cert_bytes', # PEM/DER encoded certificate for verifying partner signatures + encrypt=True, # Set to true for encrypting the message + encrypt_cert=b'encrypt_cert_bytes', # PEM/DER encoded certificate for encrypting messages + mdn_mode='SYNC', # Expect to receive synchronous MDNs from this partner + mdn_digest_alg='sha256' # Expect signed MDNs to be returned by this partner +) + +``` + +### Sending a message to your partner + +* The partner is now setup we can build and AS2 message +```python +from pyas2lib.as2 import Message + +msg = Message(sender=my_org, receiver=a_partner) +msg.build(b'data_to_transmit') + +``` +* The message is built and now `msg.content` holds the message body and `message.header` dictionary holds the message headers. These need to be passed to any http library for HTTP POSTing to the partner. +* We expect synchronous MDNs so we need to process the response to our HTTP POST +```python +from pyas2lib.as2 import Mdn + +msg_mdn = Mdn() # Initialize an Mdn object + +# Call the parse method with the HTTP response headers + content and a function that returns the related `pyas2lib.as2.Messsage` object. +status, detailed_status = msg_mdn.parse(b'response_data_with_headers', find_message_func) +``` +* We parse the response mdn to get the status and detailed status of the message that was transmitted. + +### Receiving a message from your partner + +* We need to setup and HTTP server with an endpoint for receiving POST requests fro your partner. +* When a requests is received we need to first check if this is an Async MDN +```python +from pyas2lib.as2 import Mdn + +msg_mdn = Mdn() # Initialize an Mdn object +# Call the parse method with the HTTP request headers + content and a function the returns the related `pyas2lib.as2.Messsage` object. +status, detailed_status = msg_mdn.parse(request_body, find_message_fumc) +``` +* If this is an Async MDN it will return the status of the original message. +* In case the request is not an MDN then `pyas2lib.exceptions.MDNNotFound` is raised, which needs to be catched and parse the request as a message. +```python +from pyas2lib.as2 import Message + +msg = Message() +# Call the parse method with the HTTP request headers + content, a function to return the the related `pyas2lib.as2.Organization` object, a function to return the `pyas2lib.as2.Partner` object and a function to check for duplicates. +status, exception, mdn = msg.parse( + request_body, find_organization, find_partner, check_duplicate_msg) +``` +* The parse function returns a 3 element tuple; the status of parsing, exception if any raised during parsing and an `pyas2lib.as2.Mdn` object for the message. +* If the `mdn.mdn_mode` is `SYNC` then the `mdn.content` and `mdn.header` must be returned in the response. +* If the `mdn.mdn_mode` is `ASYNC` then the mdn must be saved for later processing. + ## Contribute 1. Check for open issues or open a fresh issue to start a discussion around a feature idea or a bug. diff --git a/pyas2lib/__init__.py b/pyas2lib/__init__.py index b56c16c..dc799f3 100644 --- a/pyas2lib/__init__.py +++ b/pyas2lib/__init__.py @@ -1,15 +1,24 @@ -from __future__ import absolute_import -import sys +from pyas2lib.constants import ( + DIGEST_ALGORITHMS, + ENCRYPTION_ALGORITHMS, + MDN_CONFIRM_TEXT, + MDN_FAILED_TEXT, +) +from pyas2lib.as2 import Mdn +from pyas2lib.as2 import Message +from pyas2lib.as2 import Organization +from pyas2lib.as2 import Partner -VERSION = (1, 0, '0b1') -__version__ = VERSION -__versionstr__ = '.'.join(map(str, VERSION)) +__version__ = "1.3.3" -if (2, 7) <= sys.version_info < (3, 2): - # On Python 2.7 and Python3 < 3.2, install no-op handler to silence - # `No handlers could be found for logger "elasticsearch"` message per - # - import logging - logger = logging.getLogger('pyas2lib') - logger.addHandler(logging.NullHandler()) +__all__ = [ + "DIGEST_ALGORITHMS", + "ENCRYPTION_ALGORITHMS", + "MDN_CONFIRM_TEXT", + "MDN_FAILED_TEXT", + "Partner", + "Organization", + "Message", + "Mdn", +] diff --git a/pyas2lib/as2.py b/pyas2lib/as2.py index b85225b..7eaba35 100644 --- a/pyas2lib/as2.py +++ b/pyas2lib/as2.py @@ -1,214 +1,262 @@ -from __future__ import absolute_import, unicode_literals -from .compat import str_cls, byte_cls, parse_mime -from .cms import compress_message, decompress_message, decrypt_message, \ - encrypt_message, verify_message, sign_message -from .cms import DIGEST_ALGORITHMS, ENCRYPTION_ALGORITHMS -from .utils import canonicalize, mime_to_bytes, quote_as2name, unquote_as2name,\ - make_mime_boundary, extract_first_part, pem_to_der, \ - verify_certificate_chain -from .exceptions import * -from email import utils as email_utils -from email import message as email_message -from email import encoders -from email.mime.multipart import MIMEMultipart -from oscrypto import asymmetric +"""Define the core functions/classes of the pyas2 package.""" import logging import hashlib import binascii +import traceback +from dataclasses import dataclass +from email import encoders +from email import message as email_message +from email import message_from_bytes as parse_mime +from email import utils as email_utils +from email.mime.multipart import MIMEMultipart +from oscrypto import asymmetric -logger = logging.getLogger('pyas2lib') - -AS2_VERSION = '1.2' - -EDIINT_FEATURES = 'CMS' - -IGNORE_SELF_SIGNED_CERTS = True - -SYNCHRONOUS_MDN = 'SYNC' -ASYNCHRONOUS_MDN = 'ASYNC' - -MDN_MODES = ( +from pyas2lib.cms import ( + compress_message, + decompress_message, + decrypt_message, + encrypt_message, + sign_message, + verify_message, +) +from pyas2lib.constants import ( + AS2_VERSION, + ASYNCHRONOUS_MDN, + DIGEST_ALGORITHMS, + EDIINT_FEATURES, + ENCRYPTION_ALGORITHMS, + MDN_CONFIRM_TEXT, + MDN_FAILED_TEXT, + MDN_MODES, SYNCHRONOUS_MDN, - ASYNCHRONOUS_MDN +) +from pyas2lib.exceptions import ( + AS2Exception, + DuplicateDocument, + ImproperlyConfigured, + InsufficientSecurityError, + IntegrityError, + MDNNotFound, + PartnerNotFound, +) +from pyas2lib.utils import ( + canonicalize, + extract_first_part, + make_mime_boundary, + mime_to_bytes, + pem_to_der, + quote_as2name, + split_pem, + unquote_as2name, + verify_certificate_chain, ) -MDN_CONFIRM_TEXT = 'The AS2 message has been successfully processed. ' \ - 'Thank you for exchanging AS2 messages with pyAS2.' +logger = logging.getLogger("pyas2lib") -MDN_FAILED_TEXT = 'The AS2 message could not be processed. The ' \ - 'disposition-notification report has additional details.' +@dataclass +class Organization: + """ + Class represents an AS2 organization and defines the certificates and + settings to be used when sending and receiving messages. -class Organization(object): - """Class represents an AS2 organization and defines the certificates and - settings to be used when sending and receiving messages. """ + :param as2_name: The unique AS2 name for this organization - def __init__(self, as2_id, sign_key=None, sign_key_pass=None, - decrypt_key=None, decrypt_key_pass=None, mdn_url=None, - mdn_confirm_text=MDN_CONFIRM_TEXT): - """ - :param as2_id: The unique AS2 name for this organization + :param sign_key: A byte string of the pkcs12 encoded key pair + used for signing outbound messages and MDNs. - :param sign_key: A byte string of the pkcs12 encoded key pair - used for signing outbound messages and MDNs. + :param sign_key_pass: The password for decrypting the `sign_key` - :param sign_key_pass: The password for decrypting the `sign_key` + :param decrypt_key: A byte string of the pkcs12 encoded key pair + used for decrypting inbound messages. - :param decrypt_key: A byte string of the pkcs12 encoded key pair - used for decrypting inbound messages. + :param decrypt_key_pass: The password for decrypting the `decrypt_key` - :param decrypt_key_pass: The password for decrypting the `decrypt_key` + :param mdn_url: The URL where the receiver is expected to post + asynchronous MDNs. + """ - :param mdn_url: The URL where the receiver is expected to post - asynchronous MDNs. - """ - self.sign_key = self.load_key( - sign_key, byte_cls(sign_key_pass)) if sign_key else None + as2_name: str + sign_key: bytes = None + sign_key_pass: str = None + decrypt_key: bytes = None + decrypt_key_pass: str = None + mdn_url: str = None + mdn_confirm_text: str = MDN_CONFIRM_TEXT - self.decrypt_key = self.load_key( - decrypt_key, byte_cls(decrypt_key_pass)) if decrypt_key else None + def __post_init__(self): + """Run the post initialisation checks for this class.""" + # Load the signature and decryption keys + if self.sign_key: + self.sign_key = self.load_key(self.sign_key, self.sign_key_pass) - self.as2_id = as2_id - self.mdn_url = mdn_url - self.mdn_confirm_text = mdn_confirm_text + if self.decrypt_key: + self.decrypt_key = self.load_key(self.decrypt_key, self.decrypt_key_pass) @staticmethod - def load_key(key_str, key_pass): - """ Function to load password protected key file in p12 or pem format""" + def load_key(key_str: bytes, key_pass: str): + """Function to load password protected key file in p12 or pem format.""" - # First try to parse as a p12 file try: - key, cert, _ = asymmetric.load_pkcs12(key_str, byte_cls(key_pass)) - except ValueError: + # First try to parse as a p12 file + key, cert, _ = asymmetric.load_pkcs12(key_str, key_pass) + except ValueError as e: + # If it fails due to invalid password raise error here + if e.args[0] == "Password provided is invalid": + raise AS2Exception("Password not valid for Private Key.") + + # if not try to parse as a pem file key, cert = None, None - # Now try to parse as a pem file - for kc in pem_to_der(key_str): + for kc in split_pem(key_str): try: - key = asymmetric.load_private_key(kc, key_pass) - except ValueError: cert = asymmetric.load_certificate(kc) - if not key or not cert: - raise AS2Exception( - 'Invalid Private key file or Public key not included.') + except (ValueError, TypeError): + try: + key = asymmetric.load_private_key(kc, key_pass) + except OSError: + raise AS2Exception( + "Invalid Private Key or password is not correct." + ) + + if not key or not cert: + raise AS2Exception("Invalid Private key file or Public key not included.") + return key, cert -class Partner(object): - """Class represents an AS2 partner and defines the certificates and - settings to be used when sending and receiving messages.""" +@dataclass +class Partner: + """ + Class represents an AS2 partner and defines the certificates and + settings to be used when sending and receiving messages. - def __init__(self, as2_id, verify_cert=None, verify_cert_ca=None, - encrypt_cert=None, encrypt_cert_ca=None, validate_certs=True, - compress=False, sign=False, digest_alg='sha256', encrypt=False, - enc_alg='tripledes_192_cbc', mdn_mode=None, - mdn_digest_alg=None, mdn_confirm_text=MDN_CONFIRM_TEXT): - """ - :param as2_id: The unique AS2 name for this partner. + :param as2_name: The unique AS2 name for this partner. - :param verify_cert: A byte string of the certificate to be used for - verifying signatures of inbound messages and MDNs. + :param verify_cert: A byte string of the certificate to be used for + verifying signatures of inbound messages and MDNs. - :param verify_cert_ca: A byte string of the ca certificate if any of - the verification cert + :param verify_cert_ca: A byte string of the ca certificate if any of + the verification cert - :param encrypt_cert: A byte string of the certificate to be used for - encrypting outbound message. + :param encrypt_cert: A byte string of the certificate to be used for + encrypting outbound message. - :param encrypt_cert_ca: A byte string of the ca certificate if any of - the encryption cert + :param encrypt_cert_ca: A byte string of the ca certificate if any of + the encryption cert - :param validate_certs: Set this flag to `False` to disable validations of - the encryption and verification certificates. (default `True`) + :param validate_certs: Set this flag to `False` to disable validations of + the encryption and verification certificates. (default `True`) - :param compress: Set this flag to `True` to compress outgoing - messages. (default `False`) + :param compress: Set this flag to `True` to compress outgoing + messages. (default `False`) - :param sign: Set this flag to `True` to sign outgoing - messages. (default `False`) + :param sign: Set this flag to `True` to sign outgoing + messages. (default `False`) - :param digest_alg: The digest algorithm to be used for generating the - signature. (default "sha256") + :param digest_alg: The digest algorithm to be used for generating the + signature. (default "sha256") - :param encrypt: Set this flag to `True` to encrypt outgoing - messages. (default `False`) + :param encrypt: Set this flag to `True` to encrypt outgoing + messages. (default `False`) - :param enc_alg: - The encryption algorithm to be used. (default `"tripledes_192_cbc"`) + :param enc_alg: + The encryption algorithm to be used. (default `"tripledes_192_cbc"`) - :param mdn_mode: The mode to be used for receiving the MDN. - Set to `None` for no MDN, `'SYNC'` for synchronous and `'ASYNC'` - for asynchronous. (default `None`) + :param mdn_mode: The mode to be used for receiving the MDN. + Set to `None` for no MDN, `'SYNC'` for synchronous and `'ASYNC'` + for asynchronous. (default `None`) - :param mdn_digest_alg: The digest algorithm to be used by the receiver - for signing the MDN. Use `None` for unsigned MDN. (default `None`) + :param mdn_digest_alg: The digest algorithm to be used by the receiver + for signing the MDN. Use `None` for unsigned MDN. (default `None`) - :param mdn_confirm_text: The text to be used in the MDN for successfully - processed messages received from this partner. + :param mdn_confirm_text: The text to be used in the MDN for successfully + processed messages received from this partner. - """ + """ + + as2_name: str + verify_cert: bytes = None + verify_cert_ca: bytes = None + encrypt_cert: bytes = None + encrypt_cert_ca: bytes = None + validate_certs: bool = True + compress: bool = False + encrypt: bool = False + enc_alg: str = "tripledes_192_cbc" + sign: bool = False + digest_alg: str = "sha256" + mdn_mode: str = None + mdn_digest_alg: str = None + mdn_confirm_text: str = MDN_CONFIRM_TEXT + ignore_self_signed: bool = True + + def __post_init__(self): + """Run the post initialisation checks for this class.""" # Validations - if digest_alg not in DIGEST_ALGORITHMS: + if self.digest_alg and self.digest_alg not in DIGEST_ALGORITHMS: raise ImproperlyConfigured( - 'Unsupported Digest Algorithm {}, must be ' - 'one of {}'.format(digest_alg, DIGEST_ALGORITHMS)) + f"Unsupported Digest Algorithm {self.digest_alg}, must be " + f"one of {DIGEST_ALGORITHMS}" + ) - if enc_alg not in ENCRYPTION_ALGORITHMS: + if self.enc_alg and self.enc_alg not in ENCRYPTION_ALGORITHMS: raise ImproperlyConfigured( - 'Unsupported Encryption Algorithm {}, must be ' - 'one of {}'.format(enc_alg, ENCRYPTION_ALGORITHMS)) + f"Unsupported Encryption Algorithm {self.enc_alg}, must be " + f"one of {ENCRYPTION_ALGORITHMS}" + ) - if mdn_mode and mdn_mode not in MDN_MODES: + if self.mdn_mode and self.mdn_mode not in MDN_MODES: raise ImproperlyConfigured( - 'Unsupported MDN Mode {}, must be ' - 'one of {}'.format(digest_alg, MDN_MODES)) + f"Unsupported MDN Mode {self.mdn_mode}, must be " f"one of {MDN_MODES}" + ) - # if mdn_mode == 'ASYNC' and not mdn_url: - # raise ImproperlyConfigured( - # 'mdn_url is mandatory when mdn_mode is set to ASYNC ') - - if mdn_digest_alg and mdn_digest_alg not in DIGEST_ALGORITHMS: + if self.mdn_digest_alg and self.mdn_digest_alg not in DIGEST_ALGORITHMS: raise ImproperlyConfigured( - 'Unsupported MDN Digest Algorithm {}, must be ' - 'one of {}'.format(mdn_digest_alg, DIGEST_ALGORITHMS)) + f"Unsupported MDN Digest Algorithm {self.mdn_digest_alg}, " + f"must be one of {DIGEST_ALGORITHMS}" + ) - self.as2_id = as2_id - self.compress = compress - self.sign = sign - self.digest_alg = digest_alg - self.encrypt = encrypt - self.enc_alg = enc_alg - self.mdn_mode = mdn_mode - self.mdn_digest_alg = mdn_digest_alg - self.mdn_confirm_text = mdn_confirm_text - self.verify_cert = self.load_cert( - verify_cert, verify_cert_ca, validate_certs) if verify_cert else None - self.encrypt_cert = self.load_cert( - encrypt_cert, encrypt_cert_ca, validate_certs) if encrypt_cert else None + def load_verify_cert(self): + """Load the verification certificate of the partner and returned the parsed cert.""" + if self.validate_certs: + # Convert the certificate to DER format + cert = pem_to_der(self.verify_cert, return_multiple=False) - @staticmethod - def load_cert(cert, cert_ca=None, validate_certs=True): - """ Function validates and loads the partners certificates""" - # Validate the certificate if option is set - if validate_certs: + # Convert the ca certificate to DER format + if self.verify_cert_ca: + trust_roots = pem_to_der(self.verify_cert_ca) + else: + trust_roots = [] + + # Verify the certificate against the trusted roots + verify_certificate_chain( + cert, trust_roots, ignore_self_signed=self.ignore_self_signed + ) + + return asymmetric.load_certificate(self.verify_cert) + + def load_encrypt_cert(self): + """Load the encryption certificate of the partner and returned the parsed cert.""" + if self.validate_certs: # Convert the certificate to DER format - cert = pem_to_der(cert, return_multiple=False) + cert = pem_to_der(self.encrypt_cert, return_multiple=False) # Convert the ca certificate to DER format - if cert_ca: - trust_roots = pem_to_der(cert_ca) + if self.encrypt_cert_ca: + trust_roots = pem_to_der(self.encrypt_cert_ca) else: trust_roots = [] # Verify the certificate against the trusted roots verify_certificate_chain( - cert, trust_roots, ignore_self_signed=IGNORE_SELF_SIGNED_CERTS) + cert, trust_roots, ignore_self_signed=self.ignore_self_signed + ) - # Return the parsed certificate - return asymmetric.load_certificate(cert) + return asymmetric.load_certificate(self.encrypt_cert) -class Message(object): +class Message: """Class for handling AS2 messages. Includes functions for both parsing and building messages. """ @@ -237,40 +285,44 @@ def __init__(self, sender=None, receiver=None): @property def content(self): """Function returns the body of the as2 payload as a bytes object""" - - if not self.payload: - return '' + if self.payload is None: + return "" if self.payload.is_multipart(): - message_bytes = mime_to_bytes( - self.payload, 0).replace(b'\n', b'\r\n') - boundary = b'--' + self.payload.get_boundary().encode('utf-8') + message_bytes = mime_to_bytes(self.payload) + boundary = b"--" + self.payload.get_boundary().encode("utf-8") temp = message_bytes.split(boundary) temp.pop(0) return boundary + boundary.join(temp) - else: - content = self.payload.get_payload() - if isinstance(content, str_cls): - content = content.encode('utf-8') - return content + + content = self.payload.get_payload(decode=True) + return content @property def headers(self): + """Return the headers in the payload as a dictionary.""" if self.payload: return dict(self.payload.items()) - else: - return {} + return {} @property def headers_str(self): - message_header = '' + """Return the headers in the payload as a string.""" + message_header = "" if self.payload: for k, v in self.headers.items(): - message_header += '{}: {}\r\n'.format(k, v) - return message_header.encode('utf-8') - - def build(self, data, filename=None, subject='AS2 Message', - content_type='application/edi-consent', additional_headers=None): + message_header += f"{k}: {v}\r\n" + return message_header.encode("utf-8") + + def build( + self, + data, + filename=None, + subject="AS2 Message", + content_type="application/edi-consent", + additional_headers=None, + disposition_notification_to="no-reply@pyas2.com", + ): """Function builds the AS2 message. Compresses, signs and encrypts the payload if applicable. @@ -291,38 +343,40 @@ def build(self, data, filename=None, subject='AS2 Message', :param additional_headers: Any additional headers to be included as part of the AS2 message. + :param disposition_notification_to: + Email address for disposition-notification-to header entry. + (default "no-reply@pyas2.com") + """ # Validations - assert type(data) is byte_cls, \ - 'Parameter data must be of type {}'.format(byte_cls) + assert isinstance(data, bytes), "Parameter data must be of bytes type." additional_headers = additional_headers if additional_headers else {} - assert type(additional_headers) is dict + assert isinstance(additional_headers, dict) if self.receiver.sign and not self.sender.sign_key: raise ImproperlyConfigured( - 'Signing of messages is enabled but sign key is not set ' - 'for the sender.') + "Signing of messages is enabled but sign key is not set for the sender." + ) if self.receiver.encrypt and not self.receiver.encrypt_cert: raise ImproperlyConfigured( - 'Encryption of messages is enabled but encrypt key is not set ' - 'for the receiver.') + "Encryption of messages is enabled but encrypt key is not set for the receiver." + ) # Generate message id using UUID 1 as it uses both hostname and time - self.message_id = email_utils.make_msgid().lstrip('<').rstrip('>') + self.message_id = email_utils.make_msgid().lstrip("<").rstrip(">") # Set up the message headers as2_headers = { - 'AS2-Version': AS2_VERSION, - 'ediint-features': EDIINT_FEATURES, - 'Message-ID': '<{}>'.format(self.message_id), - 'AS2-From': quote_as2name(self.sender.as2_id), - 'AS2-To': quote_as2name(self.receiver.as2_id), - 'Subject': subject, - 'Date': email_utils.formatdate(localtime=True), - # 'recipient-address': message.partner.target_url, + "AS2-Version": AS2_VERSION, + "ediint-features": EDIINT_FEATURES, + "Message-ID": f"<{self.message_id}>", + "AS2-From": quote_as2name(self.sender.as2_name), + "AS2-To": quote_as2name(self.receiver.as2_name), + "Subject": subject, + "Date": email_utils.formatdate(localtime=True), } as2_headers.update(additional_headers) @@ -331,31 +385,43 @@ def build(self, data, filename=None, subject='AS2 Message', self.payload = email_message.Message() self.payload.set_payload(data) self.payload.set_type(content_type) - encoders.encode_7or8bit(self.payload) + + if content_type.lower().startswith("application/octet-stream"): + self.payload["Content-Transfer-Encoding"] = "binary" + else: + encoders.encode_7or8bit(self.payload) if filename: self.payload.add_header( - 'Content-Disposition', 'attachment', filename=filename) - del self.payload['MIME-Version'] + "Content-Disposition", "attachment", filename=filename + ) + del self.payload["MIME-Version"] if self.receiver.compress: self.compressed = True compressed_message = email_message.Message() - compressed_message.set_type('application/pkcs7-mime') - compressed_message.set_param('name', 'smime.p7z') - compressed_message.set_param('smime-type', 'compressed-data') + compressed_message.set_type("application/pkcs7-mime") + compressed_message.set_param("name", "smime.p7z") + compressed_message.set_param("smime-type", "compressed-data") compressed_message.add_header( - 'Content-Disposition', 'attachment', filename='smime.p7z') + "Content-Disposition", "attachment", filename="smime.p7z" + ) + compressed_message.add_header("Content-Transfer-Encoding", "binary") compressed_message.set_payload( - compress_message(canonicalize(self.payload))) - encoders.encode_base64(compressed_message) + compress_message(mime_to_bytes(self.payload)) + ) self.payload = compressed_message + logger.debug( + f"Compressed message {self.message_id} payload as:\n{mime_to_bytes(self.payload)}" + ) + if self.receiver.sign: self.signed, self.digest_alg = True, self.receiver.digest_alg signed_message = MIMEMultipart( - 'signed', protocol="application/pkcs7-signature") - del signed_message['MIME-Version'] + "signed", protocol="application/pkcs7-signature" + ) + del signed_message["MIME-Version"] signed_message.attach(self.payload) # Calculate the MIC Hash of the message to be verified @@ -366,48 +432,61 @@ def build(self, data, filename=None, subject='AS2 Message', # Create the signature mime message signature = email_message.Message() - signature.set_type('application/pkcs7-signature') - signature.set_param('name', 'smime.p7s') - signature.set_param('smime-type', 'signed-data') + signature.set_type("application/pkcs7-signature") + signature.set_param("name", "smime.p7s") + signature.set_param("smime-type", "signed-data") signature.add_header( - 'Content-Disposition', 'attachment', filename='smime.p7s') - del signature['MIME-Version'] - signature.set_payload(sign_message( - mic_content, self.digest_alg, self.sender.sign_key)) + "Content-Disposition", "attachment", filename="smime.p7s" + ) + del signature["MIME-Version"] + signature_data = sign_message( + mic_content, self.digest_alg, self.sender.sign_key + ) + signature.set_payload(signature_data) encoders.encode_base64(signature) - signed_message.set_param('micalg', self.digest_alg) + + signed_message.set_param("micalg", self.digest_alg) signed_message.attach(signature) self.payload = signed_message + logger.debug( + f"Signed message {self.message_id} payload as:\n{mime_to_bytes(self.payload)}" + ) + if self.receiver.encrypt: self.encrypted, self.enc_alg = True, self.receiver.enc_alg encrypted_message = email_message.Message() - encrypted_message.set_type('application/pkcs7-mime') - encrypted_message.set_param('name', 'smime.p7m') - encrypted_message.set_param('smime-type', 'enveloped-data') + encrypted_message.set_type("application/pkcs7-mime") + encrypted_message.set_param("name", "smime.p7m") + encrypted_message.set_param("smime-type", "enveloped-data") encrypted_message.add_header( - 'Content-Disposition', 'attachment', filename='smime.p7m') - encrypted_message.set_payload(encrypt_message( - canonicalize(self.payload), - self.enc_alg, - self.receiver.encrypt_cert - )) - encoders.encode_base64(encrypted_message) + "Content-Disposition", "attachment", filename="smime.p7m" + ) + encrypted_message.add_header("Content-Transfer-Encoding", "binary") + encrypt_cert = self.receiver.load_encrypt_cert() + encrypted_data = encrypt_message( + mime_to_bytes(self.payload), self.enc_alg, encrypt_cert + ) + encrypted_message.set_payload(encrypted_data) + self.payload = encrypted_message + logger.debug( + f"Encrypted message {self.message_id} payload as:\n{mime_to_bytes(self.payload)}" + ) if self.receiver.mdn_mode: - as2_headers['disposition-notification-to'] = 'no-reply@pyas2.com' + as2_headers["disposition-notification-to"] = disposition_notification_to if self.receiver.mdn_digest_alg: - as2_headers['disposition-notification-options'] = \ - 'signed-receipt-protocol=required, pkcs7-signature; ' \ - 'signed-receipt-micalg=optional, {}'.format( - self.receiver.mdn_digest_alg) - if self.receiver.mdn_mode == 'ASYNC': + as2_headers["disposition-notification-options"] = ( + f"signed-receipt-protocol=required, pkcs7-signature; " + f"signed-receipt-micalg=optional, {self.receiver.mdn_digest_alg}" + ) + if self.receiver.mdn_mode == "ASYNC": if not self.sender.mdn_url: raise ImproperlyConfigured( - 'MDN URL must be set in the organization when MDN mode ' - 'is set to ASYNC') - as2_headers['receipt-delivery-option'] = self.sender.mdn_url + "MDN URL must be set in the organization when MDN mode is set to ASYNC" + ) + as2_headers["receipt-delivery-option"] = self.sender.mdn_url # Update the headers of the final payload and set its boundary for k, v in as2_headers.items(): @@ -415,10 +494,26 @@ def build(self, data, filename=None, subject='AS2 Message', self.payload.replace_header(k, v) else: self.payload.add_header(k, v) + if self.payload.is_multipart(): self.payload.set_boundary(make_mime_boundary()) - def parse(self, raw_content, find_org_cb, find_partner_cb): + def _decompress_data(self, payload): + if ( + payload.get_content_type() == "application/pkcs7-mime" + and payload.get_param("smime-type") == "compressed-data" + ): + logger.debug( + f"Decompressing message {self.message_id} payload :\n" + f"{mime_to_bytes(self.payload)}" + ) + compressed_data = payload.get_payload(decode=True) + decompressed_data = decompress_message(compressed_data) + return True, parse_mime(decompressed_data) + + return False, payload + + def parse(self, raw_content, find_org_cb, find_partner_cb, find_message_cb=None): """Function parses the RAW AS2 message; decrypts, verifies and decompresses it and extracts the payload. @@ -433,72 +528,104 @@ def parse(self, raw_content, find_org_cb, find_partner_cb): A callback the returns an Partner object if exists. The as2-from header value is passed as an argument to it. + :param find_message_cb: + An optional callback the returns an Message object if exists in + order to check for duplicates. The message id and partner id is + passed as arguments to it. + :return: - A three element tuple containing (status, exception, mdn). The - status is a string indicating the status of the transaction. The - exception is populated with any exception raised during processing - and the mdn is an MDN object or None in case the partner did not - request it. + A three element tuple containing (status, (exception, traceback) + , mdn). The status is a string indicating the status of the + transaction. The exception is populated with any exception raised + during processing and the mdn is an MDN object or None in case + the partner did not request it. """ # Parse the raw MIME message and extract its content and headers - status, detailed_status, exception, mdn = 'processed', None, None, None + status, detailed_status, exception, mdn = "processed", None, (None, None), None self.payload = parse_mime(raw_content) as2_headers = {} for k, v in self.payload.items(): k = k.lower() - if k == 'message-id': - self.message_id = v.lstrip('<').rstrip('>') + if k == "message-id": + self.message_id = v.lstrip("<").rstrip(">") as2_headers[k] = v try: # Get the organization and partner for this transmission - org_id = unquote_as2name(as2_headers['as2-to']) + org_id = unquote_as2name(as2_headers["as2-to"]) self.receiver = find_org_cb(org_id) if not self.receiver: - raise PartnerNotFound( - 'Unknown AS2 organization with id {}'.format(org_id)) + raise PartnerNotFound(f"Unknown AS2 organization with id {org_id}") - partner_id = unquote_as2name(as2_headers['as2-from']) + partner_id = unquote_as2name(as2_headers["as2-from"]) self.sender = find_partner_cb(partner_id) if not self.sender: - raise PartnerNotFound( - 'Unknown AS2 partner with id {}'.format(partner_id)) + raise PartnerNotFound(f"Unknown AS2 partner with id {partner_id}") + + if find_message_cb and find_message_cb(self.message_id, partner_id): + raise DuplicateDocument( + "Duplicate message received, message with this ID already processed." + ) - if self.sender.encrypt and \ - self.payload.get_content_type() != 'application/pkcs7-mime': + if ( + self.sender.encrypt + and self.payload.get_content_type() != "application/pkcs7-mime" + ): raise InsufficientSecurityError( - 'Incoming messages from partner {} are must be encrypted' - ' but encrypted message not found.'.format(partner_id)) + f"Incoming messages from partner {partner_id} are must be encrypted " + f"but encrypted message not found." + ) + + if ( + self.payload.get_content_type() == "application/pkcs7-mime" + and self.payload.get_param("smime-type") == "enveloped-data" + ): + logger.debug( + f"Decrypting message {self.message_id} payload :\n" + f"{mime_to_bytes(self.payload)}" + ) - if self.payload.get_content_type() == 'application/pkcs7-mime' \ - and self.payload.get_param('smime-type') == 'enveloped-data': self.encrypted = True + encrypted_data = self.payload.get_payload(decode=True) self.enc_alg, decrypted_content = decrypt_message( - self.payload.get_payload(decode=True), - self.receiver.decrypt_key + encrypted_data, self.receiver.decrypt_key ) - raw_content = decrypted_content + self.payload = parse_mime(decrypted_content) - if self.payload.get_content_type() == 'text/plain': + if self.payload.get_content_type() == "text/plain": self.payload = email_message.Message() self.payload.set_payload(decrypted_content) - self.payload.set_type('application/edi-consent') + self.payload.set_type("application/edi-consent") - if self.sender.sign and \ - self.payload.get_content_type() != 'multipart/signed': + # Check for compressed data here + self.compressed, self.payload = self._decompress_data(self.payload) + + if ( + self.sender.sign + and self.payload.get_content_type() != "multipart/signed" + ): raise InsufficientSecurityError( - 'Incoming messages from partner {} are must be signed ' - 'but signed message not found.'.format(partner_id)) + f"Incoming messages from partner {partner_id} are must be signed " + f"but signed message not found." + ) - if self.payload.get_content_type() == 'multipart/signed': + if self.payload.get_content_type() == "multipart/signed": + logger.debug( + f"Verifying signed message {self.message_id} payload: \n" + f"{mime_to_bytes(self.payload)}" + ) self.signed = True + + # Split the message into signature and signed message signature = None - message_boundary = ( - '--' + self.payload.get_boundary()).encode('utf-8') + signature_types = [ + "application/pkcs7-signature", + "application/x-pkcs7-signature", + ] for part in self.payload.walk(): - if part.get_content_type() == "application/pkcs7-signature": + if part.get_content_type() in signature_types: signature = part.get_payload(decode=True) else: self.payload = part @@ -506,64 +633,61 @@ def parse(self, raw_content, find_org_cb, find_partner_cb): # Verify the message, first using raw message and if it fails # then convert to canonical form and try again mic_content = canonicalize(self.payload) - try: - self.digest_alg = verify_message( - mic_content, signature, self.sender.verify_cert) - except IntegrityError: - mic_content = raw_content.split(message_boundary)[1] - self.digest_alg = verify_message( - mic_content, signature, self.sender.verify_cert) + verify_cert = self.sender.load_verify_cert() + self.digest_alg = verify_message(mic_content, signature, verify_cert) # Calculate the MIC Hash of the message to be verified digest_func = hashlib.new(self.digest_alg) digest_func.update(mic_content) self.mic = binascii.b2a_base64(digest_func.digest()).strip() - if self.payload.get_content_type() == 'application/pkcs7-mime' \ - and self.payload.get_param('smime-type') == 'compressed-data': - self.compressed = True - decompressed_data = decompress_message( - self.payload.get_payload(decode=True)) - self.payload = parse_mime(decompressed_data) + # Check for compressed data here + if not self.compressed: + self.compressed, self.payload = self._decompress_data(self.payload) - except Exception as e: - status = getattr(e, 'disposition_type', 'processed/Error') + except Exception as e: # pylint: disable=W0703 + status = getattr(e, "disposition_type", "processed/Error") detailed_status = getattr( - e, 'disposition_modifier', 'unexpected-processing-error') - exception = e - finally: - # Update the payload headers with the original headers - for k, v in as2_headers.items(): - if self.payload.get(k): - del self.payload[k] - self.payload.add_header(k, v) + e, "disposition_modifier", "unexpected-processing-error" + ) + exception = (e, traceback.format_exc()) + logger.error(f"Failed to parse AS2 message\n: {traceback.format_exc()}") - if as2_headers.get('disposition-notification-to'): - mdn_mode = SYNCHRONOUS_MDN + # Update the payload headers with the original headers + for k, v in as2_headers.items(): + if self.payload.get(k) and k.lower() != "content-disposition": + del self.payload[k] + self.payload.add_header(k, v) - mdn_url = as2_headers.get('receipt-delivery-option') - if mdn_url: - mdn_mode = ASYNCHRONOUS_MDN + if as2_headers.get("disposition-notification-to"): + mdn_mode = SYNCHRONOUS_MDN - digest_alg = as2_headers.get('disposition-notification-options') - if digest_alg: - digest_alg = digest_alg.split(';')[-1].split(',')[-1].strip() - mdn = MDN( - mdn_mode=mdn_mode, mdn_url=mdn_url, digest_alg=digest_alg) - mdn.build(message=self, - status=status, - detailed_status=detailed_status) + mdn_url = as2_headers.get("receipt-delivery-option") + if mdn_url: + mdn_mode = ASYNCHRONOUS_MDN - return status, exception, mdn + digest_alg = as2_headers.get("disposition-notification-options") + if digest_alg: + digest_alg = digest_alg.split(";")[-1].split(",")[-1].strip() + logger.debug( + f"Building the MDN for message {self.message_id} with status {status} " + f"and detailed-status {detailed_status}." + ) + mdn = Mdn(mdn_mode=mdn_mode, mdn_url=mdn_url, digest_alg=digest_alg) + mdn.build(message=self, status=status, detailed_status=detailed_status) -class MDN(object): + return status, exception, mdn + + +class Mdn: """Class for handling AS2 MDNs. Includes functions for both parsing and building them. """ def __init__(self, mdn_mode=None, digest_alg=None, mdn_url=None): self.message_id = None + self.orig_message_id = None self.payload = None self.mdn_mode = mdn_mode self.digest_alg = digest_alg @@ -573,61 +697,68 @@ def __init__(self, mdn_mode=None, digest_alg=None, mdn_url=None): def content(self): """Function returns the body of the mdn message as a byte string""" - if self.payload: - message_bytes = mime_to_bytes( - self.payload, 0).replace(b'\n', b'\r\n') - boundary = b'--' + self.payload.get_boundary().encode('utf-8') + if self.payload is not None: + message_bytes = mime_to_bytes(self.payload) + boundary = b"--" + self.payload.get_boundary().encode("utf-8") temp = message_bytes.split(boundary) temp.pop(0) return boundary + boundary.join(temp) - else: - return '' + return "" @property def headers(self): + """Return the headers in the payload as a dictionary.""" if self.payload: return dict(self.payload.items()) - else: - return {} + return {} @property def headers_str(self): - message_header = '' + """Return the headers in the payload as a string.""" + message_header = "" if self.payload: for k, v in self.headers.items(): - message_header += '{}: {}\r\n'.format(k, v) - return message_header.encode('utf-8') - - def build(self, message, status, detailed_status=None): + message_header += f"{k}: {v}\r\n" + return message_header.encode("utf-8") + + def build( + self, + message, + status, + detailed_status=None, + confirmation_text=MDN_CONFIRM_TEXT, + failed_text=MDN_FAILED_TEXT, + ): """Function builds and signs an AS2 MDN message. :param message: The received AS2 message for which this is an MDN. :param status: The status of processing of the received AS2 message. - :param detailed_status: - The optional detailed status of processing of the received AS2 - message. Used to give additional error info (default "None") - + :param detailed_status: The optional detailed status of processing of the received AS2 + message. Used to give additional error info (default "None") + + :param confirmation_text: The confirmation message sent in the first part of the MDN. + + :param failed_text: The failure message sent in the first part of the failed MDN. """ # Generate message id using UUID 1 as it uses both hostname and time - self.message_id = email_utils.make_msgid().lstrip('<').rstrip('>') + self.message_id = email_utils.make_msgid().lstrip("<").rstrip(">") + self.orig_message_id = message.message_id # Set up the message headers mdn_headers = { - 'AS2-Version': AS2_VERSION, - 'ediint-features': EDIINT_FEATURES, - 'Message-ID': '<{}>'.format(self.message_id), - 'AS2-From': quote_as2name(message.headers.get('as2-to')), - 'AS2-To': quote_as2name(message.headers.get('as2-from')), - 'Date': email_utils.formatdate(localtime=True), - 'user-agent': 'pyAS2 Open Source AS2 Software' + "AS2-Version": AS2_VERSION, + "ediint-features": EDIINT_FEATURES, + "Message-ID": f"<{self.message_id}>", + "AS2-From": quote_as2name(message.headers.get("as2-to")), + "AS2-To": quote_as2name(message.headers.get("as2-from")), + "Date": email_utils.formatdate(localtime=True), + "user-agent": "pyAS2 Open Source AS2 Software", } # Set the confirmation text message here - confirmation_text = MDN_CONFIRM_TEXT - # overwrite with organization specific message if message.receiver and message.receiver.mdn_confirm_text: confirmation_text = message.receiver.mdn_confirm_text @@ -636,71 +767,79 @@ def build(self, message, status, detailed_status=None): if message.sender and message.sender.mdn_confirm_text: confirmation_text = message.sender.mdn_confirm_text - if status != 'processed': - confirmation_text = MDN_FAILED_TEXT + if status != "processed": + confirmation_text = failed_text - self.payload = MIMEMultipart( - 'report', report_type='disposition-notification') + self.payload = MIMEMultipart("report", report_type="disposition-notification") # Create and attach the MDN Text Message mdn_text = email_message.Message() - mdn_text.set_payload('%s\n' % confirmation_text) - mdn_text.set_type('text/plain') - del mdn_text['MIME-Version'] + mdn_text.set_payload(f"{confirmation_text}\r\n") + mdn_text.set_type("text/plain") + del mdn_text["MIME-Version"] encoders.encode_7or8bit(mdn_text) self.payload.attach(mdn_text) # Create and attache the MDN Report Message mdn_base = email_message.Message() - mdn_base.set_type('message/disposition-notification') - mdn_report = 'Reporting-UA: pyAS2 Open Source AS2 Software\n' - mdn_report += 'Original-Recipient: rfc822; {}\n'.format( - message.headers.get('as2-to')) - mdn_report += 'Final-Recipient: rfc822; {}\n'.format( - message.headers.get('as2-to')) - mdn_report += 'Original-Message-ID: <{}>\n'.format(message.message_id) - mdn_report += 'Disposition: automatic-action/' \ - 'MDN-sent-automatically; {}'.format(status) + mdn_base.set_type("message/disposition-notification") + mdn_report = "Reporting-UA: pyAS2 Open Source AS2 Software\r\n" + mdn_report += f'Original-Recipient: rfc822; {message.headers.get("as2-to")}\r\n' + mdn_report += f'Final-Recipient: rfc822; {message.headers.get("as2-to")}\r\n' + mdn_report += f"Original-Message-ID: <{message.message_id}>\r\n" + mdn_report += f"Disposition: automatic-action/MDN-sent-automatically; {status}" if detailed_status: - mdn_report += ': {}'.format(detailed_status) - mdn_report += '\n' + mdn_report += f": {detailed_status}" + mdn_report += "\r\n" if message.mic: - mdn_report += 'Received-content-MIC: {}, {}\n'.format( - message.mic.decode(), message.digest_alg) + mdn_report += f"Received-content-MIC: {message.mic.decode()}, {message.digest_alg}\r\n" mdn_base.set_payload(mdn_report) - del mdn_base['MIME-Version'] + del mdn_base["MIME-Version"] encoders.encode_7or8bit(mdn_base) self.payload.attach(mdn_base) + logger.debug( + f"MDN report for message {message.message_id} created:\n{mime_to_bytes(mdn_base)}" + ) + # Sign the MDN if it is requested by the sender - if message.headers.get('disposition-notification-options') and \ - message.receiver and message.receiver.sign_key: - self.digest_alg = \ - message.headers['disposition-notification-options'].split( - ';')[-1].split(',')[-1].strip() - signed_mdn = MIMEMultipart( - 'signed', protocol="application/pkcs7-signature") - del signed_mdn['MIME-Version'] + if ( + message.headers.get("disposition-notification-options") + and message.receiver + and message.receiver.sign_key + ): + self.digest_alg = ( + message.headers["disposition-notification-options"] + .split(";")[-1] + .split(",")[-1] + .strip() + .replace("-", "") + ) + signed_mdn = MIMEMultipart("signed", protocol="application/pkcs7-signature") + del signed_mdn["MIME-Version"] signed_mdn.attach(self.payload) # Create the signature mime message signature = email_message.Message() - signature.set_type('application/pkcs7-signature') - signature.set_param('name', 'smime.p7s') - signature.set_param('smime-type', 'signed-data') + signature.set_type("application/pkcs7-signature") + signature.set_param("name", "smime.p7s") + signature.set_param("smime-type", "signed-data") signature.add_header( - 'Content-Disposition', 'attachment', filename='smime.p7s') - del signature['MIME-Version'] - signature.set_payload(sign_message( - canonicalize(self.payload), - self.digest_alg, - message.receiver.sign_key - )) + "Content-Disposition", "attachment", filename="smime.p7s" + ) + del signature["MIME-Version"] + + signed_data = sign_message( + canonicalize(self.payload), self.digest_alg, message.receiver.sign_key + ) + signature.set_payload(signed_data) encoders.encode_base64(signature) - signed_mdn.set_param('micalg', self.digest_alg) + + signed_mdn.set_param("micalg", self.digest_alg) signed_mdn.attach(signature) self.payload = signed_mdn + logger.debug(f"Signing the MDN for message {message.message_id}") # Update the headers of the final payload and set message boundary for k, v in mdn_headers.items(): @@ -708,8 +847,11 @@ def build(self, message, status, detailed_status=None): self.payload.replace_header(k, v) else: self.payload.add_header(k, v) - if self.payload.is_multipart(): - self.payload.set_boundary(make_mime_boundary()) + self.payload.set_boundary(make_mime_boundary()) + logger.debug( + f"MDN generated for message {message.message_id} with " + f"content:\n {mime_to_bytes(self.payload)}" + ) def parse(self, raw_content, find_message_cb): """Function parses the RAW AS2 MDN, verifies it and extracts the @@ -720,103 +862,130 @@ def parse(self, raw_content, find_message_cb): :param find_message_cb: A callback the must returns the original Message Object. The - original message-id and original recipient AS2 ID are passed + original message-id and original recipient AS2 ID are passed as arguments to it. :returns: A two element tuple containing (status, detailed_status). The status is a string indicating the status of the transaction. The - optional detailed_status gives additional information about the + optional detailed_status gives additional information about the processing status. """ status, detailed_status = None, None - self.payload = parse_mime(raw_content) - orig_message_id, orig_recipient = self.detect_mdn() + try: + self.payload = parse_mime(raw_content) + self.orig_message_id, orig_recipient = self.detect_mdn() + + # Call the find message callback which should return a Message instance + orig_message = find_message_cb(self.orig_message_id, orig_recipient) + + # Extract the headers and save it + mdn_headers = {} + for k, v in self.payload.items(): + k = k.lower() + if k == "message-id": + self.message_id = v.lstrip("<").rstrip(">") + mdn_headers[k] = v + + if ( + orig_message.receiver.mdn_digest_alg + and self.payload.get_content_type() != "multipart/signed" + ): + status = "failed/Failure" + detailed_status = "Expected signed MDN but unsigned MDN returned" + return status, detailed_status + + if self.payload.get_content_type() == "multipart/signed": + logger.debug(f"Verifying signed MDN: \n{mime_to_bytes(self.payload)}") + message_boundary = ("--" + self.payload.get_boundary()).encode("utf-8") + + # Extract the signature and the signed payload + signature = None + signature_types = [ + "application/pkcs7-signature", + "application/x-pkcs7-signature", + ] + for part in self.payload.walk(): + if part.get_content_type() in signature_types: + signature = part.get_payload(decode=True) + elif part.get_content_type() == "multipart/report": + self.payload = part - # Call the find message callback which should return a Message instance - orig_message = find_message_cb(orig_message_id, orig_recipient) + # Verify the message, first using raw message and if it fails + # then convert to canonical form and try again + mic_content = extract_first_part(raw_content, message_boundary) + verify_cert = orig_message.receiver.load_verify_cert() + try: + self.digest_alg = verify_message( + mic_content, signature, verify_cert + ) + except IntegrityError: + mic_content = canonicalize(self.payload) + self.digest_alg = verify_message( + mic_content, signature, verify_cert + ) - # Extract the headers and save it - mdn_headers = {} - for k, v in self.payload.items(): - k = k.lower() - if k == 'message-id': - self.message_id = v.lstrip('<').rstrip('>') - mdn_headers[k] = v - - if orig_message.receiver.mdn_digest_alg \ - and self.payload.get_content_type() != 'multipart/signed': - status = 'failed/Failure' - detailed_status = 'Expected signed MDN but unsigned MDN returned' - return status, detailed_status - - if self.payload.get_content_type() == 'multipart/signed': - signature = None - message_boundary = ( - '--' + self.payload.get_boundary()).encode('utf-8') for part in self.payload.walk(): - if part.get_content_type() == 'application/pkcs7-signature': - signature = part.get_payload(decode=True) - elif part.get_content_type() == 'multipart/report': - self.payload = part - - # Verify the message, first using raw message and if it fails - # then convert to canonical form and try again - mic_content = extract_first_part(raw_content, message_boundary) - try: - self.digest_alg = verify_message( - mic_content, signature, orig_message.receiver.verify_cert) - except IntegrityError: - mic_content = canonicalize(self.payload) - self.digest_alg = verify_message( - mic_content, signature, orig_message.receiver.verify_cert) - - for part in self.payload.walk(): - if part.get_content_type() == 'message/disposition-notification': - mdn = part.get_payload().pop() - mdn_status = mdn['Disposition'].split( - ';').pop().strip().split(':') - status = mdn_status[0] - if status == 'processed': - mdn_mic = mdn.get('Received-Content-MIC', '').split(',')[0] - - # TODO: Check MIC for all cases - if mdn_mic and orig_message.mic \ - and mdn_mic != orig_message.mic.decode(): - status = 'processed/warning' - detailed_status = 'Message Integrity check failed.' - else: - detailed_status = ' '.join(mdn_status[1:]).strip() - + if part.get_content_type() == "message/disposition-notification": + logger.debug( + f"MDN report for message {orig_message.message_id}:\n{part.as_string()}" + ) + + mdn = part.get_payload()[-1] + mdn_status = mdn["Disposition"].split(";").pop().strip().split(":") + status = mdn_status[0] + if status == "processed": + # Compare the original mic with the received mic + mdn_mic = mdn.get("Received-Content-MIC", "").split(",")[0] + if ( + mdn_mic + and orig_message.mic + and mdn_mic != orig_message.mic.decode() + ): + status = "processed/warning" + detailed_status = "Message Integrity check failed." + else: + detailed_status = " ".join(mdn_status[1:]).strip() + except MDNNotFound: + status = "failed/Failure" + detailed_status = "mdn-not-found" + except Exception as e: # pylint: disable=W0703 + status = "failed/Failure" + detailed_status = f"Failed to parse received MDN. {e}" + logger.error(f"Failed to parse AS2 MDN\n: {traceback.format_exc()}") return status, detailed_status def detect_mdn(self): - """ Function checks if the received raw message is an AS2 MDN or not. - - :raises MDNNotFound: If the received payload is not an MDN then this + """Function checks if the received raw message is an AS2 MDN or not. + + :raises MDNNotFound: If the received payload is not an MDN then this exception is raised. - + :return: A two element tuple containing (message_id, message_recipient). The message_id is the original AS2 message id and the message_recipient is the original AS2 message recipient. """ mdn_message = None - if self.payload.get_content_type() == 'multipart/report': + if self.payload.get_content_type() == "multipart/report": mdn_message = self.payload - elif self.payload.get_content_type() == 'multipart/signed': + elif self.payload.get_content_type() == "multipart/signed": for part in self.payload.walk(): - if part.get_content_type() == 'multipart/report': + if part.get_content_type() == "multipart/report": mdn_message = self.payload if not mdn_message: - raise MDNNotFound('No MDN found in the received message') + raise MDNNotFound("No MDN found in the received message") message_id, message_recipient = None, None for part in mdn_message.walk(): - if part.get_content_type() == 'message/disposition-notification': + if part.get_content_type() == "message/disposition-notification": mdn = part.get_payload()[0] - message_id = mdn.get('Original-Message-ID') - message_recipient = mdn.get('Original-Recipient').split(';')[1] + message_id = mdn.get("Original-Message-ID").strip("<>") + message_recipient = None + if "Original-Recipient" in mdn: + message_recipient = mdn["Original-Recipient"].split(";")[1].strip() + elif "Final-Recipient" in mdn: + message_recipient = mdn["Final-Recipient"].split(";")[1].strip() return message_id, message_recipient diff --git a/pyas2lib/cms.py b/pyas2lib/cms.py index e454951..02fe8c7 100644 --- a/pyas2lib/cms.py +++ b/pyas2lib/cms.py @@ -1,202 +1,273 @@ -from __future__ import absolute_import, unicode_literals -from asn1crypto import cms, core, algos -from oscrypto import asymmetric, symmetric, util -from datetime import datetime -from collections import OrderedDict -from .compat import byte_cls -from .exceptions import * +"""Define functions related to the CMS operations such as encrypting, signature, etc.""" import hashlib import zlib +from collections import OrderedDict +from datetime import datetime, timezone -DIGEST_ALGORITHMS = ( - 'md5', - 'sha1', - 'sha224', - 'sha256', - 'sha384', - 'sha512' -) -ENCRYPTION_ALGORITHMS = ( - 'tripledes_192_cbc', - 'rc2_128_cbc', - 'rc4_128_cbc' +from asn1crypto import cms, core, algos +from oscrypto import asymmetric, symmetric, util + +from pyas2lib.exceptions import ( + AS2Exception, + DecompressionError, + DecryptionError, + IntegrityError, ) +from pyas2lib.constants import DIGEST_ALGORITHMS def compress_message(data_to_compress): """Function compresses data and returns the generated ASN.1 - + :param data_to_compress: A byte string of the data to be compressed - - :return: A CMS ASN.1 byte string of the compressed data. + + :return: A CMS ASN.1 byte string of the compressed data. """ - compressed_content = cms.ParsableOctetString( - zlib.compress(data_to_compress)) - return cms.ContentInfo({ - 'content_type': cms.ContentType('compressed_data'), - 'content': cms.CompressedData({ - 'version': cms.CMSVersion('v0'), - 'compression_algorithm': - cms.CompressionAlgorithm({ - 'algorithm': cms.CompressionAlgorithmId('zlib') - }), - 'encap_content_info': cms.EncapsulatedContentInfo({ - 'content_type': cms.ContentType('data'), - 'content': compressed_content - }) - }) - }).dump() + compressed_content = cms.ParsableOctetString(zlib.compress(data_to_compress)) + return cms.ContentInfo( + { + "content_type": cms.ContentType("compressed_data"), + "content": cms.CompressedData( + { + "version": cms.CMSVersion("v0"), + "compression_algorithm": cms.CompressionAlgorithm( + {"algorithm": cms.CompressionAlgorithmId("zlib")} + ), + "encap_content_info": cms.EncapsulatedContentInfo( + { + "content_type": cms.ContentType("data"), + "content": compressed_content, + } + ), + } + ), + } + ).dump() def decompress_message(compressed_data): - """Function parses an ASN.1 compressed message and extracts/decompresses + """Function parses an ASN.1 compressed message and extracts/decompresses the original message. - - :param compressed_data: A CMS ASN.1 byte string containing the compressed + + :param compressed_data: A CMS ASN.1 byte string containing the compressed data. - :return: A byte string containing the decompressed original message. + :return: A byte string containing the decompressed original message. """ - decompressed_content = '' try: cms_content = cms.ContentInfo.load(compressed_data) - - if cms_content['content_type'].native == 'compressed_data': - decompressed_content = cms_content['content'].decompressed - else: - raise DecompressionError('Compressed data not found in ASN.1 ') + if cms_content["content_type"].native == "compressed_data": + return cms_content["content"].decompressed + raise DecompressionError("Compressed data not found in ASN.1 ") except Exception as e: - raise DecompressionError( - 'Decompression failed with cause: {}'.format(e)) - - finally: - return decompressed_content + raise DecompressionError("Decompression failed with cause: {}".format(e)) def encrypt_message(data_to_encrypt, enc_alg, encryption_cert): """Function encrypts data and returns the generated ASN.1 :param data_to_encrypt: A byte string of the data to be encrypted - :param enc_alg: The algorithm to be used for encrypting the data - :param encryption_cert: The certificate to be used for encrypting the data - :return: A CMS ASN.1 byte string of the encrypted data. + :return: A CMS ASN.1 byte string of the encrypted data. """ - enc_alg_list = enc_alg.split('_') - cipher, key_length, mode = enc_alg_list[0], enc_alg_list[1], enc_alg_list[2] - enc_alg_asn1, key, encrypted_content = None, None, None + enc_alg_list = enc_alg.split("_") + cipher, key_length, _ = enc_alg_list[0], enc_alg_list[1], enc_alg_list[2] # Generate the symmetric encryption key and encrypt the message - if cipher == 'tripledes': - key = util.rand_bytes(int(key_length)//8) + key = util.rand_bytes(int(key_length) // 8) + if cipher == "tripledes": + algorithm_id = "1.2.840.113549.3.7" iv, encrypted_content = symmetric.tripledes_cbc_pkcs5_encrypt( - key, data_to_encrypt, None) - enc_alg_asn1 = algos.EncryptionAlgorithm({ - 'algorithm': algos.EncryptionAlgorithmId('tripledes_3key'), - 'parameters': cms.OctetString(iv) - }) + key, data_to_encrypt, None + ) + enc_alg_asn1 = algos.EncryptionAlgorithm( + {"algorithm": algorithm_id, "parameters": cms.OctetString(iv)} + ) + + elif cipher == "rc2": + algorithm_id = "1.2.840.113549.3.2" + iv, encrypted_content = symmetric.rc2_cbc_pkcs5_encrypt( + key, data_to_encrypt, None + ) + enc_alg_asn1 = algos.EncryptionAlgorithm( + { + "algorithm": algorithm_id, + "parameters": algos.Rc2Params({"iv": cms.OctetString(iv)}), + } + ) + + elif cipher == "rc4": + algorithm_id = "1.2.840.113549.3.4" + encrypted_content = symmetric.rc4_encrypt(key, data_to_encrypt) + enc_alg_asn1 = algos.EncryptionAlgorithm( + { + "algorithm": algorithm_id, + } + ) + + elif cipher == "aes": + if key_length == "128": + algorithm_id = "2.16.840.1.101.3.4.1.2" + elif key_length == "192": + algorithm_id = "2.16.840.1.101.3.4.1.22" + else: + algorithm_id = "2.16.840.1.101.3.4.1.42" + + iv, encrypted_content = symmetric.aes_cbc_pkcs7_encrypt( + key, data_to_encrypt, None + ) + enc_alg_asn1 = algos.EncryptionAlgorithm( + {"algorithm": algorithm_id, "parameters": cms.OctetString(iv)} + ) + elif cipher == "des": + algorithm_id = "1.3.14.3.2.7" + iv, encrypted_content = symmetric.des_cbc_pkcs5_encrypt( + key, data_to_encrypt, None + ) + enc_alg_asn1 = algos.EncryptionAlgorithm( + {"algorithm": algorithm_id, "parameters": cms.OctetString(iv)} + ) + else: + raise AS2Exception("Unsupported Encryption Algorithm") # Encrypt the key and build the ASN.1 message encrypted_key = asymmetric.rsa_pkcs1v15_encrypt(encryption_cert, key) - return cms.ContentInfo({ - 'content_type': cms.ContentType('enveloped_data'), - 'content': cms.EnvelopedData({ - 'version': cms.CMSVersion('v0'), - 'recipient_infos': [ - cms.KeyTransRecipientInfo({ - 'version': cms.CMSVersion('v0'), - 'rid': cms.RecipientIdentifier({ - 'issuer_and_serial_number': cms.IssuerAndSerialNumber({ - 'issuer': encryption_cert.asn1[ - 'tbs_certificate']['issuer'], - 'serial_number': encryption_cert.asn1[ - 'tbs_certificate']['serial_number'] - }) - }), - 'key_encryption_algorithm': cms.KeyEncryptionAlgorithm({ - 'algorithm': cms.KeyEncryptionAlgorithmId('rsa') - }), - 'encrypted_key': cms.OctetString(encrypted_key) - }) - ], - 'encrypted_content_info': cms.EncryptedContentInfo({ - 'content_type': cms.ContentType('data'), - 'content_encryption_algorithm': enc_alg_asn1, - 'encrypted_content': encrypted_content - }) - }) - }).dump() + return cms.ContentInfo( + { + "content_type": cms.ContentType("enveloped_data"), + "content": cms.EnvelopedData( + { + "version": cms.CMSVersion("v0"), + "recipient_infos": [ + cms.KeyTransRecipientInfo( + { + "version": cms.CMSVersion("v0"), + "rid": cms.RecipientIdentifier( + { + "issuer_and_serial_number": cms.IssuerAndSerialNumber( + { + "issuer": encryption_cert.asn1[ + "tbs_certificate" + ]["issuer"], + "serial_number": encryption_cert.asn1[ + "tbs_certificate" + ]["serial_number"], + } + ) + } + ), + "key_encryption_algorithm": cms.KeyEncryptionAlgorithm( + {"algorithm": cms.KeyEncryptionAlgorithmId("rsa")} + ), + "encrypted_key": cms.OctetString(encrypted_key), + } + ) + ], + "encrypted_content_info": cms.EncryptedContentInfo( + { + "content_type": cms.ContentType("data"), + "content_encryption_algorithm": enc_alg_asn1, + "encrypted_content": encrypted_content, + } + ), + } + ), + } + ).dump() def decrypt_message(encrypted_data, decryption_key): - """Function parses an ASN.1 encrypted message and extracts/decrypts - the original message. + """Function parses an ASN.1 encrypted message and extracts/decrypts the original message. - :param encrypted_data: A CMS ASN.1 byte string containing the encrypted - data. - + :param encrypted_data: A CMS ASN.1 byte string containing the encrypted data. :param decryption_key: The key to be used for decrypting the data. - :return: A byte string containing the decrypted original message. + :return: A byte string containing the decrypted original message. """ cms_content = cms.ContentInfo.load(encrypted_data) cipher, decrypted_content = None, None - if cms_content['content_type'].native == 'enveloped_data': - recipient_info = cms_content['content']['recipient_infos'][0].parse() - key_enc_alg = recipient_info[ - 'key_encryption_algorithm']['algorithm'].native - encrypted_key = recipient_info['encrypted_key'].native + if cms_content["content_type"].native == "enveloped_data": + recipient_info = cms_content["content"]["recipient_infos"][0].parse() + key_enc_alg = recipient_info["key_encryption_algorithm"]["algorithm"].native + encrypted_key = recipient_info["encrypted_key"].native - if key_enc_alg == 'rsa': + if cms.KeyEncryptionAlgorithmId(key_enc_alg) == cms.KeyEncryptionAlgorithmId( + "rsa" + ): try: - key = asymmetric.rsa_pkcs1v15_decrypt( - decryption_key[0], encrypted_key) - except Exception as e: - raise DecryptionError('Failed to decrypt the payload: ' - 'Could not extract decryption key.') - alg = cms_content['content']['encrypted_content_info'][ - 'content_encryption_algorithm'] + key = asymmetric.rsa_pkcs1v15_decrypt(decryption_key[0], encrypted_key) + except Exception: + raise DecryptionError( + "Failed to decrypt the payload: Could not extract decryption key." + ) - encapsulated_data = cms_content['content'][ - 'encrypted_content_info']['encrypted_content'].native + alg = cms_content["content"]["encrypted_content_info"][ + "content_encryption_algorithm" + ] + encapsulated_data = cms_content["content"]["encrypted_content_info"][ + "encrypted_content" + ].native try: - if alg.encryption_cipher == 'tripledes': - cipher = 'tripledes_192_cbc' + if alg["algorithm"].native == "rc4": + decrypted_content = symmetric.rc4_decrypt(key, encapsulated_data) + elif alg.encryption_cipher == "tripledes": + cipher = "tripledes_192_cbc" decrypted_content = symmetric.tripledes_cbc_pkcs5_decrypt( - key, encapsulated_data, alg.encryption_iv) + key, encapsulated_data, alg.encryption_iv + ) + elif alg.encryption_cipher == "aes": + decrypted_content = symmetric.aes_cbc_pkcs7_decrypt( + key, encapsulated_data, alg.encryption_iv + ) + elif alg.encryption_cipher == "rc2": + decrypted_content = symmetric.rc2_cbc_pkcs5_decrypt( + key, encapsulated_data, alg["parameters"]["iv"].native + ) else: - raise AS2Exception('Unsupported Encryption Algorithm') + raise AS2Exception("Unsupported Encryption Algorithm") except Exception as e: - raise DecryptionError( - 'Failed to decrypt the payload: {}'.format(e)) + raise DecryptionError("Failed to decrypt the payload: {}".format(e)) + else: + raise AS2Exception("Unsupported Encryption Algorithm") + else: + raise DecryptionError("Encrypted data not found in ASN.1 ") return cipher, decrypted_content -def sign_message(data_to_sign, digest_alg, sign_key, - use_signed_attributes=True): +def sign_message( + data_to_sign, + digest_alg, + sign_key, + sign_alg="rsassa_pkcs1v15", + use_signed_attributes=True, +): """Function signs the data and returns the generated ASN.1 :param data_to_sign: A byte string of the data to be signed. - :param digest_alg: - The digest algorithm to be used for generating the signature. + :param digest_alg: The digest algorithm to be used for generating the signature. :param sign_key: The key to be used for generating the signature. - - :param use_signed_attributes: Optional attribute to indicate weather the + + :param sign_alg: The algorithm to be used for signing the message. + + :param use_signed_attributes: Optional attribute to indicate weather the CMS signature attributes should be included in the signature or not. - :return: A CMS ASN.1 byte string of the signed data. + :return: A CMS ASN.1 byte string of the signed data. """ + digest_alg = digest_alg.lower() + if digest_alg not in DIGEST_ALGORITHMS: + raise AS2Exception("Unsupported Digest Algorithm") if use_signed_attributes: digest_func = hashlib.new(digest_alg) @@ -204,173 +275,245 @@ def sign_message(data_to_sign, digest_alg, sign_key, message_digest = digest_func.digest() class SmimeCapability(core.Sequence): + """"Define the possible list of Smime Capability.""" + _fields = [ - ('0', core.Any, {'optional': True}), - ('1', core.Any, {'optional': True}), - ('2', core.Any, {'optional': True}), - ('3', core.Any, {'optional': True}), - ('4', core.Any, {'optional': True}) + ("0", core.Any, {"optional": True}), + ("1", core.Any, {"optional": True}), + ("2", core.Any, {"optional": True}), + ("3", core.Any, {"optional": True}), + ("4", core.Any, {"optional": True}), ] class SmimeCapabilities(core.Sequence): + """"Define the Smime Capabilities supported by pyas2.""" + _fields = [ - ('0', SmimeCapability), - ('1', SmimeCapability, {'optional': True}), - ('2', SmimeCapability, {'optional': True}), - ('3', SmimeCapability, {'optional': True}), - ('4', SmimeCapability, {'optional': True}), - ('5', SmimeCapability, {'optional': True}), + ("0", SmimeCapability), + ("1", SmimeCapability, {"optional": True}), + ("2", SmimeCapability, {"optional": True}), + ("3", SmimeCapability, {"optional": True}), + ("4", SmimeCapability, {"optional": True}), + ("5", SmimeCapability, {"optional": True}), ] - smime_cap = OrderedDict([ - ('0', OrderedDict([ - ('0', core.ObjectIdentifier('1.2.840.113549.3.7'))])), - ('1', OrderedDict([ - ('0', core.ObjectIdentifier('1.2.840.113549.3.2')), - ('1', core.Integer(128))])), - ('2', OrderedDict([ - ('0', core.ObjectIdentifier('1.2.840.113549.3.4')), - ('1', core.Integer(128))])), - ]) - - signed_attributes = cms.CMSAttributes([ - cms.CMSAttribute({ - 'type': cms.CMSAttributeType('content_type'), - 'values': cms.SetOfContentType([ - cms.ContentType('data') - ]) - }), - cms.CMSAttribute({ - 'type': cms.CMSAttributeType('signing_time'), - 'values': cms.SetOfTime([ - cms.Time({ - 'utc_time': core.UTCTime(datetime.now()) - }) - ]) - }), - cms.CMSAttribute({ - 'type': cms.CMSAttributeType('message_digest'), - 'values': cms.SetOfOctetString([ - core.OctetString(message_digest) - ]) - }), - cms.CMSAttribute({ - 'type': cms.CMSAttributeType('1.2.840.113549.1.9.15'), - 'values': cms.SetOfAny([ - core.Any(SmimeCapabilities(smime_cap)) - ]) - }), - ]) - signature = asymmetric.rsa_pkcs1v15_sign( - sign_key[0], signed_attributes.dump(), digest_alg) + smime_cap = OrderedDict( + [ + ( + "0", + OrderedDict( + [("0", core.ObjectIdentifier("2.16.840.1.101.3.4.1.42"))] + ), + ), + ( + "1", + OrderedDict( + [("0", core.ObjectIdentifier("2.16.840.1.101.3.4.1.2"))] + ), + ), + ( + "2", + OrderedDict([("0", core.ObjectIdentifier("1.2.840.113549.3.7"))]), + ), + ( + "3", + OrderedDict( + [ + ("0", core.ObjectIdentifier("1.2.840.113549.3.2")), + ("1", core.Integer(128)), + ] + ), + ), + ( + "4", + OrderedDict( + [ + ("0", core.ObjectIdentifier("1.2.840.113549.3.4")), + ("1", core.Integer(128)), + ] + ), + ), + ] + ) + + signed_attributes = cms.CMSAttributes( + [ + cms.CMSAttribute( + { + "type": cms.CMSAttributeType("content_type"), + "values": cms.SetOfContentType([cms.ContentType("data")]), + } + ), + cms.CMSAttribute( + { + "type": cms.CMSAttributeType("signing_time"), + "values": cms.SetOfTime( + [ + cms.Time( + { + "utc_time": core.UTCTime( + datetime.utcnow().replace( + tzinfo=timezone.utc + ) + ) + } + ) + ] + ), + } + ), + cms.CMSAttribute( + { + "type": cms.CMSAttributeType("message_digest"), + "values": cms.SetOfOctetString( + [core.OctetString(message_digest)] + ), + } + ), + cms.CMSAttribute( + { + "type": cms.CMSAttributeType("1.2.840.113549.1.9.15"), + "values": cms.SetOfAny( + [core.Any(SmimeCapabilities(smime_cap))] + ), + } + ), + ] + ) else: signed_attributes = None - signature = asymmetric.rsa_pkcs1v15_sign( - sign_key[0], data_to_sign, digest_alg) - - return cms.ContentInfo({ - 'content_type': cms.ContentType('signed_data'), - 'content': cms.SignedData({ - 'version': cms.CMSVersion('v1'), - 'digest_algorithms': cms.DigestAlgorithms([ - algos.DigestAlgorithm({ - 'algorithm': algos.DigestAlgorithmId(digest_alg) - }) - ]), - 'encap_content_info': cms.ContentInfo({ - 'content_type': cms.ContentType('data') - }), - 'certificates': cms.CertificateSet([ - cms.CertificateChoices({ - 'certificate': sign_key[1].asn1 - }) - ]), - 'signer_infos': cms.SignerInfos([ - cms.SignerInfo({ - 'version': cms.CMSVersion('v1'), - 'sid': cms.SignerIdentifier({ - 'issuer_and_serial_number': cms.IssuerAndSerialNumber({ - 'issuer': sign_key[1].asn1[ - 'tbs_certificate']['issuer'], - 'serial_number': sign_key[1].asn1[ - 'tbs_certificate']['serial_number'] - }) - }), - 'digest_algorithm': algos.DigestAlgorithm({ - 'algorithm': algos.DigestAlgorithmId(digest_alg) - }), - 'signed_attrs': signed_attributes, - 'signature_algorithm': algos.SignedDigestAlgorithm({ - 'algorithm': - algos.SignedDigestAlgorithmId('rsassa_pkcs1v15') - }), - 'signature': core.OctetString(signature) - }) - ]) - }) - }).dump() + # Generate the signature + data_to_sign = signed_attributes.dump() if signed_attributes else data_to_sign + if sign_alg == "rsassa_pkcs1v15": + signature = asymmetric.rsa_pkcs1v15_sign(sign_key[0], data_to_sign, digest_alg) + elif sign_alg == "rsassa_pss": + signature = asymmetric.rsa_pss_sign(sign_key[0], data_to_sign, digest_alg) + else: + raise AS2Exception("Unsupported Signature Algorithm") + + return cms.ContentInfo( + { + "content_type": cms.ContentType("signed_data"), + "content": cms.SignedData( + { + "version": cms.CMSVersion("v1"), + "digest_algorithms": cms.DigestAlgorithms( + [ + algos.DigestAlgorithm( + {"algorithm": algos.DigestAlgorithmId(digest_alg)} + ) + ] + ), + "encap_content_info": cms.ContentInfo( + {"content_type": cms.ContentType("data")} + ), + "certificates": cms.CertificateSet( + [cms.CertificateChoices({"certificate": sign_key[1].asn1})] + ), + "signer_infos": cms.SignerInfos( + [ + cms.SignerInfo( + { + "version": cms.CMSVersion("v1"), + "sid": cms.SignerIdentifier( + { + "issuer_and_serial_number": cms.IssuerAndSerialNumber( + { + "issuer": sign_key[1].asn1[ + "tbs_certificate" + ]["issuer"], + "serial_number": sign_key[1].asn1[ + "tbs_certificate" + ]["serial_number"], + } + ) + } + ), + "digest_algorithm": algos.DigestAlgorithm( + { + "algorithm": algos.DigestAlgorithmId( + digest_alg + ) + } + ), + "signed_attrs": signed_attributes, + "signature_algorithm": algos.SignedDigestAlgorithm( + { + "algorithm": algos.SignedDigestAlgorithmId( + sign_alg + ) + } + ), + "signature": core.OctetString(signature), + } + ) + ] + ), + } + ), + } + ).dump() -def verify_message(data_to_verify, signature, verify_cert): - """Function parses an ASN.1 encrypted message and extracts/decrypts - the original message. - :param data_to_verify: - A byte string of the data to be verified against the signature. +def verify_message(data_to_verify, signature, verify_cert): + """Function parses an ASN.1 encrypted message and extracts/decrypts the original message. + :param data_to_verify: A byte string of the data to be verified against the signature. :param signature: A CMS ASN.1 byte string containing the signature. - :param verify_cert: The certificate to be used for verifying the signature. - :return: The digest algorithm that was used in the signature. + :return: The digest algorithm that was used in the signature. """ cms_content = cms.ContentInfo.load(signature) digest_alg = None + if cms_content["content_type"].native == "signed_data": - if cms_content['content_type'].native == 'signed_data': - for signer in cms_content['content']['signer_infos']: - - signed_attributes = signer['signed_attrs'].copy() - digest_alg = signer['digest_algorithm']['algorithm'].native + for signer in cms_content["content"]["signer_infos"]: + digest_alg = signer["digest_algorithm"]["algorithm"].native if digest_alg not in DIGEST_ALGORITHMS: - raise Exception('Unsupported Digest Algorithm') + raise Exception("Unsupported Digest Algorithm") - sig_alg = signer['signature_algorithm']['algorithm'].native - sig = signer['signature'].native + sig_alg = signer["signature_algorithm"].signature_algo + sig = signer["signature"].native signed_data = data_to_verify - if signed_attributes: + if signer["signed_attrs"]: attr_dict = {} - for attr in signed_attributes.native: - attr_dict[attr['type']] = attr['values'] - - message_digest = byte_cls() - for d in attr_dict['message_digest']: + for attr in signer["signed_attrs"]: + try: + attr_dict[attr.native["type"]] = attr.native["values"] + except (ValueError, KeyError): + continue + + message_digest = bytes() + for d in attr_dict["message_digest"]: message_digest += d digest_func = hashlib.new(digest_alg) digest_func.update(data_to_verify) calc_message_digest = digest_func.digest() - if message_digest != calc_message_digest: - raise IntegrityError('Failed to verify message signature: ' - 'Message Digest does not match.') + raise IntegrityError( + "Failed to verify message signature: Message Digest does not match." + ) - signed_data = signed_attributes.untag().dump() + signed_data = signer["signed_attrs"].untag().dump() try: - if sig_alg == 'rsassa_pkcs1v15': + if sig_alg == "rsassa_pkcs1v15": asymmetric.rsa_pkcs1v15_verify( - verify_cert, sig, signed_data, digest_alg) - elif sig_alg == 'rsassa_pss': - asymmetric.rsa_pss_verify( - verify_cert, sig, signed_data, digest_alg) + verify_cert, sig, signed_data, digest_alg + ) + elif sig_alg == "rsassa_pss": + asymmetric.rsa_pss_verify(verify_cert, sig, signed_data, digest_alg) else: - raise AS2Exception('Unsupported Signature Algorithm') + raise AS2Exception("Unsupported Signature Algorithm") except Exception as e: - raise IntegrityError( - 'Failed to verify message signature: {}'.format(e)) + raise IntegrityError("Failed to verify message signature: {}".format(e)) + else: + raise IntegrityError("Signed data not found in ASN.1 ") return digest_alg diff --git a/pyas2lib/compat.py b/pyas2lib/compat.py deleted file mode 100644 index 8535022..0000000 --- a/pyas2lib/compat.py +++ /dev/null @@ -1,88 +0,0 @@ -import sys - -# Syntax sugar. -_ver = sys.version_info - -#: Python 2.x? -is_py2 = (_ver[0] == 2) - -#: Python 3.x? -is_py3 = (_ver[0] == 3) - - -if is_py2: - str_cls = unicode # noqa - byte_cls = str - int_types = (int, long) # noqa - from email import message_from_string as parse_mime - from cStringIO import StringIO - from cStringIO import StringIO as BytesIO - from email.generator import Generator, NL, fcre, _make_boundary - - class BytesGenerator(Generator): - - def _handle_multipart(self, msg): - # The trick here is to write out each part separately, merge the all - # together, and then make sure that the boundary we've chosen isn't - # present in the payload. - msgtexts = [] - subparts = msg.get_payload() - if subparts is None: - subparts = [] - elif isinstance(subparts, basestring): - # e.g. a non-strict parse of a message with no starting boundary - self._fp.write(subparts) - return - elif not isinstance(subparts, list): - # Scalar payload - subparts = [subparts] - for part in subparts: - s = StringIO() - g = self.clone(s) - g.flatten(part, unixfrom=False) - msgtexts.append(s.getvalue()) - # BAW: What about boundaries that are wrapped in double-quotes? - boundary = msg.get_boundary() - if not boundary: - # Create a boundary that doesn't appear in any of the - # message texts. - alltext = NL.join(msgtexts) - boundary = _make_boundary(alltext) - msg.set_boundary(boundary) - # If there's a preamble, write it out, with a trailing CRLF - if msg.preamble is not None: - if self._mangle_from_: - preamble = fcre.sub('>From ', msg.preamble) - else: - preamble = msg.preamble - print >> self._fp, preamble - # dash-boundary transport-padding CRLF - print >> self._fp, '--' + boundary - # body-part - if msgtexts: - self._fp.write(msgtexts.pop(0)) - # *encapsulation - # --> delimiter transport-padding - # --> CRLF body-part - for body_part in msgtexts: - # delimiter transport-padding CRLF - print >> self._fp, '\n--' + boundary - # body-part - self._fp.write(body_part) - # close-delimiter transport-padding - self._fp.write('\n--' + boundary + '--' + NL) - if msg.epilogue is not None: - if self._mangle_from_: - epilogue = fcre.sub('>From ', msg.epilogue) - else: - epilogue = msg.epilogue - self._fp.write(epilogue) - -elif is_py3: - str_cls = str - byte_cls = bytes - int_types = int - from email import message_from_bytes as parse_mime - from io import StringIO - from io import BytesIO - from email.generator import Generator, BytesGenerator diff --git a/pyas2lib/constants.py b/pyas2lib/constants.py new file mode 100644 index 0000000..53e6c1f --- /dev/null +++ b/pyas2lib/constants.py @@ -0,0 +1,30 @@ +"""Module for defining the constants used by pyas2lib""" + +AS2_VERSION = "1.2" + +EDIINT_FEATURES = "CMS" + +SYNCHRONOUS_MDN = "SYNC" +ASYNCHRONOUS_MDN = "ASYNC" + +MDN_MODES = (SYNCHRONOUS_MDN, ASYNCHRONOUS_MDN) + +MDN_CONFIRM_TEXT = ( + "The AS2 message has been successfully processed. " + "Thank you for exchanging AS2 messages with pyAS2." +) + +MDN_FAILED_TEXT = ( + "The AS2 message could not be processed. The " + "disposition-notification report has additional details." +) + +DIGEST_ALGORITHMS = ("md5", "sha1", "sha224", "sha256", "sha384", "sha512") +ENCRYPTION_ALGORITHMS = ( + "tripledes_192_cbc", + "rc2_128_cbc", + "rc4_128_cbc", + "aes_128_cbc", + "aes_192_cbc", + "aes_256_cbc", +) diff --git a/pyas2lib/exceptions.py b/pyas2lib/exceptions.py index 980f1a3..1b1c2d6 100644 --- a/pyas2lib/exceptions.py +++ b/pyas2lib/exceptions.py @@ -1,9 +1,16 @@ from __future__ import absolute_import, unicode_literals __all__ = [ - 'ImproperlyConfigured', 'AS2Exception', 'DecompressionError', - 'DecryptionError', 'InsufficientSecurityError', 'IntegrityError', - 'UnexpectedError', 'MDNNotFound', 'PartnerNotFound', 'DuplicateDocument' + "ImproperlyConfigured", + "AS2Exception", + "DecompressionError", + "DecryptionError", + "InsufficientSecurityError", + "IntegrityError", + "UnexpectedError", + "MDNNotFound", + "PartnerNotFound", + "DuplicateDocument", ] @@ -20,8 +27,8 @@ class AS2Exception(Exception): apply to :class:`~pyas2lib.ImproperlyConfigured`). """ - disposition_type = 'failed/Failure' - disposition_modifier = '' + disposition_type = "failed/Failure" + disposition_modifier = "" def __init__(self, message, disposition_modifier=None): super(AS2Exception, self).__init__(message) @@ -33,52 +40,52 @@ class PartnerNotFound(AS2Exception): """Raised when the partner/organization for the message could not be found in the system""" - disposition_type = 'processed/Error' - disposition_modifier = 'unknown-trading-partner' + disposition_type = "processed/Error" + disposition_modifier = "unknown-trading-partner" class DuplicateDocument(AS2Exception): """Raised when a message with a duplicate message ID has been received""" - disposition_type = 'processed/Warning' - disposition_modifier = 'duplicate-document' + disposition_type = "processed/Warning" + disposition_modifier = "duplicate-document" class InsufficientSecurityError(AS2Exception): """Exception raised when the message security is not as per the settings for the partner.""" - disposition_type = 'processed/Error' - disposition_modifier = 'insufficient-message-security' + disposition_type = "processed/Error" + disposition_modifier = "insufficient-message-security" class DecompressionError(AS2Exception): """Raised when the decompression process fails.""" - disposition_type = 'processed/Error' - disposition_modifier = 'decompression-failed' + disposition_type = "processed/Error" + disposition_modifier = "decompression-failed" class DecryptionError(AS2Exception): """Exception raised when decryption process fails.""" - disposition_type = 'processed/Error' - disposition_modifier = 'decryption-failed' + disposition_type = "processed/Error" + disposition_modifier = "decryption-failed" class IntegrityError(AS2Exception): """Raised when a signed message signature verification fails""" - disposition_type = 'processed/Error' - disposition_modifier = 'authentication-failed' + disposition_type = "processed/Error" + disposition_modifier = "authentication-failed" class UnexpectedError(AS2Exception): """A catch all exception to be raised for any error found while parsing - a received AS2 message""" + a received AS2 message""" - disposition_type = 'processed/Error' - disposition_modifier = 'unexpected-processing-error' + disposition_type = "processed/Error" + disposition_modifier = "unexpected-processing-error" class MDNNotFound(AS2Exception): diff --git a/pyas2lib/tests/__init__.py b/pyas2lib/tests/__init__.py new file mode 100644 index 0000000..ca88db0 --- /dev/null +++ b/pyas2lib/tests/__init__.py @@ -0,0 +1,28 @@ +import unittest +import os + +TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "fixtures") + + +class Pyas2TestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + """Perform the setup actions for the test case.""" + file_list = { + "test_data": "payload.txt", + "test_data_dos": "payload_dos.txt", + "private_key": "cert_test.p12", + "public_key": "cert_test_public.pem", + "mecas2_public_key": "cert_mecas2_public.pem", + "oldpyas2_public_key": "cert_oldpyas2_public.pem", + "oldpyas2_private_key": "cert_oldpyas2_private.pem", + "sb2bi_public_key": "cert_sb2bi_public.pem", + "sb2bi_public_ca": "cert_sb2bi_public.ca", + "private_cer": "cert_extract_private.cer", + "private_pem": "cert_extract_private.pem", + } + + # Load the files to the attrs + for attr, filename in file_list.items(): + with open(os.path.join(TEST_DIR, filename), "rb") as fp: + setattr(cls, attr, fp.read()) diff --git a/pyas2lib/tests/fixtures/cert_extract_private.cer b/pyas2lib/tests/fixtures/cert_extract_private.cer new file mode 100644 index 0000000..d984cce Binary files /dev/null and b/pyas2lib/tests/fixtures/cert_extract_private.cer differ diff --git a/pyas2lib/tests/fixtures/cert_extract_private.pem b/pyas2lib/tests/fixtures/cert_extract_private.pem new file mode 100644 index 0000000..dc5736e --- /dev/null +++ b/pyas2lib/tests/fixtures/cert_extract_private.pem @@ -0,0 +1,49 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFHzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQIgfSzwLtO4wsCAggA +MB0GCWCGSAFlAwQBKgQQfAIjuBBfqCedJyQh0fvnRASCBNDbGjojvuy02uUpmPCr +sFdlxbAbnodKqxFi7xamh2uuqpl430S7R2QEuCZJUOzjIPPfAhdleVbdU56QIztz +d+RI+/jE8tLWPGeQ0vF1IOeSZqSjPf0ETxiRqrpEJ0KurLnyeul/7nOeEwe/bQAc +kKCDR80d7MZbPQd4L/kZ0uG2VU1tpuVljpkqI778QgOgX08/CGHNKXks3c/Yhfn1 +AxRx6eg1y0gazkR9g9wsOAJ69TAjX1hoBlmSZw22nJNZtn8eRlgzWKcKluI0NKFR +V97CA8WKUb59posspV4iyLFiFE+TvO/oMz8CzAoguLHIW5lGX5tQ6HUqbxEHkMbH +7Hv+6JhQGdHVeGZppcuIeWc/gnCc2X5PWSTx+0c7vBv2FIHM0WjX/krNagzcAL/A +u/6pGv8DrLLQTCSSdCojWJhD6q1VdiXkWnI2uQDHUNFODxywfmUkGROHHF8BcNsA +4YNKtNCEkNCs3QfoRkLXyRhEL6Rb0k1woR0iz8zK3hiuBTVvu3z6kASoJZJk7isF +DbeekZB2dnrOWZs9HcJ8gNWV61nQg9q67Rf8GzK0DA30r6tFLlWwqmWSzR3QucB/ +ddLXHiq9DyBlSznowYliGo8smHH+oxliMcZ7B8AmmrKwpEHKRGxFR1LkE9PFBIKI +X07PcuZRpynIq2/W+HFSRxFBjUWm7lsykO+ciojbj2caODfKfWs/Ma2kscopghBQ +hqqRzKlsfOxZBWeiJYrqLHZDz4asiYC1gvrc8Krx8u2mZdBodo7T2jfLAtaHMYnR +JwWEhPMq6Ixhc9OnsRVH+KeolthyT0XjR19quqH9mB8oByAhR0eQcmduPoMTwknU +Lah//rckT7sNGvJwum3iGUtIE0y0GBcU/OQ94bHelYKL4kZu//mXvvy+B0eYbqjW +3C5uy6GhPjBQ6BBMuafu+tfJ/ZGOU3ZG0g/4yrspa/qN5JuDzdMeKax8Y9jQZ5Ba +ZdjCnvr5MWO6krC6evQlkmnag+IOTAfqv+mBtOgZjVS9I49s+6XzR4UNr8dAKMg5 +E53dM2gHvg5k80i6JksspONP6+m+rL0ckrB2pYkWrGUyQi/U5f8h2CCChJfLPAEl +PUuhG9Ynh7rGubFtLFe3+RvHWtnIRmg+pxW+W9HBhv26qhkkTlx/AdVeYSoPaldG +7KsJX/6qJA4EJ4v4QWyyupCYJMmTePx/i4kIFz/CxEDiUc+5BQh08+gafiDvMaNc +7uiyhBCm3/BVWb9lSem770nz1QawH3Te4fxgKUTlj2ICaQBj37QvkVawjFaawdRq +KvtwagY/B2d8kIjgRWxRbAe/DCv9cpXxdkgsOANZn4S5fn/jmmSpG6t5zQTu55TT +Fkk4uESDIjpUYs65uuNeWZTAuNEiSJON4ha4W8h0/6g3MAjNyJ7YAQgOSSMuSGqk +xdyjWRJ0zmheL2NFYxIAmlrOquMwHMkU8YJb+9jI6RkNq8szkFenIKfCMgb9yfrX +P0aGOz3AvKRlTpcIgY1TDNoGQ0pSlMjW/VmhJJEfeOjbsQdJVr+XObmggXBYv8wE +DJhQXvVSV83iuyq7rCuEEjPs5gpKkq+K5AqXmTtJzFtVMcQ/BmJdVDssPEnjNmL8 +visz5I234/hl2utyZOj4yTSiYg== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDBDCCAewCCQC+x6S5XDiB+TANBgkqhkiG9w0BAQsFADBEMQswCQYDVQQGEwJB +VTETMBEGA1UECAwKU29tZS1TdGF0ZTERMA8GA1UECgwIcHlhczJsaWIxDTALBgNV +BAMMBHRlc3QwHhcNMTkwNjAzMTEzMjU3WhcNMjkwNTMxMTEzMjU3WjBEMQswCQYD +VQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTERMA8GA1UECgwIcHlhczJsaWIx +DTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/ +ialOlAPsVGq9n3cEhFHBO9G9DyZlket2gVkVk/ONF9fqgRd1uGdrhqqOw0dwjYWH +/heuKF4FbkiNGD9r8iOF2B/Wnj8iEJO0Mc5rKKKmi5e2w/84M9VVYhkpo9AGtb0q +3COtIqbp5qU7FTqyOsvTvCa13gAVVhHm8naLxCkp6MnL0om2kNK3Exv8rYQybbpe +iLkdZda/3Qo4QEvSS4EKeQsdnN6/W7Rf9GM8gpFXCKykP2tNsESHndIxXrFBPHma +qvA8llncXyUBPJtUFrhb7Q2n+dLT07TmoctOMm+B/Dw6bN7+lHMW44/9xxMCVd/i +muhYicU7rx+bU9bWPNTFAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHfyQ15A/L6A +NZjzwScbmkjnIngjSblxOeTG30Vgcm9f+4T+bLwuF6jd4F5FngkDb/9oE3N3toEk +OwRVtV4mKhiJa5Vn1KGFqDzZ8Hs6GaKaxAFpa8XqPEQx/edVyRmX2S1MFp1qEovu +ldTsYtIC3v2ZmwoxBqPf84974cdmF6j0FrnT/eaBUWCDhjn/XFpL5ZnoDS5JSw5j +E1CpLbNRi4q6fSM+VRPrr1qkGcaXduLUN58B31QizcjCEy2XjAvVSgAq8IoILt3/ +bIUOY/Wbp1BQvVEBxALc34yxWOcUbSamIm6KYSLoMpWOsZAoyuQa2rAXIwAZwejY +9RfBSn/YWSo= +-----END CERTIFICATE----- diff --git a/pyas2lib/tests/fixtures/cert_extract_public.cer b/pyas2lib/tests/fixtures/cert_extract_public.cer new file mode 100644 index 0000000..d984cce Binary files /dev/null and b/pyas2lib/tests/fixtures/cert_extract_public.cer differ diff --git a/pyas2lib/tests/fixtures/cert_extract_public.pem b/pyas2lib/tests/fixtures/cert_extract_public.pem new file mode 100644 index 0000000..2523b15 --- /dev/null +++ b/pyas2lib/tests/fixtures/cert_extract_public.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDBDCCAewCCQC+x6S5XDiB+TANBgkqhkiG9w0BAQsFADBEMQswCQYDVQQGEwJB +VTETMBEGA1UECAwKU29tZS1TdGF0ZTERMA8GA1UECgwIcHlhczJsaWIxDTALBgNV +BAMMBHRlc3QwHhcNMTkwNjAzMTEzMjU3WhcNMjkwNTMxMTEzMjU3WjBEMQswCQYD +VQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTERMA8GA1UECgwIcHlhczJsaWIx +DTALBgNVBAMMBHRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC/ +ialOlAPsVGq9n3cEhFHBO9G9DyZlket2gVkVk/ONF9fqgRd1uGdrhqqOw0dwjYWH +/heuKF4FbkiNGD9r8iOF2B/Wnj8iEJO0Mc5rKKKmi5e2w/84M9VVYhkpo9AGtb0q +3COtIqbp5qU7FTqyOsvTvCa13gAVVhHm8naLxCkp6MnL0om2kNK3Exv8rYQybbpe +iLkdZda/3Qo4QEvSS4EKeQsdnN6/W7Rf9GM8gpFXCKykP2tNsESHndIxXrFBPHma +qvA8llncXyUBPJtUFrhb7Q2n+dLT07TmoctOMm+B/Dw6bN7+lHMW44/9xxMCVd/i +muhYicU7rx+bU9bWPNTFAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHfyQ15A/L6A +NZjzwScbmkjnIngjSblxOeTG30Vgcm9f+4T+bLwuF6jd4F5FngkDb/9oE3N3toEk +OwRVtV4mKhiJa5Vn1KGFqDzZ8Hs6GaKaxAFpa8XqPEQx/edVyRmX2S1MFp1qEovu +ldTsYtIC3v2ZmwoxBqPf84974cdmF6j0FrnT/eaBUWCDhjn/XFpL5ZnoDS5JSw5j +E1CpLbNRi4q6fSM+VRPrr1qkGcaXduLUN58B31QizcjCEy2XjAvVSgAq8IoILt3/ +bIUOY/Wbp1BQvVEBxALc34yxWOcUbSamIm6KYSLoMpWOsZAoyuQa2rAXIwAZwejY +9RfBSn/YWSo= +-----END CERTIFICATE----- diff --git a/tests/fixtures/cert_mecas2_public.pem b/pyas2lib/tests/fixtures/cert_mecas2_public.pem similarity index 100% rename from tests/fixtures/cert_mecas2_public.pem rename to pyas2lib/tests/fixtures/cert_mecas2_public.pem diff --git a/pyas2lib/tests/fixtures/cert_oldpyas2_private.pem b/pyas2lib/tests/fixtures/cert_oldpyas2_private.pem new file mode 100644 index 0000000..5360dee --- /dev/null +++ b/pyas2lib/tests/fixtures/cert_oldpyas2_private.pem @@ -0,0 +1,50 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFHzBJBgkqhkiG9w0BBQ0wPDAbBgkqhkiG9w0BBQwwDgQIBkTXi4mo7gUCAggA +MB0GCWCGSAFlAwQBKgQQegRDZRY+TL4QgRAWcqdz7wSCBNCGs2pr91erMuGIN8P0 +BQPmi4ohcUmhI+q/jtSYIGhkdsV9HTnVncMdr77RO+8xVn9QGpJFxTs+zhOzdsqK +SIcB1EbBjg2OXZ6tKID0Xc2L4ZBGLEV230m995IiiGqPkpF+JYqzcsbVTPFPgs/Y +V4crs3BAJfPw3jgH92++7C5Kvn7hLy8JYbnpGb8mEBoM4QIZ0forKPwOQTQtWQ/W +82hnEiMEoYIRjdWMAxPIdBBZEsMd55b0jVNxIW/1JZWM+yp9FZNrE7XobzVXUQSo +6CRqEYvBzJ6+X223bMac8X100zl7NzXxCns8/JN9ts+6x89cqdhCxwjLRlixovvR +tPla9JFFLvie94gd685D5WtKfg6srhNCqr2j8nSIC3FrXyAnOeLJavznxOstey5b +jt1zK6RunZgO9SuBpq5qSr4huUiH7edbMw8WnNhY/ur51GBpjiE4XIVbsPVoeCjz +CL4XgGoJExOSj7n3IvZCTXKxQ9G9M8nk3LYPmIuAEKFduErarwML3GtJsj1r6HbD +BS+WD18BZv4YhhLqPIlwwPszVUNp6NX/AGhs8URGYTTV5iA37doKR+rN6GXVCwDA +Us4xdIdNfVMs/PNDlV4qZ4VrnC2w/6n3N2/K1EwmDPxICaWAtnXBDWMQiXjXPsSW +8TUIjZMCFgzIqg7PSh4wtIlaz4xmV3EIjyw2oQVc8NRK01TEhW4ZvEi3WEQB16Un +fu24OpNPSDYyeUBbnNQ1bF+Hg2d2d/OiGR8Fma/dW2obhFJVz7CDXt0LeazYR3wZ +4tIdec4t5cxRb+CTlpdv+4AcvPYC3y4nwDYxUAO1rKQthnFCkrWm/Uy6PxbF83NS +95/6YuMoKb3bYZQSe566F5t/SL6zEYOFNiLWRbYqtLE1ICoAKus7AhbaLQuLsVW0 +/0zYCDVuc5cNt3Lk1MlFc7m8h+jVRroQpipdG+5UslSNYZJIh5QhGPcly+c0vHpa +mDIX3nv7o4SdJAOuEek4XLRb9Ov/2JC3ajkr2NLXiNyH2HUvDGVujLzOE2ZfUozE +u8EjmYY1bTIJuq5+86IRt5QWchs1jWKklgo3PIpZGmUk2CxdgyoA9BHFR9TVQn+j +BxeKDJFbYX5GG/pByFe2EMllF6yN4PQyeEUMFlo0Tv5o0vsh2nwlkUZxpvPfU188 +M+YIZ9wv1Wkcv+xjjHCSMuLDFS5Ajc+5as7eE0gwKabmEmj/z8/0b/cyLBfbpOt7 +87lul9b5Se5UJd83rRZE+QjFUzRWxeGJcHrFh6s3eTJIJUlQLhlcbUSowq7hWjti +0j+YIkGmFdk5QpEXR/+3DYUwXAemoBWz3eXs4NF09mZ/qASpA67xU7tXKMxl+Aoh +HHS89RiTUetdu4Y6GiLtp31D5VsEJvqel0beiS16KK1mayPJjW1FOLcH2X56Ew04 +d+2dsFEk/3gGTrHP8MXJqa3kHjc0erdVlIkJBpB91A5VQjUaYXA6V+kMeKKUAZFg +2bolAGt1i3aCpYtosGEOdrlP7LTcwBi6QgmJs6IBuELnT5F+kyGEdqgwyhToduHS +yQ9e/pyExp8aK3YOqMCSP9yj49aPHkhmnyKNp6pjw1ar3EY3sUcWvhyDhq41sjq6 +hPQEpLerIScoJyTuDTg0WfIlIA== +-----END ENCRYPTED PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDLjCCAhYCCQD7SuhYur9VTTANBgkqhkiG9w0BAQsFADBZMQswCQYDVQQGEwJJ +TjETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 +cyBQdHkgTHRkMRIwEAYDVQQDDAlhczJzZXJ2ZXIwHhcNMTcxMjIwMDkxNDU3WhcN +MjIxMjE5MDkxNDU3WjBZMQswCQYDVQQGEwJJTjETMBEGA1UECAwKU29tZS1TdGF0 +ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRIwEAYDVQQDDAlh +czJzZXJ2ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDNC0Vq/TJz +Qasi8VqCvMhDRSN68C00J4GbDGxbbIjpXZ9+4wKd7JcPm6HOsVfQK0xxbtox/BGP +392Hwz2gjn9nEgwuE7wJD0c5zFrRcQlG1avF0CBgFPa1PuSyTrH8JJ+OFK5dO0Su +W5RfaW/CAwp65mVYcGhWKvQ+D7alCvQ8VD50PMj/vw3Wdsry9TQ44VtAz5WUK/pd +AeNbnyMRS4984o4ycUiGWI1hI3xgzFGmLrBEaSBMxTOrifpx1SnE8pRkX3iLO93E +ec0QlNdUdc+mSOSEnEayANuxcQFegFIDajMs+jGVzCu0hDfQx9IfKSwhIc/Ay0v9 +ONyTdapqopgVAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAESihgqCRSt5v2aIcoUj +BgvX4nZw0RP/rD8cGc3LA/kDLiRne/tZRzCNPPZrEjO3pyBwI1XzaFP6jxcFPVPS +JefBg2KZVoY2sRNaoXru9x8QWVbrdwWaoLg+v6Oj9iwr9CqMMEa9xegEjje87Sga +gSfYasgEWmx0g+l1rbLxkBeUfDKSGRMqi3r9MryV9udTao+4G7H8WFSHThActlxV +BQAHYVMlr6+O16AMwbKwJWIJ5jT8F8pe4aWZWtd0+EzrmP0MxQvEgObD1gk28miQ +6MkIbx+9vkBytM9LlME1YUWvQfBy8lKt93+FiTDplpwgDau7pTGGqXJen/wYjq+w +Bxo= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/fixtures/cert_oldpyas2_public.pem b/pyas2lib/tests/fixtures/cert_oldpyas2_public.pem similarity index 100% rename from tests/fixtures/cert_oldpyas2_public.pem rename to pyas2lib/tests/fixtures/cert_oldpyas2_public.pem diff --git a/pyas2lib/tests/fixtures/cert_sb2bi_public.ca b/pyas2lib/tests/fixtures/cert_sb2bi_public.ca new file mode 100644 index 0000000..9a259ac --- /dev/null +++ b/pyas2lib/tests/fixtures/cert_sb2bi_public.ca @@ -0,0 +1,55 @@ +-----BEGIN CERTIFICATE----- +MIIEPjCCAyagAwIBAgIESlOMKDANBgkqhkiG9w0BAQsFADCBvjELMAkGA1UEBhMC +VVMxFjAUBgNVBAoTDUVudHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50 +cnVzdC5uZXQvbGVnYWwtdGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3Qs +IEluYy4gLSBmb3IgYXV0aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVz +dCBSb290IENlcnRpZmljYXRpb24gQXV0aG9yaXR5IC0gRzIwHhcNMDkwNzA3MTcy +NTU0WhcNMzAxMjA3MTc1NTU0WjCBvjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUVu +dHJ1c3QsIEluYy4xKDAmBgNVBAsTH1NlZSB3d3cuZW50cnVzdC5uZXQvbGVnYWwt +dGVybXMxOTA3BgNVBAsTMChjKSAyMDA5IEVudHJ1c3QsIEluYy4gLSBmb3IgYXV0 +aG9yaXplZCB1c2Ugb25seTEyMDAGA1UEAxMpRW50cnVzdCBSb290IENlcnRpZmlj +YXRpb24gQXV0aG9yaXR5IC0gRzIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK +AoIBAQC6hLZy254Ma+KZ6TABp3bqMriVQRrJ2mFOWHLP/vaCeb9zYQYKpSfYs1/T +RU4cctZOMvJyig/3gxnQaoCAAEUesMfnmr8SVycco2gvCoe9amsOXmXzHHfV1IWN +cCG0szLni6LVhjkCsbjSR87kyUnEO6fe+1R9V77w6G7CebI6C1XiUJgWMhNcL3hW +wcKUs/Ja5CeanyTXxuzQmyWC48zCxEXFjJd6BmsqEZ+pCm5IO2/b1BEZQvePB7/1 +U1+cPvQXLOZprE4yTGJ36rfo5bs0vBmLrpxR57d+tVOxMyLlbc9wPBr64ptntoP0 +jaWvYkxN4FisZDQSA/i2jZRjJKRxAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAP +BgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRqciZ60B7vfec7aVHUbI2fkBJmqzAN +BgkqhkiG9w0BAQsFAAOCAQEAeZ8dlsa2eT8ijYfThwMEYGprmi5ZiXMRrEPR9RP/ +jTkrwPK9T3CMqS/qF8QLVJ7UG5aYMzyorWKiAHarWWluBh1+xLlEjZivEtRh2woZ +Rkfz6/djwUAFQKXSt/S1mja/qYh2iARVBCuch38aNzx+LaUa2NSJXsq9rD1s2G2v +1fN2D807iDginWyTmsQ9v4IbZT+mD12q/OWyFcq1rca8PdCE6OoGcrBNOTJ4vz4R +nAuknZoh8/CbCzB428Hch0P+vGOaysXCHMnHjf87ElgI5rY97HosTvuDls4MPGmH +VHOkc8KT/1EQrBVUAdj8BbGJoX90g5pJ19xOe4pIb4tF9g== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFDjCCA/agAwIBAgIMDulMwwAAAABR03eFMA0GCSqGSIb3DQEBCwUAMIG+MQsw +CQYDVQQGEwJVUzEWMBQGA1UEChMNRW50cnVzdCwgSW5jLjEoMCYGA1UECxMfU2Vl +IHd3dy5lbnRydXN0Lm5ldC9sZWdhbC10ZXJtczE5MDcGA1UECxMwKGMpIDIwMDkg +RW50cnVzdCwgSW5jLiAtIGZvciBhdXRob3JpemVkIHVzZSBvbmx5MTIwMAYDVQQD +EylFbnRydXN0IFJvb3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBHMjAeFw0x +NTEwMDUxOTEzNTZaFw0zMDEyMDUxOTQzNTZaMIG6MQswCQYDVQQGEwJVUzEWMBQG +A1UEChMNRW50cnVzdCwgSW5jLjEoMCYGA1UECxMfU2VlIHd3dy5lbnRydXN0Lm5l +dC9sZWdhbC10ZXJtczE5MDcGA1UECxMwKGMpIDIwMTIgRW50cnVzdCwgSW5jLiAt +IGZvciBhdXRob3JpemVkIHVzZSBvbmx5MS4wLAYDVQQDEyVFbnRydXN0IENlcnRp +ZmljYXRpb24gQXV0aG9yaXR5IC0gTDFLMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEA2j+W0E25L0Tn2zlem1DuXKVh2kFnUwmqAJqOV38pa9vH4SEkqjrQ +jUcj0u1yFvCRIdJdt7hLqIOPt5EyaM/OJZMssn2XyP7BtBe6CZ4DkJN7fEmDImiK +m95HwzGYei59QAvS7z7Tsoyqj0ip/wDoKVgG97aTWpRzJiatWA7lQrjV6nN5ZGhT +JbiEz5R6rgZFDKNrTdDGvuoYpDbwkrK6HIiPOlJ/915tgxyd8B/lw9bdpXiSPbBt +LOrJz5RBGXFEaLpHPATpXbo+8DX3Fbae8i4VHj9HyMg4p3NFXU2wO7GOFyk36t0F +ASK7lDYqjVs1/lMZLwhGwSqzGmIdTivZGwIDAQABo4IBDDCCAQgwDgYDVR0PAQH/ +BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwMwYIKwYBBQUHAQEEJzAlMCMGCCsG +AQUFBzABhhdodHRwOi8vb2NzcC5lbnRydXN0Lm5ldDAwBgNVHR8EKTAnMCWgI6Ah +hh9odHRwOi8vY3JsLmVudHJ1c3QubmV0L2cyY2EuY3JsMDsGA1UdIAQ0MDIwMAYE +VR0gADAoMCYGCCsGAQUFBwIBFhpodHRwOi8vd3d3LmVudHJ1c3QubmV0L3JwYTAd +BgNVHQ4EFgQUgqJwdN28Uz/Pe9T3zX+nYMYKTL8wHwYDVR0jBBgwFoAUanImetAe +733nO2lR1GyNn5ASZqswDQYJKoZIhvcNAQELBQADggEBADnVjpiDYcgsY9NwHRkw +y/YJrMxp1cncN0HyMg/vdMNY9ngnCTQIlZIv19+4o/0OgemknNM/TWgrFTEKFcxS +BJPok1DD2bHi4Wi3Ogl08TRYCj93mEC45mj/XeTIRsXsgdfJghhcg85x2Ly/rJkC +k9uUmITSnKa1/ly78EqvIazCP0kkZ9Yujs+szGQVGHLlbHfTUqi53Y2sAEo1GdRv +c6N172tkw+CNgxKhiucOhk3YtCAbvmqljEtoZuMrx1gL+1YQ1JH7HdMxWBCMRON1 +exCdtTix9qrKgWRs6PLigVWXUX/hwidQosk8WwBD9lu51aX8/wdQQGcHsFXwt35u +Lcw= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/pyas2lib/tests/fixtures/cert_sb2bi_public.pem b/pyas2lib/tests/fixtures/cert_sb2bi_public.pem new file mode 100644 index 0000000..2c2de76 --- /dev/null +++ b/pyas2lib/tests/fixtures/cert_sb2bi_public.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFQzCCBCugAwIBAgIRAPyxGMfr4QFOAAAAAFDb+P8wDQYJKoZIhvcNAQELBQAw +gboxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1FbnRydXN0LCBJbmMuMSgwJgYDVQQL +Ex9TZWUgd3d3LmVudHJ1c3QubmV0L2xlZ2FsLXRlcm1zMTkwNwYDVQQLEzAoYykg +MjAxMiBFbnRydXN0LCBJbmMuIC0gZm9yIGF1dGhvcml6ZWQgdXNlIG9ubHkxLjAs +BgNVBAMTJUVudHJ1c3QgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkgLSBMMUswHhcN +MTcwMzI0MTgwODAzWhcNMjAwNjIzMTgzODAxWjBoMQswCQYDVQQGEwJVUzEOMAwG +A1UECBMFVGV4YXMxDzANBgNVBAcTBlRlbXBsZTEdMBsGA1UEChMUTWNMYW5lIENv +bXBhbnksIEluYy4xGTAXBgNVBAMTEGIyYi5tY2xhbmVjby5jb20wggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDDDv75fHPPyLdBRQkNlkQuk2sKO//abQ+L +mp2xSrHWnnWpTAcuu9H6MQDKPkQKu0mUzNh1WexfmrnutuZeLOmJLTujTuYqp9Hc +p6jKlsQeR9tBe1lVFRNI7L1uHdwiiBFOhViUwsBwWDO1fZ7aUfBXQJrduxeE6O42 +8PaPtk9LP5vsFj6L8MKDskbNee8/v24MPje5e+EesLxmVRESSnC8xV651BQT6lzU +m/bFEzGV8RQLo1Cv6/Qsdb56I2PZYWJyLVItB4h3WvCuZYkwZtzZCShaW4ZV1hGS +IBlHyrvX7CARBTeBWsx0myu7Emvshrk1mSfRwxsm8jTMK/RVP7VVAgMBAAGjggGT +MIIBjzAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwMwYDVR0f +BCwwKjAooCagJIYiaHR0cDovL2NybC5lbnRydXN0Lm5ldC9sZXZlbDFrLmNybDBL +BgNVHSAERDBCMDYGCmCGSAGG+mwKAQUwKDAmBggrBgEFBQcCARYaaHR0cDovL3d3 +dy5lbnRydXN0Lm5ldC9ycGEwCAYGZ4EMAQICMGgGCCsGAQUFBwEBBFwwWjAjBggr +BgEFBQcwAYYXaHR0cDovL29jc3AuZW50cnVzdC5uZXQwMwYIKwYBBQUHMAKGJ2h0 +dHA6Ly9haWEuZW50cnVzdC5uZXQvbDFrLWNoYWluMjU2LmNlcjAxBgNVHREEKjAo +ghBiMmIubWNsYW5lY28uY29tghR3d3cuYjJiLm1jbGFuZWNvLmNvbTAfBgNVHSME +GDAWgBSConB03bxTP8971PfNf6dgxgpMvzAdBgNVHQ4EFgQUiTGHatRioLvWttlv +dXrAH6AKVbIwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAAr7qLcJUTyYc +9COEI84PTLZx1tRbzZWBzVk3Xa3ucNJY+ifii9LmiU9IZS+Mn+ikN71u7Npj3PBq +rutMS03P/ILObp1Ankil2jVxpQcy1d/VGKSis9IhMmQWzIUqOFfsbZ6SsirQA2Y4 +Vz5GGyR6VG37Gn5fZJXKXeottFVVLZkE+/FC+AJJWNGEgt3quo6GENz+3/LgtkUZ +KGlIIBNCrB6/mTTR03MKYiNtxrMgnmcgVtc8W8g1zsXdaTKcpJzZwVKwNuqw6YfE +teP7dKGOKPvqL/i23fHBBKx7DdHRlvVlL04FAK/YuXq06TFOf/AG626j3uxwmRQ2 +7AL3IEu8sA== +-----END CERTIFICATE----- \ No newline at end of file diff --git a/tests/fixtures/cert_test.p12 b/pyas2lib/tests/fixtures/cert_test.p12 similarity index 100% rename from tests/fixtures/cert_test.p12 rename to pyas2lib/tests/fixtures/cert_test.p12 diff --git a/tests/fixtures/cert_test.pem b/pyas2lib/tests/fixtures/cert_test.pem similarity index 100% rename from tests/fixtures/cert_test.pem rename to pyas2lib/tests/fixtures/cert_test.pem diff --git a/tests/fixtures/cert_test_public.pem b/pyas2lib/tests/fixtures/cert_test_public.pem similarity index 100% rename from tests/fixtures/cert_test_public.pem rename to pyas2lib/tests/fixtures/cert_test_public.pem diff --git a/tests/fixtures/mecas2_compressed.as2 b/pyas2lib/tests/fixtures/mecas2_compressed.as2 similarity index 100% rename from tests/fixtures/mecas2_compressed.as2 rename to pyas2lib/tests/fixtures/mecas2_compressed.as2 diff --git a/tests/fixtures/mecas2_compressed_signed_encrypted.as2 b/pyas2lib/tests/fixtures/mecas2_compressed_signed_encrypted.as2 similarity index 100% rename from tests/fixtures/mecas2_compressed_signed_encrypted.as2 rename to pyas2lib/tests/fixtures/mecas2_compressed_signed_encrypted.as2 diff --git a/tests/fixtures/mecas2_encrypted.as2 b/pyas2lib/tests/fixtures/mecas2_encrypted.as2 similarity index 100% rename from tests/fixtures/mecas2_encrypted.as2 rename to pyas2lib/tests/fixtures/mecas2_encrypted.as2 diff --git a/tests/fixtures/mecas2_signed.as2 b/pyas2lib/tests/fixtures/mecas2_signed.as2 similarity index 100% rename from tests/fixtures/mecas2_signed.as2 rename to pyas2lib/tests/fixtures/mecas2_signed.as2 diff --git a/tests/fixtures/mecas2_signed.mdn b/pyas2lib/tests/fixtures/mecas2_signed.mdn similarity index 100% rename from tests/fixtures/mecas2_signed.mdn rename to pyas2lib/tests/fixtures/mecas2_signed.mdn diff --git a/tests/fixtures/mecas2_signed_encrypted.as2 b/pyas2lib/tests/fixtures/mecas2_signed_encrypted.as2 similarity index 100% rename from tests/fixtures/mecas2_signed_encrypted.as2 rename to pyas2lib/tests/fixtures/mecas2_signed_encrypted.as2 diff --git a/tests/fixtures/mecas2_unsigned.mdn b/pyas2lib/tests/fixtures/mecas2_unsigned.mdn similarity index 100% rename from tests/fixtures/mecas2_unsigned.mdn rename to pyas2lib/tests/fixtures/mecas2_unsigned.mdn diff --git a/tests/fixtures/payload.binary b/pyas2lib/tests/fixtures/payload.binary similarity index 100% rename from tests/fixtures/payload.binary rename to pyas2lib/tests/fixtures/payload.binary diff --git a/tests/fixtures/payload.txt b/pyas2lib/tests/fixtures/payload.txt similarity index 100% rename from tests/fixtures/payload.txt rename to pyas2lib/tests/fixtures/payload.txt diff --git a/pyas2lib/tests/fixtures/payload_dos.txt b/pyas2lib/tests/fixtures/payload_dos.txt new file mode 100644 index 0000000..379bf8e --- /dev/null +++ b/pyas2lib/tests/fixtures/payload_dos.txt @@ -0,0 +1,28 @@ +UNB+UNOA:2+:14+:14+140407:0910+5++++1+EANCOM' +UNH+1+ORDERS:D:96A:UN:EAN008' +BGM+220+1AA1TEST+9' +DTM+137:20140407:102' +DTM+63:20140421:102' +DTM+64:20140414:102' +RFF+ADE:1234' +RFF+PD:1704' +NAD+BY+5450534000024::9' +NAD+SU+::9' +NAD+DP+5450534000109::9+++++++GB' +NAD+IV+5450534000055::9++AMAZON EU SARL:5 RUE PLAETIS LUXEMBOURG+CO PO BOX 4558+SLOUGH++SL1 0TX+GB' +RFF+VA:GB727255821' +CUX+2:EUR:9' +LIN+1++9783898307529:EN' +QTY+21:5' +PRI+AAA:27.5' +LIN+2++390787706322:UP' +QTY+21:1' +PRI+AAA:10.87' +LIN+3' +PIA+5+3899408268X-39:SA' +QTY+21:3' +PRI+AAA:3.85' +UNS+S' +CNT+2:3' +UNT+26+1' +UNZ+1+5' diff --git a/pyas2lib/tests/fixtures/sb2bi_signed.mdn b/pyas2lib/tests/fixtures/sb2bi_signed.mdn new file mode 100644 index 0000000..456dbd3 Binary files /dev/null and b/pyas2lib/tests/fixtures/sb2bi_signed.mdn differ diff --git a/pyas2lib/tests/fixtures/sb2bi_signed_cmp.msg b/pyas2lib/tests/fixtures/sb2bi_signed_cmp.msg new file mode 100644 index 0000000..8790d07 Binary files /dev/null and b/pyas2lib/tests/fixtures/sb2bi_signed_cmp.msg differ diff --git a/tests/fixtures/verify_cert_test1.pem b/pyas2lib/tests/fixtures/verify_cert_test1.pem similarity index 100% rename from tests/fixtures/verify_cert_test1.pem rename to pyas2lib/tests/fixtures/verify_cert_test1.pem diff --git a/tests/fixtures/verify_cert_test2.cer b/pyas2lib/tests/fixtures/verify_cert_test2.cer similarity index 100% rename from tests/fixtures/verify_cert_test2.cer rename to pyas2lib/tests/fixtures/verify_cert_test2.cer diff --git a/tests/fixtures/verify_cert_test3.ca b/pyas2lib/tests/fixtures/verify_cert_test3.ca similarity index 100% rename from tests/fixtures/verify_cert_test3.ca rename to pyas2lib/tests/fixtures/verify_cert_test3.ca diff --git a/tests/fixtures/verify_cert_test3.pem b/pyas2lib/tests/fixtures/verify_cert_test3.pem similarity index 100% rename from tests/fixtures/verify_cert_test3.pem rename to pyas2lib/tests/fixtures/verify_cert_test3.pem diff --git a/tests/livetest_with_mecas2.py b/pyas2lib/tests/livetest_with_mecas2.py similarity index 61% rename from tests/livetest_with_mecas2.py rename to pyas2lib/tests/livetest_with_mecas2.py index 0e3c1c3..12649da 100644 --- a/tests/livetest_with_mecas2.py +++ b/pyas2lib/tests/livetest_with_mecas2.py @@ -1,28 +1,30 @@ -from __future__ import unicode_literals, absolute_import, print_function -from . import as2, PYAS2TestCase -import requests +"""Module for testing with a live mecas2 server.""" import os -TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testdata') +import requests + +from pyas2lib import as2 +from . import Pyas2TestCase +TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testdata") -class LiveTestMecAS2(PYAS2TestCase): +class LiveTestMecAS2(Pyas2TestCase): def setUp(self): self.org = as2.Organization( - as2_id='pyas2lib', + as2_name="pyas2lib", sign_key=self.private_key, - sign_key_pass='test'.encode('utf-8'), + sign_key_pass="test", decrypt_key=self.private_key, - decrypt_key_pass='test'.encode('utf-8') + decrypt_key_pass="test", ) self.partner = as2.Partner( - as2_id='mecas2', + as2_name="mecas2", verify_cert=self.mecas2_public_key, encrypt_cert=self.mecas2_public_key, mdn_mode=as2.SYNCHRONOUS_MDN, - mdn_digest_alg='sha256' + mdn_digest_alg="sha256", ) self.out_message = None @@ -34,20 +36,21 @@ def test_compressed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/as2/HttpReceiver', + "http://localhost:8080/as2/HttpReceiver", headers=self.out_message.headers, - data=self.out_message.content + data=self.out_message.content, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) + raw_mdn += "{}: {}\n".format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_encrypted_message(self): """ Send Encrypted Unsigned Uncompressed Message to Mendelson AS2""" @@ -57,20 +60,21 @@ def test_encrypted_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/as2/HttpReceiver', + "http://localhost:8080/as2/HttpReceiver", headers=self.out_message.headers, - data=self.out_message.content + data=self.out_message.content, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) + raw_mdn += "{}: {}\n".format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_signed_message(self): """ Send Unencrypted Signed Uncompressed Message to Mendelson AS2""" @@ -80,20 +84,21 @@ def test_signed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/as2/HttpReceiver', + "http://localhost:8080/as2/HttpReceiver", data=self.out_message.content, headers=self.out_message.headers, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn += "{}: {}\n".format(k, v) + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_encrypted_signed_message(self): """ Send Encrypted Signed Uncompressed Message to Mendelson AS2""" @@ -104,20 +109,21 @@ def test_encrypted_signed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/as2/HttpReceiver', + "http://localhost:8080/as2/HttpReceiver", data=self.out_message.content, headers=self.out_message.headers, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn += "{}: {}\n".format(k, v) + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_encrypted_signed_compressed_message(self): """ Send Encrypted Signed Compressed Message to Mendelson AS2""" @@ -129,20 +135,21 @@ def test_encrypted_signed_compressed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/as2/HttpReceiver', + "http://localhost:8080/as2/HttpReceiver", data=self.out_message.content, headers=self.out_message.headers, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn += "{}: {}\n".format(k, v) + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def find_org(self, headers): return self.org diff --git a/tests/livetest_with_oldpyas2.py b/pyas2lib/tests/livetest_with_oldpyas2.py similarity index 61% rename from tests/livetest_with_oldpyas2.py rename to pyas2lib/tests/livetest_with_oldpyas2.py index 631f059..6b30c49 100644 --- a/tests/livetest_with_oldpyas2.py +++ b/pyas2lib/tests/livetest_with_oldpyas2.py @@ -1,28 +1,30 @@ -from __future__ import unicode_literals, absolute_import, print_function -from . import as2, PYAS2TestCase -import requests +"""Module for testing with a live old pyas2 server.""" import os -TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testdata') +import requests + +from pyas2lib import as2 +from . import Pyas2TestCase +TEST_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testdata") -class LiveTestMecAS2(PYAS2TestCase): +class LiveTestMecAS2(Pyas2TestCase): def setUp(self): self.org = as2.Organization( - as2_id='pyas2lib', + as2_name="pyas2lib", sign_key=self.private_key, - sign_key_pass='test'.encode('utf-8'), + sign_key_pass="test", decrypt_key=self.private_key, - decrypt_key_pass='test'.encode('utf-8') + decrypt_key_pass="test", ) self.partner = as2.Partner( - as2_id='pyas2idev', + as2_name="pyas2idev", verify_cert=self.oldpyas2_public_key, encrypt_cert=self.oldpyas2_public_key, mdn_mode=as2.SYNCHRONOUS_MDN, - mdn_digest_alg='sha256' + mdn_digest_alg="sha256", ) self.out_message = None @@ -34,20 +36,21 @@ def test_compressed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/pyas2/as2receive', + "http://localhost:8080/pyas2/as2receive", headers=self.out_message.headers, - data=self.out_message.content + data=self.out_message.content, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) + raw_mdn += "{}: {}\n".format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_encrypted_message(self): """ Send Encrypted Unsigned Uncompressed Message to Mendelson AS2""" @@ -57,20 +60,21 @@ def test_encrypted_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/pyas2/as2receive', + "http://localhost:8080/pyas2/as2receive", headers=self.out_message.headers, - data=self.out_message.content + data=self.out_message.content, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) + raw_mdn += "{}: {}\n".format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_signed_message(self): """ Send Unencrypted Signed Uncompressed Message to Mendelson AS2""" @@ -80,20 +84,21 @@ def test_signed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/pyas2/as2receive', + "http://localhost:8080/pyas2/as2receive", data=self.out_message.content, headers=self.out_message.headers, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn += "{}: {}\n".format(k, v) + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_encrypted_signed_message(self): """ Send Encrypted Signed Uncompressed Message to Mendelson AS2""" @@ -104,20 +109,21 @@ def test_encrypted_signed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/pyas2/as2receive', + "http://localhost:8080/pyas2/as2receive", data=self.out_message.content, headers=self.out_message.headers, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn += "{}: {}\n".format(k, v) + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def test_encrypted_signed_compressed_message(self): """ Send Encrypted Signed Compressed Message to Mendelson AS2""" @@ -129,20 +135,21 @@ def test_encrypted_signed_compressed_message(self): self.out_message.build(self.test_data) response = requests.post( - 'http://localhost:8080/pyas2/as2receive', + "http://localhost:8080/pyas2/as2receive", data=self.out_message.content, headers=self.out_message.headers, ) - raw_mdn = '' + raw_mdn = "" for k, v in response.headers.items(): - raw_mdn += '{}: {}\n'.format(k, v) - raw_mdn = raw_mdn + '\n' + response.text + raw_mdn += "{}: {}\n".format(k, v) + raw_mdn = raw_mdn + "\n" + response.text - out_mdn = as2.MDN() + out_mdn = as2.Mdn() status, detailed_status = out_mdn.parse( - raw_mdn, find_message_cb=self.find_message) - self.assertEqual(status, 'processed') + raw_mdn, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") def find_org(self, headers): return self.org diff --git a/pyas2lib/tests/test_advanced.py b/pyas2lib/tests/test_advanced.py new file mode 100644 index 0000000..5947194 --- /dev/null +++ b/pyas2lib/tests/test_advanced.py @@ -0,0 +1,505 @@ +"""Module for testing the advanced features of pyas2lib.""" +import base64 +import os +from email import message + +import pytest +from pyas2lib import as2 +from pyas2lib.exceptions import ImproperlyConfigured +from pyas2lib.tests import Pyas2TestCase, TEST_DIR + + +class TestAdvanced(Pyas2TestCase): + def setUp(self): + self.org = as2.Organization( + as2_name="some_organization", + sign_key=self.private_key, + sign_key_pass="test", + decrypt_key=self.private_key, + decrypt_key_pass="test", + ) + self.partner = as2.Partner( + as2_name="some_partner", + verify_cert=self.public_key, + encrypt_cert=self.public_key, + ) + + def test_binary_message(self): + """ Test Encrypted Signed Binary Message """ + + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.encrypt = True + self.partner.compress = True + out_message = as2.Message(self.org, self.partner) + test_message_path = os.path.join(TEST_DIR, "payload.binary") + with open(test_message_path, "rb") as bin_file: + original_message = bin_file.read() + out_message.build( + original_message, + filename="payload.binary", + content_type="application/octet-stream", + ) + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content + + # Parse the generated AS2 message as the partner + in_message = as2.Message() + status, _, _ = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + + # Compare the mic contents of the input and output messages + self.assertEqual(status, "processed") + self.assertEqual(original_message, in_message.payload.get_payload(decode=True)) + self.assertTrue(in_message.signed) + self.assertTrue(in_message.encrypted) + self.assertEqual(out_message.mic, in_message.mic) + + def test_partner_not_found(self): + """ Test case where partner and organization is not found """ + + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.encrypt = True + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=lambda x: None, + find_message_cb=lambda x, y: False, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + + self.assertEqual(status, "processed/Error") + self.assertEqual(detailed_status, "unknown-trading-partner") + + # Parse again but this time make without organization + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=lambda x: None, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed/Error") + self.assertEqual(detailed_status, "unknown-trading-partner") + + def test_duplicate_message(self): + """ Test case where a duplicate message is sent to the partner """ + + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.encrypt = True + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: True, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed/Warning") + self.assertEqual(detailed_status, "duplicate-document") + + def test_failed_decompression(self): + """ Test case where message decompression has failed """ + + # Build an As2 message to be transmitted to partner + self.partner.compress = True + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + base64.b64encode(b"xxxxx") + ) + in_message = as2.Message() + _, exec_info, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed/Error") + self.assertEqual(detailed_status, "decompression-failed") + + def test_insufficient_security(self): + """ Test case where message security is not as per the configuration """ + + # Build an As2 message to be transmitted to partner + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + self.partner.encrypt = True + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + status, (exc, _), mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + self.assertEqual(status, "processed/Error") + self.assertEqual(exc.disposition_modifier, "insufficient-message-security") + + # Try again for signing check + self.partner.encrypt = False + self.partner.sign = True + in_message = as2.Message() + status, (exc, _), mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + self.assertEqual(status, "processed/Error") + self.assertEqual(exc.disposition_modifier, "insufficient-message-security") + + def test_failed_decryption(self): + """ Test case where message decryption has failed """ + + # Build an As2 message to be transmitted to partner + self.partner.encrypt = True + self.partner.encrypt_cert = self.mecas2_public_key + self.partner.validate_certs = False + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, exec_info, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed/Error") + self.assertEqual(detailed_status, "decryption-failed") + + def test_failed_signature(self): + """ Test case where signature verification has failed """ + + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.verify_cert = self.mecas2_public_key + self.partner.validate_certs = False + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, exec_info, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed/Error") + self.assertEqual(detailed_status, "authentication-failed") + + def test_verify_certificate(self): + """ Test case where we have try to load an expired cert """ + + # First test with a certificate with invalid root + cert_path = os.path.join(TEST_DIR, "verify_cert_test1.pem") + with open(cert_path, "rb") as cert_file: + try: + as2.Partner(as2_name="some_partner", verify_cert=cert_file.read()) + except as2.AS2Exception as e: + self.assertIn("unable to get local issuer certificate", str(e)) + + # Test with an expired certificate + cert_path = os.path.join(TEST_DIR, "verify_cert_test2.cer") + with open(cert_path, "rb") as cert_file: + try: + as2.Partner(as2_name="some_partner", verify_cert=cert_file.read()) + except as2.AS2Exception as e: + self.assertIn("certificate has expired", str(e)) + + # Test with a chain certificate + cert_path = os.path.join(TEST_DIR, "verify_cert_test3.pem") + with open(cert_path, "rb") as cert_file: + try: + as2.Partner(as2_name="some_partner", verify_cert=cert_file.read()) + except as2.AS2Exception as e: + self.assertIn("unable to get local issuer certificate", str(e)) + + # Test chain certificate with the ca + cert_ca_path = os.path.join(TEST_DIR, "verify_cert_test3.ca") + with open(cert_path, "rb") as cert_file: + with open(cert_ca_path, "rb") as cert_ca_file: + try: + as2.Partner( + as2_name="some_partner", + verify_cert=cert_file.read(), + verify_cert_ca=cert_ca_file.read(), + ) + except as2.AS2Exception as e: + self.fail("Failed to load chain certificate: %s" % e) + + def test_load_private_key(self): + """ Test case where we have try to load keys in different formats """ + + # First test with a pkcs12 key file + cert_path = os.path.join(TEST_DIR, "cert_test.p12") + with open(cert_path, "rb") as cert_file: + try: + as2.Organization( + as2_name="some_org", sign_key=cert_file.read(), sign_key_pass="test" + ) + except as2.AS2Exception as e: + self.fail("Failed to load p12 private key: %s" % e) + + # Now test with a pem encoded key file + cert_path = os.path.join(TEST_DIR, "cert_test.pem") + with open(cert_path, "rb") as cert_file: + try: + as2.Organization( + as2_name="some_org", sign_key=cert_file.read(), sign_key_pass="test" + ) + except as2.AS2Exception as e: + self.fail("Failed to load pem private key: %s" % e) + + def test_partner_checks(self): + """Test the checks for the partner on initialization.""" + with self.assertRaises(ImproperlyConfigured): + as2.Partner("a partner", digest_alg="xyz") + + with self.assertRaises(ImproperlyConfigured): + as2.Partner("a partner", enc_alg="xyz") + + with self.assertRaises(ImproperlyConfigured): + as2.Partner("a partner", mdn_mode="xyz") + + with self.assertRaises(ImproperlyConfigured): + as2.Partner("a partner", mdn_digest_alg="xyz") + + def test_message_checks(self): + """Test the checks and other features of Message.""" + msg = as2.Message() + assert msg.content == "" + assert msg.headers == {} + assert msg.headers_str == b"" + + msg.payload = message.Message() + msg.payload.set_payload(b"data") + assert msg.content == b"data" + + org = as2.Organization(as2_name="AS2 Server") + partner = as2.Partner(as2_name="AS2 Partner", sign=True) + msg = as2.Message(sender=org, receiver=partner) + with self.assertRaises(ImproperlyConfigured): + msg.build(b"data") + + msg.receiver.sign = False + msg.receiver.encrypt = True + with self.assertRaises(ImproperlyConfigured): + msg.build(b"data") + + msg.receiver.encrypt = False + msg.receiver.mdn_mode = "ASYNC" + with self.assertRaises(ImproperlyConfigured): + msg.build(b"data") + + msg.sender.mdn_url = "http://localhost/pyas2/as2receive" + msg.build(b"data") + + def test_mdn_checks(self): + """Test the checks and other features of MDN.""" + mdn = as2.Mdn() + assert mdn.content == "" + assert mdn.headers == {} + assert mdn.headers_str == b"" + + def test_mdn_not_found(self): + """Test that the MDN parser raises MDN not found when a non MDN message is passed.""" + self.partner.encrypt = True + self.partner.validate_certs = False + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the AS2 message as an MDN + mdn = as2.Mdn() + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + status, detailed_status = mdn.parse( + raw_out_message, find_message_cb=self.find_message + ) + self.assertEqual(status, "failed/Failure") + self.assertEqual(detailed_status, "mdn-not-found") + + def test_unsigned_mdn_sent_error(self): + """Test the case where a signed mdn was expected but unsigned mdn was returned.""" + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + + # Set the mdn sig alg and parse it + self.partner.mdn_digest_alg = "sha256" + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + + self.assertEqual(status, "failed/Failure") + self.assertEqual( + detailed_status, "Expected signed MDN but unsigned MDN returned" + ) + + def test_non_matching_mic(self): + """Test the case where a the mic in the mdn does not match the mic in the message.""" + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.partner.sign = True + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + find_message_cb=lambda x, y: False, + ) + + # Set the mdn sig alg and parse it + self.out_message.mic = b"dummy value" + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + + self.assertEqual(status, "processed/warning") + self.assertEqual(detailed_status, "Message Integrity check failed.") + + def find_org(self, headers): + return self.org + + def find_partner(self, headers): + return self.partner + + def find_message(self, message_id, message_recipient): + return self.out_message + + +class SterlingIntegratorTest(Pyas2TestCase): + def setUp(self): + self.org = as2.Organization( + as2_name="AS2 Server", + sign_key=self.oldpyas2_private_key, + sign_key_pass="password", + decrypt_key=self.oldpyas2_private_key, + decrypt_key_pass="password", + ) + self.partner = as2.Partner( + as2_name="Sterling B2B Integrator", + verify_cert=self.sb2bi_public_key, + verify_cert_ca=self.sb2bi_public_ca, + encrypt_cert=self.sb2bi_public_key, + encrypt_cert_ca=self.sb2bi_public_ca, + validate_certs=False, + ) + self.partner.load_verify_cert() + self.partner.load_encrypt_cert() + + @pytest.mark.skip(reason="no way of currently testing this") + def test_process_message(self): + """ Test processing message received from Sterling Integrator""" + with open(os.path.join(TEST_DIR, "sb2bi_signed_cmp.msg"), "rb") as msg: + as2message = as2.Message() + status, exception, as2mdn = as2message.parse( + msg.read(), + lambda x: self.org, + lambda y: self.partner, + lambda x, y: False, + ) + self.assertEqual(status, "processed") + + def test_process_mdn(self): + """ Test processing mdn received from Sterling Integrator""" + msg = as2.Message(sender=self.org, receiver=self.partner) + msg.message_id = ( + "151694007918.24690.7052273208458909245@ip-172-31-14-209.ec2.internal" + ) + + as2mdn = as2.Mdn() + # Parse the mdn and get the message status + with open(os.path.join(TEST_DIR, "sb2bi_signed.mdn"), "rb") as mdn: + status, detailed_status = as2mdn.parse(mdn.read(), lambda x, y: msg) + self.assertEqual(status, "processed") diff --git a/tests/test_basic.py b/pyas2lib/tests/test_basic.py similarity index 57% rename from tests/test_basic.py rename to pyas2lib/tests/test_basic.py index b8dbba1..fc6c20a 100644 --- a/tests/test_basic.py +++ b/pyas2lib/tests/test_basic.py @@ -1,21 +1,22 @@ -from __future__ import unicode_literals, absolute_import, print_function -from . import as2, PYAS2TestCase +"""Module for testing the basic features of pyas2.""" +from pyas2lib import as2 +from . import Pyas2TestCase -class TestBasic(PYAS2TestCase): +class TestBasic(Pyas2TestCase): def setUp(self): self.org = as2.Organization( - as2_id='some_organization', + as2_name="some_organization", sign_key=self.private_key, - sign_key_pass='test'.encode('utf-8'), + sign_key_pass="test", decrypt_key=self.private_key, - decrypt_key_pass='test'.encode('utf-8') + decrypt_key_pass="test", ) self.partner = as2.Partner( - as2_id='some_partner', + as2_name="some_partner", verify_cert=self.public_key, - encrypt_cert=self.public_key + encrypt_cert=self.public_key, ) def test_plain_message(self): @@ -24,18 +25,18 @@ def test_plain_message(self): # Build an As2 message to be transmitted to partner out_message = as2.Message(self.org, self.partner) out_message.build(self.test_data) - raw_out_message = \ - out_message.headers_str + b'\r\n' + out_message.content + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content # Parse the generated AS2 message as the partner in_message = as2.Message() - in_message.parse( + status, _, _ = in_message.parse( raw_out_message, find_org_cb=self.find_org, - find_partner_cb=self.find_partner + find_partner_cb=self.find_partner, ) # Compare contents of the input and output messages + self.assertEqual(status, "processed") self.assertEqual(self.test_data, in_message.content) def test_compressed_message(self): @@ -45,19 +46,20 @@ def test_compressed_message(self): self.partner.compress = True out_message = as2.Message(self.org, self.partner) out_message.build(self.test_data) - raw_out_message = out_message.headers_str + b'\r\n' + out_message.content + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content # Parse the generated AS2 message as the partner in_message = as2.Message() - in_message.parse( + status, _, _ = in_message.parse( raw_out_message, find_org_cb=self.find_org, - find_partner_cb=self.find_partner + find_partner_cb=self.find_partner, ) # Compare the mic contents of the input and output messages - self.assertEqual( - self.test_data.replace(b'\n', b'\r\n'), in_message.content) + self.assertEqual(status, "processed") + self.assertTrue(in_message.compressed) + self.assertEqual(self.test_data.splitlines(), in_message.content.splitlines()) def test_encrypted_message(self): """ Test Encrypted Unsigned Uncompressed Message """ @@ -66,19 +68,20 @@ def test_encrypted_message(self): self.partner.encrypt = True out_message = as2.Message(self.org, self.partner) out_message.build(self.test_data) - raw_out_message = out_message.headers_str + b'\r\n' + out_message.content + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content # Parse the generated AS2 message as the partner in_message = as2.Message() - in_message.parse( + status, _, _ = in_message.parse( raw_out_message, find_org_cb=self.find_org, - find_partner_cb=self.find_partner + find_partner_cb=self.find_partner, ) # Compare the mic contents of the input and output messages - self.assertEqual( - self.test_data.replace(b'\n', b'\r\n'), in_message.content) + self.assertEqual(status, "processed") + self.assertTrue(in_message.encrypted) + self.assertEqual(self.test_data.splitlines(), in_message.content.splitlines()) def test_signed_message(self): """ Test Unencrypted Signed Uncompressed Message """ @@ -87,20 +90,19 @@ def test_signed_message(self): self.partner.sign = True out_message = as2.Message(self.org, self.partner) out_message.build(self.test_data) - raw_out_message = out_message.headers_str + b'\r\n' + out_message.content + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content # Parse the generated AS2 message as the partner in_message = as2.Message() - print(raw_out_message) - in_message.parse( + status, _, _ = in_message.parse( raw_out_message, find_org_cb=self.find_org, - find_partner_cb=self.find_partner + find_partner_cb=self.find_partner, ) # Compare the mic contents of the input and output messages - self.assertEqual( - self.test_data.replace(b'\n', b'\r\n'), in_message.content) + self.assertEqual(status, "processed") + self.assertEqual(self.test_data.splitlines(), in_message.content.splitlines()) self.assertTrue(in_message.signed) self.assertEqual(out_message.mic, in_message.mic) @@ -112,22 +114,47 @@ def test_encrypted_signed_message(self): self.partner.encrypt = True out_message = as2.Message(self.org, self.partner) out_message.build(self.test_data) - raw_out_message = out_message.headers_str + b'\r\n' + out_message.content + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content + + # Parse the generated AS2 message as the partner + in_message = as2.Message() + status, _, _ = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + ) + + # Compare the mic contents of the input and output messages + self.assertEqual(status, "processed") + self.assertTrue(in_message.signed) + self.assertTrue(in_message.encrypted) + self.assertEqual(out_message.mic, in_message.mic) + self.assertEqual(self.test_data.splitlines(), in_message.content.splitlines()) + + def test_encrypted_signed_message_dos(self): + """ Test Encrypted Signed Uncompressed Message with DOS line endings. """ + + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.encrypt = True + out_message = as2.Message(self.org, self.partner) + out_message.build(self.test_data_dos) + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content # Parse the generated AS2 message as the partner in_message = as2.Message() - in_message.parse( + status, _, _ = in_message.parse( raw_out_message, find_org_cb=self.find_org, - find_partner_cb=self.find_partner + find_partner_cb=self.find_partner, ) # Compare the mic contents of the input and output messages - self.assertEqual( - self.test_data.replace(b'\n', b'\r\n'), in_message.content) + self.assertEqual(status, "processed") self.assertTrue(in_message.signed) self.assertTrue(in_message.encrypted) self.assertEqual(out_message.mic, in_message.mic) + self.assertEqual(self.test_data_dos, in_message.content) def test_encrypted_signed_compressed_message(self): """ Test Encrypted Signed Compressed Message """ @@ -138,23 +165,23 @@ def test_encrypted_signed_compressed_message(self): self.partner.compress = True out_message = as2.Message(self.org, self.partner) out_message.build(self.test_data) - raw_out_message = out_message.headers_str + b'\r\n' + out_message.content + raw_out_message = out_message.headers_str + b"\r\n" + out_message.content # Parse the generated AS2 message as the partner in_message = as2.Message() - in_message.parse( + status, _, _ = in_message.parse( raw_out_message, find_org_cb=self.find_org, - find_partner_cb=self.find_partner + find_partner_cb=self.find_partner, ) # Compare the mic contents of the input and output messages - self.assertEqual( - self.test_data.replace(b'\n', b'\r\n'), in_message.content) + self.assertEqual(status, "processed") self.assertTrue(in_message.signed) self.assertTrue(in_message.encrypted) self.assertTrue(in_message.compressed) self.assertEqual(out_message.mic, in_message.mic) + self.assertEqual(self.test_data.splitlines(), in_message.content.splitlines()) def find_org(self, as2_id): return self.org diff --git a/pyas2lib/tests/test_cms.py b/pyas2lib/tests/test_cms.py new file mode 100644 index 0000000..34655db --- /dev/null +++ b/pyas2lib/tests/test_cms.py @@ -0,0 +1,103 @@ +"""Module to test cms related features of pyas2lib.""" +import os + +import pytest +from oscrypto import asymmetric + +from pyas2lib.as2 import Organization +from pyas2lib import cms +from pyas2lib.exceptions import ( + AS2Exception, + DecompressionError, + DecryptionError, + IntegrityError, +) +from pyas2lib.tests import TEST_DIR + + +INVALID_DATA = cms.cms.ContentInfo( + { + "content_type": cms.cms.ContentType("data"), + } +).dump() + + +def test_compress(): + """Test the compression and decompression functions.""" + compressed_data = cms.compress_message(b"data") + assert cms.decompress_message(compressed_data) == b"data" + + with pytest.raises(DecompressionError): + cms.decompress_message(INVALID_DATA) + + +def test_signing(): + """Test the signing and verification functions.""" + # Load the signature key + with open(os.path.join(TEST_DIR, "cert_test.p12"), "rb") as fp: + sign_key = Organization.load_key(fp.read(), "test") + with open(os.path.join(TEST_DIR, "cert_test_public.pem"), "rb") as fp: + verify_cert = asymmetric.load_certificate(fp.read()) + + # Test failure of signature verification + with pytest.raises(IntegrityError): + cms.verify_message(b"data", INVALID_DATA, None) + + # Test signature without signed attributes + cms.sign_message( + b"data", digest_alg="sha256", sign_key=sign_key, use_signed_attributes=False + ) + + # Test pss signature and verification + signature = cms.sign_message( + b"data", digest_alg="sha256", sign_key=sign_key, sign_alg="rsassa_pss" + ) + cms.verify_message(b"data", signature, verify_cert) + + # Test unsupported signature alg + with pytest.raises(AS2Exception): + cms.sign_message( + b"data", digest_alg="sha256", sign_key=sign_key, sign_alg="rsassa_pssa" + ) + + # Test unsupported digest alg + with pytest.raises(AS2Exception): + cms.sign_message( + b"data", + digest_alg="sha-256", + sign_key=sign_key, + use_signed_attributes=False, + ) + + +def test_encryption(): + """Test the encryption and decryption functions.""" + with open(os.path.join(TEST_DIR, "cert_test.p12"), "rb") as fp: + decrypt_key = Organization.load_key(fp.read(), "test") + with open(os.path.join(TEST_DIR, "cert_test_public.pem"), "rb") as fp: + encrypt_cert = asymmetric.load_certificate(fp.read()) + + with pytest.raises(DecryptionError): + cms.decrypt_message(INVALID_DATA, None) + + # Test all the encryption algorithms + enc_algorithms = [ + "rc2_128_cbc", + "rc4_128_cbc", + "aes_128_cbc", + "aes_192_cbc", + "aes_256_cbc", + ] + for enc_algorithm in enc_algorithms: + encrypted_data = cms.encrypt_message(b"data", enc_algorithm, encrypt_cert) + _, decrypted_data = cms.decrypt_message(encrypted_data, decrypt_key) + assert decrypted_data == b"data" + + # Test no encryption algorithm + with pytest.raises(AS2Exception): + cms.encrypt_message(b"data", "rc5_128_cbc", encrypt_cert) + + # Test no encryption algorithm on decrypt + encrypted_data = cms.encrypt_message(b"data", "des_64_cbc", encrypt_cert) + with pytest.raises(AS2Exception): + cms.decrypt_message(encrypted_data, decrypt_key) diff --git a/pyas2lib/tests/test_mdn.py b/pyas2lib/tests/test_mdn.py new file mode 100644 index 0000000..59ebfcc --- /dev/null +++ b/pyas2lib/tests/test_mdn.py @@ -0,0 +1,120 @@ +"""Module for testing the MDN related features of pyas2lib""" +from pyas2lib import as2 +from . import Pyas2TestCase + + +class TestMDN(Pyas2TestCase): + def setUp(self): + + self.org = as2.Organization( + as2_name="some_organization", + sign_key=self.private_key, + sign_key_pass="test", + decrypt_key=self.private_key, + decrypt_key_pass="test", + ) + self.partner = as2.Partner( + as2_name="some_partner", + verify_cert=self.public_key, + encrypt_cert=self.public_key, + ) + self.out_message = None + + def test_unsigned_mdn(self): + """ Test unsigned MDN generation and parsing """ + + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.encrypt = True + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + + self.assertEqual(status, "processed") + + def test_signed_mdn(self): + """ Test signed MDN generation and parsing """ + + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.encrypt = True + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.partner.mdn_digest_alg = "sha256" + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + ) + + out_mdn = as2.Mdn() + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + self.assertEqual(status, "processed") + + def test_failed_mdn_parse(self): + """Test mdn parsing failures are captured.""" + # Build an As2 message to be transmitted to partner + self.partner.sign = True + self.partner.encrypt = True + self.partner.mdn_mode = as2.SYNCHRONOUS_MDN + self.partner.mdn_digest_alg = "sha256" + self.out_message = as2.Message(self.org, self.partner) + self.out_message.build(self.test_data) + + # Parse the generated AS2 message as the partner + raw_out_message = ( + self.out_message.headers_str + b"\r\n" + self.out_message.content + ) + in_message = as2.Message() + _, _, mdn = in_message.parse( + raw_out_message, + find_org_cb=self.find_org, + find_partner_cb=self.find_partner, + ) + + out_mdn = as2.Mdn() + self.partner.verify_cert = self.mecas2_public_key + self.partner.validate_certs = False + status, detailed_status = out_mdn.parse( + mdn.headers_str + b"\r\n" + mdn.content, find_message_cb=self.find_message + ) + self.assertEqual(status, "failed/Failure") + self.assertEqual( + detailed_status, + "Failed to parse received MDN. Failed to verify message signature: " + "Message Digest does not match.", + ) + + def find_org(self, as2_id): + return self.org + + def find_partner(self, as2_id): + return self.partner + + def find_message(self, message_id, message_recipient): + return self.out_message diff --git a/pyas2lib/tests/test_utils.py b/pyas2lib/tests/test_utils.py new file mode 100644 index 0000000..f1607e5 --- /dev/null +++ b/pyas2lib/tests/test_utils.py @@ -0,0 +1,162 @@ +"""Module to test the utility functions of pyas2lib.""" +import datetime +import os +import pytest +from email.message import Message + +from pyas2lib import utils +from pyas2lib.exceptions import AS2Exception +from pyas2lib.tests import TEST_DIR + + +def test_quoting(): + """Test the function for quoting and as2 name.""" + assert utils.quote_as2name("PYAS2LIB") == "PYAS2LIB" + assert utils.quote_as2name("PYAS2 LIB") == '"PYAS2 LIB"' + + +def test_mime_to_bytes_empty_message(): + """ + It will generate the headers with an empty body + """ + message = Message() + message.set_type("application/pkcs7-mime") + assert ( + utils.mime_to_bytes(message) == b"MIME-Version: 1.0\r\n" + b"Content-Type: application/pkcs7-mime\r\n\r\n" + ) + + +def test_mime_to_bytes_unix_text(): + """ + For non-binary content types, + it converts unix new lines to network newlines. + """ + message = Message() + message.set_type("application/xml") + message.set_payload("Some line.\nAnother line.") + + result = utils.mime_to_bytes(message) + + assert ( + b"MIME-Version: 1.0\r\n" + b"Content-Type: application/xml\r\n" + b"\r\n" + b"Some line.\r\n" + b"Another line." + ) == result + + +def test_mime_to_bytes_octet_stream(): + """ + For binary octet-stream content types, + it does not converts unix new lines to network newlines. + """ + message = Message() + message.set_type("application/octet-stream") + message.set_payload("Some line.\nAnother line.\n") + + result = utils.mime_to_bytes(message) + + assert ( + b"MIME-Version: 1.0\r\n" + b"Content-Type: application/octet-stream\r\n" + b"\r\n" + b"Some line.\n" + b"Another line.\n" + ) == result + + +def test_mime_to_bytes_binary(): + """ + It makes no conversion for binary content encoding. + """ + message = Message() + message.set_type("any/type") + message["Content-Transfer-Encoding"] = "binary" + message.set_payload("Some line.\nAnother line.\n") + + result = utils.mime_to_bytes(message) + + assert ( + b"MIME-Version: 1.0\r\n" + b"Content-Type: any/type\r\n" + b"Content-Transfer-Encoding: binary\r\n" + b"\r\n" + b"Some line.\n" + b"Another line.\n" + ) == result + + +def test_make_boundary(): + """Test the function for creating a boundary for multipart messages.""" + assert utils.make_mime_boundary(text="123456") is not None + + +def test_extract_first_part(): + """Test the function for extracting the first part of a multipart message.""" + message = b"header----first_part\n----second_part\n" + assert utils.extract_first_part(message, b"----") == b"first_part" + + message = b"header----first_part\r\n----second_part\r\n" + assert utils.extract_first_part(message, b"----") == b"first_part" + + +def test_cert_verification(): + """Test the verification of a certificate chain.""" + with open(os.path.join(TEST_DIR, "cert_sb2bi_public.pem"), "rb") as fp: + certificate = utils.pem_to_der(fp.read(), return_multiple=False) + + with pytest.raises(AS2Exception): + utils.verify_certificate_chain( + certificate, trusted_certs=[], ignore_self_signed=False + ) + + +def test_extract_certificate_info(): + """Test case that extracts data from private and public certificates + in PEM or DER format""" + + cert_info = { + "valid_from": datetime.datetime( + 2019, 6, 3, 11, 32, 57, tzinfo=datetime.timezone.utc + ), + "valid_to": datetime.datetime( + 2029, 5, 31, 11, 32, 57, tzinfo=datetime.timezone.utc + ), + "subject": [ + ("C", "AU"), + ("ST", "Some-State"), + ("O", "pyas2lib"), + ("CN", "test"), + ], + "issuer": [ + ("C", "AU"), + ("ST", "Some-State"), + ("O", "pyas2lib"), + ("CN", "test"), + ], + "serial": 13747137503594840569, + } + cert_empty = { + "valid_from": None, + "valid_to": None, + "subject": None, + "issuer": None, + "serial": None, + } + + # compare result of function with cert_info dict. + with open(os.path.join(TEST_DIR, "cert_extract_private.cer"), "rb") as fp: + assert utils.extract_certificate_info(fp.read()) == cert_info + + with open(os.path.join(TEST_DIR, "cert_extract_private.pem"), "rb") as fp: + assert utils.extract_certificate_info(fp.read()) == cert_info + + with open(os.path.join(TEST_DIR, "cert_extract_public.cer"), "rb") as fp: + assert utils.extract_certificate_info(fp.read()) == cert_info + + with open(os.path.join(TEST_DIR, "cert_extract_public.pem"), "rb") as fp: + assert utils.extract_certificate_info(fp.read()) == cert_info + + assert utils.extract_certificate_info(b"") == cert_empty diff --git a/tests/test_with_mecas2.py b/pyas2lib/tests/test_with_mecas2.py similarity index 56% rename from tests/test_with_mecas2.py rename to pyas2lib/tests/test_with_mecas2.py index 3b0ed68..19ab743 100644 --- a/tests/test_with_mecas2.py +++ b/pyas2lib/tests/test_with_mecas2.py @@ -1,36 +1,35 @@ -from __future__ import unicode_literals, absolute_import, print_function -from . import PYAS2TestCase, as2 +"""Module for testing with files generated by mendelson as2 server.""" import os +from pyas2lib import as2 +from pyas2lib.tests import Pyas2TestCase, TEST_DIR -class TestMecAS2(PYAS2TestCase): +class TestMecAS2(Pyas2TestCase): def setUp(self): self.org = as2.Organization( - as2_id='some_organization', + as2_name="some_organization", sign_key=self.private_key, - sign_key_pass='test'.encode('utf-8'), + sign_key_pass="test", decrypt_key=self.private_key, - decrypt_key_pass='test'.encode('utf-8') + decrypt_key_pass="test", ) self.partner = as2.Partner( - as2_id='mecas2', + as2_name="mecas2", verify_cert=self.mecas2_public_key, encrypt_cert=self.mecas2_public_key, - validate_certs=False + validate_certs=False, ) def test_compressed_message(self): """ Test Compressed Message received from Mendelson AS2""" # Parse the generated AS2 message as the partner - received_file = os.path.join(self.TEST_DIR, 'mecas2_compressed.as2') - with open(received_file, 'rb') as fp: + received_file = os.path.join(TEST_DIR, "mecas2_compressed.as2") + with open(received_file, "rb") as fp: in_message = as2.Message() in_message.parse( - fp.read(), - find_org_cb=self.find_org, - find_partner_cb=self.find_partner + fp.read(), find_org_cb=self.find_org, find_partner_cb=self.find_partner ) # Compare the mic contents of the input and output messages @@ -41,99 +40,90 @@ def test_encrypted_message(self): """ Test Encrypted Message received from Mendelson AS2""" # Parse the generated AS2 message as the partner - received_file = os.path.join(self.TEST_DIR, 'mecas2_encrypted.as2') - with open(received_file, 'rb') as fp: + received_file = os.path.join(TEST_DIR, "mecas2_encrypted.as2") + with open(received_file, "rb") as fp: in_message = as2.Message() in_message.parse( - fp.read(), - find_org_cb=self.find_org, - find_partner_cb=self.find_partner + fp.read(), find_org_cb=self.find_org, find_partner_cb=self.find_partner ) # Compare the mic contents of the input and output messages self.assertTrue(in_message.encrypted) - self.assertEqual(in_message.enc_alg, 'tripledes_192_cbc') + self.assertEqual(in_message.enc_alg, "tripledes_192_cbc") self.assertEqual(self.test_data, in_message.content) def test_signed_message(self): """ Test Unencrypted Signed Uncompressed Message from Mendelson AS2""" # Parse the generated AS2 message as the partner - received_file = os.path.join(self.TEST_DIR, 'mecas2_signed.as2') - with open(received_file, 'rb') as fp: + received_file = os.path.join(TEST_DIR, "mecas2_signed.as2") + with open(received_file, "rb") as fp: in_message = as2.Message() in_message.parse( - fp.read(), - find_org_cb=self.find_org, - find_partner_cb=self.find_partner + fp.read(), find_org_cb=self.find_org, find_partner_cb=self.find_partner ) # Compare the mic contents of the input and output messages self.assertTrue(in_message.signed) - self.assertEqual(in_message.digest_alg, 'sha1') + self.assertEqual(in_message.digest_alg, "sha1") self.assertEqual(self.test_data, in_message.content) def test_encrypted_signed_message(self): """ Test Encrypted Signed Uncompressed Message from Mendelson AS2""" # Parse the generated AS2 message as the partner - received_file = os.path.join( - self.TEST_DIR, 'mecas2_signed_encrypted.as2') - with open(received_file, 'rb') as fp: + received_file = os.path.join(TEST_DIR, "mecas2_signed_encrypted.as2") + with open(received_file, "rb") as fp: in_message = as2.Message() in_message.parse( - fp.read(), - find_org_cb=self.find_org, - find_partner_cb=self.find_partner + fp.read(), find_org_cb=self.find_org, find_partner_cb=self.find_partner ) # Compare the mic contents of the input and output messages self.assertTrue(in_message.encrypted) - self.assertEqual(in_message.enc_alg, 'tripledes_192_cbc') + self.assertEqual(in_message.enc_alg, "tripledes_192_cbc") self.assertTrue(in_message.signed) - self.assertEqual(in_message.digest_alg, 'sha1') + self.assertEqual(in_message.digest_alg, "sha1") self.assertEqual(self.test_data, in_message.content) def test_encrypted_signed_compressed_message(self): """ Test Encrypted Signed Compressed Message from Mendelson AS2""" # Parse the generated AS2 message as the partner - received_file = os.path.join( - self.TEST_DIR, 'mecas2_compressed_signed_encrypted.as2') - with open(received_file, 'rb') as fp: + received_file = os.path.join(TEST_DIR, "mecas2_compressed_signed_encrypted.as2") + with open(received_file, "rb") as fp: in_message = as2.Message() in_message.parse( - fp.read(), - find_org_cb=self.find_org, - find_partner_cb=self.find_partner + fp.read(), find_org_cb=self.find_org, find_partner_cb=self.find_partner ) # Compare the mic contents of the input and output messages self.assertTrue(in_message.encrypted) - self.assertEqual(in_message.enc_alg, 'tripledes_192_cbc') + self.assertEqual(in_message.enc_alg, "tripledes_192_cbc") self.assertTrue(in_message.signed) - self.assertEqual(in_message.digest_alg, 'sha1') + self.assertEqual(in_message.digest_alg, "sha1") self.assertEqual(self.test_data, in_message.content) def test_unsigned_mdn(self): """ Test Unsigned MDN received from Mendelson AS2""" # Parse the generated AS2 message as the partner - received_file = os.path.join(self.TEST_DIR, 'mecas2_unsigned.mdn') - with open(received_file, 'rb') as fp: - in_message = as2.MDN() + received_file = os.path.join(TEST_DIR, "mecas2_unsigned.mdn") + with open(received_file, "rb") as fp: + in_message = as2.Mdn() status, detailed_status = in_message.parse( - fp.read(), find_message_cb=self.find_message) + fp.read(), find_message_cb=self.find_message + ) - self.assertEqual(status, 'processed/error') - self.assertEqual(detailed_status, 'authentication-failed') + self.assertEqual(status, "processed/error") + self.assertEqual(detailed_status, "authentication-failed") def test_signed_mdn(self): """ Test Signed MDN received from Mendelson AS2""" # Parse the generated AS2 message as the partner - received_file = os.path.join(self.TEST_DIR, 'mecas2_signed.mdn') - with open(received_file, 'rb') as fp: - in_message = as2.MDN() + received_file = os.path.join(TEST_DIR, "mecas2_signed.mdn") + with open(received_file, "rb") as fp: + in_message = as2.Mdn() in_message.parse(fp.read(), find_message_cb=self.find_message) def find_org(self, headers): @@ -146,5 +136,5 @@ def find_message(self, message_id, message_recipient): message = as2.Message() message.sender = self.org message.receiver = self.partner - message.mic = b'O4bvrm5t2YunRfwvZicNdEUmPaPZ9vUslX8loVLDck0=' + message.mic = b"O4bvrm5t2YunRfwvZicNdEUmPaPZ9vUslX8loVLDck0=" return message diff --git a/pyas2lib/utils.py b/pyas2lib/utils.py index 5655612..8267afd 100644 --- a/pyas2lib/utils.py +++ b/pyas2lib/utils.py @@ -1,83 +1,135 @@ -from __future__ import absolute_import, unicode_literals -from .compat import BytesIO, BytesGenerator, is_py2, _ver -from .exceptions import AS2Exception -from OpenSSL import crypto -from asn1crypto import pem +"""Define utility functions used by the pyas2-lib package.""" + import email +import random import re import sys -import random +from datetime import datetime, timezone +from email import message +from email import policy +from email.generator import BytesGenerator +from io import BytesIO + +from OpenSSL import crypto +from asn1crypto import pem + +from pyas2lib.exceptions import AS2Exception -def unquote_as2name(quoted_name): +def unquote_as2name(quoted_name: str): + """ + Function converts as2 name from quoted to unquoted format. + + :param quoted_name: the as2 name in quoted format + :return: the as2 name in unquoted format + """ return email.utils.unquote(quoted_name) -def quote_as2name(unquoted_name): - if re.search(r'[\\" ]', unquoted_name, re.M): - return '"' + email.utils.quote(unquoted_name) + '"' - else: - return unquoted_name +def quote_as2name(unquoted_name: str): + """ + Function converts as2 name from unquoted to quoted format. + :param unquoted_name: the as2 name in unquoted format + :return: the as2 name in unquoted format + """ -def mime_to_bytes(msg, header_len): + if re.search(r'[\\" ]', unquoted_name, re.M): + return '"' + email.utils.quote(unquoted_name) + '"' + return unquoted_name + + +class BinaryBytesGenerator(BytesGenerator): + """Override the bytes generator to better handle binary data.""" + + def _handle_text(self, msg): + """ + Handle writing the binary messages to prevent default behaviour of + newline replacements. + """ + if ( + msg.get_content_type() == "application/octet-stream" + or msg.get("Content-Transfer-Encoding") == "binary" + ): + payload = msg.get_payload(decode=True) + if payload is None: + return + self._fp.write(payload) + else: + super()._handle_text(msg) + + _writeBody = _handle_text + + +def mime_to_bytes(msg: message.Message, email_policy: policy.Policy = policy.HTTP): + """ + Function to convert and email Message to flat string format. + + :param msg: email.Message to be converted to string + :param email_policy: the policy to be used for flattening the message. + :return: the byte string representation of the email message + """ fp = BytesIO() - g = BytesGenerator(fp, maxheaderlen=header_len) + g = BinaryBytesGenerator(fp, policy=email_policy) g.flatten(msg) return fp.getvalue() -def canonicalize(message): +def canonicalize(email_message: message.Message): + """ + Function to convert an email Message to standard format string/ - if message.is_multipart() \ - or message.get('Content-Transfer-Encoding') != 'binary': + :param email_message: email.message.Message to be converted to standard string + :return: the standard representation of the email message in bytes + """ - return mime_to_bytes(message, 0).replace( - b'\r\n', b'\n').replace(b'\r', b'\n').replace(b'\n', b'\r\n') - else: - message_header = '' - message_body = message.get_payload(decode=True) - for k, v in message.items(): - message_header += '{}: {}\r\n'.format(k, v) - message_header += '\r\n' - return message_header.encode('utf-8') + message_body + if email_message.get("Content-Transfer-Encoding") == "binary": + message_header = "" + message_body = email_message.get_payload(decode=True) + for k, v in email_message.items(): + message_header += "{}: {}\r\n".format(k, v) + message_header += "\r\n" + return message_header.encode("utf-8") + message_body + return mime_to_bytes(email_message) -def make_mime_boundary(text=None): - # Craft a random boundary. If text is given, ensure that the chosen - # boundary doesn't appear in the text. + +def make_mime_boundary(text: str = None): + """ + Craft a random boundary. If text is given, ensure that the chosen + boundary doesn't appear in the text. + """ width = len(repr(sys.maxsize - 1)) - fmt = '%%0%dd' % width + fmt = "%%0%dd" % width token = random.randrange(sys.maxsize) - boundary = ('=' * 15) + (fmt % token) + '==' + boundary = ("=" * 15) + (fmt % token) + "==" if text is None: return boundary b = boundary counter = 0 while True: - cre = re.compile('^--' + re.escape(b) + '(--)?$', re.MULTILINE) + cre = re.compile("^--" + re.escape(b) + "(--)?$", re.MULTILINE) if not cre.search(text): break - b = boundary + '.' + str(counter) + b = boundary + "." + str(counter) counter += 1 return b -def extract_first_part(message, boundary): - """ Function to extract the first part of a multipart message""" - first_message = message.split(boundary)[1].lstrip() - if first_message.endswith(b'\r\n'): +def extract_first_part(message_content: bytes, boundary: bytes): + """Extract the first part of a multipart message.""" + first_message = message_content.split(boundary)[1].lstrip() + if first_message.endswith(b"\r\n"): first_message = first_message[:-2] else: first_message = first_message[:-1] return first_message -def pem_to_der(cert, return_multiple=True): - """ Converts a given certificate or list to PEM format""" - +def pem_to_der(cert: bytes, return_multiple: bool = True): + """Convert a given certificate or list to PEM format.""" # initialize the certificate array cert_list = [] @@ -91,15 +143,44 @@ def pem_to_der(cert, return_multiple=True): # return multiple if return_multiple is set else first element if return_multiple: return cert_list - else: - return cert_list.pop() + return cert_list.pop() + + +def split_pem(pem_bytes: bytes): + """ + Split a give PEM file with multiple certificates. + :param pem_bytes: The pem data in bytes with multiple certs + :return: yields a list of certificates contained in the pem file + """ + started, pem_data = False, b"" + for line in pem_bytes.splitlines(False): -def verify_certificate_chain(cert_str, trusted_certs, ignore_self_signed=True): - """ Verify a given certificate against a trust store""" + if line == b"" and not started: + continue + + if line[0:5] in (b"-----", b"---- "): + if not started: + started = True + else: + pem_data = pem_data + line + b"\r\n" + yield pem_data + + started = False + pem_data = b"" + + if started: + pem_data = pem_data + line + b"\r\n" + + +def verify_certificate_chain(cert_bytes, trusted_certs, ignore_self_signed=True): + """ + Verify a given certificate against a trust store. + :return: True; or None if certificate is invalid or cannot be loaded by OpenSSL. + """ # Load the certificate - certificate = crypto.load_certificate(crypto.FILETYPE_ASN1, cert_str) + certificate = crypto.load_certificate(crypto.FILETYPE_ASN1, cert_bytes) # Create a certificate store and add your trusted certs try: @@ -110,17 +191,77 @@ def verify_certificate_chain(cert_str, trusted_certs, ignore_self_signed=True): # Assuming the certificates are in PEM format in a trusted_certs list for _cert in trusted_certs: - store.add_cert( - crypto.load_certificate(crypto.FILETYPE_ASN1, _cert)) + store.add_cert(crypto.load_certificate(crypto.FILETYPE_ASN1, _cert)) # Create a certificate context using the store and the certificate store_ctx = crypto.X509StoreContext(store, certificate) # Verify the certificate, returns None if certificate is not valid - store_ctx.verify_certificate() - + try: + store_ctx.verify_certificate() + except Exception as e: + return None + return True except crypto.X509StoreContextError as e: - raise AS2Exception('Partner Certificate Invalid: %s' % e.args[-1][-1]) + raise AS2Exception( + "Partner Certificate Invalid: %s" % e.args[-1][-1], "invalid-certificate" + ) + + +def extract_certificate_info(cert: bytes): + """ + Extract validity information from the certificate and return a dictionary. + + Provide either key with certificate (private) or public certificate. + + :param cert: the certificate as byte string in PEM or DER format + :return: a dictionary holding certificate information: + valid_from (datetime) - UTC + valid_to (datetime) - UTC + subject (list of name, value tuples) + issuer (list of name, value tuples) + serial (int) + """ + # initialize the cert_info dictionary + cert_info = { + "valid_from": None, + "valid_to": None, + "subject": None, + "issuer": None, + "serial": None, + } + + # get certificate to DER list + der = pem_to_der(cert) + + # iterate through the list to find the certificate + for _item in der: + try: + # load the certificate. if element is key, exception is triggered + # and next element is tried + certificate = crypto.load_certificate(crypto.FILETYPE_ASN1, _item) + + # on successful load, extract the various fields into the dictionary + cert_info["valid_from"] = datetime.strptime( + certificate.get_notBefore().decode("utf8"), "%Y%m%d%H%M%SZ" + ).replace(tzinfo=timezone.utc) + cert_info["valid_to"] = datetime.strptime( + certificate.get_notAfter().decode("utf8"), "%Y%m%d%H%M%SZ" + ).replace(tzinfo=timezone.utc) + cert_info["subject"] = [ + tuple(item.decode("utf8") for item in sets) + for sets in certificate.get_subject().get_components() + ] + cert_info["issuer"] = [ + tuple(item.decode("utf8") for item in sets) + for sets in certificate.get_issuer().get_components() + ] + cert_info["serial"] = certificate.get_serial_number() + break + except crypto.Error: + continue + # return the dictionary + return cert_info diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..aa5e6ec --- /dev/null +++ b/setup.cfg @@ -0,0 +1,22 @@ +[aliases] +test=pytest + +[pylava:pycodestyle] +max_line_length = 100 + +[pylava:pylint] +max_line_length = 100 +ignore = E1101,R0902,R0903,W1203,C0103 + +[pylava:pydocstyle] +convention = numpy +ignore = D202 + +[pylava:pep8] +max_line_length = 100 + +[pylava] +format = pep8 +skip = venv/*,.tox/*,*/tests/*,setup.py +linters= pycodestyle,pyflakes,pylint,pep8 +ignore = D203,D212,E231,C0330,R0912,R0914,W1202,R1702,C0114 diff --git a/setup.py b/setup.py index c11c93f..2b49fdf 100644 --- a/setup.py +++ b/setup.py @@ -1,50 +1,49 @@ from setuptools import setup, find_packages -from pyas2lib import __versionstr__ -from os.path import join, dirname install_requires = [ - 'asn1crypto==0.24.0', - 'oscrypto==0.19.1', - 'pyOpenSSL==17.5.0' + "asn1crypto==1.4.0", + "oscrypto==1.2.1", + "pyOpenSSL==20.0.1", ] tests_require = [ - 'nose', - 'requests', + "pytest==6.2.1", + "toml==0.10.1", + "pytest-cov==2.8.1", + "coverage==5.0.4", + "pylint==2.4.4", + "pylava-pylint==0.0.3", + "black==20.8b1", + "pytest-black==0.3.12", ] setup( - name='pyas2lib', + name="pyas2lib", description="Python library for building and parsing AS2 Messages", license="GNU GPL v2.0", url="https://github.com/abhishek-ram/pyas2-lib", - # long_description=long_description, long_description="Docs for this project are maintained at " - "https://github.com/abhishek-ram/pyas2-lib/blob/" - "master/README.md", - version=__versionstr__, + "https://github.com/abhishek-ram/pyas2-lib/blob/" + "master/README.md", + version="1.3.3", author="Abhishek Ram", author_email="abhishek8816@gmail.com", - packages=find_packages( - where='.', - exclude=('test*',) - ), + packages=find_packages(where=".", exclude=("test*",)), classifiers=[ "Development Status :: 4 - Beta", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "Topic :: Security :: Cryptography", "Topic :: Communications", - ], + setup_requires=["pytest-runner"], install_requires=install_requires, - - test_suite='nose.collector', tests_require=tests_require, + extras_require={ + "tests": tests_require, + }, ) diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index 0551d00..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -import unittest -import os -import sys -sys.path.insert(0, os.path.abspath('..')) - -from pyas2lib import as2, exceptions - - -class PYAS2TestCase(unittest.TestCase): - TEST_DIR = os.path.join( - os.path.dirname(os.path.abspath(__file__)), 'fixtures') - - @classmethod - def setUpClass(cls): - with open(os.path.join(cls.TEST_DIR, 'payload.txt'), 'rb') as t_file: - cls.test_data = t_file.read() - - with open(os.path.join( - cls.TEST_DIR, 'cert_test.p12'), 'rb') as key_file: - cls.private_key = key_file.read() - - with open(os.path.join( - cls.TEST_DIR, 'cert_test_public.pem'), 'rb') as pub_file: - cls.public_key = pub_file.read() - - with open(os.path.join( - cls.TEST_DIR, 'cert_mecas2_public.pem'), 'rb') as pub_file: - cls.mecas2_public_key = pub_file.read() - - with open(os.path.join( - cls.TEST_DIR, 'cert_oldpyas2_public.pem'), 'rb') as pub_file: - cls.oldpyas2_public_key = pub_file.read() diff --git a/tests/test_advanced.py b/tests/test_advanced.py deleted file mode 100644 index 3c8df7d..0000000 --- a/tests/test_advanced.py +++ /dev/null @@ -1,274 +0,0 @@ -from __future__ import unicode_literals, absolute_import, print_function -from . import PYAS2TestCase, as2 -import os - - -class TestAdvanced(PYAS2TestCase): - - def setUp(self): - self.org = as2.Organization( - as2_id='some_organization', - sign_key=self.private_key, - sign_key_pass='test'.encode('utf-8'), - decrypt_key=self.private_key, - decrypt_key_pass='test'.encode('utf-8') - ) - self.partner = as2.Partner( - as2_id='some_partner', - verify_cert=self.public_key, - encrypt_cert=self.public_key, - ) - - def test_binary_message(self): - """ Test Encrypted Signed Binary Message """ - - # Build an As2 message to be transmitted to partner - self.partner.sign = True - self.partner.encrypt = True - self.partner.compress = True - out_message = as2.Message(self.org, self.partner) - test_message_path = os.path.join(self.TEST_DIR, 'payload.binary') - with open(test_message_path, 'rb') as bin_file: - original_message = bin_file.read() - out_message.build( - original_message, - filename='payload.binary', - content_type='application/octet-stream' - ) - raw_out_message = out_message.headers_str + b'\r\n' + out_message.content - - # Parse the generated AS2 message as the partner - in_message = as2.Message() - in_mic_content = in_message.parse( - raw_out_message, - find_org_cb=self.find_org, - find_partner_cb=self.find_partner - ) - - # Compare the mic contents of the input and output messages - # self.assertEqual(original_message, - # in_message.payload.get_payload(decode=True)) - self.assertTrue(in_message.signed) - self.assertTrue(in_message.encrypted) - self.assertEqual(out_message.mic, in_message.mic) - - def test_partner_not_found(self): - """ Test case where partner and organization is not found """ - - # Build an As2 message to be transmitted to partner - self.partner.sign = True - self.partner.encrypt = True - self.partner.mdn_mode = as2.SYNCHRONOUS_MDN - self.out_message = as2.Message(self.org, self.partner) - self.out_message.build(self.test_data) - - # Parse the generated AS2 message as the partner - raw_out_message = \ - self.out_message.headers_str + b'\r\n' + self.out_message.content - in_message = as2.Message() - _, _, mdn = in_message.parse( - raw_out_message, - find_org_cb=self.find_org, - find_partner_cb=self.find_none - ) - - out_mdn = as2.MDN() - status, detailed_status = out_mdn.parse( - mdn.headers_str + b'\r\n' + mdn.content, - find_message_cb=self.find_message - ) - self.assertEqual(status, 'processed/Error') - self.assertEqual(detailed_status, 'unknown-trading-partner') - - # Parse again but this time make without organization - in_message = as2.Message() - _, _, mdn = in_message.parse( - raw_out_message, - find_org_cb=self.find_none, - find_partner_cb=self.find_partner - ) - - out_mdn = as2.MDN() - status, detailed_status = out_mdn.parse( - mdn.headers_str + b'\r\n' + mdn.content, - find_message_cb=self.find_message - ) - self.assertEqual(status, 'processed/Error') - self.assertEqual(detailed_status, 'unknown-trading-partner') - - def test_insufficient_security(self): - """ Test case where message security is not as per the configuration """ - - # Build an As2 message to be transmitted to partner - self.partner.mdn_mode = as2.SYNCHRONOUS_MDN - self.out_message = as2.Message(self.org, self.partner) - self.out_message.build(self.test_data) - - # Parse the generated AS2 message as the partner - self.partner.sign = True - self.partner.encrypt = True - raw_out_message = \ - self.out_message.headers_str + b'\r\n' + self.out_message.content - in_message = as2.Message() - _, _, mdn = in_message.parse( - raw_out_message, - find_org_cb=self.find_org, - find_partner_cb=self.find_partner - ) - - out_mdn = as2.MDN() - status, detailed_status = out_mdn.parse( - mdn.headers_str + b'\r\n' + mdn.content, - find_message_cb=self.find_message - ) - self.assertEqual(status, 'processed/Error') - self.assertEqual(detailed_status, 'insufficient-message-security') - - def test_failed_decryption(self): - """ Test case where message decryption has failed """ - - # Build an As2 message to be transmitted to partner - self.partner.encrypt = True - self.partner.encrypt_cert = self.partner.load_cert( - self.mecas2_public_key, validate_certs=False) - self.partner.mdn_mode = as2.SYNCHRONOUS_MDN - self.out_message = as2.Message(self.org, self.partner) - self.out_message.build(self.test_data) - - # Parse the generated AS2 message as the partner - raw_out_message = \ - self.out_message.headers_str + b'\r\n' + self.out_message.content - in_message = as2.Message() - _, exec_info, mdn = in_message.parse( - raw_out_message, - find_org_cb=self.find_org, - find_partner_cb=self.find_partner - ) - - out_mdn = as2.MDN() - status, detailed_status = out_mdn.parse( - mdn.headers_str + b'\r\n' + mdn.content, - find_message_cb=self.find_message - ) - self.assertEqual(status, 'processed/Error') - self.assertEqual(detailed_status, 'decryption-failed') - - def test_failed_signature(self): - """ Test case where signature verification has failed """ - - # Build an As2 message to be transmitted to partner - self.partner.sign = True - self.partner.verify_cert = self.partner.load_cert( - self.mecas2_public_key, validate_certs=False) - self.partner.mdn_mode = as2.SYNCHRONOUS_MDN - self.out_message = as2.Message(self.org, self.partner) - self.out_message.build(self.test_data) - - # Parse the generated AS2 message as the partner - raw_out_message = \ - self.out_message.headers_str + b'\r\n' + self.out_message.content - in_message = as2.Message() - _, exec_info, mdn = in_message.parse( - raw_out_message, - find_org_cb=self.find_org, - find_partner_cb=self.find_partner - ) - - out_mdn = as2.MDN() - status, detailed_status = out_mdn.parse( - mdn.headers_str + b'\r\n' + mdn.content, - find_message_cb=self.find_message - ) - self.assertEqual(status, 'processed/Error') - self.assertEqual(detailed_status, 'authentication-failed') - - def test_verify_certificate(self): - """ Test case where we have try to load an expired cert """ - - # First test with a certificate with invalid root - cert_path = os.path.join(self.TEST_DIR, 'verify_cert_test1.pem') - with open(cert_path, 'rb') as cert_file: - try: - as2.Partner( - as2_id='some_partner', - verify_cert=cert_file.read() - ) - except as2.AS2Exception as e: - self.assertIn( - 'unable to get local issuer certificate', str(e)) - - # Test with an expired certificate - cert_path = os.path.join(self.TEST_DIR, 'verify_cert_test2.cer') - with open(cert_path, 'rb') as cert_file: - try: - as2.Partner( - as2_id='some_partner', - verify_cert=cert_file.read() - ) - except as2.AS2Exception as e: - self.assertIn( - 'certificate has expired', str(e)) - - # Test with a chain certificate - cert_path = os.path.join(self.TEST_DIR, 'verify_cert_test3.pem') - with open(cert_path, 'rb') as cert_file: - try: - as2.Partner( - as2_id='some_partner', - verify_cert=cert_file.read() - ) - except as2.AS2Exception as e: - self.assertIn( - 'unable to get local issuer certificate', str(e)) - - # Test chain certificate with the ca - cert_ca_path = os.path.join(self.TEST_DIR, 'verify_cert_test3.ca') - with open(cert_path, 'rb') as cert_file: - with open(cert_ca_path, 'rb') as cert_ca_file: - try: - as2.Partner( - as2_id='some_partner', - verify_cert=cert_file.read(), - verify_cert_ca=cert_ca_file.read() - ) - except as2.AS2Exception as e: - self.fail('Failed to load chain certificate: %s' % e) - - def test_load_private_key(self): - """ Test case where we have try to load keys in different formats """ - - # First test with a pkcs12 key file - cert_path = os.path.join(self.TEST_DIR, 'cert_test.p12') - with open(cert_path, 'rb') as cert_file: - try: - as2.Organization( - as2_id='some_org', - sign_key=cert_file.read(), - sign_key_pass=b'test' - ) - except as2.AS2Exception as e: - self.fail('Failed to load p12 private key: %s' % e) - - # Now test with a pem encoded key file - cert_path = os.path.join(self.TEST_DIR, 'cert_test.pem') - with open(cert_path, 'rb') as cert_file: - try: - as2.Organization( - as2_id='some_org', - sign_key=cert_file.read(), - sign_key_pass=b'test' - ) - except as2.AS2Exception as e: - self.fail('Failed to load pem private key: %s' % e) - - def find_org(self, headers): - return self.org - - def find_partner(self, headers): - return self.partner - - def find_none(self, as2_id): - return None - - def find_message(self, message_id, message_recipient): - return self.out_message diff --git a/tests/test_mdn.py b/tests/test_mdn.py deleted file mode 100644 index 5125069..0000000 --- a/tests/test_mdn.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import unicode_literals, absolute_import, print_function -from . import as2, PYAS2TestCase - - -class TestMDN(PYAS2TestCase): - - def setUp(self): - - self.org = as2.Organization( - as2_id='some_organization', - sign_key=self.private_key, - sign_key_pass='test'.encode('utf-8'), - decrypt_key=self.private_key, - decrypt_key_pass='test'.encode('utf-8') - ) - self.partner = as2.Partner( - as2_id='some_partner', - verify_cert=self.public_key, - encrypt_cert=self.public_key, - ) - self.out_message = None - - def test_unsigned_mdn(self): - """ Test unsigned MDN generation and parsing """ - - # Build an As2 message to be transmitted to partner - self.partner.sign = True - self.partner.encrypt = True - self.partner.mdn_mode = as2.SYNCHRONOUS_MDN - self.out_message = as2.Message(self.org, self.partner) - self.out_message.build(self.test_data) - - # Parse the generated AS2 message as the partner - raw_out_message = \ - self.out_message.headers_str + b'\r\n' + self.out_message.content - in_message = as2.Message() - _, _, mdn = in_message.parse( - raw_out_message, - find_org_cb=self.find_org, - find_partner_cb=self.find_partner - ) - - out_mdn = as2.MDN() - status, detailed_status = out_mdn.parse( - mdn.headers_str + b'\r\n' + mdn.content, - find_message_cb=self.find_message - ) - - self.assertEqual(status, 'processed') - - def test_signed_mdn(self): - """ Test signed MDN generation and parsing """ - - # Build an As2 message to be transmitted to partner - self.partner.sign = True - self.partner.encrypt = True - self.partner.mdn_mode = as2.SYNCHRONOUS_MDN - self.partner.mdn_digest_alg = 'sha256' - self.out_message = as2.Message(self.org, self.partner) - self.out_message.build(self.test_data) - - # Parse the generated AS2 message as the partner - raw_out_message = \ - self.out_message.headers_str + b'\r\n' + self.out_message.content - in_message = as2.Message() - _, _, mdn = in_message.parse( - raw_out_message, - find_org_cb=self.find_org, - find_partner_cb=self.find_partner - ) - - out_mdn = as2.MDN() - status, detailed_status = out_mdn.parse( - mdn.headers_str + b'\r\n' + mdn.content, - find_message_cb=self.find_message - ) - self.assertEqual(status, 'processed') - - def find_org(self, as2_id): - return self.org - - def find_partner(self, as2_id): - return self.partner - - def find_message(self, message_id, message_recipient): - return self.out_message diff --git a/tox.ini b/tox.ini index 87dbbfa..f209c38 100644 --- a/tox.ini +++ b/tox.ini @@ -4,9 +4,8 @@ # and then run "tox" from this directory. [tox] -envlist = py27, py34, py35, py36 +envlist = py37, py38 [testenv] commands = {envpython} setup.py test deps = -