Node.js实现钉钉回调接口AES-256-CBC加解密完整指南
本文详细介绍如何在Node.js环境下实现钉钉回调接口的注册与AES-256-CBC加解密。涵盖从POST请求注册URL、生成加密响应JSON到使用CryptoJS库处理加解密的全过程,包括密钥生成、IV偏移量处理及消息签名验证,帮助开发者解决Node服务器对接钉钉平台的加密难题。
钉钉回调接口概述与Node.js支持现状
钉钉小程序后台接收钉钉开放平台的回调数据至关重要,例如通讯录变动和审批流程的回调,这些是业务逻辑中不可或缺的部分。回调接口是连接钉钉平台与内部业务系统的关键通道。
然而,在配置回调接口时存在一个技术门槛:服务器必须支持AES-CBC加解密,否则无法成功注册或解析回调内容。
尽管钉钉官方文档提供了Java、PHP和C#的后台SDK及示例代码,却未提供Node.js服务器的支持代码。考虑到Node.js在服务器端的高占有率,这似乎是一个缺口,但Node.js完全可以作为钉钉平台的回调服务器。

幸运的是,钉钉已公开其加密算法,开发者可以自行编写JavaScript版本的加解密程序,并将Node.js服务器注册为钉钉的回调接口。
钉钉回调接口注册流程
首先,我们需要了解钉钉回调接口的注册流程。

注册流程的第一步是由开发者主动发起一个POST请求至钉钉开放平台,传递回调URL。钉钉在该请求中返回“ok”状态,如下图所示。

在此示例中,我们申请了通讯录加人或修改人事件的回调权限。
接口请求完成后,钉钉会迅速向参数中指定的URL发送一个POST请求,具体格式如下。
{"encrypt":"ihVRgn3eZZrCYHfAW4Lbh9eoOcpy1VddxGS9IIYsteFgAxpPN9ZaKKp4EH/7ArtmVEACxmyGCdUFtGuXxfNfcbXXXXXXXXXXXXXXXXXXXkGy+Oq/hIN"}
AES-256-CBC加密机制解析
此时,钉钉要求服务器对“success”字符串进行加密,并返回相应的响应。
AES是一种对称加密算法,加密者使用密钥加密密文,接收者使用相同密钥解密。但在CBC模式下,还需要一个偏移量(IV向量)参与加解密过程。因此,加解密实际上需要两个参数:密钥和IV。换言之,钉钉回调接口采用的加密方式为AES-256-CBC模式。
加密响应JSON字段生成详解
根据文档要求,我们返回的JSON中必须包含四个字段。

其中,nonce是任意字符串,长度不限,用于增加msg_signature的变化度。
timeStamp是10位数的时间戳。JavaScript默认生成的时间戳为13位,我们需要将其除以1000或截取后3位以符合格式。
encrypt是一段Base64编码后的字符串,其内容是“success”被加密后的密文。
msg_signature是一段哈希值,由其余三个字符串加上注册接口时设定的自定义token组成。这四个字符串排序后,通过SHA1算法计算哈希值,用于验证完整性。具体实现如下。

使用CryptoJS实现加解密
最复杂的环节是encrypt字段的生成。好在JavaScript社区中已有Google提供的CryptoJS库,只需少量代码即可解决。
首先分析encrypt字段的生成逻辑:

需要被加密的明文由四个部分组成,分别是:
16个字节的随机字符串:在ASCII编码中,一个字符占1个字节(8位),因此我们可以随意填写由16个字母组成的字符串。
4个字节的msg长度:这里的长度并非文本格式的长度,而是以4*8=32位二进制表示的长度。文档未明确指出是填写msg的字节长度还是比特位数,经个人验证,此处应填写msg的字节数。由于“success”由7个ASCII字符组成,长度为7,以4个字节的二进制表示即为00000000 00000000 00000000 00000111。
00000000 00000000 00000000 00000111
在JS中,若要将二进制数转化为字节,可先转换为十进制,然后使用String.fromCharCode(0)方法转换为字节。因此,此处若想用字符串表示,就是将0、0、0、7作为ASCII码转换为不可见字符。
var lengthString = String.fromCharCode(0)+String.fromCharCode(0)+String.fromCharCode(0)+String.fromCharCode(7)
明文msg:即字符串“success”。
$key:对于企业内部开发,Corpid可在钉钉开发者后台查看。
有了明文,下一步便是进行加密操作。
我们知道AES-CBC算法需要一个密钥KEY和一个偏移量IV,而钉钉规定IV是密钥的前16位,如下所示。

