设备端签名架构

从 Android 12 开始,Android 运行时 (ART) 模块就成为了一个 Mainline 模块。如需更新该模块,可能需要重新构建 bootclasspath jar 和系统服务器的预先 (AOT) 编译制品。由于这些制品对安全性要求较高,因此 Android 12 采用了一项名为设备端签名的功能,以防止这些制品被篡改。本页将介绍设备端签名架构以及该架构与其他 Android 安全功能的交互方式。

概要设计

设备端签名具有两个核心组件:

  • odrefresh 属于 ART Mainline 模块,负责生成运行时制品。它会对比已安装的 ART 模块版本、bootclasspath jar 和系统服务器 jar 来对现有制品进行检查,以确定这些制品是最新版本,还是需要重新生成。如果需要重新生成,odrefresh 会生成这些制品并将它们存储起来。

  • odsign 是 Android 平台中的一个二进制文件。该文件在前期启动期间运行,装载 /data 分区后会立即运行此文件。其主要职责是调用 odrefresh 来检查是否需要生成或更新任何制品。对于 odrefresh 生成的任何新制品或更新后的制品,odsign 会计算一个哈希函数。这种哈希计算的结果被称为文件摘要。对于已经存在的任何制品,odsign 会验证现有制品的摘要是否与 odsign 之前计算的摘要相匹配。这样可确保制品未被篡改。

出现错误时,例如文件的摘要不匹配时,odrefreshodsign 会丢弃 /data 分区上的所有现有制品,并尝试重新生成制品。如果重新生成失败,则系统会回退到 JIT 模式。

odrefreshodsign 受到 dm-verity 的保护,并且是 Android 启动时验证链的一部分。

使用 fs-verity 计算文件摘要

fs-verity 是 Linux 内核的一项功能,它基于 Merkle 树对文件数据进行验证。如果对文件启用 fs-verity,则会导致文件系统使用 SHA-256 哈希为文件数据构建一个 Merkle 树,将该 Merkle 树存储在文件旁边的隐藏位置,并将文件标记为只读。在读取文件时,fs-verity 会根据需要自动对比此 Merkle 树来验证文件数据。fs-verity 将此 Merkle 树的根哈希作为一个值(称为 fs-verity 文件摘要)来提供,并确保从文件中读取的任何数据都与该文件摘要相一致。

odsign 利用 fs-verity 来优化设备端编译制品在启动时的加密认证,借此提高启动性能。当生成一个制品时,odsign 会对其启用 fs-verity。odsign 验证制品时,会验证 fs-verity 文件摘要,而不是完整的文件哈希。这样就无需在启动时读取制品的完整数据以及对其进行哈希处理。相反,系统在使用制品数据时,会让 fs-verity 按需逐块对制品数据进行哈希处理。

对于内核不支持 fs-verity 的设备,odsign 会回退到在用户空间中计算文件摘要。odsign 会使用与 fs-verity 相同且基于 Merkle 树的哈希算法,因此在任何情况下,摘要都是相同的。所有发布时搭载 Android 11 及更高版本的设备都要求支持 fs-verity。

存储文件摘要

odsign 将制品的文件摘要存储在一个名为 odsign.info 的单独文件中。为了确保 odsign.info 未被篡改,系统会使用一个具有重要安全属性的签名密钥对 odsign.info 进行签名。具体而言,只能在前期启动阶段生成和使用此密钥,此时只有可信代码在运行;如需有关详情,请参阅可信签名密钥

验证文件摘要

每次启动时,如果 odrefresh 确定现有制品是最新的,那么 odsign 就会确保相应文件自生成以来未被篡改。odsign 会通过验证文件摘要来实现这一点。首先,它会验证 odsign.info 的签名。如果签名有效,那么 odsign 会验证每个文件的摘要是否与 odsign.info 中相应的摘要相匹配。

可信签名密钥

Android 12 中引入了一项名为“启动阶段密钥”的新密钥库功能,该功能解决了以下安全问题:

  • 什么能阻止攻击者使用我们的签名密钥来签署他们自己的 odsign.info 版本?
  • 什么能阻止攻击者生成他们自己的签名密钥并使用该密钥来签署他们自己的 odsign.info 版本?

启动阶段密钥会将 Android 的启动周期拆分为多个级别,并以加密方式将密钥的创建和使用与指定级别关联。odsign 会在前期级别创建其签名密钥,此时仅运行可信代码(通过 dm-verity 进行保护)。

