做API对接时接触HMAC的频率很高:Webhook回调要验签、请求头要携带签名、内部服务间通信要防篡改。hmac.update()看起来只是“往里塞数据”,但塞进去的方式、编码的选择、与digest()的配合时机,任何一处偏差都会让最终摘要对不上,而且错误往往不报异常,只是静默地产生一个“看起来也对”的错误值。下面从接口语义、编码行为、流式场景、验签几个角度把这件事讲清楚。
1.接口语义:Hmac对象、update与digest的三段式
根据Node.js官方文档,Hmac类有两种使用方式:作为可读可写流,或者使用hmac.update()配合hmac.digest()手动计算。hmac.update(data[,inputEncoding])的作用是将给定数据更新到Hmac内容中,可以多次调用,适用于流式场景。
data可以是字符串、Buffer、TypedArray或DataView。inputEncoding仅在data为字符串时生效;如果传入的是Buffer、TypedArray或DataView,inputEncoding会被忽略。这个细节很关键:当你把请求体以Buffer形式传入时,不需要也不应该再指定编码,因为字节序列已经确定,不存在“按什么编码解析”的问题。
const crypto = require('crypto');
const hmac = crypto.createHmac('sha256', 'course-secret');
hmac.update('代码号');
hmac.update('学习编程');
const signature = hmac.digest('hex');
console.log(signature);
两次update的效果与一次传入完整字符串等价。HMAC的内部状态会累积每次喂入的数据,直到digest()才输出最终结果。
2.digest()的一次性终结:调用之后Hmac对象作废
官方文档明确说明:hmac.digest()计算所有通过hmac.update()传入数据的HMAC摘要。如果提供了编码参数则返回字符串,否则返回Buffer。digest()调用之后,Hmac对象不能再次使用,多次调用digest()会抛出错误。
这一点在需要“同一份数据、多种编码输出”的场景中经常被误解。有人会创建一个Hmac对象,update之后连续调用digest('hex')和digest('base64'),期望得到两种格式的相同摘要。实际上第二次调用会抛错。正确做法是为每种需要的输出编码分别创建独立的Hmac实例,各自update相同的数据。
项目反思:曾在一个日志签名模块中看到类似的写法,开发者为了省一次createHmac的开销,试图复用同一个对象输出hex和base64,测试环境没暴露问题是因为只走了hex分支,上线后base64分支触发异常才被发现。创建Hmac对象的开销在多数场景下可以忽略,不必在这个点上做优化。
3.编码参数的真实影响:字符串与Buffer的分野
update的编码集中体现在字符串输入上。如果传入字符串且不指定inputEncoding,默认按'utf8'处理。这意味着如果服务端和客户端对同一段“文本”的字节表示理解不一致,HMAC结果就会不同。
一个典型场景是中文内容。'代码号'在UTF-8下是9个字节,但如果某处误按latin1或GBK处理,字节序列变了,HMAC值自然对不上。更隐蔽的情况是上游传来的数据已经是Buffer,下游为了“方便”先toString()再update,如果toString()的默认编码与实际字节内容不匹配,中间就发生了一次无声的转码。
const crypto = require('crypto');
// 推荐:Buffer 直接传入,不经过字符串转换
function signBuffer(secret, bodyBuffer) {
return crypto.createHmac('sha256', secret)
.update(bodyBuffer)
.digest('hex');
}
// 有风险:先 toString 再 update,依赖 toString 的默认编码
function signString(secret, bodyBuffer) {
return crypto.createHmac('sha256', secret)
.update(bodyBuffer.toString()) // 默认 utf8,若原始字节非 utf8 则出错
.digest('hex');
}
在Webhook验签这类场景中,始终将原始请求体保持为Buffer,不要先解析成JSON再序列化回去,也不要经过任何字符编码转换。签名是对字节的签名,不是对“逻辑上的JSON对象”的签名。
4.与crypto.createHash().update()的职责差异
两者语法相似,但解决的问题不同。createHash产出的摘要只取决于输入数据本身,任何人都可以对同一份数据计算出相同的hash。createHmac的摘要额外依赖密钥,没有密钥就无法生成或验证签名。
选型上的判断很直接:需要验证数据完整性(是否被意外损坏)用hash;需要验证数据来源真实性(是否由持有密钥的一方发出)用HMAC。登录密码存储用hash加盐,Webhook验签用HMAC。两者不可互换,也不存在“HMAC比hash更安全所以一律用HMAC”的说法。
5.项目实例:Webhook签名验证的完整链路
代码号学习编程的一个课程平台对接了外部支付回调。支付方在请求头中携带X-Signature,值为sha256=加上HMAC-SHA256的hex摘要,密钥为双方约定的字符串。接收端的关键写法如下:
const crypto = require('crypto');
function verifyWebhook(rawBody, receivedSignature, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody) // rawBody 必须是 Buffer,不能是解析后的对象
.digest('hex');
// 使用 timingSafeEqual 避免时序攻击
const sigBuffer = Buffer.from(receivedSignature);
const expBuffer = Buffer.from(expected);
if (sigBuffer.length !== expBuffer.length) return false;
return crypto.timingSafeEqual(sigBuffer, expBuffer);
}
这里有两个容易出错的点。第一,rawBody的获取方式。如果用Express的express.json()中间件,请求体已经被解析成对象,再JSON.stringify回去得到的字符串与原始字节序列在空格、键顺序、Unicode转义上可能不一致,HMAC必然对不上。正确做法是在express.json()的verify回调中保存原始Buffer。第二,比较时应使用crypto.timingSafeEqual,字符串===比较存在时序侧信道风险。
6.流式场景与大文件处理
update()可以多次调用的设计,本身就是为了流式处理。当需要对一个大文件计算HMAC时,不需要把整个文件读入内存,而是分块读取、分块update:
const fs = require('fs');
const crypto = require('crypto');
function hmacFile(filePath, secret) {
return new Promise((resolve, reject) => {
const hmac = crypto.createHmac('sha256', secret);
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hmac.update(chunk));
stream.on('end', () => resolve(hmac.digest('hex')));
stream.on('error', reject);
});
}
这里同样体现了“digest只能在之后调用一次”的约束:流结束之前不能提前digest,流结束之后立即digest得到结果。如果需要同时输出多种编码,需要在流开始前创建多个Hmac实例,各自接收相同的chunk。
7.本节课程知识要点
-
hmac.update()可多次调用,用于流式喂入数据;hmac.digest()只能调用一次,调用后Hmac对象作废。 -
字符串输入默认按
utf8处理,Buffer/TypedArray输入忽略编码参数。验签场景应始终传入原始Buffer。 -
HMAC比hash多一个密钥维度,解决的是来源认证问题;hash解决的是完整性校验问题,两者职责不同。
-
Webhook验签的关键:保留原始请求体字节、不经过JSON序列化往返、使用
timingSafeEqual比较。 -
需要多种编码输出时,创建多个Hmac实例分别update和digest,不要复用单个对象。
8.练习与思考
思考题一:一个团队在Node.js服务中对接了第三方API,对方使用Python的hmac.new(key,msg,hashlib.sha256)生成签名,Node.js端用createHmac('sha256',key).update(msg)验证。如果签名始终不匹配,而双方都说“密钥和数据一样”,你会按什么顺序排查?请从字符串编码、Buffer处理、密钥表示三个角度列出检查项。
思考题二:如果需要在浏览器端(WebCryptoAPI)完成与Node.js端一致的HMAC计算,crypto.subtle.importKey和crypto.subtle.sign的参数与Node.js的createHmac有哪些对应关系需要留意?
四、延伸阅读与参考文献
Node.js官方文档。Hmac类。https://beta.docs.nodejs.org/crypto/Hmac。官方API说明,明确update可多次调用、digest只能调用一次且调用后对象作废、inputEncoding仅在字符串输入时生效,是理解方法语义的直接依据。
Node.js官方文档。Crypto模块概览。https://nodejs.org/api/crypto.html。提供了createHmac与createHash的对照语境,有助于区分摘要与消息认证码的职责边界。
腾讯云开发者社区。Node加密模块:crypto。https://cloud.tencent.cn/developer/article/1664219。以中文语境解释了HMAC与hash的区别,指出HMAC需要额外指定密钥,比单纯hash多一层来源认证能力。
GitHub。nodejs-learning-guide:摘要与HMAC。https://raw.githubusercontent.com/chyingp/nodejs-learning-guide/master/%E8%BF%9B%E9%98%B6/crypto-theory.md。用“带密钥的hash函数”概括HMAC的直观含义,适合作为概念入门的辅助材料。
GitHub规则文档。WebhookSetup:HMACDeliveryandVerification。https://raw.githubusercontent.com/yonatangross/orchestkit/1ff988bd66daf223028ed44767b591fecc8510c2/plugins/ork/skills/emulate-seed/rules/webhook-setup.md。展示了生产与测试环境中HMAC-SHA256验签的惯用写法,包括timingSafeEqual的使用和原始请求体的保留策略,对有参考价值。