~/posts/mobile/android-key-attestation-relay-session-binding.md

Android 硬件证明:证据转发与会话绑定

沿挑战、证书链、应用身份和密钥寿命追踪证明转发,区分真实硬件证据与业务请求来源,并用离线模型验证策略边界。

date[31:24]
read[23:16]
7 分钟
cat[15:8]
移动端
目录
  1. 0x00先区分真实证明与合格状态
  2. 0x01三个对象,两个编码通道
  3. 0x02返回值接缝改变了证据来源
  4. 0x03证明服务与密钥寿命
  5. 0x04新鲜度防重放,不等于请求源绑定
  6. 0x05身份约束与持钥证明各管一层
  7. 0x06从字节一致性开始验证
  8. 0x07参考资料

证书链真实、签名有效、挑战足够新,并不自动等于“当前 HTTP 请求就来自生成密钥的那台设备”。Android Key Attestation 的价值在于提供可验证的密钥与设备状态;业务协议还要决定这些证据绑定到哪个应用、会话和操作。

下面沿一组公开演示程序的调用链分析这个区别。证据包括客户端、证明服务与后端的代码快照,以及两次设备记录;离线验证覆盖编码和策略分支,没有重新运行手机端实验,也不把演示结果扩展为商业应用的通用结论。

先区分真实证明与合格状态

本地生成记录返回 HTTP 400,直接原因是 deviceLocked=false。证明级别仍为 StrongBox,启动状态为 Unverified。硬件可以诚实报告一份不符合后端策略的状态,这与伪造证书是两个问题。

两次记录的字段对照7 行
字段 本地生成 转发记录
HTTP 状态 400 200
attestation_version 100 3
keymaster_version 100 4
证明安全级别 StrongBox StrongBox
device_locked false true
verified_boot_state Unverified Verified
证书数量 4 4

这些字段变化与另一台设备生成新证明相符,但两次记录使用不同挑战,未构成固定同一输入的成对实验。要确认转发过程中挑战未变,应关联各边界的原始字节与 DER 解析结果。

StrongBox、启动加载器锁定、Verified Boot 和运行时进程完整性是不同维度。单个字段既不说明应用进程未经修改,也不推出所有获得 root 的设备都会呈现相同状态。

三个对象,两个编码通道

应用生成的业务密钥、签发证明的密钥、接收证书的业务会话应分开记录。证书及公钥能被复制,不意味着对应硬件私钥也离开了设备。证明凭据的配置与轮换,同样不会自行把 HTTP 会话绑定到密钥所在设备。

数据从哪里来,应该在哪里验证5 行
对象 演示中的表示 验证重点
挑战 无填充 base64url → byte[] 与已发行挑战逐字节对应
证书 每张 DER 的普通 Base64,组成数组 构建受信路径、验签、有效期与吊销
请求级别 requested_level 文本 仅为客户端标签
实际级别 已验证扩展中的安全级别 按策略读取证明与密钥实现字段
会话 后端当前交互 另行绑定账号、密钥与动作

证明扩展 OID 为 1.3.6.1.4.1.11129.2.1.17。验证应沿构建并验证后的路径查找靠近根的第一处证明扩展,而非假设它总在叶证书;新链中的 provisioning 扩展还带有相邻关系要求。信任根集合应按官方维护信息更新,2026 年根轮换也是兼容性的一部分。Android 验证流程

代码快照有一个值得单列的审阅点:路径验证之后,扩展提取仍对客户端输入数组执行 reversed(chain)。这与遍历验证器返回的路径不是同一操作,应显式检查两者的一致性。这里记录的是静态审阅结果,不宣称已经构造出该差异的可利用证书链。

返回值接缝改变了证据来源

演示客户端依次申请挑战、调用自己的 generateAttestedKey(challenge) 封装、提交 {nonce, chain}。这个方法名属于样本,并非 Android 通用接口。

