夜雨聆风学习资料网

ARTICLE · 1057071

iOS API Key 安全整改:Cloudflare Worker 与 App Attest 实战

iOS API Key 安全整改:Cloudflare Worker 与 App Attest 实战

iOS API Key 安全整改的关键,不是把密钥从安装包挪到远程配置,而是让客户端永远拿不到长期共享秘密。本文以 SpeechNote 的真实改造为例,把原来的“客户端取 Key + 通用代理”重构为“Supabase JWT + App Attest + D1 额度 + 服务端固定上游”,并说明它能解决什么、不能解决什么。

把 Key 放到远程配置,为什么仍然不安全

只要 App 能自动拿到一把长期 Key,逆向者通常也能拿到。Key 不在 IPA 里,并不等于 Key 不在客户端安全边界里。

这次 SpeechNote 整改目标:把部署、鉴权、额度和业务能力的控制权收回服务端。

这套方案是什么,又不是什么

这套方案本质上是一个面向移动端的受控 Backend for Frontend。它用 Cloudflare Worker 暴露少量业务路由,由服务端保存长期凭据、固定上游参数、验证用户与设备,并在调用付费服务前完成额度预占。

它不是一个“更隐蔽的 Key 中转站”,也不是给 /proxy 再套一层鉴权。只要客户端还能指定任意 URL、模型或请求体,攻击面就没有真正收口。

每个付费请求依次经过四道闸:

  1. 1. Supabase JWT:确认用户身份,并提供稳定的 sub 作为计量主体。
  2. 2. App Attest assertion:确认请求来自一个经过 Apple 背书的 App 实例,并绑定本次请求内容。
  3. 3. D1 额度与全局预算:同时限制用户、设备和供应商总成本。
  4. 4. 固定业务路由:模型、上游地址、提示词、输出上限和超时全部由服务端决定。

最终保留的是 /v1/ai/cleanup/v1/ai/polish/v1/asr/deepgram/token 等明确能力,而不是“给我一个 URL 和 body,我替你转发”的万能入口。

第一原理:客户端不可信,服务端只授予最小能力

移动端安全设计首先要接受一个事实:设备在用户手里,客户端代码、内存、网络请求和本地状态都可能被观察或修改。因此,服务端不能把“App 没有恶意”当作安全前提。

从这个前提出发,JWT、App Attest 和请求签名各自解决不同问题:

  • • JWT 证明“这个请求属于哪个用户”,但匿名注册成本很低,单独使用无法证明请求来自正版 App。
  • • App Attest 证明“这个密钥由目标 App 在 Apple 设备上生成并获得背书”,但它不是用户登录系统。
  • • assertion 把一次业务请求与设备私钥、challenge 和请求摘要绑定,用递增 counter 降低重放风险。
  • • 服务端固定业务能力,即使一个合法用户控制自己的设备,也不能借接口访问任意上游资源。

Apple 的服务端验证流程要求校验证书链、attestation nonce、App ID 哈希、AAGUID、credentialId 和初始 counter。后续 assertion 还要验证签名和严格递增的 counter。任何一步省略,都会让“设备可信”退化成一个形式检查。

App Attest 服务端怎么落地

App Attest 的正确边界是“注册一次,敏感请求按需签名”。注册阶段建立设备公钥信任,业务阶段不再请求 Apple,而是由服务端使用已保存的公钥验证 assertion。

注册阶段:校验的不只是证书链

客户端先向服务端申请 32 字节随机 challenge,再调用 generateKey() 和 attestKey()。服务端收到 attestation 后至少执行这些检查:

  1. 1. CBOR 格式和 fmt 是否为 apple-appattest
  2. 2. 凭证证书和中间证书能否链到 Apple App Attestation Root CA。
  3. 3. SHA256(authData || clientDataHash) 是否等于证书扩展中的 nonce。
  4. 4. 公钥哈希是否等于客户端提交的 keyId
  5. 5. rpIdHash 是否等于 SHA256(TeamID.BundleID)
  6. 6. AAGUID 是否符合 development 或 production 环境。
  7. 7. credentialId 是否等于 keyId,初始 counter 是否为 0。

全部通过后,服务端才原子消费 challenge,并把设备公钥、所属用户和环境写入 D1。

