本章导读
第 74~76 章给了你全部算法原语——但真实世界里被攻破的系统,绝大多数不是算法被解,而是系统层误用:密钥进了日志、比较函数泄漏时序、随机源不对、协议组合自创。本章是前三章的"使用说明书",也是卷八从原语走向证书与 TLS 前的纪律总结。四个主题:常量时间纪律(为什么比较 MAC 必须不早退)、密钥生命周期(生成—使用—存储—销毁四段各自的规则与 API)、随机源纪律(安全随机与普通随机的分界)、组合纪律(不发明协议、失败单一、上下文绑定)。压轴示例 crypto/session 把前三章原语组装成一次完整临时会话——它就是 TLS 1.3 的骨架缩微版,第 82 章的握手在它之上只是加上了证书与状态机。
77.1引入
看三段真实事故的抽象。其一:某服务的会话令牌校验用 memcmp——攻击者测量响应时间,逐字节试出合法令牌:第一个字节对了响应慢几十纳秒,一百万次请求把令牌完整"摸"出来。其二:开发者用 memset 清除密钥后释放内存——编译器判定该内存"再无读取"而把清零整个删除,密钥明文留在堆里被后续分配读走。其三:两个模块各自生成"随机"挑战值,用的是可预测的普通随机源——攻击者预测输出,认证形同虚设。
三起事故的共同点:算法全对,调用全错。密码学 API 的安全性只在"正确使用"的上下文里成立——xrtConstTimeEqual、xrtSecureZero、xrtSecureRandom 这三个不起眼的函数,就是 XRT 为这三类事故准备的出口。本章把它们与更大的"组合图景"一起讲清楚。
77.2概念
三层失效模型:误用发生在哪一层
把安全系统想成三层,攻击者永远挑最软的那层:
一个实用自查法:每写一行密码相关代码,问"这行打破哪一层"。memcmp 比较密文打破系统层;"验证失败但继续处理"打破协议层;选 MD5 打破算法层。三层里算法层最不可能出错(用对 API 即可),所以工程师的注意力应该反过来分配——系统层最多、协议层次之、算法层最少。本章避坑清单全部来自系统层。
常量时间纪律:比较也是一种攻击面
memcmp(Left, Right, N) 在第一个不同字节处提前返回——这个"快"就是漏洞:比较耗时与公共前缀长度成正比,攻击者远程计时即可逐字节恢复秘密(令牌、MAC、密钥)。xrtConstTimeEqual(Left, Right, N) 逐位累积差异、最后统一判定,运行时间只与 N 有关。两条精确边界:它不隐藏 N 本身(长度公开);它也不等于完整侧信道防护(功耗、缓存是另一维度)——但在"远程计时"这个最现实的攻击面上,它是必需品。适用对象:MAC/标签比较、令牌比较、共享秘密比较、密文指纹比较——一切有一侧是秘密的比较。比较两侧全公开的数据(文件校验、缓存键)用 memcmp 即可,常量时间不是免费的性能。
密钥生命周期四段
- 生成:对称密钥与私钥一律来自
xrtSecureRandom(下一节)或 KDF 派生;测试向量的固定密钥必须像示例那样标注"仅复现用"。 - 使用:密钥只住在栈缓冲或专用状态结构(
xaesgcm的密钥日程、xed25519key的展开态);不复制到别的缓冲——每多一份副本,销毁面就大一分。 - 存储:密钥永不进日志(第 38 章的 Logger 也一样)、不进错误消息、不进交换文件的持久结构(这超出
xrtSecureZero能力——契约原文明确它只覆盖调用给出的区间,编译器寄存器副本、崩溃转储、OS 交换需要系统级措施:mlock、关闭转储、全盘加密)。 - 销毁:
xrtSecureZero用 volatile 写清零,普通memset可能被编译器按死存储删除(开头的第二起事故);带状态对象用专属Clear/KeyClear(它知道结构里所有敏感角落)。成功路径与错误路径都要清——本章示例的 cleanup 段就是模板。
随机源纪律:两种随机不可混用
XRT 把随机分成两个世界:普通随机(第 11 章,xrtFastRandSeed 播种的序列——快、可种子、可复现)用于测试数据、负载分布、洗牌;安全随机(xrtSecureRandom)用于一切密钥、令牌、盐、nonce——平台 CSPRNG(Windows CNG、Linux getrandom),不可种子、不可预测。分界的试金石:这个值被预测会造成安全后果吗?是就用安全随机——哪怕它慢两个数量级。反向规则同样重要:安全随机不用于可复现场景(测试向量、基准数据)——不是为了性能,而是确定性输出根本不是它的承诺。密码模块通过独立的 RANDOM_SECURE 特性依赖随机源(第 74 章裁剪表),文件与网络模块复用基础随机时不会拖入密码算法——这条边界让"随机源的严肃性"与"模块的轻量性"并存。
组合纪律:别发明协议
有了原语,最大的诱惑是"自己拼一个加密协议"——四条替代方案与一条铁律:
- 先找现成协议:传输安全直接用第 82 章的 TLS;令牌用 HMAC 或签名;文件加密用 AEAD 单记录。自创协议的每个决定(字段序、握手状态机、失败语义)都要重新踩一遍业界踩过的坑。
- 失败必须单一:验证失败即失败——没有"降级继续"、没有"警告但放行"。降级攻击(迫使双方回落到弱参数)利用的就是"失败后继续"的路径。
- 上下文绑定:派生密钥用
info绑定用途(第 74 章)、加密记录用 AAD 绑定序号与版本(第 75 章)、握手摘要绑定全部握手消息(第 82 章)——每一层都要把自己"是谁、第几条"写进认证范围,攻击者才无法把一段合法密文挪到另一个位置重放。 - 临时密钥优先:能用会话临时密钥就不用长期密钥——泄露半径决定事故大小。TLS 1.3 的前向保密就是这条纪律的极致:每个连接全新 x25519 密钥对,长期私钥只签握手不参与加密。
77.3示例
第一个完整程序:两条铁律的入口
下面的程序来自 examples/crypto/core/main.c——摘要元数据、常量时间比较、安全清零三件套一次演示:
#include <stdio.h> #include <xrt.h> */ /* 展示摘要元数据、常量时间比较和敏感数据清理。 */ int main(void) { uint8 Left[XRT_SHA256_SIZE] = { 1u, 2u, 3u }; uint8 Right[XRT_SHA256_SIZE] = { 1u, 2u, 3u }; bool bEqual; /* 算法元数据不要求把对应摘要实现编入程序。 */ if ( xrtCryptoHashSize(XCRYPTO_HASH_SHA256) != sizeof(Left) ) { return 1; } /* 密钥、标签和摘要等固定长度数据应使用常量时间比较。 */ bEqual = xrtConstTimeEqual(Left, Right, sizeof(Left)); xrtSecureZero(Left, sizeof(Left)); xrtSecureZero(Right, sizeof(Right)); printf("equal: %s\n", bEqual ? "yes" : "no"); return bEqual ? 0 : 1; }
刚才发生了什么。 ① xrtCryptoHashSize 是纯元数据——查询 SHA-256 长度不需要编入 SHA-256 实现,容量计算与编译配置解耦。② 两个摘要缓冲用 xrtConstTimeEqual 比较——这里是"相同内容"的演示,生产场景把它用在"我算的 MAC vs 收到的 MAC":即便攻击者构造的 MAC 与真值只有最后一字节不同,比较耗时也完全一致,计时攻击无从下手。③ 比较完立即 xrtSecureZero 两块缓冲——它们模拟的是密钥材料;volatile 写保证这个清零不会消失。整个程序不到 20 行,却是全卷出现频率最高的三个函数。
第二个完整程序:一次完整临时会话(TLS 1.3 骨架缩微版)
第二个程序来自 examples/crypto/session/main.c,把第 74/75/76 章的原语串成真实协议形态:
#include <stdio.h> #include <string.h> #include <xrt.h> */ /* 组合 X25519、HKDF-SHA256 和 ChaCha20-Poly1305 建立一次临时会话。 */ int main(void) { static const char Message[] = "authenticated session message"; static const char Salt[] = "xrt-session-v1"; static const char Aad[] = "record:0"; uint8 AlicePrivate[XRT_X25519_PRIVATE_SIZE]; uint8 AlicePublic[XRT_X25519_PUBLIC_SIZE]; uint8 BobPrivate[XRT_X25519_PRIVATE_SIZE]; uint8 BobPublic[XRT_X25519_PUBLIC_SIZE]; uint8 AliceShared[XRT_X25519_SHARED_SIZE]; uint8 BobShared[XRT_X25519_SHARED_SIZE]; uint8 Transcript[XRT_X25519_PUBLIC_SIZE * 2u]; uint8 SessionKey[XRT_CHACHA20_POLY1305_KEY_SIZE]; uint8 Nonce[XRT_CHACHA20_POLY1305_NONCE_SIZE] = { 0 }; uint8 Buffer[96]; size_t iMessageSize = sizeof(Message) - 1u; size_t iSealedSize = iMessageSize + XRT_CHACHA20_POLY1305_OVERHEAD; bool bValid = false; /* 双方共享秘密必须相同,且对端公钥应进入派生上下文。 */ if ( !xrtX25519KeyPair(AlicePrivate, AlicePublic) || !xrtX25519KeyPair(BobPrivate, BobPublic) || !xrtX25519Shared(AlicePrivate, BobPublic, AliceShared) || !xrtX25519Shared(BobPrivate, AlicePublic, BobShared) || !xrtConstTimeEqual( AliceShared, BobShared, sizeof(AliceShared) ) ) { goto cleanup; } memcpy(Transcript, AlicePublic, sizeof(AlicePublic)); memcpy( Transcript + sizeof(AlicePublic), BobPublic, sizeof(BobPublic) ); /* 派生后的会话密钥才进入 AEAD,原始共享秘密不直接使用。 */ if ( !xrtHkdfSha256( Salt, sizeof(Salt) - 1u, AliceShared, sizeof(AliceShared), Transcript, sizeof(Transcript), SessionKey, sizeof(SessionKey) ) ) { goto cleanup; } /* 新临时密钥下的首条记录使用序号零,后续记录必须递增 nonce。 */ memcpy(Buffer, Message, iMessageSize); if ( !xrtChaCha20Poly1305Seal( SessionKey, Nonce, Aad, sizeof(Aad) - 1u, Buffer, iMessageSize, Buffer, sizeof(Buffer) ) || !xrtChaCha20Poly1305Open( SessionKey, Nonce, Aad, sizeof(Aad) - 1u, Buffer, iSealedSize, Buffer, sizeof(Buffer) ) ) { goto cleanup; } bValid = memcmp(Buffer, Message, iMessageSize) == 0; cleanup: xrtSecureZero(AlicePrivate, sizeof(AlicePrivate)); xrtSecureZero(BobPrivate, sizeof(BobPrivate)); xrtSecureZero(AliceShared, sizeof(AliceShared)); xrtSecureZero(BobShared, sizeof(BobShared)); xrtSecureZero(SessionKey, sizeof(SessionKey)); xrtSecureZero(Buffer, sizeof(Buffer)); printf("session round trip: %s\n", bValid ? "valid" : "invalid"); return bValid ? 0 : 1; }
刚才发生了什么。 三个安全设计点,每个都对应本章一条纪律。① 双方密钥对:xrtX25519KeyPair 从安全随机源采样——每方各自的临时私钥永不离开本方;共享秘密用 xrtX25519Shared(安全入口,抗恶意公钥)计算并以 xrtConstTimeEqual 核对一致。② 派生绑定身份:原始共享秘密不直接进 AEAD(第 76 章坑 2 的正面示范)——HKDF 的 salt 是协议版本字符串、info 是双方公钥拼接的 Transcript:派生出的会话密钥同时绑定"这次协商的双方身份",中间人换掉任何一方公钥都会让两端派生出不同密钥、后续消息互相验证失败(防未知密钥共享攻击的工程形态)。③ 记录绑定序号:AAD 是 "record:0"——首条记录序号零,后续递增;重放旧记录或乱序都会因 AAD 不匹配被 Open 拒绝。收尾 cleanup 段把五类敏感材料(双方私钥、双方共享秘密、会话密钥、明文缓冲)逐一 xrtSecureZero——这就是密钥生命周期第四段的模板代码。对照第 82 章:TLS 1.3 握手在此之上增加的只是证书验证与更完整的状态机——骨架你已经全部拥有。
77.4契约
- 常量时间:
xrtConstTimeEqual运行时间只与长度参数成正比;不隐藏长度、非完整侧信道防护;一切"一侧为秘密"的比较必须使用。 - 安全清零:
xrtSecureZerovolatile 写、不被死存储删除;只覆盖调用给出的区间——编译器寄存器副本、日志、交换文件、崩溃转储需要系统级措施;带状态对象用专属Clear/KeyClear。 - 随机源:安全用途(密钥/盐/nonce/令牌)一律
xrtSecureRandom;可复现用途(测试/基准)一律普通随机;密码模块经独立RANDOM_SECURE特性依赖安全随机。 - 密钥不入非易失记录:日志、错误消息、异常报告、调试打印零密钥内容——十六进制转储密钥的"调试便利"就是泄露事故。
- 失败单一:验证失败即失败,无降级继续;错误路径与成功路径执行相同的敏感材料清理。
- 上下文绑定:KDF 的 info、AEAD 的 AAD、握手的 Transcript 各自绑定用途/序号/身份——密文与它的语境不可分离。
- 临时密钥:每会话全新密钥对(前向保密);长期密钥只签名不加密数据。
- 组合优先级:现成协议 > 标准原语组合 > 自创协议;自创协议的每一步都需要安全评审。
常见错误
症状:远程计时攻击逐字节恢复令牌/MAC——响应时间随公共前缀长度变化,攻击者每字节几千次请求即可锁定。
原因:memcmp 在首个不同字节提前返回。功能测试完全正常(结果对),安全属性完全缺失(时序泄漏)。
uint8 Mac[XRT_SHA256_SIZE]; xrtHmacSha256(Key, sizeof(Key), Body, iSize, Mac); if ( memcmp(Mac, SentMac, sizeof(Mac)) == 0 ) { accept(); /* 计时侧信道:前缀正确时返回更慢 */ }
uint8 Mac[XRT_SHA256_SIZE]; xrtHmacSha256(Key, sizeof(Key), Body, iSize, Mac); if ( xrtConstTimeEqual(Mac, SentMac, sizeof(Mac)) ) { accept(); /* 耗时只与长度有关,无前缀信息 */ }
症状:内存审计发现密钥明文残留在已释放内存中;反汇编发现清零代码根本不存在。
原因:编译器看到"memset 之后无读取",按死存储优化把清零删除——C 标准允许,因为 memset 后的值在抽象机上确实无人使用。
void handle_key(uint8* pKey, size_t iSize) { use(pKey, iSize); memset(pKey, 0, iSize); /* 被 DSE 优化删除 */ }
void handle_key(uint8* pKey, size_t iSize) { use(pKey, iSize); xrtSecureZero(pKey, iSize); /* volatile 写,优化器不可删除 */ }
症状:日志收集平台(及其全部下游:检索、备份、三方分析)拿到了生产密钥;轮换密钥的应急响应要花一整晚——日志不可撤回。
原因:开发期"看一眼密钥对不对"的调试打印进了默认日志级别,发布时没人记得删。密钥一旦进入日志系统就完成了不可控扩散。
/* 排障时的"顺手一打" */ log_debug("key=%02x%02x... nonce=%02x%02x...", Key[0], Key[1], Nonce[0], Nonce[1]);
/* 只打印密钥的存在性与长度,永不打印内容 */ log_debug("key ready: %zu bytes, nonce ready: %zu bytes", iKeySize, iNonceSize); 而不是密钥本身 */
编程练习
构造两对 32 字节缓冲:一对完全相同、一对仅最后一字节不同。分别用 memcmp 与 xrtConstTimeEqual 循环比较一千万次并计时,输出四组耗时。验收标准:memcmp 在"前 31 字节相同"时明显快于"首字节即异";xrtConstTimeEqual 两组耗时统计上无法区分。
回到你第 75 章练习写的记录流通道,逐行标注每类敏感材料(密钥/nonce/明文)的生成、使用、清理位置,检查:有没有用普通随机生成安全值?有没有 memcmp 比较秘密?cleanup 是否覆盖成功路径?修复全部问题并记录修改点。验收标准:审计清单覆盖三层失效模型的系统层全部条目。
扩展 crypto/session:支持任意条记录的收发——维护 64 位记录序号,nonce 由序号填充(计数器法),AAD 绑定"方向 + 序号"(双向各有独立计数);最后一条消息后双方主动销毁会话(清密钥并打印 session destroyed)。攻击实验:截获第 3 条记录重放——Open 必须失败;乱序投递——必须失败。验收标准:正反各 10 条记录全部往返成功;全部敏感材料在销毁段被清零(第 6 章统计对照)。
小结
| 知识点 | 速查 |
|---|---|
| 三层失效 | 算法层(最难破)< 协议层 < 系统层(最常破)——注意力反向分配 |
| 常量时间 | xrtConstTimeEqual 只与长度相关;秘密比较必用;不隐藏长度、非全侧信道 |
| 安全清零 | xrtSecureZero volatile 写防 DSE;只覆盖给定区间;状态对象用 Clear/KeyClear |
| 随机分界 | 安全值(密钥/盐/nonce/令牌)→ xrtSecureRandom;可复现值 → 普通随机 |
| 密钥生命周期 | 生成(合规来源)→ 使用(最小驻留)→ 存储(不入日志/转储)→ 销毁(SecureZero) |
| 失败单一 | 验证失败即失败;无降级继续;错误路径同样清理 |
| 上下文绑定 | KDF info 绑用途 / AAD 绑序号与版本 / Transcript 绑身份 |
| 临时密钥 | 每会话全新密钥对;前向保密;长期密钥只签不加密 |
| 组合优先级 | 现成协议 > 标准组合 > 自创;session 示例是标准组合的模板 |
| 会话骨架 | KeyPair → Shared → HKDF(Transcript) → AEAD(AAD=序号) → SecureZero 全清 |
📖 本章涉及的 API 参考手册:crypto(140 个条目)random(41 个条目)
点击查看完整签名、参数约束、返回值与错误说明