返回对象包含证书列表、请求级别和回退说明。注入逻辑替换该对象,后续网络代码仍能继续;它改变了应用内调用行为,却没有修改安全硬件、证书签名或签名覆盖的启动状态。

sequenceDiagram
    participant C as 客户端
    participant B as 后端
    C->>B: POST /nonce
    B-->>C: 新挑战
    C->>C: 取得证明结果
    C->>B: POST /verify
    B-->>C: 策略判定
sequenceDiagram
    participant C as 客户端
    participant H as 控制端
    participant D as 证明设备
    C->>H: 挑战字节
    H->>D: POST /attest
    D-->>H: 新证书链
    H-->>C: 返回对象

控制程序使用 USB 连接客户端进程,自己的运行位置与手机内 agent 分开;图中不把二者强行归入同一设备。传递的是挑战和证书,不是私钥。

requested_level 可以被写成任意展示标签。后端应以经过验证的扩展为准,而不是相信这个字符串。演示缺少的绑定是“这份证明对应当前请求端的密钥与后续操作”,不是“硬件签名已失效”。

证明服务与密钥寿命

另一台设备上的服务接收挑战,生成 EC P-256 密钥并返回证书。它按 Android API 版本先尝试 StrongBox,失败后走非 StrongBox 路径;客户端自己的代码则先查询 StrongBox 特性。两处条件不同,均应以实际结果为准。

硬件证明服务控制界面示意图

界面示意图:监听端口、运行状态与两个服务接口,不代表一次验证结果。

一次请求内的密钥生命周期4 步
  1. 1

    解码挑战

    将无填充 base64url 文本还原为字节,作为 setAttestationChallenge 的输入。

  2. 2

    生成与读取

    生成新密钥,从 Keystore 读取证书链,将每张 DER 证书按普通 Base64 编码。

  3. 3

    尝试清理

    finally 中通过 runCatching 调用 deleteEntry(alias)。删除异常被捕获,进入 finally 本身不证明删除成功。

  4. 4

    区分证书与持钥能力

    成功删除条目后,证书仍可验签,但服务失去通过该 alias 为同一密钥继续签名的正常路径。

样本没有为后续业务操作保留密钥并提供签名的流程。于是“交出一次证书链”和“持续持有那把密钥”形成可单独测试的边界。

新鲜度防重放,不等于请求源绑定

后端发行 32 字节随机挑战,有效期 300 秒。代码顺序是:验证证书与吊销状态,比较挑战,单次消费挑战,再应用设备策略。因此,已经进入设备策略但被拒绝的请求也消耗了 nonce。

转发使用的是新挑战,旧证据重放则再次使用已消费的挑战,两者不是同一个分支。以下离线模型把密码学校验作为前提,保留消费顺序,并比较增加应用身份约束前后的结果。

policy_model.pypython
from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Evidence:
    challenge: bytes
    chain_valid: bool
    hardware_backed: bool
    device_locked: bool
    boot_state: str
    package: str
    signer: bytes

EXPECTED_PACKAGE = "example.demo.client"
EXPECTED_SIGNER = bytes.fromhex("11" * 32)

def decide(e, pending, bind_app=False):
    if not e.chain_valid:
        return "chain rejected"
    if e.challenge not in pending:
        return "challenge rejected"
    pending.remove(e.challenge)
    if not (e.hardware_backed and e.device_locked and e.boot_state == "Verified"):
        return "device rejected"
    if bind_app and (e.package != EXPECTED_PACKAGE or e.signer != EXPECTED_SIGNER):
        return "application rejected"
    return "accepted"

challenge = bytes(range(32))
local = Evidence(challenge, True, True, False, "Unverified",
                 EXPECTED_PACKAGE, EXPECTED_SIGNER)
relay = replace(local, device_locked=True, boot_state="Verified",
                package="example.demo.oracle", signer=bytes.fromhex("22" * 32))