钉钉要求提供32字节长的密钥,即256比特,然后将密钥进行Base64编码,通过上述注册接口发送给钉钉。
由于一个ASCII字符对应一个字节,我们可以生成一个32字符长度的字符串作为密钥。我选择的密钥是“@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@”,因为@字符的ASCII码值是64,易于记忆。同理,IV就是由16个@组成的字符串。
注意,32字节长度的字符串经Base64编码后,长度必为44个字符,最后一位必然是“=”,去掉等号后即为43个字符。
通过使用10进制数字转换为Byte字符串,也可以通过数组解决。例如,我可以通过以下代码生成密钥。
var key_256 = [64, 64, 64, 64, 64, 64, 64, 64,
64, 64, 64, 64, 64, 64, 64, 64,
64, 64, 64, 64, 64, 64, 64, 64,
64, 64, 64, 64, 64, 64, 64, 64];
var key_text = '';
for(let i=0;i<32;i++){
key_text += String.fromCharCode(key_256[i]);
}
console.log(btoa(key_text))
通过JS的btoa()函数,可以直接将密钥转换为Base64格式。
同理,生成IV之后,即可开始进行加密操作,此处直接给出代码。
CryptoJS库既可以在HTML中使用,也可以通过require引入到Node.js环境中。
在HTML中使用时,需先前往https://code.google.com/archive/p/crypto-js/downloads下载最新压缩包,然后解压到项目目录,如下所示。

