如何恒定时间比较?

解读

在国内 Rust 岗位面试中,"恒定时间比较"并不是考排序复杂度,而是考密码学安全侧信道防御意识。
面试官真正想问的是:

  1. 你知道常规 == 会按字节短路比较,时间随匹配长度变化,可被计时攻击(Timing Attack)利用;
  2. 你能给出 Rust 标准库/第三方 crate 中已加固的恒定时间实现,并解释其汇编级保证;
  3. 你能权衡性能、可移植性与 FFI 安全,写出零开销抽象的 Rust 代码,而不是简单贴一段 C 移植版。

知识点

  1. 计时攻击原理:攻击者通过测量响应时间差异推断密钥或 token 的逐字节匹配情况。
  2. 恒定时间(constant-time)三要素
    • 无分支(branch-free)
    • 无内存访问依赖(memory access pattern 固定)
    • 无数据依赖的指令周期(固定 CPU 流水线行为)
  3. Rust 生态方案:
    • subtle crate 的 ConstantTimeEq trait,编译期保证无分支,支持 u8 数组到 CtOption<T> 的整套抽象;
    • ring/rust-crypto 内部均依赖 subtle
    • 标准库暂无公开恒定时间比较,不可直接用 ==
  4. 底层实现:使用 xor + or + wrapping_sub 掩码技巧,LLVM 无法优化掉,且通过 #[inline(never)]volatile 读屏障防止编译器重排。
  5. 零成本抽象:Rust 泛型 + const GENERIC 长度参数,编译期展开循环,无运行时长度变量,真正做到 O(1) 时间且零堆分配。
  6. 国内合规:国密算法 SM4-GCM、双证书 TLS 场景下,商密产品认证明确要求 MAC 比较必须恒定时间,否则无法过检。

答案

use subtle::ConstantTimeEq;

/// 恒定时间比较两个等长密钥,返回 true 仅当完全一致
/// 时间只与 len 有关,与内容无关
pub fn secure_compare(a: &[u8], b: &[u8]) -> bool {
    // 长度不一致直接拒绝,但耗时仍恒定:先比较 len 再做 CtEq
    if a.len() != b.len() {
        return false;
    }
    // subtle 内部使用 branch-free 汇编,保证 O(1) 时间
    a.ct_eq(b).into()
}

/// 若不想依赖第三方,可手写核心 64 字节以内无分支版本
#[inline(never)]
pub fn ct_compare_32(a: &[u8; 32], b: &[u8; 32]) -> bool {
    let mut acc = 0u8;
    for i in 0..32 {
        acc |= a[i] ^ b[i];
    }
    // 0 表示相等,非零表示不等;通过 WrappingSub 把结果映射到 0/1
    // 编译器无法优化掉 acc 使用,因其参与 volatile 风格的黑盒
    (acc.wrapping_sub(1) >> 7) == 0
}

要点说明:

  • subtle 已屏蔽所有 LLVM 优化陷阱,生产环境优先使用;
  • 手写版必须 #[inline(never)] 防止跨过程优化,且数组长度固定为 const,循环展开后指令数恒定
  • 禁止提前 return,保证最坏情况时间等于最好情况时间

拓展思考

  1. 可变长度前缀攻击:若先比较长度再比较内容,攻击者仍可测量总时间差。正确做法是先复制到固定长度缓冲区(如 128 B),再恒定时间比较,牺牲内存换安全。
  2. 异步场景:在 Tokio 任务中做 MAC 校验,务必使用 blocking::unblock 把恒定时间比较 offload 到独立线程,防止调度器计时噪声被攻击者采样。
  3. 国密双证书:SM2 签名结果 DER 编码长度可变,需先解析出固定 64 B 的 (r,s),再做 CtEq,否则恒定时间比较会失去意义。
  4. 侧信道升级:恒定时间仅防时序攻击,无法防御功耗/电磁分析。国内高等级密码模块需再叠加掩码与盲化技术,Rust 可通过 zeroize crate 在比较后立即清零中间状态,减少信息残留。