Wenzi

深入理解 node 中的 crypto 加密模块

蚊子前端博客
发布于 2020/04/05 21:51
在 node 中也有原生的 crypto 模块,该模块提供了 hash、hmac、加密解密、签名、验证功能等一整套的封装。

我们在日常的业务中经常会遇到这样的场景:

  1. 对比两个文件的内容是否相同;
  2. 生成 token;
  3. 密码保护;
  4. 加密和解密数据;

等等,有各种各样的需要加密的场景。在 node 中也有原生的 crypto 模块,该模块提供了 hash、hmac、加密解密、签名、验证功能等一整套的封装。

使用const crypto = require('crypto');即可引入该模块。

1. hash 算法 #

hash 算法也被称为摘要算法,该算法可以将任意长度的数据,转换为固定长度的 hash 值,这种方式具有不可逆性。你可以把一本小说转换为 hash 数据,但无法从这 hash 数据再逆转回一本小说。因此,若要获取 hash 的原数据,只能靠字典碰撞。

该算法通常在文本校验、存储密码时用的比较多。虽然摘要算法会用于密码的存储,但严格来说,摘要算法不算做是加密算法。

使用getHashes()方法,可以获取到所有支持的 hash 算法:

crypto.getHashes();

获取到一个数组:

[
    "RSA-MD4",
    "RSA-MD5",
    "RSA-MDC2",
    "RSA-RIPEMD160",
    "RSA-SHA1",
    "RSA-SHA1-2",
    "RSA-SHA224",
    "RSA-SHA256",
    "RSA-SHA3-224",
    "RSA-SHA3-256",
    "RSA-SHA3-384",
    "RSA-SHA3-512",
    "RSA-SHA384",
    "RSA-SHA512",
    "RSA-SHA512/224",
    "RSA-SHA512/256",
    "RSA-SM3",
    "blake2b512",
    "blake2s256",
    "id-rsassa-pkcs1-v1_5-with-sha3-224",
    "id-rsassa-pkcs1-v1_5-with-sha3-256",
    "id-rsassa-pkcs1-v1_5-with-sha3-384",
    "id-rsassa-pkcs1-v1_5-with-sha3-512",
    "md4",
    "md4WithRSAEncryption",
    "md5",
    "md5-sha1",
    "md5WithRSAEncryption",
    "mdc2",
    "mdc2WithRSA",
    "ripemd",
    "ripemd160",
    "ripemd160WithRSA",
    "rmd160",
    "sha1",
    "sha1WithRSAEncryption",
    "sha224",
    "sha224WithRSAEncryption",
    "sha256",
    "sha256WithRSAEncryption",
    "sha3-224",
    "sha3-256",
    "sha3-384",
    "sha3-512",
    "sha384",
    "sha384WithRSAEncryption",
    "sha512",
    "sha512-224",
    "sha512-224WithRSAEncryption",
    "sha512-256",
    "sha512-256WithRSAEncryption",
    "sha512WithRSAEncryption",
    "shake128",
    "shake256",
    "sm3",
    "sm3WithRSAEncryption",
    "ssl3-md5",
    "ssl3-sha1",
    "whirlpool"
]

这么多 hash 算法,我们平时用的比较多的是md5, sha1, sha256, sha512。这里把同一个文本,按照不同的摘要算法来生成 hash 值:

// text 要摘要的文本
// hashtype 摘要的算法
function createHash(text, hashtype) {
    const hash = crypto.createHash(hashtype).update(text).digest("hex");
    console.log(hashtype, hash, hash.length);
}
hashes.forEach((type) => {
    createHash("蚊子", type);
});

生成的结果:

md5 37725295ea78b626efcf77768be478cb 32
sha1 21f226b5a07ed3f74e6ae07e994f36d6a9bf6fac 40
sha256 a200ce289b67afbfb6fbc3d7dd33f7ef493daef64fb159c2e48e8534a0289a9b 64
sha512 b88bd9eac191f58e06c99c256bbcfdf2945aa94b47d5e0242be1f0739bf4adccebf4753e9f38f92603fe3f52f331121540c1dda2ed91796410abcfe49a677fba 128

不同的算法,生成的 hash 值的长度也不一样,碰撞成功的难度也越大。

同时,update方法不止可以接收字符串,还可以接收 stream 流:

const filename = "./node-crypto.md";
const hash = crypto.createHash("sha1");
const fsStream = fs.createReadStream(filename);

