IAP 中的服务器验证实现

目标 & 背景

最近的一段时间,我司收了其他公司一个运营很多年的项目,不幸的是最近要参与到这个项目的功能开发和维护上,该项目是我目前为止见到过,维护成本最高的项目,一共用了 5 种语言

  • 客户端 C# + lua
  • 打包流程 python
  • 服务器 go
  • 后台 php

其中客户端的 lua 代码有足足百万行之多...其他项目的维护和开发,靠的是技术力,而这个项目靠的是意志力

据说,该项目的源码泄露了很多份,由于写的很烂导致几乎没有几家公司可以接起来,这一刻我明白了,什么叫核心竞争力

有些扯远了,但接受这个项目的这段时间,当真是被折磨的够呛,不吐不快。本篇博客的主题是围绕该项目接入印尼一个发行渠道展开,由于要接入 IAP,而印尼的发行并没有封装自己的原生 SDK,因此客户端和服务器的支付流程要自己手撸

请注意,本篇内容只会围绕服务器验证支付是否合法,而不会介绍客户端如何接入,客户端可以找到的现成 SDK 比较多,可以自行翻阅

iOS 后台配置

iOS 的商品信息配置比较简单,来到 connect 后台,增加对应的商品信息就好了,需要在下图中的位置获取一份 Shared Secret,用于后续的校验

image

谷歌后台配置

谷歌的就比较烦了,首先你需要创建一份包含谷歌支付的 aab,上传到谷歌后台,才可以配置商品信息,在配置好后,在如下内容中找到 public key,用于后续的校验

image

iOS 验证

客户端支付完成后,会获得一个 receipt-data,此时需要拿着这个值,问苹果的服务器获取解密后的结果,而服务器分两种 沙箱环境线上环境

const password string = "xxx"  
const onlineUrl string = "https://buy.itunes.apple.com/verifyReceipt"  
const sandboxUrl string = "https://sandbox.itunes.apple.com/verifyReceipt"

这里的 password,就是上文提到的 Shared Secret,接着向苹果的服务器发起一次校验,第三个 Transactions 参数一般填 false

type iOSReceiptRequest struct {  
    ReceiptData  string `json:"receipt-data"`  
    Password     string `json:"password"`  
    Transactions bool   `json:"exclude-old-transactions"`  
}

这里可以根据 返回错误代码文档 做后续的校验,返回的对象参数可以参考 App Store receipt data types 文档 我懒得挨个解析,这里就省略了,至此苹果的验证过程就基本结束

谷歌验证

谷歌的订单验证就比较烦了,我在整个接入过程中,搜到的文档信息都不对,最后是在客户端的 SDK 源码中找到了验证的关键信息

// 客户端的 SDK 会有这个包的引用
com.android.billingclient:billing

这里是关键代码的实现,由于 SDK 底层使用的是 java 版本,而服务器要用 go 语言实现,这种转换的活交给 AI 非常合适

public class Security {
    private static final String TAG = Const.LOG_TAG;

    private static final String KEY_FACTORY_ALGORITHM = "RSA";
    private static final String SIGNATURE_ALGORITHM = "SHA1withRSA";

    public static boolean verifyPurchase(String base64PublicKey, String signedData, String signature) {
        //return true;
        if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) ||
                TextUtils.isEmpty(signature)) {
            Log.e(TAG, "Purchase verification failed: missing data.");
            return false;
        }

        PublicKey key = Security.generatePublicKey(base64PublicKey);
        return Security.verify(key, signedData, signature);
    }

    public static PublicKey generatePublicKey(String encodedPublicKey) {
        try {
            byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT);
            KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);
            return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        } catch (InvalidKeySpecException e) {
            Log.e(TAG, "Invalid key specification.");
            throw new IllegalArgumentException(e);
        }
    }

    public static boolean verify(PublicKey publicKey, String signedData, String signature) {
        byte[] signatureBytes;
        try {
            signatureBytes = Base64.decode(signature, Base64.DEFAULT);
        } catch (IllegalArgumentException e) {
            Log.e(TAG, "Base64 decoding failed.");
            return false;
        }
        try {
            Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM);
            sig.initVerify(publicKey);
            sig.update(signedData.getBytes());
            if (!sig.verify(signatureBytes)) {
                Log.e(TAG, "Signature verification failed.");
                return false;
            }
            return true;
        } catch (NoSuchAlgorithmException e) {
            Log.e(TAG, "NoSuchAlgorithmException.");
        } catch (InvalidKeyException e) {
            Log.e(TAG, "Invalid key specification.");
        } catch (SignatureException e) {
            Log.e(TAG, "Signature exception.");
        }
        return false;
    }
}

