def parse(self, raw_content, find_message_cb): """Function parses the RAW AS2 MDN, verifies it and extracts the processing status of the orginal AS2 message. :param raw_content: A byte string of the received HTTP headers followed by the body. :param find_message_cb: A callback the must returns the original Message Object. The 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 processing status. """ status, detailed_status = None, None 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': 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 # 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) for part in self.payload.walk(): if part.get_content_type() == 'message/disposition-notification': logger.debug('Found MDN report for message %s:\n%s' % (orig_message.message_id, part.as_string())) mdn = part.get_payload()[-1] 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() return status, detailed_status
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. :param raw_content: A byte string of the received HTTP headers followed by the body. :param find_org_cb: A callback the returns an Organization object if exists. The as2-to header value is passed as an argument to it. :param 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, 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), 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('>') as2_headers[k] = v try: # Get the organization and partner for this transmission 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)) 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)) 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': raise InsufficientSecurityError( 'Incoming messages from partner {} are must be encrypted' ' but encrypted message not found.'.format(partner_id)) if self.payload.get_content_type() == 'application/pkcs7-mime' \ and self.payload.get_param('smime-type') == 'enveloped-data': logger.debug('Decrypting message %s payload :\n%s' % (self.message_id, self.payload.as_string())) self.encrypted = True encrypted_data = self.payload.get_payload(decode=True) self.enc_alg, decrypted_content = decrypt_message( encrypted_data, self.receiver.decrypt_key) raw_content = decrypted_content self.payload = parse_mime(decrypted_content) 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') # 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)) if self.payload.get_content_type() == 'multipart/signed': logger.debug('Verifying signed message %s payload: \n%s' % (self.message_id, self.payload.as_string())) self.signed = True # Split the message into signature and signed message 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) else: self.payload = part # Verify the message, first using raw message and if it fails # then convert to canonical form and try again mic_content = canonicalize(self.payload) 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() # 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') detailed_status = getattr(e, 'disposition_modifier', 'unexpected-processing-error') exception = (e, traceback.format_exc()) logger.error('Failed to parse AS2 message\n: %s' % traceback.format_exc()) finally: # 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) if as2_headers.get('disposition-notification-to'): mdn_mode = SYNCHRONOUS_MDN mdn_url = as2_headers.get('receipt-delivery-option') if mdn_url: mdn_mode = ASYNCHRONOUS_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) return status, exception, mdn
def build(self, message, status, detailed_status=None): """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") """ # Generate message id using UUID 1 as it uses both hostname and time 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' } # 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 # overwrite with partner specific message if message.sender and message.sender.mdn_confirm_text: confirmation_text = message.sender.mdn_confirm_text if status != 'processed': confirmation_text = MDN_FAILED_TEXT 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'] 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) if detailed_status: mdn_report += ': {}'.format(detailed_status) mdn_report += '\n' if message.mic: mdn_report += 'Received-content-MIC: {}, {}\n'.format( message.mic.decode(), message.digest_alg) mdn_base.set_payload(mdn_report) del mdn_base['MIME-Version'] encoders.encode_7or8bit(mdn_base) self.payload.attach(mdn_base) logger.debug('MDN for message %s created:\n%s' % (message.message_id, mdn_base.as_string())) # 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().replace('-', '').lower() 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.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)) encoders.encode_base64(signature) signed_mdn.set_param('micalg', self.digest_alg) signed_mdn.attach(signature) self.payload = signed_mdn logger.debug('Signature for MDN %s created:\n%s' % (message.message_id, signature.as_string())) # Update the headers of the final payload and set message boundary for k, v in mdn_headers.items(): if self.payload.get(k): 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 build(self, data, filename=None, subject='AS2 Message', content_type='application/edi-consent', additional_headers=None): """Function builds the AS2 message. Compresses, signs and encrypts the payload if applicable. :param data: A byte string of the data to be transmitted. :param filename: Optional filename to be included in the Content-disposition header. :param subject: The subject for the AS2 message, used by some AS2 servers for additional routing of messages. (default "AS2 Message") :param content_type: The content type for the AS2 message, to be used in the MIME header. (default "application/edi-consent") :param additional_headers: Any additional headers to be included as part of the AS2 message. """ # Validations assert type(data) is bytes, \ 'Parameter data must be of bytes type.' additional_headers = additional_headers if additional_headers else {} assert type(additional_headers) is 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.') 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.') # Generate message id using UUID 1 as it uses both hostname and time 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_name), 'AS2-To': quote_as2name(self.receiver.as2_name), 'Subject': subject, 'Date': email_utils.formatdate(localtime=True), # 'recipient-address': message.partner.target_url, } as2_headers.update(additional_headers) # Read the input and convert to bytes if value is unicode/str # using utf-8 encoding and finally Canonicalize the payload self.payload = email_message.Message() self.payload.set_payload(data) self.payload.set_type(content_type) encoders.encode_7or8bit(self.payload) if filename: self.payload.add_header('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.add_header('Content-Disposition', 'attachment', filename='smime.p7z') compressed_message.add_header('Content-Transfer-Encoding', 'binary') compressed_message.set_payload( compress_message(mime_to_bytes(self.payload, 0))) self.payload = compressed_message logger.debug('Compressed message %s payload as:\n%s' % (self.message_id, self.payload.as_string())) 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_message.attach(self.payload) # Calculate the MIC Hash of the message to be verified mic_content = canonicalize(self.payload) digest_func = hashlib.new(self.digest_alg) digest_func.update(mic_content) self.mic = binascii.b2a_base64(digest_func.digest()).strip() # 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.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)) encoders.encode_base64(signature) signed_message.set_param('micalg', self.digest_alg) signed_message.attach(signature) self.payload = signed_message logger.debug('Signed message %s payload as:\n%s' % (self.message_id, mime_to_bytes(self.payload, 0))) 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.add_header('Content-Disposition', 'attachment', filename='smime.p7m') encrypted_message.add_header('Content-Transfer-Encoding', 'binary') encrypt_cert = self.receiver.load_encrypt_cert() encrypted_message.set_payload( encrypt_message(mime_to_bytes(self.payload, 0), self.enc_alg, encrypt_cert)) self.payload = encrypted_message logger.debug('Encrypted message %s payload as:\n%s' % (self.message_id, self.payload.as_string())) if self.receiver.mdn_mode: as2_headers['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': 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 # Update the headers of the final payload and set its boundary for k, v in as2_headers.items(): if self.payload.get(k): 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_message_cb): """Function parses the RAW AS2 MDN, verifies it and extracts the processing status of the orginal AS2 message. :param raw_content: A byte string of the received HTTP headers followed by the body. :param find_message_cb: A callback the must returns the original Message Object. The 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 processing status. """ status, detailed_status = None, None 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 # 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) for part in self.payload.walk(): 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 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 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.orig_message_id = message.message_id # Set up the message headers mdn_headers = { "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 # overwrite with organization specific message if message.receiver and message.receiver.mdn_confirm_text: confirmation_text = message.receiver.mdn_confirm_text # overwrite with partner specific message if message.sender and message.sender.mdn_confirm_text: confirmation_text = message.sender.mdn_confirm_text if status != "processed": confirmation_text = failed_text self.payload = MIMEMultipart("report", report_type="disposition-notification") # Create and attach the MDN Text Message mdn_text = email_message.Message() 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\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 += f": {detailed_status}" mdn_report += "\r\n" if message.mic: 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"] 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().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.add_header("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.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(): if self.payload.get(k): self.payload.replace_header(k, v) else: self.payload.add_header(k, v) 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_org_cb, find_partner_cb, find_message_cb=None): """Function parses the RAW AS2 message; decrypts, verifies and decompresses it and extracts the payload. :param raw_content: A byte string of the received HTTP headers followed by the body. :param find_org_cb: A callback the returns an Organization object if exists. The as2-to header value is passed as an argument to it. :param 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, 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), 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(">") as2_headers[k] = v try: # Get the organization and partner for this transmission org_id = unquote_as2name(as2_headers["as2-to"]) self.receiver = find_org_cb(org_id) if not self.receiver: raise PartnerNotFound( f"Unknown AS2 organization with id {org_id}") partner_id = unquote_as2name(as2_headers["as2-from"]) self.sender = find_partner_cb(partner_id) if not self.sender: 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"): raise InsufficientSecurityError( 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)}") self.encrypted = True encrypted_data = self.payload.get_payload(decode=True) self.enc_alg, decrypted_content = decrypt_message( encrypted_data, self.receiver.decrypt_key) self.payload = parse_mime(decrypted_content) 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") # 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( 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": 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 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) else: self.payload = part # Verify the message, first using raw message and if it fails # then convert to canonical form and try again mic_content = canonicalize(self.payload) 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() # Check for compressed data here if not self.compressed: self.compressed, self.payload = self._decompress_data( self.payload) 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, traceback.format_exc()) logger.error( f"Failed to parse AS2 message\n: {traceback.format_exc()}") # 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) if as2_headers.get("disposition-notification-to"): mdn_mode = SYNCHRONOUS_MDN mdn_url = as2_headers.get("receipt-delivery-option") if mdn_url: mdn_mode = ASYNCHRONOUS_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) return status, exception, mdn
def build( self, data, filename=None, subject="AS2 Message", content_type="application/edi-consent", additional_headers=None, disposition_notification_to="*****@*****.**", ): """Function builds the AS2 message. Compresses, signs and encrypts the payload if applicable. :param data: A byte string of the data to be transmitted. :param filename: Optional filename to be included in the Content-disposition header. :param subject: The subject for the AS2 message, used by some AS2 servers for additional routing of messages. (default "AS2 Message") :param content_type: The content type for the AS2 message, to be used in the MIME header. (default "application/edi-consent") :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 "*****@*****.**") """ # Validations assert isinstance(data, bytes), "Parameter data must be of bytes type." additional_headers = additional_headers if additional_headers else {} 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." ) 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." ) # Generate message id using UUID 1 as it uses both hostname and time 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": 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) # Read the input and convert to bytes if value is unicode/str # using utf-8 encoding and finally Canonicalize the payload self.payload = email_message.Message() self.payload.set_payload(data) self.payload.set_type(content_type) 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"] 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.add_header("Content-Disposition", "attachment", filename="smime.p7z") compressed_message.add_header("Content-Transfer-Encoding", "binary") compressed_message.set_payload( 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_message.attach(self.payload) # Calculate the MIC Hash of the message to be verified mic_content = canonicalize(self.payload) digest_func = hashlib.new(self.digest_alg) digest_func.update(mic_content) self.mic = binascii.b2a_base64(digest_func.digest()).strip() # 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.add_header("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.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.add_header("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"] = disposition_notification_to if self.receiver.mdn_digest_alg: 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 # Update the headers of the final payload and set its boundary for k, v in as2_headers.items(): if self.payload.get(k): 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())