随后在HTML中引用该脚本。
<script src = "crypto-js-4.0.0/crypto-js.js"></script>
这样我们就可以直接在浏览器本地调试,生成所需的字符串,让Node.js服务器直接返回原文即可。
<html>
<head>
<script src = "crypto-js-4.0.0/crypto-js.js"></script>
<script>
// AES 秘钥
var AesKey = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@";
console.log(btoa(AesKey))
// AES-128-CBC偏移量
var CBCIV = "@@@@@@@@@@@@@@@@";
//16个字节的随机字符串
var randomString = '1234567890123456';
//明文msg
var msg = 'success';
//$key,对于企业内部开发来说,$key填写企业的Corpid。
var corpid = 'ding00000035b90000000005d6980864d335'
function len_msg(msg){//该函数返回的是字符串,无文本意义
result = String.fromCharCode(0)+String.fromCharCode(0)+String.fromCharCode(0)+String.fromCharCode(7);
return result;
}
//msg_len(4B),此处为ASCII编码的二进制字符串,无文本意义
var msg_len = len_msg(msg);
//要加密的明文是[random(16B) + msg_len(4B) + msg + $key]
var codeString = randomString + msg_len + msg + corpid;
console.log('要加密的明文字符串为:'+codeString);
console.log('要加密的字符串Base64为:'+btoa(codeString));
// 加密选项
var CBCOptions = {
iv: CryptoJS.enc.Latin1.parse(CBCIV),
mode:CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
/**
* AES加密(CBC模式,需要偏移量)
* @param data
* @returns {*}
*/
function encrypt(data){
var key = CryptoJS.enc.Latin1.parse(AesKey);
var secretData = CryptoJS.enc.Latin1.parse(data);
var encrypted = CryptoJS.AES.encrypt(
secretData,
key,
CBCOptions
);
return encrypted.toString();
}
/**
* AES解密(CBC模式,需要偏移量)
* @param data
* @returns {*}
*/
function decrypt(data){
var key = CryptoJS.enc.Latin1.parse(AesKey);
var decrypt = CryptoJS.AES.decrypt(
data,
key,
CBCOptions
);
return CryptoJS.enc.Latin1.stringify(decrypt).toString();
}
//encrypt = Base64_Encode(AES_Encrypt[random(16B) + msg_len(4B) + msg + $key])
var encodeData=encrypt(codeString);
console.log('加密后密文为:'+encodeData);
console.log('10位时间戳:'+parseInt(new Date()/1000));
var timeStamp = ""+parseInt(new Date()/1000);
var nonce = "aaaaaa";
var encrypt = "LwJ0000000000000000000000000000000000000YYQIBxRvsQ=="
var token = '@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@';
//dev_msg_signature=sha1(sort(token、timestamp、nonce、msg_encrypt))
var sortList = [timeStamp,nonce,encrypt,token];
sortList.sort();
console.log(sortList);
var msg_signature = '';
for (i in sortList){
msg_signature += i;
}
console.log(msg_signature);
console.log(CryptoJS.SHA1(msg_signature).toString())
var secretTxt = 'Fuqa0wgIvMtUgFBnyZkCb1z3tpSYJ0000000000000000000000p64KnDZkGsjP3y5AIGnryUjkMi16Lz5C/ZzkMRbaipIgz60U5gELKSblZ3MnTf1CVbPMvyjoYbyenjbKCDmQpdgdA4Ejh8Cnlil1laZ8wQSUSD0ju8a9pFIx9Rh6HwNfh0FenpnX22HpfU000007ZjNM5PeK5DeCbmCrqnrq1zwjqomeXSw8mw9g0i83DQKYMXuU3KsO000cHPLdfbWIKUyTcw=='
var realMessage = decrypt(secretTxt);
console.log('实际内容是'+realMessage);
</script>
</head>
<body>
</body>
</html>
注意,加密选项中CryptoJS.enc.Latin1.parse(AesKey)是将字符串表示的密钥通过ASCII码转换为字节。在加密时也可以使用CryptoJS.enc.Utf8,因为UTF-8编码在ASCII字符中无区别。
但是反过来,加密中使用Latin1和Utf8均无问题,而在解密时,钉钉服务器使用的是ASCII编码。如果使用CryptoJS.enc.Utf8就会发生错误,因为钉钉返回的内容应全为普通英文字符,不含中文或其他特殊字符。
对于消息体签名,我们只需使用JS的arr.sort(),将四个字段组成的数组按首字母排序,然后首尾相连变为一个字符串,再使用CryptoJS.SHA1(msg_signature).toString()的SHA1算法取哈希值。注意,这里的哈希值是HEX格式表示的(文档未明确说明,但通过实验得出),不要使用Base64。代码上等价于CryptoJS.SHA1(msg_signature).toString(CryptoJS.enc.Hex)。
CryptoJS.SHA1(msg_signature).toString(CryptoJS.enc.Hex);
此外,encrypted.toString()方法默认返回Base64编码格式,无需转换,这一点与上述SHA1方法的默认值不同。还有,CryptoJS.AES.decrypt()方法传入的待解码密文,也可直接传入钉钉提供的Base64格式密文,无需提前解码Base64。
注意,排序四元素之一的token,既不是AES的密钥,也不是IV,也不是钉钉平台的access_token,而是我们在前面https://oapi.dingtalk.com/call_back/register_call_back接口中上传的token字段,这是一个纯自定义的字段。
我们通过在浏览器中执行上述代码,即可获取注册回调需要返回的JSON值。随后,我们直接在Node.js中写死这些值用于返回。同时,我们还需要在Node.js中引入CryptoJS,用于对钉钉发来的回调信息进行解密。
const express = require('express')
const bodyParser = require('body-parser');
const CryptoJS = require("crypto-js");
const app = express()
const port = 8080
const appkey = 'dingxxxx';
const appsecret = 'xxxxxx';
const agentId = 'xxxxxx';
var dingToken = '';
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(port, function(){
console.log(`Example app listening on port ${port}!`);
getToken();
})
app.use(bodyParser.json())
app.post('/dingCallback', function (req, res) {
console.log('钉钉回调接口收到请求了:'+JSON.stringify(req.body));//获取钉钉的回调参数
var timeStamp = ""+parseInt(new Date()/1000);//动态项
var nonce = "aaaaaa";//随便写
var encrypt = "LwXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxRvsQ=="
var token = '666666';
//dev_msg_signature=sha1(sort(token、timestamp、nonce、msg_encrypt))
var sortList = [timeStamp,nonce,encrypt,token];
sortList.sort();
console.log(sortList);
var msg_signature = '';
for (let text of sortList){
msg_signature += text;
}
console.log('msg_signature明文='+msg_signature)
msg_signature = CryptoJS.SHA1(msg_signature).toString()
var resp = {
msg_signature:msg_signature,
timeStamp:timeStamp,
nonce:nonce,
encrypt:encrypt
}
console.log(''+JSON.stringify(resp))
console.log('解密内容是:'+decryptMsg(req.body.encrypt));//获取钉钉传过来的参数,并解密处json信息
res.send(JSON.stringify(resp));
});
// AES 秘钥
var AesKey = "@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@";
// AES-128-CBC偏移量
var CBCIV = "@@@@@@@@@@@@@@@@";
// 加密选项
var CBCOptions = {
iv: CryptoJS.enc.Latin1.parse(CBCIV),
mode:CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
/**
* AES解密(CBC模式,需要偏移量)
* @param data Base64格式
* @returns {*}
*/
function decrypt(data){
var key = CryptoJS.enc.Latin1.parse(AesKey);
var decrypt = CryptoJS.AES.decrypt(
data,
key,
CBCOptions
);
return CryptoJS.enc.Latin1.stringify(decrypt).toString();
}
function decryptMsg(base64_crypt_msg){
var realMessage = decrypt(base64_crypt_msg);
var endPosition = realMessage.lastIndexOf('dingXXXXXXXX');//掐头去尾,前面掐掉20字节,后面掐掉Corpid
if(!realMessage || realMessage.length < 20 || endPosition==0){
console.log('解密失败')
return;
}
var jsonData = realMessage.slice(20,endPosition);
return jsonData;
}
回调信息解密与验证
钉钉用于验证服务器的POST请求,与发送信息的回调参数格式相同。POST收到的明文为:
{"encrypt":"ihVRgn3eZZrCYHfAW4Lbh9eoOcpy1VddxGS9IIYsteFgAxpPN9ZaKKp4EH/7ArtmV"}
解密之后,密文部分为一个JSON字符串,其中包含我们所需的信息。例如,用于验证URL的参数解密后为:这个与我们设置的响应加密字符串一样,包含16字节的随机字符串、4个字节的二进制长度、正文及Corpid。
AzW30dHltl1iocOd{"EventType":"check_url"}dingxxxxxxxxxxxxxxxxxxxxxxx
要判断钉钉回调我们的接口是否成功,或者说我们是否返回了正确的加密报文,只需调用钉钉的查看回调接口列表。方法是使用POST请求调用https://oapi.dingtalk.com/call_back/get_call_back?access_token=,然后观察回调接口中是否包含你刚注册的URL即可。

另外推荐一个网站,可以将Base64后的待加密字符串,使用AES-256-CBC算法进行加解密测试。
https://the-x.cn/cryptography/Aes.aspx

参考:https://blog.csdn.net/myzksky/article/details/82052920
到此这篇关于基于Node.js开发钉钉回调接口实现AES-256-CBC加解密的文章就介绍到这了,更多相关Node.js AES-256-CBC加解密内容请搜索本站以前的文章或继续浏览下面的相关文章,希望大家以后多多支持本站!
在 NestJS 中使用管道验证数据与守卫控制访问权限
2026-11-05 12:43:41
Node.js后端生成高强度随机字符串的方法
2026-11-04 16:29:54
Node.js环境中怎么使用axios或http模块发送GET请求
2026-11-03 21:57:15
Node.js环境下怎么实现JS反混淆自动化?常用库与CLI工具推荐
2026-11-03 21:15:54
解决JS逆向常见报错:缺少DOM环境导致代码运行失败
2026-11-03 20:41:59
