8

安全地在前后端之间传输数据 - 「1」技术预研

 3 years ago
source link: https://segmentfault.com/a/1190000039827138
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

安全地在前后端之间传输数据 - 「1」技术预研

发布于 4 月 15 日

已经不是第一次写这个主题了,最近有朋友拿 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(摘要)算法、对称加密算法和非对称加密算法。基本的安全传输过程可以用一张图来 展示:

image.png

不过这只是最基本的安全传输理论,实际上,证书(公钥)分发等方面仍然存在安全隐患,所以才会有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 编码的证书。ExportRSAPublicKeyExportSubjectPublicKeyInfo 这两个方法的返回类型都是 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 导入时不会要求这么严格,省了不少事。

剩下的就是将 pkcs1spki 传递给前端了。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 加/解密来完成安全的数据传输。其作法总结如下:

  1. 后端产生 RSA 密钥对,保存备用。保存方式可根据实际情况选择:内存、文件、数据库、缓存服务等
  2. 后端以 SPKI 格式导出公钥(别忘了 Base64 编码),通过某种业务接口形式传递给前端,或由前端主动请求获得(比如调用特定 API)
  3. 前端使用 JSEncrypt,通过 setKey() 导入公钥,使用 encrypt() 加密字符串。加密前字符串会按 UTF8 编码成二进制数据。
  4. 后端获得前端加密后的数据(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 也很明显。只有 publicKeyEncodingprivateKeyEncoding 需要稍微解释一下 —— 其实文档也说得很明白:参考 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());

产生的 publicKeyprivateKey 都是纯纯的 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,安全传输和保存用户密码。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK