故障定位与升级 / 文件安全实验发布 内容更新

Secure Boot 和 Flash 加密分别解决什么?先用虚拟 eFuse 理解流程

在私有QEMU文件中实际区分签名、加密和两者组合,保留首次就地加密失败,用受控文件副本继续观察四状态与签名负例。

读完能做什么
说清真实性与静态保密的区别,保留每个虚拟模型、密钥角色和原log;不执行物理eFuse操作。
开始之前
先完成所列前置;能激活固定IDF环境、保存同次源码/配置/ELF/log。下载README含完整器材、命令、失败与恢复步骤。
所需器材
电脑与已安装ESP-IDF5.5.5工具链;不需要真实开发板、Espressif QEMU esp-develop-9.2.2-20260417 / qemu-system-xtensa、工程之外的私有目录,足够空间保存四构建、原始模型与日志
版本组合
框架:ESP-IDF5.5.5 / b774170ff46c393eeb5e495ea37936038d3f4f4f实验工具:Espressif QEMU9.2.2 / esptool4.12.0
适用范围

ESP32-S3文件模型,8MiB DIO80/PSRAM关闭;IDF5.5.5、官方QEMU9.2.2与esptool4.12.0;无物理串口、刷入或烧写。

这篇验证到了哪一步

资料核对已通过
docs/esp32-evidence/maintenance-frontier-sources.json;固定官方源码鲜取/安装cmp及原厂板图,包含一条404与正确v1.1路径重试。验证日期:
构建 / 配置已通过
docs/esp32-evidence/maintenance-frontier-builds.json;实际最终构建Exit0,输入快照与产物/原日志SHA单列。对象:QEMU ESP32-S3版本:ESP-IDF5.5.5验证日期:
仿真已通过
docs/esp32-evidence/r08-final-model-checks.json;四APP状态与两拒绝负例,纯文件副本,无真实eFuse操作。
本站实板待验证
没有本例实体烧录、安全配置、电气、无线配对、控制器事件或重启恢复记录;下载observations CSV仅表头。

构建通过只说明指定环境中的代码或配置通过检查;实物功能、功耗和稳定性以对应的实板记录为准。

01 / 文件安全实验

把真实安全配置与可复制的文件模型分开

本篇保留原计划R08用途:用四种可运行状态与受控错误镜像解释Secure Boot/Flash Encryption。全部操作只针对自己生成的QEMU私有文件;真实芯片OTP eFuse不可按文件删除或复制来恢复,本例没有物理烧写命令。

E09已讲分区预算,E10已运行适用QEMU检查,R07已分离项目秘密。这里应用本身只读安全状态;启用安全的动作来自所选SDK bootloader,只有在虚拟文件中允许执行。真实产品还需独立安全方案、签名密钥托管、版本策略与恢复设计。

参考:[1] IDF5.5.5:S3 QEMU文件eFuse与工具入口[2] 固定QEMU指南:模拟器能力与安全文件模型[3] Secure Boot v2:信任、签名与真实不可逆边界[4] Flash Encryption:地址相关加密与开发模式

02 / 文件安全实验

固定环境与私有输出,默认不启用安全

IDF5.5.5固定commit与官方QEMU9.2.2配合esptool/espsecure4.12.0。应用目标esp32s3、8MiB DIO80、无PSRAM、UART输出;使用QEMU文件后端eFuse,CONFIG_EFUSE_VIRTUAL保持关闭,二者不是同一个模拟机制。

完整下载含独立工程、7个工具、门禁测试、许可和两张空表。默认safe关闭安全。私有工具拒绝工程内输出或覆盖旧目录,RSA私钥、XTS密钥、完整Flash/eFuse与安全固件均不进入下载。先在新终端确认版本,再设置绝对路径;umask只约束主机文件访问,不等于硬件秘密保护。

