3. 密码实现中的秘密相关分支

在常数时间密码实现中,最容易被忽视、也最难彻底消除的泄漏源,往往不是复杂的数学运算,而是那些看起来再普通不过的控制流语句:if/else、switch、循环边界判断、提前返回。它们本身没有错,问题在于当判断条件依赖于秘密数据时,CPU 的分支预测、指令缓存、流水线状态会随秘密取值不同而产生可观测差异。攻击者通过计时、缓存命中率或功耗轨迹,就能逐步还原出密钥比特。

本章的目标不是让你记住“不能用 if”,而是建立一套识别与改写的方法:先判断哪些分支真正与秘密相关,再根据数据宽度、平台特性和性能预算,选择掩码选择、算术消除或恒定遍历等改写策略。

一、秘密相关分支的四种典型模式

在审查密码代码时,可以优先关注以下四类结构。它们并不总是危险,但只要条件表达式里出现了密钥、明文、随机数或中间状态,就应被标记为候选风险点。

  • if/else 二选一:例如根据密钥比特选择两个预计算值之一,或根据比较结果决定是否执行某段代码。
  • switch/case 多路选择:常见于查表、S 盒访问、模式选择,分支数量越多,泄漏面越大。
  • 循环边界依赖秘密:例如“循环次数等于密钥中 1 的个数”,或“遇到某个秘密条件就 break”。
  • 提前返回:例如比较函数在第一个不匹配字节处 return,返回位置直接暴露了公共前缀长度。

下面这张图把“秘密输入—分支条件—可观测差异—泄漏后果”的因果链集中呈现出来,便于在代码审查时对照定位。

秘密相关分支的泄漏因果链 秘密数据 密钥 / 明文 / 中间态 分支条件求值 if / switch / 循环边界 微架构可观测差异 分支预测 / 缓存 / 流水线 密钥信息泄漏 计时 / 缓存 / 功耗 四种典型模式及其改写方向 if / else 二选一 if (bit) r = a; else r = b; 风险:分支方向暴露 bit 改写:掩码选择 r = (a & m) | (b & ~m) switch / case 多路 switch (idx) { case 0: ... } 风险:跳转表 / 缓存行差异 改写:全表遍历 + 掩码 for i: r |= T[i] & eq(i,idx) 循环边界依赖秘密 for (i=0; i<secret; i++) 风险:迭代次数可计时 改写:固定上界 + 条件掩码 for i<N: 用掩码屏蔽无效项 提前返回 if (a[i]!=b[i]) return 0; 风险:返回位置暴露前缀 改写:累积差异标志 diff |= a[i]^b[i]; 最后判 diff 改写三原则 1 无分支化:用算术/位运算替代控制转移,让执行路径与秘密无关。 2 等时化:无法消除分支时,保证各路径执行时间、访存模式一致。 3 掩码化:用全 0/全 1 掩码选择数据,避免条件跳转和短路求值。

二、识别:从“条件是否含秘密”开始

识别风险的第一步,是明确哪些数据属于秘密。在密码实现中,秘密通常包括:长期密钥、会话密钥、随机数种子、解密后的明文、以及由它们派生的中间状态。公开数据如算法参数、消息长度、公开 IV 则一般不构成秘密。判断一个分支是否危险,只需问一句:条件表达式的取值是否可能随秘密变化?

以常见的字节比较为例:

int ct_compare_bad(const uint8_t *a, const uint8_t *b, size_t n) {
    for (size_t i = 0; i < n; i++) {
        if (a[i] != b[i]) return 0;   // 提前返回,泄漏首个不匹配位置
    }
    return 1;
}

这段代码在功能上完全正确,但返回位置与公共前缀长度相关。攻击者反复提交不同猜测,通过测量返回时间,可以逐字节恢复出正确值。改写方式是累积差异,最后统一判断:

int ct_compare_ok(const uint8_t *a, const uint8_t *b, size_t n) {
    uint8_t diff = 0;
    for (size_t i = 0; i < n; i++) {
        diff |= (uint8_t)(a[i] ^ b[i]);
    }
    return diff == 0;   // 循环次数固定,无提前返回
}

注意,最后的 diff == 0 仍然是一个分支,但它发生在循环结束之后,且此时所有秘密相关计算已经完成。如果调用方对返回值的使用方式不泄漏额外信息,这个末端分支通常可以接受;若要求更严格,可将其转换为掩码返回值。

三、改写原则与常见手法

原则一:优先消除分支,而不是“平衡分支”。很多开发者试图让 if 和 else 两条路径执行相同数量的指令,但分支预测器、指令缓存和流水线仍可能产生差异。更可靠的做法是用位运算直接计算两种结果,再用掩码选择。

例如根据密钥比特 k(取值 0 或 1)在 a 和 b 之间选择,可以写成:

uint32_t mask = (uint32_t)0 - (uint32_t)(k & 1);  // k=1 时全 1,k=0 时全 0
uint32_t r = (a & mask) | (b & ~mask);

这里没有跳转,执行的是固定的与、或、非运算,时间与 k 无关。前提是 k 已经被规范化为 0 或 1,否则需要先做布尔化处理。

原则二:循环次数必须与秘密无关。如果循环上界来自秘密,应改为固定上界,并在循环体内用掩码屏蔽超出有效范围的部分。例如处理一个长度可变的秘密缓冲区时,可以固定遍历最大长度,对无效位置用零掩码跳过累加。

原则三:警惕短路求值。C 语言中 && 和 || 具有短路语义,if (secret_a && secret_b) 会在 secret_a 为假时跳过 secret_b 的求值,从而泄漏 secret_a 的信息。应改用按位运算符 &、|,并确保操作数已布尔化。

原则四:查表访问要防缓存泄漏。即使消除了分支,如果表索引依赖秘密,不同索引可能落在不同缓存行,仍会通过缓存时序泄漏。常见对策是整表线性扫描,对每个表项计算“索引是否匹配”的掩码,再累加掩码后的值。这种方式把访问模式固定为顺序遍历,代价是 O(N) 而非 O(1),需要根据性能预算权衡。

注意:编译器优化可能把无分支代码重新变成分支,或把掩码选择识别为条件移动甚至跳转。改写后应检查生成的汇编,确认关键路径中没有依赖秘密的条件跳转。必要时使用 volatile、编译器屏障或内联汇编约束优化行为。

四、一个可操作的审查清单

在实际项目中,可以按以下顺序逐项检查密码实现代码。清单中的每一项都对应一个具体的观察点,而不是泛泛的“注意安全”。

检查项 观察点 改写方向
条件表达式 是否含密钥、明文、中间态 掩码选择或算术消除
循环上界 是否由秘密决定 固定上界 + 条件掩码
提前返回 返回位置是否依赖秘密 累积标志,末端统一判断
短路运算符 是否对秘密使用 && / || 改为按位 & / |
查表索引 索引是否来自秘密 整表扫描 + 掩码累加
编译器输出 是否重新引入条件跳转 屏障、volatile 或内联汇编

需要强调的是,常数时间改写不是一次性的代码替换,而是一个持续验证的过程。同一段源码在不同编译器、不同优化等级、不同 CPU 微架构下可能产生不同的机器码行为。因此,改写之后应结合目标平台的计时测试或缓存分析工具进行验证,确认关键操作的执行轨迹不再随秘密取值发生可观测变化。只有在“识别—改写—验证”形成闭环时,常数时间实现才真正可信。