Verify PDF signatures
This commit is contained in:
		
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -23,6 +23,7 @@ lockfile = "*" | ||||
| defusedxml = "*" | ||||
| "flask-rq2" = "*" | ||||
| simplejson = "*" | ||||
| asn1crypto = "*" | ||||
|  | ||||
| [dev-packages] | ||||
| bandit = "*" | ||||
|   | ||||
							
								
								
									
										3
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|     "_meta": { | ||||
|         "hash": { | ||||
|             "sha256": "7aff94ddfb4f3f3ebf7f7910f3ade4eebd546b297cf72863a618824f87ec76fc" | ||||
|             "sha256": "03d5c2a739febe9a3c10d599ad5825ef603130098ecd73ce9833310d1eaed253" | ||||
|         }, | ||||
|         "pipfile-spec": 6, | ||||
|         "requires": { | ||||
| @@ -36,6 +36,7 @@ | ||||
|                 "sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", | ||||
|                 "sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49" | ||||
|             ], | ||||
|             "index": "pypi", | ||||
|             "version": "==0.24.0" | ||||
|         }, | ||||
|         "certifi": { | ||||
|   | ||||
							
								
								
									
										225
									
								
								atst/utils/pdf_verification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								atst/utils/pdf_verification.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,225 @@ | ||||
| import hashlib | ||||
| from OpenSSL import crypto | ||||
| from asn1crypto import cms, pem, core | ||||
| from atst.domain.authnid.crl import CRLCache, CRLRevocationException | ||||
| from cryptography.hazmat.primitives import hashes | ||||
| from cryptography.hazmat.primitives.asymmetric import padding | ||||
|  | ||||
|  | ||||
| class PDFSignature: | ||||
|     def __init__(self, byte_range_start=None, pdf=None): | ||||
|         self.pdf = pdf | ||||
|         self.byte_range_start = byte_range_start | ||||
|         self._signers_cert = None | ||||
|  | ||||
|         # assert byte_range_start != -1 and self.start != -1 and self.stop != -1 | ||||
|  | ||||
|     @property | ||||
|     def byte_range(self): | ||||
|         """ | ||||
|         This returns an array of 4 numbers that represent the byte range of | ||||
|         the PDF binary file that is signed by the certificate. | ||||
|  | ||||
|         E.G: [0, 2045, 3012, 5012] | ||||
|  | ||||
|         Bytes    0 to 2045 - represent part A of the signed file | ||||
|         Bytes 2046 to 3012 - would contain the signature and certificate information | ||||
|         Bytes 3013 to 5012 - represent part B of the signed file | ||||
|         """ | ||||
|         start = self.pdf.find(b"[", self.byte_range_start) | ||||
|         stop = self.pdf.find(b"]", start) | ||||
|         contents_range = [int(i, 10) for i in self.pdf[start + 1 : stop].split()] | ||||
|  | ||||
|         return contents_range | ||||
|  | ||||
|     @property | ||||
|     def signed_binary_data(self): | ||||
|         """ | ||||
|         This is the binary data stored in the signature | ||||
|         """ | ||||
|         br = self.byte_range | ||||
|         contents = self.pdf[br[0] + br[1] + 1 : br[2] - 1] | ||||
|         data = [] | ||||
|  | ||||
|         for i in range(0, len(contents), 2): | ||||
|             data.append(int(contents[i : i + 2], 16)) | ||||
|  | ||||
|         return cms.ContentInfo.load(bytes(data))["content"] | ||||
|  | ||||
|     @property | ||||
|     def signers_cert(self): | ||||
|         """ | ||||
|         This returns the certificate used to sign the PDF | ||||
|         """ | ||||
|         if self._signers_cert == None: | ||||
|             for cert in self.signed_binary_data["certificates"]: | ||||
|                 if ( | ||||
|                     self.signers_serial | ||||
|                     == cert.native["tbs_certificate"]["serial_number"] | ||||
|                 ): | ||||
|                     cert = cert.dump() | ||||
|                     self._signers_cert = pem.armor("CERTIFICATE", cert) | ||||
|                     break | ||||
|  | ||||
|         return self._signers_cert | ||||
|  | ||||
|     @property | ||||
|     def signers_serial(self): | ||||
|         """ | ||||
|         Return the signers serial from their certificate | ||||
|         """ | ||||
|         return self.signed_binary_data["signer_infos"][0]["sid"].native["serial_number"] | ||||
|  | ||||
|     @property | ||||
|     def hashing_algorithm(self): | ||||
|         """ | ||||
|         This is the hashing algorithm used to generate the hash of binary file content | ||||
|         which is then signed by the certificate. | ||||
|  | ||||
|         E.G. sha256, sha1 | ||||
|         """ | ||||
|         return self.signed_binary_data["digest_algorithms"][0]["algorithm"].native | ||||
|  | ||||
|     @property | ||||
|     def cert_common_name(self): | ||||
|         """ | ||||
|         This returns the common name on the certificate. This might be a name or | ||||
|         a DOD ID for example. | ||||
|         """ | ||||
|         return ( | ||||
|             crypto.load_certificate(crypto.FILETYPE_PEM, self.signers_cert) | ||||
|             .get_subject() | ||||
|             .commonName | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def encrypted_hash_of_signed_document(self): | ||||
|         """ | ||||
|         This is the calculated hash of the PDF binary data stored in the | ||||
|         signature. We calculate it outselves and then compare to this | ||||
|         so we can see if data has changed. | ||||
|         """ | ||||
|         stored_hash = None | ||||
|  | ||||
|         for attr in self.signed_binary_data["signer_infos"][0]["signed_attrs"]: | ||||
|             if attr["type"].native == "message_digest": | ||||
|                 stored_hash = attr["values"].native[0] | ||||
|                 break | ||||
|  | ||||
|         return stored_hash | ||||
|  | ||||
|     @property | ||||
|     def binary_data(self): | ||||
|         """ | ||||
|         Take the byte range and return the binary data for that rage. | ||||
|         """ | ||||
|         br = self.byte_range | ||||
|         data1 = self.pdf[br[0] : br[0] + br[1]] | ||||
|         data2 = self.pdf[br[2] : br[2] + br[3]] | ||||
|  | ||||
|         return data1 + data2 | ||||
|  | ||||
|     @property | ||||
|     def hashed_binary_data(self): | ||||
|         """ | ||||
|         Takes the data in the byte range and hashes it using | ||||
|         the hashing algorithm specified in the signed PDF. We | ||||
|         can later compare this to the encrypted_hash_of_signed_document. | ||||
|         """ | ||||
|         return getattr(hashlib, self.hashing_algorithm)(self.binary_data) | ||||
|  | ||||
|     @property | ||||
|     def is_cert_valid(self): | ||||
|         """ | ||||
|         Takes the signing certificate and runs it through the CRLCache | ||||
|         checker. Returns a boolean. | ||||
|         """ | ||||
|         try: | ||||
|             cache = CRLCache( | ||||
|                 "ssl/server-certs/ca-chain.pem", | ||||
|                 crl_locations=["ssl/client-certs/client-ca.der.crl"], | ||||
|             ) | ||||
|             return cache.crl_check(self.signers_cert) | ||||
|         except CRLRevocationException: | ||||
|             return False | ||||
|  | ||||
|     @property | ||||
|     def is_signature_valid(self): | ||||
|         """ | ||||
|         Get signed PDF signature and determine if it was actually signed | ||||
|         by the certificate that it claims it was. Returns a boolean. | ||||
|         """ | ||||
|         public_key = ( | ||||
|             crypto.load_certificate(crypto.FILETYPE_PEM, self.signers_cert) | ||||
|             .get_pubkey() | ||||
|             .to_cryptography_key() | ||||
|         ) | ||||
|         attrs = self.signed_binary_data["signer_infos"][0]["signed_attrs"] | ||||
|         signedData = None | ||||
|  | ||||
|         if attrs is not None and not isinstance(attrs, core.Void): | ||||
|             signedData = attrs.dump() | ||||
|             signedData = b"\x31" + signedData[1:] | ||||
|         else: | ||||
|             signedData = self.binary_data | ||||
|  | ||||
|         try: | ||||
|             public_key.verify( | ||||
|                 bytes(self.signed_binary_data["signer_infos"][0]["signature"]), | ||||
|                 signedData, | ||||
|                 padding.PKCS1v15(), | ||||
|                 getattr(hashes, self.hashing_algorithm.upper())(), | ||||
|             ) | ||||
|             return True | ||||
|         except Exception: | ||||
|             return False | ||||
|  | ||||
|     @property | ||||
|     def to_dict(self): | ||||
|         is_cert_valid = self.is_cert_valid | ||||
|         is_signature_valid = self.is_signature_valid | ||||
|         is_hash_valid = ( | ||||
|             self.hashed_binary_data.digest() == self.encrypted_hash_of_signed_document | ||||
|         ) | ||||
|  | ||||
|         return { | ||||
|             "cert_common_name": self.cert_common_name, | ||||
|             "hashed_binary_data": self.hashed_binary_data.hexdigest(), | ||||
|             "hashing_algorithm": self.hashing_algorithm, | ||||
|             "is_valid": is_cert_valid and is_hash_valid and is_signature_valid, | ||||
|             "is_valid_cert": is_cert_valid, | ||||
|             "is_valid_hash": is_hash_valid, | ||||
|             "is_valid_signature": is_signature_valid, | ||||
|             "signers_serial": self.signers_serial, | ||||
|         } | ||||
|  | ||||
|  | ||||
| def pdf_signature_validations(pdf=None): | ||||
|     signatures = [] | ||||
|     start_byte = 0 | ||||
|  | ||||
|     while True: | ||||
|         start = start_byte + 1 | ||||
|         n = pdf.find(b"/ByteRange", start) | ||||
|  | ||||
|         if n == -1: | ||||
|             break | ||||
|  | ||||
|         signatures.append(PDFSignature(byte_range_start=n, pdf=pdf)) | ||||
|         start_byte = n | ||||
|  | ||||
|     response = {"result": None, "signature_count": len(signatures), "signatures": []} | ||||
|  | ||||
|     for signature in signatures: | ||||
|         sig = signature.to_dict | ||||
|         response["signatures"].append(sig) | ||||
|  | ||||
|         if not sig["is_valid"]: | ||||
|             response["result"] = "FAILURE" | ||||
|         elif response["result"] is not "FAILURE": | ||||
|             response["result"] = "OK" | ||||
|  | ||||
|     if len(signatures) == 0: | ||||
|         response["result"] = "FAILURE" | ||||
|  | ||||
|     return response | ||||
							
								
								
									
										
											BIN
										
									
								
								tests/fixtures/sally-darth-signed.pdf
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/fixtures/sally-darth-signed.pdf
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								tests/fixtures/signed-expired-cert.pdf
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/fixtures/signed-expired-cert.pdf
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								tests/fixtures/signed-pdf-not-dod.pdf
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								tests/fixtures/signed-pdf-not-dod.pdf
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										151
									
								
								tests/utils/test_pdf_verification.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										151
									
								
								tests/utils/test_pdf_verification.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,151 @@ | ||||
| import pytest | ||||
| from atst.utils.pdf_verification import pdf_signature_validations | ||||
|  | ||||
|  | ||||
| def test_unsigned_pdf(): | ||||
|     unsigned_pdf = open("tests/fixtures/sample.pdf", "rb").read() | ||||
|     result = pdf_signature_validations(pdf=unsigned_pdf) | ||||
|  | ||||
|     assert result == {"result": "FAILURE", "signature_count": 0, "signatures": []} | ||||
|  | ||||
|  | ||||
| def test_valid_signed_pdf(): | ||||
|     valid_signed_pdf = open("tests/fixtures/sally-darth-signed.pdf", "rb").read() | ||||
|     result = pdf_signature_validations(pdf=valid_signed_pdf) | ||||
|  | ||||
|     assert result == { | ||||
|         "result": "OK", | ||||
|         "signature_count": 2, | ||||
|         "signatures": [ | ||||
|             { | ||||
|                 "cert_common_name": "WILLIAMS.SALLY.3453453453", | ||||
|                 "hashed_binary_data": "b879a15e19eece534dc63019d3fe539ff4a3efbf8e8f5403a8bdae26a9b713ea", | ||||
|                 "hashing_algorithm": "sha256", | ||||
|                 "is_valid": True, | ||||
|                 "is_valid_cert": True, | ||||
|                 "is_valid_hash": True, | ||||
|                 "is_valid_signature": True, | ||||
|                 "signers_serial": 9_662_248_800_192_484_626, | ||||
|             }, | ||||
|             { | ||||
|                 "cert_common_name": "VADER.DARTH.9012345678", | ||||
|                 "hashed_binary_data": "d98339766c20a369219f236220d7b450111554acc902e242d015dd6d306c7809", | ||||
|                 "hashing_algorithm": "sha256", | ||||
|                 "is_valid": True, | ||||
|                 "is_valid_cert": True, | ||||
|                 "is_valid_hash": True, | ||||
|                 "is_valid_signature": True, | ||||
|                 "signers_serial": 9_662_248_800_192_484_627, | ||||
|             }, | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_signed_pdf_thats_been_modified(): | ||||
|     valid_signed_pdf = open("tests/fixtures/sally-darth-signed.pdf", "rb").read() | ||||
|     modified_pdf = valid_signed_pdf.replace(b"PDF-1.6", b"PDF-1.7") | ||||
|     result = pdf_signature_validations(pdf=modified_pdf) | ||||
|  | ||||
|     assert result == { | ||||
|         "result": "FAILURE", | ||||
|         "signature_count": 2, | ||||
|         "signatures": [ | ||||
|             { | ||||
|                 "cert_common_name": "WILLIAMS.SALLY.3453453453", | ||||
|                 "hashed_binary_data": "d1fb3c955b57f139331586276ba4abca90ecc5d36b53fe6bbbbbd8707d7124bb", | ||||
|                 "hashing_algorithm": "sha256", | ||||
|                 "is_valid": False, | ||||
|                 "is_valid_cert": True, | ||||
|                 "is_valid_hash": False, | ||||
|                 "is_valid_signature": True, | ||||
|                 "signers_serial": 9_662_248_800_192_484_626, | ||||
|             }, | ||||
|             { | ||||
|                 "cert_common_name": "VADER.DARTH.9012345678", | ||||
|                 "hashed_binary_data": "75ef47824de4b5477c75665c5a90e39a2b8a8985422cf2f7f641661a7b5217a8", | ||||
|                 "hashing_algorithm": "sha256", | ||||
|                 "is_valid": False, | ||||
|                 "is_valid_cert": True, | ||||
|                 "is_valid_hash": False, | ||||
|                 "is_valid_signature": True, | ||||
|                 "signers_serial": 9_662_248_800_192_484_627, | ||||
|             }, | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_signed_pdf_not_on_chain(): | ||||
|     signed_pdf_not_on_chain = open("tests/fixtures/signed-pdf-not-dod.pdf", "rb").read() | ||||
|     result = pdf_signature_validations(pdf=signed_pdf_not_on_chain) | ||||
|  | ||||
|     assert result == { | ||||
|         "result": "FAILURE", | ||||
|         "signature_count": 1, | ||||
|         "signatures": [ | ||||
|             { | ||||
|                 "cert_common_name": "John B Harris", | ||||
|                 "hashed_binary_data": "3f0047e6cb5b9bb089254b20d174445c3ba4f513", | ||||
|                 "hashing_algorithm": "sha1", | ||||
|                 "is_valid": False, | ||||
|                 "is_valid_cert": False, | ||||
|                 "is_valid_hash": True, | ||||
|                 "is_valid_signature": True, | ||||
|                 "signers_serial": 514, | ||||
|             } | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|  | ||||
| @pytest.mark.skip(reason="Need fixture file") | ||||
| def test_signed_pdf_dod_revoked(): | ||||
|     signed_pdf_dod_revoked = open( | ||||
|         "tests/fixtures/signed-pdf-dod_revoked.pdf", "rb" | ||||
|     ).read() | ||||
|     result = pdf_signature_validations(pdf=signed_pdf_dod_revoked) | ||||
|  | ||||
|     assert result == { | ||||
|         "result": "FAILURE", | ||||
|         "signature_count": 1, | ||||
|         "signatures": [ | ||||
|             { | ||||
|                 "cert_common_name": None, | ||||
|                 "hashed_binary_data": None, | ||||
|                 "hashing_algorithm": None, | ||||
|                 "is_valid": None, | ||||
|                 "is_valid_cert": None, | ||||
|                 "is_valid_hash": None, | ||||
|                 "signers_serial": None, | ||||
|             } | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|  | ||||
| def test_signed_dod_pdf_signer_cert_expired(): | ||||
|     # | ||||
|     # TODO: Is this good enough? Do we want an expired DOD certificate? This test is using | ||||
|     #       a fake DOD certificate. | ||||
|     # | ||||
|     signed_pdf_dod_revoked = open("tests/fixtures/signed-expired-cert.pdf", "rb").read() | ||||
|     result = pdf_signature_validations(pdf=signed_pdf_dod_revoked) | ||||
|  | ||||
|     assert result == { | ||||
|         "result": "FAILURE", | ||||
|         "signature_count": 1, | ||||
|         "signatures": [ | ||||
|             { | ||||
|                 "cert_common_name": "Bob Alice", | ||||
|                 "hashed_binary_data": "bcfad46c89b1695325f5b6e73b589d086e3925ab384def6fcb13904991e69077", | ||||
|                 "hashing_algorithm": "sha256", | ||||
|                 "is_valid": False, | ||||
|                 "is_valid_cert": False, | ||||
|                 "is_valid_hash": True, | ||||
|                 "is_valid_signature": True, | ||||
|                 "signers_serial": -180_673_825_300_246_991_177_196, | ||||
|             } | ||||
|         ], | ||||
|     } | ||||
|  | ||||
|  | ||||
| @pytest.mark.skip(reason="TODO") | ||||
| def test_crl_check_unavailable(): | ||||
|     pass | ||||
		Reference in New Issue
	
	Block a user