启动阶段级别的编号为 0 到魔数 1000000000。在 Android 的启动过程中,您可通过从 init.rc 设置系统属性来提高启动级别。例如,以下代码可将启动级别设为 10:

setprop keystore.boot_level 10

密钥库的客户端可以创建与特定启动级别关联的密钥。 例如,如果您为启动级别 10 创建了一个密钥,那么该密钥就只能在设备的启动级别为 10 时使用。

odsign 会使用启动级别 30,而且它创建的签名密钥与该启动级别关联。在使用密钥为工件签名之前,odsign 还会验证并确保相应密钥与启动级别 30 关联。

这种机制可以防止此部分的前文中提到的两种攻击:

  • 攻击者无法使用生成的密钥,因为等到攻击者有机会运行恶意代码时,启动级别已提高到 30 以上,届时密钥库会拒绝所有使用该密钥的操作。
  • 攻击者无法创建新密钥,因为等到攻击者有机会运行恶意代码时,启动级别已提高到 30 以上,届时密钥库会拒绝使用该启动级别创建新密钥。如果攻击者创建了未与启动级别 30 关联的新密钥,odsign 会拒绝该密钥。

密钥库可保证启动级别得到正确执行。以下几部分将详细介绍在不同的 Keymaster 版本中如何做到这一点。

Keymaster 4.0 实现

不同版本的 Keymaster 会以不同的方式处理启动阶段密钥的实现。在使用 Keymaster 4.0 TEE/Strongbox 的设备上,Keymaster 会按以下方式处理该实现:

  1. 首次启动时,密钥库会创建一个对称密钥 K0,并将 MAX_USES_PER_BOOT 标记设为 1。这意味着,在每次启动时,该密钥只能被使用 1 次。
  2. 在启动期间,如果启动级别提高了,则可以使用 HKDF 函数从 K0 为该启动级别生成新密钥:Ki+i=HKDF(Ki, "some_fixed_string")。例如,如果您将启动级别从 0 提高到 10,系统会调用 10 次 HKDF,以便从 K0 逐步派生出 K10。
  3. 当启动级别发生变化后,先前启动级别的密钥会被从内存中清除,与先前启动级别关联的密钥不再可用。

    密钥 K0 是一个 MAX_USES_PER_BOOT=1 密钥。这意味着,在启动过程的后续阶段也不可能使用该密钥,因为总是会发生至少 1 次启动级别过渡(到最终启动级别)。

当密钥库客户端(例如 odsign)请求在启动级别 i 创建密钥时,其 blob 会使用密钥 Ki 进行加密。由于在启动级别 i 之后 Ki 不可用,该密钥无法在启动过程的后续阶段创建或解密。

Keymaster 4.1 和 KeyMint 1.0 实现

Keymaster 4.1 和 KeyMint 1.0 实现与 Keymaster 4.0 实现大致相同。主要区别在于:K0 不是一个 MAX_USES_PER_BOOT 密钥,而是 Keymaster 4.1 中引入的一个 EARLY_BOOT_ONLY 密钥。EARLY_BOOT_ONLY 密钥只能用在启动过程的前期阶段(未运行任何不可信的代码时)。这可提供一层额外保护:在 Keymaster 4.0 实现中,侵入文件系统和 SELinux 的攻击者可修改密钥库数据库,以创建自己的 MAX_USES_PER_BOOT=1 密钥来为工件签名。而在 Keymaster 4.1 和 KeyMint 1.0 实现中,此类攻击不可能得逞,因为 EARLY_BOOT_ONLY 密钥只能在前期启动期间创建。

可信签名密钥的公开组件

odsign 会从密钥库中检索签名密钥的公钥组件。但是,密钥库不会从存放相应私钥的 TEE/SE 中检索公钥,而是从自己的磁盘数据库中检索公钥。这意味着,侵入文件系统的攻击者可以修改密钥库数据库,使其包含由自己控制的公钥/私钥对中的公钥。

为防止此攻击,odsign 会额外创建一个与签名密钥具有相同启动级别的 HMAC 密钥。然后,在创建签名密钥时,odsign 会使用此 HMAC 密钥来创建公钥的签名,并将公钥存储在磁盘上。等到在后续启动期间检索签名密钥的公钥时,使用 HMAC 密钥来验证磁盘上的签名是否与检索到的公钥的签名匹配。如果这二者匹配,则公钥可信,因为 HMAC 密钥只能在前期启动级别中使用,因此不可能是攻击者创建的。