rc-security

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Phase 0: Intent

阶段0:目标

Tell the user: "I will review your RevenueCat security posture on Android: verification mode, API keys, user identity, and server side access decisions."
告知用户:“我将审核您在Android平台上的RevenueCat安全配置,包括验证模式、API密钥、用户身份以及服务器端访问决策。”

Phase 1: Discovery

阶段1:排查

Confirm what RevenueCat already covers so you can focus on the real gaps.
ConcernWho handles itHow
Receipt validation against Google PlayRevenueCat backendRuns before
awaitPurchase()
returns
Purchase token reuseRevenueCat backendTokens are deduplicated server side
Fabricated purchase tokensRevenueCat backendFails Google Play verification, no entitlement granted
HTTPS transport to RevenueCatSDKAlways on
Retry of token post on network failureSDKRetries on next app launch
Ask the project these questions:
  • Is
    EntitlementVerificationMode
    set? If not,
    CustomerInfo
    responses are trusted without signature checking.
  • Is the SDK configured with the public Android key (starts with
    goog_
    ) or has someone accidentally pasted the secret key into the client?
  • Are real users identified with
    Purchases.logIn(yourUserId)
    , or is the app still relying on anonymous
    $RCAnonymousID:...
    ?
  • Do server endpoints that serve premium content verify entitlement server side, or do they trust a client sent flag?
确认RevenueCat已覆盖的安全点,以便聚焦真正的漏洞。
关注点负责方实现方式
针对Google Play的收据验证RevenueCat后端
awaitPurchase()
返回前执行
购买令牌重复使用RevenueCat后端在服务器端对令牌进行去重
伪造购买令牌RevenueCat后端无法通过Google Play验证,不会授予权益
与RevenueCat的HTTPS传输SDK始终启用
网络失败时重新提交令牌SDK在下次应用启动时重试
向项目团队询问以下问题:
  • 是否设置了
    EntitlementVerificationMode
    ?如果未设置,
    CustomerInfo
    响应将在不进行签名检查的情况下被信任。
  • SDK是否配置了公开Android密钥(以
    goog_
    开头),还是有人不慎将私密密钥粘贴到客户端中?
  • 是否通过
    Purchases.logIn(yourUserId)
    识别真实用户,还是应用仍依赖匿名的
    $RCAnonymousID:...
  • 提供付费内容的服务器端点是否在服务器端验证权益,还是信任客户端发送的标记?

Phase 2: Plan

阶段2:规划

Decide each of the following before changing code.
在修改代码前确定以下各项决策。

Decision A: Entitlement verification mode

决策A:权益验证模式

ModeFailed verification does whatPick when
DISABLED
(default)
No signing happensOnly for quick prototypes
INFORMATIONAL
Logged, access still grantedYou want signal without risking false denials to real users
ENFORCED
EntitlementInfo.isActive
returns
false
You accept some false negatives from proxies or VPNs in exchange for a strict client guarantee
模式验证失败时的行为适用场景
DISABLED
(默认)
不执行签名验证仅用于快速原型开发
INFORMATIONAL
记录日志,但仍授予访问权限希望获取验证信号,同时避免误拒真实用户
ENFORCED
EntitlementInfo.isActive
返回
false
愿意接受因代理或VPN导致的少量误判,以换取严格的客户端安全保障

Decision B: Where is the authority?

决策B:权威方定位

Even with
ENFORCED
, the client is not the authority for paid content. Decide whether each premium endpoint:
  1. Calls the RevenueCat REST API per request, or
  2. Reads a local
    has_entitlement
    flag driven by RevenueCat webhooks.
Option 2 is cheaper at request time, option 1 has no cache staleness. Pick one, document it, do not mix per endpoint without a reason.
即使启用
ENFORCED
模式,客户端也不是付费内容的权威方。需为每个付费端点决定:
  1. 每次请求调用RevenueCat REST API,或
  2. 读取由RevenueCat webhook驱动的本地
    has_entitlement
    标记。
选项2在请求时成本更低,选项1则不存在缓存过期问题。选择其中一种方式并记录,若无特殊原因,不要在不同端点混合使用。

Decision C: User identity

决策C:用户身份管理

If the app is in production, plan to call
Purchases.logIn("your_user_id")
with your own authenticated user id. Anonymous ids are device scoped identifiers, not credentials, and are shared across users of the same device.
如果应用已上线,计划使用您自己的已认证用户ID调用
Purchases.logIn("your_user_id")
。匿名ID是设备范围的标识符,而非凭据,会在同一设备的多个用户之间共享。

Phase 3: Execute

阶段3:执行

3.1 Turn on response verification

3.1 开启响应验证

Add the mode to
PurchasesConfiguration
:
kotlin
PurchasesConfiguration.Builder(context, apiKey)
    .entitlementVerificationMode(EntitlementVerificationMode.INFORMATIONAL)
    .build()
Read the verification result when you inspect entitlements:
kotlin
when (customerInfo.entitlements.verification) {
    VerificationResult.VERIFIED -> { /* response is authentic */ }
    VerificationResult.FAILED -> { /* possible tampering, log and alert */ }
    VerificationResult.NOT_REQUESTED -> { /* verification disabled */ }
    VerificationResult.VERIFIED_ON_DEVICE -> { /* verified locally */ }
}
Upgrade to
ENFORCED
once you have telemetry confirming
FAILED
is rare on real traffic:
kotlin
.entitlementVerificationMode(EntitlementVerificationMode.ENFORCED)
In
ENFORCED
, a failed signature flips
isActive
to
false
on the affected entitlement.
将模式添加到
PurchasesConfiguration
kotlin
PurchasesConfiguration.Builder(context, apiKey)
    .entitlementVerificationMode(EntitlementVerificationMode.INFORMATIONAL)
    .build()
