2. 时间侧信道基础
密码算法在数学上可以证明安全,但一旦落到真实处理器上执行,就会留下物理痕迹。时间侧信道研究的正是这些痕迹中最容易被利用的一类:执行时间随秘密数据变化。要理解常数时间实现,必须先看清时间信息是如何从微架构层面泄漏出来的,以及“常数时间”与“恒定时间”这两个常被混用的概念究竟差在哪里。
一、时间侧信道的物理来源
现代 CPU 为了追求性能,引入了大量与数据相关的动态行为。这些行为本身不是缺陷,但它们让指令的执行延迟不再固定,从而把秘密“编码”进了时间。
缓存计时(Cache Timing)。缓存命中与未命中的访问延迟相差一到两个数量级。以典型的 L1 命中约 4 个周期、L3 命中约 40 个周期、主存访问约 200 个周期为例,如果一次查表操作的索引由密钥决定,那么攻击者通过测量访问时间,就能推断出该索引落在哪个缓存行,进而缩小密钥搜索空间。AES 的 T 表实现、RSA 的模幂窗口算法都是经典受害者。
分支预测(Branch Prediction)。处理器遇到条件分支时会预测走向并投机执行。预测正确则流水线顺畅,预测失败则要清空并重新取指,产生十几到几十周期的惩罚。如果分支条件依赖密钥比特,那么“走哪条路”就会通过预测失败率反映到总耗时上。更隐蔽的是,攻击者还可以利用分支预测器状态作为跨进程的隐蔽信道。
指令流水线(Instruction Pipeline)。流水线让多条指令重叠执行,但数据相关、结构相关和控制相关会造成停顿(stall)。当指令序列本身随秘密变化时——例如提前退出循环、跳过某段计算——流水线的填充与排空次数就不同,累计时间差可达数百周期。此外,乘法器、除法器等可变延迟执行单元,其完成时间取决于操作数,也会直接泄漏信息。
功耗与电磁泄漏。CMOS 电路在翻转时消耗能量,翻转次数与处理的数据相关。通过测量芯片电源引脚上的电流波动,或用近场探头采集电磁辐射,攻击者可以恢复出运算中间值。这类侧信道与时间侧信道同源:都源于“数据影响物理行为”。区别在于,时间侧信道是数字化的、易远程测量;功耗与电磁是模拟的、通常需要物理接近,但信息量更大。三者关系可以用一条链概括:秘密数据 → 微架构状态/电路翻转 → 时间、功耗、电磁等可观测量 → 攻击者推断。
二、常数时间与恒定时间的区别
这两个词在中文资料里经常互换,但在工程语境下应当严格区分。
常数时间(Constant Time)指的是:算法的执行时间不依赖于秘密数据。注意,它并不要求每次执行耗时完全相同,只要求“秘密不同不会导致时间不同”。例如,一个函数无论密钥比特是 0 还是 1,都执行相同数量的指令、访问相同数量的缓存行,那么它就是常数时间的。它的时间可以是 100 周期,也可以是 1000 周期,只要这个数字与秘密无关。
恒定时间(Constant-Time / Fixed Time)在更严格的用法中,指执行时间不仅与秘密无关,而且与公开输入也无关,每次调用耗时几乎完全一致。这在实践中很难做到,因为公开输入长度、循环次数、系统调度都会影响总时间。因此,安全社区通常把“constant-time”作为目标,而把“fixed-time”视为一种更强的、有时不必要甚至不可达的性质。
更准确地说,密码实现追求的是数据无关时间(Data-Independent Timing):所有与秘密相关的操作路径、访存地址、指令序列都保持一致。判断一个实现是否达标,不能只看代码里有没有 if,而要看编译后的机器码是否引入了数据相关的分支或访存。
三、一个可观察的对比示例
下面用一段伪代码说明“数据相关分支”如何造成泄漏,以及如何改写为常数时间形式。假设我们要根据密钥比特 k 选择两个值之一:
// 非常数时间:分支依赖秘密
if (k == 1) {
result = a;
} else {
result = b;
}
// 常数时间:用位掩码消除分支
// mask = 0xFFFFFFFF 当 k==1,否则为 0
uint32_t mask = -(uint32_t)k;
result = (a & mask) | (b & ~mask);
第一段代码在 k 不同时,会走不同的分支,分支预测器状态、流水线填充次数都不同,攻击者可以通过大量测量区分。第二段代码无论 k 取何值,都执行相同的与、或、非运算,指令序列固定,时间不随 k 变化。这就是常数时间实现最基本的“掩码选择”技巧。
再看访存。假设有一个 256 字节的 S 盒,索引由秘密决定:
// 非常数时间:访存地址依赖秘密
uint8_t val = sbox[secret_index];
// 常数时间:扫描全表,用掩码累加
uint8_t val = 0;
for (int i = 0; i < 256; i++) {
uint8_t mask = (uint8_t)-(i == secret_index);
val |= sbox[i] & mask;
}
第二段代码每次都访问全部 256 个表项,缓存访问模式与秘密无关,代价是性能下降约两个数量级。实际工程中会用向量化、预取或位切片来缓解,但核心思想不变:让访存地址集合与秘密解耦。
四、从泄漏链到防护的映射
把前面的泄漏链反过来看,就得到防护的着力点:
- 缓存计时 → 固定访存模式,避免秘密索引查表,或使用扫描全表、位切片。
- 分支预测 → 消除秘密相关分支,用算术/逻辑运算替代条件跳转。
- 指令流水线 → 保持指令序列一致,避免提前退出循环、避免可变延迟指令。
- 功耗与电磁 → 在算法层做掩码(Masking)和隐藏(Hiding),使中间值与秘密统计独立。
需要强调的是,常数时间实现不是“加一个开关”就能完成的。它要求从算法选择、代码编写、编译器优化到目标微架构验证的全链条控制。例如,编译器可能把位掩码优化回分支,或者把循环展开成不同的访存序列。因此,写完代码后必须检查生成的汇编,确认没有数据相关的 jmp、cmov 之外的秘密相关条件,以及访存地址不随秘密变化。
&&、||、三元运算符、短路求值、数组越界检查、甚至某些除法指令,都可能引入数据相关延迟。必须逐条核对机器码。