把真实安全配置与可复制的文件模型分开
本篇保留原计划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:地址相关加密与开发模式
固定环境与私有输出,默认不启用安全
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安全配置
四次构建各用独立配置
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安全配置
只读应用给出观察终点
应用打印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指南:模拟器能力与安全文件模型
原首轮加密失败也属于本课结果
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:地址相关加密与开发模式
受控替代:同一虚拟状态的新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文件模型布局
实际四状态与不可扩大的结论
当前最终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:地址相关加密与开发模式
签名负例需要保持合法结构
B签名应用先在主机被B公钥验证成功;原A应用被A验证成功;B应用被A验证失败。再把B应用放到只信任A的signed模型副本,实际QEMU给出untrusted key/signature bad且无APP。这比随意破坏一个字节更能区分信任者。
另一副本修改payload一个字节,实际先被镜像校验拒绝。它也无APP,但不能单独证明公钥不受信任。两负例不重写原模型;不要将加密模型直接覆盖明文来混淆错误原因。下载README第4步给出完整生成和运行命令,停止标记包含错误终点,matched不是通过判据。
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"失败恢复与复跑门禁
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:地址相关加密与开发模式
下一步:把身份、安全状态与业务配对区分开
R09进入可构建Matter Wi-Fi小灯。开发DAC/setup参数、Matter fabric、Secure Boot信任和Flash密钥分别属于不同边界;本篇的文件模型不为下一篇设备提供生产身份。先读完整README与固定来源,再在隔离实验板做尚未执行的实板记录。
参考:[3] Secure Boot v2:信任、签名与真实不可逆边界[4] Flash Encryption:地址相关加密与开发模式
资料来源与版本说明
保留原计划ID/slug/M6与用途。R08以私有QEMU文件学习安全状态,原首次就地加密失败与文件替代路径分别留证;R09/R10完整配对路线未冒充无线或控制器实测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。