├── .gitignore ├── README.md ├── applepay ├── __init__.py ├── payment.py └── utils.py ├── ca ├── AppleAAICAG3.cer └── AppleRootCA-G3.cer ├── setup.cfg ├── setup.py └── tests ├── applepay_test.py ├── fixtures ├── certificate.pem ├── private_key.pem └── token.json └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | .venv/ 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | 92 | # IntelliJ settings 93 | .idea/ 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Python library for decrypting Apple Pay payment tokens. 2 | 3 | ApplePay reference https://developer.apple.com/library/ios/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html 4 | 5 | ## Apple's intermediate and root certificates 6 | 7 | ```sh 8 | $ wget 'https://www.apple.com/certificateauthority/AppleAAICAG3.cer' 9 | $ wget 'https://www.apple.com/certificateauthority/AppleRootCA-G3.cer' 10 | ``` 11 | 12 | ## Install 13 | 14 | Installing library into your environment: 15 | 16 | ```sh 17 | $ pip install applepay 18 | ``` 19 | 20 | ## Usage 21 | 22 | Step by step: 23 | 24 | 25 | ```python 26 | from applepay import payment as apple 27 | 28 | # payment_json value example: 29 | # 30 | # {"data":"<>", 31 | # "header": 32 | # {"publicKeyHash":"<>", 33 | # "ephemeralPublicKey":"<>", 34 | # "transactionId":"<>"}, 35 | # "version":"EC_v1"} 36 | 37 | 38 | certificate_pem = open('merchant_cert.pem', 'rb').read() 39 | private_key_pem = open('merchant_private_key', 'rb').read() 40 | 41 | payment = apple.Payment(certificate_pem, private_key_pem) 42 | 43 | decrypted_json = payment.decrypt(payment_json['header']['ephemeralPublicKey'], payment_json['data']) 44 | 45 | 46 | # decrypted_json value example 47 | # { 48 | # "applicationPrimaryAccountNumber"=>"4804123456789012", 49 | # "applicationExpirationDate"=>"190123", 50 | # "currencyCode"=>"123", 51 | # "transactionAmount"=>700, 52 | # "deviceManufacturerIdentifier"=>"123456789012", 53 | # "paymentDataType"=>"3DSecure", 54 | # "paymentData"=> { 55 | # "onlinePaymentCryptogram"=>"<>", 56 | # "eciIndicator"=>"5" 57 | # } 58 | # } 59 | ``` 60 | 61 | ## Testing 62 | 63 | ```sh 64 | $ python setup.py test 65 | ``` 66 | 67 | ## Contributors 68 | -------------------------------------------------------------------------------- /applepay/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powermobileweb/applepay/7d947af24101902d4361f26130274e70c114f817/applepay/__init__.py -------------------------------------------------------------------------------- /applepay/payment.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | from binascii import unhexlify 3 | from hashlib import sha256 4 | 5 | from cryptography.hazmat.backends import default_backend 6 | from cryptography.hazmat.primitives import ciphers, hashes 7 | from cryptography.hazmat.primitives.asymmetric import ec 8 | from cryptography.hazmat.primitives.serialization import load_pem_private_key 9 | from cryptography.hazmat.primitives.serialization import load_der_public_key 10 | from cryptography.x509 import load_pem_x509_certificate, load_der_x509_certificate 11 | 12 | 13 | OID_MERCHANT_ID = "1.2.840.113635.100.6.32" 14 | OID_LEAF_CERTIFICATE = "1.2.840.113635.100.6.29" 15 | OID_INTERMEDIATE_CERTIFICATE = "1.2.840.113635.100.6.2.14" 16 | OID_MESSAGE_DIGEST = "1.2.840.113549.1.9.4" 17 | OID_SIGNING_TIME = "1.2.840.113549.1.9.5" 18 | 19 | ROOT_CA_FILE = 'ca/AppleRootCA-G3.cer' 20 | AAI_CA_FILE = 'ca/AppleAAICAG3.cer' 21 | 22 | 23 | class Payment(object): 24 | def __init__(self, merc_ca_pem, private_key_pem, root_ca_der=None, aai_ca_der=None): 25 | backend = default_backend() 26 | if root_ca_der is None: 27 | self._root_ca = load_der_x509_certificate(open(ROOT_CA_FILE, 'rb').read(), backend) 28 | else: 29 | self._root_ca = load_der_x509_certificate(root_ca_der, backend) 30 | 31 | if aai_ca_der is None: 32 | self._aai_ca = load_der_x509_certificate(open(AAI_CA_FILE, 'rb').read(), backend) 33 | else: 34 | self._aai_ca = load_der_x509_certificate(aai_ca_der, backend) 35 | 36 | merc_ca = load_pem_x509_certificate(merc_ca_pem, backend) 37 | 38 | self._validate_cert(merc_ca) 39 | 40 | self._merc_id = unhexlify(self._extract_merchant_id(merc_ca)) 41 | self._private_key = load_pem_private_key(private_key_pem, None, backend) 42 | 43 | def _validate_cert(self, merc_ca): 44 | pass 45 | 46 | def _valid_signature(self, ephemeral_public_key, data, transaction_id, application_data=''): 47 | s = b64decode(ephemeral_public_key) + b64decode(data) + b64decode(transaction_id) + b64decode(application_data) 48 | return self._private_key.sign(s, ec.ECDSA(hashes.SHA256())) 49 | 50 | def _extract_merchant_id(self, cert_pem): 51 | for ext in cert_pem.extensions: 52 | if ext.oid.dotted_string == OID_MERCHANT_ID: 53 | return ext.value.value[2:] 54 | 55 | return None 56 | 57 | def _generate_symmetric_key(self, shared_secred): 58 | sha = sha256() 59 | sha.update(b'\0' * 3) 60 | sha.update(b'\1') 61 | sha.update(shared_secred) 62 | sha.update(b'\x0did-aes256-GCM' + b'Apple' + self._merc_id) 63 | 64 | return sha.digest() 65 | 66 | def decrypt(self, ephemeral_public_key, cipher_data, transaction_id=None, application_data=''): 67 | 68 | if transaction_id is not None: 69 | sig = self._valid_signature(ephemeral_public_key, cipher_data, transaction_id, application_data) 70 | 71 | public_key = load_der_public_key(b64decode(ephemeral_public_key), default_backend()) 72 | cipherdata = b64decode(cipher_data) 73 | shared_secred = self._private_key.exchange(ec.ECDH(), public_key) 74 | 75 | symmetric_key = self._generate_symmetric_key(shared_secred) 76 | 77 | mode = ciphers.modes.GCM(b'\0' * 16, cipherdata[-16:], 16) 78 | decryptor = ciphers.Cipher(ciphers.algorithms.AES(symmetric_key), mode, backend=default_backend()).decryptor() 79 | 80 | return decryptor.update(cipherdata[:-16]) + decryptor.finalize() 81 | -------------------------------------------------------------------------------- /applepay/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | The utilites in this module are used to verify and parse an apple pay token. The 3 | utilities assume the token is a python dictionary. See the #ApplePaySpec below for 4 | more documentation and guidelines on the steps taken here. 5 | 6 | #ApplePaySpec: https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html#//apple_ref/doc/uid/TP40014929-CH8-SW2 7 | """ 8 | import base64 9 | import binascii 10 | from datetime import timedelta, datetime 11 | from functools import partial 12 | from itertools import ifilter 13 | import hashlib 14 | import logging 15 | 16 | from asn1crypto import cms, parser 17 | from ecdsa import VerifyingKey, BadSignatureError, curves, util 18 | from pytz import utc 19 | from OpenSSL import crypto 20 | 21 | import payment 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def retrieve_signature_signing_time(signature): 28 | """ Return the 'signingTime' CMS attribute from the detached PKCS signature. 29 | This parsing depends on the structure of 'ContentInfo' objects defined in 30 | RFC-5652 (specifically the inner OID 1.2.840.113549.1.9.5): 31 | https://tools.ietf.org/html/rfc5652#section-11.3 32 | 33 | This function is deprecated. Use of `verify_signature` with an optional threshold 34 | is preferred. 35 | 36 | :param signature: Base64 encoded signature data (of a 'ContentInfo' object). 37 | :type: str 38 | :return: A datetime object representing the inner 'signingTime' object. 39 | :rtype: datetime 40 | :raises: AttributeError if no 'signing_time' object can be found. 41 | """ 42 | data = base64.b64decode(signature) 43 | content_info = cms.ContentInfo.load(data) 44 | signer_data = content_info['content'] 45 | signer_infos = signer_data['signer_infos'] 46 | signer_info = signer_infos[0] # We expect only one item in the list. 47 | signed_attrs = signer_info['signed_attrs'] 48 | for signed_attr in signed_attrs: 49 | if 'signing_time' == signed_attr['type'].native: 50 | value = signed_attr['values'] 51 | return value.native[0] # datetime object, only item in the list. 52 | raise AttributeError('No signing_time attribute found in signature.') 53 | 54 | 55 | def signing_time_is_valid(signature, current_time, threshold): 56 | """ Given a detached top-level CMS signature, validate the 'signingTime' 57 | attribute against the current time and a time-delta threshold. 58 | If the difference between the current time and the 'signingTime' exceeds 59 | the threshold, the token should be considered invalid. 60 | 61 | This function is deprecated. Use of `verify_signature` with an optional threshold 62 | is preferred. 63 | 64 | :param signature: Base64 encoded detached CMS signature data. 65 | :type: str 66 | :param current_time: Current system time to compare the token against. 67 | :type: offset-aware datetime 68 | :param threshold: Amount of time to consider the token valid. 69 | :type: timedelta 70 | :return: False if the signing time exceeds the threshold, otherwise True 71 | :rtype: bool 72 | :raises: AttributeError if no 'signingTime' attribute can be found, 73 | indicating an invalid token. May also raise if signature data is in an 74 | unexpected format, inconsistent with the CMS 'ContentInfo' object. 75 | """ 76 | signing_time = retrieve_signature_signing_time(signature) 77 | return valid_signing_time(signing_time, current_time, threshold) 78 | 79 | 80 | def valid_signing_time(signing_time, current_time, threshold): 81 | """ Validate that the signing time occurred within the current time minus 82 | the threshold and the current time. 83 | 84 | Args: 85 | signing_time (timezone-aware datetime): Signing time to 86 | validate. 87 | current_time (timezone-aware datetime): Current system time 88 | to compare the token against. 89 | threshold (datetime.timedelta): Amount of time to consider the token 90 | valid. 91 | Returns: 92 | boolean: indicates if the signing time falls within the defined range 93 | or not. 94 | """ 95 | is_valid = timedelta(0) <= (current_time - signing_time) <= threshold 96 | logger.debug(( 97 | "Signing time is {is_valid}. " 98 | "Signing time: {signing_time:%Y-%m-%d %H:%M:%S %Z}, " 99 | "Current time: {current_time:%Y-%m-%d %H:%M:%S %Z}, " 100 | "Threshold: {threshold}.").format( 101 | is_valid='valid' if is_valid else 'invalid', 102 | signing_time=signing_time, 103 | current_time=current_time, 104 | threshold=threshold) 105 | ) 106 | return is_valid 107 | 108 | 109 | def valid_chain_of_trust(root_cert_der, intermediate_cert_der, leaf_cert_der): 110 | """Validate the chain of trust for the provided der encoded root, intermediate, 111 | and leaf certificates. 112 | 113 | From: #ApplePaySpec 114 | Part C: Ensure that there is a valid X.509 chain of trust from the 115 | signature to the root CA. Specifically, ensure that the signature 116 | was created using the private key corresponding to the leaf certificate, 117 | that the leaf certificate is signed by the intermediate CA, and that the 118 | intermediate CA is signed by the Apple Root CA - G3. 119 | 120 | Args: 121 | root_cert_der (str): der-encoded root cert 122 | intermediate_cert_der (str): der-encoded intermediate cert 123 | leaf_cert_der (str): der-encoded leaf cert 124 | Returns: 125 | Boolean: If there is a valid chain of trust for the provided certificates 126 | """ 127 | root_cert = crypto.load_certificate(crypto.FILETYPE_ASN1, root_cert_der) 128 | 129 | # Only add certs we trust, starting with the implicitly trusted root cert. 130 | store = crypto.X509Store() 131 | store.add_cert(root_cert) 132 | 133 | # validate the intermediate cert against root 134 | intermediate_cert = crypto.load_certificate(crypto.FILETYPE_ASN1, intermediate_cert_der) 135 | store_ctx = crypto.X509StoreContext(store, intermediate_cert) 136 | # throws 'X509StoreContextError' when cert is invalid. 137 | try: 138 | store_ctx.verify_certificate() 139 | except crypto.X509StoreContextError: 140 | logger.warning("Intermediate cert not signed by the Apple Root CA - G3.") 141 | return False 142 | else: 143 | store.add_cert(intermediate_cert) 144 | 145 | # validate the leaf cert against intermediate. 146 | leaf_cert = crypto.load_certificate(crypto.FILETYPE_ASN1, leaf_cert_der) 147 | store_ctx = crypto.X509StoreContext(store, leaf_cert) 148 | # throws 'X509StoreContextError' when cert is invalid. 149 | try: 150 | store_ctx.verify_certificate() 151 | except crypto.X509StoreContextError: 152 | logger.warning("Leaf cert not signed by the intermediate cert.") 153 | return False 154 | else: 155 | store.add_cert(leaf_cert) 156 | 157 | return True 158 | 159 | 160 | def get_leaf_and_intermediate_certs(candidate_certificates): 161 | """Iterate over the provided candidate_certificates 162 | to find the leaf and intermediate certificates based 163 | on their OIDs. 164 | 165 | Args: 166 | candidate_certificate (asn1crypto.cms.CertificateSet): Unordered iterable of 167 | candidate certificates 168 | Returns: 169 | tuple: The leaf and intermediate cert. Each will default to None 170 | if not found. 171 | """ 172 | found_leaf_cert = None 173 | found_intermediate_cert = None 174 | for certificate in candidate_certificates: 175 | if found_leaf_cert and found_intermediate_cert: 176 | break 177 | 178 | candidate_cert = certificate.chosen 179 | 180 | leaf_cert_ext = get_first_from_iterable( 181 | filter_func=lambda ext: ext['extn_id'].native == payment.OID_LEAF_CERTIFICATE, 182 | iterable=candidate_cert['tbs_certificate']['extensions'] 183 | ) 184 | if leaf_cert_ext: 185 | found_leaf_cert = candidate_cert 186 | continue 187 | 188 | intermediate_cert_ext = get_first_from_iterable( 189 | filter_func=lambda ext: ext['extn_id'].native == payment.OID_INTERMEDIATE_CERTIFICATE, 190 | iterable=candidate_cert['tbs_certificate']['extensions'] 191 | ) 192 | if intermediate_cert_ext: 193 | found_intermediate_cert = candidate_cert 194 | continue 195 | 196 | return found_leaf_cert, found_intermediate_cert 197 | 198 | 199 | def get_payment_data(token): 200 | """Build a string of the concatenated payment 201 | data provided in the apple pay token, including 202 | the ephemeral public key, payload, transaction id 203 | and the optional application data. 204 | 205 | This assumes the provided token contains an ECDSA 206 | signature. RSA is not supported. 207 | 208 | Args: 209 | token (dict): the decoded apple pay token 210 | Returns: 211 | str: the concatenated payment data 212 | """ 213 | ephemeral_public_key = token['header']['ephemeralPublicKey'] 214 | payload = token['data'] 215 | transaction_id = token['header']['transactionId'] 216 | application_data = token['header'].get('applicationData') # optional 217 | 218 | concatenated_data = base64.b64decode(ephemeral_public_key) 219 | concatenated_data += base64.b64decode(payload) 220 | concatenated_data += binascii.unhexlify(transaction_id) 221 | if application_data: 222 | concatenated_data += binascii.unhexlify(application_data) 223 | 224 | return concatenated_data 225 | 226 | 227 | def get_ber_encoded_signed_attributes(signed_attrs): 228 | """Get the BER-encoded signed attributes. 229 | 230 | class_, method, and tag are needed to emit the BER 231 | encoded version of the header + content. 232 | These values are not exposed on SignerInfo 233 | so we get them from the parent class. 234 | 235 | Args: 236 | signed_attrs (asn1crypto.cms.CMSAttributes): The signed 237 | attributes from the signature 238 | Returns: 239 | str: The BER-encoded signed attributes as a string 240 | of bytes 241 | """ 242 | class_ = super(signed_attrs.__class__, signed_attrs).class_ 243 | method = super(signed_attrs.__class__, signed_attrs).method 244 | tag = super(signed_attrs.__class__, signed_attrs).tag 245 | 246 | return parser.emit(class_, method, tag, signed_attrs.contents) 247 | 248 | 249 | def remove_ec_point_prefix(point): 250 | """Remove the prefix from the uncompressed ec point. 251 | 252 | We expect the point to be in the uncompressed 253 | format described here: https://tools.ietf.org/html/rfc5480#section-2.2 254 | This means the first byte must be "\x04" otherwise consider 255 | the public key not usable. ecdsa.keys.VerifyingKey does not 256 | handle this first byte so its chopped off before turning the public 257 | key bytes. 258 | 259 | Args: 260 | point (str): The uncompressed byte string of an EC Point 261 | Returns: 262 | str: The EC point byte string minus the uncompressed 263 | byte prefix indicator 264 | """ 265 | if not point.startswith("\x04"): 266 | logger.warning("Expected uncompressed EC point.") 267 | return None 268 | 269 | return point[1:] 270 | 271 | 272 | def get_first_from_iterable(filter_func, iterable): 273 | """Get the first filtered item from an iterable. 274 | 275 | Args: 276 | filter_func (callable): the function to filter on 277 | iterable (iterable): the iterable to filter on 278 | Returns: 279 | object: the first filtered item from iterable or None 280 | if no items matching the filter are found 281 | """ 282 | filtered = ifilter(filter_func, iterable) 283 | return next(filtered, None) 284 | 285 | 286 | def get_hashfunc_by_name(name, data): 287 | """ 288 | Get a callable hashfunc by name. 289 | 290 | This function can be used directly or with functools.partial, for example: 291 | 292 | >>> hashfunc = functools.partial(get_hashfunc_by_name, 'sha256') 293 | >>> hashfunc('sir robin').digest() 294 | 295 | Args: 296 | name (str): The string name of the desired algorithm 297 | data (buffer): The buffer to hash 298 | Returns: 299 | callable: The callable hash function of the provided 300 | algorithm updated with the data to be hashed 301 | """ 302 | hashfunc = hashlib.new(name) 303 | hashfunc.update(data) 304 | return hashfunc 305 | 306 | 307 | def validate_message_digest(signed_attrs, hashed_payment_data): 308 | """Validate the message_digest matches the provided payment. 309 | 310 | Args: 311 | signed_attrs (asn1crypto.cms.CMSAttributes): The signed 312 | attributes from the signature 313 | payment_data (str): the hashed payment data to validate 314 | against 315 | Returns: 316 | boolean: True if the message digest matches the hashed 317 | payment data, otherwise False. 318 | 319 | """ 320 | message_digest_attr = get_first_from_iterable( 321 | filter_func=lambda signed_attr: signed_attr['type'].dotted == payment.OID_MESSAGE_DIGEST, 322 | iterable=signed_attrs 323 | ) 324 | 325 | if not message_digest_attr: 326 | logger.warning("No message digest found for the leaf cert.") 327 | return False 328 | 329 | message_digest = message_digest_attr['values'][0].native 330 | 331 | if hashed_payment_data != message_digest: 332 | logger.warning("Message digest does not match provided data.") 333 | return False 334 | 335 | return True 336 | 337 | 338 | def verify_signature(token, threshold=None): 339 | """Verify the signature within an apple pay token according to 340 | the #ApplePaySpec documentation. 341 | 342 | Args: 343 | token (dict): the PKPaymentToken object defined within the #ApplePaySpec 344 | Kwargs: 345 | threshold (timedelta, None): Amount of time to consider the token valid. 346 | No validation will be performed if a threshold is not provided 347 | Returns: 348 | boolean: Indicates if the signature is valid or not 349 | """ 350 | 351 | # We only bother to validate EC_v1. RSA is only used 352 | # for transactions from China and is not supported at this time. 353 | if token['version'] != 'EC_v1': 354 | logger.warning("Unsupported version {}".format(token['version'])) 355 | return False 356 | 357 | # Extract and decode the signature object into a CMS object. 358 | signature = token['signature'] 359 | signature_data = base64.b64decode(signature) 360 | content_info = cms.ContentInfo.load(signature_data) 361 | signed_data = content_info['content'] 362 | certificates = signed_data['certificates'] 363 | 364 | # There should be exactly 2 certificates present. 365 | if len(certificates) != 2: 366 | logger.warning("Expected 2 certificates, found {}".format(len(certificates))) 367 | return False 368 | 369 | leaf_cert, intermediate_cert = get_leaf_and_intermediate_certs(certificates) 370 | 371 | if not leaf_cert: 372 | logger.warning("Leaf Certificate OID not found") 373 | return False 374 | 375 | if not intermediate_cert: 376 | logger.warning("Intermediate certificate OID not found") 377 | return False 378 | 379 | root_der = open(payment.ROOT_CA_FILE, 'r').read() 380 | # Pass the der-encoded representation of the certs. 381 | if not valid_chain_of_trust(root_der, intermediate_cert.dump(), leaf_cert.dump()): 382 | return False 383 | 384 | # Build the signer information from the signer that matches the leaf cert. 385 | signer_info = get_first_from_iterable( 386 | filter_func=lambda signer: signer['sid'].chosen['serial_number'].native == leaf_cert.serial_number, 387 | iterable=signed_data['signer_infos'] 388 | ) 389 | if not signer_info: 390 | logger.warning("No signature found for the leaf cert.") 391 | return False 392 | 393 | # Use the signed attrs to verify the data was signed within the threshold 394 | # provided and is from who it says its from. 395 | signed_attrs = signer_info['signed_attrs'] 396 | 397 | # Only check the signing time if a threshold was provided. 398 | if threshold: 399 | signing_time_attr = get_first_from_iterable( 400 | filter_func=lambda signed_attr: signed_attr['type'].dotted == payment.OID_SIGNING_TIME, 401 | iterable=signed_attrs 402 | ) 403 | 404 | if not valid_signing_time(signing_time_attr['values'][0].native, datetime.now(utc), threshold): 405 | logger.warning("Signing time outside of threshold.") 406 | return False 407 | 408 | payment_data = get_payment_data(token) 409 | # Build the hashfunc from the leaf_cert's defined algorithm. 410 | hashfunc = partial(get_hashfunc_by_name, leaf_cert.hash_algo) 411 | hashed_payemnt_data = hashfunc(payment_data).digest() 412 | if not validate_message_digest(signed_attrs, hashed_payemnt_data): 413 | return False 414 | 415 | signed_attrs_ber = get_ber_encoded_signed_attributes(signed_attrs) 416 | public_key_point = remove_ec_point_prefix(leaf_cert.public_key['public_key'].native) 417 | if not public_key_point: 418 | return False 419 | 420 | sigdecode = util.sigdecode_der # The signature is der-encoded 421 | sig_octets = signer_info['signature'].native # The actual signature to verify 422 | vk = VerifyingKey.from_string(public_key_point, curve=curves.NIST256p, hashfunc=hashfunc) 423 | # Verify that the signature matches the signed data. 424 | try: 425 | vk.verify(sig_octets, signed_attrs_ber, hashfunc=hashfunc, sigdecode=sigdecode) 426 | except BadSignatureError: 427 | logger.warning("Invalid signature.") 428 | return False 429 | 430 | return True 431 | -------------------------------------------------------------------------------- /ca/AppleAAICAG3.cer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powermobileweb/applepay/7d947af24101902d4361f26130274e70c114f817/ca/AppleAAICAG3.cer -------------------------------------------------------------------------------- /ca/AppleRootCA-G3.cer: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/powermobileweb/applepay/7d947af24101902d4361f26130274e70c114f817/ca/AppleRootCA-G3.cer -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test = pytest 3 | 4 | [tool:pytest] 5 | addopts = --verbose 6 | python_files = tests/*.py 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="applepay", 5 | version="0.4.0", 6 | author="Taras Halturin", 7 | author_email="halturin@gmail.com", 8 | description=("A Python library for decrypting Apple Pay payment tokens."), 9 | license="BSD", 10 | keywords="applepay payment tokens", 11 | url="https://github.com/halturin/applepay", 12 | packages=['applepay', 'tests'], 13 | install_requires=['asn1crypto>=0.21.0', 'cryptography>=1.7.2', 'ecdsa>=0.13', 'pyOpenSSL>=16.2.0'], 14 | setup_requires=['pytest-runner>=2.0,<3dev'], 15 | tests_require=[ 16 | 'pytest>=3.0.6', 'pytz==2016.10', 'pytest-capturelog>=0.7', 17 | 'freezegun>=0.3.8' 18 | ], 19 | classifiers=[ 20 | "Development Status :: 4 - Beta", 21 | "Topic :: Utilities", 22 | "License :: OSI Approved :: BSD License", 23 | ], 24 | ) 25 | -------------------------------------------------------------------------------- /tests/applepay_test.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import copy 4 | from datetime import datetime, timedelta 5 | from functools import partial 6 | import hashlib 7 | import logging 8 | 9 | 10 | from asn1crypto import cms 11 | from freezegun import freeze_time 12 | import pytest 13 | from pytz import utc 14 | 15 | from applepay import payment, utils as applepay_utils 16 | 17 | import utils as test_utils 18 | 19 | 20 | @pytest.fixture(scope='session') 21 | def token_fixture(): 22 | return test_utils.load_json_fixture('tests/fixtures/token.json') 23 | 24 | 25 | @pytest.fixture(scope='session') 26 | def root_der_fixture(): 27 | with open(payment.ROOT_CA_FILE, 'r') as root_ca: 28 | return root_ca.read() 29 | 30 | 31 | @pytest.fixture(scope='session') 32 | def signed_data_fixture(token_fixture): 33 | """Returns the certificates from the apple pay token.""" 34 | signature = token_fixture['signature'] 35 | signature_data = base64.b64decode(signature) 36 | content_info = cms.ContentInfo.load(signature_data) 37 | signed_data = content_info['content'] 38 | 39 | return signed_data 40 | 41 | 42 | @pytest.fixture(scope='session') 43 | def certificates_fixture(candidate_certificates_fixture): 44 | """Returns a tuple of valid der-encoded root, intermeidate 45 | and leaf certificates""" 46 | return applepay_utils.get_leaf_and_intermediate_certs(candidate_certificates_fixture) 47 | 48 | 49 | @pytest.fixture(scope='session') 50 | def signed_attributes_fixture(signed_data_fixture, certificates_fixture): 51 | leaf_cert, _ = certificates_fixture 52 | signer_info = applepay_utils.get_first_from_iterable( 53 | filter_func=lambda signer: signer['sid'].chosen['serial_number'].native == leaf_cert.serial_number, 54 | iterable=signed_data_fixture['signer_infos'] 55 | ) 56 | return signer_info['signed_attrs'] 57 | 58 | 59 | @pytest.fixture(scope='session') 60 | def candidate_certificates_fixture(signed_data_fixture): 61 | """Returns the certificates from the apple pay token.""" 62 | return signed_data_fixture['certificates'] 63 | 64 | 65 | @pytest.fixture(scope='session') 66 | def certificates_der_fixture(root_der_fixture, certificates_fixture): 67 | """Returns a tuple of valid der-encoded root, intermeidate 68 | and leaf certificates""" 69 | leaf_cert, intermediate_cert = certificates_fixture 70 | 71 | return (root_der_fixture, intermediate_cert.dump(), leaf_cert.dump()) 72 | 73 | 74 | def test_retrieve_signature_signing_time(token_fixture): 75 | # Given a detached CMS signature in the token, 76 | signature = token_fixture['signature'] 77 | 78 | # when we attempt to retrieve the signing time from the signature, 79 | signing_time = applepay_utils.retrieve_signature_signing_time(signature) 80 | 81 | # then the signing time matches the datetime we expect. 82 | expected_time = datetime(2014, 10, 27, 19, 51, 43, tzinfo=utc) 83 | assert signing_time == expected_time 84 | 85 | 86 | def test_signing_time_is_valid(token_fixture): 87 | # Given a detached CMS signature in the token, 88 | signature = token_fixture['signature'] 89 | 90 | # and a current time exactly one hour past the signing time, 91 | current_time = datetime(2014, 10, 27, 20, 51, 43, tzinfo=utc) 92 | 93 | # and a time-delta threshold of one hour, 94 | threshold = timedelta(hours=1) 95 | 96 | # when we attempt to validate the signing time against the threshold, 97 | valid = applepay_utils.signing_time_is_valid(signature, current_time, threshold) 98 | 99 | # then the token should be considered valid. 100 | assert valid is True 101 | 102 | 103 | def test_expired_signing_time_is_invalid(token_fixture): 104 | # Given a detached CMS signature in the token, 105 | signature = token_fixture['signature'] 106 | 107 | # and a current time well past the signing time, 108 | current_time = datetime(2017, 2, 16, 17, 9, 55, tzinfo=utc) 109 | 110 | # and a time-delta threshold of only one day, 111 | threshold = timedelta(days=1) 112 | 113 | # when we attempt to validate the signing time against the threshold, 114 | valid = applepay_utils.signing_time_is_valid(signature, current_time, threshold) 115 | 116 | # then the token should be considered invalid. 117 | assert valid is False 118 | 119 | 120 | def test_future_signing_time_is_invalid(token_fixture): 121 | # Given a detached CMS signature in the token, 122 | signature = token_fixture['signature'] 123 | 124 | # and a current time which is well before the signing time, 125 | current_time = datetime(2010, 1, 2, 5, 22, 13, tzinfo=utc) 126 | 127 | # and a time-delta threshold of five weeks, 128 | threshold = timedelta(weeks=5) 129 | 130 | # when we attempt to validate the signing time against the threshold, 131 | valid = applepay_utils.signing_time_is_valid(signature, current_time, threshold) 132 | 133 | # then the token should be considered invalid. 134 | assert valid is False 135 | 136 | 137 | def test_signing_time_equals_current_time_is_valid(token_fixture): 138 | # Given a detached CMS signature in the token, 139 | signature = token_fixture['signature'] 140 | 141 | # and a current time that exactly matches the signing time, 142 | current_time = datetime(2014, 10, 27, 19, 51, 43, tzinfo=utc) 143 | 144 | # and a time-delta of zero, 145 | threshold = timedelta(0) 146 | 147 | # when we attempt to validate the signing time against the threshold, 148 | valid = applepay_utils.signing_time_is_valid(signature, current_time, threshold) 149 | 150 | # then the token should be considered valid. 151 | assert valid is True 152 | 153 | 154 | def test_valid_signing_time(): 155 | # Given an timezone-aware signing time 156 | signing_time = datetime(2014, 10, 27, 20, 51, 43, tzinfo=utc) 157 | 158 | # and a current time exactly one hour past the signing time, 159 | current_time = signing_time + timedelta(hours=1) 160 | 161 | # and a time-delta threshold of one hour, 162 | threshold = timedelta(hours=1) 163 | 164 | # when we attempt to validate the signing time against the threshold, 165 | valid = applepay_utils.valid_signing_time(signing_time, current_time, threshold) 166 | 167 | # then the token shosuld be considered valid. 168 | assert valid is True 169 | 170 | 171 | def test_expired_signing_time(): 172 | # Given an timezone-aware signing time 173 | signing_time = datetime(2014, 10, 27, 20, 51, 43, tzinfo=utc) 174 | 175 | # and a current time well past the signing time, 176 | current_time = signing_time + timedelta(days=100) 177 | 178 | # and a time-delta threshold of only one day, 179 | threshold = timedelta(days=1) 180 | 181 | # when we attempt to validate the signing time against the threshold, 182 | valid = applepay_utils.valid_signing_time(signing_time, current_time, threshold) 183 | 184 | # then the token should be considered invalid. 185 | assert valid is False 186 | 187 | 188 | def test_future_signing_time(): 189 | # Given an timezone-aware signing time 190 | signing_time = datetime(2014, 10, 27, 20, 51, 43, tzinfo=utc) 191 | 192 | # and a current time which is well before the signing time, 193 | current_time = signing_time - timedelta(weeks=10) 194 | 195 | # and a time-delta threshold of five weeks, 196 | threshold = timedelta(weeks=5) 197 | 198 | # when we attempt to validate the signing time against the threshold, 199 | valid = applepay_utils.valid_signing_time(signing_time, current_time, threshold) 200 | 201 | # then the token should be considered invalid. 202 | assert valid is False 203 | 204 | 205 | def test_signing_time_equals_current_time(): 206 | # Given an timezone-aware signing time 207 | signing_time = datetime(2014, 10, 27, 20, 51, 43, tzinfo=utc) 208 | 209 | # and a current time that exactly matches the signing time, 210 | current_time = signing_time 211 | 212 | # and a time-delta of zero, 213 | threshold = timedelta(0) 214 | 215 | # when we attempt to validate the signing time against the threshold, 216 | valid = applepay_utils.valid_signing_time(signing_time, current_time, threshold) 217 | 218 | # then the token should be considered valid. 219 | assert valid is True 220 | 221 | 222 | def test_valid_signing_time_data_is_logged(caplog): 223 | # Given: a valid signing time 224 | signing_time = datetime(2014, 10, 27, 20, 51, 43, tzinfo=utc) 225 | current_time = signing_time + timedelta(minutes=5) 226 | threshold = timedelta(hours=1) 227 | 228 | # When we attempt to validate the signing time against the threshold, 229 | with caplog.atLevel(logging.DEBUG): 230 | valid = applepay_utils.valid_signing_time(signing_time, current_time, threshold) 231 | 232 | # Then the signign time is valid 233 | assert valid is True 234 | 235 | # Then a new debug log is captured 236 | # filter on DEBUG log records only 237 | records = filter(lambda log_record: log_record.levelno == logging.DEBUG, caplog.records()) 238 | assert len(records) == 1 239 | assert records[0].name == 'applepay.utils' 240 | assert records[0].message == 'Signing time is valid. Signing time: 2014-10-27 20:51:43 UTC, Current time: 2014-10-27 20:56:43 UTC, Threshold: 1:00:00.' 241 | 242 | 243 | def test_invalid_signing_time_data_is_logged(caplog): 244 | # Given: an invalid signing time 245 | signing_time = datetime(2014, 10, 27, 20, 51, 43, tzinfo=utc) 246 | current_time = signing_time + timedelta(hours=5) 247 | threshold = timedelta(hours=1) 248 | 249 | # When we attempt to validate the signing time against the threshold, 250 | with caplog.atLevel(logging.DEBUG): 251 | valid = applepay_utils.valid_signing_time(signing_time, current_time, threshold) 252 | 253 | # Then the signing time is not valid 254 | assert valid is False 255 | 256 | # Then a new debug log is captured 257 | # filter on DEBUG log records only 258 | records = filter(lambda log_record: log_record.levelno == logging.DEBUG, caplog.records()) 259 | assert len(records) == 1 260 | assert records[0].name == 'applepay.utils' 261 | assert records[0].message == 'Signing time is invalid. Signing time: 2014-10-27 20:51:43 UTC, Current time: 2014-10-28 01:51:43 UTC, Threshold: 1:00:00.' 262 | 263 | 264 | def test_verify_signature(token_fixture): 265 | """Test that a token known to be valid has a valid 266 | signature""" 267 | assert applepay_utils.verify_signature(token_fixture) is True 268 | 269 | 270 | def test_verify_signature_with_threshold(token_fixture): 271 | """Test that a token known to be valid has a valid 272 | signature and the signing time is within the provided threshold""" 273 | with freeze_time("2017-04-06 23:20:50'", tz_offset=0): 274 | valid_signature = applepay_utils.verify_signature( 275 | token_fixture, threshold=timedelta(days=1000) 276 | ) 277 | 278 | assert valid_signature is True 279 | 280 | 281 | def test_valid_chain_of_trust(certificates_der_fixture): 282 | # Given a valid root, intermediate, and leaf cert 283 | root_der, intermediate_der, leaf_der = certificates_der_fixture 284 | 285 | # When: we test for a valid chain of trust 286 | is_valid = applepay_utils.valid_chain_of_trust(root_der, intermediate_der, leaf_der) 287 | 288 | # Then: the chain is valid 289 | assert is_valid 290 | 291 | 292 | def test_invalid_leaf_cert(certificates_der_fixture): 293 | # Given a valid root, intermediate, and leaf cert 294 | root_der, intermediate_der, leaf_der = certificates_der_fixture 295 | 296 | # Given: the leaf cert is not valid 297 | leaf_der = leaf_der.replace("\x86H", "\x86G") # some arbitrary change 298 | 299 | # When: we test for a valid chain of trust 300 | is_valid = applepay_utils.valid_chain_of_trust(root_der, intermediate_der, leaf_der) 301 | 302 | # Then: the chain is valid 303 | assert not is_valid 304 | 305 | 306 | def test_invalid_intermediate_cert(certificates_der_fixture): 307 | # Given a valid root, intermediate, and leaf cert 308 | root_der, intermediate_der, leaf_der = certificates_der_fixture 309 | 310 | # Given: the intermediate cert is not valid 311 | intermediate_der = intermediate_der.replace("\x86H", "\x86G") # some arbitrary change 312 | 313 | # When: we test for a valid chain of trust 314 | is_valid = applepay_utils.valid_chain_of_trust(root_der, intermediate_der, leaf_der) 315 | 316 | # Then: the chain is valid 317 | assert not is_valid 318 | 319 | 320 | def test_found_both_certificates(candidate_certificates_fixture): 321 | # Given two candidate certificates that match the leaf and intermediate OIDs 322 | candidate_certificates = candidate_certificates_fixture 323 | 324 | # When the leaf and intermediate certifcates are extracted from the list of candidates 325 | leaf_cert, intermediate_cert = applepay_utils.get_leaf_and_intermediate_certs(candidate_certificates) 326 | 327 | # Then a leaf and intermediate certificate are found 328 | assert leaf_cert 329 | assert intermediate_cert 330 | 331 | 332 | def test_missing_leaf_certificate(candidate_certificates_fixture): 333 | # Given only one candidate certificate that matches the intermediate OID 334 | candidate_certificates = candidate_certificates_fixture[-1:] 335 | 336 | # When the leaf and intermediate certifcates are extracted from the list of candidates 337 | leaf_cert, intermediate_cert = applepay_utils.get_leaf_and_intermediate_certs(candidate_certificates) 338 | 339 | # Then not leaf certifice is found 340 | assert not leaf_cert 341 | 342 | # Then the intermediate certificate is found 343 | assert intermediate_cert 344 | 345 | 346 | def test_no_matching_leaf_certificate(candidate_certificates_fixture): 347 | # Given two candidate certificates where the intermediate cert matches the OID 348 | # but the leaf does not. This is accomplished by removing the last extension which is 349 | # the leaf cert OID extension. 350 | candidate_certificates = copy.deepcopy(candidate_certificates_fixture) # copy the fixture since we are modifying it 351 | i = len(candidate_certificates[0].chosen['tbs_certificate']['extensions']) - 1 352 | del candidate_certificates[0].chosen['tbs_certificate']['extensions'][i] 353 | 354 | # When the leaf and intermediate certifcates are extracted from the list of candidates 355 | leaf_cert, intermediate_cert = applepay_utils.get_leaf_and_intermediate_certs(candidate_certificates) 356 | 357 | # Then no leaf certifice is found 358 | assert not leaf_cert 359 | 360 | # Then the intermediate certificate is found 361 | assert intermediate_cert 362 | 363 | 364 | def test_no_matching_intermediate_certificate(candidate_certificates_fixture): 365 | # Given two candidate certificates where the leaf cert matches the OID 366 | # but the intermediate does not. This is accomplished by removing the last extension which is 367 | # the intermediate cert OID extension. 368 | candidate_certificates = copy.deepcopy(candidate_certificates_fixture) # copy the fixture since we are modifying it 369 | i = len(candidate_certificates[1].chosen['tbs_certificate']['extensions']) - 1 370 | del candidate_certificates[1].chosen['tbs_certificate']['extensions'][i] 371 | 372 | # When the leaf and intermediate certifcates are extracted from the list of candidates 373 | leaf_cert, intermediate_cert = applepay_utils.get_leaf_and_intermediate_certs(candidate_certificates) 374 | 375 | # Then the leaf certifice is found 376 | assert leaf_cert 377 | 378 | # Then no intermediate certificate is found 379 | assert not intermediate_cert 380 | 381 | 382 | def test_missing_intermediate_certificate(candidate_certificates_fixture): 383 | # Given only one candidate certificate that matches the leaf OID 384 | candidate_certificates = candidate_certificates_fixture[:1] 385 | 386 | # When the leaf and intermediate certifcates are extracted from the list of candidates 387 | leaf_cert, intermediate_cert = applepay_utils.get_leaf_and_intermediate_certs(candidate_certificates) 388 | 389 | # Then a leaf certifice is found 390 | assert leaf_cert 391 | 392 | # Then no intermediate certificate is found 393 | assert not intermediate_cert 394 | 395 | 396 | def test_missing_both_certificates(candidate_certificates_fixture): 397 | # Given no certificates 398 | candidate_certificates = [] 399 | 400 | # When the leaf and intermediate certifcates are extracted from the list of candidates 401 | leaf_cert, intermediate_cert = applepay_utils.get_leaf_and_intermediate_certs(candidate_certificates) 402 | 403 | # Then not leaf certifice is found 404 | assert not leaf_cert 405 | 406 | # Then no intermediate certificate is found 407 | assert not intermediate_cert 408 | 409 | 410 | def test_get_payment_data(): 411 | # Given an apple pay token with minimal data 412 | token = { 413 | "version": "does not matter", 414 | "data": base64.b64encode('sir robin;'), 415 | "signature": "does not matter", 416 | "header": { 417 | "transactionId": binascii.hexlify('sir lancelot;'), 418 | "ephemeralPublicKey": base64.b64encode('king arthur;'), 419 | "publicKeyHash": "does not matter" 420 | } 421 | } 422 | 423 | # When the payment data is accessed and decoded 424 | payment_data = applepay_utils.get_payment_data(token) 425 | 426 | # Then it is the expected value 427 | expected_payment_data = 'king arthur;sir robin;sir lancelot;' 428 | assert expected_payment_data == payment_data 429 | 430 | 431 | def test_get_payment_data_includes_application_data(): 432 | # Given an apple pay token including application data 433 | token = { 434 | "version": "does not matter", 435 | "data": base64.b64encode('sir robin;'), 436 | "signature": "does not matter", 437 | "header": { 438 | "transactionId": binascii.hexlify('sir lancelot;'), 439 | "ephemeralPublicKey": base64.b64encode('king arthur;'), 440 | "publicKeyHash": "does not matter", 441 | "applicationData": binascii.hexlify('sir galahad;') 442 | } 443 | } 444 | 445 | # When the payment data is accessed and decoded 446 | payment_data = applepay_utils.get_payment_data(token) 447 | 448 | # Then it is the expected value 449 | expected_payment_data = 'king arthur;sir robin;sir lancelot;sir galahad;' 450 | assert expected_payment_data == payment_data 451 | 452 | 453 | def test_get_ber_encoded_signed_attributes(signed_attributes_fixture): 454 | # Given: some signed attributes 455 | # from the signed_attributes_fixture 456 | 457 | # When the ber-encoded attributes are retrieved 458 | signed_attrs_ber = applepay_utils.get_ber_encoded_signed_attributes(signed_attributes_fixture) 459 | 460 | # Then the result is the expected value 461 | expected_ber_attributes = '1i0\x18\x06\t*\x86H\x86\xf7\r\x01\t\x031\x0b\x06\t*\x86H\x86\xf7\r\x01\x07\x010\x1c\x06\t*\x86H\x86\xf7\r\x01\t\x051\x0f\x17\r141027195143Z0/\x06\t*\x86H\x86\xf7\r\x01\t\x041"\x04 {M_{\x87\xb5\xfb\n\x11\x9d\xa5w\xa3\xc6\xd9/\xbb\xe6L\xb1\x03\xb2v_M\n\xbe\x0f\xb1\x98\x8er' 462 | assert expected_ber_attributes == signed_attrs_ber 463 | 464 | 465 | def test_remove_ec_point_prefix(certificates_fixture): 466 | # Given a string that stars with the EC point uncompressed prefix 467 | point = '\x04\xc2\x15w\xed' 468 | 469 | # When we remove the ec point prefix from the point string 470 | public_key_point = applepay_utils.remove_ec_point_prefix(point) 471 | 472 | # Then the returned bytes are the remaining bytes minus the prefix 473 | assert len(public_key_point) == 4 474 | assert not public_key_point.startswith("\x04") 475 | 476 | 477 | def test_remove_ec_point_prefix_finds_unexpected_format(certificates_fixture): 478 | # Given a point that is not in the uncompressed format 479 | point = '\xc2\x15w\xed\xeb\xd6\xc7\xb2!\x8fh\xddp\x90\xa1!\x8d\xc7\xb0\xbdo,(=\x84`\x95\xd9J\xf4\xa5A\x1b\x83B\x0e\xd8\x11\xf3@~\x833\x1f\x1cT\xc3\xf7\xeb2 \xd6\xba\xd5\xd4\xef\xf4\x92\x89\x89>|\x0f\x13' 480 | 481 | # When we remove the ec point prefix from the point string 482 | public_key_bytes = applepay_utils.remove_ec_point_prefix(point) 483 | 484 | # Then no bytes are returned due to the point not being uncompressed 485 | assert not public_key_bytes 486 | 487 | 488 | def test_get_first_from_iterable_finds_single_match(): 489 | # Given an iterable 490 | i = range(5) 491 | 492 | # Given a filter function which will find 1 match 493 | def f(x): 494 | return x == 3 495 | 496 | # When we get the first match from the iterable 497 | first_match = applepay_utils.get_first_from_iterable(f, i) 498 | 499 | # Then the returned match is the expected value 500 | assert first_match == 3 501 | 502 | 503 | def test_get_first_from_iterable_finds_first_of_many_matches(): 504 | # Given an iterable 505 | i = range(5) 506 | 507 | # Given a filter function which will find more than 1 match 508 | def f(x): 509 | return x in range(1, 5) 510 | 511 | # When we get the first match from the iterable 512 | first_match = applepay_utils.get_first_from_iterable(f, i) 513 | 514 | # Then the returned match is the expected value 515 | assert first_match == 1 516 | 517 | 518 | def test_get_first_from_iterable_does_not_match(): 519 | # Given an iterable 520 | i = range(5) 521 | 522 | # Given a filter function which will not find any matches 523 | def f(x): 524 | return False 525 | 526 | # When we get the first match get_first_from_iterableom the iterable 527 | first_match = applepay_utils.get_first_from_iterable(f, i) 528 | 529 | # Then the returned match is None 530 | assert first_match is None 531 | 532 | 533 | def test_get_hashfunc_by_name(): 534 | # Given a hashing algorithm 535 | name = 'sha256' 536 | 537 | # Given some data to hash 538 | data = 'sir robin' 539 | 540 | # When a hashfunc is created by that name 541 | hashfunc = applepay_utils.get_hashfunc_by_name(name, data) 542 | 543 | # The digest of the hashfunc is the same as the built-in algorithm 544 | assert hashlib.sha256(data).digest() == hashfunc.digest() 545 | 546 | 547 | def test_unknown_hash_algoritm_not_support(): 548 | # Given an unknown hashing algorithm 549 | name = 'fake' 550 | 551 | # When a hashfunc is created 552 | with pytest.raises(ValueError): 553 | # Then a ValueError is raised 554 | applepay_utils.get_hashfunc_by_name(name, '') 555 | 556 | 557 | def test_message_digest_valid(signed_attributes_fixture, token_fixture): 558 | # Given signed attributes 559 | signed_attrs = signed_attributes_fixture 560 | 561 | # Given a payment data from the same token 562 | # hashed via the sha256 hashfunc 563 | hashed_payment_data = hashlib.sha256(applepay_utils.get_payment_data(token_fixture)).digest() 564 | 565 | # When the message digest is validated 566 | is_valid = applepay_utils.validate_message_digest(signed_attrs, hashed_payment_data) 567 | 568 | # Then the message digest is valid 569 | assert is_valid is True 570 | 571 | 572 | def test_missing_message_digest(token_fixture): 573 | # Given some signed attrs that are missing the message digest 574 | signed_attrs = [] 575 | 576 | # Given a payment data from the same token 577 | # hashed via the sha256 hashfunc 578 | hashed_payment_data = hashlib.sha256(applepay_utils.get_payment_data(token_fixture)).digest() 579 | 580 | # When the message digest is validated 581 | is_valid = applepay_utils.validate_message_digest(signed_attrs, hashed_payment_data) 582 | 583 | # Then the message digest is not valid 584 | assert is_valid is False 585 | 586 | 587 | def test_mismatched_payment_data(signed_attributes_fixture): 588 | # Given some signed attrs 589 | signed_attrs = signed_attributes_fixture 590 | 591 | # Given a payment data object that will not match 592 | hashed_payment_data = hashlib.sha256('sir robin').digest() 593 | 594 | # When the message digest is validated 595 | is_valid = applepay_utils.validate_message_digest(signed_attrs, hashed_payment_data) 596 | 597 | # Then the message digest is not valid 598 | assert is_valid is False 599 | -------------------------------------------------------------------------------- /tests/fixtures/certificate.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEcDCCBBagAwIBAgIIUyrEM4IzBHQwCgYIKoZIzj0EAwIwgYAxNDAyBgNVBAMM 3 | K0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zIENBIC0gRzIxJjAk 4 | BgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApB 5 | cHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0xNDEwMjYxMjEwMTBaFw0xNjExMjQx 6 | MjEwMTBaMIGhMS4wLAYKCZImiZPyLGQBAQwebWVyY2hhbnQuY29tLnNlYXRnZWVr 7 | LlNlYXRHZWVrMTQwMgYDVQQDDCtNZXJjaGFudCBJRDogbWVyY2hhbnQuY29tLnNl 8 | YXRnZWVrLlNlYXRHZWVrMRMwEQYDVQQLDAo5QjNRWTlXQlo1MRcwFQYDVQQKDA5T 9 | ZWF0R2VlaywgSW5jLjELMAkGA1UEBhMCVVMwWTATBgcqhkjOPQIBBggqhkjOPQMB 10 | BwNCAAQPjiA1kTEodST2wy5d5kQFrM0D5qBX9Ukry8W6D+vC7OqbMoTm/upRM1GR 11 | HeA2LaVTrwAnpGhoO0ETqYF2Nu4Vo4ICVTCCAlEwRwYIKwYBBQUHAQEEOzA5MDcG 12 | CCsGAQUFBzABhitodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDA0LWFwcGxld3dk 13 | cmNhMjAxMB0GA1UdDgQWBBQWGfKgPgVBX8JOv84q1c04HShMmzAMBgNVHRMBAf8E 14 | AjAAMB8GA1UdIwQYMBaAFIS2hMw6hmJyFlmU6BqjvUjfOt8LMIIBHQYDVR0gBIIB 15 | FDCCARAwggEMBgkqhkiG92NkBQEwgf4wgcMGCCsGAQUFBwICMIG2DIGzUmVsaWFu 16 | Y2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBhbnkgcGFydHkgYXNzdW1lcyBhY2Nl 17 | cHRhbmNlIG9mIHRoZSB0aGVuIGFwcGxpY2FibGUgc3RhbmRhcmQgdGVybXMgYW5k 18 | IGNvbmRpdGlvbnMgb2YgdXNlLCBjZXJ0aWZpY2F0ZSBwb2xpY3kgYW5kIGNlcnRp 19 | ZmljYXRpb24gcHJhY3RpY2Ugc3RhdGVtZW50cy4wNgYIKwYBBQUHAgEWKmh0dHA6 20 | Ly93d3cuYXBwbGUuY29tL2NlcnRpZmljYXRlYXV0aG9yaXR5LzA2BgNVHR8ELzAt 21 | MCugKaAnhiVodHRwOi8vY3JsLmFwcGxlLmNvbS9hcHBsZXd3ZHJjYTIuY3JsMA4G 22 | A1UdDwEB/wQEAwIDKDBPBgkqhkiG92NkBiAEQgxARjkzOEY0NjU4Q0EyQzFDOUMz 23 | OEI4REZDQjVEQkIyQTIyNDU2MDdEREUyRjExNDYyMEU4NDY4RUY1MkQyMDhDQTAK 24 | BggqhkjOPQQDAgNIADBFAiB+Q4zzpMj2DJTCIhDFBcmwK1zQAC70fY2IsYd8+Nxu 25 | uwIhAKj9RrTOyiaQnoT5Mqi3UHopb6xTugl3LUDBloraBHyP 26 | -----END CERTIFICATE----- 27 | -------------------------------------------------------------------------------- /tests/fixtures/private_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIDqrpF0KEFW4Ncb76vyBi3StFLiT222sFC0wC3LsP1M9oAoGCCqGSM49 3 | AwEHoUQDQgAED44gNZExKHUk9sMuXeZEBazNA+agV/VJK8vFug/rwuzqmzKE5v7q 4 | UTNRkR3gNi2lU68AJ6RoaDtBE6mBdjbuFQ== 5 | -----END EC PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /tests/fixtures/token.json: -------------------------------------------------------------------------------- 1 | { 2 | "version":"EC_v1", 3 | "data":"4OZho15e9Yp5K0EtKergKzeRpPAjnKHwmSNnagxhjwhKQ5d29sfTXjdbh1CtTJ4DYjsD6kfulNUnYmBTsruphBz7RRVI1WI8P0LrmfTnImjcq1mi+BRN7EtR2y6MkDmAr78anff91hlc+x8eWD/NpO/oZ1ey5qV5RBy/Jp5zh6ndVUVq8MHHhvQv4pLy5Tfi57Yo4RUhAsyXyTh4x/p1360BZmoWomK15NcJfUmoUCuwEYoi7xUkRwNr1z4MKnzMfneSRpUgdc0wADMeB6u1jcuwqQnnh2cusiagOTCfD6jO6tmouvu6KO54uU7bAbKz6cocIOEAOc6keyFXG5dfw8i3hJg6G2vIefHCwcKu1zFCHr4P7jLnYFDEhvxLm1KskDcuZeQHAkBMmLRSgj9NIcpBa94VN/JTga8W75IWAA==", 4 | "signature":"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", 5 | "header":{ 6 | "transactionId":"2686f5297f123ec7fd9d31074d43d201953ca75f098890375f13aed2737d92f2", 7 | "ephemeralPublicKey":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMwliotf2ICjiMwREdqyHSilqZzuV2fZey86nBIDlTY8sNMJv9CPpL5/DKg4bIEMe6qaj67mz4LWdr7Er0Ld5qA==", 8 | "publicKeyHash":"LbsUwAT6w1JV9tFXocU813TCHks+LSuFF0R/eBkrWnQ=" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | 4 | def load_json_fixture(path): 5 | """ Return the Python representation of the JSON fixture stored in path. 6 | :param path: Local path to JSON fixture file. 7 | :type: str 8 | :return: Python representation of JSON content. 9 | :rtype: object 10 | """ 11 | with open(path, 'r') as f: 12 | return json.load(f) 13 | --------------------------------------------------------------------------------