3

在 Javascript 和 PHP 中进行加密和解密

 2 years ago
source link: https://akarin.dev/2020/02/26/crypto-in-js-and-php/
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.
neoserver,ios ssh client
在 Javascript 和 PHP 中进行加密和解密
✨小透明・宸✨
存在感消失的地方|ω•`)
brightness_4 深色模式 keyboard_arrow_down
根据系统主题切换
insert_chart 访问统计 keyboard_arrow_down
站点访问量 51125
站点访客数 26479
页面访问量 218
在 Javascript 和 PHP 中进行加密和解密
✨小透明・宸✨
2020-02-26 22:09:56

封面图:Pixiv ID: 77846017 「京あか」 by ネリヲ

因为上个学期的课程里有学习到了一点入门的密码学(逃 所以就顺便想尝试一下在编程中进行加密解密相关的操作~ノ・・ノ∗・ω・ノ

在开始之前,先简单地过一遍相关的一些概念:

  • 明文:没有加密的数据
  • 密文:加密后的数据
  • 对称加密:使用同一个密钥(如果是除 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 数值组成的数组和一个标志位来代表原始的二进制数据:

Hbab311ab1f324d76a1d1754e72667b3e2.png

(由于字节序不同,虽然也可以使用 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 中加入一个有 stringifyparse 两个函数的对象自行扩充。比如下面的一段代码就添加了对 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.*) 任意转换为一般的数据,否则就会报错了・・´・・‘

H799a3635f4a6488f903ed8d099ae48aaZ.png
// "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_encryptopenssl_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 / hex2binbase64_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 的基本算法:

  1. 取素数 p 和 q。
  2. 计算 n=pq,ϕ(n)=(n−1)(q−1)。
  3. 取整数 e 满足 gcd(e,ϕ(n))=1。
  4. 计算 d=e−1modϕ(n)。
  5. 私钥是 d,公钥是 (e,n)。
  6. 加密:c=memodn,解密:m=cdmodn。

RSA 算法的保密性取决于将 n 分解成 p 和 q 的难度,不过根据维基百科上的记录,在 2019 年已经有了 795 位的数被分解的记录,目前的主流是使用至少 1024 位的密钥,不过这里作为测试就还是使用 512 位的密钥了~

这种以一行“BEGIN”和“END”的分割线标记开始和结束、正文使用 Base64 编码的 RSA 密钥格式就是 PEM 格式(私钥又分为 PKCS#1 和 PKCS#8 两种标准,分别以 BEGIN RSA PRIVATE KEYBEGIN PRIVATE KEY 开头)。虽然按照上面的算法,私钥涉及到的参数比公钥少,但是 PEM 格式中私钥的长度反而比公钥长了很多,这是因为这里的“私钥”实际上还包含了上面涉及到的几乎所有参数(其中就包括 e 和 n),因此完全可以从私钥中直接“提取”出公钥,设定了私钥则既可以加密也可以解密,但设定了公钥就只能进行加密。

由于 RSA 加密的使用的填充方案在密文中添加了一些随机数,所以即使是同样的密钥加密同样的明文,得到的密文也会不同。

OpenSSL

和前面的 AES 加密一样,使用 OpenSSL 扩展弄 RSA 加密也是有点麻烦 ┐`´−`┌

使用 OpenSSL 时,一般也是先生成私钥,再根据私钥提取出公钥。对应到 PHP 这边就是使用 openssl_pkey_newopenssl_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/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK