基于NodeJS开发钉钉回调接口 实现AES-CBC加解密

本文介绍了如何使用NodeJS开发钉钉回调接口,并处理AES-CBC加解密问题。详细讲解了加密解密的流程,包括加密算法、密钥生成、加密库的选择以及回调验证的方法,提供了JavaScript和Node内置crypto库的解决方案。

钉钉小程序后台接收钉钉开放平台的回调比较重要,比如通讯录变动的回调,审批流程的回调都是在业务上十分需要的。回调接口时打通钉钉平台和内部系统的重要渠道。

但是给回调的接口增加了一些障碍,它需要支持回调的服务器的接口支持AES-CBC加解密。不然无法成功注册或解析内容。

钉钉官方文档中给出了JAVA,PHP,C#的后台SDK和demo,但是却没有Node服务器的代码支持,这让占有率很高的node服务器非常尴尬,难道node就不能作为钉钉平台的回调服务器么

好在钉钉已经开放了其加密算法,可以通过加密流程自己写一套JavaScript版的加解密程序,然后将node服务器注册为钉钉的回调接口。

首先,看一下钉钉回调接口的注册流程

首先,是由开发者主动发起一个POST请求到钉钉开放平台,传过去回调的URL,然后钉钉在这个请求中返回一个ok,如下图

在这里,我申请了通讯录加人或修改人事件的回调。

在这个接口请求完毕之后,钉钉会迅速的向你请求参数中写的url发送一个POST请求,如下

{"encrypt":"ihVRgn3eZZrCYHfAW4Lbh9eoOcpy1VddxGS9IIYsteFgAxpPN9ZaKKp4EH/7ArtmVEACxmyGCdUFtGuXxfNfcbXXXXXXXXXXXXXXXXXXXkGy+Oq/hIN"}

此时,钉钉要求我们“success”加密,然后在服务器中响应。

AES是一种对称性加密,即加密者通过一个密钥进行加密,将密文发送给接收人,接收人通过相同的密钥进行解密。但是CBC这种模式下,还需要一个偏移,或者说IV向量进行加解密。所以在加解密的时候实际上需要两个参数,密钥和IV。换句话说,钉钉回调接口使用的加密方式为AES-256-CBC模式

按照文档要求,我们返回的JSON中需要包含4个字段

其中,nonce是可以随便写的字符串,长度也没有限制,是用来增加msg_signature的变化度的,只能为ascii字符,如果出现中文会校验失败。

timeStamp是10位数的时间戳,JavaScript默认时间戳是13位的,我们需要除以1000或者截取后3位。

encrypt是一段base64编码后的字符串,被编码的是“sucess”被加密后的密文

msg_signature是一段hash值,是将其余3个字符串,加上我们注册接口时设定的自定义token,4个字符串排序好,通过SHA1算法HASH后的值,用来验证完整性的。具体如下

 

最难以解决的就是encrypt字段了,还好在JS界谷歌已经给我们准备好了CryptoJS库,不用几行代码就可以解决问题。

首先观察下这个encrypt字段的形成逻辑:

需要被加密的明文由四个部分组成,分别是

16个字节的随机字符串:ASCII编码中,一个字符就占1个字节(8位),所以这里我们随便填16个字母组成的字符串就行,在Node服务端,可以用自带crypto库快速生成16个随机字节

crypto.randomBytes(16)

4个字节的msg长度:这里的长度不是文本格式的长度,而是4*8=32位二进制表示的长度,文档中没有明确指出是填msg的字节长度,还是比特位数,通过我个人验证,此处应该填msg的字节数。由于"success"由7个ASCII字符组成,所以长度为7,以4个字节的二进制表示就是

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)

在Node服务端,可以使用Buffer类进行更方便的创建

Buffer.from([0,0,0,7])

小技巧:对于动态的长度,可以用Buffer的自动截断功能

var myLength = 61789524
const buf = Buffer.from([Math.trunc(myLength/16777216),Math.trunc(myLength/65536),Math.trunc(myLength/256),myLength]);
console.log(buf.toString('hex')) //输出为03aed554
console.log(buf.readInt32BE()) //输出为61789524

明文msg:就是字符串"success"

$key:我是企业内部开发,Corpid可以在钉钉开发者后台看到,在新版钉钉的API接口中,一个组织可以为每个H5微应用或钉钉小程序注册单独的回调接口,此时的$key就是微应用的app_secret

 

有了明文,下一步就是进行加密

首先我们知道AES-CBC算法需要一个密钥KEY和一个偏移量IV,而钉钉说IV是密钥的前16位(IV弄了个寂寞),如下

钉钉让我们提供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中使用,在Node中,还有自带的crypto库可以直接用,方法如下

const key = '@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@'
const iv = key.slice(0,16)
const cipher = crypto.createCipheriv('aes-256-cbc',key,iv)
let encrypted = cipher.update(codeStringBuffer,'binary','base64')
encrypted += cipher.final('base64');

在HTML中使用时,先到https://code.google.com/archive/p/crypto-js/downloads下载最新压缩包,然后解压到项目目录即可,如下

然后再HTML中进行引用

<script src = "crypto-js-4.0.0/crypto-js.js"></script>

这样我们就可以直接通过浏览器本地调试,生成我们想要的字符串,让node服务器直接原文返回就可以了

<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,因为utf8编码再ASCII字符中编码没有区别。

但是反过来,加密中用Latin1和Utf8都没有问题,但是在解密时,钉钉那边是使用ASCII编码的,如果使用CryptoJS.enc.Utf8就会发生错误。因为钉钉返回内容应该全是普通英文字符,没有中文或其他特殊字符

对于消息体签名,我们只需使用JS的arr.sort(),把四个字段组成的数组通过首字母进行排序,然后首尾相连变为一个字符串,再使用CryptoJS.SHA1(msg_signature).toString()的SHA1算法取HASH值即可,注意这里的HASH值是HEX格式表示的(文档没有写,但是通过实验得出的),不要用Base64了,代码上等价于

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字段,是个纯自定义的的字段,下图中aes_key的“QEBAQEBA......”就是明问密钥“@@@@......”经过Base64后得到的

我们通过在浏览器中执行上面的代码,就可以把注册回调需要返回的JSON值都获取到,然后我们直接在node里写死这几个值用来返回就可以了,同时,我们还需要在nodejs中引入CryptoJS,用来对钉钉发来的回调信息进行解密

const express = require('express')
const bodyParser = require('body-parser');
const CryptoJS = require("crypto-js");


const app = express()
const port = 80


app.get('/', (req, res) => res.send('Hello World!'))

app.listen(port, function(){ 
	console.log(`Example app listening on port ${port}!`);
})

app.use(bodyParser.json())

app.post('/', 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

第二种方案

除了使用crypto-js这个前后端均可用的JavaScript库以外,还可以使用Node自带的crypto在服务端进行加解密,这样就不需要额外安装库了,代码也一样很简单,下面只放解密的方法,其他字符串分割与上述一样:

const crypto = require('crypto')
function aesDecrypt(encrypted) {
    const key = Buffer.from('@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@', 'utf8');
    const iv = Buffer.from('@@@@@@@@@@@@@@@@', 'utf8');
    const decipher = crypto.createDecipheriv('aes-256-cbc', key,iv)
    decipher.setAutoPadding(false)//如果不加这个在解密钉钉加密信息的时候final()容易出错
    let decrypted= decipher.update(encrypted, 'base64')
    decrypted= Buffer.concat([decrypted,decipher.final()])
    console.log(decrypted)
    // return decrypted;
}

aesDecrypt('trdbGFvTG75VTKkpu4kA4aXH4TDvzD21Gypbw/q0sQC3f4VwJphxohak59qeB589FVy22dwpdbnheQ4zlLMvkTEfAfG5WbZtA1cvSM4+MZVb/hJ3lT1ey20sbkWNuHyDT+1hKj+dVQLEFRkgf5jTlWGTdlnWhen0HSaE5mCpO3adpzM57F/KiXEZYdHYOY+Q1emePW2SIyshPk4fJeONQjEYqrplerIfmIgxMINPUyVRccoGlIWF6jr4EasxYW8kFj1orSPgOw1yL5QxvtiRSpN0Dibffffsdfv=')

返回sucess的全部代码:

const express = require('express')
const bodyParser = require('body-parser')
const crypto = require('crypto')


const app = express()
const port = 80

app.listen(port, function(){ 
	console.log(`listening on port ${port}!`);
})


app.use(bodyParser.json())

app.post('/', async function (req, res) {
    console.log(req.body)

    //下面是返回给钉钉success
    const encrypt = makeEncrypt('dingxxxxxxxxxxxxxxxxxxxxxxxxxx')
    const timeStamp = ""+parseInt(new Date()/1000);
    const charCollection = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    let nonce = ''//随机字符串,不限制长度,但是不能出现中文
    for(let i=0;i<10;i++){nonce += charCollection[Math.round(Math.random()*(charCollection.length-1))]}
    const token = 'token from oapi.dingtalk.com/call_back/register_call_back'//该字符串也不能出现中文,是钉钉注册回调接口时传给钉钉端的token字段
    const msg_signature = signMsg(timeStamp,nonce,encrypt,token)
    const resp = {
        msg_signature,
        timeStamp,
        nonce,
        encrypt
    }
    console.log('返回给钉钉的响应是:',resp)
    res.json(resp)

    //下面是解密钉钉推送来的消息
    const msgFromDing = aesDecrypt(req.body.encrypt)
    console.log('钉钉发来的消息是:'+msgFromDing)
})

function makeEncrypt(corpId){
    const msg_len = Buffer.from([0,0,0,7])//success 长度为7,二进制为00000000 00000000 00000000 00000111
    const random = crypto.randomBytes(16)//16字节的随机字符,可以不是ascii
    const msg = Buffer.from('success','ascii')
    const corpid = Buffer.from(corpId,'ascii')
    const codeStringBuffer = Buffer.concat([random,msg_len,msg,corpid])
    const key = '@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@'
    const iv = key.slice(0,16)
    const cipher = crypto.createCipheriv('aes-256-cbc',key,iv)
    let encrypted = cipher.update(codeStringBuffer,'binary','base64')
    encrypted += cipher.final('base64');
    return encrypted
}

/**
 * 
 * @param {*} timeStamp 当前10位的时间戳
 * @param {*} nonce 随机字符串,长度不限
 * @param {*} encrypt 新随机字符串+二进制(0007)+ success + CorpId 然后用aes-256-cbc加密,再转为Base64字符串
 * @param {*} token 注册回调接口时设定的自定义token
 */
function signMsg(timeStamp,nonce,encrypt,token){
    let sortList = [timeStamp,nonce,encrypt,token];
    sortList.sort();
    let msg_signature = '';
    for (let text of  sortList){
        msg_signature += text;
    }
    const hash = crypto.createHash('sha1')
    hash.update(msg_signature)
    msg_signature = hash.digest('hex')
    return msg_signature
}

function aesDecrypt(encrypted) {
    const key = '@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@'
    const iv = key.slice(0,16)
    const decipher = crypto.createDecipheriv('aes-256-cbc', key,iv)
    decipher.setAutoPadding(false)//如果不加这个在解密钉钉加密信息的时候final()容易出错
    let decrypted= decipher.update(encrypted, 'base64')
    decrypted= Buffer.concat([decrypted,decipher.final()])
    const content_length = decrypted.slice(16,20).readInt32BE()//正文的长度,是4个字节的整数
	return decrypted.slice(20,20+content_length).toString('utf-8')//通过指定长度提取出json
}

新的发现

笔者发现,在进行AES加密中,对于企业内部应用开发有一个key,最近钉钉进行了更新,如下:

原说明:

新说明:

从corpId换成了suiteKey,以前只能够使用企业的唯一CorpId,一个企业不管多少应用都只能注册一个回调接口,非常不方便,需要单独做程序去分发,现在使用suiteKey(其实就是钉钉小程序或H5微应用的AppKey),每个应用都可以注册自己的回调接口,并且不需要使用POST请求去注册,只需在管理员的后台配置即可,如下图

 

参考:https://blog.csdn.net/myzksky/article/details/82052920

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值