1.时序攻击:为什么===会出卖你的秘密
普通字符串比较在发现第一个不匹配字节时立即返回。若攻击者能够反复发送请求并测量响应时间,就能逐字节推断出正确的秘密值——先匹配首字节,再匹配第二字节,如此推进,将一个指数级的搜索空间压缩为多项式级别。
这不是理论推演。uhttpd项目曾用strcmp直接比较密码,修复补丁明确承认该实现“在首个不同字节处短路,通过可测量的响应时间差异泄露密码匹配进度”。Node.jsWebCryptography的HMAC验证也曾因使用memcmp()而非恒时原语,被分配了CVE-2026-21713。
crypto.timingSafeEqual的设计目标正是消除这一时间侧信道:无论输入内容如何不同,比较操作的执行时间保持恒定。
2.函数签名与硬性约束
crypto.timingSafeEqual(a, b)
参数a和b必须同时为Buffer、TypedArray或DataView实例。返回值是布尔值。
两个输入必须具有相同的字节长度。长度不等时函数抛出错误,而不会返回false。
这一约束源于恒时比较的实现机制:算法逐字节比较,操作次数由缓冲区长度决定。若长度不同,比较无法执行,函数只能选择报错。
关键认知:timingSafeEqual对长度本身不是恒时的。攻击者若能在应用层观察到“长度不匹配导致异常”与“长度匹配但内容不同”之间的响应差异,仍可能获得信息。
3.长度不等时的正确策略
Cloudflare的安全文档给出了一条反直觉但非常重要的建议:不要因为长度不同而提前返回。
错误做法:
// 危险:长度差异通过响应时间泄露
function unsafeCompare(userInput, secret) {
if (userInput.length !== secret.length) {
return false; // 攻击者可区分“长度错误”和“内容错误”
}
return crypto.timingSafeEqual(userInput, secret);
}
正确做法是始终执行恒时比较,当长度不等时,让用户输入与自身比较并取反——结果必然为false,但耗时与正常比较一致:
function safeCompare(userInput, secret) {
const userBuf = Buffer.from(userInput);
const secretBuf = Buffer.from(secret);
if (userBuf.length !== secretBuf.length) {
// 长度不同时,比较输入与自身(恒为 true),取反得到 false
// 耗时与正常比较相同,不泄露长度信息
return !crypto.timingSafeEqual(userBuf, userBuf);
}
return crypto.timingSafeEqual(userBuf, secretBuf);
}
这一模式在Deno的推荐实践中也有体现:先将双方哈希到固定长度(如SHA-256的32字节),再进行比较。这样无论原始输入长度如何,比较操作面对的都是等长摘要。
4.示例:API密钥验证
以下代码展示了一个HTTP中间件中验证API密钥的完整实现。注意其回避了长度短路,并用哈希将任意长度的用户输入映射到固定长度。
const crypto = require('crypto');
// 从环境变量读取服务端密钥
const SERVER_SECRET = process.env.API_SECRET || 'sk_live_demo_key_2026';
function hashToFixedBuffer(input) {
return crypto.createHash('sha256').update(input).digest();
}
function safeCompareStrings(userInput, serverSecret) {
const userHash = hashToFixedBuffer(userInput);
const secretHash = hashToFixedBuffer(serverSecret);
// 两者都是 32 字节 SHA-256 摘要,长度必然一致
return crypto.timingSafeEqual(userHash, secretHash);
}
// 模拟请求处理
function authenticateRequest(headers) {
const apiKey = headers['x-api-key'];
if (!apiKey) {
return { status: 401, body: { error: 'missing api key' } };
}
const isValid = safeCompareStrings(apiKey, SERVER_SECRET);
if (!isValid) {
return { status: 403, body: { error: 'invalid api key' } };
}
return { status: 200, body: { message: 'access granted' } };
}
// 测试
console.log(authenticateRequest({ 'x-api-key': 'sk_live_demo_key_2026' }));
// { status: 200, body: { message: 'access granted' } }
console.log(authenticateRequest({ 'x-api-key': 'wrong_key' }));
// { status: 403, body: { error: 'invalid api key' } }
这里先用SHA-256将用户输入和服务端密钥都转为32字节摘要,timingSafeEqual面对的是等长缓冲区,不存在长度异常的抛出风险,也不需要处理长度不等时的分支逻辑。
5.类型系统:为什么不要传Float64Array
官方文档明确警告:当两个输入都是Float32Array或Float64Array时,函数可能返回意料之外的结果。原因在于IEEE754浮点编码:x===y为真并不意味着x和y的字节表示相同(-0与+0的字节表示不同,NaN更是如此)。
如果你需要比较的是数值而非其二进制表示,timingSafeEqual不是合适的工具。
若至少一个输入是多字节TypedArray(如Uint16Array),结果将使用平台字节序计算。这意味着同一段代码在大小端机器上的比较行为可能不一致。
实践准则:始终使用Buffer承载待比较的字节序列。在Node.js中,Buffer的字节语义明确,避免TypedArray字节序和浮点编码带来的不确定性。
6.常见错误与异常
crypto.timingSafeEqual抛出的错误集中在两个场景:
字节长度不一致。这是最常见的误用。调用方若未预先对齐长度,函数会直接抛出TypeError或RangeError。正确模式是先检查长度或先哈希到固定长度。
传入不可接受类型。若参数不是Buffer、TypedArray或DataView,函数同样抛出类型错误。
错误处理示例:
function compareHashes(hashA, hashB) {
if (!Buffer.isBuffer(hashA) || !Buffer.isBuffer(hashB)) {
throw new TypeError('both arguments must be Buffers');
}
if (hashA.length !== hashB.length) {
// 不直接抛出,而是安全地返回 false,避免长度信息泄露
return !crypto.timingSafeEqual(hashA, hashA);
}
return crypto.timingSafeEqual(hashA, hashB);
}
7.与===的职责边界
===比较的是值语义,适用于常规业务逻辑中的相等判断。它的执行路径包含短路优化,这在性能上是合理的,但在安全上下文中是危险的。
timingSafeEqual比较的是字节表示,且牺牲了短路优化换取恒时特性。它只应被用于比较秘密值:密码哈希、HMAC摘要、API密钥、会话令牌、能力URL中的秘密片段。
在MAC验证场景中,Node.js官方文档直接建议:验证认证标签时,使用crypto.timingSafeEqual()比较等长的Buffer值。
8.单元测试与边界用例
const assert = require('assert');
const crypto = require('crypto');
function testTimingSafeEqual() {
// 用例 1:相同字节序列返回 true
const a = Buffer.from('secret_value');
const b = Buffer.from('secret_value');
assert.strictEqual(crypto.timingSafeEqual(a, b), true);
// 用例 2:长度相同但内容不同返回 false
const c = Buffer.from('secret_value');
const d = Buffer.from('secret_other');
assert.strictEqual(crypto.timingSafeEqual(c, d), false);
// 用例 3:长度不同抛出异常
const e = Buffer.from('short');
const f = Buffer.from('longer_value');
assert.throws(
() => crypto.timingSafeEqual(e, f),
/Input buffers must have the same byte length/
);
// 用例 4:空缓冲区比较
const g = Buffer.alloc(0);
const h = Buffer.alloc(0);
assert.strictEqual(crypto.timingSafeEqual(g, h), true);
// 用例 5:十六进制字符串转 Buffer 后比较
const hex1 = Buffer.from('482c811da5d5b4bc6d497ffa98491e38', 'hex');
const hex2 = Buffer.from('482c811da5d5b4bc6d497ffa98491e38', 'hex');
assert.strictEqual(crypto.timingSafeEqual(hex1, hex2), true);
// 用例 6:类型错误
assert.throws(
() => crypto.timingSafeEqual('not a buffer', Buffer.from('x')),
/The "buf1" argument must be an instance of Buffer, TypedArray, or DataView/
);
console.log('all timingSafeEqual tests passed');
}
testTimingSafeEqual();
9.性能对照
恒时比较的代价是放弃短路优化。对于相同长度的缓冲区,timingSafeEqual无论内容如何,都会完整遍历所有字节。在典型长度(32字节的HMAC摘要、64字节的SHA-512摘要)下,这一开销可以忽略。
若将它与Buffer.compare对比:后者在首个差异处返回,在内容高度相似时更快,但正是这种“更快”构成了侧信道。在安全敏感路径上,性能差异不应作为选择依据。
10.一个被忽视的真相
timingSafeEqual只保证函数本身不泄露时序信息。官方文档的措辞值得逐字阅读:“使用crypto.timingSafeEqual并不能保证周围代码是时序安全的”。
这意味着:即使你正确调用了timingSafeEqual,如果在它之前使用了===来检查某个前缀、如果在它之后根据结果执行了不同耗时的分支、如果日志记录逻辑对匹配与不匹配路径做了差异化处理——时序侧信道依然存在。
安全比较的边界延伸到整个认证路径,而非单个函数调用。
11.思考题
题一:一个API用timingSafeEqual验证请求签名。用户传入的签名是Base64字符串,服务端先解码再比较。解码过程耗时与字符串内容无关,但Base64解码器对包含非法字符的输入会提前报错。这个解码阶段是否引入了时序漏洞?
题二:你维护的代码库中有多处密码比较,分别使用了===、Buffer.compare和timingSafeEqual。在不重写全部逻辑的前提下,如何审计哪些路径真正需要恒时比较?
12.现在框架中的惯用写法
Express中间件中常见的API密钥验证写法:
const crypto = require('crypto');
function apiKeyAuth(expectedKey) {
const expectedBuffer = crypto.createHash('sha256')
.update(expectedKey)
.digest();
return (req, res, next) => {
const provided = req.headers['x-api-key'] || '';
const providedBuffer = crypto.createHash('sha256')
.update(provided)
.digest();
const valid = crypto.timingSafeEqual(providedBuffer, expectedBuffer);
if (!valid) {
return res.status(401).json({ error: 'unauthorized' });
}
next();
};
}
无论provided是空字符串还是100字节的垃圾数据,providedBuffer始终是32字节,timingSafeEqual的调用路径一致。
13.延伸阅读与参考文献
Node.js官方文档—crypto.timingSafeEqual(a,b)
https://nodejs.org/api/crypto.html#cryptotimingsafeequala-b
最权威的参数说明、异常行为与TypedArray字节序注意事项。
Node.js官方文档—Mac/HMAC验证
https://nodejs.org/api/crypto.html#class-mac
明确指出验证认证标签时应使用crypto.timingSafeEqual()比较等长Buffer。
CloudflareWorkers—Protectagainsttimingattacks
https://developers.cloudflare.com/workers/examples/protect-against-timing-attacks/
给出长度不等时的恒时比较模式:比较用户输入与自身并取反。
CVE-2026-21713—Node.js Web Cryptography HMAC验证时序漏洞
https://mallory.ai/vulnerabilities/CVE-2026-21713
真实漏洞案例:memcmp()替代恒时原语导致的时序侧信道。
Deno—恒定时间秘密比较
https://docs.deno.org.cn/examples/timing_safe_comparison/
演示先哈希到固定长度再比较的通用模式。
uhttpd提交记录—用恒时比较替换strcmp
https://git.staging.openwrt.org/project/uhttpd/commit/?id=6fadf0da50509a510ac4f85d2adb70a83de2e1fc
真实项目中的修复补丁,附有对strcmp短路行为的分析。