引
已经不是第一次写这个主题了, 最近有朋友拿 5 年前的《web 应用中保证密码传输安全》来问我:"为什么按你说的一步步做下来, 后端解不出来呢?" 加解密这种事情, 差之毫厘谬以千里, 我认为多半就是什么参数没整对, 仔细查查改对了就行. 代码拿来一看, 傻眼了...... 没毛病啊, 为啥解不出来呢?
时间久远, 原文附带的源代码已经下不下来了. 翻阅各种参考链接的时候从 CodeProject 上找了个代码, 把各参数换过去一试, 没毛病呀! 这可奇了怪了, 于是去 RSA.JS 的文档 (没有专门的文档, 就是文档注释) 中查, 发现 RSA.JS 在 2014 年 1 月加入了 Padding 参数,《Web 应用中保证密码传输安全》虽然是 2014 年 2 月写的, 但可能阴差阳错用到了老版本.
不就是 Padding 吗, 文档也懒得看了, 前后端都指定 PKCS1Padding 试试. 失败!
那暴力一点, 所有 Padding 都试试!
前端使用 RSA.JS 在 RSAAPP 中定义的 4 种 Padding, 后端 C# 使用 RSAEncryptionPadding 中定义的 5 种 Padding, 组合了 20 种情况, 逐一试验...... 好吧, 没一个对的!
世界上这么多树, 何必非要在这一棵上吊死, 何况它还没有发布到 NPM ...... 理由找够了, 咱就换!
网上搜了一圈之后, 选择了 JSEncrypt http://travistidwell.com/jsencrypt/ 这个库.
核心知识
在讲 JSEncrypt 之前, 咱们回到 "安全传输" 这一主题. 这一主题的关键技术在于加解密, 说起加解密, 那就是三大类算法: HASH(摘要)算法, 对称加密算法和非对称加密算法. 基本的安全传输过程可以用一张图来 展示:
不过这只是最基本的安全传输理论, 实际上, 证书 (公钥) 分发等方面仍然存在安全隐患, 所以才会有 CA, 才会有受信根证书...... 不过这里不作延展, 只给个结论: 在 Web 前后端传输这个问题上, HTTPS 就是最佳实践, 是首选 Web 传输解决方案, 只有在不能使用 HTTPS 的情况, 才退而求其次, 用自己的实现来提高一点安全门槛.
JSEncrypt http://travistidwell.com/jsencrypt/
JSEncrypt 一个月前刚有新版本, 还算活跃. 不过在使用方式上跟 RSA.JS 不同, 它不需要指定 RSA 的参数, 而是直接导入一个 PEM 格式的密钥(证书). 关于证书格式呢, 就不在这里科普了, 总之 PEM 是一种文本格式, Base64 编码.
既然 JSEnrypt 需要导入密钥, 这里主要是需要导入公钥. 我们来看看 C# 里 RSACryptoServiceProvider 能导出些什么, 搜了一下 Export... 方法, 导出公约相关的主要就这两个:
因为原始需求是用 .NET, 所以先研究 .NET 跟 JSEncrypt 的配合, 后面再补充 Node.JS 和 Java 的.
ExportRSAPublicKey()
, 以 PKCS#1 RSAPublicKey 格式导出当前密钥的公钥部分.
ExportSubjectPublicKeyInfo()
, 以 X.509 SubjectPublicKeyInfo 格式导出当前密钥的公钥部分.
还有两个 Try... 前缀的方法作用相似, 可以忽略. 这两个方法的区别就在于导出的格式不同, 一个是 PKCS#1 (Public-Key Cryptography Standards), 一个是 SPKI (Subject Public Key Info).
JSEncrypt 能导入哪种格式呢? 文档里没明确说明, 不妨试试.
- C# 产生密钥并导出
- 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 编码. 这里使用 https://gitee.com/jamesfancy/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 导入公钥并加密
JSEncrypt 提供了 setPublicKey() 和 setPrivateKey() 来导入密钥. 不过文档中提到它们其实都是 setKey() 的别名, 这点需要注意一下. 为了避免语义不清, 我建议直接使用 setKey().
- You can use also setPrivateKey and setPublicKey, they are both alias to setKey
- from: http://travistidwell.com/jsen...
那么导入公钥并试验加密的过程大概会是这样:
- // 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# 中验证这个密文是可以解出来的.
C# 验证可以解密 JSEncrypt 生成的密文
上面生成的那一段 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 加 / 解密来完成安全的数据传输. 其做法总结如下:
后端产生 RSA 密钥对, 保存备用. 保存方式可根据实际情况选择: 内存, 文件, 数据库, 缓存服务等
后端以 SPKI 格式导出公钥(别忘了 Base64 编码), 通过某种业务接口形式传递给前端, 或由前端主动请求获得(比如调用特定 API)
前端使用 JSEncrypt, 通过 setKey() 导入公钥, 使用 encrypt() 加密字符串. 加密前字符串会按 UTF8 编码成二进制数据.
后端获得前端加密后的数据 (Base64 编码) 后, 解密成二进制数据, 并使用 UTF8 解码成文本.
特别需要注意的一点是: 不管以何种方式 (xml,PEM 等) 将公钥传送给前端的时候, 都切记不要把私钥给出去了. 这尤其容易发生在使用 .ToXmlString(true) 之后再直接把结果送给前端. 不要问我为什么会有这么个提醒, 要问就是因为...... 我见过!
关门放 Node
还没完呢, 前面说过要补充 Node.JS 后端的情况. Node.JS 关于加 / 解密的 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", 所以还是没得选.
不过 Node.JS 的 PEM 输出要规范得多, 看(同样省略了中间部分):
- -----BEGIN PUBLIC KEY-----
- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCYur0zYBtqOqs98l4rh1J2olBb
... ... ...
- 8I8y4j9dZw05HD3u7QIDAQAB
- -----END PUBLIC KEY-----
- -----BEGIN RSA PRIVATE KEY-----
- MIICXAIBAAKBgQCYur0zYBtqOqs98l4rh1J2olBbYpm5n6aNonWJ6y59smqipfj5
... ... ...
- UJKGwVN8328z40R5w0iXqtYNvEhRtYGl0pTBP1FjJKg=
- -----END RSA PRIVATE KEY-----
不管是否含标头 / 标尾, 也不管是不是有折行, JSEncrypt 都认, 所以倒不用太在意这些细节. 总之 JSEncrypt 拿到公钥之后还是跟之前一样, 做同样的事情, 逻辑代码一个字都不用改.
然后回到 Node.JS 解密:
- // 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 也大同小异, 不过说实在, 代码量要大不少. 为了干这些事情, 大概需要导入这么些类:
- // 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 是真累, 所以, 以后的后端示例就用 Node.JS 了 -- 不是 Java 的锅, 主要是不想切环境.
下节看点:「注册」的 DEMO, 安全传输和保存用户密码.「传送门」
来源: https://segmentfault.com/a/1190000039827138