检查权益时读取验证结果:
kotlin
when (customerInfo.entitlements.verification) {
    VerificationResult.VERIFIED -> { /* 响应真实可信 */ }
    VerificationResult.FAILED -> { /* 可能存在篡改,记录日志并发出警报 */ }
    VerificationResult.NOT_REQUESTED -> { /* 验证已禁用 */ }
    VerificationResult.VERIFIED_ON_DEVICE -> { /* 已在本地验证 */ }
}
当遥测数据确认真实流量中
FAILED
情况很少时,升级为
ENFORCED
模式:
kotlin
.entitlementVerificationMode(EntitlementVerificationMode.ENFORCED)
ENFORCED
模式下,签名失败会将受影响权益的
isActive
设为
false

3.2 Keep API keys in the right place

3.2 正确存放API密钥

KeyWhere it livesWhat it can do
Public Android SDK key (
goog_...
)
Embedded in the app binaryRead and purchase for the calling user only
Secret REST API keyYour server, secret manager or env varFull REST API, admin operations, grant entitlements
In Android code, only the public key appears:
kotlin
Purchases.configure(
    PurchasesConfiguration.Builder(context, "goog_PUBLIC_android_sdk_key").build()
)
Never commit the secret key to the app repo. Grep the Android source tree for the secret key prefix and confirm zero hits before release.
密钥存放位置权限
公开Android SDK密钥(
goog_...
嵌入应用二进制文件仅能读取当前调用用户的信息并进行购买操作
私密REST API密钥您的服务器、密钥管理器或环境变量拥有完整REST API权限,可执行管理操作、授予权益
Android代码中仅应出现公开密钥:
kotlin
Purchases.configure(
    PurchasesConfiguration.Builder(context, "goog_PUBLIC_android_sdk_key").build()
)
切勿将私密密钥提交到应用代码仓库。发布前,在Android源码树中搜索私密密钥前缀,确认无匹配结果。

3.3 Identify real users

3.3 识别真实用户

Call
logIn
as soon as you have an authenticated user id:
kotlin
val result = Purchases.sharedInstance.awaitLogIn("your_user_id")
val customerInfo = result.customerInfo
Do not rely on the anonymous id as a credential. It is a device scoped identifier and does not protect purchase history on shared devices.
一旦获取到已认证用户ID,立即调用
logIn
kotlin
val result = Purchases.sharedInstance.awaitLogIn("your_user_id")
val customerInfo = result.customerInfo
不要依赖匿名ID作为凭据。它是设备范围的标识符,无法保护共享设备上的购买历史。

3.4 Enforce on the server, not on the client

3.4 在服务器端而非客户端强制执行

Even with
ENFORCED
mode, every server endpoint that serves paid content checks entitlement server side:
python
def get_premium_content(user_id):
    info = revenuecat.get_subscriber(user_id)
    if not info.entitlements["pro"].is_active:
        raise Forbidden()
    return content
Or read a local
has_pro
flag driven by RevenueCat webhooks and check that flag per request.
即使启用
ENFORCED
模式,每个提供付费内容的服务器端点都必须在服务器端验证权益:
python
def get_premium_content(user_id):
    info = revenuecat.get_subscriber(user_id)
    if not info.entitlements["pro"].is_active:
        raise Forbidden()
    return content
或者读取由RevenueCat webhook驱动的本地
has_pro
标记,并在每次请求时检查该标记。

Phase 4: Verify

阶段4:验证

  • Android source contains only the public SDK key. Secret key grep is clean.
  • EntitlementVerificationMode
    is set (not
    DISABLED
    ) and telemetry for
    VerificationResult.FAILED
    is watched.
  • Real users are identified through
    Purchases.logIn
    . Anonymous ids are only used for pre login flows.
  • Every paid content endpoint on your server verifies entitlement through the REST API or a webhook driven flag. None trust a client header.
  • Android源码仅包含公开SDK密钥,搜索私密密钥无结果。
  • 已设置
    EntitlementVerificationMode
    (非
    DISABLED
    ),并监控
    VerificationResult.FAILED
    的遥测数据。
  • 通过
    Purchases.logIn
    识别真实用户,匿名ID仅用于登录前流程。
  • 服务器上所有付费内容端点均通过REST API或webhook驱动的标记验证权益,无端点信任客户端头信息。

Common mistakes

常见错误

MistakeWhy it hurtsFix
Shipping the secret key in the appAn attacker who decompiles the APK can call admin REST endpointsPublic key in app, secret key only on server
Leaving mode at
DISABLED
in production
A man in the middle can forge
CustomerInfo
responses
Set
INFORMATIONAL
or
ENFORCED
Treating the anonymous id as a credentialIt is not secret and is shared across users of the same deviceCall
logIn
with your authenticated user id
Trusting
customerInfo
from the client on the server
A tampered client can claim any entitlementVerify on the server via REST API or webhook driven DB
错误做法危害修复方案
在应用中打包私密密钥攻击者反编译APK后可调用管理员级REST端点应用中仅使用公开密钥,私密密钥仅存于服务器
生产环境中保留默认的
DISABLED
模式
中间人攻击者可伪造
CustomerInfo
响应
设置为
INFORMATIONAL
ENFORCED
模式
将匿名ID视为凭据它并非私密信息,会在同一设备的多个用户之间共享使用您的已认证用户ID调用
logIn
服务器端信任来自客户端的
customerInfo
被篡改的客户端可任意声称拥有某项权益通过REST API或webhook驱动的数据库在服务器端验证

References

参考资料