一、HMAC解决的是“认证”问题,而非“保密”
HMAC的全称是Hash-basedMessageAuthenticationCode。它用一把共享密钥和一个哈希算法,对消息生成一个固定长度的认证标签。接收方持有同一把密钥,重新计算标签并比对,就能判断消息是否被篡改、是否来自持有密钥的一方。
这里有一个常见的理解偏差:HMAC不加密消息,消息本身仍然是明文。它提供的保证是完整性和真实性——消息没有被改动,且生成标签的人持有正确的密钥。crypto.createHmac(algorithm,key[,options])返回一个Hmac对象,该对象继承自stream.Transform,既可以作为流使用,也可以通过update()/digest()手动喂养数据。方法本身不执行计算,真正的摘要发生在调用digest()时。
二、参数与返回值的工程含义
algorithm取决于当前Node.js构建所链接的OpenSSL版本支持的摘要算法。'sha256'是服务端签名场景中使用频率较高的选择。在较新的OpenSSL中可以通过openssllist-digest-algorithms查看可用算法。需要留意的是,'md5'和'sha1'虽然仍可传入,但在新的安全设计中不应作为HMAC的哈希底座,碰撞攻击的进展已经削弱了它们的信任基础。
key的类型接受字符串、Buffer、TypedArray、DataView或KeyObject。如果传入KeyObject,其类型必须是secret。官方文档中有一句容易被跳过的话:如果密钥来自密码学安全的熵源(如crypto.randomBytes()),其长度不应超过算法的块大小,SHA-256对应512位。这意味着用randomBytes(64)生成的密钥对SHA-256是合适的,而randomBytes(128)并不会带来额外的安全增益。
options是可选参数,控制流的行为,常规使用中很少需要干预。
返回的Hmac对象有两个关键约束:digest()调用一次后对象即失效,再次调用会抛出错误;update()可以多次调用,适合流式或分块数据。
三、与createHash()的本质差异
crypto.createHash()和crypto.createHmac()的API形态相似,但安全模型不同。createHash()是无密钥的摘要,任何人都可以对任意数据计算相同的哈希值。它适合做内容指纹、缓存键,但不能用来验证“这条消息是否来自持有密钥的一方”。
createHmac()引入了密钥。没有密钥的人无法伪造一个能通过验证的标签。这个差异决定了使用场景的分野:文件完整性校验(防意外损坏)用createHash()足够;API请求签名、Webhook验证、会话令牌签发(防伪造)必须用createHmac()。
一个需要纠正的常见做法是“把密钥拼在消息前面再createHash()”。hash(key+message)与HMAC不是等价构造,前者容易受到长度扩展攻击的影响。HMAC内部的双轮结构正是为了规避这类问题而设计的。
四、单元测试与边界验证
以下测试在Node.jsv20环境下运行,覆盖标准输出、密钥类型差异、流式更新与digest后重用四个场景。
const crypto = require('crypto');
const assert = require('assert');
// 场景 1:标准 SHA-256 HMAC 输出固定值
const h1 = crypto.createHmac('sha256', 'secret-key')
.update('codehao-learn-programming')
.digest('hex');
assert.strictEqual(h1.length, 64);
console.log('场景 1 通过:输出为 64 位十六进制字符串');
// 场景 2:字符串密钥与 Buffer 密钥(相同字节)结果一致
const keyStr = 'my-key';
const keyBuf = Buffer.from(keyStr, 'utf8');
const h2a = crypto.createHmac('sha256', keyStr).update('data').digest('hex');
const h2b = crypto.createHmac('sha256', keyBuf).update('data').digest('hex');
assert.strictEqual(h2a, h2b);
console.log('场景 2 通过:字符串密钥按 utf8 解释');
// 场景 3:分多次 update 与一次性 update 结果一致
const h3a = crypto.createHmac('sha256', 'k').update('part1').update('part2').digest('hex');
const h3b = crypto.createHmac('sha256', 'k').update('part1part2').digest('hex');
assert.strictEqual(h3a, h3b);
console.log('场景 3 通过:流式更新与批量更新等价');
// 场景 4:digest 后再次调用抛出错误
const hmac = crypto.createHmac('sha256', 'k');
hmac.update('data');
hmac.digest('hex');
assert.throws(() => hmac.digest('hex'));
console.log('场景 4 通过:digest 后对象不可重用');
边界层面需要关注的是密钥长度与算法块大小的关系。虽然Node.js不会因为密钥过长而拒绝,但HMAC规范中超过块大小的密钥会先被哈希压缩。用crypto.randomBytes(32)作为SHA-256的密钥是简洁且符合规范的做法。
五、异常处理与常见错误
crypto.createHmac()本身在参数类型合法时不抛异常,但两类错误值得主动防御。
第一类是无效算法名。传入不存在的摘要算法(如'sha256x')会抛出ERR_CRYPTO_INVALID_DIGEST。如果算法名来自配置文件或环境变量,需要在服务启动阶段用crypto.getHashes()做一次白名单校验,而不是等到第一次请求时才暴露。
第二类是签名比对时的时序侧信道。直接用===比较计算出的HMAC和收到的签名,比较操作会在发现第一个不同字节时提前返回,攻击者可以通过测量响应时间逐步推断正确的签名。Node.js提供了crypto.timingSafeEqual(a,b),它要求两个Buffer长度一致,比较过程不提前退出。这是HMAC验证代码中必须使用的函数,===在这个场景下是不安全的。
function verifySignature(payload, receivedSig, secret) {
const expected = crypto.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(receivedSig, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
六、与PHPhash_hmac()的职责对比
PHP的hash_hmac()是一个一次性函数:传入算法、数据和密钥,直接返回摘要字符串,没有中间对象,也不支持流式增量更新。它的参数顺序是hash_hmac(algo,data,key),注意密钥在之后。PHP8.0起,未知或非加密算抛出ValueError。
Node.js的createHmac()返回可写流对象,天然适配大文件和网络流的增量计算。两者在安全语义上等价:都是标准的HMAC构造。选型取决于运行时环境,而不是功能差异。跨语言对接时需要注意的是输出编码的一致性——Node.js的digest('hex')输出小写十六进制,PHP的hash_hmac()默认也是小写十六进制,在签名校验场景中通常需要统一大小写或直接比较Buffer。
七、常见误区速查
误区一:HMAC可以替代加密。HMAC不隐藏消息内容。如果需要保密性,应当在HMAC验证通过后使用AES等对称加密处理消息,或者使用AEAD模式(如AES-GCM)同时提供加密和认证。
误区二:密钥越随意越好。使用短字符串或可预测的密钥(如'secret'、'123456')会显著降低HMAC的安全强度。密钥应当来自crypto.randomBytes()或密钥管理服务,长度至少与目标安全强度匹配。
误区三:比较签名可以用===。在验证场景中,===的短路比较特性构成时序侧信道。timingSafeEqual是必要的。
误区四:digest()可以调用多次。不可以。调用后Hmac对象被冻结,再次调用会抛出错误。每次计算新的HMAC都需要重新createHmac()。
误区五:update()之后消息还能被修改。update()将数据送入内部哈希状态,一旦送入就无法回退或更改。需要计算不同消息的HMAC时,必须创建新对象。
八、工程实践中的取舍
项目反思:在一个内部服务的Webhook接收端,最初的签名验证代码直接用了===比较十六进制字符串。代码审查时没有发现问题,因为功能测试全部通过。后来在一次安全评审中,有人指出这个比较方式存在时序攻击面。虽然该服务在内网,风险等级不高,但修复成本极低——替换为timingSafeEqual只需要三行改动。这个经历说明的问题不是“内网可以放松”,而是安全相关的比较操作应当从第一版代码就使用正确的函数,因为事后审计时很容易遗漏这类“功能正常但语义错误”的写法。
另一个值得注意的实践是流式文件的HMAC计算。官方示例中使用了createReadStream配合hmac.update(data)的方式。如果直接fs.readFileSync把整个文件读入内存再update,在处理大文件时内存占用会线性增长。createHmac()的流式设计正是为了避免这个问题,但在简单场景中容易被忽略。
关于算法选型:SHA-256在绝大多数服务端签名场景中是合理的默认选择。如果系统间存在遗留的SHA-1HMAC对接需求,可以在边界层做兼容,但新生成的签名不应继续使用SHA-1。createHmac()也接受'sha3-256'等较新的算法,但与对方系统的兼容性需要在协议层确认。
九、练习与思考
练习1:实现一个hmacFile(filePath,key,algorithm)函数,使用流式方式计算文件的HMAC,返回十六进制摘要。要求处理文件不存在时的错误,并且不在内存中缓存整个文件内容。
练习2:编写一个Webhook验证函数,接收原始请求体、签名头和时间戳头,实现时间窗口校验(如5分钟)加HMAC验证。思考:为什么签名需要覆盖时间戳,而不仅仅是请求体?
思考题:如果服务端需要同时对接三个第三方平台,它们的HMAC算法分别是SHA-256、SHA-1和SHA-512,密钥长度各不相同。在设计验证模块时,应该把算法和密钥硬编码在代码中,还是从配置读取?从可维护性和密钥轮换两个角度分析。
延伸阅读与参考文献
-
Node.js官方文档:
crypto.createHmac()与Hmac类,包含参数说明、流式用法及digest后不可重用的约束。https://nodejs.org/api/crypto.html#cryptocreatehmacalgorithm-key-options—最直接的权威参考,覆盖签名、返回值与示例。 -
Node.js中文文档:
crypto.createHmac条目,对密钥长度与块大小的关系有明确说明。https://url.nodejs.cn/api/crypto/crypto_createhmac_algorithm_key_options.html—中文语境下的对照阅读,降低理解门槛。 -
CoinPaymentsAPI文档:生成API签名的完整Node.js示例,展示了
createHmac('sha256',secret).update(message).digest('base64')在支付接口中的应用。https://docs.coinpayments.net/api/auth/generate-api-signature/—真实生产接口的签名构造参考。 -
GeneralTranslationWebhook验证示例:演示了时间戳窗口校验、
timingSafeEqual的使用以及原始请求体在签名验证中的关键性。https://generaltranslation.com/en-US/docs/platform/dashboard/reference/webhooks.mdx—与“练习2”直接相关的工程实现参考。 -
PHP官方手册:
hash_hmac()函数说明,包含算法白名单、ValueError行为及hash_equals的时序安全比较建议。https://www.php.net/manual/en/function.hash-hmac.php—跨语言对比时的权威依据。