Verify PDF signatures
This commit is contained in:
		
							
								
								
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								Pipfile
									
									
									
									
									
								
							| @@ -23,6 +23,7 @@ lockfile = "*" | |||||||
| defusedxml = "*" | defusedxml = "*" | ||||||
| "flask-rq2" = "*" | "flask-rq2" = "*" | ||||||
| simplejson = "*" | simplejson = "*" | ||||||
|  | asn1crypto = "*" | ||||||
|  |  | ||||||
| [dev-packages] | [dev-packages] | ||||||
| bandit = "*" | bandit = "*" | ||||||
|   | |||||||
							
								
								
									
										3
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								Pipfile.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,7 +1,7 @@ | |||||||
| { | { | ||||||
|     "_meta": { |     "_meta": { | ||||||
|         "hash": { |         "hash": { | ||||||
|             "sha256": "7aff94ddfb4f3f3ebf7f7910f3ade4eebd546b297cf72863a618824f87ec76fc" |             "sha256": "03d5c2a739febe9a3c10d599ad5825ef603130098ecd73ce9833310d1eaed253" | ||||||
|         }, |         }, | ||||||
|         "pipfile-spec": 6, |         "pipfile-spec": 6, | ||||||
|         "requires": { |         "requires": { | ||||||
| @@ -36,6 +36,7 @@ | |||||||
|                 "sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", |                 "sha256:2f1adbb7546ed199e3c90ef23ec95c5cf3585bac7d11fb7eb562a3fe89c64e87", | ||||||
|                 "sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49" |                 "sha256:9d5c20441baf0cb60a4ac34cc447c6c189024b6b4c6cd7877034f4965c464e49" | ||||||
|             ], |             ], | ||||||
|  |             "index": "pypi", | ||||||
|             "version": "==0.24.0" |             "version": "==0.24.0" | ||||||
|         }, |         }, | ||||||
|         "certifi": { |         "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