代码 / 配置
export LAB_PROJECT="/absolute/path/esp32-secure-boot-encryption-lab"
export LAB_PRIVATE="/absolute/private/path/r08-models"
export LAB_QEMU="/absolute/path/qemu-system-xtensa"
umask 077
python "$LAB_PROJECT/tools/make_private_keys.py" --new-vault "$LAB_PRIVATE"

参考:[1] IDF5.5.5:S3 QEMU文件eFuse与工具入口[2] 固定QEMU指南:模拟器能力与安全文件模型[5] 固定bootloader安全配置

03 / 文件安全实验

四次构建各用独立配置

safe/signed/encrypted/combined分别使用新build/sdkconfig;私有配置生成器固定开发模式加密,不启防回滚/激进吊销。signed/combined包含匹配签名bootloader,分区表在0x10000、factory在0x20000,不能混装另一次构建的产物。

作者四构建真实Exit0:app分别190848、266240、197120、266240B。签名变体含对齐后的unsigned与signed文件,不能把两个文件尺寸相加当运行app;输入、ELF/map/config/日志摘要各自对应。完整构建命令在README第2步。

代码 / 配置
# ---- tools/private_config.py ----
# SPDX-License-Identifier: MIT
"""Generate QEMU-only security defaults outside the downloadable project.

This does not build, flash, burn fuses, or execute a device. Never use the
security-enabled outputs to flash a physical board as part of this lesson.
"""
import argparse
from pathlib import Path


def generate(mode, output, signing_key, project):
    output = output.resolve()
    if output.is_relative_to(project.resolve()) or output.exists():
        raise ValueError('Choose a new private output file outside the source project')
    secure_boot = mode in ('signed', 'combined')
    encryption = mode in ('encrypted', 'combined')
    lines = ['# PRIVATE QEMU-ONLY OVERLAY. NOT A PHYSICAL FLASHING CONFIGURATION.',
             '# CONFIG_EFUSE_VIRTUAL is not set']
    if secure_boot:
        if signing_key is None or not signing_key.resolve().is_file():
            raise ValueError('Secure boot requires an existing private RSA-3072 signing key')
        key = str(signing_key.resolve())
        if signing_key.resolve().is_relative_to(project.resolve()) or any(c in key for c in '\n\r"\\'):
            raise ValueError('Signing key must be outside the project, with an unambiguous path')
        lines += ['CONFIG_SECURE_BOOT=y', 'CONFIG_SECURE_BOOT_V2_ENABLED=y',
                  'CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES=y',
                  'CONFIG_SECURE_BOOT_FLASH_BOOTLOADER_DEFAULT=y',
                  f'CONFIG_SECURE_BOOT_SIGNING_KEY="{key}"']
    else:
        lines += ['# CONFIG_SECURE_BOOT is not set']
    if encryption:
        lines += ['CONFIG_SECURE_FLASH_ENC_ENABLED=y',
                  'CONFIG_SECURE_FLASH_ENCRYPTION_AES128=y',
                  'CONFIG_SECURE_FLASH_ENCRYPTION_MODE_DEVELOPMENT=y']
    else:
        lines += ['# CONFIG_SECURE_FLASH_ENC_ENABLED is not set']
    with output.open('x') as handle:
        handle.write('\n'.join(lines) + '\n')
    output.chmod(0o600)
    return output


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--mode', required=True, choices=['safe', 'signed', 'encrypted', 'combined'])
    parser.add_argument('--private-output', required=True, type=Path)
    parser.add_argument('--signing-key', type=Path)
    args = parser.parse_args()
    try:
        output = generate(args.mode, args.private_output, args.signing_key,
                          Path(__file__).resolve().parents[1])
    except (ValueError, OSError) as error:
        parser.exit(2, str(error) + '\n')
    print(f'Private {args.mode} QEMU overlay written. No hardware operation executed.')

参考:[3] Secure Boot v2:信任、签名与真实不可逆边界[4] Flash Encryption:地址相关加密与开发模式[5] 固定bootloader安全配置

04 / 文件安全实验

只读应用给出观察终点

