约 3 分钟 阅读 45

起因:一个假密钥活了三周

2026 年 7 月中旬,Keel 框架的 bootloader 加密功能刚做完。我跑通了 ECDSA-P256 签名验证、AES-128-CTR 加密封装、HMAC-SHA256 完整性校验——三条链路全部自测绿灯。

然后我做了一件"以后再做的事":用了一个默认密钥

具体来说,签名验证的公钥就是 secp256r1 曲线的生成点 G。对应的私钥是 1。对,就是数字 1。

这不是偷懒。bootloader 的 OTP(One-Time Programmable)烧录流程还没打通,产品板也没到手。我需要一个"能跑通全链路但明显不安全"的占位符,等硬件就绪再换。

问题是:这个占位符活了三周

三周里,我写了文档、跑了测试、做了评审。所有人都看到了代码里的 #warning "using default dev key",但没人觉得这是紧急事项。毕竟——"反正没人会攻击我们的开发板"。

直到有一天,我在写 OTA 升级文档时突然意识到:如果有人拿到固件包,用私钥=1 签一个恶意镜像,bootloader 会欣然接受并烧进去。签名验证从"安全门"变成了"摆设"。

那天晚上,我加了五层防御。

第一层:编译期告警——让"忘了换密钥"变成编译噪音

最简单的防线:如果产品构建没有显式提供公钥,编译器会输出一行警告。

#if !defined(KBOOT_PRODUCTION_PUBKEY)
#warning "bootloader using default dev key -- NOT for production"
#endif

这解决不了问题,但解决了一个更根本的问题:遗忘

在嵌入式开发中,"开发配置泄漏到量产"是经典事故。不是因为工程师不知道区别,而是因为从开发到量产之间有太多步骤,任何一步漏掉都会出事。编译期告警的价值在于:它不需要人记得去检查,它自己会在每次构建时喊一声。

第二层:OTP 优先——让硬件成为信任根

OTP(One-Time Programmable)存储是嵌入式安全的信任锚点。烧进去就改不了,读出来就能验证。

bootloader 的密钥加载逻辑是这样的:

// 1. 先尝试从 OTP 读取公钥
if (otp_read_pubkey(otp_pubkey) == KEEL_OK &&
    is_valid_curve_point(otp_pubkey)) {
    // 用 OTP 公钥
    memcpy(active_pubkey, otp_pubkey, 64);
} else {
    // 2. OTP 没有或损坏,回退默认
    memcpy(active_pubkey, default_pubkey, 64);
    ev_record(EV_MOD_BOOT, EV_BOOT_DEFAULT_KEY, 0);
}

关键设计:OTP 优先,默认兜底。不是"有 OTP 就用 OTP,没有就报错"——那样开发阶段就没法跑了。而是"有 OTP 就用 OTP,没有就用默认的但打事件"。

ev_record 是 Keel 框架的黑匣子事件环。即使设备复位,事件记录还在 SRAM 里(跨复位保留)。事后取证时,搜 EV_BOOT_DEFAULT_KEY 就知道这台设备是否用过默认密钥。

第三层:生产模式硬拒——让默认密钥在量产中失效

开发阶段用默认密钥没问题,但量产阶段必须拒绝。

#if KBOOT_SIGNATURE_MODE >= 2
// 生产模式:检查当前密钥是否为默认
if (memcmp(active_pubkey, secp256r1_G, 64) == 0) {
    // 默认密钥 = 未烧录 = 拒绝启动
    fault(FAULT_BOOT_DEFAULT_KEY_IN_PRODUCTION);
    while (1) { watchdog_refresh(); }  // 永久停机
}
#endif

KBOOT_SIGNATURE_MODE 是构建时的档位:

  • 0 = 无签名(开发用)
  • 1 = 过渡模式(验签失败打日志但不阻止)
  • 2 = 生产模式(验签失败 = 拒绝启动)

模式 2 下,如果 OTP 没烧公钥,bootloader 会检测到"当前密钥是默认的"并永久停机。不是返回错误码让上层决定——是直接停机。因为在这种情况下,任何"降级启动"都是安全漏洞。

第四层:OTP 曲线校验——防止"烧错了也能用"

OTP 是一次性写入的。如果烧进去的公钥不是合法的 secp256r1 曲线点(比如写入时 SRAM 翻转、或者操作失误写了全零),会发生什么?

答案是:如果不校验,签名验证会用一个"不存在的密钥"去验——大概率验不过,但具体行为取决于底层数学库。有的库会返回"不匹配"(安全但困惑),有的库会崩溃(危险)。

所以加载 OTP 公钥时要显式校验:

bool is_valid_curve_point(const uint8_t pubkey[64]) {
    // 检查点是否在 secp256r1 曲线上
    // y^2 = x^3 + ax + b (mod p)
    // 公钥是 (x, y),各 32 字节,大端序
    // ...
    return point_is_on_curve(x, y);
}

