证书链真实、签名有效、挑战足够新,并不自动等于“当前 HTTP 请求就来自生成密钥的那台设备”。Android Key Attestation 的价值在于提供可验证的密钥与设备状态;业务协议还要决定这些证据绑定到哪个应用、会话和操作。
下面沿一组公开演示程序的调用链分析这个区别。证据包括客户端、证明服务与后端的代码快照,以及两次设备记录;离线验证覆盖编码和策略分支,没有重新运行手机端实验,也不把演示结果扩展为商业应用的通用结论。
先区分真实证明与合格状态
本地生成记录返回 HTTP 400,直接原因是 deviceLocked=false。证明级别仍为 StrongBox,启动状态为 Unverified。硬件可以诚实报告一份不符合后端策略的状态,这与伪造证书是两个问题。
| 字段 | 本地生成 | 转发记录 |
|---|---|---|
| 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 会话绑定到密钥所在设备。
| 对象 | 演示中的表示 | 验证重点 |
|---|---|---|
| 挑战 | 无填充 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 特性。两处条件不同,均应以实际结果为准。

界面示意图:监听端口、运行状态与两个服务接口,不代表一次验证结果。
- 1
解码挑战
将无填充 base64url 文本还原为字节,作为 setAttestationChallenge 的输入。
- 2
生成与读取
生成新密钥,从 Keystore 读取证书链,将每张 DER 证书按普通 Base64 编码。
- 3
尝试清理
finally 中通过 runCatching 调用 deleteEntry(alias)。删除异常被捕获,进入 finally 本身不证明删除成功。
- 4
区分证书与持钥能力
成功删除条目后,证书仍可验签,但服务失去通过该 alias 为同一密钥继续签名的正常路径。
样本没有为后续业务操作保留密钥并提供签名的流程。于是“交出一次证书链”和“持续持有那把密钥”形成可单独测试的边界。
新鲜度防重放,不等于请求源绑定
后端发行 32 字节随机挑战,有效期 300 秒。代码顺序是:验证证书与吊销状态,比较挑战,单次消费挑战,再应用设备策略。因此,已经进入设备策略但被拒绝的请求也消耗了 nonce。
转发使用的是新挑战,旧证据重放则再次使用已消费的挑战,两者不是同一个分支。以下离线模型把密码学校验作为前提,保留消费顺序,并比较增加应用身份约束前后的结果。
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 字段定义
| 检查项 | 本例中补上的约束 | 仍需另行分析 |
|---|---|---|
| 应用身份 | 排除另一个应用生成的证明 | 同一受信应用被远程驱动 |
| 后续请求签名 | 操作持续依赖被证明私钥 | 可持续访问私钥的在线代理 |
| 会话与动作绑定 | 签名对应具体上下文 | 账号切换、重试及状态并发 |
应用身份检查对本例的跨应用路径有效,不是所有转发方式的统一终点。模型保留同应用、同签名身份仍通过的分支。
后续签名可覆盖用途标签、会话标识、新挑战、动作、请求体摘要和到期时间;使用长度明确或规范序列化的结构,避免可变长字符串拼接歧义。这是协议设计建议,不是已在样本中实现的功能。
从字节一致性开始验证
挑战采用 base64url,证书采用普通 Base64,混淆二者会把编码错误伪装成证明失败。下面的测试包含会产生 - 与 _ 的输入,也拒绝非规范的带填充表示。
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 也应按各自协议分别评估。