我用 GPT-4 和 GPT-4o 两个版本,分别转换了 C# 的实现过程,以及 go 语言的实现过程

using System;
using System.Security.Cryptography;
using System.Text;
using System.Security.Cryptography.X509Certificates;

namespace SecurityNamespace
{
    public static class Security
    {
        private static readonly string TAG = "LOG_TAG"; // Replace with actual log tag

        private static readonly string KEY_FACTORY_ALGORITHM = "RSA";
        private static readonly string SIGNATURE_ALGORITHM = "SHA1withRSA";

        public static bool VerifyPurchase(string base64PublicKey, string signedData, string signature)
        {
            if (string.IsNullOrEmpty(signedData) || string.IsNullOrEmpty(base64PublicKey) ||
                string.IsNullOrEmpty(signature))
            {
                Console.Error.WriteLine($"{TAG}: Purchase verification failed: missing data.");
                return false;
            }

            var key = GeneratePublicKey(base64PublicKey);
            return Verify(key, signedData, signature);
        }

        public static RSA GeneratePublicKey(string encodedPublicKey)
        {
            try
            {
                byte[] decodedKey = Convert.FromBase64String(encodedPublicKey);
                var rsa = RSA.Create();
                rsa.ImportSubjectPublicKeyInfo(decodedKey, out _);
                return rsa;
            }
            catch (CryptographicException e)
            {
                Console.Error.WriteLine($"{TAG}: Invalid key specification.");
                throw new ArgumentException("Invalid public key format", e);
            }
        }

        public static bool Verify(RSA publicKey, string signedData, string signature)
        {
            byte[] signatureBytes;
            try
            {
                signatureBytes = Convert.FromBase64String(signature);
            }
            catch (FormatException e)
            {
                Console.Error.WriteLine($"{TAG}: Base64 decoding failed.");
                return false;
            }
            try
            {
                var verifier = publicKey;
                var dataBytes = Encoding.UTF8.GetBytes(signedData);
                if (!verifier.VerifyData(dataBytes, signatureBytes, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1))
                {
                    Console.Error.WriteLine($"{TAG}: Signature verification failed.");
                    return false;
                }
                return true;
            }
            catch (CryptographicException e)
            {
                Console.Error.WriteLine($"{TAG}: {e.Message}");
                return false;
            }
        }
    }
}
func VerifyPurchase(base64PublicKey, signedData, signature string) bool {  
    if signedData == "" || base64PublicKey == "" || signature == "" {  
       return false  
    }  
  
    key, err := GeneratePublicKey(base64PublicKey)  
    if err != nil {  
       return false  
    }  
  
    return Verify(key, signedData, signature)  
}  
  

func GeneratePublicKey(encodedPublicKey string) (*rsa.PublicKey, error) {  
    decodedKey, err := base64.StdEncoding.DecodeString(encodedPublicKey)  
    if err != nil {  
       return nil, errors.New("invalid public key format")  
    }  
  
    publicKey, err := x509.ParsePKIXPublicKey(decodedKey)  
    if err != nil {  
       return nil, errors.New("invalid public key format")  
    }  
  
    rsaPubKey, ok := publicKey.(*rsa.PublicKey)  
    if !ok {  
       return nil, errors.New("not an RSA public key")  
    }  
  
    return rsaPubKey, nil  
}  

func Verify(publicKey *rsa.PublicKey, signedData, signature string) bool {  
    signatureBytes, err := base64.StdEncoding.DecodeString(signature)  
    if err != nil {  
       return false  
    }  
  
    dataBytes := []byte(signedData)  
    hashed := sha1.Sum(dataBytes)  
  
    err = rsa.VerifyPKCS1v15(publicKey, crypto.SHA1, hashed[:], signatureBytes)  
    if err != nil {
       return false  
    }  
  
    return true  
}

谷歌的验证流程,不需要访问谷歌的服务器,在项目部署的物理机上可以直接完成验证

最后

印尼的发行,路子还是挺野的,除了谷歌和苹果的 IAP,还需要接入第三方支付,一般是他们自己搭建的 web 支付,获取到用户的 uid 之后,直接调用游戏服务器发货接口,可以实现离线发货...

最近看到的 dnf 手游私服,似乎也是这么个流程,还挺有意思(韩国人做的游戏还是一如既往的裸奔,ab 也是毫无加密)

在着手做这件事时,按照我做事情的习惯,会把这一整套流程,封装成一个可用的库,后面再遇到类似的场景,直接用就好了,但是!我司本身有自己的 native sdk,再加上这个项目实在是烂,能让一切顺利进行下去,就已经耗光我的精力了

所以非常遗憾,这段时间的努力,几乎没有可以复用的内容,也是让我非常上火