应用打印Secure Boot/Flash Encryption是否启用、运行分区/偏移和ELF标识,然后休眠;不调用写eFuse接口。APP标记能证明这一次模型走到应用,不能证明实物安全、攻击抵抗、认证或外设运行。

文件启动器从固定SDK qemu_ext.py AST读取S3初始eFuse字节,先核该文件SHA;合并同次flash_args。正常启动不套用helper中ROM下载专用strap参数。首轮可写注册与后续snapshot分开,记录输入Flash/eFuse前后摘要。

代码 / 配置
// ---- main/main.c ----
/* SPDX-License-Identifier: MIT */
/* Read-only application: security-changing paths belong to the selected SDK
 * bootloader. The lab driver runs security variants only in file-backed QEMU. */
#include <stdio.h>
#include <inttypes.h>
#include "esp_app_desc.h"
#include "esp_flash_encrypt.h"
#include "esp_ota_ops.h"
#include "esp_secure_boot.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

void app_main(void)
{
    const esp_app_desc_t *app = esp_app_get_description();
    const esp_partition_t *running = esp_ota_get_running_partition();
    printf("R08_APP_REACHED,secure_boot=%d,flash_encryption=%d,partition=%s,offset=0x%08"PRIx32",mono_us=%"PRId64"\n",
           esp_secure_boot_enabled(), esp_flash_encryption_enabled(),
           running ? running->label : "UNKNOWN", running ? running->address : 0,
           esp_timer_get_time());
    printf("R08_APP_ID,project=%s,version=%s,elf_sha256=", app->project_name, app->version);
    for (unsigned i = 0; i < sizeof(app->app_elf_sha256); ++i) {
        printf("%02x", app->app_elf_sha256[i]);
    }
    puts("\nR08_APPLICATION_PERFORMS_NO_FUSE_WRITES");
    fflush(stdout);
    for (;;) {
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

参考:[1] IDF5.5.5:S3 QEMU文件eFuse与工具入口[2] 固定QEMU指南:模拟器能力与安全文件模型

05 / 文件安全实验

原首轮加密失败也属于本课结果

safe与signed原流程实际进入APP,状态分别0,0与1,0;encrypted/combined首次就地加密后重启出现应用checksum错误,没有APP。作者保留原坏模型和全部日志,不把超时或主机退出码判为成功。

本轮encrypted的197120B已知app离线比对有359B不同;这只是固定组合的观察,未把原因归结为真实芯片缺陷。可写模型使用DEVNULL时还曾无应用输出,保持stdin=PIPE后正常对照可运行;下载strap与stdin两个主机夹具修正也保留历史。

参考:[1] IDF5.5.5:S3 QEMU文件eFuse与工具入口[2] 固定QEMU指南:模拟器能力与安全文件模型[4] Flash Encryption:地址相关加密与开发模式

06 / 文件安全实验

受控替代:同一虚拟状态的新app密文副本

为了继续学习状态位,提供纯文件替代路径:读取自己已注册的QEMU eFuse文件中已存在的槽位,按固定espefuse布局尝试六槽/四种字节序,要求已知app前128B加密结果唯一匹配。它不是搜索密码空间,也不是读取真实芯片受保护密钥;模型格式校验不等于来源认证,只接受自己按本文生成的文件。

随后用espsecure对原app离线加密并完整解密比较,只替换新副本app区间。原坏模型、所有非app Flash字节与eFuse摘要均须不变;这不等于原SDK首轮路径已修复。完整往返/唯一匹配失败立即停止。

代码 / 配置
# ---- tools/rebuild_private_app.py ----
# SPDX-License-Identifier: MIT
"""Rebuild only the application ciphertext of an owned file-backed S3 model.

This is a controlled emulator-file recovery, not physical provisioning. A QEMU
eFuse file exposes its emulated keys to its owner; it provides no secret-storage
boundary. No serial port or physical eFuse/flash command is accepted.

The original SDK first-boot in-place encryption failure remains a failed case.
This tool does not repair an unknown firmware, a real chip, or a lost real key.
"""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys


def digest(path):
    return hashlib.sha256(path.read_bytes()).hexdigest()


def rebuild(model, build, output, project):
    import esptool
    import espefuse.efuse.esp32s3.mem_definition as definitions
    if esptool.__version__ != '4.12.0':
        raise ValueError('Use the fixed esptool/espefuse/espsecure4.12.0 environment')
    definition_path = Path(definitions.__file__)
    if digest(definition_path) != '67c591e12d3ac06bc9ff3672e49f7826451fc873029f47b76fa4fd963c838d6e':
        raise ValueError('Fixed S3 block-layout definition differs')
    model, build, output = model.resolve(), build.resolve(), output.resolve()
    if any(path.is_relative_to(project.resolve()) for path in (model, output)) or output.exists():
        raise ValueError('Use an existing private model and a new private output outside the source project')
    config = json.loads((build / 'config/sdkconfig.json').read_text())
    description = json.loads((build / 'project_description.json').read_text())
    if description.get('project_name') != 'r08_security_lab' or config.get('IDF_TARGET') != 'esp32s3' or not config.get('SECURE_FLASH_ENC_ENABLED'):
        raise ValueError('Expected the fixed flash-encryption-enabled R08 S3 build')
    flash_path, efuse_path = model / 'flash.bin', model / 'efuse.bin'
    if flash_path.stat().st_size != 0x800000 or efuse_path.stat().st_size != 1024:
        raise ValueError("Unexpected model size; only this lesson QEMU backing-file format is supported")
    app_path = build / 'r08_security_lab.bin'
    app = app_path.read_bytes()
    if not 128 <= len(app) <= 0x200000 or len(app) % 16:
        raise ValueError('Unexpected fixed-partition application size/alignment')
    before = {path.name: digest(path) for path in (flash_path, efuse_path)}
    flash, efuse = flash_path.read_bytes(), efuse_path.read_bytes()
    output.mkdir(mode=0o700)
    operations = output / 'private-host-operations'
    operations.mkdir(mode=0o700)
    prefix = operations / 'known-app-prefix.bin'
    prefix.write_bytes(app[:128])
    commands, matches = [], []

    def run(arguments, name):
        command = [sys.executable, '-m', 'espsecure'] + arguments
        result = subprocess.run(command, capture_output=True, text=True)
        log = operations / (name + '.log')
        log.write_text(result.stdout + result.stderr)
        commands.append({'command': command, 'exitCode': result.returncode,
                         'log': str(log), 'logSHA256': digest(log)})
        if result.returncode:
            raise RuntimeError('Offline operation failed; private original log retained')

    # These are six known file-layout slots and four byte-order interpretations,
    # not a search over cryptographic keys. Keys already exist in the owned
    # emulator file. Match the known app prefix to reject a wrong slot/order.
    for block in definitions.EfuseDefineBlocks.BLOCKS:
        if block[2] not in range(4, 10):
            continue
        offset = sum(item[7] * 4 for item in definitions.EfuseDefineBlocks.BLOCKS if item[2] < block[2])
        raw = efuse[offset:offset + 32]
        if not any(raw):
            continue
        variants = {'raw': raw, 'reversed': raw[::-1],
                    'word-bytes-reversed': b''.join(raw[n:n + 4][::-1] for n in range(0, 32, 4)),
                    'word-order-reversed': b''.join(raw[n:n + 4] for n in range(28, -1, -4))}
        for order, key in variants.items():
            name = str(block[2]) + '-' + order
            keyfile = operations / (name + '.key')
            keyfile.write_bytes(key)
            keyfile.chmod(0o600)
            cipher = operations / (name + '-prefix.bin')
            run(['encrypt_flash_data', '--aes_xts', '--keyfile', str(keyfile), '--address', '0x20000',
                 '--output', str(cipher), str(prefix)], name)
            if cipher.read_bytes() == flash[0x20000:0x20080]:
                matches.append({'block': block[2], 'fileOffset': offset, 'byteOrder': order, 'keyfile': keyfile})
    if len(matches) != 1:
        raise ValueError('No unique known-prefix match; preserve this model and stop')
    match = matches[0]
    cipher, roundtrip = operations / 'complete-app-cipher.bin', operations / 'complete-app-roundtrip.bin'
    run(['encrypt_flash_data', '--aes_xts', '--keyfile', str(match['keyfile']), '--address', '0x20000',
         '--output', str(cipher), str(app_path)], 'encrypt-app')
    run(['decrypt_flash_data', '--aes_xts', '--keyfile', str(match['keyfile']), '--address', '0x20000',
         '--output', str(roundtrip), str(cipher)], 'decrypt-app')
    if roundtrip.read_bytes() != app or cipher.stat().st_size != len(app):
        raise ValueError('Complete known application failed exact offline round trip')
    updated = bytearray(flash)
    updated[0x20000:0x20000 + len(app)] = cipher.read_bytes()
    if updated[:0x20000] != flash[:0x20000] or updated[0x20000 + len(app):] != flash[0x20000 + len(app):]:
        raise ValueError('Unexpected change outside the fixed app interval')
    (output / 'flash.bin').write_bytes(updated)
    shutil.copyfile(efuse_path, output / 'efuse.bin')
    if before != {path.name: digest(path) for path in (flash_path, efuse_path)}:
        raise RuntimeError('Original model changed unexpectedly')
    record = {'sourceModel': str(model), 'sourceOriginalUnchanged': before,
              'fixedLayoutSourceSHA256': digest(definition_path), 'uniqueMatchingCandidate': 1,
              'selectedBlock': match['block'], 'selectedFileOffset': match['fileOffset'],
              'selectedByteOrder': match['byteOrder'], 'knownAppSHA256': digest(app_path), 'knownAppBytes': len(app),
              'exactOfflineRoundtrip': True, 'outsideAppFlashBytesUnchanged': True,
              'efuseBytesUnchanged': digest(output / 'efuse.bin') == digest(efuse_path),
              'newFlashSHA256': digest(output / 'flash.bin'), 'commands': commands,
              'scope': 'Owned emulator-file recovery only; original first-boot in-place path remains failed. No QEMU boot verdict until a separate run. No physical chip/key recovery.'}
    (output / 'offline-recovery.json').write_text(json.dumps(record, indent=2) + '\n')
    for path in output.rglob('*'):
        if path.is_file():
            path.chmod(0o600)
    return record


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--enrolled-model', required=True, type=Path)
    parser.add_argument('--build', required=True, type=Path)
    parser.add_argument('--private-output', required=True, type=Path)
    args = parser.parse_args()
    try:
        rebuild(args.enrolled_model, args.build, args.private_output, Path(__file__).resolve().parents[1])
    except (ValueError, RuntimeError, OSError) as error:
        parser.exit(2, str(error) + '\n')
    print('Private application ciphertext rebuilt; original files preserved. This is not a first-boot or physical security pass.')

参考:[4] Flash Encryption:地址相关加密与开发模式[6] espefuse4.12.0 S3文件模型布局

07 / 文件安全实验

实际四状态与不可扩大的结论

当前最终snapshot检查实际得到四个APP:safe 0,0;signed 1,0;encrypted离线副本0,1;combined离线副本1,1。每个来源模型前后SHA不变。encrypted/combined原始首次流程仍列失败,不因替代副本可运行而改写历史。

加密配置日志还有nvs_keys分区缺失的provider提示;本应用不初始化NVS,不提供NVS数据保密,不能从Flash Encryption=1推导所有数据分区都加密。文件key可被宿主机看见,模型不是生产秘密存储。

对照实际APP结果边界
safe / 原路径0,0没有安全特性保证
signed / 原路径1,0验证代码信任,不隐藏Flash
encrypted / 新离线副本0,1原首次加密路径仍失败
combined / 新离线副本1,1无实板/认证结论

参考:[3] Secure Boot v2:信任、签名与真实不可逆边界[4] Flash Encryption:地址相关加密与开发模式

08 / 文件安全实验

签名负例需要保持合法结构

B签名应用先在主机被B公钥验证成功;原A应用被A验证成功;B应用被A验证失败。再把B应用放到只信任A的signed模型副本,实际QEMU给出untrusted key/signature bad且无APP。这比随意破坏一个字节更能区分信任者。

另一副本修改payload一个字节,实际先被镜像校验拒绝。它也无APP,但不能单独证明公钥不受信任。两负例不重写原模型;不要将加密模型直接覆盖明文来混淆错误原因。下载README第4步给出完整生成和运行命令,停止标记包含错误终点,matched不是通过判据。

参考:[3] Secure Boot v2:信任、签名与真实不可逆边界[5] 固定bootloader安全配置

09 / 文件安全实验

AES-XTS错误参数仍可能退出0

offline_xts.py实际使用4096B合成数据,在0x20000加密;正确密钥/地址解密逐字相等,错误地址0x21000和另一密钥所得内容不等,命令仍可退出0。必须比较字节,不能把CLI成功当内容真实性。

AES-XTS不是签名或消息认证码。这个离线对照不等于SDK首次加密流程、Flash电气或真实读取保护。密钥/生成数据留私有目录,公开记录只含摘要与比较结论。

代码 / 配置
python "$LAB_PROJECT/tools/offline_xts.py" --vault "$LAB_PRIVATE" \
  --private-output "$LAB_PRIVATE/offline-xts-case"

参考:[4] Flash Encryption:地址相关加密与开发模式

10 / 文件安全实验

失败恢复与复跑门禁

SDK helper摘要不符先检查版本;签名/合并失败核同次配置、密钥路径和完整flash_args;无APP先读原log并回safe对照,不用超时替代签名拒绝。旧文件保留,在新目录准备;丢失测试私钥可另建文件模型,不适用于已烧实物OTP。

tests_tools.py实际8组主机输入/路径门禁通过;最终QEMU检查为4个APP与2个明确拒绝,均保留真实日志和来源SHA。qemu.csv只供读者记自己的实验,physical-security.csv只有表头。真实OTP/烧录/故障注入/读保护/认证仍pending。

代码 / 配置
python "$LAB_PROJECT/tests_tools.py"
# 后续重新运行时,每次使用尚不存在的新日志目录。

参考:[1] IDF5.5.5:S3 QEMU文件eFuse与工具入口[2] 固定QEMU指南:模拟器能力与安全文件模型[3] Secure Boot v2:信任、签名与真实不可逆边界[4] Flash Encryption:地址相关加密与开发模式

11 / 文件安全实验

下一步:把身份、安全状态与业务配对区分开

R09进入可构建Matter Wi-Fi小灯。开发DAC/setup参数、Matter fabric、Secure Boot信任和Flash密钥分别属于不同边界;本篇的文件模型不为下一篇设备提供生产身份。先读完整README与固定来源,再在隔离实验板做尚未执行的实板记录。

参考:[3] Secure Boot v2:信任、签名与真实不可逆边界[4] Flash Encryption:地址相关加密与开发模式

BACK TO THE SOURCE

资料来源与版本说明

保留原计划ID/slug/M6与用途。R08以私有QEMU文件学习安全状态,原首次就地加密失败与文件替代路径分别留证;R09/R10完整配对路线未冒充无线或控制器实测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。

  1. IDF5.5.5:S3 QEMU文件eFuse与工具入口
  2. 固定QEMU指南:模拟器能力与安全文件模型
  3. Secure Boot v2:信任、签名与真实不可逆边界
  4. Flash Encryption:地址相关加密与开发模式
  5. 固定bootloader安全配置
  6. espefuse4.12.0 S3文件模型布局