第4章 服务端-客户端通信模型:nonce生成策略、服务端签名流程、客户端请求结构、典型时序图

在 Play Integrity 验证中,服务端与客户端之间的安全通信模型是整个防篡改链路的核心。本章从 nonce 随机数生成、服务端签名请求、客户端请求组装到完整时序流程,逐层拆解可信通信的每个环节。

4.1 Nonce 生成策略

Nonce(Number used once)用于防止重放攻击,确保每个 Integrity 只绑定一次特定的请求。生成策略需满足:不可预测性、服务端可控性、时间窗口约束。

属性推荐实现方式安全要点
熵源服务端 CSPRNG(如 /dev/urandom、SecureRandom)禁止使用客户端传入的固定值或时间戳
长度至少 32 字节(256 位)输出 Base64 URL-safe 编码,约 43 字符
唯一绑定nonce = HMAC(secret, request_context)绑定用户会话、时间戳、请求哈希,防止碰撞
过期服务端存储 nonce 并设置 TTL(建议 60 秒)验证后立即丢弃,或标记已使用
提升熵的安全实践:服务端生成 nonce 后,额外对 nonce 做一次 HMAC-SHA256 签名的哈希摘要再下发,使客户端即使反向也无法推导出原始随机种子。

4.2 服务端签名流程

服务端不直接向客户端暴露 raw nonce,而是构造一个签名后的挑战(challenge)。该挑战需包含 nonce、时间戳、请求标识,并由服务端私钥签名。

4.2.1 签名数据结构

// 服务端签名负载 JSON
{
  "nonce": "aGVsbG8gd29ybGQ...",     // 32字节随机nonce
  "timestamp": 1712345678,           // Unix 秒
  "requestId": "order_98765",        // 本次业务标识
  "expiry": 60                       // 有效秒数(可选)
}
// 服务端使用 Ed25519 或 HMAC-SHA256 签名
// 示例:HMAC-SHA256 方式(共享密钥)
server_secret = "32-byte-secret-key"
signature = HMAC-SHA256(server_secret, canonical_json(load))
challenge = base64url(load) + "." + base64url(signature)

4.2.2 签名验证逻辑(服务端回查时)

  • 解析客户端回传的 challenge,拆出负载与签名。
  • 用服务端 secret 或公钥重新计算 HMAC 或验签。
  • 检查 nonce 是否在服务端已使用表(Redis / 内存)中未被消费。
  • 检查 timestamp 是否在允许的时间偏移内(±30 秒)。
常见错误:nonce 重用— 服务端未记录已用的 nonce 导致重放攻击。务必在验证成功后立即将 nonce 标记为已消费,或使用支持原子删除的缓存(如 Redis SETNX + EXPIRE)。

4.3 客户端请求结构

客户端收到服务端下发的 challenge 后,将其作为 Play Integrity API 的 nonce 参数(注意:官方要求 nonce 字段必须是 Base64 URL-safe 字符串)。

4.3.1 Android 客户端请求代码

// Kotlin / Android
val integrityManager = IntegrityManagerFactory.create(context)
val serverChallenge = "base64load.base64signature" // 来自服务端

// 设置 IntegrityTokenRequest
val request = IntegrityTokenRequest.builder()
    .setNonce(serverChallenge)   // 直接使用服务端签名的challenge字符串
    .build()

// 请求令牌
integrityManager.requestIntegrityToken(request)
    .addOnSuccessListener { response ->
        // 将 response.() 发送回服务端做最终验证
        sendToServer(response.())
    }
    .addOnFailureListener { exception ->
        // 处理网络/API异常
    }

4.3.2 请求结构字段说明

参数类型说明
setNonce(String)必填服务端下发的签名挑战字符串,长度 ≥ 32 字节且必须 Base64 编码
setCloudProjectNumber(Long)可选如使用云项目关联,设置项目编号
requestIntegrityToken()异步返回 Task<IntegrityTokenResponse>

4.4 典型时序图

下图展示了从客户端触发验证到服务端完成完整性校验的完整通信时序:

Play Integrity 服务端-客户端通信时序 Android 客户端 业务服务端 Google Play Integrity ① 请求nonce ② 签名challenge+nonce ③ 携带challenge请求 ④ 返回Integrity ⑤ 提交+challenge 验证签名+nonce+ ⑦ 验证通过/失败 ★ 关键点:服务端必须先将challenge和nonce绑定签名后再下发,客户端不得自行构造nonce。 Step ⑤需将challenge原样传回,服务端用其内的nonce做防重放检查。

4.5 常见错误与优化

错误模式现象解决方案
客户端伪造 nonce重放攻击成功服务端必须对 challenge 整体签名,且签名密钥不泄露
nonce 被截断Google API 返回 INVALID_ARGUMENTnonce 必须是 Base64 URL-safe,长度不少于 32 字节
服务端未校验时间差旧的 legitimate 被重用验证 timestamp 在当前时间 ±30 秒以内
nonce 存储未设 TTL内存无限增长使用 Redis EXPIRE 或 HashMap + 定时清理
性能优化建议:nonce 的签名运算使用对称 HMAC(如 HMAC-SHA256)比非对称签名快 3-5 倍,且无需管理公钥分发。在服务端负载高时,推荐 HMAC + 共享密钥方式。

4.6 部署关键检查点

  • 确认服务端 nonce 生成接口仅接受 POST 且需客户端认证(如 OAuth2 、session cookie)。
  • 公开 API 必须添加速率限制(Rate Limiting),防止 nonce 接口被穷举。
  • 签名密钥定期轮换(建议每 30 天),旧密钥在一段重叠期内保留用于验证旧挑战。
  • 客户端的 challenge 接收字段使用 ProtectedData 或 KeyStore 加密存储,避免被 hook 修改。
调试辅助:在开发阶段,可以在服务端 nonce 响应头中添加 X-Debug-Nonce-Hash 字段(值为 nonce 的 SHA256 摘要),方便排查前后端不匹配问题。生产环境务必关闭此头。