核心思想:常数时间比较的定义、数学本质与设计目标
好,咱们进入正题。上一章聊了为什么需要常数时间比较,这一章我带你深挖一下它的底层逻辑。
说白了,常数时间比较不是一种「更快的比较」,而是一种「更安全的比较」。它的核心目标,是让攻击者无法通过测量比较耗时,来推断出任何关于被比较数据的信息。
什么是常数时间比较?
先给个直观定义:常数时间比较,是指无论输入数据是否匹配、在哪个位置不匹配,整个比较过程消耗的时间都完全相同。
举个例子。你写了个字符串比较函数,用来验证用户输入的密码是否和数据库里的哈希值一致。如果用传统的 strcmp,一旦发现第一个字符不同就立刻返回 false。攻击者可以反复尝试,测量每次返回的时间,慢慢猜出正确的密码前缀。这就是典型的「时间侧信道攻击」。
常数时间比较的做法恰恰相反:它会把两个输入的所有字节都读完,不管中间发现多少次不匹配,都坚持到最后才给出结果。
一句话总结:常数时间比较牺牲了「提前退出」的优化机会,换来了「时间消耗与数据内容无关」的安全保证。
数学本质:从信息论角度看
我个人习惯从信息论的角度来理解这个问题。你想想看,一个比较函数本质上是在泄露信息——泄露「两个输入是否相等」这个布尔值。
传统比较函数的问题在于,它泄露的信息远不止这个布尔值。它还泄露了「第一个不匹配的位置」。攻击者每做一次比较,就能获得一个比特的信息:「哦,前 N 个字符是对的,第 N+1 个是错的。」
数学上,我们可以这样描述:
- 传统比较:输出结果的时间
T是输入数据X和Y的函数,且T(X, Y)依赖于第一个不匹配的位置k。即T = f(k),其中k是第一个不匹配的索引。 - 常数时间比较:输出结果的时间
T与输入数据无关,只与输入长度n有关。即T = g(n),且g(n)是一个常数(对于固定长度的输入)。
说白了,常数时间比较的目标就是:让攻击者从时间维度上获得的信息量,严格等于零。
一个小技巧:我在项目中经常用「异或累加」的方式来实现常数时间比较。因为异或运算天然具有「只要有一位不同,最终结果就不为零」的特性,而且它的执行时间与数据内容无关。后面我们会详细讲这个实现。
设计目标:三个必须满足的条件
一个合格的常数时间比较函数,必须同时满足以下三个条件。我管它叫「铁三角」:
| 条件 | 说明 | 违反后果 |
|---|---|---|
| 无数据依赖分支 | 代码中不能有基于输入数据的条件分支(if/else、switch、三元运算符等) | 攻击者可以通过分支预测失败或分支延迟来推断数据 |
| 无数据依赖内存访问 | 不能根据输入数据来决定访问哪个内存地址(如查表操作) | 攻击者可以通过缓存时间差异来推断数据 |
| 无数据依赖运算耗时 | 所有运算的耗时不能随输入数据变化(如除法、取模等可变延迟指令) | 攻击者可以通过指令执行时间的差异来推断数据 |
嗯,这里要注意。第三个条件在 x86 架构上尤其容易踩坑。我记得有一次,我在实现一个 AES 密钥比较函数时,用了取模运算来对齐数据。结果性能分析工具告诉我,取模指令的延迟竟然随操作数变化——最高能差出十几个时钟周期。从那以后,我对所有「看起来是常数时间」的指令都持怀疑态度。
一个直观的对比示例
来看两段代码。左边是传统比较,右边是常数时间比较。都是比较两个 32 字节的数组。
// 传统比较(不安全)
int insecure_compare(const uint8_t *a, const uint8_t *b, size_t len) {
for (size_t i = 0; i < len; i++) {
if (a[i] != b[i]) { // 数据依赖分支!
return 0; // 提前退出
}
}
return 1;
}
// 常数时间比较(安全)
int secure_compare(const uint8_t *a, const uint8_t *b, size_t len) {
uint8_t result = 0;
for (size_t i = 0; i < len; i++) {
result |= a[i] ^ b[i]; // 无分支,只做异或和或运算
}
return (result == 0); // 最后统一判断
}
看出来区别了吗?左边那个函数,如果第一个字节就不匹配,它只循环一次就返回了。右边那个函数,不管数据长什么样,它都老老实实循环 32 次。这就是「常数时间」的代价——也是它的安全所在。
我曾经踩过的坑:一开始我写常数时间比较时,觉得用 memcmp 就安全了。后来才发现,很多标准库的 memcmp 实现内部用了 SIMD 指令优化,而这些指令的耗时和数据的对齐方式、内容都有关系。所以,永远不要假设标准库函数是常数时间的。自己写,或者用经过验证的专用库。
知识体系总览
下面这张图,把本章的核心逻辑串起来了。你可以把它当作一个思维导图来理解。
为什么「常数时间」不等于「固定时间」?
这里有个容易混淆的点,我得说清楚。常数时间比较不是说「每次执行都精确地花同样的纳秒数」。实际上,由于 CPU 的缓存命中率、指令流水线状态、中断处理等因素,每次执行的时间都会有微小波动。
真正的含义是:这些时间波动与输入数据的内容无关。 也就是说,攻击者无法通过统计多次执行的时间,来推断出任何关于数据的信息。
我打个比方。你测量一个人从北京走到上海的时间,每次测量结果可能差几分钟——因为路上可能堵车、天气不同。但如果这个人每次走的路程都一样,那这些时间波动就是「无信息」的噪声。常数时间比较追求的就是这种效果:让所有的时间差异都变成与数据无关的噪声。
关键认知:常数时间比较不是追求「绝对的时间恒定」,而是追求「时间与数据之间的统计独立性」。只要攻击者无法从时间测量中提取出关于数据的任何信息,这个比较函数就是安全的。
设计目标总结
最后,我把常数时间比较的设计目标归纳成一句话:在保证功能正确的前提下,消除所有可能被侧信道攻击利用的时间信息泄露渠道。
具体来说,就是做到三点:
- 消除数据依赖的分支——用位运算代替条件判断
- 消除数据依赖的内存访问——用顺序访问代替查表
- 消除数据依赖的指令延迟——用简单指令代替复杂指令