引
曾经不是第一次写这个主题了,最近有敌人拿 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 这个库。
外围常识
在讲 JSEncrypt 之前,咱们回到“平安传输”这一主题。这一主题的关键技术在于加解密,说起加解密,那就是三大类算法:HASH(摘要)算法、对称加密算法和非对称加密算法。根本的平安传输过程能够用一张图来 展现:
不过这只是最根本的平安传输实践,实际上,证书(公钥)散发等方面依然存在安全隐患,所以才会有 CA、才会有受信根证书……不过这里不作延展,只给个论断:在 Web 前后端传输这个问题上,HTTPS 就是最佳实际,是首先 Web 传输解决方案,只有在不能应用 HTTPS 的状况,才退而求其次,用本人的实现来进步一点平安门槛。
JSEncrypt
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# 产生密钥并导出
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 导入公钥并加密
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
还没完呢,后面说过要补充 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 也大同小异,不过说切实,代码量要大不少。为了干这些事件,大略须要导入这么些类:
// 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,平安传输和保留用户明码。