assert decide(local, {challenge}) == "device rejected"
pending = {challenge}
assert decide(relay, pending) == "accepted"
assert decide(relay, pending) == "challenge rejected"  # old evidence replay
assert decide(relay, {challenge}, bind_app=True) == "application rejected"
same_app_relay = replace(relay, package=EXPECTED_PACKAGE, signer=EXPECTED_SIGNER)
assert decide(same_app_relay, {challenge}, bind_app=True) == "accepted"
print("five policy branches: PASS")

实际输出为 five policy branches: PASS。模型中的集合不实现 300 秒时钟、跨进程原子消费、账号绑定或证书验证;重置集合只用于独立测试场景,不意味着线上重新启用旧挑战。

代码快照的设备策略检查证明级别、锁定状态与 Verified 状态;没有解析并匹配 attestationApplicationId,也没有要求后续请求由被证明密钥签名。这些具体前提比“证明能被绕过”的笼统结论更重要。

身份约束与持钥证明各管一层

attestationApplicationId 记录平台认定可使用该密钥的应用信息。签名摘要指向应用签名证书,不是 APK 文件哈希;共享 UID 可能对应多个包。因此应在链、挑战与设备状态满足要求后,验证包集合和签名证书集合,并显式设计签名轮换策略。AOSP 字段定义

新增约束的作用范围3 行
检查项 本例中补上的约束 仍需另行分析
应用身份 排除另一个应用生成的证明 同一受信应用被远程驱动
后续请求签名 操作持续依赖被证明私钥 可持续访问私钥的在线代理
会话与动作绑定 签名对应具体上下文 账号切换、重试及状态并发

应用身份检查对本例的跨应用路径有效,不是所有转发方式的统一终点。模型保留同应用、同签名身份仍通过的分支。

后续签名可覆盖用途标签、会话标识、新挑战、动作、请求体摘要和到期时间;使用长度明确或规范序列化的结构,避免可变长字符串拼接歧义。这是协议设计建议,不是已在样本中实现的功能。

从字节一致性开始验证

挑战采用 base64url,证书采用普通 Base64,混淆二者会把编码错误伪装成证明失败。下面的测试包含会产生 - 与 _ 的输入,也拒绝非规范的带填充表示。

challenge_codec.pypython
import base64
import re

def encode_challenge(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")

def decode_challenge(text):
    if not isinstance(text, str) or not re.fullmatch(r"[A-Za-z0-9_-]+", text):
        raise ValueError("not unpadded base64url")
    padded = text + "=" * (-len(text) % 4)
    raw = base64.b64decode(padded, altchars=b"-_", validate=True)
    if encode_challenge(raw) != text:
        raise ValueError("non-canonical encoding")
    return raw

fixtures = [bytes(range(32)), b"\xfb\xff\xff" * 10 + b"\x00\x01"]
for raw in fixtures:
    encoded = encode_challenge(raw)
    assert len(raw) == 32
    assert "=" not in encoded and "\n" not in encoded
    assert decode_challenge(encoded) == raw
assert "-" in encode_challenge(fixtures[1])
assert "_" in encode_challenge(fixtures[1])
try:
    decode_challenge("AA==")
except ValueError:
    pass
else:
    raise AssertionError("padded input unexpectedly accepted")
print("challenge codec fixtures: PASS")

实际输出为 challenge codec fixtures: PASS。Android 对应的无填充、无换行、URL-safe 标志位为 1 | 2 | 8 = 11;它们改变文本表示,不改变随机字节。

复核完整协议还应保存:设备与 APK 版本、应用签名摘要、每张 DER 的哈希、实际验证路径、挑战发行及消费记录、密钥 alias 的创建与删除结果。并发转发工具另需关联 ID、超时和取消语义,避免迟到回包被另一调用消费。

结论落在协议边界:有效证明可以来自另一执行环境;把证明绑定到受信应用、持续持有的密钥及具体动作,才是在业务层补上缺失条件。Android Key Attestation 与 Play Integrity 也应按各自协议分别评估。

参考资料

NORMAL~/posts/mobile/android-key-attestation-relay-session-binding.md§--
0%zh-CN