已经不是第一次写这个主题了,最近有朋友拿 5 年前的《Web 应用中保证密码传输安全》来问我:“为何按你说的一步步作下来,后端解不出来呢?”加解密这种事情,差之毫厘谬以千里,我认为多半就是什么参数没整对,仔细查查改对了就行。代码拿来一看,傻眼了……没毛病啊,为啥解不出来呢?前端
时间久远,原文附带的源代码已经下不下来了。翻阅各类参考连接的时候从 CodeProject 上找了个代码,把各参数换过去一试,没毛病呀!这可奇了怪了,因而去 RSA.js 的文档(没有专门的文档,就是文档注释)中查,发现 RSA.js 在 2014 年 1 月加入了 Padding 参数,《Web 应用中保证密码传输安全》虽然是 2014 年 2 月写的,但可能阴差阳错用到了老版本。java
不就是 Padding 吗,文档也懒得看了,先后端都指定 PKCS1Padding 试试。失败!git
那暴力一点,全部 Padding 都试试!算法
前端使用 RSA.js 在 RSAAPP
中定义的 4 种 Padding,后端 C# 使用 RSAEncryptionPadding
中定义的 5 种 Padding,组合了 20 种状况,逐一试验……好吧,没一个对的!数据库
世界上这么多树,何须非要在这一棵上吊死,况且它尚未发布到 npm …… 理由找够了,咱就换!npm
网上搜了一圈以后,选择了 JSEncrypt 这个库。后端
在讲 JSEncrypt 以前,我们回到“安全传输”这一主题。这一主题的关键技术在于加解密,提及加解密,那就是三大类算法:HASH(摘要)算法、对称加密算法和非对称加密算法。基本的安全传输过程能够用一张图来 展现:api
不过这只是最基本的安全传输理论,实际上,证书(公钥)分发等方面仍然存在安全隐患,因此才会有CA、才会有受信根证书……不过这里不做延展,只给个结论:在 Web 先后端传输这个问题上,HTTPS 就是最佳实践,是首先 Web 传输解决方案,只有在不能使用 HTTPS 的状况,才退而求其次,用本身的实现来提升一点安全门槛。缓存
JSEncrypt 一个月前刚有新版本,还算活跃。不过在使用方式上跟 RSA.js 不一样,它不须要指定 RSA 的参数,而是直接导入一个 PEM 格式的密钥(证书)。关于证书格式呢,就不在这里科普了,总之 PEM 是一种文本格式,Base64 编码。安全
既然 JSEnrypt 须要导入密钥,这里主要是须要导入公钥。咱们来看看 C# 里 RSACryptoServiceProvider
能导出些什么,搜了一下 Export...
方法,导出公约相关的主要就这两个:
由于原始需求是用 .NET,因此先研究 .NET 跟 JSEncrypt 的配合,后面再补充 NodeJS 和 Java 的。
ExportRSAPublicKey()
,以 PKCS#1 RSAPublicKey 格式导出当前密钥的公钥部分。ExportSubjectPublicKeyInfo()
,以 X.509 SubjectPublicKeyInfo 格式导出当前密钥的公钥部分。还有两个 Try...
前缀的方法做用类似,能够忽略。这两个方法的区别就在于导出的格式不一样,一个是 PKCS#1 (Public-Key Cryptography Standards),一个是 SPKI (Subject Public Key Info)。
JSEncrypt 能导入哪一种格式呢?文档里没明确说明,不妨试试。
C# 中产生 RSA 密钥对比较简单,使用 RSACryptoServiceProvider
就行,好比产生一对 1024 位的 RSA 密钥,并以 XML 格式导出:
// C# Code private RSACryptoServiceProvider GenerateRsaKeys(int keySize = 1024) { var rsa = new RSACryptoServiceProvider(keySize); var xmlPrivateKey = rsa.ToXmlString(true); // 若是须要单独的公钥部分,将传入 `ToXmlString()` 改成 false 就好 // var xmlPublicKey = rsa.ToXmlString(false); File.WriteAllText("RSA_KEY", xmlPrivateKey); return rsa; }
为了能在进程每次重启都使用相同的密钥,上面的示例将产生的 xmlPrivateKey
保存到文件中,重启进程时能够尝试从文件加载导入。注意,因为私钥包含公钥,因此只须要保存 xmlPrivateKey
就够了。那么加载的过程:
// C# Code private RSACryptoServiceProvider LoadRsaKeys() { if (!File.Exists("RSA_KEY")) { return null; } var xmlPrivateKey = File.ReadAllText("RSA_KEY"); var rsa = new RSACryptoServiceProvider(); rsa.FromXmlString(xmlPrivateKey); return rsa; }
先尝试导入,不成再新生成的过程就一句话:
// C# Code var rsa = LoadRsaKeys() ?? GenerateRsaKeys();
导出 XML Key 是为了持久化。JSEncrypt 须要的是 PEM 格式的证书,也就是 Base64 编码的证书。ExportRSAPublicKey
和 ExportSubjectPublicKeyInfo
这两个方法的返回类型都是 byte[]
,因此须要对它们进行 Base64 编码。这里使用 Viyi.Util 提供的 Base64Encode()
扩展方法来实现:
// C# Code var pkcs1 = rsa.ExportRSAPublicKey().Base64Encode(); var spki = rsa.ExportSubjectPublicKeyInfo().Base64Encode();
严格的说,PEM 格式还应该加上 -----BEGIN PUBLIC KEY-----
和 -----END PUBLIC KEY-----
这样的标头标尾,Base64 编码也应该按每行 64 个字符进行折行处理。不过实测 JSEncrypt 导入时不会要求这么严格,省了很多事。
剩下的就是将 pkcs1
和 spki
传递给前端了。Web 应用直接经过 API 返回一个 JSON,或者 TEXT 都行,根据接口规范来决定。固然也能够经过拷贝/粘贴的方式来传递。这里既然是在作实验,那就用 Console.WriteLine
输出到控制台,经过剪贴板来传递好了。
我这里 PKCS#1 导出的是长度为 188 个字符的 Base64:
MIGJAoGB...tAgMBAAE=
SPKI 导出的是长度为 216 个字符的 Base64:
MIGfMA0GC...QIDAQAB
JSEncrypt 提供了 setPublicKey()
和 setPrivateKey()
来导入密钥。不过文档中提到它们其实都是 setKey()
的别名,这点须要注意一下。为了不语义不清,我建议直接使用 setKey()
。
You can use also setPrivateKey and setPublicKey, they are both alias to setKey
那么导入公钥并试验加密的过程大概会是这样:
// JavaScript Code const pkcs1 = "MIGJAoGB...tAgMBAAE="; // 注意,这里的 KEY 值仅做示意,并不完整 const spki = "MIGfMA0GC...QIDAQAB"; // 注意,这里的 KEY 值仅做示意,并不完整 [pkcs1, spki].forEach((pKey, i) => { const jse = new JSEncrypt(); jse.setKey(pKey); const eCodes = jse.encrypt("Hello World"); console.log(`[${i} Result]: ${eCodes}`); });
运行后获得输出(密文也是省略了中间很长一串的 ):
[0 Result]: false [1 Result]: ZkhFRnigoHt...wXQX4=
看这结果,没啥悬念了,JSEncrypt 只认 SPKI 格式。
不过还得去 C# 中验证这个密文是能够解出来的。
上面生成的那一段 ZkhFRnigoHt...wXQX4=
拷贝到 C# 代码中,用来验证解密。C# 使用 RSACryptoServiceProvider.Decrypt()
实例方法来解密,这个方法的第 1 个参数是密文,类型 byte[]
,是以二进制数据的形式提供的。
第二个参数能够是 boolean
类型,true
表示使用 OAEP
填充方式,false
表示使用 PKCS#1 v1.5
;这个参数也能够是 RSAEncryptionPadding
对象,直接从预约义的几个静态对象中选择一个就好。这些在文档中都说得很清楚。由于通常都是使用的 PKCS 填充方式,因此此次赌一把,直接上:
// C# Code var eCodes = "ZkhFRnigoHt...wXQX4="; // 示例代码这里省略了中间大部份内容 var rsa = LoadRsaKeys(); // rsa 确定是使用以前生成的密钥对,要否则无法解密 byte[] data = rsa.Decrypt(eCodes.Base64Decode(), false); Console.WriteLine(data.GetString()); // GetString 也是 Viyi.Util 中定义的扩展方法,默认用 UTF8 编码
结果正如预期:
Hello World
如今,经过实验,Web 前端使用 JSEncrypt 和 .NET 后端之间已经实现了 RSA 加/解密来完成安全的数据传输。其做法总结以下:
setKey()
导入公钥,使用 encrypt()
加密字符串。加密前字符串会按 UTF8 编码成二进制数据。特别须要注意的一点是:无论以何种方式(XML、PEM 等)将公钥传送给前端的时候,都切记不要把私钥给出去了。这尤为容易发生在使用 .ToXmlString(true)
以后再直接把结果送给前端。不要问我为何会有这么个提醒,要问就是由于……我见过!
还没完呢,前面说过要补充 NodeJS 后端的状况。NodeJS 关于加/解密的 SDK 都在 crypto
模块中,
generateKeyPair()
或 generateKeyPairSync()
来产生密钥对privateDecrypt()
来解密数据generateKeyPair()
是异步操做。如今 Node 中异步函数很常见,尤为是写 Web 服务端的时候,处处都是异步。不喜欢回调方式的话,可使用util
模块中的promisify()
把它转换一下。
// JavaScript Code, in Node environtment import { promisify } from "util"; import crypto from "crypto"; const asyncGenerateKeyPair = promisify(crypto.generateKeyPair); (async () => { const { publicKey, privateKey } = await asyncGenerateKeyPair( "rsa", { modulusLength: 1024, publicKeyEncoding: { type: "spki", format: "pem", }, privateKeyEncoding: { type: "pkcs1", format: "pem" } } ); console.log(publicKey) console.log(privateKey); })();
generateKeyPair
第 1 个参数是算法,很明显。第 2 个参数是选项,强度 1024 也很明显。只有 publicKeyEncoding
和 privateKeyEncoding
须要稍微解释一下 —— 其实文档也说得很明白:参考 keyObject.export()
。
对于公钥,type
可选 "pkcs1"
或者 "spki"
,以前已经试过,JSEncrypt 只认 "spki"
,因此没得选。
对于私钥,RSA 只能选 "pkcs1"
,因此仍是没得选。
不过 NodeJS 的 PEM 输出要规范得多,看(一样省略了中间部分):
-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCYur0zYBtqOqs98l4rh1J2olBb ... ... ... 8I8y4j9dZw05HD3u7QIDAQAB -----END PUBLIC KEY----- -----BEGIN RSA PRIVATE KEY----- MIICXAIBAAKBgQCYur0zYBtqOqs98l4rh1J2olBbYpm5n6aNonWJ6y59smqipfj5 ... ... ... UJKGwVN8328z40R5w0iXqtYNvEhRtYGl0pTBP1FjJKg= -----END RSA PRIVATE KEY-----
不论是否含标头/标尾,也不论是不是有折行,JSEncrypt 都认,因此倒不用太在乎这些细节。总之 JSEncrypt 拿到公钥以后仍是跟以前同样,作一样的事情,逻辑代码一个字都不用改。
而后回到 NodeJS 解密:
// JavaScript Code, in Node environtment import crypto from "crypto"; const eCodes = "ZkhFRnigoHt...wXQX4="; // 做为示例,偷个懒就用以前的那一段了 const buffer = crypto.privateDecrypt( { key: privateKey, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(eCodes, "base64") ); console.log(buffer.toString());
privateDecrypt()
第 1 个参数给私钥,能够是以前导出的私钥 PEM,也能够是没导出的 KeyObject
对象。须要注意的是必需要指定填充方式是 RSA_PKCS1_PADDING
,由于文档说默认使用 RSA_PKCS1_OAEP_PADDING
。
还有一点须要注意的是别忘了 Buffer.from(..., "base64")
。
解密的结果是保存在 Buffer 中的,直接 toString()
转成字符串就好,显示指定 UTF-8,用 toString("utf-8")
固然也是能够的。
Java 也大同小异,不过说实在,代码量要大很多。为了干这些事情,大概须要导入这么些类:
// Java Code import java.nio.charset.StandardCharsets; import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; import java.util.Base64.Decoder; import java.util.Base64.Encoder; import javax.crypto.Cipher;
而后是产生密钥对
// Java Code KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); gen.initialize(1024); KeyPair pair = gen.generateKeyPair(); Encoder base64Encoder = Base64.getEncoder(); String publicKey = base64Encoder.encodeToString(pair.getPublic().getEncoded()); String privateKey = base64Encoder.encodeToString(pair.getPrivate().getEncoded()); // 这里输出 PKCS#8,因此解密时须要用 PKCS8EncodedKeySpec System.out.println(pair.getPrivate().getFormat());
产生的 publicKey
和 privateKey
都是纯纯的 Base64,没有其余内容(没有标头/标尾等)。
而后是解密过程……
// Java Code String eCode = "k7M0hD....qvdk="; // 再次声明,这是仅为演示写的阉割版数据 Decoder base64Decoder = Base64.getDecoder(); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(base64Decoder.decode(privateKey)); KeyFactory keyFactory = KeyFactory.getInstance("RSA"); Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm()); cipher.init(Cipher.DECRYPT_MODE, keyFactory.generatePrivate(keySpec)); byte[] data = cipher.doFinal(base64Decoder.decode(eCode)); System.out.println(new String(data, StandardCharsets.UTF_8));
写完 Java 是真累,因此,之后的后端示例就用 NodeJS 了 —— 不是 Java 的锅,主要是不想切环境。
下节看点:「注册」的 DEMO,安全传输和保存用户密码。