先看结论与判断条件
- 原生移动应用属于公共客户端,不能靠嵌入同一份长期共享秘密证明请求来自正版安装包。
- 密钥改名、拆分、Native 化、运行时拼接和代码混淆只能提高查找成本,不能建立可撤销的服务端授权边界。
- AI 厂商长期凭据应保存在服务端,移动端先完成用户授权,再取得面向具体资源服务器的短期限权令牌。
- 资源服务器必须验证签发者、受众、有效期、权限范围、客户端与主体,并拒绝把格式正确等同于授权正确。
- DPoP 或等价持有者绑定可降低被盗访问令牌脱离原客户端重放的风险,但不证明设备绝对可信。
- 构建门禁、运行时日志过滤、密钥轮换和用量隔离要共同工作,发现泄露后应立即撤销而不是等待下个版本。
原生 App 无法保守随包分发的共享秘密
移动应用要在用户设备上自动调用 AI 服务,就必须在某个时刻取得凭据的可用表示。若凭据直接写在源码、资源、构建变量、Native 库或运行时拼接逻辑中,安装包或进程最终都包含恢复它所需的信息。攻击者不需要理解全部业务,只要复制有效请求所需的长期值,就能在应用之外调用接口。工程判断是把任何随所有安装实例共同发布、无需服务端再次授权即可使用的值视为公开标识,而不是秘密。
RFC 8252 OAuth 2.0 for Native Apps 将原生应用视为公共客户端,推荐通过外部 user-agent 和 PKCE 完成授权,而不是依赖客户端秘密。这个边界直接解释了为什么把 AI API Key 放进 App 不成立:同一二进制交付给大量设备,无法证明某次携带共享 Key 的请求来自哪个合法用户、哪次安装或哪个业务授权。Key 一旦泄露,单纯更新客户端不能阻止旧版本继续被提取和重放。
公开客户端并不等于移动端无法安全访问 AI 服务,而是凭据职责要重划。客户端可以持有公开 client_id、一次性授权状态、PKCE verifier、设备生成私钥和短期访问令牌;AI 厂商主密钥、计费账户凭据、模型管理权限和跨租户能力保留在受控服务端。服务端对每次请求重新验证用户、应用版本、授权范围、配额与风险信号,再决定是否代理或签发受限凭据。
| 值 | 是否可随 App 发布 | 正确用途 | 不能承担 |
|---|---|---|---|
| client_id | 可以公开 | 标识 OAuth 客户端 | 证明调用者持有秘密 |
| AI 厂商 API Key | 不应发布 | 服务端调用或受控签发 | 移动安装实例授权 |
| PKCE verifier | 单次授权临时存在 | 绑定授权请求与换码 | 长期 API 调用凭据 |
| 短期 access token | 按会话下发 | 访问限定受众与权限 | 永久离线秘密 |
| 设备生成私钥 | 私钥不离开安全存储 | 签名或持有者绑定 | 证明用户业务权益 |
| 模型和端点短标识 | 可以公开 | 选择已批准资源 | 替代服务端权限检查 |
隐藏、拆分和 Native 化只改变提取成本
团队常见的补救是把 Key 拆成多段、做字符串加密、放入 so、从多个函数拼接,或在启动后才解密。这些措施可以降低静态搜索直接命中的概率,却没有改变核心事实:应用若能在无人干预时构造有效请求,运行时就必须得到明文或等价认证材料。观察请求构造点、授权头或厂商 SDK 调用,仍可能获得可重放值。
Android Keystore 能限制密钥用途,并在支持的设备上使用硬件保护。它适合保存设备生成私钥、用户解锁绑定密钥或本地数据加密密钥,但不能神奇地保护一份需要预置给所有安装实例的共享 API Key。若应用先从资源解出厂商 Key 再存入 Keystore,首次导入前仍有明文;若 Keystore 中的密钥直接用于生成服务端可接受签名,服务端仍需登记公钥、验证主体和限制权限。
OWASP MASVS-RESILIENCE 把抗逆向和抗篡改放在纵深防御位置。代码混淆、完整性检查和运行时保护可以增加批量分析成本,帮助保护业务逻辑,却不能替代服务端授权和完整发布链。工程判断是继续使用加固降低自动化滥用效率,同时按“客户端秘密终会暴露”设计权限、有效期、撤销和用量限制,避免把全部安全责任压在隐藏字符串上。
| 做法 | 可能提高的成本 | 仍可出现的位置 | 必须补充的控制 |
|---|---|---|---|
| 字符串拆分 | 降低简单关键字搜索 | 拼接结果和请求参数 | 服务端短期授权 |
| 本地加密 | 降低资源文件直接读取 | 解密点和使用点 | 不下发长期厂商密钥 |
| Native 化 | 提高部分静态分析成本 | JNI 边界、内存和网络调用 | 权限收缩与撤销 |
| 代码混淆 | 降低符号和控制流可读性 | 运行时参数与 SDK 调用 | 服务端最终决策 |
| Keystore 导入共享 Key | 限制导入后的直接导出 | 导入前明文和认证结果 | 设备独立密钥与公钥登记 |
| 证书固定 | 限制部分网络中间人路径 | 被控制客户端的调用逻辑 | 令牌受众和持有者绑定 |
正确架构是客户端授权加服务端代理
客户端不应直接持有 AI 厂商的主账户凭据。用户先通过标准登录或设备授权与业务服务建立身份,业务服务根据租户、套餐、模型、地区、应用版本和风险策略判断是否允许请求。允许后,服务端可以代理调用 AI 厂商,也可以签发只面向专用资源服务器的短期访问令牌。厂商 Key 留在秘密管理系统中,只由受限工作负载读取。
代理层不仅隐藏 Key,还把商业和安全边界集中到服务端。它可以限制模型集合、输入大小、并发、调用频率、数据保留、租户隔离和成本预算,并为每个业务主体记录独立用量。客户端即使被修改,也只能发起服务端允许的请求,不能利用主 Key 调用模型管理、文件管理或其他租户资源。具体权限和预算必须来自项目策略,文章不能凭空给出通用阈值。
服务端代理也不是自动安全。若代理接受任意上游 URL、任意模型名或客户端自报租户,攻击面只是从 Key 泄露变成代理滥用。接口应使用明确 schema、服务端映射和资源所有权检查,拒绝自由形式的厂商参数;日志删除授权头和用户敏感输入;错误响应不返回上游凭据、内部端点或完整调试信息。
| 决策维度 | 可信输入 | 允许动作 | 失败默认 |
|---|---|---|---|
| 用户与租户 | 服务端会话和账号记录 | 访问所属租户资源 | 拒绝客户端自报身份 |
| 模型范围 | 服务端批准目录 | 选择已授权模型短标识 | 拒绝任意厂商模型名 |
| 权限 scope | 产品策略和角色 | 仅执行具体推理动作 | 不继承主账户权限 |
| 请求资源 | schema 与所有权检查 | 限制输入、文件和工具 | 拒绝未知字段与跨租户引用 |
| 用量与风险 | 服务端计数和风险状态 | 在预算内处理或降级 | 不信任本地计数 |
| 上游凭据 | 秘密管理系统 | 由受限工作负载使用 | 绝不返回客户端 |
原生应用授权使用外部浏览器和 PKCE
RFC 8252 建议原生应用通过外部 user-agent 执行 OAuth 授权,让用户在系统浏览器或安全浏览上下文中与授权服务器交互,并使用 PKCE 绑定授权请求和换码。App 生成高熵 verifier,仅在本次授权事务中保存,发送其变换后的 challenge;授权码被其他应用截获时,没有原 verifier 也不能正常换取令牌。
重定向 URI 需要精确注册和验证,应用链接、通用链接或私有 scheme 各有平台边界。RFC 9700 OAuth Security BCP 进一步讨论授权码注入、重定向匹配、令牌重放和不安全 grant。工程判断是弃用资源所有者密码等让客户端直接收集用户凭据的路径,使用 state 或等价事务绑定,授权服务器严格匹配 redirect_uri,并把授权码限定为短期和一次性。
OAuth 登录解决用户授权流程,不直接决定 AI 业务 scope。授权服务器签发前仍要将用户、客户端、租户和允许模型映射为最小权限。原生 App 不能用 client_secret 弥补缺失的业务校验,因为相同 secret 会随安装包传播。任何需要证明“这是某次合法安装”的额外信号,都应在服务端验证,并作为风险信号而不是用户权益本身。
- 使用外部 user-agent 而非嵌入式页面收集账号凭据
- 每次授权生成新的 PKCE verifier 和 state
- 授权服务器严格匹配已登记重定向 URI
- 授权码短期、一次性并与客户端事务绑定
- 不使用安装包内 client_secret 证明客户端身份
- 业务 scope 由服务端账号与租户策略决定
短期令牌要验证受众、权限和主体
RFC 9068 JWT access token profile 说明 JWT access token 可以携带 issuer、audience、expiry、scope 和 client 标识,供资源服务器执行验证。资源服务器不能只检查签名格式,还要确认签发者来自受信配置、audience 包含自身、当前时间在有效期内、scope 覆盖本次动作,并把 subject、client_id 与业务资源所有权结合。
短期不应只写在文档里。授权服务为每类客户端设定最大有效期,资源服务器拒绝超出策略的令牌;高风险操作使用更窄 scope 或一次性授权;撤销、账号冻结和租户状态由服务端实时检查。JWT 自包含可以减少查询,却可能延迟权限变化,因此项目要在有效期、状态查询和缓存之间做明确选择,不能把“格式是 JWT”当成安全结论。
令牌也不应进入 URL、崩溃报告、分析事件或自由文本错误。客户端将 bearer token 放在标准授权头,通过系统安全存储保存必要会话状态,并在退出、撤销或设备风险变化后删除。日志只记录受控的 token 标识摘要、签发者、受众验证结果和错误类别,不记录完整值。没有真实签发与资源服务器配置时,只能说明设计方法,不能宣称令牌策略已经生效。
| 检查项 | 要回答的问题 | 失败动作 | 常见误判 |
|---|---|---|---|
| 签发者 | 令牌是否来自受信授权服务 | 拒绝并记录原因码 | 任何可验证签名都接受 |
| 受众 | 令牌是否签给当前 API | 拒绝跨服务重放 | 只看 scope 不看 audience |
| 有效期 | 当前时间是否在策略窗口 | 拒绝过期或异常长令牌 | JWT 默认短期 |
| 权限范围 | 是否允许具体模型与动作 | 拒绝过宽或缺失 scope | 登录等于全权限 |
| 主体与客户端 | 谁以哪个客户端访问 | 绑定业务资源所有权 | 信任请求体自报用户 |
| 状态与撤销 | 账号或会话是否仍有效 | 停止处理并要求重授权 | 只依赖自包含声明 |
持有者绑定降低令牌脱离客户端重放
普通 bearer token 谁拿到谁能用。RFC 9449 OAuth DPoP 通过客户端持有的非对称密钥生成每次请求证明,把 access token 与公钥绑定,并在证明中关联 HTTP 方法、目标 URI、时间和唯一标识。资源服务器同时验证 token 绑定与 DPoP proof,可以降低被盗 token 在没有对应私钥时被脱离原客户端重放的风险。
移动端可为每次安装或账号生成独立私钥,并用 Android Keystore 限制签名用途和直接导出。私钥身份需要由授权服务器在合法流程中登记,公钥轮换、应用重装、设备迁移和凭据恢复都有明确状态机。若把同一私钥或私钥材料预置到全部安装包,持有者绑定就退化为另一份共享秘密。
DPoP 不是设备可信证明。被控制的应用进程可能请求 Keystore 为恶意请求签名,服务端仍需验证用户、scope、资源所有权、风险与用量;短有效期和撤销仍然必要。工程判断是把持有者绑定用于缩小 token 被复制到其他环境的重放面,而不是把它描述成能够阻止所有本机滥用。
| 维度 | bearer token | 持有者绑定 token | 共同需要 |
|---|---|---|---|
| 使用条件 | 持有 token 即可请求 | 还需对应私钥证明 | TLS 与服务端授权 |
| 跨环境复制 | 可能直接重放 | 缺少私钥时应失败 | 短有效期与撤销 |
| 客户端密钥 | 不要求请求签名密钥 | 每安装或主体独立生成 | 安全存储与轮换 |
| 设备可信 | 不能证明 | 仍不能绝对证明 | 风险信号和业务校验 |
| 本机恶意调用 | 可能发生 | 进程仍可能请求签名 | scope、配额和行为控制 |
| 运维复杂度 | 相对较低 | 增加 nonce、时钟和密钥状态 | 可观测失败原因 |
构建门禁要同时扫描凭据形态和令牌策略
禁止把 Key 放进 App 需要自动门禁,而不是依赖代码评审记忆。流水线可从最终 APK、AAB、IPA、资源、Native 字符串和生成配置中提取公开安全的字符串清单,再检查厂商 Key 前缀、Authorization 样式、私钥头、构建变量名和高熵长值。命中结果只输出文件位置与规则标识,不在普通日志回显完整疑似秘密,避免扫描器本身造成二次泄露。
下面示例读取一份经过脱敏的发布检查 JSON。artifactFindings 只包含规则名和位置,tokenPolicy 则声明允许受众、允许 scope、最大有效期与一次示例令牌声明。脚本在存在任意构建凭据命中、受众错误、权限超集或有效期超过项目策略时失败。它不会读取真实安装包、验证 JWT 签名或处理生产凭据,真实流水线应由专用提取器产生 findings,并在隔离环境验证签发配置。
门禁结果必须绑定最终发布候选摘要。Debug 包未命中不能证明 Release、渠道重打包或后续配置注入也未命中;任何重新构建、重签名或资源替换都需要重跑。允许列表只接受明确的公开 client_id、测试占位符或已撤销样本,并记录负责人和到期时间,不能用大范围正则排除整个配置目录。
- 扫描最终发布候选而不是仅扫描源码
- 覆盖资源、生成配置和 Native 字符串
- 扫描日志不回显完整疑似凭据
- 令牌受众、scope 与有效期都有项目策略
- 允许列表逐项登记负责人和到期时间
- 任何重构建或渠道处理后重新执行门禁
from pathlib import Path
import json
import sys
if len(sys.argv) != 2:
raise SystemExit("usage: check_credentials.py release-check.json")
source = Path(sys.argv[1])
if not source.is_file():
raise SystemExit("release check file does not exist")
payload = json.loads(source.read_text(encoding="utf-8"))
findings = payload.get("artifactFindings")
policy = payload.get("tokenPolicy")
claims = payload.get("sampleClaims")
if not isinstance(findings, list) or not isinstance(policy, dict):
raise SystemExit("artifactFindings and tokenPolicy are required")
if not isinstance(claims, dict):
raise SystemExit("sampleClaims are required")
failures = []
for finding in findings:
rule = finding.get("rule")
location = finding.get("location")
if not rule or not location:
failures.append("credential finding lacks rule or location")
else:
failures.append(f"credential-shaped value detected by {rule} at {location}")
allowed_audiences = set(policy.get("allowedAudiences", []))
allowed_scopes = set(policy.get("allowedScopes", []))
max_lifetime = policy.get("maxLifetimeSeconds")
audience = claims.get("aud")
scopes = set(claims.get("scope", "").split())
issued_at = claims.get("iat")
expires_at = claims.get("exp")
if audience not in allowed_audiences:
failures.append(f"token audience is not approved: {audience!r}")
if not scopes or not scopes <= allowed_scopes:
failures.append("token scope is empty or exceeds the approved set")
if not all(isinstance(value, int) for value in (issued_at, expires_at, max_lifetime)):
failures.append("token lifetime fields must be integers")
elif expires_at <= issued_at or expires_at - issued_at > max_lifetime:
failures.append("token lifetime exceeds the release policy")
if failures:
print("credential release gate failed:")
for failure in failures:
print(f"- {failure}")
raise SystemExit(2)
print("artifact and short-lived token policy checks passed")发现已发布 Key 后先撤销再修版本
如果长期 Key 已进入发布包,应按已泄露处理,而不是先提交一个删除字符串的新版本。首要动作是在厂商或秘密管理侧撤销、轮换并限制旧 Key,检查异常用量、模型调用、文件操作和账单影响;然后临时收紧服务端配额与允许模型。客户端商店更新无法覆盖所有已安装版本,也不能让公开仓库、缓存和第三方镜像中的旧包消失。
修复架构时把厂商 Key 移到服务端代理,引入用户授权、短期令牌、最小 scope、资源所有权检查和独立用量归属。旧客户端如果仍直接调用厂商 API,应在服务端或厂商侧逐步拒绝旧凭据,并设计可理解的升级提示,不能为了兼容永久保留已泄露 Key。调查记录保存候选摘要、Key 身份、撤销时间、观察窗口和确认边界,不保存完整 Key。
最终结论要限定证据范围,例如指定 Release 候选未命中已知凭据规则、服务端签发策略满足列出的受众与 scope 检查,而不是“密钥永远无法提取”。准备评估时,可通过御盾中央平台提交脱敏后的架构图、最终候选、凭据清单、令牌策略和资源服务器验证规则,先固定检查对象与授权边界。
- 已发布凭据立即撤销或限制而非等待发版
- 检查厂商用量、账单和高风险操作
- 修复版本不继续兼容已泄露主 Key
- 旧客户端迁移具有明确截止和拒绝策略
- 调查材料不复制完整秘密
- 最终结论绑定具体候选与服务端配置
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 原生应用属于公共客户端,不应依赖随安装包分发的客户端秘密。 | RFC 8252 OAuth 2.0 for Native Apps 描述原生应用公共客户端边界以及外部 user-agent 与 PKCE。 | RFC 8252 不定义具体 AI API 的业务 scope、计费、模型权限或令牌时长。 |
| OAuth 部署需要处理重定向、授权码注入、令牌重放和不安全授权流程。 | RFC 9700 OAuth Security BCP 汇总当前 OAuth 部署的安全建议与已知风险。 | 安全 BCP 不替代具体 AI 服务的商业权限、租户隔离和资源所有权策略。 |
| DPoP 可以把访问令牌与客户端持有的密钥绑定以降低脱离客户端的重放风险。 | RFC 9449 OAuth DPoP 定义 proof、密钥绑定和请求关联的处理方式。 | DPoP 不能证明设备绝对可信,也不替代短有效期、撤销、scope 和服务端授权。 |
| JWT 访问令牌可携带签发者、受众、有效期、权限和客户端标识供资源服务器验证。 | RFC 9068 JWT access token profile 描述相关声明和资源服务器处理边界。 | JWT 格式本身不保证令牌短命、签发策略正确或用户仍然拥有资源权限。 |
| Android Keystore 可以限制密钥用途,并在可用设备上采用硬件保护。 | Android Keystore 描述不可导出密钥材料、用途限制和硬件安全能力。 | Keystore 不保护应用使用时的明文 API Key,也不把共享 Key 变成安装实例独立秘密。 |
| 移动端抗逆向和抗篡改属于纵深防御,不能替代服务端授权。 | OWASP MASVS-RESILIENCE 将相关控制放在移动应用韧性与纵深防御范围。 | 控制目录不证明某个候选包达到特定强度,也不阻止所有运行时凭据观察。 |
| AI 厂商长期凭据应保存在服务端,客户端只取得业务所需短期限权凭据。 | 工程判断:服务端保管主凭据并重新执行授权,可以限制共享秘密泄露后的跨用户和跨模型影响。 | 具体代理方式、scope、有效期与配额必须依据项目账户、模型和风险证据设置。 |
| 构建产物扫描与线上撤销必须共同覆盖凭据泄露处置。 | 工程判断:扫描减少新凭据进入候选,撤销处理已经公开且无法从所有旧安装包收回的值。 | 规则未命中不证明不存在未知凭据形态,撤销完成也需要厂商用量与访问回执确认。 |
工程常见问题
把 API Key 加密后放进 App 是否安全?
不能建立长期秘密边界。App 必须拥有解密和使用所需信息,运行时仍会出现明文或等价认证材料。加密可提高静态查找成本,但主 Key 仍应移到服务端。
把 Key 放进 Native so 会比资源文件安全吗?
只会增加部分静态分析成本。请求构造、厂商 SDK 调用和运行时内存仍可能暴露可用值。Native 化不能代替短期令牌、服务端授权和撤销。
Android Keystore 能否保存所有安装共用的 AI API Key?
可以限制导入后的使用,但无法解决共享 Key 如何安全进入每台设备,也不能区分合法业务主体。更合适的用途是设备独立私钥、本地数据密钥和持有者绑定。
客户端只调用自家代理是否就足够安全?
不够。代理仍要验证用户、租户、模型、scope、资源所有权、用量和风险,拒绝任意上游参数,并避免把厂商错误、令牌或敏感输入写入日志。
DPoP 是否意味着访问令牌被盗也完全无法滥用?
不能这样承诺。它降低缺少对应私钥时的跨环境重放,但被控制的本机进程可能请求签名。仍需短有效期、撤销、最小 scope、服务端授权和行为控制。
申请移动 AI 凭据安全评估需要准备什么?
准备最终安装候选、脱敏架构图、厂商凭据使用位置、OAuth 流程、令牌策略、资源服务器校验和撤销方案,再通过御盾中央平台提交申请。