
验签函数的最后一行通常是这么一句:把客户端送来的签名和本地算出来的比一下。客户端少发两个字符,这一行就抛 RangeError,一个本该返回 401 的请求会走成 500。
crypto.timingSafeEqual 只认等长的两个 Buffer。下面八种情况都在本机 Node v26.8.1 上跑过。
长度不等,抛错而不是 false
四组长度不匹配的输入,抛出来的都是同一条错。
== 1. 长度不同时抛什么 ==
len 3 对 len 4 -> RangeError code=ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH message=Input buffers must have the same byte length
RangeError 是 Error 吗=true 是 RangeError 吗=true 是 TypeError 吗=false
len 3 对 len 2 -> RangeError code=ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH message=Input buffers must have the same byte length
RangeError 是 Error 吗=true 是 RangeError 吗=true 是 TypeError 吗=false
len 0 对 len 1 -> RangeError code=ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH message=Input buffers must have the same byte length
RangeError 是 Error 吗=true 是 RangeError 吗=true 是 TypeError 吗=false
len 64 对 len 63 -> RangeError code=ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH message=Input buffers must have the same byte length
RangeError 是 Error 吗=true 是 RangeError 吗=true 是 TypeError 吗=false
constructor.name=RangeError
message 是 Input buffers must have the same byte length,code 是 ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH,instanceof RangeError 为真,instanceof TypeError 为假。函数没有「不等就返回 false」这条分支,布尔值只能自己在调用前拦。这行代码落进接口层的统一 catch,状态码本身就替客户端区分了长度对不对。
等长之后才轮到内容
等长时它的行为和普通比较一致:内容相同给 true,任一个字节不同给 false。
== 2. 等长的两种情况 ==
完全相同(两个不同 Buffer 对象): true
只有末字节不同(长度 29/29): false
只有首字节不同(长度 29/29): false
两个空 Buffer(长度 0/0): true
t1 === t1same(对象引用): false
两个空 Buffer 也算等长,返回 true。最后一行要多看一秒:=== 比的是对象引用,内容一模一样的两个 Buffer 用 === 得到 false。有人因此判比较函数坏了,改成 toString() ===。
字符串传进去,先炸在类型上
== 3. 传字符串而不是 Buffer ==
timingSafeEqual('abc', 'abc') -> 抛 TypeError: The "buf1" argument must be an instance of ArrayBuffer, Buffer, TypedArray, or DataView.
timingSafeEqual('abc', Buffer.from('abc')) -> 抛 TypeError: The "buf1" argument must be an instance of ArrayBuffer, Buffer, TypedArray, or DataView.
timingSafeEqual(null, Buffer.from('a')) -> 抛 TypeError: The "buf1" argument must be an instance of ArrayBuffer, Buffer, TypedArray, or DataView.
timingSafeEqual(new Uint8Array([97,98,99]), Buffer.from('abc')) -> 返回 true
timingSafeEqual(new Uint8Array([97,98]), new Uint8Array([97,98,99])) -> 抛 RangeError: Input buffers must have the same byte length
三条报错是同一句:buf1 的类型不对,跟长度无关。Uint8Array 能直接当参数,长度不匹配时仍然是那条 RangeError。Buffer.from(x) 省不掉,它按 utf8 编成字节,一个汉字占三字节。
先比 length 的那道闸,比的是字符数吗
常见写法先守一道长度,再调比较。
function verify(clientSig, localSig) {
if (clientSig.length !== localSig.length) return false;
return crypto.timingSafeEqual(
Buffer.from(clientSig, 'utf8'),
Buffer.from(localSig, 'utf8')
);
}
这道闸查的是字符个数,后面要比的是字节。
== 4. 先比 length 再比内容,比的是哪一种长度 ==
'a' 对 64 字节目标(先比 length) -> 返回 false(长度判断拦下)
'密码abc' .length=5 Buffer.byteLength=9
'abcde' .length=5 Buffer.byteLength=5
.length 相同之后调 timingSafeEqual -> 抛 RangeError: Input buffers must have the same byte length
改成比字节长度,同一个输入: false
'密码abc' 的 .length 是 5,字节长度 9;'abcde' 两个数都停在 5。闸门放行,比较抛错,异常绕过 401 变成 500。改用 Buffer.byteLength,同一个输入返回 false。第一行那种请求只有 1 字节的情况确实被拦住了,可这条路径没进比较函数,长度差多少在返回路径上就分了岔。
hex 签名要先解码
签名多以 hex 文本传输,两端解码成 Buffer 再比,长度才对得上。
== 5. 两个十六进制签名,先解码再比 ==
sigA 共 64 个字符,解码后 32 字节
同一份数据签两次是否相同: true
解码后比 A 与 B: true
解码后比 A 与 C: false
把 sigA 截成 60 个字符再解码: 30 字节
客户端只给 60 个字符时的比较 -> 抛 RangeError: Input buffers must have the same byte length
64 个字符对应 32 字节。截短到 60 个字符,解码是 30 字节,比较直接抛错。长度校验得放在解码之后,字符和字节不是一比一,奇数长度的 hex 还会掉半个字节。
大小写不同,字节相同
== 6. 十六进制大小写不同,字节其实一样 ==
小写 415d48a82f85 共 64 字符
大写 415D48A82F85 共 64 字符
解码后字节长度 32 / 32,timingSafeEqual: true
当成 utf8 文本,字节长度 64 / 64,timingSafeEqual: false
当成 utf8 文本,trim 后是否相等: false
hex 解码不看大小写,小写和大写解出同样的 32 字节。把 hex 文本按 utf8 比就不同:两边都是 64 字节,不抛错,结果 false。上游做过 toUpperCase 归一,下游拿原文比就判成签名错,方向容易带到密钥上。
坏字符不报错,直接截断
== 7. Buffer.from(hex) 遇到坏字符不报错 ==
Buffer.from('zz11','hex') -> 0 字节
Buffer.from('abzz','hex') -> 1 字节 ab
Buffer.from('abc','hex') -> 1 字节 ab
Buffer.from('aaZZ','hex') -> 1 字节 aa
Buffer.from('abcdZZ','hex') -> 2 字节 abcd
Buffer.from('abcdZZZZ','hex') -> 2 字节 abcd
Buffer.from('ab cd','hex') -> 1 字节 ab
'abcdZZ' 与 'abcdZZZZ' 文本不同,解码后 2/2 字节,相等: true
对这两个解码结果调 timingSafeEqual: true
'zz11' 解出 0 字节,'abc' 是奇数长度,解出 1 字节,空格之后的部分被丢掉。'abcdZZ' 与 'abcdZZZZ' 是两个不同的字符串,解出来都是 abcd 两个字节,比较返回 true。验签前得自己校验 hex:长度偶数,字符只落在 0-9a-fA-F。
同一份数据的耗时
== 8. 同一份数据上两种比较的耗时 ==
timingSafeEqual 全等 98.78 ns/次
timingSafeEqual 末字节不同 116.57 ns/次
字符串 === 全等 15.88 ns/次
字符串 === 末字符不同 12.88 ns/次
toString() === 全等 198.63 ns/次
toString() === 末字节不同 147.75 ns/次
迭代 200000 次,预热 20000 次,sink=660000
同一台机器同一进程内的顺序测量,不是基准测试
20 万次,64 字节。=== 快六倍以上,两种错位情况的差别只有十几到二十几 ns,和重跑一次的波动一个量级,不能说它随匹配位置变慢。64 字节太短,引擎对短字符串有专门路径,这组数别当基准。toString() === 反而更贵,150 到 200 ns,每次都要把 Buffer 解成字符串。
=== 的提前返回属于引擎实现,不构成保证,需要挡时序观察时别用它。签名格式我放在解码前校验,字节长度放在解码后校验,比不了长度的请求一律并进 401。两边都可能变长时,各自过一遍 sha256 再比 32 字节摘要,长度天然相等。