先看一张贯穿全程的图: 沿中央 01 → 10 向下读,从芯片上电走到 Python 运行;左侧接入 OTP、程序密文和预期哈希,右侧展开 TEE 内的密钥与请求流程,底部是程序更新后重新进入启动链的闭环。
打开可无限放大的完整流程图(SVG) · 打开高清图片(PNG)
图中橙色表示尚未完成的条件,绿色表示解锁与秘密处理环节,不代表整条硬件安全链已验收。注意第 07 步:应用解锁秘密会从 TA 返回普通 Linux 内存;第 10 步的 Python 也在普通世界执行。HUK、应用解锁秘密、LUKS 内部卷密钥和签名私钥各有职责,不能当成同一把钥匙。
动画入门:一段 Python 的安全旅程
如果第一次接触 OTP、TEE、TA、FIT,可以先看下面的八组动画,再往下读实际命令。点击章节切换主题,点击“播放”观看数据沿流程移动;也可以暂停、逐步查看、调整速度,或展开本章的完整文字讲解。带“模拟”的选项用于解释异常路径,不代表新增了板上测试结果。
在独立页面打开完整动画课(含 24 项术语速查,手机阅读推荐)
| 动画章节 | 重点看懂什么 |
|---|---|
| 01 签名与加密 | SHA-256 像指纹,签名像授权印章,加密像保险箱;为什么三者不能互相代替。 |
| 02 OTP 与启动链 | BootROM、OTP、DDR、SPL、U-Boot 如何逐级检查;谁来保证第一个检查员可信。 |
| 03 FIT 装了什么 | 固件 FIT 与 Linux FIT 分别装什么;BL31/32/33、DTB、initramfs 和 RAM 根系统的职责。 |
| 04 TEE、TA 与 HUK | 安全世界与普通世界、TA 与 CA、REE 安全存储、硬件秘密,以及调用身份检查的边界。 |
| 05 程序怎样解锁 | 从 volume-unlock、匿名管道、LUKS2、cryptsetup 到 dm-crypt、ext4、哈希与降权执行。 |
| 06 复制与回滚 | 跨板硬件绑定与降级攻击为什么是两回事;RPMB 能提供什么、不能自动提供什么。 |
| 07 怎样替换程序 | 新加密容器与新签名运行策略必须配对;所有者 SD 如何维护,版本不匹配为何会被拒绝。 |
| 08 完整流程与边界 | 安全启动与度量启动的区别;串口输出与 shell 的区别;静态保密和运行期明文的边界。 |
动画对应的最新实验边界(2026-10-22): hello.py 阶段已经完成一次跨板复制负向测试:同一签名测试镜像、同一加密容器和同一 TEE 存储基准副本在第二块板上不能取得原板解锁秘密,移回原板后仍能解锁,测试期间两块板的 eMMC 均未挂载。这是本次实验条件下的设备绑定证据,不等于厂商 HUK 已完成安全审计,也不等于完整业务应用已经在三块板上部署。OTP 未烧录,ROM 强制根信任、SPL 错误签名真板拒绝与恢复、RPMB/完整防回滚和长期掉电可靠性仍需继续验证。
动画默认不自动播放,支持系统的“减少动态效果”设置。完整页面包含文字讲解和官方参考资料;图中的密文、签名和指纹均为教学示意,不含真实密钥。原有工程记录和命令保留在下文。
这次实验从一个实际问题开始:BPI-R2 Pro 上运行自己编译的 OpenWrt,核心程序主要用 Python 编写,设备交付后必须离线、无人值守、上电自动运行,同时不希望别人直接把存储芯片或固件读走,解包后就拿到程序,也不希望接上串口便得到 root shell。
最初部署的 hello.py 只负责证明一条最小链路:签名系统启动、TEE 管理解锁材料、LUKS2 容器只读解锁、固定文件哈希通过,再以普通用户运行 Python。它不是最终应用,也不足以证明多文件项目已经获得完整保护。
更新至 2026 年 10 月 22 日:真实 Python 项目已经替换 hello.py,完成 AArch64/musl/CPython 3.13 离线依赖封装、全量文件清单验证、基于已做过 hello 跨板负测的设备绑定机制制作的 256 MiB LUKS2 应用卷,以及角色化签名 RAM 根;当前 fix5 容器仅在 master 完成验收。master 已在无 SD、无网络条件下从 eMMC 冷启动并自动运行;接回受控板间网后通过业务 API 完成 20 MHz 扫描,崩溃重启和工作 tmpfs 满盘恢复路径也做了现场测试。这里的结论只覆盖 master:两块子板和三板联调、真实旧版回退/更新中断、SPL 错签真板负测、OTP/RPMB/防回滚等仍未完成。
边界先说在前面: 这套方案保护的是静态存储与当前受控启动路径。解锁材料由 TEE 管理,但受控解锁时仍会短暂进入普通世界进程内存并通过管道交给 cryptsetup;Python 最终运行在普通 Linux 中,不在 TEE 内。内核或 root 已经失陷后,不能承诺 Python 明文仍绝对隐藏。
2026-10-22:从 hello 演示到完整项目 master fix5
下面这张图是首图的“真实部署版”:首图保留完整安全旅程,这张图只总结 master fix5 已经在真板上走通的链路、故障表现与仍未完成的边界。

