├── .travis.yml ├── LICENSE ├── README.md ├── receipt.go └── receipt_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.6.3 5 | - tip 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Googol Lee 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of m4gate-prototype nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/googollee/iaplocal?status.svg)](https://godoc.org/github.com/googollee/iaplocal) 2 | [![Build Status](https://travis-ci.org/googollee/iaplocal.svg?branch=master)](https://travis-ci.org/googollee/iaplocal) 3 | 4 | # Description 5 | 6 | 7 | `iaplocal` is a Go library that supports Apple Local In-App Purchase 8 | (IAP) receipt processing. 9 | 10 | - Verify the receipt signature against [App Root CA certificate](https://www.apple.com/certificateauthority/). 11 | - Parse the receipt from binary, extract in-app receipts. 12 | - Validate the receipts hash with [host GUID](https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW5). 13 | 14 | # Installation 15 | 16 | ``` 17 | go install github.com/googollee/iaplocal 18 | ``` 19 | 20 | # Usage 21 | 22 | The simplest possible usage is: 23 | 24 | ```go 25 | rootBytes, _ := ioutil.ReadFile("./AppleComputerRootCertificate.cer") 26 | rootCA, _ := x509.ParseCertificate(rootBytes) 27 | 28 | receiptB64, _ := ioutil.ReadFile("./receipt_b64") 29 | receiptBytes, _ := base64.StdEncoding.DecodeString(string(receiptB64)) 30 | receipt, _ := iaplocal.Parse(rootCA, receiptBytes) 31 | 32 | guid, _ := uuid.FromString(hostGUID) 33 | receipt.Verify(guid) 34 | ``` 35 | 36 | # License 37 | 38 | See `LICENSE`. 39 | -------------------------------------------------------------------------------- /receipt.go: -------------------------------------------------------------------------------- 1 | // Package iaplocal supports Apple Local In-App Purchase (IAP) 2 | // receipt processing. 3 | // 4 | // It loads the receipt from binary, parses the receipt's 5 | // attributes, and verifies the receipt signature and hash. 6 | package iaplocal 7 | 8 | import ( 9 | "crypto/sha1" 10 | "crypto/x509" 11 | "encoding/asn1" 12 | "errors" 13 | "time" 14 | 15 | "github.com/fullsailor/pkcs7" 16 | ) 17 | 18 | // Receipt is the receipt for an in-app purchase. 19 | type Receipt struct { 20 | Quantity int 21 | ProductID string 22 | TransactionID string 23 | PurchaseDate time.Time 24 | OriginalTransactionID string 25 | OriginalPurchaseDate time.Time 26 | ExpiresDate time.Time 27 | WebOrderLineItemID int 28 | CancellationDate time.Time 29 | } 30 | 31 | // Receipts is the app receipt. 32 | type Receipts struct { 33 | BundleID string 34 | ApplicationVersion string 35 | OpaqueValue []byte 36 | SHA1Hash []byte 37 | ReceiptCreationDate time.Time 38 | InApp []Receipt 39 | OriginalApplicationVersion string 40 | ExpirationDate time.Time 41 | 42 | rawBundleID []byte 43 | } 44 | 45 | var ( 46 | // ErrInvalidCertificate returns when parse a receipt 47 | // with invalid certificate from given root certificate. 48 | ErrInvalidCertificate = errors.New("iaplocal: invalid certificate in receipt") 49 | // ErrInvalidSignature returns when parse a receipt 50 | // which improperly signed. 51 | ErrInvalidSignature = errors.New("iaplocal: invalid signature of receipt") 52 | ) 53 | 54 | // Parse parses a receipt binary which certificates with 55 | // root certificate. 56 | // Need decode to DER binary if recevied a base64 file. 57 | func Parse(root *x509.Certificate, data []byte) (Receipts, error) { 58 | pkcs, err := pkcs7.Parse(data) 59 | if err != nil { 60 | return Receipts{}, err 61 | } 62 | 63 | if !verifyCertificates(root, pkcs.Certificates) { 64 | return Receipts{}, ErrInvalidCertificate 65 | } 66 | 67 | if !verifyPKCS(pkcs) { 68 | return Receipts{}, ErrInvalidSignature 69 | } 70 | 71 | return parsePKCS(pkcs) 72 | } 73 | 74 | // Verify verifys the receipts with given guid. 75 | // TestReceiptValidate shows how to get GUID from string. 76 | // Check https://developer.apple.com/library/ios/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateLocally.html#//apple_ref/doc/uid/TP40010573-CH1-SW5 77 | func (r *Receipts) Verify(guid []byte) bool { 78 | hash := sha1.New() 79 | hash.Write(guid) 80 | hash.Write(r.OpaqueValue) 81 | hash.Write([]byte(r.rawBundleID)) 82 | sign := hash.Sum(nil) 83 | if len(sign) != len(r.SHA1Hash) { 84 | return false 85 | } 86 | for i := range sign { 87 | if sign[i] != r.SHA1Hash[i] { 88 | return false 89 | } 90 | } 91 | return true 92 | } 93 | 94 | func verifyCertificates(root *x509.Certificate, certs []*x509.Certificate) bool { 95 | roots := x509.NewCertPool() 96 | if root != nil { 97 | roots.AddCert(root) 98 | } 99 | for _, cert := range certs { 100 | roots.AddCert(cert) 101 | } 102 | opts := x509.VerifyOptions{ 103 | Roots: roots, 104 | } 105 | for _, cert := range certs { 106 | chain, err := cert.Verify(opts) 107 | for _, c := range chain { 108 | if len(c) > 1 && c[0] == c[1] { 109 | // self certificate 110 | return false 111 | } 112 | } 113 | if err != nil { 114 | return false 115 | } 116 | } 117 | return true 118 | } 119 | 120 | func verifyPKCS(pkcs *pkcs7.PKCS7) bool { 121 | return pkcs.Verify() == nil 122 | } 123 | 124 | type attribute struct { 125 | Type int 126 | Version int 127 | Value []byte 128 | } 129 | 130 | func parsePKCS(pkcs *pkcs7.PKCS7) (ret Receipts, err error) { 131 | var r asn1.RawValue 132 | _, err = asn1.Unmarshal(pkcs.Content, &r) 133 | if err != nil { 134 | return 135 | } 136 | rest := r.Bytes 137 | for len(rest) > 0 { 138 | var ra attribute 139 | rest, err = asn1.Unmarshal(rest, &ra) 140 | if err != nil { 141 | return 142 | } 143 | switch ra.Type { 144 | case 2: 145 | if _, err = asn1.Unmarshal(ra.Value, &ret.BundleID); err != nil { 146 | return 147 | } 148 | ret.rawBundleID = ra.Value 149 | case 3: 150 | if _, err = asn1.Unmarshal(ra.Value, &ret.ApplicationVersion); err != nil { 151 | return 152 | } 153 | case 4: 154 | ret.OpaqueValue = ra.Value 155 | case 5: 156 | ret.SHA1Hash = ra.Value 157 | case 12: 158 | ret.ReceiptCreationDate, err = asn1ParseTime(ra.Value) 159 | if err != nil { 160 | return 161 | } 162 | case 17: 163 | var inApp Receipt 164 | inApp, err = parseInApp(ra.Value) 165 | if err != nil { 166 | return 167 | } 168 | ret.InApp = append(ret.InApp, inApp) 169 | case 19: 170 | if _, err = asn1.Unmarshal(ra.Value, &ret.OriginalApplicationVersion); err != nil { 171 | return 172 | } 173 | case 21: 174 | ret.ExpirationDate, err = asn1ParseTime(ra.Value) 175 | if err != nil { 176 | return 177 | } 178 | } 179 | } 180 | return 181 | } 182 | 183 | func parseInApp(data []byte) (ret Receipt, err error) { 184 | var r asn1.RawValue 185 | _, err = asn1.Unmarshal(data, &r) 186 | if err != nil { 187 | return 188 | } 189 | data = r.Bytes 190 | for len(data) > 0 { 191 | var ra attribute 192 | data, err = asn1.Unmarshal(data, &ra) 193 | if err != nil { 194 | return 195 | } 196 | switch ra.Type { 197 | case 1701: 198 | if _, err = asn1.Unmarshal(ra.Value, &ret.Quantity); err != nil { 199 | return 200 | } 201 | case 1702: 202 | if _, err = asn1.Unmarshal(ra.Value, &ret.ProductID); err != nil { 203 | return 204 | } 205 | case 1703: 206 | if _, err = asn1.Unmarshal(ra.Value, &ret.TransactionID); err != nil { 207 | return 208 | } 209 | case 1704: 210 | ret.PurchaseDate, err = asn1ParseTime(ra.Value) 211 | if err != nil { 212 | return 213 | } 214 | case 1705: 215 | if _, err = asn1.Unmarshal(ra.Value, &ret.OriginalTransactionID); err != nil { 216 | return 217 | } 218 | case 1706: 219 | ret.OriginalPurchaseDate, err = asn1ParseTime(ra.Value) 220 | if err != nil { 221 | return 222 | } 223 | case 1708: 224 | ret.ExpiresDate, err = asn1ParseTime(ra.Value) 225 | if err != nil { 226 | return 227 | } 228 | case 1711: 229 | if _, err = asn1.Unmarshal(ra.Value, &ret.WebOrderLineItemID); err != nil { 230 | return 231 | } 232 | case 1712: 233 | ret.CancellationDate, err = asn1ParseTime(ra.Value) 234 | if err != nil { 235 | return 236 | } 237 | } 238 | } 239 | return 240 | } 241 | 242 | func asn1ParseTime(data []byte) (time.Time, error) { 243 | var str string 244 | if _, err := asn1.Unmarshal(data, &str); err != nil { 245 | return time.Time{}, err 246 | } 247 | if str == "" { 248 | return time.Time{}, nil 249 | } 250 | return time.Parse(time.RFC3339, str) 251 | } 252 | -------------------------------------------------------------------------------- /receipt_test.go: -------------------------------------------------------------------------------- 1 | package iaplocal 2 | 3 | import ( 4 | "encoding/base64" 5 | "log" 6 | "testing" 7 | ) 8 | 9 | func TestCertCheck(t *testing.T) { 10 | // at := assert.New(t) 11 | // _, err := Parse(nil, base64ToBytes(receipt1)) 12 | // at.Equal(ErrInvalidCertificate, err) 13 | } 14 | 15 | func TestParseReceipt1(t *testing.T) { 16 | // rootCA, err := x509.ParseCertificate(base64ToBytes(rootCA)) 17 | // if err != nil { 18 | // t.Fatal("parse ca error:", err) 19 | // } 20 | // receipt, err := Parse(rootCA, base64ToBytes(receipt1)) 21 | // if err != nil { 22 | // t.Fatal("parse receipt error:", err) 23 | // } 24 | // at := assert.New(t) 25 | // at.Equal("1", receipt.ApplicationVersion) 26 | // at.Equal([]byte{0xf6, 0xf0, 0xf5, 0x8b, 0x39, 0xaf, 0x26, 0xe2, 0x51, 0x2b, 0x52, 0xad, 0xa1, 0xed, 0xcd, 0x4a}, receipt.OpaqueValue) 27 | // at.Equal([]byte{0xc5, 0xb4, 0x83, 0x90, 0xcf, 0xea, 0x65, 0x89, 0xfd, 0x63, 0xad, 0x72, 0x2c, 0x8, 0x1c, 0xcb, 0x1f, 0x6d, 0xbd, 0x28}, receipt.SHA1Hash) 28 | // at.Equal(2, len(receipt.InApp)) 29 | // at.Equal("1.0", receipt.OriginalApplicationVersion) 30 | // at.True(receipt.ExpirationDate.IsZero()) 31 | // 32 | // inApp1 := receipt.InApp[0] 33 | // at.Equal(1, inApp1.Quantity) 34 | // at.Equal("1000000225325901", inApp1.TransactionID) 35 | // at.Equal("2016-07-23T06:21:11Z", inApp1.PurchaseDate.Format(time.RFC3339)) 36 | // at.Equal("1000000225325901", inApp1.OriginalTransactionID) 37 | // at.Equal("2016-07-23T06:21:11Z", inApp1.OriginalPurchaseDate.Format(time.RFC3339)) 38 | // at.True(inApp1.ExpiresDate.IsZero()) 39 | // at.Equal(0, inApp1.WebOrderLineItemID) 40 | // at.True(inApp1.CancellationDate.IsZero()) 41 | // 42 | // inApp2 := receipt.InApp[1] 43 | // at.Equal(1, inApp2.Quantity) 44 | // at.Equal("1000000225334938", inApp2.TransactionID) 45 | // at.Equal("2016-07-23T11:00:59Z", inApp2.PurchaseDate.Format(time.RFC3339)) 46 | // at.Equal("1000000225334938", inApp2.OriginalTransactionID) 47 | // at.Equal("2016-07-23T11:00:59Z", inApp2.OriginalPurchaseDate.Format(time.RFC3339)) 48 | // at.True(inApp2.ExpiresDate.IsZero()) 49 | // at.Equal(0, inApp2.WebOrderLineItemID) 50 | // at.True(inApp2.CancellationDate.IsZero()) 51 | } 52 | 53 | func TestReceiptValidate(t *testing.T) { 54 | // at := assert.New(t) 55 | // rootCA, err := x509.ParseCertificate(base64ToBytes(rootCA)) 56 | // if err != nil { 57 | // t.Fatal("parse ca error:", err) 58 | // } 59 | 60 | // receipt, err := Parse(rootCA, base64ToBytes(receipt1)) 61 | // if err != nil { 62 | // t.Fatal("parse receipt error:", err) 63 | // } 64 | // 65 | // guid, err := uuid.FromString(hostGUID) 66 | // if err != nil { 67 | // t.Fatal("parse guid error:", err) 68 | // } 69 | // at.True(receipt.Verify(guid.Bytes())) 70 | } 71 | 72 | func base64ToBytes(b64 string) []byte { 73 | d, err := base64.StdEncoding.DecodeString(b64) 74 | if err != nil { 75 | log.Fatalln("decode error:", err) 76 | } 77 | return d 78 | } 79 | 80 | var ( 81 | rootCA = `MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh` 82 | hostGUID = `urn:uuid:xxxx-xxx-xxx-xxx` 83 | receipt1 = `` 84 | ) 85 | --------------------------------------------------------------------------------