commit ef32d5d91ce103c33431dc3a8df5709a9c64095f Author: Julien Fastré Date: Thu Jun 27 09:51:32 2024 +0200 First commit: create mvp project - create an orchestrator to orchestrate signature of pdf; - create a worker to get request from amqp and resend it to amqp diff --git a/pythonProject/assets/.gitignore b/pythonProject/assets/.gitignore new file mode 100644 index 0000000..74e0e63 --- /dev/null +++ b/pythonProject/assets/.gitignore @@ -0,0 +1,4 @@ +* +!dummy.p12 +!test.pdf +!.gitignore \ No newline at end of file diff --git a/pythonProject/assets/dummy.p12 b/pythonProject/assets/dummy.p12 new file mode 100644 index 0000000..fc577cf Binary files /dev/null and b/pythonProject/assets/dummy.p12 differ diff --git a/pythonProject/assets/test.pdf b/pythonProject/assets/test.pdf new file mode 100644 index 0000000..a9c7002 Binary files /dev/null and b/pythonProject/assets/test.pdf differ diff --git a/pythonProject/requirements.txt b/pythonProject/requirements.txt new file mode 100644 index 0000000..dafc337 --- /dev/null +++ b/pythonProject/requirements.txt @@ -0,0 +1,20 @@ +asn1crypto==1.5.1 +certifi==2024.6.2 +cffi==1.16.0 +charset-normalizer==3.3.2 +click==8.1.7 +cryptography==42.0.8 +idna==3.7 +oscrypto==1.3.0 +pika==1.3.2 +pycparser==2.22 +pyHanko==0.25.0 +pyhanko-certvalidator==0.26.3 +pypng==0.20220715.0 +PyYAML==6.0.1 +qrcode==7.4.2 +requests==2.32.3 +typing_extensions==4.12.2 +tzlocal==5.2 +uritools==4.0.3 +urllib3==2.2.2 diff --git a/pythonProject/sign.py b/pythonProject/sign.py new file mode 100644 index 0000000..4c1d293 --- /dev/null +++ b/pythonProject/sign.py @@ -0,0 +1,74 @@ +import io +from typing import Optional + +from pyhanko import stamp +from pyhanko.pdf_utils.incremental_writer import IncrementalPdfFileWriter +from pyhanko.sign import signers, timestamps, fields +from pyhanko_certvalidator import ValidationContext +from typing_extensions import Buffer + + +class SignOrchestrator: + """Orchestrate the signature on document""" + + def __init__(self, pkcs12_path: str, timestamp_url: str, pkcs12_password: Optional[bytes] = None): + # Load signer key material from PKCS#12 file + # This assumes that any relevant intermediate certs are also included + # in the PKCS#12 file. + self.signer = signers.SimpleSigner.load_pkcs12( + pfx_file=pkcs12_path, passphrase=pkcs12_password + ) + + # Set up a timestamping client to fetch timestamps tokens + self.timestamper = timestamps.HTTPTimeStamper( + url=timestamp_url, + ) + + self.stamp_style = stamp.TextStampStyle( + stamp_text="Signé par:\n%(signer_text)s\nLe %(ts)s", + border_width=1, + ) + + + def _make_signature_metadata(self, reason: str, field_name: str): + # Settings for PAdES-LTA + return signers.PdfSignatureMetadata( + field_name=field_name, md_algorithm='sha256', + # Mark the signature as a PAdES signature + subfilter=fields.SigSeedSubFilter.PADES, + # We'll also need a validation context + # to fetch & embed revocation info. + # validation_context=ValidationContext(allow_fetching=True), + # Embed relevant OCSP responses / CRLs (PAdES-LT) + # embed_validation_info=True, + # Tell pyHanko to put in an extra DocumentTimeStamp + # to kick off the PAdES-LTA timestamp chain. + use_pades_lta=True, + reason=reason, + ) + + def sign(self, reason: str, signature_index: int, input_content: Buffer, on_page: int, box_place: (int, int, int, int), signer_text: str) -> io.BytesIO: + field_name = 'Signature' + str(signature_index) + signature_meta = self._make_signature_metadata(reason, field_name) + + pdf_signer = signers.PdfSigner( + signature_meta, signer=self.signer, timestamper=self.timestamper, + stamp_style=self.stamp_style + ) + + inf = io.BytesIO(input_content) + w = IncrementalPdfFileWriter(inf) + fields.append_signature_field(w, sig_field_spec=fields.SigFieldSpec( + field_name, on_page=on_page, box=box_place + )) + outf = io.BytesIO() + pdf_signer.sign_pdf( + w, output=outf, appearance_text_params={'signer_text': signer_text} + ) + + return outf + + + + + diff --git a/pythonProject/sign_individual.py b/pythonProject/sign_individual.py new file mode 100644 index 0000000..9e3c79c --- /dev/null +++ b/pythonProject/sign_individual.py @@ -0,0 +1,19 @@ +from sign import SignOrchestrator + +orchestrator = SignOrchestrator('./assets/dummy.p12','http://freetsa.org/tsr', pkcs12_password=None) + +with open('./assets/test.pdf', 'rb') as input: + signed_content = orchestrator.sign(reason="first signer", signature_index=0, + input_content=input.read(), box_place=(300, 600, 500, 660), on_page=0, + signer_text="Mme Caroline Diallo") + + with open('./assets/test_signed_0.pdf', 'wb') as output: + output.write(signed_content.read()) + +with open('./assets/test_signed_0.pdf', 'rb') as input: + signed_content = orchestrator.sign(reason="second signer", signature_index=1, + input_content=input.read(), box_place=(100, 600, 300, 660), on_page=0, + signer_text="M. Bah Mamadou") + + with open('./assets/test_signed_1.pdf', 'wb') as output: + output.write(signed_content.read()) diff --git a/pythonProject/worker.py b/pythonProject/worker.py new file mode 100644 index 0000000..49f7d58 --- /dev/null +++ b/pythonProject/worker.py @@ -0,0 +1,75 @@ +import base64 +import io +import json +import logging +import pika +import sign + +dsn = 'amqp://guest:guest@localhost:32773/%2f/to_python_sign' + +LOG_FORMAT = ('%(levelname) -10s %(asctime)s %(name) -30s %(funcName) ' + '-35s %(lineno) -5d: %(message)s') +logging.basicConfig(level=logging.INFO, format=LOG_FORMAT) +LOGGER = logging.getLogger(__name__) +LOGGER.setLevel(logging.DEBUG) + +orchestrator = sign.SignOrchestrator('./assets/dummy.p12', + 'http://freetsa.org/tsr', pkcs12_password=None) + +parameters = pika.URLParameters(dsn) +connection = pika.BlockingConnection(parameters) +channel = connection.channel() +channel.confirm_delivery() + + +def on_message(channel, method_frame, header_frame, body): + LOGGER.debug("receiving a message") + body_content = json.loads(body) + LOGGER.info(f"request to add a signature, signatureId: {body_content['signatureId']}") + + try: + box_place = (body_content['signatureZone']['x'], body_content['signatureZone']['y'], + body_content['signatureZone']['x'] + body_content['signatureZone']['width'], + body_content['signatureZone']['y'] + body_content['signatureZone']['height']) + LOGGER.debug("will try signature") + signed = orchestrator.sign(reason=body_content['reason'], signature_index=body_content['signatureZoneIndex'], + box_place=box_place, on_page=body_content['signatureZone']['PDFPage']['index'], + signer_text=body_content['signerText'], + input_content=base64.b64decode(body_content['content'])) + LOGGER.info(f"signature obtained, signatureId: {body_content['signatureId']}") + + with open(f"./assets/new.{method_frame.consumer_tag}.{method_frame.delivery_tag}.pdf", 'wb') as f: + f.write(signed.read()) + LOGGER.debug("signed file saved") + # because we consumed the buffer to write a file, we have to rewind it + signed.seek(0) + channel.basic_publish(exchange='signed_docs', + body=json.dumps({'signatureId': body_content['signatureId'], + 'content': base64.b64encode(signed.read()).decode('utf-8')}), + properties=pika.BasicProperties(content_type='application/json', + delivery_mode=pika.DeliveryMode.Transient), + routing_key='signed_doc') + LOGGER.debug("signed file resend to amqp") + channel.basic_ack(delivery_tag=method_frame.delivery_tag) + + except Exception as e: + LOGGER.warning(f"error encountered while signing: {e}") + if method_frame.redelivered: + LOGGER.warning( + f"stopping handling this message, because the message is already redelivered, signatureId: {body_content['signatureId']}") + channel.basic_reject(delivery_tag=method_frame.delivery_tag, requeue=True) + else: + LOGGER.warning(f"first try failed, signatureId: {body_content['signatureId']}") + channel.basic_ack(delivery_tag=method_frame.delivery_tag) + + +if __name__ == '__main__': + LOGGER.info('starting worker') + channel.basic_consume('to_python_sign', on_message) + try: + LOGGER.info("start consuming") + channel.start_consuming() + except KeyboardInterrupt: + LOGGER.info("keyboard interrupt") + channel.stop_consuming() + connection.close()