这次真正换掉了什么
目标环境由构建产物和真板读回再次确认:BPI-R2 Pro、RK3568、AArch64、OpenWrt 25.12.5、Linux 6.12.94、musl 与 CPython 3.13.9。没有把 Windows、x86 Linux 或 glibc 虚拟环境直接复制到板上,也没有把普通 manylinux wheel 当作 OpenWrt 可用产物。
完整应用包目前包含 1402 个受保护文件,payload 约 103.6 MiB(108.66 MB)。应用容器由 hello 阶段的 64 MiB 扩为 256 MiB LUKS2,代码和固定依赖只读挂载;扫描脚本、会话文件和临时结果进入单独的 256 MiB RAM tmpfs,TEE 普通世界工作副本进入另一块受限 tmpfs。当前版本没有需要长期保存的业务数据库或客户配置。
依赖按三类处理:
- 纯 Python 包固定版本后进入离线 bundle;
- C/C++/Rust 等原生扩展使用匹配 AArch64、musl 与 CPython ABI 的产物,并使用目标 OpenWrt 根配合 AArch64 QEMU 做导入检查;
iw-full、iwinfo、aircrack-ng、wpa-cli、wpad等系统工具直接编入受签名的 RAM 根。
设备启动和依赖加载不需要互联网,发布、启动与运行路径也不联网下载依赖;业务功能本身仍会使用受控板间网和无线接口。设备不现场执行 pip install、apk add 或 opkg install。需要准确说明的是:OpenWrt 25.12 基础根仍带 apk-tools,opkg 不存在;本项目的发布、启动与运行路径不调用 apk,客户现场补装被策略禁止,而不是声称系统里根本没有包管理器。
当前实际启动链
签名 FIT
→ kernel、设备树和完整 RAM 根
→ 挂载持久承载卷并准备 TEE 存储
→ 受控 root 解锁器向 TEE 读取设备绑定秘密
→ 解锁 LUKS2 应用容器
→ dm-crypt 映射与只读 ext4 挂载
→ 精确验证完整应用文件集合
→ procd / ujail 降权启动 Python
这次没有替换 bootloader 或 TEE,没有烧写 OTP,也没有配置 RPMB。完整 RAM 根不使用普通持久化 overlay;业务代码、固定依赖、启动入口、插件和会影响执行的固定资源都在只读边界内。
完整性不再只盯着入口文件
hello 阶段只固定检查一个 hello.py 的 SHA-256。master fix5 改为验证精确文件集合,覆盖全部业务 Python、固定入口、启动脚本、受保护 site-packages、原生扩展、项目内 AArch64 程序、插件、生成代码和会影响执行的固定资源。
校验器同时核对路径、文件类型、模式、大小与内容摘要,并拒绝:
- 文件缺失、被替换或出现清单外文件;
- 符号链接、额外硬链接和特殊文件;
- 非规范路径、重复路径与路径逃逸;
- 通过可写目录放置同名模块来覆盖受保护代码。
Python 使用隔离模式启动,禁用 .pyc 写入,清理外部 PYTHONPATH 和用户 site-packages,并固定模块搜索路径。真板上把同名伪模块放进可写工作目录后重启应用,实际入口仍来自受保护只读目录。
进程权限、调试口与 API 边界
业务 Python 使用专用普通 UID/GID 65533,通过 ujail 运行,并启用 NoNewPrivs。现有无线功能仍需网络管理与原始套接字能力,因此只保留经评估的 NET_ADMIN、NET_RAW capability;需要 root 的无线配置变更尽量收进白名单辅助流程。这里应称为“已降权并缩小能力”,不能写成“完全无特权”。
当前 master 是调试变体,保留了一个所有者 SSH 入口:仅接受公钥、限制来源,临时主机密钥通过串口核对;密码、错误密钥和错误来源均被拒绝。文章不公开实际地址、端口、密钥位置或指纹,这个入口也不代表最终客户镜像一定保留。
业务 HTTP API 只应存在于受控板间网络,目前没有应用层认证。20 MHz 扫描是通过这条业务 API 触发的,不是用调试 shell 直接代跑;在增加认证或额外隔离前,不能把它暴露到客户网或不可信网络。本文涉及的无线测试仅在自有或明确授权的实验环境中进行,不提供注入、干扰或攻击他人网络的操作步骤。
master 上已经做到哪一步
本轮在真实 BPI-R2 Pro master 上完成或现场观察到:
- 拔掉 SD 后从 eMMC 自动启动;
- 拔掉网线后做了一次物理冷启动,在重新接网前应用已经健康;
- 应用卷与代码目录保持只读,Python 以专用普通用户运行;
- 1402 文件 manifest 在目标端复核一致;
- 冷启动前后都通过业务 HTTP API 完成 20 MHz 扫描,扫描后两个无线接口仍未关联;
- 对业务进程发送 SIGKILL 后,procd 按限速策略重新拉起,API 恢复;
- 在真板 RAM 副本中新增文件或修改业务模块,目标校验器均拒绝;
- 可写目录中的同名伪模块不能替换受保护入口;
- 将专用工作 tmpfs 填满后,应用以
Errno 28明确失败;在快速重试预算耗尽前移除填充文件,约 6 秒后恢复,并再次完成扫描。
这里的 procd 参数意味着最多 5 次快速失败重试,每次间隔 5 秒;第 6 次快速失败会停止崩溃循环,稳定运行足够长时间后计数才重置。本轮没有把 tmpfs 持续占满直到重试预算耗尽,因此不能概括成“磁盘满后一定会自动恢复”。
Python 测试共收集 385 项,其中 379 项通过、6 项跳过、1 个 warning。部分真板项目有结构化记录和读回证据,另一些只有本轮现场观察,没有保存完整原始 UART transcript;本文不会把后者升级成完整可靠性认证。一次无网冷启动也不能替代长期反复掉电测试。
更新、恢复与可复现性的当前状态
当前发布流程已经形成如下路径:
固定源码和依赖
→ 离线构建目标 bundle
→ 目标 ABI 导入检查
→ 为每块板制作 TEE 基准和板绑定加密容器
→ 生成角色化 RAM 根及签名 FIT
→ 维护环境预检
→ 保留旧版后切换
→ 从 eMMC 做完整验收
master 安装时保留了上一版本,但尚未真正切回旧版并完成一次恢复启动,也没有做更新途中断电故障注入。因此现在只能说“恢复路径已设计并保留旧版”,不能说“回滚已经完整验证”。同一工作树快照的两次离线 bundle 结果一致,目标环境导入检查也通过;但本次 fix5 来自尚未固化的工作树,基线提交单独不能复现,仍需建立经审核的不可变源码快照或 release commit/tag。
还没有完成的工作
截至 2026-10-22,下面这些仍是明确待办:
- child-1、child-2 的独立 TEE 基准、板绑定 LUKS2 容器、角色 FIT 与安装;
- 一主两子的完整业务联调;
- 直接修改真实应用卷后,从启动路径验证拒绝运行;
- 实际切回旧版、安装失败恢复和更新中断/掉电测试;
- tmpfs 持续满到 procd 重试预算耗尽后的人工恢复流程;
- 多轮断电、关键写入与长期运行可靠性;
- SPL 在真板上拒绝错误签名固件 FIT 并走通恢复;
- OTP/BootROM 硬件信任根锁定、RPMB、版本防回滚;
- 厂商 HUK 的完整审计与 TEE 固件更新兼容性;
- 业务 API 的应用层认证;
- 将当前工作树固化为可审计的正式发布快照。
所以,当前可以确认的是:master 已经能离线启动、解锁并校验完整应用,以降权进程运行,并完成实际 20 MHz 扫描。 不能据此声称三块板已经部署完成,也不能声称硬件信任根和防回滚体系已经闭环。
从下面“第 1 节”开始,保留的是 2026-10-15 至 2026-10-19 的
hello.py工程日志、阶段命令和当时的判断。它用于解释方案如何一步步形成,不是 master fix5 的现行部署手册;文中的旧路径、旧哈希、64 MiB 容器、UID/GID 65534 和单文件更新流程都不能直接套到当前完整应用。
1. 这套方案究竟想解决什么
1.1 把需要保护的东西说清楚
这里首先要保护的是程序文件及其实现逻辑,而不是让整块开发板变得“任何人都无法分析”。Python 程序一旦以明文放在普通 ext4 分区里,获得镜像的人通常不需要运行设备,直接挂载文件系统就可以阅读源码。把 .py 改成 .pyc,或换一个目录名,并没有改变这个基本事实。
离线自动启动又带来第二个约束:不能要求每次开机都联网取密钥,也不能让用户在串口上输入密码。设备必须能够自行找到解锁材料,但这个材料又不能简单地以 key.bin 的形式与加密程序放在同一份固件里。
第三个约束是运行环境。程序终究要被 Python 解释器读取。如果对方可以随意替换内核、修改启动脚本、获取 root,或者在解锁后挂载程序目录,那么磁盘加密本身并不能阻止明文被复制。因此,存储加密、可信启动和运行时入口限制必须一起考虑。
1.2 不同措施分别负责哪一层
| 措施 | 在本项目里负责什么 | 单独使用时的边界 |
|---|---|---|
| 关闭串口交互 shell | 避免接上 UART、按回车就进入系统命令行 | 不能阻止替换整个引导或离线读存储 |
| 关闭测试系统的网络管理服务 | 减少 SSH、Web 等可进入系统的入口 | 不等于已经做过全面网络安全测试 |
| 程序存入 LUKS2 容器 | 让存储介质上保存的是加密数据 | 解锁后的 Linux 仍能读取明文 |
| TEE 持久化解锁秘密 | 避免把明文解锁文件和程序一起打包进固件 | 依赖实际 Secure OS 实现和调用方控制 |
| SPL 验证固件 FIT | 约束后续 U-Boot、TEE、BL31 等启动组件 | SPL 本身可被替换时,约束可以整体被替换 |
| U-Boot 验证 Linux FIT | 保护内核、设备树和 initramfs 的组合 | 上一级 U-Boot 的真实性也必须受保护 |
| 程序 SHA-256 固定在签名 initramfs 中 | 防止只改程序文件便改变实际执行内容 | 只覆盖明确校验的文件,不自动覆盖整个程序目录 |
| OTP 中建立硬件信任根 | 目标是让 ROM 拒绝不符合设备信任策略的最初引导 | 本轮尚未启用,不能算已经具备 |
| 降低程序运行权限 | 减少应用自身获得系统权限的机会 | 普通用户运行也不等于完整沙箱 |
U-Boot 的验证启动文档强调,验签所依据的公钥必须处于不能随意替换的信任位置。本文中“还没有锁住最初引导”之所以是重要缺口,就是因为后续每一级检查都需要向前追溯到可信的起点。U-Boot Verified Boot
1.3 当前威胁边界
截至 10 月 19 日,本次演示主要围绕以下场景设计:读取 SD/eMMC 镜像、修改启动文件、替换程序、通过常见串口输入尝试拿到 shell。它没有覆盖芯片开盖、故障注入、总线探测、运行时漏洞利用等更强的攻击方式;当时也尚未实测把全部密文复制到另一块 RK3568 上能否解锁。10 月 22 日新增的 hello 跨板负向结果见文章开头,它仍不等于 fix5 三板部署或 HUK 完整审计。
另外,“不给交付对象 root 权限”应当最终落实为设备上的访问控制和可信启动策略,而不是只把 root 密码保密。10 月 19 日已加入 TA 内部的调用者身份检查,公开会话和普通用户会话被拒绝;它仍依赖可信 Linux 对用户身份的提供,不能抵御已经失陷的 root 或内核。
2. 2026-10-19 的阶段状态(历史)
状态以 2026-10-19 的部署日志、镜像哈希和最终冷启动记录为准。“主机检查通过”和“开发板真的拒绝了错误镜像”分别列出,不互相替代。
| 项目 | 当前结果 | 具体意义与边界 |
|---|---|---|
| 完整稳定版系统 | 已构建并部署 | OpenWrt 25.12.5、Linux 6.12.94、Python 3.13.9;已解决编译空间与依赖问题 |
| ath9k 补丁迁移 | 已上板验证设备与频点表 | AR9300 Rev4、PCI 168c:0033;5845 MHz 存在,5849 MHz 不存在;此前被动扫描发现 2 个 BSS,未做关联/吞吐/发射性能完整验收 |
| eMMC 部署 | 已完成 | 写入前整盘备份;写后回读核对,分区表及目标范围外内容一致 |
| 无 SD、断网冷启动 | 已通过 | 日志发现 eMMC mmcblk1,没有发现 SD mmcblk0;程序持续心跳 |
| 根文件系统完整性 | 已部署 | 完整 RAM 根与内核、DTB 一起包含在签名 FIT 中;不加载持久化 overlay;不是已部署 dm-verity |
| 程序加密 | 已部署 | LUKS2 文件容器存放在 eMMC,启动后只读挂载;明文程序不会作为普通文件直接留在数据分区 |
| TEE 持久化 | 已验证 | 原有秘密迁移存储位置后仍能解锁;更新程序、换签名密钥没有重建解锁秘密 |
| TA 调用权限 | 已加入并板测 | PUBLIC 和普通用户会话被拒绝,root 用户会话成功;运行版禁用 CREATE |
| 程序完整性 | 已部署并负向验证 | 程序 SHA-256 固定在签名运行镜像内;错误哈希不会被接受 |
| 程序权限 | 已实测 | hello.py v2 输出 UID/GID 65534;不以 root 长期运行 |
| U-Boot → Linux FIT | 板上正反向测试通过 | 正确签名启动,错误签名拒绝;后续新签名身份启动通过 |
| SPL → 固件 FIT | 板上正向、主机负向通过 | 错误/缺失签名的 SPL 硬件拒绝测试仍待做 |
| 最初引导厂家格式签名 | 文件检查、启动兼容通过 | sign_flag=0;不能据此声称 ROM 已强制验签 |
| 签名身份分离 | 已更换并验证 | OS FIT、固件 FIT、TA 使用分离的新候选发布密钥,并做受限备份;硬件生产资格未完成 |
| 运行版入口关闭 | 已验证 | 10 月 19 日 hello 运行版无串口登录、无 TCP 监听;SysRq/kexec 关闭,必要模块加载后禁止后续加载 |
| 串口命令负向测试 | eMMC 最终版通过 | 回车、Ctrl-C、Ctrl-D、测试命令未执行;无 shell 提示符,之后仍有 3 次心跳 |
| 所有者维护通道 | 已验证 | 签名 SD 维护系统,专用 SSH 密钥与非默认端口;密码和错误密钥拒绝,主机密钥与当次 UART 日志核对 |
| 程序更新 | SD 上完整流程通过 | 新容器、发布回执、新哈希签名 FIT、离线 v2 启动;当前 eMMC 使用这份 v2 程序 |
| eMMC 恢复入口 | 已实机验证 | SD 恢复系统能够解锁真实 eMMC 程序卷并核对哈希,再完成 eMMC 引导部署 |
| OTP / RPMB | 未烧录、未启用 | 没有硬件固定信任根,也没有硬件防回滚 |
| 跨设备复制保护 | 未独立验证 | 不能凭同一块板重启成功,推导复制到另一块板一定不能解锁 |
最终冷启动中的关键输出如下,完整原始日志保存在本地审计目录:
TA_AUTH_public: result=0xffff0001 origin=4
TA_AUTH_unprivileged: result=0xffff0001 origin=4
TA_AUTH_root: result=0x00000000 origin=4
SECURE_FULL_UNLOCK_OK: LUKS2 readonly; application hash verified
SECURE_FULL_RADIO_OK: ath9k phy0; v6 channel table
SECURE_FULL_READY: no login console; no TCP listeners; modules and kexec locked
HELLO_V2_STARTED uid=65534 gid=65534
HELLO_V2_HEARTBEAT offline protected application
现在读取 eMMC 镜像,能看到系统镜像、LUKS 头和密文文件,但不能像挂载普通 ext4 源码目录一样直接阅读受保护的 hello.py。接串口也不会自动获得 shell。与此同时,设备仍存在替换最初引导的物理攻击路径;“存储中已经加密”与“已抵御完整物理攻击”必须分开判断。
3. 硬件、版本和存储布局
3.1 本次使用的环境
| 部件 | 实验值 |
|---|---|
| 开发板 | Banana Pi BPI-R2 Pro |
| SoC | Rockchip RK3568B2 |
| 内存 | 实机日志识别为 2 GiB LPDDR4;设备树型号字符串含 DDR4,不用字符串替代硬件日志 |
| 串口 | Windows COM7,1500000 波特率,8N1,无流控 |
| eMMC 原系统 | OpenWrt 24.10-SNAPSHOT,r28982-984a219781 |
| eMMC 原内核 | 6.6.114 |
| 新源码基线 | OpenWrt v25.12.5 |
| 新源码提交 | f0a60eee2fe051741c643ea6118718aae1ef17fb |
| 当前 eMMC 系统 / 内核 | OpenWrt 25.12.5 / Linux 6.12.94 |
| U-Boot / SPL | 2025.10 |
| 厂商 BL31 | v1.45 |
| 厂商 BL32 | OP-TEE 3.13.0-891-g9f2aca7d1,固件 v2.15 |
| OP-TEE Client | 4.10.0 |
| Python | 3.13.9 |
| cryptsetup | 2.8.0 |
| 交叉工具链 | AArch64 OpenWrt GCC 14.3 / musl |
这里的版本是本项目固定并验证的基线,不是对阅读当天“最新稳定版”的承诺。10 月 15 日使用精简实验 RAM 根,10 月 19 日已改成由完整 OpenWrt 构建产物组装的签名 RAM 根,再加入 TEE、加密卷和运行策略。旧 v6 的控制台、网络管理配置不会原样沿用;10 月 19 日 hello 运行版当时关闭了这些入口。fix5 master 调试变体后来明确保留了受限所有者 SSH 和业务 API。ath9k backports 基线为 6.18.26。
3.2 SD 和 eMMC 不能凭编号猜
| 视角 | SD 卡 | eMMC |
|---|---|---|
| 本次 Linux 设备名 | /dev/mmcblk0 | /dev/mmcblk1 |
| 本次 U-Boot 编号 | mmc 1 | mmc 0 |
| 容量 | 约 29.1 GiB | 约 14.6 GiB |
| 512 字节扇区总数 | 61067264 | 30535680 |
| 当前用途 | 已更新引导,保留为所有者 eMMC 维护/恢复卡 | 已部署安全运行系统,普通上电从此启动 |
这张表只适用于这次识别到的设备。换内核、换启动环境、换另一块板子,都应重新核对。脚本使用 CID 再做身份检查,不能只看到 mmcblk0 就认定是 SD。
SD 的原分区布局保留如下:
| 区域 | 起点 | 大小 / 用途 |
|---|---|---|
| 引导保留区 | 磁盘开头至 32 MiB 前 | 本轮备份、更新引导的区域 |
| 第 1 分区 | 扇区 65536,即 32 MiB | 32768 扇区,16 MiB |
| 第 2 分区 | 扇区 131072,即 64 MiB | 8388608 扇区,4 GiB;新增实验目录位于其中 |
早期 SD 更新引导使用的起点是扇区 64,也就是 32768 字节;该阶段引导文件长度为 9963520 字节。写入末尾仍在第一个分区起点之前。这里的计算和回读检查是为了确认没有覆盖原有文件系统,不代表这种布局可直接套用到任何存储介质。
部署后的 eMMC 第 2 分区仍为 4 GiB,文件系统显示约 3.9 GiB,总体剩余约 3.7 GiB。这是 10 月 19 日 hello 阶段的快照,不是实时读数;整颗 eMMC 约 14.6 GiB,其余未分配空间本次没有扩展。当时的 hello LUKS 文件容器只有 64 MiB;10 月 22 日 master fix5 已改用 256 MiB 应用容器。 外层空闲空间与容器内部容量不能混用。
3.3 SD 的角色从测试卡变成维护恢复卡
早期保留原 eMMC 系统,通过 SW1/MASKROM 配合 RST 选择 SD 做试验。现在普通上电已从 eMMC 启动新系统,不插 SD、不连网络也能运行程序。
当前 SD 中的固定 FIT 路径仍是 /hello-security-test-20261015/hello.itb,但里面已换成维护 eMMC 的签名镜像。文件夹名字保留了最初日期,不代表其内容仍是最初的 SD 演示。维护时保留网线,按住 SW1/MASKROM、按一下 RST、约五秒后松开 SW1;日常运行不需要做这些操作。
维护 SD 只应由所有者保管。它有经过认证的 root 维护通道;运行 eMMC 没有给交付对象开放这个入口。原 eMMC v6 系统现在以整盘备份保存,不能再说“只按 RST 就返回旧 v6”。
4. 工程目录与文件地图
4.1 Windows 本地工作区
本次工作区的实际根目录在公开版中统一记为:
$WORKSPACE
下面为了缩短命令,PowerShell 示例用 $LabRoot 表示它。这是一个任务专用变量,不覆盖系统的 $HOME 等变量。
$LabRoot = '<WORKSPACE>'
Set-Location -LiteralPath $LabRoot
| 相对工作区路径 | 用途 |
|---|---|
secure-openwrt\hello-demo\hello.py | 独立心跳程序源码 |
secure-openwrt\hello-demo\hello-start.sh | 固定哈希检查、清空补充组、降权和启动 Python |
secure-openwrt\hello-demo\init | 最终 RAM 系统的启动入口,挂载、加载驱动、解锁、启动程序 |
secure-openwrt\tee-volume\ta\volume_ta.c | TEE 可信应用:创建与读取随机秘密 |
secure-openwrt\tee-volume\include\volume_ta.h | TA UUID、命令号与 64 字节长度定义 |
secure-openwrt\tee-volume\ta\Makefile | TA 构建入口,要求显式指定签名私钥 |
secure-openwrt\tee-volume\ta\user_ta_header_defines.h | TA 属性定义 |
secure-openwrt\tee-volume\host\volume-unlock.c | 普通世界的 TEE 客户端和 cryptsetup 调用器 |
secure-openwrt\secure-board.mk | 实验用内核模块包定义;定义存在不等于功能已经启用验证 |
secure-openwrt\prepare-netboot-kernel.py | 为测试内核准备外部 initramfs 支持 |
secure-openwrt\build-tee-lab.py | 早期带恢复控制台的 TEE 引导构建脚本 |
secure-openwrt\build-tee-netboot.py | 组装 RAM 根文件系统、设备树与 initramfs |
secure-openwrt\build-hello-runtime.py | 收集 Python、cryptsetup、库和模块,构建运行时 |
secure-openwrt\build-hello-boot.py | 生成 Linux FIT 签名、错误密钥样本和第一版 hello 引导 |
secure-openwrt\build-hello-spl-chain.py | 给固件 FIT 加签名,在 SPL 控制 DTB 中放入必需公钥 |
secure-openwrt\prepare-hello-rom-signature.py | 生成厂家格式的初级引导签名,并做文件级验证 |
secure-openwrt\hello-spl-write-sd.sh | 从上一版引导更新到 SPL 验签版本的带检查写卡脚本 |
secure-openwrt\hello-rom-write-sd.sh | 从 SPL 验签版本更新到厂家格式初级签名版本的脚本 |
secure-openwrt\serial-lab.py | 串口命令与日志辅助工具,仅在有维护 shell 时有效 |
secure-openwrt\hello-http.py | 临时、限定文件和客户端的只读传输服务 |
secure-openwrt\tftp-lab.py | 早期网络引导测试辅助工具 |
.secure-sd-test\sd-first32MiB.bin | 原 SD 前 32 MiB 备份 |
.secure-sd-test\session-status.json | 本轮最终阶段状态与证据索引 |
.secure-sd-test\netboot\ | 本地已下载的内核、设备树、FIT、引导和运行时产物 |
有些早期说明文件还保留着“尚未上板”的阶段描述,例如开发组件目录里的旧 STATUS.txt。现在应以 secure-openwrt/emmc-deployment-20261019/deployment-state.json、cold-boot-verification.json 和对应串口日志为准。旧 session-status.json 只代表早期 SD 阶段;构建 manifest 中静态写入的 pending 列表也不能代替后续实机验收。
4.2 Linux 编译机
编译工作使用 builder 用户。登录地址不公开,实际操作先登录编译机,再切换用户:
# 编译机:进入构建用户环境
sudo -iu builder
# 以下变量只描述本项目路径
BUILD_ROOT='<BUILD_ROOT>'
ORIGINAL="$BUILD_ROOT/openwrt"
TREE="$BUILD_ROOT/openwrt-secure-25.12.5"
AUDIT="$BUILD_ROOT/secure-build-25.12.5"
三个目录承担不同角色:ORIGINAL 保存原编译工程;TREE 是新版本源码和构建目录;AUDIT 保存准备脚本、签名实验、独立运行时、日志和阶段产物。原工程并没有被新版本目录直接覆盖。
| 编译机目录 / 文件 | 用途 |
|---|---|
$TREE/package/kernel/mac80211/patches/ | 迁移后的无线补丁所在区域 |
$TREE/package/kernel/linux/modules/secure-board.mk | 实验模块定义的源码树副本 |
$AUDIT/patch-validation.json | 补丁应用检查记录 |
$AUDIT/patch-audit/ | 补丁审计材料 |
$AUDIT/compile-baseline.log | 完整基线构建日志,包括空间不足问题 |
$AUDIT/compile-hello-runtime.log | 独立运行时构建日志 |
$AUDIT/vendor-bsp/ | 香蕉派厂商 BSP 的检出副本 |
$AUDIT/rkbin-audit/rkbin-2025.06.13~74213af1/ | 本次使用的 Rockchip 二进制及工具基线 |
$AUDIT/optee_client-4.10.0/ | OP-TEE Client 源码 |
$AUDIT/optee-client-out/ | libteec 和 tee-supplicant 构建输出 |
$AUDIT/tee-volume/ | TA 和 host 工具的源码 / 构建副本 |
$AUDIT/tee-lab/uboot-out/ | U-Boot、SPL、mkimage、fit_check_sign 等输出 |
$AUDIT/tee-netboot/ | 测试内核、DTB、initramfs |
$AUDIT/hello-runtime-root/ | 收集好的 Python 与存储工具运行时 |
$AUDIT/hello-boot/ | 第一版 Linux 签名 FIT 和引导 |
$AUDIT/hello-spl-chain/ | SPL 固件 FIT 签名阶段产物 |
$AUDIT/hello-rom-signing/ | 厂家初级引导签名阶段产物 |
$AUDIT/hello-session-status.json | 从本地同步的最终状态快照 |
4.3 当前 eMMC 与运行系统的路径
以下是 eMMC 第 2 分区内部的路径:
/secure-boot/runtime.itb
/secure-data/hello-v2-20261019.luks
/secure-data/hello-v2-20261019.release.json
/secure-data/hello-demo.luks # 保留的旧容器
/secure-data/secure-storage/ # TEE 的持久化存储文件
Linux 将这个分区挂到 /media/secure-data,于是维护系统看到的是:
/media/secure-data/secure-boot/runtime.itb
/media/secure-data/secure-data/hello-v2-20261019.luks
/media/secure-data/secure-data/hello-v2-20261019.release.json
/media/secure-data/secure-data/secure-storage/
/mnt/secure-storage # TEE 存储绑定点
/dev/mapper/secure-app # 解锁后的映射
/opt/hello/hello.py # 运行版只读挂载后的入口
/usr/sbin/secure-app-stage # 仅维护镜像的程序暂存助手
路径中的两次 secure-data 分别是挂载点名和分区内目录名,不是笔误。eMMC U-Boot 从 mmc 0:2 读取 /secure-boot/runtime.itb;不使用 Linux 挂载前缀。
4.4 10 月 19 日新增的构建与验收入口
下表相对于 Windows 工作区的 secure-openwrt/。构建脚本在编译机 $AUDIT 下有对应副本。
| 路径 | 用途 |
|---|---|
build-full-secure.py | 从完整 OpenWrt rootfs 构建 normal / maintenance 签名镜像;区分 SD/eMMC |
secure-full-start.sh | 运行策略模板;构建时固定存储位置、容器名、程序哈希 |
secure-maint-start.sh | 所有者维护策略模板 |
secure-app-stage.py | 单文件 Python 更新助手源码;构建时替换成所选存储介质配置 |
owner_ssh.py | 将本次 UART 观察到的 SSH 主机公钥固定到客户端 |
prepare-release-keys.py | 新候选发布签名密钥准备,不写 OTP |
build-release-chain.py / build-emmc-uboot.py | 分离密钥启动链、eMMC 固定路径 U-Boot |
full-maint-20261019/maintenance-verification.json | SSH 错误密钥/密码拒绝、正确密钥成功、更新暂存测试 |
full-release-20261019/hardware-verification.json | v2 程序离线运行测试 |
release-chain-20261019/hardware-verification.json | 新签名身份启动链验证 |
emmc-deployment-20261019/cold-boot-emmc.txt | 无 SD、无网络最终冷启动日志 |
emmc-deployment-20261019/cold-boot-verification.json | 冷启动逐项验收 |
emmc-deployment-20261019/serial-negative.json | 最终串口拒绝命令测试 |
emmc-deployment-20261019/recovery-verification.txt | SD 恢复镜像解锁 eMMC 成功 |
emmc-deployment-20261019/bootloader-write-result.txt | 引导回读与写入范围验证 |
emmc-deployment-20261019/filesystem-repair.txt | 旧 ext4 inode 位图填充修复记录 |
emmc-deployment-20261019/emmc-backup.json | 整盘备份及解压核验 |
emmc-deployment-20261019/使用与维护说明.txt | 私有交接说明,含实际地址及密钥位置,不作为博客附件公开 |
当前构建输出目录为 $AUDIT/emmc-runtime-20261019/ 和 $AUDIT/emmc-maint-20261019/。名字中的日期是脚本固定值,下次构建仍会更新这些目录;发布前必须归档上一版本。
5. 第一条主线:TEE 保存解锁秘密
5.1 为什么不能把密码写在启动脚本里
如果启动脚本中写着一个固定密码,或者旁边放着一个明文 key.bin,那么提取固件的人会同时拿到“被锁住的程序”和“开锁材料”。这样的自动解锁只是改变了存放方式,并没有建立独立的保护边界。
本次采用的路径是:由 TEE 中的可信应用生成随机秘密,使用 TEE 的持久化对象接口保存;普通 Linux 启动后向这个 TA 请求读取,再将它用于 LUKS2 解锁。设备无需连接服务器,也无需人工输入密码。
TEE 负责安全世界中的对象管理,REE 文件系统负责保存持久化存储的外部数据。通用 OP-TEE 的安全存储文档描述了相应的密钥派生与加密存储架构,但 RK3568 这里运行的是厂商 BL32,所以不能只凭通用文档就声称已验证了这颗芯片上的全部硬件绑定细节。OP-TEE Secure Storage
5.2 TA 的具体实现
TA 的标识和接口为:
UUID:476da8cc-ec92-49fe-a834-709a80a06852
对象 ID:application-volume-key-v1
秘密长度:64 字节
命令 0:CREATE
命令 1:READ
存储类型:TEE_STORAGE_PRIVATE_REE
源码位置:
secure-openwrt/tee-volume/ta/volume_ta.c
secure-openwrt/tee-volume/include/volume_ta.h
创建命令先调用 TEE_GenerateRandom(),然后调用 TEE_CreatePersistentObject()。创建时没有设置覆盖旧对象的选项。这一点对无人值守设备很重要:如果后续某次启动出现读取错误,不能直接“重新生成一把新钥匙”,否则旧加密数据可能再也打不开。
读取命令调用 TEE_OpenPersistentObject() 和 TEE_ReadObjectData(),检查读出的长度正好是 64 字节,再复制到输出参数。无论成功还是失败,局部缓冲区都会清零。最终 /init 的启动流程只调用读取和解锁,不会在失败后自动执行创建或重新格式化。
5.3 已做过哪些 TA 验证
本轮不是只看 TA 编译成功。实际做了以下检查:
- 自有签名 TA 能被当前 BL32 接受,能够打开会话。
- 由 SDK 公共示例密钥签名的测试 TA 被拒绝,返回
0xffff000f,即TEE_ERROR_SECURITY。 - 首次创建随机对象成功。
- 再次创建同名对象返回
0xffff0003,对应访问冲突,旧对象没有被覆盖。 - 将 TEE 存储放到 SD 的持久化目录后,复位启动能够继续使用原对象解锁原 LUKS2 容器。
这证明了本次配置下“自有 TA 可用、一个已测试的公共示例签名不可用、对象能持久化”。它没有证明所有默认密钥槽都已关闭,也没有证明其他固件版本或其他板子一定不能读出同一对象。
5.4 为什么没有启用 RPMB
实验显式选择 TEE_STORAGE_PRIVATE_REE,并将实验版 tee-supplicant 的 RPMB 请求处理改为不支持,同时禁用本轮内核中的 RPMB 功能。对应审计文件位于:
$AUDIT/disable-lab-rpmb.patch
这样做是为了把本次验证限制在可恢复的文件存储路径中,不在排查 TEE 与内核兼容问题时顺带写入一次性 RPMB 密钥。没有写 RPMB,并不代表已经有防回滚能力。 REE 存储被删除、恢复到旧快照、与程序容器版本不匹配等情况,还需要单独设计和验证。
5.5 已修复公开会话缺口,仍要承认 root 边界
10 月 15 日的 host 使用 TEEC_LOGIN_PUBLIC,TA 未绑定调用者。仅检查 host 自身的 geteuid(),不能阻止别人编写另一个客户端。这项缺口在 10 月 19 日已修复:构建脚本加入 TA 内部的 TEE_Identity 检查,要求 USER 登录方式及预期 root 身份;Linux 端 /dev/tee* 权限设为 600,运行 TA 禁用 CREATE。
最终 eMMC 冷启动日志中,公开会话和普通用户会话均返回 0xffff0001,root 用户会话成功。新签名 TA 已随新 BL32 信任配置验证,原有持久化秘密仍可读取。
这里认证的是由可信 Linux/驱动传入的调用身份,并不是对任意 Linux 内核做远程证明,也不是把所有 root 进程区分成“合法解锁器”和“恶意进程”。恶意 root 或被替换的内核仍可能冒充允许身份,或直接读取解锁后的程序。OTP 未锁住最初引导时,这个边界尤其需要明确。
6. 第二条主线:LUKS2 加密程序与离线解锁
6.1 解锁秘密与数据卷密钥不是同一个概念
代码里有时把那 64 字节简称为“volume key”,但从 cryptsetup 的接口语义看,它作为 --key-file - 的输入,是用于打开 LUKS 密钥槽的解锁材料。LUKS 内部还有实际用于数据加密的卷密钥。二者不应在技术说明中混为一谈。
本次格式化参数选择 aes-xts-plain64 和 --key-size 512。这里的 512 是 XTS 所需的两部分密钥总长度,不存在一种因此叫做“AES-512”的算法。演示使用随机解锁材料和固定 PBKDF2 测试参数,不应把该参数直接复制成面向人工弱口令的生产建议。
6.2 为什么用 64 MiB 文件容器
早期为了不重新分区,先在 SD 原有第二分区内创建 64 MiB 普通文件:
/media/sd-lab/hello-security-test-20261015/hello-demo.luks
这个文件是 LUKS2 容器,内部再建立 ext4 文件系统。LUKS 元数据会占用空间,因此容器的 64 MiB 不等于内部文件系统的全部可用容量;本轮内部文件系统约为 48 MiB,足够放入演示程序。
10 月 19 日继续采用文件容器设计,并将 v2 容器与 TEE 存储迁入 eMMC 的 /secure-data/。它不是单独分区,也不会自动占满外层剩余空间。多文件、大程序或专用加密分区仍需另行设计容量和迁移流程。
6.3 volume-unlock 做了什么
源码在 secure-openwrt/tee-volume/host/volume-unlock.c。它的操作顺序是:
- 检查参数和 root 身份。
- 检查目标是相应模式允许的设备或文件。
- 设置
RLIMIT_CORE=0,关闭自身可转储属性,并锁住秘密缓冲区。 - 连接 TEE,创建或读取对象。
- 通过匿名管道把解锁材料交给 cryptsetup 的标准输入。
- 不把秘密打印到终端,不把它放进命令行参数,不创建明文密钥文件。
- 用完后清零缓冲区,等待 cryptsetup 返回,并传播失败结果。
演示文件的打开模式会检查:目标为 root 所有的 64 MiB 普通文件,不接受符号链接。创建模式使用 O_EXCL 和 O_NOFOLLOW,已有路径不会被覆盖。这些检查减少了误操作范围,但不等于已经完成针对恶意并发修改的完整文件打开竞态审计。
6.4 可用命令与执行阶段
以下是早期初始化接口说明,不是常规更新步骤。10 月 19 日 hello 运行镜像当时没有 shell,且 CREATE 已禁用;程序更新请使用第 15 节签名维护镜像里的 secure-app-stage,不要重建原秘密或重新格式化现用容器。fix5 master 调试变体后来保留的受限所有者 SSH 不改变这段历史流程。
| 命令 | 用途 | 是否改变状态 |
|---|---|---|
volume-unlock create-key | 首次创建 TA 持久化对象 | 会创建对象;已有对象时失败 |
volume-unlock format-demo NEW_FILE | 新建 64 MiB 文件并格式化为 LUKS2 | 会创建并格式化新文件 |
volume-unlock open-demo-rw DEMO_FILE | 为首次写入程序打开容器 | 可写映射,仅用于准备阶段 |
volume-unlock unlock-demo DEMO_FILE | 打开演示容器 | 只读映射,最终启动使用 |
volume-unlock unlock BLOCK_DEVICE | 对真实块设备做只读解锁 | 预留接口;不表示专用 eMMC 分区已部署 |
cryptsetup status secure-app | 检查映射状态 | 只读检查 |
cryptsetup luksDump DEMO_FILE | 检查容器元数据 | 只读检查;不要附加导出秘密的选项 |
初始化流程的核心命令可以归纳为:
# 板端维护环境;仅用于首次初始化一个全新的实验容器
LAB=/media/sd-lab/hello-security-test-20261015
volume-unlock create-key
volume-unlock format-demo "$LAB/hello-demo.luks"
volume-unlock open-demo-rw "$LAB/hello-demo.luks"
# 创建内部文件系统之前,必须确认映射来自新建容器。
cryptsetup status secure-app
# 本轮仅对核实过的 /dev/mapper/secure-app 建立 ext4。
# 不应把 mkfs 的目标改成整张 SD 或整块 eMMC。
程序准备完成后,正常启动采用只读路径:
# 板端维护环境中的流程展示;实际自动启动由签名 /init 执行
volume-unlock unlock-demo "$LAB/hello-demo.luks"
mount -o ro /dev/mapper/secure-app /opt/hello
cryptsetup status secure-app
sha256sum /opt/hello/hello.py
6.5 必须承认的运行期明文边界
在这个方案里,解锁秘密会从 TEE 返回普通世界,进入 volume-unlock 的内存,再交给 cryptsetup。数据卷打开后,dm-crypt 需要在内核中使用卷密钥,Python 也必须读到解密后的源码或字节码。
所以,本轮实现的是“秘密不作为明文文件落在普通存储上”和“程序在存储介质上加密”,并不是“密钥永远不离开 TEE”,更不是“程序运行时从不出现明文”。mlock、禁用 core dump、匿名管道和清零缓冲区有实际价值,但不能让已经失陷的 root 或内核自动变得可信。
7. 第三条主线:让 hello.py 只能按预期方式启动
7.1 最初的 hello.py 与当前 v2
下面保留最初版本,便于解释早期日志。当前 eMMC 运行的是第 15 节说明的 v2,输出 HELLO_V2_STARTED 和 HELLO_V2_HEARTBEAT,其哈希为 a1e9beea2ed556b7593eb25367fb72ae661dcef96939ca3ba4f7e62ff974d686。
#!/usr/bin/env python3
"""Offline demonstration application: print a heartbeat; no network or radio IO."""
import datetime
import signal
import threading
stopped = threading.Event()
def stop(signum, frame):
stopped.set()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print("hello.py started", flush=True)
while not stopped.is_set():
print(datetime.datetime.now(datetime.timezone.utc).isoformat(),
"Hello from BPI-R2 Pro", flush=True)
stopped.wait(10)
print("hello.py stopped", flush=True)
上述最初版本文件 SHA-256 是:
bfadb913f2eacca3a570f2ba6d971983e1419518da1aaa21fad0f11dedfa6cf0
后面出现的启动日志时间来自板子的 RTC,实验时并未校准到实际日期。日志里的 2025-10-29 不是本轮工作的日期,也不能用它证明安全时间、防回滚或证书时间验证已经成立。
7.2 hello-start.sh:固定哈希,然后降权
启动器放在签名保护的 initramfs 中,而 hello.py 放在 LUKS2 内部。启动器先计算 /opt/hello/hello.py 的 SHA-256,与固定值比较;不一致就返回错误,不执行 Python 程序。
下面是最初版本的关键逻辑;当前构建器会按发布回执替换 expected 和容器名,不应把这个旧哈希复制到新发布中:
#!/bin/sh
set -eu
expected=bfadb913f2eacca3a570f2ba6d971983e1419518da1aaa21fad0f11dedfa6cf0
actual=$(sha256sum /opt/hello/hello.py)
[ "${actual%% *}" = "$expected" ] || {
echo 'hello.py integrity check failed' >&2
exit 1
}
exec /usr/bin/python3 -I -B -u -c 'import os; os.setgroups([]); os.setgid(65534); os.setuid(65534); os.execv("/usr/bin/python3", ["python3", "-I", "-B", "-u", "/opt/hello/hello.py"])'
这里清空补充组,再设置 GID 和 UID 65534,避免程序以 root 身份继续运行。-I 使用隔离模式,-B 避免写入 .pyc,-u 让心跳及时输出;这些选项帮助减少环境干扰,但并不构成对恶意 Python 代码的安全沙箱。Python 命令行选项
本轮负向验证用改变预期哈希的测试启动器执行检查,得到 hello.py integrity check failed 和失败退出码。这个结果证明哈希检查路径确实会阻止执行。它没有把整个加密文件系统升级成带认证的文件系统:当前只显式钉住了这个 hello.py 的内容。
7.3 /init 的执行顺序
早期精简 SD 启动脚本位于 secure-openwrt/hello-demo/init,被打进签名的 initramfs。它主要做以下事情:
- 挂载
/proc、/sys和临时文件系统,设置严格 umask。 - 将运行时 Magic SysRq 开关设为 0。
- 加载 TEE、OP-TEE、device-mapper、dm-crypt 及密码学相关模块。
- 等待 SD 第二分区出现,最多等待有限次数,不无限阻塞在找盘上。
- 核对 SD CID,避免挂载错误介质。
- 挂载 SD 分区,并把实验的
secure-storage绑定到 TEE supplicant 的存储位置。 - 启动 tee-supplicant。
- 调用
volume-unlock unlock-demo。 - 将映射设备只读挂载到
/opt/hello。 - 执行哈希检查和降权启动器。
- 检查应用进程是否仍存活,持续监督;退出则进入失败状态。
失败处理函数只输出 HELLO_BOOT_FAILED 并等待,不进入应急 shell,也不会重新创建密钥或自动格式化程序卷。这样可以避免“正常路径没有 shell,出错后却自动给一个 root shell”的反效果。
7.4 串口关闭到了什么程度
U-Boot 测试配置使用 BOOTDELAY=-2,并在自动启动命令里将标准输入切到 nulldev;Linux 演示没有启动交互式 getty 或 ash 控制台,也没有配置网络管理服务。串口仍输出启动日志和 hello 心跳,便于验证。
运行后发送回车、Ctrl-C、shell 命令和 Ctrl-D,没有出现命令执行,心跳继续。终端可能回显输入字符,例如显示 ^C 或输入的命令文本;回显与获得 shell 是两回事,判断依据是命令是否产生执行结果、是否出现可交互提示符、程序是否继续运行。
10 月 19 日已在最终 eMMC 冷启动后重复上述测试并通过。当时的完整运行策略位于 secure-full-start.sh:检查 RAM 根、核对 eMMC、测试 TA 身份、只读解锁、核对程序哈希、检查无 TCP 监听,再锁定后续模块加载并降权运行。维护镜像只允许所有者密钥通过 SSH 管理;替代引导路径仍需最终硬件信任根约束。fix5 master 调试变体后来把受限所有者 SSH 与业务 API 作为明确例外单独验收。没有 TCP 监听不等于所有协议、应用漏洞都已经做过审计。
8. 第四条主线:分三段建立启动签名链
8.1 第一段:U-Boot 验证 Linux FIT
Linux FIT 内包含三部分:内核 Image、板级 DTB、压缩的 initramfs。签名不是只对内核文件做一个独立校验,而是对配置节点签名,让所选内核、设备树和启动根文件系统共同受到约束。
本次配置使用 sha256,rsa2048。控制设备树中放入对应公钥,并将它标记为 required = "conf"。这一标记很关键:只有镜像里出现一个 signature 节点,并不能证明加载端会强制要求它通过。U-Boot 对必需密钥和配置签名的说明可参阅官方文档。U-Boot FIT Signature Verification
相关产物在:
$AUDIT/hello-boot/hello.its
$AUDIT/hello-boot/hello.itb
$AUDIT/hello-boot/hello-wrong-key.itb
$AUDIT/hello-boot/firmware-fdt-1.bin
$AUDIT/hello-boot/manifest.json
主机上检查已生成产物的核心命令如下:
# 编译机,只读验证已生成的 FIT 与控制 DTB
TOOLS="$AUDIT/tee-lab/uboot-out/tools"
"$TOOLS/dumpimage" -l "$AUDIT/hello-boot/hello.itb"
fdtget -t s "$AUDIT/hello-boot/firmware-fdt-1.bin" \
/signature/key-hello-boot required
"$TOOLS/fit_check_sign" \
-f "$AUDIT/hello-boot/hello.itb" \
-k "$AUDIT/hello-boot/firmware-fdt-1.bin"
# 负向样本:预期非零退出,而不是把失败当作构建异常修掉
"$TOOLS/fit_check_sign" \
-f "$AUDIT/hello-boot/hello-wrong-key.itb" \
-k "$AUDIT/hello-boot/firmware-fdt-1.bin"
hello-wrong-key.itb 使用另一把密钥签名。测试版 U-Boot 会先尝试它,验证失败后再加载正确版本。这个流程使每次实验启动都能同时看到一个明确的拒绝结果和一个正常结果。
板上关键日志是:
Verifying Hash Integrity ... sha256,rsa2048:hello-boot- error!
Failed to verify required signature 'key-hello-boot'
HELLO_NEGATIVE_FIT_REJECTED
Verifying Hash Integrity ... sha256,rsa2048:hello-boot+ OK
Starting kernel ...
前半段的错误是有意构造的负向测试,不是设备无法启动。正式产品不需要每次先读一份四十多 MiB 的错误镜像;本轮保留它,是为了让验签行为可观察。后续生产版应改成经过验证的主镜像 / 恢复镜像策略。
8.2 第二段:SPL 验证 U-Boot、TEE 和 BL31
只保护 Linux FIT 还不够。如果前面的 U-Boot 可以被替换,攻击者就可以换一个不执行验签的 U-Boot。因此第二步是在 SPL 中加入必需公钥,并给包含 U-Boot、TEE、BL31 等组件的固件 FIT 加上配置签名。
本轮 SPL 已开启的关键选项包括:
CONFIG_SPL_FIT=y
CONFIG_SPL_FIT_SIGNATURE=y
CONFIG_SPL_FIT_FULL_CHECK=y
CONFIG_SPL_OF_CONTROL=y
CONFIG_SPL_RSA=y
CONFIG_SPL_RSA_VERIFY=y
这也说明一个容易忽略的问题:编译选项已经支持验签,不代表实际产物已经装入了必需公钥。 最初实验日志只显示 SHA-256 哈希检查;后续把公钥和配置签名补齐后,才看到 SPL 输出 RSA 验签通过。
实现脚本:
secure-openwrt/build-hello-spl-chain.py
$AUDIT/build-hello-spl-chain.py
脚本先用原 DDR 数据和 SPL 重新封装 ID block,并要求结果与已有 ID block 完全一致。这样先确认封装方式正确,再改变签名相关内容。之后给固件配置增加签名,覆盖 firmware、loadables 和 fdt 引用,并检查每个镜像节点及其 hash 节点都进入签名覆盖范围。
实际覆盖的节点为:
u-boot
atf-1
atf-2
atf-3
atf-4
atf-5
atf-6
tee-1
fdt-1
没有把 TEE 当作一个可以随意替换的外部附件;它和其他启动组件一起受该级签名约束。
主机检查命令:
# 编译机,只读检查
SPLTEST="$AUDIT/hello-spl-chain"
fdtget -t s "$SPLTEST/spl-control.dtb" \
/signature/key-hello-boot required
"$TOOLS/fit_check_sign" \
-f "$SPLTEST/firmware-signed.itb" \
-k "$SPLTEST/spl-control.dtb"
# 下面两条都预期失败:一个用错误密钥,一个没有该级签名
"$TOOLS/fit_check_sign" \
-f "$SPLTEST/firmware-wrong-key.itb" \
-k "$SPLTEST/spl-control.dtb"
"$TOOLS/fit_check_sign" \
-f "$AUDIT/hello-boot/firmware.itb" \
-k "$SPLTEST/spl-control.dtb"
板上正向启动的关键输出变为:
## Checking hash(es) for config config-1 ... sha256,rsa2048:hello-boot+ OK
## Checking hash(es) for Image atf-1 ... sha256+ OK
## Checking hash(es) for Image u-boot ... sha256+ OK
## Checking hash(es) for Image fdt-1 ... sha256+ OK
...
## Checking hash(es) for Image tee-1 ... sha256+ OK
这一层的错误密钥与未签名拒绝测试已经在主机做过,尚未把错误固件 FIT 放到板上,让 SPL 实际走一次拒绝和恢复路径。文章保留这个差别,不把主机检查升级为未发生的上板负向验证。
8.3 第三段:最初引导的厂家格式签名
再往前走就是 BootROM 读取的最初引导。主线 U-Boot 生成的 ID block 与厂家安全启动签名工具需要配合验证,不能简单地把 Linux FIT 的签名方法搬到这一层。
本轮使用固定版本的 rk_sign_tool 与配套配置文件,选定 RK3568,对已经验证过的 DDR + SPL ID block 生成厂家格式签名。工具和配置放在独立实验目录,避免更改其它工程正在使用的工具设置。
对应路径:
$AUDIT/hello-rom-signing/rk_sign_tool
$AUDIT/hello-rom-signing/boot_merger
$AUDIT/hello-rom-signing/setting.ini
$AUDIT/hello-rom-signing/setting.upstream.ini
$AUDIT/hello-rom-signing/hello-boot.pubkey
$AUDIT/hello-rom-signing/hello-idblock-signed.img
$AUDIT/hello-rom-signing/hello-rom-bootloader.bin
$AUDIT/hello-rom-signing/manifest.json
配置文件来自 Rockchip 官方 rkbin 的固定提交。该文件包含芯片支持列表、新 ID block 格式和签名方案相关分类;缺失它时,仅复制工具二进制会导致工具无法正确识别配置。本次使用的 setting.ini
实际签名阶段的核心命令如下,均在编译机的普通文件上执行:
# 编译机:文件级签名操作,不连接板子、不写存储设备
ROMTEST="$AUDIT/hello-rom-signing"
KEYS='<RESTRICTED_SIGNING_KEY_DIR>'
cd "$ROMTEST"
openssl pkey -in "$KEYS/hello-boot.key" \
-pubout -out hello-boot.pubkey
./rk_sign_tool cc --chip 3568
./rk_sign_tool lk \
--key "$KEYS/hello-boot.key" \
--pubkey "$ROMTEST/hello-boot.pubkey"
# 清除签名附加标志;本轮没有使能烧录标志
./rk_sign_tool ss --flag 0
# 目标必须是已准备好的实验副本,工具会修改该文件
./rk_sign_tool sb --idb "$ROMTEST/hello-idblock-signed.img"
./rk_sign_tool vb --idb "$ROMTEST/hello-idblock-signed.img"
完整入口是 prepare-hello-rom-signature.py。它复制输入文件、检查工具配置、签名、验证,再将签名后的 ID block 与已经验证的后续固件组合。签名期间 DDR 与 SPL 的可执行载荷保持不变,改变的是头部签名等数据。
这份带厂家格式签名的引导已经写入 SD 并在未锁定芯片上成功启动,后面的 SPL 验签、Linux FIT 验签、TEE 解锁和 hello.py 心跳均正常。这个结果可以写为“签名格式与当前启动链兼容”,不能写为“BootROM 已经在拒绝未签名引导”。
8.4 一个实际发现:厂家验签工具没有替我们检查全部载荷
文件级测试时,我先改动了签名 ID block 中的一字节程序载荷,再运行 rk_sign_tool vb --idb。工具仍然返回验证成功。继续核对后,本轮确认该命令检查的是头部签名,并不能替代对 DDR/SPL 载荷的独立哈希检查。
于是验证拆成两部分:
- 用厂家工具检查头部签名,再用已知公钥和 OpenSSL 独立复核。
- 根据 U-Boot 2025.10 的
tools/rkcommon.c中的 ID block 布局,分别读取两段载荷的范围和记录的 SHA-256,独立计算比较。
这不是“签名无效”,而是“工具报告的成功覆盖了什么”必须明确。头部签名保护载荷哈希,载荷字节还要与这些哈希比较,才能形成完整的文件级检查。
OpenSSL 复核使用脚本已经拆出的头部与签名文件:
# 编译机,只读验证;这两个输入由准备脚本按当前格式生成
openssl dgst -sha256 \
-verify "$ROMTEST/hello-boot.pubkey" \
-signature "$ROMTEST/header-signature.bin" \
-sigopt rsa_padding_mode:pss \
-sigopt rsa_pss_saltlen:32 \
"$ROMTEST/header.bin"
结果是 Verified OK。改变头部签名字节后,厂家工具拒绝;改变载荷字节后,独立载荷检查拒绝。prepare-hello-rom-signature.log 保存了这一阶段最终检查输出。
8.5 OTP 到底还差什么
当前的 SPL、U-Boot 和镜像中都已经有签名和公钥,但最前面的芯片信任根尚未锁住。别人如果可以替换最初引导,就可能启动另一套系统,绕过当前镜像中的限制。因此“有签名文件”和“设备只允许自己的签名文件启动”是两个阶段。
厂商 U-Boot 源码中的 scripts/fit-core.sh 和 lib/rsa/rsa-verify.c 还包含 burn-key-hash 相关路径;该路径涉及把公钥哈希和安全启动使能状态写入 OTP。本轮没有启用它,也没有运行相应烧录流程。相关源码可以在本次厂商 BSP 副本中审阅:
$AUDIT/vendor-bsp/u-boot/scripts/fit-core.sh
$AUDIT/vendor-bsp/u-boot/lib/rsa/rsa-verify.c
文章不把尚未验证的 OTP 地址、写入顺序或烧录命令拼成可直接执行的脚本。锁定前需要先完成匹配的受信恢复引导、升级与密钥备份流程,并验证锁定后不再能依赖现在的未签名旧系统恢复。这个阶段的风险不是普通构建失败,而是可能永久改变设备接受哪些引导文件。
9. 早期 SD 写入流程:先确认对象,再回读验证
本节保留 10 月 15 日的阶段操作。10 月 19 日的正常程序更新只更换容器和运行 FIT,不重刷引导区;不要重复执行本节的一次性阶段脚本。
9.1 保存原始恢复材料
本地备份文件:
.secure-sd-test/sd-first32MiB.bin
大小为 33554432 字节,SHA-256 为:
a400a1998bcfd59edcb39e54828a877f8b208db378e68feaefae57fd52d7ace6
PowerShell 只读检查:
Get-Item -LiteralPath "$LabRoot\.secure-sd-test\sd-first32MiB.bin" |
Select-Object Name, Length
Get-FileHash -Algorithm SHA256 -LiteralPath "$LabRoot\.secure-sd-test\sd-first32MiB.bin"
这份备份只覆盖前 32 MiB,不是 SD 全盘备份,也没有包含后来新建的整个加密程序容器。它的用途是恢复实验前的引导保留区,不是恢复全部应用数据或 TEE 持久化状态。
9.2 写盘之前检查什么
维护系统中的只读检查包括:
# 板端维护 shell:只读识别
uname -r
cat /etc/openwrt_release
cat /sys/class/block/mmcblk0/device/cid
cat /sys/class/block/mmcblk0/size
cat /sys/class/block/mmcblk1/device/cid
cat /proc/mounts
写入脚本还检查目标 SD 没有被挂载、镜像大小正确、新镜像 SHA-256 正确,以及 SD 上当前引导与预期上一版相符。最后一项很有用:它防止在未知状态的介质上直接应用一个只适用于特定阶段的更新。
9.3 本轮最终写入命令的结构
以下是最后一轮 SD 更新脚本的结构,设备 CID 的实际值已改为占位符。不要把它当成刷 eMMC 的命令;它只针对本次核对过的 SD 和固定的前一版 / 后一版产物。
#!/bin/sh
set -eu
dev=/dev/mmcblk0
image=/tmp/hello-rom-bootloader.bin
[ "$(cat /sys/class/block/mmcblk0/device/cid)" = '<EXPECTED_SD_CID>' ]
[ "$(cat /sys/class/block/mmcblk0/size)" = 61067264 ]
if grep -q '^/dev/mmcblk0' /proc/mounts; then
echo SD_STILL_MOUNTED
exit 1
fi
[ "$(wc -c < "$image" | tr -d ' ')" = 9963520 ]
expected=e5a668ef3ac87562d452cae3bdd1ef83b7a446f3259ffe573e5fe50de73e2ecb
actual=$(sha256sum "$image")
[ "${actual%% *}" = "$expected" ]
previous=743d1a3dbbe8164581d498430552aa294bbbcfdef6dbe0a0548c169589958a4b
actual=$(dd if="$dev" bs=512 skip=64 count=19460 2>/dev/null | sha256sum)
[ "${actual%% *}" = "$previous" ]
dd if="$image" of="$dev" bs=512 seek=64 conv=notrunc
sync
actual=$(dd if="$dev" bs=512 skip=64 count=19460 2>/dev/null | sha256sum)
[ "${actual%% *}" = "$expected" ]
echo HELLO_ROM_SD_BOOT_WRITE_READBACK_OK
bs=512、seek=64 和 count=19460 都与本轮镜像布局对应。conv=notrunc 用于保留目标其他区域,真正防止写错目标的关键仍然是前面的身份、布局和长度检查。生产工具应把这些检查做成不可跳过的流程,而不是依赖操作者记住所有扇区值。
最终串口记录包含:
SD_CID_SIZE_PREVIOUS_BOOT_AND_NEW_IMAGE_VERIFIED
19460+0 records in
19460+0 records out
HELLO_ROM_SD_BOOT_WRITE_READBACK_OK
WRITE_EXIT=0
10. 产物版本与 SHA-256 对照
10.1 当前 eMMC 发布与恢复产物
eMMC runtime.itb:
40aee0f2928415ecc3793a22b841ceed6419141c18094a3947731453456e5253
eMMC bootloader:
a90e7a904690cfc6a32b1500a29c0900320766f7e8509941caa2729937068153
SD eMMC recovery FIT:
ee1a1b38a74dbeb7680fa2533731f113f6235df1993c0d63189693ea0e6b56d8
hello.py v2:
a1e9beea2ed556b7593eb25367fb72ae661dcef96939ca3ba4f7e62ff974d686
部署前完整 eMMC 备份为 15,634,268,160 字节,已在电脑端完整解压核对大小和原始 SHA-256。压缩文件约 78.8 MB 是因为原介质大部分空间可压缩,不意味着只备份了几个文件。整盘备份、TEE 存储和签名私钥均不公开下载。
10.2 保留的早期 SD 阶段产物
下面是 10 月 15 日的阶段哈希,不是现在 eMMC 的当前版本。
本轮先后生成三版 hello 引导,名字相近,但保护范围不同。排查时一定要带上阶段名称和校验值。
| 文件 | 大小 | 所处阶段 |
|---|---|---|
hello-bootloader.bin | 9963520 字节 | U-Boot 强制验证 Linux FIT;当时 SPL 固件 FIT 还只有哈希 |
hello-spl-bootloader.bin | 9963520 字节 | 增加 SPL 对固件 FIT 的必需公钥验证 |
hello-rom-bootloader.bin | 9963520 字节 | 再给最初 ID block 增加厂家格式签名;本轮最后写入 SD 的版本 |
hello.itb | 47173930 字节 | 正确密钥签名的 Linux / DTB / initramfs |
hello-wrong-key.itb | 47173930 字节 | 错误密钥签名的板上负向样本 |
hello-idblock-signed.img | 188416 字节 | 带厂家格式签名的初级 ID block,仅是完整引导的一部分 |
对应 SHA-256:
hello-bootloader.bin
2c5cea6c7378cc0a4837755b0c77c0d3febd4936a9a6349b75d36f038b8e1044
hello-spl-bootloader.bin
743d1a3dbbe8164581d498430552aa294bbbcfdef6dbe0a0548c169589958a4b
hello-rom-bootloader.bin
e5a668ef3ac87562d452cae3bdd1ef83b7a446f3259ffe573e5fe50de73e2ecb
hello.itb
4049b236830865a8fff8904c0f4ad54aaf68b47bc0e046714b7cbabfb3a12af7
hello-wrong-key.itb
6b14f9419e0257de90b37a86cd0be8fc08e7a5fd72d766e719ac8156eeaaf029
hello-idblock-signed.img
9db633da841abea17933b6ede6f993a0e54b348652616cd42b4697b79bc3d9a6
本地完整引导和 Linux FIT 位于 .secure-sd-test/netboot/;初级 ID block 的独立文件保存在编译机 $AUDIT/hello-rom-signing/。同一个文件名重新构建后,时间戳、签名随机盐或输入内容都可能使校验值改变;文章中的哈希只标识这一次经过验证的产物,不是所有未来构建都应得到的“标准答案”。
本地核对命令:
Get-Content -LiteralPath "$LabRoot\secure-openwrt\emmc-deployment-20261019\serial-negative.json"
Get-FileHash -Algorithm SHA256 -LiteralPath "$LabRoot\.secure-sd-test\netboot\hello.itb"
Get-Content -LiteralPath "$LabRoot\secure-openwrt\emmc-deployment-20261019\deployment-state.json"
编译机核对命令:
sha256sum "$AUDIT/hello-rom-signing/hello-rom-bootloader.bin"
sha256sum "$AUDIT/hello-boot/hello.itb"
cat "$AUDIT/hello-rom-signing/manifest.json"
cat "$AUDIT/hello-session-status.json"
构建阶段 manifest 中的“hardware test pending”记录的是生成文件那一刻的状态,之后的上板结果在会话状态和启动日志中补充。不要因为构建 manifest 没被原地改写,就忽略后续证据;也不要反过来只看“已上板”,忘记文件级检查属于哪一份二进制。
11. 日志文件与怎样判断一次验证通过
本节日志表和示例属于早期 SD 阶段;当前 eMMC 证据入口见第 4.4 节和第 2 节。不要拿旧启动日志替代本次冷启动记录。
11.1 主要证据文件
以下均位于本地工作区的 .secure-sd-test/:
| 文件 | 记录内容 |
|---|---|
sd-backup.json、sd-backup-board-check.txt | 最初介质识别和备份信息 |
emmc-recovery-check.txt | 返回原 eMMC 后的系统版本、容量检查 |
hello-runtime.txt | 手工运行时验证、解锁与进程权限检查 |
hello-console-test.txt | 串口输入负向测试,程序心跳仍继续 |
hello-offline-boot.txt | 第一版 hello 离线启动,内核错误签名拒绝 |
hello-spl-write-log.txt | SPL 验签版本写 SD 与回读检查 |
hello-spl-offline-boot.txt | SPL 验签版本的板上离线启动 |
build-hello-spl-chain.log | SPL 产物的主机签名检查 |
prepare-hello-rom-signature.log | 厂家初级签名、OpenSSL、签名破坏和载荷检查 |
hello-rom-write-log.txt | 最后版本写 SD 与回读检查 |
hello-rom-offline-boot.txt | 最后版本完整离线启动日志 |
session-status.json | 最终部署状态、未完成项和日志哈希 |
11.2 最后一轮启动应串起来看
只看到 Hello from BPI-R2 Pro,只能证明某个 hello 程序运行了。完整判定要把前面的启动组件、签名、TEE 和挂载结果串起来:
SPL configuration RSA signature: OK
U-Boot wrong-key Linux FIT: rejected
U-Boot correct Linux FIT: OK
Linux version: 6.12.94
optee: initialized driver
dm-0: mounted ... ro
HELLO_OFFLINE_UNLOCK_OK: readonly encrypted volume; network services disabled
hello.py started
HELLO_APP_READY: pid=203 uid=65534; no interactive console
... Hello from BPI-R2 Pro
... Hello from BPI-R2 Pro
上面是判读顺序,保留了实际关键标记,并把冗长的启动信息缩写。HELLO_APP_READY 中的 UID 文字来自启动策略输出;进程真实 UID/GID 的检查是在前面的手工验证阶段完成的。严格的生产验收还应在受控诊断模式下独立读取进程凭据,而不是只相信程序自己打印的字符串。
最后一轮物理网线已拔掉,也没有依赖 TFTP 或 HTTP 来下载启动文件。板子从 SD 读取 FIT 和加密容器,TEE 读取持久化对象后解锁,程序连续输出心跳。复位测试验证了重新加载系统后的持久化使用;它仍不等于经过了长时间断电、完全掉电的冷启动与掉电一致性测试。
快速搜索日志:
Select-String -LiteralPath "$LabRoot\.secure-sd-test\hello-rom-offline-boot.txt" `
-Pattern 'rsa2048','Failed to verify','optee','HELLO_','Hello from BPI-R2 Pro'
Get-Content -LiteralPath "$LabRoot\.secure-sd-test\hello-console-test.txt"
12. 构建入口与复现边界
12.1 先确认使用哪一棵源码树
在编译机上,下面这些是只读检查:
cd "$BUILD_ROOT/openwrt-secure-25.12.5"
git describe --tags --always
git rev-parse HEAD
git status --short
df -h .
本轮得到的标签为 v25.12.5,提交为前面列出的 f0a60...。自定义补丁和模块文件需要另外保存,不能只记录一个 Git 提交就认为所有实验改动都已经被提交管理;实际 git status --short 中仍能看到未跟踪的定制文件。
ath9k 已在完整新系统上识别 AR9300,并验证 v6 频点表;此前被动扫描成功。hello 本身不使用无线。关联、吞吐量、长时间运行和发射性能验收仍没有完成,不能把模块加载成功写成无线功能全部验收完成。
12.2 各构建脚本的职责和阶段调用
下面保留早期 SD 构建入口。当前应用发布入口是 build-full-secure.py,完整命令见第 15 节;这里不是一条可从头重复执行的量产流水线:
# 编译机,builder 用户;每个脚本都依赖对应阶段已准备的输入
python3 "$AUDIT/prepare-netboot-kernel.py"
python3 "$AUDIT/build-tee-lab.py"
python3 "$AUDIT/build-hello-runtime.py"
HELLO_AUTOSTART=1 python3 "$AUDIT/build-tee-netboot.py"
python3 "$AUDIT/build-hello-boot.py"
python3 "$AUDIT/build-hello-spl-chain.py"
python3 "$AUDIT/prepare-hello-rom-signature.py"
注意三个实际限制:
build-tee-lab.py属于早期诊断阶段,会生成保留恢复控制台的配置。它不是最终无 shell 产品的默认构建入口。build-tee-netboot.py在 hello 自动启动模式下才装入对应/init。不能遗漏HELLO_AUTOSTART=1后仍假定得到最终策略。- 后两段脚本有固定输入哈希断言,针对本轮经过验证的上一阶段产物。如果从第一段重新构建导致输入变化,需要重新审阅并建立新的产物清单,不能简单删除断言使构建“通过”。
另外,旧 hello-runtime.tar.gz 在某一阶段打包时,host 工具还没有最终的 unlock-demo 模式;最终启动镜像在组装时显式覆盖了最新 volume-unlock。因此归档文件“存在”不代表它与最终镜像内所有组件都完全一致。正式复现流程应消除这种阶段差异,统一从冻结的源码、配置和依赖生成产物。
12.3 密钥文件在哪里
早期开发签名身份与后续候选发布签名身份分开保存;OS FIT、固件 FIT 和 TA 也使用彼此分离的身份。私钥只存在于所有者控制的受限签名环境中,权限和备份另有私有记录,设备交付镜像与博客均不包含其内容、实际路径或文件名。错误密钥测试材料与发布身份严格分开,不能混用。
10 月 19 日的新链已经在 SD 上启动成功,最终 eMMC 也完成冷启动。候选发布密钥的使用不代表 OTP 已锁定或已经通过全部生产资格测试。
日常替换 Python 只使用现有 OS FIT 私钥签署新运行镜像,不需要更换固件 FIT 密钥、TA 密钥或 TEE 解锁秘密。所有者 SSH 登录私钥又是另一种用途,不应与上述签名私钥混淆。私钥留在所有者受控环境,不能进入设备交付镜像或公开附件。
不能在已经部署并依赖现有密钥的设备上,随意删除开发密钥目录后“重新生成一套试试”。 同样,程序版本更新也不应触发 TA 对象重新创建。文件签名身份、TEE 持久化秘密和 LUKS 内部卷密钥是不同生命周期的材料,需要分别管理。
13. 实验中遇到的问题与修复经验
13.1 内核能启动,不代表支持外部 initramfs
早期沿用的内核配置没有开启 CONFIG_BLK_DEV_INITRD。在通过 U-Boot 加载外部 initramfs 的实验路径中,这会影响预期根文件系统接管。后续单独构建支持 initramfs 的测试内核,才把诊断环境和最终 hello 自动启动环境跑通。
相关文件包括 $AUDIT/tee-netboot/Image-with-initrd、实验内核配置,以及 prepare-netboot-kernel.py。原常规构建配置与测试配置需要分开保存,避免为了排查问题临时改的选项悄悄变成正式系统配置。
13.2 TEE 内存区域要与固件加载位置匹配
本次 BL32 加载地址使用 0x08400000,设备树保留了对应 16 MiB 区域,并增加 OP-TEE 节点。启动日志能够看到保留区域和 OP-TEE 驱动初始化。
把一个 BL32 文件放进引导包,并不意味着 Linux 会自动知道它占用哪段内存、使用什么调用方式。地址、保留内存、固件格式和设备树需要一起匹配。本轮对 BL32 的 ELF 包装只用于表达加载地址,保留了已准备的 BL32 载荷字节。
13.3 TEE 存储路径被覆盖,可能看起来像密钥坏了
手工调试时曾出现 0xf0100003 存储不可用。原因不是需要重新生成密钥,而是挂载关系使预期的 secure-storage 路径被其它挂载遮住。修正挂载后,TA 对象访问恢复。
遇到这种问题应先检查 /proc/mounts、supplicant 的存储位置和目录权限。把“读不到”直接处理成“重建密钥”,会把一个可修复的路径问题变成不可恢复的数据问题。
13.4 精简 BusyBox 的 applet 不能想当然
实验根文件系统混合使用了已有工具和新运行库,有些常见命令并不存在。例如某个 BusyBox 构建没有期望的 insmod、wget 或 losetup applet。后续使用实际的 kmodloader、明确拷贝库依赖,并补齐模块目录。
同样,手工指定模块路径也不表示 kmodloader 可以忽略 /lib/modules/内核版本 的存在。最终打包脚本把目录和模块依赖一起处理,避免启动时才发现缺少 dax、device-mapper 或算法模块。
13.5 匿名管道必须跨 exec 正确保留标准输入
host 解锁工具使用带 close-on-exec 标记的管道。将读端接到标准输入之后,还需要确保标准输入不会在执行 cryptsetup 时被关闭。最终代码在 dup2() 后显式清除该描述符的 close-on-exec 标记,并在子进程中清零继承的秘密缓冲区。
这类问题的表现可能是 cryptsetup 读不到密钥材料,而不是 TEE 读取失败。把两个阶段的错误分开记录,才能避免错误地怀疑持久化对象损坏。
13.6 降权后要检查每一级父目录权限
启动阶段使用 umask 077 是为了限制新文件权限,但它也曾让 /opt 目录权限过于严格,导致 UID 65534 无法访问程序。修复时对 /opt 和 /opt/hello 明确设置 755,并验证普通用户能够读取程序。
这不意味着应该把所有目录都放宽。加密容器文件、TEE 存储目录和程序只读目录的权限目的不同,应该分别设置。让 Python 降权成功,和让它有必要的只读访问能力,需要同时满足。
13.7 串口粘贴长命令会被截断
实际传输较长 base64 命令时出现过输入截断,shell 停在 > 续行提示符。那次写入没有成功执行。后续改为短行 heredoc,每行限制长度,用回车结束,并在运行脚本前比较脚本本身的 SHA-256。
U-Boot 命令输入尤其要区分回车和回车换行。在某些终端组合中,额外的换行会造成重复执行上一条命令。写盘、启动和引导交互不适合依靠未经验证的大段粘贴。
13.8 串口日志采集器会独占 COM 口
同一时间只能由一个脚本打开 COM7。开始串口交互前,先让采集器退出;进入需要物理复位的阶段,再启动新的日志采集。否则出现“串口打不开”,并不一定是线缆或驱动坏了。
本轮各阶段使用不同日志和停止标记,便于确认哪个工具仍持有串口。最终验证完成后,采集器和临时 HTTP/TFTP 服务都已关闭,板上 hello 程序继续运行。
13.9 拔网线测试后,下一轮传输前要确认物理链路
有一次返回 eMMC 后下载没有成功,检查发现板端 eth0/carrier 为 0,Windows USB 网卡也显示未连接。重新插紧网线后即可传输。此时没有必要重刷固件,也没有必要修改防火墙或 DNS。
诊断命令可以先保持简单:
# 板端维护环境,只读
cat /sys/class/net/eth0/carrier
ip -4 addr show
# Windows,只读
Get-NetAdapter | Select-Object Name, Status, LinkSpeed
实际接口名要按当前系统确认;交换芯片的 CPU 端口显示 Link Up,不代表外接网线已经连到预期的物理端口。
13.10 编译空间、依赖和旧文件系统问题已经处理
10 月 15 日完整构建因虚拟磁盘空间不足中断;随后按所有者选择清理旧项目,释放空间并继续构建。现在完整固件已经构建完成,空间不足不是当前仍在等待解决的阻塞。
Scapy 的主机构建依赖需要在 feeds/packages/net/scapy/Makefile 补充 PKG_BUILD_DEPENDS:=python-setuptools/host。另一个问题是 make_ext4fs 生成的 inode 位图填充不符合检查要求,源码修复保存在 tools/make-ext4fs/patches/100-inode-bitmap-padding.patch。旧 eMMC 上同类错误则在整盘备份后离线使用 e2fsck 修复,再只读复查通过。这里的修复有明确目标与日志,不能把对已挂载文件系统执行修复当作常规维护步骤。
13.11 完整系统集成时的启动顺序
完整运行策略曾因加载不存在的可选模块、只读挂载后再 chmod 挂载点而中断。修正后只对实际需要的模块做加载检查,并将挂载点权限设置放在只读挂载前。这些问题解释了为什么“早期最小系统能运行”不能直接证明完整发行版已经集成成功。
最终按顺序完成完整系统 SD 测试、TA 身份拒绝测试、程序 v2 更新、新签名身份测试、eMMC 恢复验证和无 SD 冷启动,才把状态改为已部署。
14. 2026-10-19 当时的完成项与待办(历史)
14.1 不再列作待办的项目
完整 OpenWrt 构建、ath9k 设备识别与频点表验证、eMMC 部署、无 SD 断网冷启动、TA 公开会话拒绝、普通用户运行、串口命令拒绝、专用维护 SSH、新签名身份及备份均已完成。单文件 Python 更新流程已在 SD 上完整验证,v2 随后迁入 eMMC;专门维护 eMMC 的 SD 镜像也已上板并成功解锁实际 eMMC 程序卷。
这不等于每一条升级路径都完成了批量或破坏性测试。本文后面的 eMMC 新版本更新步骤依据现有接口整理;并没有为了写文章再执行一次 v3 更新或掉电故障注入。
14.2 OTP 硬件信任根:最主要的未完成项
尚未烧录 RK3568 OTP,也没有证明 ROM 已拒绝未授权最初引导。当前最初引导的厂家签名格式和 sign_flag=0 启动兼容成功,不能升级成硬件安全启动已经启用。
在这个状态下,具有物理访问权的人可能替换最早启动代码,进而绕过后续公钥、内核或权限检查。因此,本方案暂不能作为“设备交给对方后无法提取程序”的保证。下一步必须核实本板与当前引导格式匹配的厂商流程、锁定后恢复路径和密钥备份,再经过明确授权处理不可逆锁定;本文不提供猜测的 OTP 地址与烧录顺序。
14.3 SPL 的硬件负向验收
SPL 正确签名在板上通过;错误签名、未签名固件 FIT 在主机验证工具中被拒绝。还需在可恢复条件下,让实际 SPL 处理错误样本并验证停止启动和恢复行为。这项与已通过的 U-Boot 错误 Linux FIT 拒绝测试不同。
14.4 HUK / 跨设备绑定与 RPMB 防回滚
厂商闭源 BL32 的 HUK 行为尚未独立验证,也未做第二块板复制负向测试。单板重启、换签名身份后仍能解锁,只证明持久化对象在该板可用,不能证明复制全部存储到别的板一定失败。
RPMB 没有初始化,没有基于硬件单调状态的版本拒绝策略。旧合法签名镜像仍可能被重放。所有者保存旧 FIT 用于手动恢复是可用性设计,和“拒绝旧版本攻击”的防回滚不是同一件事;将来要协调两者的版本策略。
14.5 量产所需的可靠性与应用适配
已做真正的断电冷启动,但没有完成多轮随机掉电、更新中断、存储损坏、长期运行、自动 A/B 回退或跨板批量部署测试。当前更新失败后的恢复由所有者用 SD 手动完成。原子重命名和 fsync 可减少切换风险,不能替代断电实测,也不构成整个发布事务的自动回滚系统。
多文件 Python、第三方依赖、大容器、硬件访问权限、可变配置和数据目录需要按真实业务适配;当前只能将不超过 1 MiB 的单个 Python 文件作为更新输入。无线尚缺关联、吞吐量等完整实机验收。
14.6 运行期明文与权限边界一直存在
程序在普通 Linux 中运行,解锁后必然可由内核读取,Python 也必须得到明文代码。应用降权、禁止登录、关闭调试入口有实际作用,但不能抵御已经完全控制内核或 root 的攻击者。TA 的 root 身份门槛也不解决这个问题。
dm-verity 没有在当前方案中部署;当前系统完整性依靠签名覆盖完整 RAM 根,应用完整性依靠签名策略里固定的单文件哈希。扩大保护范围时,应继续明确每个会被执行或加载的文件由哪一层校验。
15. 旧版单文件 hello.py 更新流程(历史,不用于 fix5)
本节保留的是 10 月 19 日单文件演示的维护思路。master fix5 已经改用完整离线 bundle、精确 manifest、256 MiB 应用容器和角色化签名镜像;不要再按本节命令把真实项目压回单个 hello.py,也不要把旧容器名、旧哈希或旧 UID/GID 复制到当前发布。
15.1 先理解一次发布由什么组成
日常替换程序不需要重新刷整块 eMMC,不需要重新写 SPL/U-Boot,也不需要生成新签名密钥或重新创建 TEE 秘密。当前发布由两个相互匹配的东西组成:包含新程序的加密容器,以及 固定新容器文件名和程序哈希的签名运行 FIT。
只覆盖容器里的 hello.py,会触发旧运行 FIT 的哈希检查失败;只换 FIT 却没有对应容器,也会启动失败。这正是完整性限制在起作用,不能通过删除检查绕过去。
| 操作对象 | 普通单文件更新是否改变 |
|---|---|
| Python 内容 | 改成新程序;容器内部入口仍叫 hello.py |
| LUKS2 容器 | 新建 hello-VERSION.luks,保留现用旧容器 |
| 发布回执 | 新建 hello-VERSION.release.json,记录容器名和明文程序 SHA-256 |
| runtime.itb | 重建并用现有 OS FIT 私钥签名,固定新版本 |
| TEE 持久化秘密 | 保持原对象,不重建、不删除 |
| TEE 存储目录 | 继续使用原目录,不随程序版本替换 |
| SPL、U-Boot、BL32、TA 签名身份 | 普通程序更新不变 |
| OTP / RPMB | 本流程不写入 |
以下流程是根据当前已经存在的工具整理的 eMMC 维护操作。底层更新流程在 SD 上验证过;当前 eMMC 恢复通道、解锁和 v2 冷启动也已通过。下面的通用上传/切换示例是本次文档整理代码,并非又在板上执行过一遍新版本更新。 执行者应逐项检查自己的发布内容与路径。
15.2 哪些程序现在可以直接替换
当前 /usr/sbin/secure-app-stage 接受单个非空 Python 源文件,最大 1 MiB;保存进新的 64 MiB LUKS2 容器,容器内部文件名固定为 hello.py。输入文件在电脑上叫 main.py、app.py 都可以,更新后仍由签名启动器执行 /opt/hello/hello.py。
新程序使用当前签名系统已有的 Python 和依赖,以 UID/GID 65534 运行。它不能默认向代码目录写文件,不能假定有 root 权限、联网能力、任意设备访问权限或持久化 overlay。当前示例仅验证常驻心跳,真实业务的重启策略、健康检查和服务监督需要单独设计。
可以先用下面的单文件测试自己的发布过程:
import os
import time
print(f"MY_APP_STARTED uid={os.getuid()} gid={os.getgid()}", flush=True)
while True:
print("MY_APP_HEARTBEAT version=v3", flush=True)
time.sleep(10)
语法检查不等于依赖检查,也不会执行程序来验证功能。应先在兼容的 Python 环境测试,再进维护流程。
15.3 准备所有者维护环境
先保存当前版本的 FIT、发布回执、程序容器、构建清单和验收记录。保留当前 SD 恢复卡,保证所有者 SSH 私钥和现有 OS FIT 签名私钥可用。不要把私钥复制到板上。
10 月 19 日 hello 运行版当时没有 SSH,需要关机后插入所有者 SD,连接维护网线与串口,再进入专用维护环境。fix5 master 调试变体后来保留了受限所有者 SSH,但这不把旧单文件更新步骤变成当前部署流程。进入维护后要看到 SECURE_MAINT_READY;维护卡必须是维护 eMMC 的那版,不能用仍指向 SD 数据的旧维护镜像。公开版不展开具体按键时序和本机串口编号。
维护 SSH 使用私有记录中的非默认端口,并仅允许配置的管理电脑地址访问。它每次启动在 RAM 生成临时主机密钥,因此需根据本次物理串口日志中的主机公钥记录固定服务器身份。不要复用旧日志,不要用关闭主机密钥检查来消除报错。公开版不披露实际地址、端口或指纹。
本地工程已有 owner_ssh.py,其 connect(log=...) 实现了这种固定。该脚本包含本项目的实际地址与私钥位置,文章不公开这些值;迁移环境时按自己的配置调整。默认 log 指向旧测试记录,因此这里必须显式传入新的完整串口日志路径。
在 Windows 的 secure-openwrt 目录启动 Python,以下示例在同一个电脑端 Python 会话中依次使用。电脑需已有 Paramiko,板端不需要 SFTP。
from pathlib import Path
import hashlib
import json
import re
import shlex
from owner_ssh import connect, run
# 改为这一次维护启动实际采集的完整日志。
c = connect(log=Path(r"D:\release-work\maintenance-current-boot.txt"))
run(c, "cat /proc/mounts")
run(c, "cat /sys/class/block/mmcblk1/device/cid")
run(c, "test -f /media/secure-data/secure-boot/runtime.itb")
run(c, "test -d /media/secure-data/secure-data/secure-storage")
run(c, "test ! -e /dev/mapper/secure-app")
将 /media/secure-data 的实际挂载来源、CID 与自己私有交接记录核对,不能只看到路径存在就继续。维护系统不自动运行 hello,更新前 mapper 应未占用。
15.4 将程序明文只传到 RAM,不先写入 eMMC
维护镜像没有部署 SFTP,也没有独立的 base64 applet,不能假设电脑上的 scp 一定能直接传到板上。本地实测采用 SSH stdin 传输、板端 Python 接收。下面提供二进制传输函数,供后面的程序和 FIT 上传共用;它不经 shell 拼接文件内容,不要求 SFTP。
def upload_file(client, source, destination):
"""电脑端调用;远端目标必须是全新暂存路径,不覆盖已有文件。"""
source = Path(source)
expected_size = source.stat().st_size
with source.open("rb") as f:
expected_hash = hashlib.file_digest(f, "sha256").hexdigest()
receiver = r'''
import hashlib, json, os, sys
path, count, expected = sys.argv[1:]
count = int(count)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
try:
h = hashlib.sha256()
total = 0
with os.fdopen(fd, "wb") as output:
while True:
block = sys.stdin.buffer.read(1024 * 1024)
if not block:
break
total += len(block)
if total > count:
raise ValueError("Input longer than expected")
output.write(block)
h.update(block)
if total != count or h.hexdigest() != expected:
raise ValueError("Transfer size or SHA256 mismatch")
output.flush()
os.fsync(output.fileno())
print(json.dumps({"bytes": total, "sha256": h.hexdigest()}))
except BaseException:
os.unlink(path)
raise
'''
command = "python3 -I -B -c " + shlex.quote(receiver)
command += " " + " ".join(map(shlex.quote, [destination, str(expected_size), expected_hash]))
stdin, stdout, stderr = client.exec_command(command, timeout=300)
with source.open("rb") as f:
while block := f.read(256 * 1024):
stdin.write(block)
stdin.flush()
stdin.channel.shutdown_write()
output, error = stdout.read(), stderr.read()
status = stdout.channel.recv_exit_status()
if status:
raise RuntimeError(error.decode(errors="replace"))
result = json.loads(output)
if result != {"bytes": expected_size, "sha256": expected_hash}:
raise RuntimeError("Upload receipt mismatch")
return result
version = "v3-20261020" # 示例版本;请换成自己的全新版本名
if re.fullmatch(r"[A-Za-z0-9_-]{1,40}", version) is None:
raise ValueError("Invalid version")
source = Path(r"D:\release-work\my_app.py")
if not 0 < source.stat().st_size <= 1024 * 1024:
raise ValueError("Current staging helper supports at most 1 MiB")
ram_source = f"/tmp/hello-{version}.py"
sent = upload_file(c, source, ram_source)
print(sent)
/tmp 在 RAM 中,因此新程序明文不会先落到普通 eMMC 数据目录。传输异常时不要盲目重复同一路径;先检查错误与残留。SSH 断线不保证接收进程已清理,已有文件的拒绝是有意设计。
15.5 创建新加密卷,取回发布回执
在同一个电脑端 Python 会话继续:
command = "/usr/sbin/secure-app-stage " + " ".join(
shlex.quote(x) for x in [ram_source, version, sent["sha256"]]
)
output = run(c, command, timeout=300)
line = next(x for x in output.splitlines() if x.startswith("SECURE_APP_STAGE_OK "))
receipt = json.loads(line.split(" ", 1)[1])
if receipt["app_sha256"] != sent["sha256"]:
raise RuntimeError("Application digest differs")
if receipt["container_filename"] != f"hello-{version}.luks":
raise RuntimeError("Container version differs")
receipt_file = Path(r"D:\release-work") / f"hello-{version}.release.json"
receipt_file.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
等价的板端维护命令格式为:
# VERSION 和 SHA256 替换为该次实际版本及原文件摘要;不要保留尖括号执行。
/usr/sbin/secure-app-stage /tmp/hello-new.py '<VERSION>' '<SHA256>'
助手检查 root 身份、输入文件位置/所有者/类型/大小、SHA-256 和 Python 语法,并核对目标介质。之后使用已有 TEE 秘密创建新容器,格式化其内部文件系统,写入 hello.py,关闭映射,再保存回执。它不覆盖同名版本,不删除旧容器,不切换运行 FIT。成功后会删除 /tmp 的输入明文。
eMMC 维护版的输出位置是:
/media/secure-data/secure-data/hello-VERSION.luks
/media/secure-data/secure-data/hello-VERSION.release.json
本次已部署 v2 的真实回执格式如下。新版本的字段要由助手输出,不能照抄 v2 哈希:
{
"version": "v2-20261019",
"container_filename": "hello-v2-20261019.luks",
"app_sha256": "a1e9beea2ed556b7593eb25367fb72ae661dcef96939ca3ba4f7e62ff974d686",
"activation": "pending: build and install a FIT signature covering this release"
}
回执包含程序哈希而不包含解锁秘密。它本身不是签名授权文件,必须通过可信维护连接取得、由所有者审核,再作为签名构建输入。不要接收来源不明的回执直接让签名机签署。
15.6 在编译机重建签名运行 FIT
把回执传到自己的编译机,使用 builder 用户。下面假定它已保存为 $AUDIT/releases/hello-v3-20261020.release.json。版本名必须与前面一致。
sudo -iu builder
set -eu
AUDIT="$BUILD_ROOT/secure-build-25.12.5"
VERSION=v3-20261020
OUT="$AUDIT/emmc-runtime-20261019"
ARCHIVE="$AUDIT/release-archives/before-$VERSION"
# 输出目录固定,先归档旧版本;归档目标存在时停止,避免混合发布。
test ! -e "$ARCHIVE" || exit 1
mkdir -p "$ARCHIVE"
cp -a "$OUT/full.itb" "$OUT/manifest.json" "$OUT/root-manifest.json" "$ARCHIVE/"
python3 "$AUDIT/build-full-secure.py" \
--profile normal \
--release-keys \
--storage emmc \
--release "$AUDIT/releases/hello-$VERSION.release.json"
构建必须成功退出。--storage emmc 选择正确设备、CID 和数据目录;--release-keys 使用当前链信任的候选发布密钥;--profile normal 生成运行版。不能漏掉 --release 后把默认旧程序重新打进去,也不能把 maintenance 镜像当成正常运行版。
编译器从回执固定新容器名与程序 SHA-256,将完整 rootfs、内核、DTB 组装成 FIT,执行正确签名通过、错误密钥签名拒绝检查。产物仍在固定目录:
$AUDIT/emmc-runtime-20261019/
├── full.itb
├── manifest.json
├── root-manifest.json
└── root/ # 审阅生成策略,不直接作为设备更新文件
在可信编译机核对 manifest 的 profile=normal、storage=emmc、两个主机验签结果和 artifacts["full.itb"].sha256。当前 manifest 没有自动记录完整回执字段,因此发布归档应额外保存回执,并在生成的 root 中确认新容器名与新哈希都出现在运行策略中。
cat "$OUT/manifest.json"
sha256sum "$OUT/full.itb"
grep -n -F "hello-$VERSION.luks" "$OUT/root/usr/sbin/secure-full-start"
# APP_SHA 换成回执中的实际 app_sha256 后检查。
APP_SHA='<APP_SHA256_FROM_RECEIPT>'
grep -n -F "$APP_SHA" "$OUT/root/usr/sbin/secure-full-start"
这是已有构建树上的重新组装与签名,不是从干净环境自动完成依赖下载的独立发布流水线。新增系统包或依赖时,需要先更新 OpenWrt 构建产物,再组装 FIT。
15.7 上传新 FIT,保留旧版本后切换
将可信编译机的 full.itb 与 manifest.json 下载到电脑自己的发布目录。电脑到编译机的文件传输使用其正常 SSH 配置;这与精简板端不支持 SFTP 的限制不同。
在前面的电脑端 Python 会话中,上传到与 runtime.itb 同一 eMMC 文件系统中的新暂存文件。签名 FIT 不包含受保护程序源码,只包含程序哈希、系统及启动策略;它可以写到外层数据分区。
release_dir = Path(r"D:\release-work\new-runtime")
manifest = json.loads((release_dir / "manifest.json").read_text(encoding="utf-8"))
if manifest["profile"] != "normal" or manifest["storage"] != "emmc":
raise RuntimeError("Wrong image profile or storage")
if not (manifest["host_signature_positive"] and manifest["host_wrong_key_rejected"]):
raise RuntimeError("Build signature tests did not pass")
new_fit_sha = manifest["artifacts"]["full.itb"]["sha256"]
fit_file = release_dir / "full.itb"
with fit_file.open("rb") as f:
if hashlib.file_digest(f, "sha256").hexdigest() != new_fit_sha:
raise RuntimeError("Downloaded FIT differs from build manifest")
pending_fit = f"/media/secure-data/secure-boot/runtime-{version}.pending.itb"
upload_file(c, fit_file, pending_fit)
哈希防止传错或损坏,不能替代签名。manifest 必须来自自己的可信构建环境;不能信任与恶意镜像一同提供的陌生清单。激活前应再次核对板端回执、容器是否存在、新旧 FIT 哈希以及目标挂载设备。
不要先删除 runtime.itb 再复制新文件。 以下板端 Python 示例要求手工填入已审核的版本、新旧 FIT 哈希和程序哈希,保留一份旧 FIT,再使用同目录原子重命名切换。通过所有者 SSH 执行,执行前保存发布记录;它不是让交付对象自行更新的入口。
# 在板端所有者维护 Python 中执行;将四个占位符换成已审核发布值。
from pathlib import Path
import hashlib
import json
import os
import re
version = "<VERSION>"
expected_new = "<NEW_FIT_SHA256>"
expected_old = "<CURRENT_FIT_SHA256>"
expected_app = "<APP_SHA256>"
if re.fullmatch(r"[A-Za-z0-9_-]{1,40}", version) is None:
raise ValueError("Set the actual version")
if not all(re.fullmatch(r"[0-9a-f]{64}", x) for x in [expected_new, expected_old, expected_app]):
raise ValueError("Set all reviewed hashes")
mount = Path("/media/secure-data")
if not mount.is_mount():
raise ValueError("eMMC data is not mounted")
boot = mount / "secure-boot"
data = mount / "secure-data"
current = boot / "runtime.itb"
pending = boot / f"runtime-{version}.pending.itb"
backup = boot / f"runtime-before-{version}.itb"
receipt_path = data / f"hello-{version}.release.json"
container = data / f"hello-{version}.luks"
for p in [current, pending, receipt_path, container]:
if not p.is_file() or p.is_symlink():
raise ValueError(f"Invalid release file: {p}")
if backup.exists() or backup.is_symlink():
raise ValueError("Backup already exists; inspect previous attempt")
r = json.loads(receipt_path.read_text())
if (r["version"], r["container_filename"], r["app_sha256"]) != (version, container.name, expected_app):
raise ValueError("Application receipt does not match this release")
def sha(path):
with path.open("rb") as f:
return hashlib.file_digest(f, "sha256").hexdigest()
if sha(current) != expected_old or sha(pending) != expected_new:
raise ValueError("FIT hash mismatch; no activation")
with current.open("rb") as source, backup.open("xb") as target:
while block := source.read(1024 * 1024):
target.write(block)
target.flush()
os.fsync(target.fileno())
if sha(backup) != expected_old:
raise ValueError("Backup verification failed")
os.sync() # 确保新卷、回执、暂存 FIT、旧 FIT 备份先落盘。
os.replace(pending, current)
directory_fd = os.open(boot, os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
os.sync()
if sha(current) != expected_new:
raise ValueError("Activated FIT readback mismatch")
print("RUNTIME_ACTIVATED", version)
上述代码只在已核对 CID、使用正确 eMMC 维护镜像且没有并行更新任务的前提下使用。首次更新时当前 FIT 哈希可从第 10.1 节确认;第二次以后应使用最近一次成功发布记录,不能永远照抄本篇的初始值。
15.8 验证离线启动,失败时如何回退
完成同步后,在维护环境正常关机。断开电源,拔掉 SD 和网线,再正常上电,不按 SW1。观察本次新串口日志中的签名检查、TEE 身份检查、LUKS 只读解锁、新程序哈希通过、UID/GID 和业务健康输出。
自己的程序如果不再输出 HELLO_V2 字样,验收应检查它自己的版本标识和业务结果,不能沿用只匹配旧心跳文字的测试脚本。还应检查没有出现未授权 shell 或非预期管理端口;fix5 调试变体的受限所有者 SSH 需作为明确例外单独验收,并在不依赖互联网的条件下运行足够时间。
如果新程序存在逻辑错误,即使语法和签名都正确也可能无法工作。此时重新从所有者 SD 进入维护系统,核对设备与备份哈希,把 runtime-before-VERSION.itb 复制为同目录暂存文件,经哈希核验后原子替换 runtime.itb。旧 FIT 已经固定旧容器名和旧程序哈希,所以必须保留它所引用的旧 LUKS 容器。回退后再次冷启动验收。
不要用“把旧程序写进新容器”临时拼接版本,也不要在解锁失败时删除 secure-storage 重新初始化。TEE 存储、LUKS 容器和对应签名策略分别负责不同状态,盲目重建秘密会让旧卷失去解锁材料。
这是一条所有者手动回退流程,不是自动 A/B 升级,也没有硬件防回滚。新版本确认稳定前,不删除旧镜像和旧容器;后续清理应先记录每个 FIT 引用的容器,避免删除仍被恢复镜像使用的数据。
15.9 当时尚未接入的真实多文件工程
现有助手不会递归打包项目,也不能把 ZIP 当 Python 文件输入。不能只校验一个 main.py,却让它从未校验的位置导入其他模块。
| 新需求 | 应调整的位置 |
|---|---|
| 多个自有 Python 模块 | 扩展 secure-app-stage 的包格式和运行策略;对全部可执行模块建立清单或整体包哈希,并把可信摘要纳入签名 FIT |
| requirements.txt / pip 包 | 固定版本与目标架构依赖;在构建端离线准备,加入签名根或受校验程序包;不是开机联网 pip install |
| 含原生扩展的包 | 使用匹配 AArch64、musl 和当前 Python ABI 的构建产物,不能直接复制 Windows/Linux glibc 的 wheel |
| 需要写配置、数据库、日志 | 明确独立可写数据位置、权限和是否需要加密;代码卷继续只读,当前尚未自动提供业务数据分区策略 |
| 程序大于 1 MiB / 容器不足 | 修改输入大小限制及 volume-unlock 固定容器校验,创建新容量容器并迁移验证;不是直接把旧 LUKS 文件 truncate 变大 |
| 需要硬件操作 | 单独审阅设备节点、最小权限、受控初始化;不要为了方便把整个程序改成 root |
| 更换运行入口或解释器 | 修改签名运行策略并重建 FIT,保持执行路径和完整性边界一致 |
当前启动器使用 Python -I 隔离模式,脚本所在目录不会自动成为普通的导入搜索路径。因此真实项目有同目录模块时,还需要设计受控加载方式,例如由签名启动器明确加入已验证的只读包目录;不能简单关闭隔离或改从可写目录导入来绕开问题。Python 隔离模式
若采用 tar 等归档格式,更新助手还要限制解包路径、文件数量和大小,拒绝路径穿越、危险链接、设备文件及重复覆盖;这些能力当前并未实现。现有 64 MiB 容器内可用空间小于 64 MiB,外层 eMMC 剩余约 3.7 GiB 不会自动扩展它。
15.10 哪些现成脚本不要直接当通用更新器
maintenance-verify.py、maintenance-install-release.py 是本次 SD v2 测试的阶段脚本,绑定旧日志、版本和路径;deploy-emmc-stage.py、stage-emmc-on-board.py、flash-emmc-bootloader.py 则用于一次性迁移/部署。它们不能作为以后“换一个 Python 程序”的通用命令反复执行。
真正可复用的当前接口是:经过主机身份核对的所有者 SSH、板端 secure-app-stage、编译机 build-full-secure.py --release-keys --storage emmc --release ...,以及经核验的 FIT 文件切换。后续可以把本文这些步骤封装成有版本记录和恢复检查的正式发布工具,但本文不把尚未完成的封装称为已有一键升级功能。
16. 维护时最常用的只读命令
为了方便后续回到项目,这里把不涉及写盘、不生成新秘密的检查入口集中列出来。10 月 19 日 hello eMMC 运行版当时没有 shell,板端命令需进入所有者 SD 维护环境执行;fix5 master 调试变体后来保留了受限所有者 SSH。维护环境本身不自动运行 hello,也不一定保持 secure-app 已解锁。
16.1 Windows:查产物和日志
$LabRoot = '<WORKSPACE>'
Get-Content -LiteralPath "$LabRoot\secure-openwrt\emmc-deployment-20261019\deployment-state.json"
Get-Content -LiteralPath "$LabRoot\secure-openwrt\emmc-deployment-20261019\cold-boot-emmc.txt" -Tail 40
Get-Content -LiteralPath "$LabRoot\secure-openwrt\emmc-deployment-20261019\cold-boot-verification.json"
Get-Content -LiteralPath "$LabRoot\secure-openwrt\emmc-deployment-20261019\serial-negative.json"
Get-NetAdapter | Select-Object Name, Status, LinkSpeed
16.2 编译机:查版本、输入和空间
sudo -iu builder
TREE="$BUILD_ROOT/openwrt-secure-25.12.5"
AUDIT="$BUILD_ROOT/secure-build-25.12.5"
git -C "$TREE" describe --tags --always
git -C "$TREE" rev-parse HEAD
git -C "$TREE" status --short
df -h "$TREE"
cat "$AUDIT/patch-validation.json"
cat "$AUDIT/emmc-runtime-20261019/manifest.json"
sha256sum "$AUDIT/emmc-runtime-20261019/full.itb"
16.3 板端维护环境:查启动与解锁状态
uname -a
cat /proc/cmdline
cat /proc/mounts
dmesg | grep -E 'optee|tee|device-mapper|dm-0|mmcblk'
# 以下两项仅在维护操作已明确建立只读映射时有结果
cryptsetup status secure-app
sha256sum /opt/hello/hello.py
# 当前维护镜像不自动启动应用;仅在有对应进程的诊断环境使用。
# APP_PID 应替换为本次实际进程号,不能沿用文章日志中的 203。
APP_PID='<CURRENT_HELLO_PID>'
grep -E '^(Uid|Gid|Groups):' "/proc/$APP_PID/status"
查看日志和查看设备当前状态应当分开。本文给出的记录证明某一次实验通过,后续重新构建、替换镜像或改动配置后,应重新核对新产物和新的上板结果。
17. 资料来源与这份记录的适用范围
厂商兼容性资料主要来自 Banana Pi 的 BPI-R2PRO-BSP-5.10.x。本次使用了其中的 TEE SDK、工具和相关引导源码作为核对材料,并没有把该 BSP 的内核版本误写成当前实验内核版本。
本地研究资料目录为:
$WORKSPACE/.secure-build-research
其中保存了 TEE SDK、OTP、U-Boot Nextdev 和 Secure Boot 等厂家文档的实验副本。文档里不同芯片、不同年代的示例并不通用,尤其不能把旧平台的烧录流程直接套到 RK3568。
本文提到的几个基础机制,可以继续查阅以下原始资料:U-Boot 的验证启动说明与FIT 签名说明、OP-TEE 的安全存储架构、Python 的命令行与隔离模式说明,以及本轮固定版本的 Rockchip 签名工具配置。这些资料用于解释机制,项目是否真的具备某项能力,仍以本文列出的实际配置与验证证据为准。
截至 2026-10-22,master 已从 hello.py 演示推进到完整业务应用:无 SD、无网冷启动、全量 manifest、降权运行、20 MHz API 扫描、进程崩溃恢复和一次工作 tmpfs 满盘恢复均已完成实测或现场观察。hello 阶段的跨板复制负向测试也已通过。两块子板、三板联调、真实旧版回退、更新中断、真实应用卷启动级篡改负测、SPL 错签真板负测、OTP/RPMB 与防回滚仍未完成。本文保留实际证据边界,不把 master 单板进展写成三板或量产安全闭环。