4. 逐字节异或:常数时间比较的经典实现——异或累加法

好了,前面我们聊了那么多理论,现在该上手写真正的代码了。

这一章要讲的,是常数时间比较里最经典、最常用的实现方式——异或累加法。说白了,就是把两个字节数组逐字节做异或,然后把所有结果累加(或按位或)起来,最后检查累加值是不是零。

为什么异或能用来做比较?因为 a XOR b 只有在 a 和 b 完全相等时才等于 0。你想想看,只要有一个字节不同,异或结果就不是 0,累加和自然也不是 0。这个逻辑简单到令人发指,但恰恰是这种简单,保证了它能在常数时间内完成。

4.1 核心原理:异或的数学性质

先复习一下异或的几个基本性质:

  • 自反性:a XOR a = 0 —— 自己跟自己异或,结果一定是 0。
  • 交换律:a XOR b = b XOR a —— 顺序不影响结果。
  • 结合律:(a XOR b) XOR c = a XOR (b XOR c) —— 可以任意分组。

基于这些性质,我们可以推导出:如果两个数组 A 和 B 长度相同,且每个对应字节都相等,那么 A[0] XOR B[0] 到 A[n-1] XOR B[n-1] 全部为 0,累加结果自然也是 0。只要有一个字节不同,累加结果就非零。

核心结论:异或累加法的正确性完全依赖于异或的自反性。这是整个算法的数学根基。

4.2 经典实现:C 语言版本

我个人习惯用 C 语言来写这类底层函数,因为 C 能精确控制内存访问和指令执行。下面这个实现,是我在多个项目中反复打磨过的版本。

#include <stdint.h>
#include <stddef.h>

/* 常数时间比较:逐字节异或累加法 */
int constant_time_compare(const uint8_t *a, const uint8_t *b, size_t len) {
    uint8_t result = 0;
    size_t i;

    for (i = 0; i < len; i++) {
        result |= a[i] ^ b[i];
    }

    /* 返回 0 表示相等,返回非 0 表示不等 */
    return (int)result;
}

嗯,这里要注意几个关键点:

  • 使用 uint8_t:确保每个字节都是无符号 8 位整数,避免符号扩展带来的意外。
  • 使用 |= 累加:按位或操作不会溢出,而且能保留所有差异信息。如果用 +=,理论上可能溢出(虽然实际中很少发生),但 |= 更安全。
  • 循环次数固定:循环执行 len 次,不提前退出。这是常数时间的关键。

小技巧:如果你需要返回布尔值(0/1),可以用 !!result 来归一化。但注意,!! 在某些编译器上可能被优化成条件分支,所以最好保持原样返回,让调用方自己判断。

4.3 为什么用 |= 而不是 +=?

这个问题我经常被问到。其实两种方式在大多数情况下都能工作,但 |= 有几个明显的优势:

特性 |=(按位或) +=(加法)
溢出风险 无溢出 可能溢出(uint8_t 范围 0-255)
信息保留 保留所有差异位 可能因溢出丢失信息
编译器优化 通常映射到简单 OR 指令 可能被优化成条件分支
可读性 意图明确:累积差异 容易误解为计数

我在项目中遇到过一个问题:用 += 实现时,某个编译器把加法优化成了 SIMD 指令,结果因为对齐问题导致段错误。换成 |= 后,编译器生成了更保守的代码,问题就解决了。所以我现在一律用 |=。

4.4 避坑指南:常见错误

我曾经在代码审查中看到过不少新手犯的错误,这里列几个典型的:

  • 提前退出:在循环里加 if (result != 0) break; —— 这直接破坏了常数时间性质。
  • 使用 memcmp:标准库的 memcmp 通常不是常数时间实现的,它会在找到第一个不同字节时立即返回。
  • 类型错误:用 int 或 char 存储异或结果,可能导致符号扩展或未定义行为。
  • 长度不一致:如果两个数组长度不同,直接比较会出问题。我一般会先比较长度,如果长度不同直接返回不等。

警告:永远不要假设编译器会保留你的常数时间意图。某些激进优化(如循环展开、向量化)可能改变执行路径。建议在关键代码处加 volatile 或使用编译器屏障来防止优化。

4.5 知识体系:异或累加法的核心逻辑

下面这张图,把整个算法的流程和关键决策点都画出来了。我建议你仔细看看,尤其是「累加方式」那个分支——选错了,常数时间性质就可能打折扣。

异或累加法核心逻辑流程图 异或累加法核心逻辑 开始比较 输入:数组 a, 数组 b, 长度 len 初始化 result = 0 i < len ? (循环 len 次) 是 result |= a[i] ^ b[i] (逐字节异或 + 按位或累加) 否 返回 (int)result 关键:循环次数固定为 len,不提前退出;累加使用 |= 避免溢出和优化风险

4.6 扩展:处理长度不一致的情况

实际应用中,两个数组的长度可能不同。这时候直接逐字节比较会出问题。我的做法是:

int constant_time_compare_safe(const uint8_t *a, size_t len_a,
                               const uint8_t *b, size_t len_b) {
    /* 先比较长度,结果存入 result */
    uint8_t result = (len_a != len_b);
    
    /* 取较小长度进行逐字节比较 */
    size_t min_len = (len_a < len_b) ? len_a : len_b;
    size_t i;
    
    for (i = 0; i < min_len; i++) {
        result |= a[i] ^ b[i];
    }
    
    /* 如果长度不同,result 已经非零;如果长度相同,result 反映字节差异 */
    return (int)result;
}

这里有个细节:result = (len_a != len_b) 这个赋值本身是常数时间的——它只是一个比较指令,没有分支。但要注意,某些编译器可能把 != 编译成条件跳转,所以最好用位运算来替代:result = (len_a ^ len_b) != 0 或者直接用 result = (len_a ^ len_b) >> 8 之类的技巧。不过说实话,在大多数现代编译器上,直接写 != 是安全的。

个人经验:我在一个密码学库中看到过一种更激进的写法——把长度也当作字节数组的一部分来比较。具体做法是把 len_a 和 len_b 拆成 4 个字节,然后跟数据字节一起异或累加。这样连长度比较都融入循环里了,进一步减少了侧信道信息泄露。

4.7 性能考量:真的「常数时间」吗?

你可能会问:这个循环执行 len 次,时间跟长度成正比,怎么能叫常数时间?

嗯,这里要澄清一下:常数时间比较中的「常数」,指的是对于给定的输入长度,执行时间不随数据内容变化。也就是说,无论两个数组是相等还是不等,无论第一个字节就不同还是最后一个字节才不同,循环都执行同样的次数。这才是我们关心的——防止攻击者通过时间差异推断出比较结果。

至于长度本身,它通常是公开信息(比如密码哈希值的长度是固定的),所以长度带来的时间变化不构成安全风险。

场景 普通比较 常数时间比较
第一个字节不同 1 次循环后返回 len 次循环后返回
最后一个字节不同 len 次循环后返回 len 次循环后返回
完全相等 len 次循环后返回 len 次循环后返回

看到了吧?普通比较的时间会随数据变化,而常数时间比较的时间只取决于长度。这就是我们想要的效果。

4.8 小结

异或累加法是常数时间比较的基石。它简单、可靠、容易验证。我个人在写安全相关的代码时,几乎都用这个模式。记住几个要点:

  • 用 |= 而不是 +=
  • 循环次数固定,不提前退出
  • 注意类型安全,用 uint8_t
  • 长度不一致时要额外处理