bigywater.tech
Java · 260

常量时间比较:为什么密钥标签不能直接 equals

验证 HMAC 或令牌时直接用 equals 比较,耗时会泄露「前面几个字节猜对了」。MessageDigest.isEqual 为什么是正解?这篇用大量重复测量,看不同比较方式的耗时怎么随第一处不同的位置变化,以及小数组上测量的局限。

14.7 → 827.8 ns
手写早退出循环:第一处不同在位置 0 → 4095(4096 字节)
约 1986 ns
MessageDigest.isEqual 与位置无关的耗时
32 字节
这个长度的测量被 JIT 左右,没有得出结论

第一处不同的位置

实测 · 本机执行输出
  两个 4096 字节的数组,第一处不同出现在不同的位置,比较一次的耗时(每批 500 次,200 批的中位数):
  手写循环(早退出)                位置0:14.7 位置1024:223.7 位置2048:414.2 位置4095:827.8 位置无(相同):830.2 ns
  Arrays.equals            位置0:22.5 位置1024:49.7 位置2048:43.2 位置4095:76.0 位置无(相同):79.4 ns
  MessageDigest.isEqual    位置0:1986.3 位置1024:1989.7 位置2048:1977.1 位置4095:1984.0 位置无(相同):1981.3 ns

差异与噪声

实测 · 本机执行输出
  [32 字节] 第一个字节就不同 vs 最后一个字节才不同(32 字节的测量被 JIT 与调用点状态左右,正负号都可能反过来,不能用来下结论):
    手写循环                     早不同 中位 4.1 ns(四分位距 4.0–4.2);晚不同 中位 24.0 ns(四分位距 8.8–24.9);中位数之差 +19.88 ns(四分位距 16.06 ns,差 / 四分位距 = 1.2)
    Arrays.equals            早不同 中位 21.1 ns(四分位距 1.8–22.2);晚不同 中位 2.3 ns(四分位距 2.3–2.4);中位数之差 -18.81 ns(四分位距 20.40 ns,差 / 四分位距 = 0.9)
    MessageDigest.isEqual    早不同 中位 18.8 ns(四分位距 18.7–20.4);晚不同 中位 18.6 ns(四分位距 18.6–18.7);中位数之差 -0.19 ns(四分位距 1.71 ns,差 / 四分位距 = 0.1)
  [4096 字节] 第一个字节就不同 vs 最后一个字节才不同:
    手写循环                     早不同 中位 3.1 ns(四分位距 3.0–3.1);晚不同 中位 836.3 ns(四分位距 831.6–842.9);中位数之差 +833.21 ns(四分位距 11.33 ns,差 / 四分位距 = 73.5)
    Arrays.equals            早不同 中位 3.0 ns(四分位距 3.0–3.0);晚不同 中位 77.4 ns(四分位距 77.4–77.4);中位数之差 +74.35 ns(四分位距 0.08 ns,差 / 四分位距 = 890.5)
    MessageDigest.isEqual    早不同 中位 2206.3 ns(四分位距 2197.8–2271.5);晚不同 中位 2202.0 ns(四分位距 2196.8–2209.0);中位数之差 -4.25 ns(四分位距 73.71 ns,差 / 四分位距 = 0.1)

isEqual 的语义与 String.equals

实测 · 本机执行输出
  MessageDigest.isEqual(a, a) = true;长度不同 isEqual([1,2], [1,2,3]) = false
  isEqual(null, null) = true
  isEqual(null, [1]) = false
  验证 HMAC 的推荐写法:重新计算标签,用 MessageDigest.isEqual 比较两个定长标签:true;猜错一个 bit → false
  长度不同时 isEqual 每次 21.7 ns;长度相同(相等)每次 20.6 ns(这次测量没有看到显著差异;比较的是固定长度的标签时,长度本身不是秘密)
  String.equals(5000 个字符):末尾才不同 中位 193.9 ns(四分位距 193.8–199.6);开头就不同 中位 19.7 ns(四分位距 19.6–19.8)(字符串比较同样是早退出,不能用来比较密钥或标签)

分步演示

常量时间比较
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / OpenJDK(Temurin 17.0.20、Homebrew 21.0.12 与 27,每篇注明实际用的版本);用 javac 编译、java 直接运行,需要第三方库时用 Maven 解析依赖;计时用 System.nanoTime,数字每次运行会有小幅抖动。JDK 21.0.12,macOS Apple Silicon;测量在同一个 JVM 里重复大量次数取中位数
    • 没有验证:通过网络远程测量时序的可行性(这里只在本进程里测比较函数本身)、JDK 其它版本里 MessageDigest.isEqual 的实现、更小数组上的统计方法(需要更多的样本与统计检验)、缓存与分支预测对常量时间代码的影响、Java 之外的语言
    京ICP备2026067502号-1京公网安备11010502063487号