因为上个学期的课程里有学习到了一点入门的密码学(逃 所以就顺便想尝试一下在编程中进行加密解密相关的操作~(ノ*・ω・)ノ
在开始之前,先简单地过一遍相关的一些概念:
- 明文:没有加密的数据
- 密文:加密后的数据
- 对称加密:使用同一个密钥(如果是除 CBC 以外模式的分组密码,还需要 IV)进行加密和解密
- 非对称加密:任何人都可以使用公开的公钥加密,只有藏着私钥的人才能解密
- Hash:把任意长度的输入变换成固定长度的、尽可能避免产生碰撞的输出,是不可逆操作
- Base64:使用可打印的字符表示二进制数据的一种编码方式,因为任何人都可以直接进行编码和解码,所以不是加密算法
这里的目标是在前端 Javascript 环境和后端 PHP 环境,分别使用一下 RSA 和 AES 这两种常见的非对称和对称加密算法~
用到的各种开源库:
- CryptoJS:用 Javascript 实现了多种对称加密算法和 Hash 算法的函数库。
- JSEncrypt:用 Javascript 实现了 RSA 的加密解密、验证签名、密钥生成。
- OpenSSL:广泛用于安全通信的一个库,支持大多数的加密算法和 Hash 算法,这里用到的是 PHP 自带的相关扩展。
- phpseclib:一个以面向对象的方式封装了 RSA 和其他一些对称加密算法的 PHP composer 包,实际上还是在调用 PHP 的 OpenSSL 之类的加密扩展。(如果没有安装扩展的话,这个库自己也已经用 PHP 把各种算法实现了一遍……)
AES 对称加解密
CryptoJS
在开始进行加解密之前,有必要先介绍一下 CryptoJS 中的 WordArray
~
由于 JS 中直接操作数值型数据的 TypedArray 是一个相对比较新的标准,而 CryptoJS 这个库开发于 TypedArray 进入 JS 标准之前,所以 CryptoJS 中明文、密文、密钥和 Hash 一直都是使用了一个自建的 WordArray 对象,通过一个相当于 UInt32 数值组成的数组和一个标志位来代表原始的二进制数据:
(由于字节序不同,虽然也可以使用 CryptoJS.lib.WordArray.create(new Uint32Array([...]))
得到 WordArray ,但是里面的 words
和输入的 Uint32Array
是不同的)
加密和解密都是本质上都是对二进制数据进行操作,CryptoJS 也支持一些“编码”标准,可以与 WordArray 相互转换:
// 三个数据实际上都是QWERTYUIOP123456
// 使用CryptoJS.enc.*.parse将数据转换为WordArray
let a = CryptoJS.enc.Utf8.parse('QWERTYUIOP123456');
let b = CryptoJS.enc.Hex.parse('51574552545955494f50313233343536');
let c = CryptoJS.enc.Base64.parse('UVdFUlRZVUlPUDEyMzQ1Ng==');
// 使用CryptoJS.enc.*.stringify将WordArray转换为数据:
CryptoJS.enc.Utf8.stringify(a); // "QWERTYUIOP123456"
CryptoJS.enc.Hex.stringify(b); // "51574552545955494f50313233343536"
CryptoJS.enc.Base64.stringify(c); // "UVdFUlRZVUlPUDEyMzQ1Ng=="
// 也可以对WordArray使用.toString(CryptoJS.enc.*),和CryptoJS.enc.*.stringify是等效的
a.toString(CryptoJS.enc.Utf8);
b.toString(CryptoJS.enc.Hex);
c.toString(CryptoJS.enc.Base64);
除了已有的编码,还可以在 CryptoJS.enc
中加入一个有 stringify
和 parse
两个函数的对象自行扩充。比如下面的一段代码就添加了对 Uint8Array
的支持(原代码出处):
CryptoJS.enc.Uint8 = {
// 将WordArray转换为数据,虽然是stringify但是不一定要求是字符串
stringify: wordArray => {
let words = wordArray.words;
let sigBytes = wordArray.sigBytes;
let u8arr = new Uint8Array(sigBytes);
for (let i = 0; i < sigBytes; i++) {
u8arr[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xFF;
}
return u8arr;
},
// 将数据转换为WordArray
parse: u8arr => {
let len = u8arr.length;
let words = [];
for (let i = 0; i < len; i++) {
words[i >>> 2] |= (u8arr[i] & 0xFF) << (24 - (i % 4) * 8);
}
return CryptoJS.lib.WordArray.create(words, len);
}
};
// "ABCD"
CryptoJS.enc.Uint8.parse(new Uint8Array([0x41, 0x42, 0x43, 0x44])).toString(CryptoJS.enc.Utf8)
调用 CryptoJS.*.encrypt
函数可以进行加密,并钦定加密模式、填充模式和 IV,比如加密明文 ABCD
:
let message = CryptoJS.enc.Utf8.parse('ABCD');
let key = CryptoJS.enc.Utf8.parse('0123456789ABCDEF');
let iv = CryptoJS.enc.Utf8.parse('FEDCBA9876543210');
let cipher = CryptoJS.AES.encrypt(message, key, {
iv: iv, // 如果是ECB模式就不需要钦定IV
mode: CryptoJS.mode.CBC, // 不钦定的话,默认也是这个
padding: CryptoJS.pad.Pkcs7, // 不钦定的话,默认也是这个
});
容易踩坑的一点是上面的 cipher
并不是 WordArray,而是一个包含了加密算法、加密模式、密文、密钥、IV 等信息的对象。对这个 cipher
直接 toString
可以得到 Base64 编码后的数据,但是只有从这个对象的 ciphertext
获取对应的 WordArray 才能使用 CryptoJS.enc.*.stringify
和 .toString(CryptoJS.enc.*)
任意转换为一般的数据,否则就会报错了(´・_・`)
// "1Wl2Kg1/0Vh+fJPX+UsJaA=="
cipher.toString();
cipher.ciphertext.toString(CryptoJS.enc.Base64);
CryptoJS.enc.Base64.stringify(cipher.ciphertext);
// "d569762a0d7fd1587e7c93d7f94b0968"
cipher.ciphertext.toString(CryptoJS.enc.Hex);
CryptoJS.enc.Hex.stringify(cipher.ciphertext);
// 报错
cipher.toString(CryptoJS.enc.Base64);
CryptoJS.enc.Base64.stringify(cipher);
至于解密操作……当然是把 encrypt
改成 decrypt
了,但是和加密一样也有一个坑:
用来解密的是上面提到的“对象”,而不是 WordArray。直接传一个 WordArray 进去是无法正常解密的,然而解密之后得到的又是 WordArray。一开始使用 CryptoJS 的时候总是发现解密得到的东西是空白的,其实就是在这个地方踩了坑┐(´-`)┌
在实际中,收到的密文显然不会是上面的“对象”的形式(如果把加密算法、密钥、IV 之类的也传过来的话……?!),因此就需要模仿上面的 cipher
,用 WordArray 临时创建一个只有 ciphertext
这个属性的“对象”用来解密:
// 使用上面的“对象”解密,可以得到ABCD
CryptoJS.AES.decrypt(cipher, key, {iv: iv}).toString(CryptoJS.enc.Utf8);
// 直接使用WordArray解密,得到的是空字符串
CryptoJS.AES.decrypt(
CryptoJS.enc.Base64.parse('1Wl2Kg1/0Vh+fJPX+UsJaA=='),
key, {iv: iv}
).toString(CryptoJS.enc.Utf8);
// 临时用WordArray创建一个“对象”,也能得到ABCD
CryptoJS.AES.decrypt(
{ciphertext: CryptoJS.enc.Base64.parse('1Wl2Kg1/0Vh+fJPX+UsJaA==')},
key, {iv: iv}
).toString(CryptoJS.enc.Utf8);
嗯……总的来说,使用 CryptoJS 进行加解密是比较麻烦的一件事,而且它的官方文档也只是介绍了一下支持的算法,甚至没有一段演示用的代码(README.md 里面倒是简单地介绍了一下用法)。至于加密和解密时涉及到的那个“对象”和 WordArray 的区别,不自己摸索一下估计就很难弄明白呢……(ฅ́дฅ̀)
另外,用 CryptoJS 计算 Hash 既可以输入字符串也可以输入 WordArray,得到的还是 WordArray:
// "cb08ca4a7bb5f9683c19133a84872ca7"
CryptoJS.MD5('ABCD').toString(CryptoJS.enc.Hex);
CryptoJS.MD5(CryptoJS.enc.Hex.parse('41424344')).toString(CryptoJS.enc.Hex);
OpenSSL
在 PHP 中进行 AES 加密和解密分别用到的是 openssl_encrypt
和 openssl_decrypt
两个函数,参数的顺序也基本相同:
openssl_encrypt(string $data, string $method, string $key, int $options = 0, string $iv = "");
openssl_decrypt(string $data, string $method, string $key, int $options = 0, string $iv = "");
$data
是明文和密文,$method
是加密方式,$key
是密钥。$option
可以统一设定调用上面的函数加密和解密时密文的格式:默认为 0
代表使用 Base64,设为 OPENSSL_RAW_DATA
就代表直接使用字符串。但是明文、密钥和 IV 仍然是固定使用字符串而不是 Base64。
// 使用Base64编码:
echo openssl_encrypt('ABCD', 'AES-128-CBC', '0123456789ABCDEF', 0, 'FEDCBA9876543210'); // "1Wl2Kg1/0Vh+fJPX+UsJaA=="
echo openssl_decrypt('1Wl2Kg1/0Vh+fJPX+UsJaA==', 'AES-128-CBC', '0123456789ABCDEF', 0, 'FEDCBA9876543210'); // "ABCD"
// 不使用Base64编码:
echo bin2hex(openssl_encrypt('ABCD', 'AES-128-CBC', '0123456789ABCDEF', OPENSSL_RAW_DATA, 'FEDCBA9876543210')); // "d569762a0d7fd1587e7c93d7f94b0968"
echo openssl_decrypt(hex2bin('d569762a0d7fd1587e7c93d7f94b0968'), 'AES-128-CBC', '0123456789ABCDEF', OPENSSL_RAW_DATA, 'FEDCBA9876543210'); // "ABCD"
前面提到的 CryptoJS 可以在十六进制、Base64 和字符串之间进行转换,而 PHP 这边除了加密时自带的可选的 Base64 编码,也可以直接使用 bin2hex
/ hex2bin
、base64_encode
/ base64_decode
这些函数实现数据类型的转换,PHP 的字符串本身也是可以用来存储二进制数据的~
phpseclib
composer require phpseclib/phpseclib
OpenSSL 扩展使用的是面向过程的写法,一个函数一个函数地调用,调用一次函数时要把明文 / 密文、密钥、加密方式等参数都写一遍,结果是把一行代码弄得很长,参数含义不清晰,比较难看_(:_」∠)_
phpseclib 采用了面向对象的写法,加密解密前新建一个对象作为“AES 加密 / 解密器”,一次性设定好密钥,就可以直接用来加密 / 解密了∠( ᐛ 」∠)_
phpseclib 统一使用原始数据也就是字符串进行加密和解密,本身不会进行任何的数据格式转换,需要自己手动调用相关函数。
require_once __DIR__ . '/vendor/autoload.php';
use phpseclib\Crypt\AES;
$aes = new AES(AES::MODE_CBC);
$aes->setKey('0123456789ABCDEF');
$aes->setIV('FEDCBA9876543210');
echo base64_encode($aes->encrypt('ABCD')); // "1Wl2Kg1/0Vh+fJPX+UsJaA=="
echo $aes->decrypt(hex2bin('d569762a0d7fd1587e7c93d7f94b0968')); // "ABCD"
↑这种写法舒服多了(〃′▽`)
RSA 非对称加解密
JSEncrypt
JSEncrypt 的使用还是比较简单的,生成一对 RSA 公钥和私钥:
// 钦定使用512位的密钥
let rsa = new JSEncrypt({default_key_size: 512});
// 随机生成公钥和私钥
// 可以填写一个回调函数进行异步操作,不写的话就是同步操作
// 异步操作比同步操作明显慢了不少……
rsa.getKey(() => {
console.log(rsa.getPrivateKey());
console.log(rsa.getPublicKey());
});
生成密钥需要的时间是随着位数增加而指数上升的。生成 512 位密钥一般需要 10-30 ms,1024 位一般需要 100-200 ms,2048 位需要 1000-2000 ms,至于 4096 位……生成一次需要几十秒,浏览器会直接卡死,在前端直接使用的话大概是不行的(っ’-‘)╮
JSEncrypt 也支持自己指定密钥,进行加密和解密操作,密文统一使用 Base64 编码:
// 设定公钥并进行加密
rsa.setPublicKey(`
-----BEGIN PUBLIC KEY-----
MFswDQYJKoZIhvcNAQEBBQADSgAwRwJAfVNVmlMys1Ur/DHm+Hy+5ZiSYbI1JQWj
LjXOWazsdgHA2dhNeb5lyBeoV0jDY4kxbNIqoK8SC0DdqvSbf43uHQIDAQAB
-----END PUBLIC KEY-----
`);
rsa.encrypt('ABCD'); // "YqMFMygirIkg4JHmjH7FUk4AeluSWZjsaoHJlm5flyYQtWPdK+se2BF5WAnxKMS9eckSbijXFwFKDBmwTsirqg=="
// 设定私钥并进行解密
rsa.setPrivateKey(`
-----BEGIN RSA PRIVATE KEY-----
MIIBOAIBAAJAfVNVmlMys1Ur/DHm+Hy+5ZiSYbI1JQWjLjXOWazsdgHA2dhNeb5l
yBeoV0jDY4kxbNIqoK8SC0DdqvSbf43uHQIDAQABAkBh6TZKsE8+/f60NERvw/3c
IDvUqcaNWv8mvhCtzMScWB79WA5tj78VDeu+c89S3HVRzwp1IpdaKXwLUeeGlvMB
AiEAxl99A8cey501bOFGW/4p6xgePDyGMKCnaxERj7Jn2UECIQChu3wu8fdh3VcY
kyVjqu6Q5zAtbdv4N5npX/F7SWYh3QIgb9TRE1ojwEHmJnVxRtcoJoK4ONkeBTfs
ae17avj2loECICdI8jY5Ah0cdZ57h5Z5G6/I6FPwnoDhiJo+etq2jn9lAiBAGRen
ktqwA3k6arwEpjEj89ZqYiTzIFbCngH1oKbp9A==
-----END RSA PRIVATE KEY-----
`);
rsa.decrypt('YqMFMygirIkg4JHmjH7FUk4AeluSWZjsaoHJlm5flyYQtWPdK+se2BF5WAnxKMS9eckSbijXFwFKDBmwTsirqg=='); // "ABCD"
先回想一下 RSA 的基本算法:
- 取素数
和 。 - 计算
, 。 - 取整数
满足 。 - 计算
。 - 私钥是
,公钥是 。 - 加密:
,解密: 。
RSA 算法的保密性取决于将
这种以一行“BEGIN”和“END”的分割线标记开始和结束、正文使用 Base64 编码的 RSA 密钥格式就是 PEM 格式(私钥又分为 PKCS#1 和 PKCS#8 两种标准,分别以 BEGIN RSA PRIVATE KEY
和 BEGIN PRIVATE KEY
开头)。虽然按照上面的算法,私钥涉及到的参数比公钥少,但是 PEM 格式中私钥的长度反而比公钥长了很多,这是因为这里的“私钥”实际上还包含了上面涉及到的几乎所有参数(其中就包括
由于 RSA 加密的使用的填充方案在密文中添加了一些随机数,所以即使是同样的密钥加密同样的明文,得到的密文也会不同。
OpenSSL
和前面的 AES 加密一样,使用 OpenSSL 扩展弄 RSA 加密也是有点麻烦 ┐(´-`)┌
使用 OpenSSL 时,一般也是先生成私钥,再根据私钥提取出公钥。对应到 PHP 这边就是使用 openssl_pkey_new
和 openssl_pkey_export
两个函数:
$res = openssl_pkey_new([
'config' => realpath('./openssl.cnf'),
'private_key_bits' => 512,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($res, $private_key, null, [
'config' => realpath('./openssl.cnf'),
]);
$public_key = openssl_pkey_get_details($res)['key'];
这段代码看上去没什么问题,但是官方文档提示“必须安装有效的 openssl.cnf
以保证此函数正确运行”。为什么生成一个 RSA 密钥还需要额外的配置文件?为什么获取私钥是将私钥写入参数中指定的引用的变量,但是获取公钥(和其他 RSA 算法中涉及到的参数)又使用了返回关联数组的形式?这种写法有点特别啊……
Linux 平台上 OpenSSL 本身就是标配,所以 PHP 可以根据环境变量找到 openssl.cnf
。Windows 版的 PHP 也自带了一个 openssl.cnf
,但是如果不配置环境变量的话就需要手动指定路径了(也就是上面的 config
)。
OpenSSL 的加密和解密中的密文同样是统一使用字符串,有需要的话可以自己另外使用 Base64 编码 / 解码。然而进行 RSA 加密和解密也是使用了“写入参数中指定的引用变量”的形式,和前面的使用返回值的 AES 加密解密也不一样:
// 设定公钥并进行加密
$public_key = <<< KEY
-----BEGIN PUBLIC KEY-----
MFswDQYJKoZIhvcNAQEBBQADSgAwRwJAfVNVmlMys1Ur/DHm+Hy+5ZiSYbI1JQWj
LjXOWazsdgHA2dhNeb5lyBeoV0jDY4kxbNIqoK8SC0DdqvSbf43uHQIDAQAB
-----END PUBLIC KEY-----
KEY;
openssl_public_encrypt('ABCD', $encrypted, $public_key);
echo base64_encode($encrypted); // "YqMFMygirIkg4JHmjH7FUk4AeluSWZjsaoHJlm5flyYQtWPdK+se2BF5WAnxKMS9eckSbijXFwFKDBmwTsirqg=="
// 设定私钥并进行解密
$private_key = <<< KEY
-----BEGIN RSA PRIVATE KEY-----
MIIBOAIBAAJAfVNVmlMys1Ur/DHm+Hy+5ZiSYbI1JQWjLjXOWazsdgHA2dhNeb5l
yBeoV0jDY4kxbNIqoK8SC0DdqvSbf43uHQIDAQABAkBh6TZKsE8+/f60NERvw/3c
IDvUqcaNWv8mvhCtzMScWB79WA5tj78VDeu+c89S3HVRzwp1IpdaKXwLUeeGlvMB
AiEAxl99A8cey501bOFGW/4p6xgePDyGMKCnaxERj7Jn2UECIQChu3wu8fdh3VcY
kyVjqu6Q5zAtbdv4N5npX/F7SWYh3QIgb9TRE1ojwEHmJnVxRtcoJoK4ONkeBTfs
ae17avj2loECICdI8jY5Ah0cdZ57h5Z5G6/I6FPwnoDhiJo+etq2jn9lAiBAGRen
ktqwA3k6arwEpjEj89ZqYiTzIFbCngH1oKbp9A==
-----END RSA PRIVATE KEY-----
KEY;
openssl_private_decrypt(base64_decode('YqMFMygirIkg4JHmjH7FUk4AeluSWZjsaoHJlm5flyYQtWPdK+se2BF5WAnxKMS9eckSbijXFwFKDBmwTsirqg=='), $decrypted, $private_key);
echo $decrypted; // "ABCD"
phpseclib
由于使用了面向对象的写法,使用 phpseclib 的代码仍然是看上去非常舒服,非常清晰……至少不会有“把函数执行的结果写入参数中指定的引用变量”这种特别的写法了|•’-‘•)
生成密钥:
require_once __DIR__ . '/vendor/autoload.php';
use phpseclib\Crypt\RSA;
$rsa = new RSA();
$rsakey = $rsa->createKey(512);
echo $rsakey['privatekey'];
echo $rsakey['publickey'];
加密和解密:
require_once __DIR__ . '/vendor/autoload.php';
use phpseclib\Crypt\RSA;
$rsa = new RSA();
$rsa->setEncryptionMode(RSA::ENCRYPTION_PKCS1);
// 设定公钥并进行加密
$public_key = <<< KEY
-----BEGIN PUBLIC KEY-----
MFswDQYJKoZIhvcNAQEBBQADSgAwRwJAfVNVmlMys1Ur/DHm+Hy+5ZiSYbI1JQWj
LjXOWazsdgHA2dhNeb5lyBeoV0jDY4kxbNIqoK8SC0DdqvSbf43uHQIDAQAB
-----END PUBLIC KEY-----
KEY;
$rsa->loadKey($public_key);
echo base64_encode($rsa->encrypt('ABCD')); // "YqMFMygirIkg4JHmjH7FUk4AeluSWZjsaoHJlm5flyYQtWPdK+se2BF5WAnxKMS9eckSbijXFwFKDBmwTsirqg=="
// 设定私钥并进行解密
$private_key = <<< KEY
-----BEGIN RSA PRIVATE KEY-----
MIIBOAIBAAJAfVNVmlMys1Ur/DHm+Hy+5ZiSYbI1JQWjLjXOWazsdgHA2dhNeb5l
yBeoV0jDY4kxbNIqoK8SC0DdqvSbf43uHQIDAQABAkBh6TZKsE8+/f60NERvw/3c
IDvUqcaNWv8mvhCtzMScWB79WA5tj78VDeu+c89S3HVRzwp1IpdaKXwLUeeGlvMB
AiEAxl99A8cey501bOFGW/4p6xgePDyGMKCnaxERj7Jn2UECIQChu3wu8fdh3VcY
kyVjqu6Q5zAtbdv4N5npX/F7SWYh3QIgb9TRE1ojwEHmJnVxRtcoJoK4ONkeBTfs
ae17avj2loECICdI8jY5Ah0cdZ57h5Z5G6/I6FPwnoDhiJo+etq2jn9lAiBAGRen
ktqwA3k6arwEpjEj89ZqYiTzIFbCngH1oKbp9A==
-----END RSA PRIVATE KEY-----
KEY;
$rsa->loadKey($private_key);
echo $rsa->decrypt(base64_decode('YqMFMygirIkg4JHmjH7FUk4AeluSWZjsaoHJlm5flyYQtWPdK+se2BF5WAnxKMS9eckSbijXFwFKDBmwTsirqg==')); // "ABCD"
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。不允许内容农场类网站、CSDN 用户和微信公众号转载。
本文作者:✨小透明・宸✨
本文链接:https://akarin.dev/2020/02/26/crypto-in-js-and-php/