fsStream.on("readable", () => {
    // 哈希流只会生成一个元素。
    const data = fsStream.read();
    if (data) {
        hash.update(data);
    } else {
        // 数据接收完毕后,输出hash值
        console.log(`${hash.digest("hex")} ${filename}`);
    }
});

既然可以接收 stream 流的格式,那么就使用 pipe 管道进行处理:

const filename = "./node-crypto.md";
const hash = crypto.createHash("sha1");
const fsStream = fs.createReadStream(filename);

fsStream.pipe(hash).pipe(process.stdout);

hash 后传给下个管道进行处理,不过这里输出的通常会是乱码,因此这里我们自己写一个可写流:

const { Writable } = require("stream");

const write = Writable();
write._write = function (data, enc, next) {
    // 将流中的数据写入底层
    process.stdout.write(hash.digest("hex") + "\n");
    // 写入完成时,调用`next()`方法通知流传入下一个数据
    process.nextTick(next);
};

fsStream.pipe(hash).pipe(write); // 正常输出hash值

2. hmac 算法 #

我们先看下 hmac 算法的用法:

const result = crypto.createHmac("sha1", "123456").update("蚊子").digest("hex");

console.log(result); // 0bdd6c1192e321e34887d965c1140be4361ada65

hmac 算法与 hash 算法的调用方式很像,但createHmac()方法这里多了一个参数,这个参数相当于密钥。密钥不一样,即使要加密的文本一样,生成的结果也会不一样。

function createHmac() {
    const text = "蚊子";
    const key = Math.random().toString().slice(-6);

    const result = crypto.createHmac("sha1", key).update(text).digest("hex");

    console.log(text, key, result);
}

let n = 10;
while (n--) {
    createHmac();
}

生成的结果:

蚊子 508028 486d1f539e4bb8adfd601fd6a3302fae74043bfe
蚊子 644233 dcd6501e6eee9e1462625b50c1ff91c613559b35
蚊子 479257 752945c62b87ce1edb24661103b65e612bb849b7
蚊子 445857 0c6399758a2348ea31bc778f87f503b050e036d5
蚊子 954174 a78ff9d4301bb09d249db9fa6c9a3a28c04acff7
蚊子 629736 b7fd4d3836363f029dd9009f51ad6c14280987c1
蚊子 343366 7a8cadf5dd620f8c82315f38de1f6dc60bfc5336
蚊子 168627 cc51e4531449642a5a10357cbf8f206319fb1b1f
蚊子 103054 49b1ad9dc2de5da2cd67dc892f51718aa9475a05
蚊子 477238 82615006638be235a220bcfdee0705b5cc6551fc

由此看到,hmac 算法相当于加盐版的 hash 算法,但内部具体的实现原理,恕在下才疏学浅,实在是没看懂:github-node-crypto-hmac

这种算法实现密码存储就非常的合适,碰撞成功的概率大大减少。在数据库中,我们可以这样存储:

{
    "username": "蚊子",
    "password": "486d1f539e4bb8adfd601fd6a3302fae74043bfe",
    "key": "508028"
}

即使脱库得到了这些数据,反向获取到原密码的机会也非常的低。

在 stream 流的操作上,hmac 算法和 hash 算法的用法一样。

3. 对称加密和解密算法 #

前面的两种方法都是不可逆的 hash 加密算法,这里我们介绍下可加密和可解密的算法。常见的对称加密算法有aesdes

crypto 模块中提供了createCipherivcreateDecipheriv来进行加密和解密的功能。之前的 createCipher 和 createDecipher 在 10.0.0 版本已经废弃了,我们这里以新的方法为例,写下加密和解密的算法。

这两个方法都接收 3 个参数:

  1. algorithm:加密解密的类型;
  2. key: 加密解密的密钥:密钥必须是 8/16/32 位,如果加密算法是 128,则对应的密钥是 16 位,如果加密算法是 256,则对应的密钥是 32 位;
  3. iv: 初始向量,规则与 key 一样

key 和 iv 两个参数都必须是 'utf8' 编码的字符串、Buffer、 TypedArray 或 DataView。 key 可以是 secret 类型的 KeyObject。 如果密码不需要初始化向量,则 iv 可以为 null。

加密的算法:

function encode(src, key, iv) {
    let sign = "";
    const cipher = crypto.createCipheriv("aes-128-cbc", key, iv); // createCipher在10.0.0已被废弃
    sign += cipher.update(src, "utf8", "hex");
    sign += cipher.final("hex");
    return sign;
}

解密的算法:

function decode(sign, key, iv) {
    let src = "";
    const cipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
    src += cipher.update(sign, "hex", "utf8");
    src += cipher.final("utf8");
    return src;
}

使用方法:

const key = "37725295ea78b626"; // Buffer.from('37725295ea78b626', 'utf8');
const iv = "efcf77768be478cb"; // Buffer.from('efcf77768be478cb', 'utf8');
// console.log(key, iv);
const src = "hello, my name is wenzi! my password is `etu^&&*(^123)`";
const sign = encode(src, key, iv);
const _src = decode(sign, key, iv);

console.log("key: ", key, "iv: ", iv);
console.log("原文:", src);
console.log("加密后: ", sign);
console.log("解密后: ", _src);

// key:  37725295ea78b626 iv:  efcf77768be478cb
// 原文: hello, my name is wenzi! my password is `etu^&&*(^123)`
// 加密后:  ce6dc873bfd5a5ae6fe0b2bb3f3de46fb9fc15e0ffc75d12286871dbfa3ed185b3ebf60b8e16dd0057eb0750e897347abeddf5a2741944d5a307ceb25c181276
// 解密后:  hello, my name is wenzi! my password is `etu^&&*(^123)`

4. 非对称加密算法 #

我们刚才了解了下对称加密,即加密和解密用的都是相同的密钥。非对称加密相对来说,比对称加密更安全,用公钥加密的内容,必须通过对应的私钥才能解密。双方传输信息时,可以使用先使用对方的公钥进行加密,然后对方再使用自己的私钥解开即可。

我们先用创建一个私钥:

openssl genrsa -out rsa_private.key 1024

然后根据私钥创建对应的公钥:

openssl rsa -in rsa_private.key -pubout -out rsa_public.key

这里我们就可以进行非对称的加密和解密了:

const crypto = require("crypto");
const fs = require("fs");

const pub_key = fs.readFileSync("./rsa_public.key");
const priv_key = fs.readFileSync("./rsa_private.key");

const text = "hello, my name is 蚊子";

const secret = crypto.publicEncrypt(pub_key, Buffer.from(text));
const result = crypto.privateDecrypt(priv_key, secret);

console.log(secret); // buffer格式
console.log(result.toString()); // hello, my name is 蚊子

使用publicEncrypt进行公钥的加密过程,使用privateDecrypt进行私钥的解密过程。

5. 签名 #

在网络中传输的数据,除可使用 Cipher 类进行数据加密外,还可以对数据生成数字签名,以防止在传输过程中对数据进行修改。

签名的过程与非对称加密的过程正好相反,是使用私钥进行加密签名,然后使用公钥进行解密的签名验证。

const crypto = require("crypto");
const fs = require("fs");

const pub_key = fs.readFileSync("./rsa_public.key");
const priv_key = fs.readFileSync("./rsa_private.key");

const text = "hello, my name is 蚊子";

// 生成签名
const sign = crypto.createSign("RSA-SHA256");
sign.update(text);
const signed = sign.sign(priv_key, "hex");

// 验证签名
const verify = crypto.createVerify("RSA-SHA256");
verify.update(text);
const verifyResult = verify.verify(pub_key, signed, "hex");

console.log("sign", signed); // ca364a6e31c1f540737ba3efb1ddf7fa2a087c5c11efe52a9e1f2c88b1fd1e0e50f12da4f22362fdfc3d77f3f538995a27a8206d250dba3572510dfcb33064f48685b96f2b2393f56de4958448cec92a4299434aa3318efe418e166b38100bc3a1d1a9310a510087021da0f66a817043ddfd2fb88db76eb2ace480c17a7f732f
console.log("verifyResult", verifyResult); // true

生成签名的 sign 方法有两个参数,第一个参数为私钥,第二个参数为生成签名的格式,最后返回的 signed 为生成的签名(字符串)。

验证签名的 verify 方法有三个参数,第一个参数为公钥,第二个参数为被验证的签名,第三个参数为生成签名时的格式,返回为布尔值,即是否通过验证。

6. 总结 #

我从简单的 hash 算法,到对称加密,最后到非对称加密和签名,都有了个大致的了解。后续我们也会对 node 的其他模块进行深入的理解。

标签:nodecrypto
阅读(2850)
Simple Empty
No data