这个校验在 OTP 烧录工具和 bootloader 加载时各做一次。烧录时校验是为了"烧错了当场报错";加载时校验是为了"读出来确认没坏"。

第五层:UID 派生——让每台设备的密钥不同

即使 OTP 公钥烧对了,还有一个问题:所有设备用同一个密钥

如果一台设备的固件被提取(物理攻击或侧信道),攻击者可以用同一份密钥伪造所有设备的固件。这在仪器设备中尤其危险——一台被攻破的设备可以伪造校准证书。

解决方案是设备唯一密钥:每台设备的密钥从根密钥 + 芯片 UID 派生。

int keel_seal_init(void) {
    uint8_t uid[12];
    keel_crypto_uid_read(uid);  // 读芯片 UID

    // 全零 UID = 未实现 = 拒绝初始化
    uint8_t zero_check = 0;
    for (int i = 0; i < 12; i++) zero_check |= uid[i];
    if (zero_check == 0) return -KEEL_ENOTSUP;

    // 派生设备密钥: HKDF(root_key, uid) -> dev_key
    hkdf_derive(root_key, uid, 12, dev_key);
    return KEEL_OK;
}

这里有一个"弱默认"的陷阱:如果芯片的 UID 读取函数没有实现(比如移植到新平台时忘了),它会返回全零。如果派生函数不检查,所有设备会派生出同一个密钥。

所以代码里有一个显式的全零检测:UID 全零 = 拒绝初始化。宁可 keel_seal 不能用(返回 -KEEL_ENOTSUP),也不能默默用一个"所有设备共享"的密钥。

安全细节:常量时间比较

加密学里有一个经典陷阱:时间侧信道

MAC 验证(HMAC-SHA256)需要比较两个标签是否相等。朴素的比较是逐字节比较,遇到不同就提前返回:

// 危险!时间侧信道
for (i = 0; i < tag_len; i++) {
    if (mac[i] != expected[i]) return false;  // 提前退出
}
return true;

问题是:攻击者可以通过测量比较耗时来推断"前 N 个字节是对的"。逐字节缩小搜索空间,最终伪造出正确的标签。

正确的做法是常量时间比较——无论匹配多少字节,耗时都一样:

uint8_t diff = 0;
for (i = 0; i < tag_len; i++) {
    diff |= mac[i] ^ expected[i];  // 累积差异
}
return diff == 0;  // 全部匹配才返回 true

没有提前退出。无论第一个字节就不同还是最后一个字节才不同,循环都完整跑完。diff 的值只在最后一次性判断。

安全细节:安全清零

另一个容易被编译器"优化掉"的安全措施:密钥用完后清零

uint8_t dev_key[32];
derive_key(dev_key);
use_key(dev_key);
memset(dev_key, 0, 32);  // 编译器:这行是死代码,删了

问题是:编译器看到 memset 之后没有读取 dev_key,认为这是"无副作用的死代码",直接优化掉。密钥残留在栈上,下次函数调用时可能被覆盖,也可能不被覆盖——取决于运气。

解决方案是用 volatile 指针:

void keel_secure_bzero(void *p, size_t n) {
    volatile uint8_t *vp = (volatile uint8_t *)p;
    while (n--) *vp++ = 0;
}

volatile 告诉编译器"这个写入有副作用,不许优化掉"。密钥清零是安全需求,不是性能需求——宁可多花几个周期,也不能让密钥残留在内存里。

总结:安全是一个渐进过程

回到最初的问题:默认密钥活了三周,是不是我的错?

是,也不是。

是我选择了"先占位再替换"的策略。但更深层的原因是:嵌入式安全没有"一步到位"的方案。它是一系列防线的叠加:

防线解决什么失效条件
编译期告警遗忘构建日志没人看
OTP 优先信任根OTP 烧录工具不可靠
生产模式硬拒开发配置泄漏模式设置错误
曲线校验OTP 数据损坏校验算法有 bug
UID 派生单点密钥泄漏UID 读取未实现
常量时间比较时间侧信道实现有遗漏
安全清零内存残留编译器优化

每层防线都有失效条件。但攻击者需要同时突破所有层才能得手。这就是纵深防御的意义:不是让每一层都完美,而是让失败的成本足够高。

现在,那个默认密钥还在代码里。但它不再是"活了三周没人管的占位符"——它是一个被五层防线包围的开发工具,每层都有明确的失效检测和告警。

如果你也在做嵌入式安全,我的建议是:不要等到"安全审计"才想起安全。从第一行代码开始,就假设有人会攻击你的设备。不是因为偏执,而是因为嵌入式设备的安全漏洞往往在发现时已经无法修复——OTA 能更新固件,但烧错的 OTP 密钥永远改不了。

标签: none

添加新评论