1

I have Xamarin iOS app with in-app products. I get base64 encoded app receipt:

NSUrl receiptURL = NSBundle.MainBundle.AppStoreReceiptUrl;
String receiptData = receipt.GetBase64EncodedString(0);

According to Apple docs 7.0+ app receipt is packed in PKCS7 container using ASN1. When I send it to apple server it returns receipt in JSON. But I want to parse it locally to know what iaps user does already have. I don't need validation, as Apple does it remotely, I just need to get receipts for purchased inaps.

So far, as an investigation, I've parsed it in php with phpseclib, manually (no idea how to do this programatically) located receipt and parsed it as well.

$asn_parser = new File_ASN1();
//parse the receipt binary string
$pkcs7 = $asn_parser->decodeBER(base64_decode($f));
//print_r($pkcs7);
$payload_sequence = $pkcs7[0]['content'][1]['content'][0]['content'][2]['content'];

$pld = $asn_parser->decodeBER($payload_sequence[1]['content'][0]['content']);
//print_r($pld);
$prd = $asn_parser->decodeBER($pld[0]['content'][21]['content'][2]['content']);
print_r($prd);

But even this way I've got a mess of attributes, each looks like:

                                        Array
                                        (
                                            [start] => 271
                                            [headerlength] => 2
                                            [type] => 4
                                            [content] => 2016-08-22T13:22:00Z
                                            [length] => 24
                                        )

It is not human readable, I need something like (output with print_r of returned by Apple) :

[receipt] => Array
(
    [receipt_type] => ProductionSandbox
    [adam_id] => 0
    [app_item_id] => 0
    [bundle_id] => com.my.test.app.iOS
...
    [in_app] => Array
        (
            [0] => Array
            (
                [quantity] => 1
                [product_id] => test_iap_1
                [transaction_id] => 1000000230806171
...
                [is_trial_period] => false
            )
        )
)

Things seem too complicated, I hardly believe unpacking receipt is so complex. Does anybody know how to manage this? I've found this post but library is written in objective-C which is not applicable to my current environment. I'd say sources of this lib are frightened me: so much complex code just to unpack standartized container. I mean working with json, bson, etc. is very easy, but not asn1.

Community
  • 1
  • 1
