Skip to content

Instantly share code, notes, and snippets.

@lxcid
Last active November 17, 2021 12:38
Show Gist options
  • Star 13 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lxcid/4441003 to your computer and use it in GitHub Desktop.
Save lxcid/4441003 to your computer and use it in GitHub Desktop.
# Copyright (c) 2013 Stan Chang Khin Boon (http://lxcid.com/)
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# References
# - In-App Purchase Receipt Validation on iOS (http://developer.apple.com/library/ios/#releasenotes/StoreKit/IAP_ReceiptValidation/_index.html)
# - CargoBay (https://github.com/mattt/CargoBay)
require 'test/unit'
require 'base64'
require 'openssl'
require 'osx/plist'
class ReceiptDecodingTest < Test::Unit::TestCase
def test_receipt_decoding
the_root_certificate_pem = <<-CERTIFICATE
-----BEGIN CERTIFICATE-----
MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET
MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv
biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0
MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw
bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx
FjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw
ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg+
+FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1
XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9w
tj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IW
q6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKM
aLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8E
BAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3
R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAE
ggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93
d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNl
IG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0
YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBj
b25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZp
Y2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBc
NplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQP
y3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7
R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4Fg
xhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oP
IQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX
UKqK1drk/NAJBzewdXUh
-----END CERTIFICATE-----
CERTIFICATE
the_root_certificate = OpenSSL::X509::Certificate.new(the_root_certificate_pem)
assert_not_nil the_root_certificate, 'Root certificate could not be instantiated'
the_intermediate_certificate_pem = <<-CERTIFICATE
-----BEGIN CERTIFICATE-----
MIIECzCCAvOgAwIBAgIBGjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzET
MBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlv
biBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDkwNTE5MTgz
MTMwWhcNMTYwNTE4MTgzMTMwWjB/MQswCQYDVQQGEwJVUzETMBEGA1UECgwKQXBw
bGUgSW5jLjEmMCQGA1UECwwdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkx
MzAxBgNVBAMMKkFwcGxlIGlUdW5lcyBTdG9yZSBDZXJ0aWZpY2F0aW9uIEF1dGhv
cml0eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKS8rzKUQz4LvDeH
zWOJ8szZviBNWrT+h2fSmt4aVJ2i89+H5EzLkxF4oDCPNEHB075mbUdsmLjsetXJ
3aXk6sZw9DXQkfez2AoRmas6Yjq9e/RWT9ufJJNRUHwg1WZNZvMYpBOWIhb9Maf0
OWab+2JpXEuflKhL6OxbZFoYeYoWdWNCpEnZjDPerXvWOQT04p0KaYzrSxIoSzRI
B5sOWfkfYrADnza4TqPTdVnU8zoFysUzO/jABgkIk9vnTb8R81IspRY1FfNBAs0C
0fz1+MWEvWNqhta2mfaGrl/9A9Qoilpdr7xldNH3GsOSCPQcrWnoAkwOlRUHvL5q
b8GzraECAwEAAaOBrjCBqzAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB
/zAdBgNVHQ4EFgQUNh3o4p2C0gEYtTJrDtdDC5FYQzowHwYDVR0jBBgwFoAUK9Bp
R5R2Cf70a40uQKb3R01/CF4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL3d3dy5h
cHBsZS5jb20vYXBwbGVjYS9yb290LmNybDAQBgoqhkiG92NkBgICBAIFADANBgkq
hkiG9w0BAQUFAAOCAQEAdaaQ5pqn22VwpgmTbwjfLNvpKI1AG1deoOr07BNlG3FK
TdyASE/y5an7hWy3Hp3b9BhIEHkX6sM9h9i0eW0UUK3Svz1O/A3ixQOUdYBzTaWh
kf4c3hUXrIlxKm8PZwrTnDChaPvPcBfK2UD8+Bu/zrDErvRKLamZhwZCCYYiaoRA
OfS7rFYY95ocAYFcjG5B8l0ZLBccSUbZHH6TEhPIZ5nC6oPjoowOuDsq3xy/S4tv
Grjul2dK2Kuvi6TaXIceILjF87HEmKI3+J7GmmulrfZ4lg6CjwRGHLKl/ZowUSj9
UgQVA9U8rf72eODqNe9ltSF226Tvy3LvVGsBDcfdGg==
-----END CERTIFICATE-----
CERTIFICATE
the_intermediate_certificate = OpenSSL::X509::Certificate.new(the_intermediate_certificate_pem)
assert_not_nil the_intermediate_certificate, 'Intermediate certificate could not be instantiated'
# Base 64 encoded transaction receipt
the_transaction_receipt_encoded = "ewoJInNpZ25hdHVyZSIgPSAiQWtZdVBNRGc1bjl5NDBRL2pXT08vVU5KeUZBbzNjTytvUmpJWklLWXQ3L00wNUV5WHFKTkhKR1BRbm1kYTRaeTBCcUdzejFtMmZwU0pRYXRUMDNWL2IwVGZBcjQrcDhib2ZVUmpDTFk5TlgzNkxDZ1dEandTMVN4UmFvKzRlazcycTUzTWVHVlNrR295NUUyN2pTejVQMmZRZHM4UHZ3UGlkM0R4M081OTQvd0FBQURWekNDQTFNd2dnSTdvQU1DQVFJQ0NHVVVrVTNaV0FTMU1BMEdDU3FHU0liM0RRRUJCUVVBTUg4eEN6QUpCZ05WQkFZVEFsVlRNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURXpNREVHQTFVRUF3d3FRWEJ3YkdVZ2FWUjFibVZ6SUZOMGIzSmxJRU5sY25ScFptbGpZWFJwYjI0Z1FYVjBhRzl5YVhSNU1CNFhEVEE1TURZeE5USXlNRFUxTmxvWERURTBNRFl4TkRJeU1EVTFObG93WkRFak1DRUdBMVVFQXd3YVVIVnlZMmhoYzJWU1pXTmxhWEIwUTJWeWRHbG1hV05oZEdVeEd6QVpCZ05WQkFzTUVrRndjR3hsSUdsVWRXNWxjeUJUZEc5eVpURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd2daOHdEUVlKS29aSWh2Y05BUUVCQlFBRGdZMEFNSUdKQW9HQkFNclJqRjJjdDRJclNkaVRDaGFJMGc4cHd2L2NtSHM4cC9Sd1YvcnQvOTFYS1ZoTmw0WElCaW1LalFRTmZnSHNEczZ5anUrK0RyS0pFN3VLc3BoTWRkS1lmRkU1ckdYc0FkQkVqQndSSXhleFRldngzSExFRkdBdDFtb0t4NTA5ZGh4dGlJZERnSnYyWWFWczQ5QjB1SnZOZHk2U01xTk5MSHNETHpEUzlvWkhBZ01CQUFHamNqQndNQXdHQTFVZEV3RUIvd1FDTUFBd0h3WURWUjBqQkJnd0ZvQVVOaDNvNHAyQzBnRVl0VEpyRHRkREM1RllRem93RGdZRFZSMFBBUUgvQkFRREFnZUFNQjBHQTFVZERnUVdCQlNwZzRQeUdVakZQaEpYQ0JUTXphTittVjhrOVRBUUJnb3Foa2lHOTJOa0JnVUJCQUlGQURBTkJna3Foa2lHOXcwQkFRVUZBQU9DQVFFQUVhU2JQanRtTjRDL0lCM1FFcEszMlJ4YWNDRFhkVlhBZVZSZVM1RmFaeGMrdDg4cFFQOTNCaUF4dmRXLzNlVFNNR1k1RmJlQVlMM2V0cVA1Z204d3JGb2pYMGlreVZSU3RRKy9BUTBLRWp0cUIwN2tMczlRVWU4Y3pSOFVHZmRNMUV1bVYvVWd2RGQ0TndOWXhMUU1nNFdUUWZna1FRVnk4R1had1ZIZ2JFL1VDNlk3MDUzcEdYQms1MU5QTTN3b3hoZDNnU1JMdlhqK2xvSHNTdGNURXFlOXBCRHBtRzUrc2s0dHcrR0szR01lRU41LytlMVFUOW5wL0tsMW5qK2FCdzdDMHhzeTBiRm5hQWQxY1NTNnhkb3J5L0NVdk02Z3RLc21uT09kcVRlc2JwMGJzOHNuNldxczBDOWRnY3hSSHVPTVoydG04bnBMVW03YXJnT1N6UT09IjsKCSJwdXJjaGFzZS1pbmZvIiA9ICJld29KSW05eWFXZHBibUZzTFhCMWNtTm9ZWE5sTFdSaGRHVXRjSE4wSWlBOUlDSXlNREV5TFRFeUxUQXhJREl6T2pFMU9qVTBJRUZ0WlhKcFkyRXZURzl6WDBGdVoyVnNaWE1pT3dvSkluQjFjbU5vWVhObExXUmhkR1V0YlhNaUlEMGdJakV6TlRRME16STFOVFF3TURBaU93b0pJblZ1YVhGMVpTMXBaR1Z1ZEdsbWFXVnlJaUE5SUNJd01EQXdZakF3T1RJNE1UZ2lPd29KSW05eWFXZHBibUZzTFhSeVlXNXpZV04wYVc5dUxXbGtJaUE5SUNJeE1EQXdNREF3TURVNU5qTXlNemcxSWpzS0NTSmxlSEJwY21WekxXUmhkR1VpSUQwZ0lqRXpOVFEwTXpZeE5UUXdNREFpT3dvSkluUnlZVzV6WVdOMGFXOXVMV2xrSWlBOUlDSXhNREF3TURBd01EVTVOak15TXpnMUlqc0tDU0p2Y21sbmFXNWhiQzF3ZFhKamFHRnpaUzFrWVhSbExXMXpJaUE5SUNJeE16VTBORE15TlRVME1EQXdJanNLQ1NKM1pXSXRiM0prWlhJdGJHbHVaUzFwZEdWdExXbGtJaUE5SUNJeE1EQXdNREF3TURJMk5ETTJNamt3SWpzS0NTSmlkbkp6SWlBOUlDSTNJanNLQ1NKbGVIQnBjbVZ6TFdSaGRHVXRabTl5YldGMGRHVmtMWEJ6ZENJZ1BTQWlNakF4TWkweE1pMHdNaUF3TURveE5UbzFOQ0JCYldWeWFXTmhMMHh2YzE5QmJtZGxiR1Z6SWpzS0NTSnBkR1Z0TFdsa0lpQTlJQ0kxT0RBeE9UTTVNemNpT3dvSkltVjRjR2x5WlhNdFpHRjBaUzFtYjNKdFlYUjBaV1FpSUQwZ0lqSXdNVEl0TVRJdE1ESWdNRGc2TVRVNk5UUWdSWFJqTDBkTlZDSTdDZ2tpY0hKdlpIVmpkQzFwWkNJZ1BTQWlZMjl0TG1SZlgySjFlbm91WjJGblgzQnNkWE11YVc5ekxqQXdNUzVoY25NdWNISmxiV2wxYlM0eGVTSTdDZ2tpY0hWeVkyaGhjMlV0WkdGMFpTSWdQU0FpTWpBeE1pMHhNaTB3TWlBd056b3hOVG8xTkNCRmRHTXZSMDFVSWpzS0NTSnZjbWxuYVc1aGJDMXdkWEpqYUdGelpTMWtZWFJsSWlBOUlDSXlNREV5TFRFeUxUQXlJREEzT2pFMU9qVTBJRVYwWXk5SFRWUWlPd29KSW1KcFpDSWdQU0FpWTI5dExtUXRMV0oxZW5vdVoyRm5MWEJzZFhNdWFXOXpMakF3TVNJN0Nna2ljSFZ5WTJoaGMyVXRaR0YwWlMxd2MzUWlJRDBnSWpJd01USXRNVEl0TURFZ01qTTZNVFU2TlRRZ1FXMWxjbWxqWVM5TWIzTmZRVzVuWld4bGN5STdDZ2tpY1hWaGJuUnBkSGtpSUQwZ0lqRWlPd3A5IjsKCSJlbnZpcm9ubWVudCIgPSAiU2FuZGJveCI7CgkicG9kIiA9ICIxMDAiOwoJInNpZ25pbmctc3RhdHVzIiA9ICIwIjsKfQ=="
the_transaction_receipt_decoded = Base64.decode64(the_transaction_receipt_encoded)
the_transaction_receipt = OSX::PropertyList.load(the_transaction_receipt_decoded) # Property list in OpenStep format
the_purchase_info_encoded = the_transaction_receipt['purchase-info']
the_purchase_info_decoded = Base64.decode64(the_purchase_info_encoded)
the_purchase_info = OSX::PropertyList.load(the_purchase_info_decoded) # Property list in OpenStep format
# Binary format looks as follows:
#
# +-----------------+-----------+------------------+-------------+
# | RECEIPT VERSION | SIGNATURE | CERTIFICATE SIZE | CERTIFICATE |
# +-----------------+-----------+------------------+-------------+
# | 1 byte | 128 bytes | 4 bytes | |
# +-----------------+-----------+------------------+-------------+
# | big endian |
# +--------------------------------------------------------------+
#
# 1. Extract receipt version, signature and certificate(s).
# 2. Check receipt version == 2.
# 3. Sanity check that signature is 128 bytes.
# 4. Sanity check certification size <= remaining payload data.
the_signature_encoded = the_transaction_receipt['signature']
the_signature_decoded = Base64.decode64(the_signature_encoded)
the_receipt_version, the_signature, the_certificate_size, the_certificate_der = the_signature_decoded.unpack('CA128L>A*')
the_certificate = OpenSSL::X509::Certificate.new(the_certificate_der)
assert_equal the_receipt_version, 2, 'The receipt version must be 2'
assert_equal the_signature.length, 128, 'The signature must be 128 bytes in size'
assert_equal the_certificate_size, the_certificate_der.length, 'The certificate expected and actual size must match'
assert_not_nil the_certificate, 'Certificate could not be instantiated'
# Make sure that the certificate is issued by Apple.
the_store = OpenSSL::X509::Store.new
the_store.add_cert(the_root_certificate)
the_store.add_cert(the_intermediate_certificate)
assert the_store.verify(the_certificate), the_store.error_string
# Another way to verify that the certificate is signed/issued by the intermediate certificate
assert the_certificate.verify(the_intermediate_certificate.public_key), 'Certificate is not signed/issued by intermediate certificate'
# Verify the signature
the_public_key = the_certificate.public_key
assert the_public_key.verify(OpenSSL::Digest::SHA1.new, the_signature, [the_receipt_version, the_purchase_info_decoded].pack('CA*')), 'Signature verification failed'
# The transaction receipt is not compromised and is from Apple.
# Accompanied receipt (returned by Apple)
the_receipt = {
"original_purchase_date_ms"=> "1354432554000",
"original_purchase_date_pst"=> "2012-12-01 23=>15=>54 America\/Los_Angeles",
"transaction_id"=> "1000000059632385",
"quantity"=> "1",
"bid"=> "com.d--buzz.gag-plus.ios.001",
"original_transaction_id"=> "1000000059632385",
"bvrs"=> "7",
"expires_date_formatted"=> "2012-12-02 08=>15=>54 Etc\/GMT",
"purchase_date"=> "2012-12-02 07=>15=>54 Etc\/GMT",
"expires_date"=> "1354436154000",
"product_id"=> "com.d__buzz.gag_plus.ios.001.ars.premium.1y",
"purchase_date_ms"=> "1354432554000",
"expires_date_formatted_pst"=> "2012-12-02 00=>15=>54 America\/Los_Angeles",
"purchase_date_pst"=> "2012-12-01 23=>15=>54 America\/Los_Angeles",
"original_purchase_date"=> "2012-12-02 07=>15=>54 Etc\/GMT",
"item_id"=> "580193937",
"web_order_line_item_id"=> "1000000026436290",
"unique_identifier"=> "0000b0092818"
}
assert_equal the_receipt['bid'], the_purchase_info['bid'], 'Mismatch bundle ID'
assert_equal the_receipt['product_id'], the_purchase_info['product-id'], 'Mismatch product ID'
assert_equal the_receipt['quantity'], the_purchase_info['quantity'], 'Mismatch quantity'
assert_equal the_receipt['item-id'], the_purchase_info['item_id'], 'Mismatch item ID'
# This is the device unique vendor identifier (iOS 6 and above)
assert_equal the_receipt['unique-vendor-identifier'], the_purchase_info['unique_vendor_identifier'], 'Mismatch unique vendor identifier'
# This is the device unique identifier (iOS 5 and below)
assert_equal the_receipt['unique-identifier'], the_purchase_info['unique_identifier'], 'Mismatch unique identifier'
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment