nodejs 加密场景应用

411 阅读3分钟

前言

数据加密是前后端开发常用的技术,应用场景包括,信息通讯,敏感数据存储等,本文列举的几个常用的加密方案,并配合对应的场景讲解具体的应用。

密码加密存储

场景说明

一般软件系统用户名是明文存储在数据库中,密码是hash 处理后存在数据库中,对比验证时将用户输入的密码hash 处理后和数据库中的hash 比对,一致即为验证通过。 现在常用的加密方案中会做加盐hash ,用于对抗彩虹表暴力破解,尽量保证每个用户的盐值不相同,增加攻击方破解难度。

一个简单的示例

const crypto = require('crypto');

const random = () => {
    return Math.random().toString().slice(5, 10);
}

const cryptPwd = (password, salt) => {
    const saltPassword = `${password}:${salt}`;
    const md5 = crypto.createHash('sha1');
    const result = md5.update(saltPassword).digest('hex');
    console.log("saltPassword", saltPassword)
    console.log("result", result)
    return result
}

const password = '123456';
cryptPwd(password, random());

/**
 * saltPassword 123456:62130
 * result ef8b60fe12179fd88dbe7304231d0bf1
 */
cryptPwd(password, random());
/**
 * saltPassword 123456:77042
 * result 79b05a3e6644cccaf9f57061588d13cd
 */

实际应用

crypto 的例子

加密生产salt 和hash,并将两条数据保存进数据库

const crypto = require('crypto');
function makeSalt() {
    return crypto.randomBytes(10).toString('base64');
}
const salt = makeSalt()
const hash = crypto.pbkdf2Sync(password, salt, 10000, 16, 'sha1').toString('base64')
/**
 * password 类型为  string | NodeJS.ArrayBufferView;
 * salt 类型同上
 * iterations 迭代次数,number 类型,数字越高越安全
 * keylen 返回值的长度。这里是指返回buffer的长度,toString()里encoding不同,会导致字符串长度不同。
 * digest 摘要算法名称 string 类型
 */

验证时,password 来源于前端输入,salt 来源于数据.。再次算出 hash 和数据库里字段做比对,相同即为通过验证。

bcrypt 的例子

加密时

const bcrypt = require('bcrypt');
const saltRounds = 10;
const myPlaintextPassword = '123456';
const hash = bcrypt.hashSync(myPlaintextPassword,saltRounds)
// 把hash 值存到数据库里

验证时:password 来源于前端输入,hash 来源于数据库,返回true 即为验证通过

const bcrypt = require('bcrypt');
bcrypt.compareSync(myPlaintextPassword, hash); // true

和上面的例子比起来,这里把salt 和hash 合并成一个字段了。

官方的说明

$2b$10$nOUIs5kJ7naTuTFkBy1veuK0kSxUFXfuaOKdOKf9xYT0KKIGSJwFa
 |  |  |                     |
 |  |  |                     hash-value = K0kSxUFXfuaOKdOKf9xYT0KKIGSJwFa
 |  |  |
 |  |  salt = nOUIs5kJ7naTuTFkBy1veu
 |  |
 |  cost-factor => 10 = 2^10 rounds
 |
 hash-algorithm identifier => 2b = BCrypt

签名

场景说明

签名的目的是防止数据被篡改,常用场景是在发送请求时,对body 里的数据做个签名放在header上,接收方拿到数据后,对body 进行签名并且和请求方给予的签名字段相对比,相同即为验证通过。

无私钥

var signature = crypto.createHmac('sha1', accessSecret )
 signature.update(new Buffer(StringToSign, 'utf-8')).digest().toString('base64');

验证过程和加密过程一样。

有私钥

用私钥做签名

const crypto = require('crypto');
const fs = require('fs');
const privateKey = fs.readFileSync('./private-key.pem');  // 私钥
const publicKey = fs.readFileSync('./public-key.pem');  // 公钥
const algorithm = 'RSA-SHA256';  // 签名算法
const encoding = 'hex'

// 数字签名
function sign(text){
    const sign = crypto.createSign(algorithm);
    sign.update(text);
    return sign.sign(privateKey, encoding);
}
// 对内容进行签名
const content = 'hello world';
const signature = sign(content);
console.log(signature);