Tertium
  • 6,049
  • 3
  • 30
  • 51
  • The receipt isn't a great way to manage inventory since consumables disappear from it (or at least that's the documented behavior, YMMV). Non-consumables can be discovered if the user does a restore. What is the case where you need to know locally what is in the receipt? – Ben Flynn Aug 23 '16 at 16:59
  • reinstalled app, refreshed app receipt - the only place where I can see which pruduct is purchased. of course inventory is in local files and cloud. Anyway, I've unpacked it using Liping Dai LCLib (lipingshare.com). Asn1Parser returns DOM-like tree with root node - very handy lib. – Tertium Aug 23 '16 at 22:23
  • Good find on the library. Still a little confused -- the receipt shouldn't contain any consumables and having the user restore should get you back your non-consumables, but if it's working for you, then I guess you're good. – Ben Flynn Aug 23 '16 at 23:24
  • after you purchase consumable you must also check/use app receipt to fulfill product. transaction receipts now obsolete and maybe disabled in near future. Of course after you called finishtransaction record about this product will disappear from app receipt. – Tertium Aug 24 '16 at 10:41
  • You need it to do client side verification, but the `SKPaymentTransaction` has all the information you need to fulfill the transaction. When `- (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions` tells you `SKPaymentTransactionStatePurchased` or `SKPaymentTransactionStateRestored` you can use the `transaction.payment.productIdentifier` to award the purchase. Be aware there is a bug where if your purchaser does parent-child approval your receipt may have an empty IAP array. – Ben Flynn Aug 24 '16 at 16:39
  • @BenFlynn what IAP array? in 7.0+ appreceipt? I use transaction id and product id from SKPaymentTransaction only to compare with ones I get from appreceipt. Verification is made on server side. I wanted to unpack appreceipt to implement restore functionality, as restore transactions is not a option (obsolete). – Tertium Aug 28 '16 at 09:13
  • Yes the IAP array in the 7.0+ app receipt. If not for verification I'm not sure why you "compare with ones [you] get from the app receipt". Receipt are really only there for IAP validation. – Ben Flynn Aug 28 '16 at 18:27
  • 1
    1. After you reinstall the app you need to restore non-consumables. Apple has marked restoreTransactions as obsolete, so appreceipt is the only option. But I may have purchased features already enabled, so I get appreceipt, unpack it and check which items I already have. If there are new items in receipt, I send it to my backend and get items' content. 2. When purchasing an item, it's reasonable to check the same appreceipt and send it to backend the same way. To let server know what is purchased from list of owned items in appreceipt I send transaction id and product id from transaction obj. – Tertium Aug 28 '16 at 21:18
  • Huh. If you look up the receipt Apple only discusses verification and if you look at SKPaymentQueue it doesn't have restoreTransactions marked as deprecated *BUT* their guide to "Restoring Purchased Products" absolutely says to do it the way you are talking about. I honestly didn't think this was supported, but knowing it is, I'll start doing it too. Thanks! – Ben Flynn Aug 28 '16 at 22:27
  • Restoring Purchased Products: "In most cases, all your app needs to do is refresh its receipt and deliver the products in its receipt." https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Chapters/Restoring.html – Ben Flynn Aug 28 '16 at 22:28
  • that's what I've said. But if apple occasionally send an empty receipt - I can't customer help with this - if his receipt is empty I suppose he don't have eny goods – Tertium Sep 06 '16 at 03:24
  • I don't think it will happen for this type of restore. I've only seen it during an actual purchase when the purchaser needed the purchase approved by another device. I opened a bug with Apple on it and they asked for more details, maybe someday it will get fixed. – Ben Flynn Sep 06 '16 at 04:12

1 Answers1

3

