IAP 中的服务器验证实现
IAP 中的服务器验证实现
目标 & 背景
最近的一段时间,我司收了其他公司一个运营很多年的项目,不幸的是最近要参与到这个项目的功能开发和维护上,该项目是我目前为止见到过,维护成本最高的项目,一共用了 5 种语言
- 客户端 C# + lua
- 打包流程 python
- 服务器 go
- 后台 php
其中客户端的 lua 代码有足足百万行之多…其他项目的维护和开发,靠的是技术力,而这个项目靠的是意志力
据说,该项目的源码泄露了很多份,由于写的很烂导致几乎没有几家公司可以接起来,这一刻我明白了,什么叫核心竞争力
有些扯远了,但接受这个项目的这段时间,当真是被折磨的够呛,不吐不快。本篇博客的主题是围绕该项目接入印尼一个发行渠道展开,由于要接入 IAP,而印尼的发行并没有封装自己的原生 SDK,因此客户端和服务器的支付流程要自己手撸
请注意,本篇内容只会围绕服务器验证支付是否合法,而不会介绍客户端如何接入,客户端可以找到的现成 SDK 比较多,可以自行翻阅
iOS 后台配置
iOS 的商品信息配置比较简单,来到 connect 后台,增加对应的商品信息就好了,需要在下图中的位置获取一份 Shared Secret,用于后续的校验
谷歌后台配置
谷歌的就比较烦了,首先你需要创建一份包含谷歌支付的 aab,上传到谷歌后台,才可以配置商品信息,在配置好后,在如下内容中找到 public key,用于后续的校验
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,再加上这个项目实在是烂,能让一切顺利进行下去,就已经耗光我的精力了
所以非常遗憾,这段时间的努力,几乎没有可以复用的内容,也是让我非常上火
- 感谢你赐予我前进的力量