公钥做验证

const crypto = require('crypto');
const fs = require('fs');
const publicKey = fs.readFileSync('./public-key.pem');  // 公钥
const algorithm = 'RSA-SHA256';  // 签名算法
const encoding = 'hex'
// 校验签名
function verify(oriContent, signature){
    const verifier = crypto.createVerify(algorithm);
    verifier.update(oriContent);
    return verifier.verify(publicKey, signature, encoding);
}
// 校验签名,如果通过,返回true
const verified = verify(content, signature);
console.log(verified);

数据加密传输

场景说明

通常意义上的加密,即将发送的数据加密发送给对方,达到保密的目的。

对称加密

流程

  • A 使用密钥加密
  • A 并且把密钥给B
  • B 使用密钥解密
const crypto = require("crypto")  
const key = crypto.randomBytes(192 / 8)
const iv = crypto.randomBytes(128 / 8)
const algorithm = 'aes192'
const encoding = 'hex'


const encrypt = (text) => {
    const cipher = crypto.createCipheriv(algorithm, key, iv)
    cipher.update(text)
    return cipher.final(encoding)
}

const content = 'Hello Node.js'
const crypted = encrypt(content)
console.log(crypted)


const decrypt = (encrypted) => {
    const decipher = crypto.createDecipheriv(algorithm, key, iv)
    decipher.update(encrypted, encoding)
    return decipher.final('utf8')
}

// db75f3e9e78fba0401ca82527a0bbd62
const decrypted = decrypt(crypted)
console.log(decrypted)
// Hello Node.js

非对称加密

流程

  • 由B 生成公钥和私钥
  • B 把公钥给A
  • A 使用B给的公钥加密数据,然后发给B
  • B 使用私钥进行解密
const crypto = require("crypto");  
const fs = require('fs');
const privateKey = fs.readFileSync('./private-key.pem');  // 私钥
const publicKey = fs.readFileSync('./public-key.pem');  // 公钥

const msg = "Hello Node.js"
const encodeData = crypto.publicEncrypt(publicKey,Buffer.from(msg))
console.log(encodeData.toString("base64"))

const decodedData = crypto.privateDecrpt(privateKey,encodeData)
console.log(decodeData.toString("utf8"))

混合加密

流程

  • 由B 生成公钥和私钥
  • B 把公钥给A
  • A 生成一个 对称密钥 ,并用B 给的公钥加密,发送给B
  • B 使用私钥解密,得到对称密钥
  • A 和B 之间后续都使用对称密钥加密和解密

企业微信加密解密案例

场景说明

这里设定一个特定的场景,企业微信第三方套件,设置回调接口,以及从回调接口中拿到数据并解密。大体流程是 验证数据签名 -> 解密-> 解码

参考的是 官方文档

解码(编码)

这里使用 PKCS#7 加密标准,不过只涉及到了编码补全部分。 这个类仅充当Buffer补全功能,代替 createDecipheriv 的自动补全功能 setAutoPadding。 它的具体作用是设置一个块大小比如16,返回一个为它整数倍的Buffer。如果源数据能整除16 则添加一整个块的数据,否则将填充剩余空间的数据,填充的数据为源数据的长度。 例子1、buffer 长度 6,填充剩余10个位,每位都是6。 例子2 、buffer 长度20 ,填充剩余 16-(20-16)= 12 位,每位都是20 例子3 、buffer 长度16 ,填充 16-(16-16)= 16 位,每位都是16

class PKCS7Encoder {
    constructor(blockSize) {
        this.blockSize = blockSize
    }
    
     /**
     * @param {Buffer} msg
     */
    encode(msg) {

        const that = this;
        const msgLength = msg.length
        const padding = that.blockSize - (msgLength % that.blockSize)
        const pad = Buffer.alloc(padding, padding)

        return Buffer.concat([msg, pad])

    }

    /**
     * @param {Buffer} msg
     * @returns
     */
    decode(msg) {
    
        const that = this;
        const msgLength = msg.length
        const padding = msg[msgLength - 1]
        
        if (padding < 1 || padding > that.blockSize) {
            throw new Error('Invalid padding');
        }

        return msg.subarray(0, msgLength - padding)

    }
}

解密部分

class WXMsgCrypt {

    /**
     *
     * @param {String} token
     * @param {String} encodingAESKey
     * @param {String} reciveId
     */
    constructor(token, encodingAESKey, reciveId) {

        if (!token || !encodingAESKey) {
            throw new Error("arguments miss")
        }
        this.token = token;
        this.encodingAESKey = encodingAESKey
        let AESKey = Buffer.from(encodingAESKey + "=", "base64")
        if (AESKey.length !== 32) {
            throw new Error('encodingAESKey invalid');
        }
        this.key = AESKey;
        this.iv = AESKey.subarray(0, 16)
        this.pkcs7Encoder = new PKCS7Encoder(16)
        this.reciveId = reciveId
        
    }

    /**
     *
     * @param {String} timestamp
     * @param {String} nonce
     * @param {String} encryptMsg
     * @returns
     */
    getSignature(timestamp, nonce, encryptMsg) {

        let that = this;
        const shasum = crypto.createHash("sha1")
        const arr = [that.token, timestamp, nonce, encryptMsg]
        shasum.update(arr.join(""))
        return shasum.digest('hex')

    }
    /**
     * @param {String} plainMsg
     * @returns
     */
    encrypt(plainMsg) {

        let that = this;
        const randomString = crypto.pseudoRandomBytes(16);
        const msg = Buffer.from(plainMsg)
        let msgLength = Buffer.alloc(4)
        msgLength.writeUInt32BE(msg.length, 0)

        let bufMsg = Buffer.concat([
	        randomString,
	        msgLength, 
	        msg, 
	        Buffer.from(that.reciveId)
	        ])

        let encoded = that.pkcs7Encoder.encode(bufMsg)
        let cipher = crypto.createCipheriv('aes-256-cbc', that.key, that.iv);
        cipher.setAutoPadding(false);// 这里用pkcs7 编码代替
        let cipheredMsg = Buffer.concat([cipher.update(encoded), cipher.final()]);
        return cipheredMsg.toString("base64")

    }


    /**
     * @param {String} encryptMsg
     * @returns
     */
    decrypt(encryptMsg) {

        let that = this;
        let decipher = crypto.createDecipheriv("aes-256-cbc", that.key, that.iv)
        decipher.setAutoPadding(false);
        
        let decipheredMsg = Buffer.concat([
	        decipher.update(encryptMsg, "base64"), 
	        decipher.final()
	        ])
	        
        decipheredMsg = that.pkcs7Encoder.decode(decipheredMsg)
        
        // 算法:AES_Encrypt[random(16B) + msg_len(4B) + msg + $reciveId]
        let content = decipheredMsg.subarray(16)
        const length = content.subarray(0, 4).readUInt32BE(0)
        return {
            message: content.subarray(4, length + 4).toString(),
            reciveId: content.subarray(length + 4).toString()
        }
        
    }
    
}

实际调用

先拿timestamp,nonce,echostr 做签名和msg_signature 做比对,验证通过后对echostr 进行解密。 在回调接口验证的阶段,需要返回message 给企业微信验证,如果是接收数据阶段就是把解析出来的数据做进一步的处理

const qywxThirdCallback =  function (req, res) {

  // 省略部分代码
	const _config = {
		token: "",
		encodingAESKey:"",
		suiteid: ""
	}
	const { msg_signature, timestamp, nonce } = req.query
	const echostr = decodeURIComponent(req.query.echostr);
	let cryptor = new WXMsgCrypt(
					_config.token, 
					_config.encodingAESKey, 
					_config.suiteId
					);

	if (msg_signature !== cryptor.getSignature(timestamp, nonce, echostr)) {
		res.writeHead(401);
		res.end('Invalid signature');
		return;
	}

	let result = cryptor.decrypt(echostr);
	res.writeHead(200);
	res.end(result.message);

}

后语

本文简述nodejs 在各场景下的使用方法,属于一篇应用型的文章。

看完文章大家会发现

  • 签名:私钥签名,公钥验证
  • 数据加密 :公钥加密,私钥解密

这两个正好是反的,大家可以想一想背后的原理是什么?