Cloudflare Workers 不是完整的 Node.js 运行时。这个项目只需要解析 CBOR 中的少量类型,以及 X.509 中的证书签名、SPKI、有效期和特定扩展,因此没有引入一套依赖 Node crypto 的大型库,而是写了受限解析器,把签名验证交给 WebCrypto。

这里有一个容易忽略的格式差异:X.509 证书中的 ECDSA 签名通常是 DER 编码,WebCrypto 验签需要固定长度的 r || s。转换代码必须剥离整数前导零,再把 r 和 s 左侧补齐到曲线长度。

export function derSignatureToRaw(signature: Uint8Array, size: number) {  const [r, s] = children(readTlv(signature).value)  const output = new Uint8Array(size * 2)  for (const [index, integer] of [r, s].entries()) {    let bytes = integer.value    // DER INTEGER 为避免负数可能带一个 0x00 前缀    while (bytes.byteLength > 0 && bytes[0] === 0) {      bytes = bytes.subarray(1)    }    if (bytes.byteLength > size) {      throw new Error('ECDSA integer is too large')    }    output.set(bytes, index * size + size - bytes.byteLength)  }  return output}

受限解析器的价值不是“少装一个包”,而是让可接受输入和失败路径都可审计。代价也很明确:它不能被当成通用 CBOR 或 X.509 库,遇到未支持的编码必须直接拒绝。

业务阶段:method、path 和 body 都要进签名

业务请求先领取一个两分钟有效、只能使用一次的 challenge,然后构造规范化内容:

clientData = utf8(  "SpeechNote-Assertion-v1\n" +  METHOD + "\n" +  PATH_WITH_QUERY + "\n" +  challenge + "\n" +  hex(SHA256(requestBodyBytes)))

客户端调用 generateAssertion(keyId, SHA256(clientData)),服务端则用注册时保存的公钥验签。路径必须包含查询参数,否则攻击者可以保留签名、只修改 language 等参数;body 使用摘要,避免对大音频重复复制。

counter 更新要和重放判断合在一条 SQL 中:

const result = await env.DB.prepare(  `UPDATE app_attest_keys   SET counter = ?1, last_seen_at = ?2   WHERE key_id = ?3     AND counter < ?1     AND revoked_at IS NULL`,).bind(counter, now, keyId).run()if (result.meta.changes !== 1) {  throw new ApiError(403, 'assertion_invalid', 'Assertion replayed')}

challenge 一次性消费与 counter 递增是两道独立防线。只测“同一个 assertion 再发一次”并不能证明 challenge 消费真的有效,这一点在后面的变异测试中暴露得很清楚。

JWT、短期凭据与固定上游如何配合

用户身份采用 Supabase JWT,但服务端只信验证结果,不信客户端自己提交的 userId 或 isPro。使用非对称签名时,Worker 从项目的 JWKS 地址取得公钥,并校验签名、issexpsub 和允许的角色;这避免把能够签发 token 的共享秘密分发给每个后端组件。

上游接入按能力分成三类:

场景
服务端策略
客户端得到什么
AI 文本生成
Worker 代理调用,固定模型、提示词、输入和输出上限
业务结果
Deepgram 实时转写
Worker 用长期 Key 请求临时 JWT
默认 30 秒有效的短期 token
腾讯云实时 ASR
Worker 生成一次性签名 WSS URL
约 60 秒有效的连接地址

短期凭据不是“绝对安全”,但泄露窗口和可用范围都明显缩小。更重要的是,签发动作本身仍要经过 JWT、App Attest 和额度检查,不能把临时 token 接口做成公开兑换器。

固定上游时还要保证业务行为不漂移。项目把 Swift 多行字符串中的 7 个提示词按语言规则抽取,再与 TypeScript 常量逐字节比较;腾讯云签名则用固定时间戳和 nonce,让 Swift 版本产生标准答案,再锁进 TypeScript 测试。

这一步发现了一个真实坑:JavaScript 的 replaceAll() 会解释替换串中的 $&,而 Swift 的纯文本替换不会。最终改用 split().join(),并增加包含 $& 的测试用例。安全重构如果悄悄改变输出,用户感知到的仍然是故障。

D1 配额:精确计量不能只靠 Rate Limiting

Cloudflare Rate Limiting binding 适合限制秒级或分钟级突发请求,但官方明确说明它是按 Cloudflare 位置生效、最终一致且偏宽松的,不能当作精确计费系统。用户日额度和供应商全局预算因此落到 D1。

最容易写错的是“先查余额,再累加用量”。两个并发请求可能同时看到还剩一次,然后都被放行。修复方式是把条件判断和写入合成一条语句:

INSERT INTO usage_counters (  period, user_id, attest_key_id, operation, requests, units)SELECT ?1, ?2, ?3, ?4, 1, ?5WHERE (  SELECT COALESCE(SUM(requests), 0)  FROM usage_counters  WHERE period = ?1 AND user_id = ?2 AND operation = ?4) + 1 <= ?6AND (  SELECT COALESCE(SUM(units), 0)  FROM usage_counters  WHERE period = ?1 AND user_id = ?2 AND operation = ?4) + ?5 <= ?7ON CONFLICT (period, user_id, attest_key_id, operation)DO UPDATE SET  requests = requests + 1,  units = units + excluded.units

meta.changes !== 1 就返回 429。记录按 (user, key) 分行、按 user 汇总,这样同一账号多台设备共享额度,同时仍能定位异常设备。

配额在上游调用前预占;上游失败时释放日额度,但分钟级速率限制不退还,防止攻击者用失败请求无限打上游。供应商全局预算独立存在,避免匿名账号批量注册后绕过单用户额度。

最小可运行路径:先 staging,再接真机

这套方案的最小交付不是“Worker 部署成功”,而是 staging 中的配置、数据库、鉴权失败路径和限流都能被观察,然后再接入真机 App Attest。

前置条件

  • • Node.js 使用当前受支持的 LTS 版本。
  • • 项目安装 Wrangler 4、Vitest 4.1 及以上和 @cloudflare/vitest-plugin
  • • Cloudflare 账户能创建 Worker、D1 和 Rate Limiting binding。
  • • iOS App 已有 App ID,并启用 App Attest capability。
  • • 上游凭据使用 staging 专用 Key,且只写入 Secret。

最小部署命令

# 登录并确认账户pnpm wrangler loginpnpm wrangler whoami# 创建 staging 数据库;区域创建后不能修改pnpm wrangler d1 create speechnote-api-staging --location apac# 应用远程迁移pnpm wrangler d1 migrations apply \  speechnote-api-staging \  --env staging \  --remote# 逐项写入 Secret,值不会出现在 wrangler 配置中pnpm wrangler secret put DEEPGRAM_API_KEY --env staging# 部署脚本应先执行 typecheck 和 testpnpm deploy:staging

wrangler.toml 中的 D1 binding、普通变量和 Rate Limiting binding 要在每个 environment 下显式声明,因为这些配置不会自动继承。部署命令也必须带 --env staging 或 --env production,避免生成一个缺少 binding 的顶层 Worker。

如何判断部署真的成功

本文素材中的 staging 结果为:D1 迁移创建 5 张业务表,部署前 typecheck 通过,55 个测试全部通过;线上冒烟得到以下响应:

GET  /v1/bootstrap             -> 200POST /proxy                    -> 404POST /v1/ai/cleanup 无 token   -> 401GET  /v1/ai/cleanup            -> 405ASR 接口使用伪造 token         -> 401

这些结果只证明公开路由、方法限制和基础鉴权边界符合预期,不证明真机 App Attest 或生产凭据可用。真正的完成标准还包括:真机注册、请求体篡改、assertion 重放、短期凭据、上游调用、Archive/IPA 密钥扫描和 production 冒烟全部通过。

最常见的两个首发错误也很具体:Secret 误填成普通 Text 变量,下一次部署后被配置覆盖;复制 Key 时带入空格或换行,上游返回 401,Worker 对外表现成 502。排查时先核对 Secret 名称和长度,不要把值打印到日志。

55 个测试之外:用变异测试检查安全断言

测试全绿只能证明实现满足现有断言,不能证明断言覆盖了预期安全边界。

项目最初有 53 个测试。为了验证测试是否真的有用,我把关键检查逐个故意改坏:删除 attestation nonce 校验、跳过 assertion 签名、放宽额度、取消失败回滚、改坏腾讯云百分号编码。大部分变异都会让对应测试失败。

但删除 challenge 一次性消费时,53 个测试仍然全绿。原因是重放测试原样复用了 assertion,counter 检查已经提前拒绝它,challenge 逻辑从未被真正命中。

随后补了两类用例:同一 challenge 配合更高 counter 重新签名,以及使用属于另一个用户的 challenge。测试数从 53 增加到 55,再删除消费逻辑时,新用例会稳定失败。

这个失败案例给出的工程结论很明确:安全测试不能只覆盖“坏请求被拒绝”,还要确认它是被预期的那道防线拒绝。否则多道校验可能互相遮蔽,某一层已经失效却长期不被发现。

这套整改仍然解决不了什么

把长期 Key 留在服务端,只是建立安全边界,不会自动解决所有滥用和运营问题。

  • • App Attest 不是万能反作弊。合法设备仍可能由恶意用户控制,服务端还需要行为分析、预算和封禁能力。
  • • 匿名账号可以批量注册。用户额度不是最后防线,全局预算和设备维度限制必须独立存在。
  • • 腾讯云一次性 WSS URL 发给客户端后,Worker 不在 WebSocket 数据链路上,只能近似控制并发会话。
  • • App 重装、设备迁移或恢复备份后,App Attest key 可能失效,客户端要允许一次受控的重新注册。
  • • StoreKit 会员身份不能相信客户端布尔值。服务端 entitlement 验证没完成前,只能采用统一且保守的上限。
  • • staging 的 55 个测试与 HTTP 冒烟不能替代 TestFlight 和生产真机验证。本文素材记录的 production 部署尚未完成。

回滚策略也必须受安全边界约束:新版 Worker 出故障时,可以回滚到上一个仍然“密钥仅在服务端”的版本,但不能重新开放旧配置接口或恢复长期密钥下发。功能暂时不可用,比重新暴露生产凭据更容易补救。

一份可直接复用的上线检查表

  • • IPA 和 Archive 中搜不到第三方长期生产密钥与旧共享 token。
  • • 配置接口不返回 apiKeysecretKey 或长期 token。
  • • 无 JWT 返回 401,无效 App Attest 返回 403
  • • 修改 method、path、query 或 body 后,旧 assertion 失效。
  • • challenge 只能使用一次,counter 必须严格递增。
  • • 通用代理路由不存在,客户端不能指定模型、上游 URL 和输出上限。
  • • 超出用户额度返回 429,且没有调用上游。
  • • 全局预算触发后停止付费调用,并产生可观察告警。
  • • Deepgram 只向客户端发短期 JWT,腾讯云只发短期签名 URL。
  • • staging 与 production 使用独立数据库、Secret 和部署命令。
  • • 关闭旧接口前,已验证新版本可从目标 App Store 区域下载并通过真机冒烟。
  • • Git 历史中曾出现的密钥已在服务商控制台吊销;从代码中删除不等于凭据失效。

结论

移动端 API 安全的目标不是把 Key 藏得更深,而是重划信任边界:客户端只表达业务意图,服务端决定身份是否可信、设备是否可信、这次请求是否完整、额度是否足够,以及允许调用哪一种上游能力。

Cloudflare Worker、Supabase JWT、App Attest 和 D1 分别承担边缘执行、用户身份、设备与请求完整性、精确额度。它们组合起来能显著提高滥用成本,但仍需要真机验证、全局预算、凭据轮换和受约束的回滚策略,才能形成可上线的安全闭环。

参考资料

  • • Validating apps that connect to your server,Apple Developer[1]
  • • Workers Best Practices,Cloudflare Developers[2]
  • • Rate Limiting,Cloudflare Workers[3]
  • • JWT Signing Keys,Supabase Docs[4]
  • • Token-Based Authentication,Deepgram Docs[5]

如果你也在给 iOS App 接入 AI 或语音服务,欢迎在评论区说说你目前怎样管理上游凭据、限额和设备校验。把真实踩坑留下来,可能会帮到其他开发者。

如果这篇文章对你有帮助,也欢迎点个“在看”或转发给正在做移动端后端安全的朋友。

关注沐风,不定期更新,全是干货。

2026.09.22 12:29

沪 · 赵巷

引用链接

[1] Validating apps that connect to your server,Apple Developer: 

https://developer.apple.com/documentation/devicecheck/validating-apps-that-connect-to-your-server

[2] Workers Best Practices,Cloudflare Developers: 

https://developers.cloudflare.com/workers/best-practices/workers-best-practices/

[3] Rate Limiting,Cloudflare Workers: 

https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/

[4] JWT Signing Keys,Supabase Docs: 

https://supabase.com/docs/guides/auth/signing-keys

[5] Token-Based Authentication,Deepgram Docs: 

https://developers.deepgram.com/reference/auth/tokens/grant

相关学习资料