Finally I've unpacked it using Liping Dai LCLib (lipingshare.com). Asn1Parser returns DOM-like tree with root node - very handy lib.

    public class AppleAppReceipt
    {
        public class AppleInAppPurchaseReceipt
        {
            public int Quantity;
            public string ProductIdentifier;
            public string TransactionIdentifier;
            public DateTime PurchaseDate;
            public string OriginalTransactionIdentifier;
            public DateTime OriginalPurchaseDate;
            public DateTime SubscriptionExpirationDate;
            public DateTime CancellationDate;
            public int WebOrderLineItemID;
        }

        const int AppReceiptASN1TypeBundleIdentifier = 2;
        const int AppReceiptASN1TypeAppVersion = 3;
        const int AppReceiptASN1TypeOpaqueValue = 4;
        const int AppReceiptASN1TypeHash = 5;
        const int AppReceiptASN1TypeReceiptCreationDate = 12;
        const int AppReceiptASN1TypeInAppPurchaseReceipt = 17;
        const int AppReceiptASN1TypeOriginalAppVersion = 19;
        const int AppReceiptASN1TypeReceiptExpirationDate = 21;

        const int AppReceiptASN1TypeQuantity = 1701;
        const int AppReceiptASN1TypeProductIdentifier = 1702;
        const int AppReceiptASN1TypeTransactionIdentifier = 1703;
        const int AppReceiptASN1TypePurchaseDate = 1704;
        const int AppReceiptASN1TypeOriginalTransactionIdentifier = 1705;
        const int AppReceiptASN1TypeOriginalPurchaseDate = 1706;
        const int AppReceiptASN1TypeSubscriptionExpirationDate = 1708;
        const int AppReceiptASN1TypeWebOrderLineItemID = 1711;
        const int AppReceiptASN1TypeCancellationDate = 1712;

        public string BundleIdentifier;
        public string AppVersion;
        public string OriginalAppVersion; //какую покупали
        public DateTime ReceiptCreationDate;

        public Dictionary<string, AppleInAppPurchaseReceipt> PurchaseReceipts;

        public bool parseAsn1Data(byte[] val)
        {
            if (val == null)
                return false;
            Asn1Parser p = new Asn1Parser();
            var stream = new MemoryStream(val);
            try
            {
                p.LoadData(stream);
            }
            catch (Exception e)
            {
                return false;
            }

            Asn1Node root = p.RootNode;
            if (root == null)
                return false;

            PurchaseReceipts = new Dictionary<string, AppleInAppPurchaseReceipt>();
            parseNodeRecursive(root);

            return !string.IsNullOrEmpty(BundleIdentifier);
        }


        private static string getStringFromSubNode(Asn1Node nn)
        {
            string dataStr = null;

            if ((nn.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.OCTET_STRING && nn.ChildNodeCount > 0)
            {
                Asn1Node n = nn.GetChildNode(0);

                switch (n.Tag & Asn1Tag.TAG_MASK)
                {
                    case Asn1Tag.PRINTABLE_STRING:
                    case Asn1Tag.IA5_STRING:
                    case Asn1Tag.UNIVERSAL_STRING:
                    case Asn1Tag.VISIBLE_STRING:
                    case Asn1Tag.NUMERIC_STRING:
                    case Asn1Tag.UTC_TIME:
                    case Asn1Tag.UTF8_STRING:
                    case Asn1Tag.BMPSTRING:
                    case Asn1Tag.GENERAL_STRING:
                    case Asn1Tag.GENERALIZED_TIME:
                        {
                            if ((n.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.UTF8_STRING)
                            {
                                UTF8Encoding unicode = new UTF8Encoding();
                                dataStr = unicode.GetString(n.Data);
                            }
                            else
                            {
                                dataStr = Asn1Util.BytesToString(n.Data);
                            }
                        }
                        break;
                }
            }
            return dataStr;
        }
        private static DateTime getDateTimeFromSubNode(Asn1Node nn)
        {
            string dataStr = getStringFromSubNode(nn);
            if (string.IsNullOrEmpty(dataStr))
                return DateTime.MinValue;
            DateTime retval = DateTime.MaxValue;
            try
            {
                retval = DateTime.Parse(dataStr);
            }
            catch (Exception e)
            {
            }
            return retval;
        }

        private static int getIntegerFromSubNode(Asn1Node nn)
        {
            int retval = -1;

            if ((nn.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.OCTET_STRING && nn.ChildNodeCount > 0)
            {
                Asn1Node n = nn.GetChildNode(0);
                if ((n.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.INTEGER)
                    retval = (int)Asn1Util.BytesToLong(n.Data);
            }
            return retval;
        }

        private void parseNodeRecursive(Asn1Node tNode)
        {
            bool processed_node = false;
            if ((tNode.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && tNode.ChildNodeCount == 3)
            {
                Asn1Node node1 = tNode.GetChildNode(0);
                Asn1Node node2 = tNode.GetChildNode(1);
                Asn1Node node3 = tNode.GetChildNode(2);

                if ((node1.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.INTEGER && (node2.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.INTEGER &&
                    (node3.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.OCTET_STRING)
                {
                    processed_node = true;
                    int type = (int)Asn1Util.BytesToLong(node1.Data);
                    switch (type)
                    {
                        case AppReceiptASN1TypeBundleIdentifier:
                            BundleIdentifier = getStringFromSubNode(node3);
                            break;
                        case AppReceiptASN1TypeAppVersion:
                            AppVersion = getStringFromSubNode(node3);
                            break;
                        case AppReceiptASN1TypeOpaqueValue:
                            break;
                        case AppReceiptASN1TypeHash:
                            break;
                        case AppReceiptASN1TypeOriginalAppVersion:
                            OriginalAppVersion = getStringFromSubNode(node3);
                            break;
                        case AppReceiptASN1TypeReceiptExpirationDate:
                            break;
                        case AppReceiptASN1TypeReceiptCreationDate:
                            ReceiptCreationDate = getDateTimeFromSubNode(node3);
                            break;
                        case AppReceiptASN1TypeInAppPurchaseReceipt:
                            {
                                if (node3.ChildNodeCount > 0)
                                {
                                    Asn1Node node31 = node3.GetChildNode(0);
                                    if ((node31.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SET && node31.ChildNodeCount > 0)
                                    {
                                        AppleInAppPurchaseReceipt receipt = new AppleInAppPurchaseReceipt();

                                        for (int i = 0; i < node31.ChildNodeCount; i++)
                                        {
                                            Asn1Node node311 = node31.GetChildNode(i);
                                            if ((node311.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.SEQUENCE && node311.ChildNodeCount == 3)
                                            {
                                                Asn1Node node3111 = node311.GetChildNode(0);
                                                Asn1Node node3112 = node311.GetChildNode(1);
                                                Asn1Node node3113 = node311.GetChildNode(2);
                                                if ((node3111.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.INTEGER && (node3112.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.INTEGER &&
                                                    (node3113.Tag & Asn1Tag.TAG_MASK) == Asn1Tag.OCTET_STRING)
                                                {
                                                    int type1 = (int)Asn1Util.BytesToLong(node3111.Data);
                                                    switch (type1)
                                                    {
                                                        case AppReceiptASN1TypeQuantity:
                                                            receipt.Quantity = getIntegerFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypeProductIdentifier:
                                                            receipt.ProductIdentifier = getStringFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypeTransactionIdentifier:
                                                            receipt.TransactionIdentifier = getStringFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypePurchaseDate:
                                                            receipt.PurchaseDate = getDateTimeFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypeOriginalTransactionIdentifier:
                                                            receipt.OriginalTransactionIdentifier = getStringFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypeOriginalPurchaseDate:
                                                            receipt.OriginalPurchaseDate = getDateTimeFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypeSubscriptionExpirationDate:
                                                            receipt.SubscriptionExpirationDate = getDateTimeFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypeWebOrderLineItemID:
                                                            receipt.WebOrderLineItemID = getIntegerFromSubNode(node3113);
                                                            break;
                                                        case AppReceiptASN1TypeCancellationDate:
                                                            receipt.CancellationDate = getDateTimeFromSubNode(node3113);
                                                            break;
                                                    }
                                                }
                                            }
                                        }

                                        if (!string.IsNullOrEmpty(receipt.ProductIdentifier))
                                            PurchaseReceipts.Add(receipt.ProductIdentifier, receipt);
                                    }
                                }
                            }
                            break;
                        default:
                            processed_node = false;
                            break;
                    }
                }
            }


            if (!processed_node)
            {
                for (int i = 0; i < tNode.ChildNodeCount; i++)
                {
                    Asn1Node chld = tNode.GetChildNode(i);
                    if (chld != null)
                        parseNodeRecursive(chld);
                }
            }
        }
    }

And usage:

    public void printAppReceipt()
    {
        NSUrl receiptURL = NSBundle.MainBundle.AppStoreReceiptUrl;
        if (receiptURL != null)
        {
            Console.WriteLine("receiptUrl='" + receiptURL + "'");

            NSData receipt = NSData.FromUrl(receiptURL);
            if (receipt != null)
            {
                byte[] rbytes = receipt.ToArray();
                AppleAppReceipt apprec = new AppleAppReceipt();
                if (apprec.parseAsn1Data(rbytes))
                {
                    Console.WriteLine("Received receipt for " + apprec.BundleIdentifier + " with " + apprec.PurchaseReceipts.Count +
                                      " products");
                    Console.WriteLine(JsonConvert.SerializeObject(apprec,Formatting.Indented));
                }
            }
            else
                Console.WriteLine("receipt == null");
        }
    }
Tertium
  • 6,049
  • 3
  • 30
  • 51