前言

去年开始负责一个项目,使用用 PHP 语言,Laravel 框架开发,其中登录认证使用了 Laravel 内置的 Encrypter 接口中的 encryptdecrypt 方法,用 Java 重构就一定绕不开这两个关键方法。

一、PHP 运行环境配置

为了可以运行 PHP 代码验证,我们先安装 PHP 运行环境。

  1. 进如 PHP 官网下载 PHP 7.4.27 版本,有线程不安全和线程安全两种版本,只是简单跑一下 PHP 代码用哪种版本都行,这里出于习惯性下载线程安全版,点击 zip[24.96MB] 下载得到一个压缩包,选择合适的地方解压,将 PHP 目录配置到环境变量。
  2. 找到 php.ini-development 文件,拷贝一份并重命名为 php.ini 然后打开编辑。
    • 搜索 date.timezone 删掉前面的分号 ;,并将值设置为 Asia/Shanghai
    • 搜索 extension_dirextension=mysqliextension=opensslextension=pdo_mysql 删掉前面的分号 ;
  3. 打开 cmd 窗口输入 php -v,可以看到 PHP 版本即安装成功。

二、创建 Java 工程

创建 maven 工程并引入如下依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.20</version>
</dependency>
<!-- php 对象序列化与反序列化 -->
<dependency>
<groupId>org.sction</groupId>
<artifactId>phprpc</artifactId>
<version>3.0.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.78</version>
</dependency>

我们先看一下 Encrypter.php 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace Contracts\Encryption;

interface Encrypter
{
/**
* Encrypt the given value.
*
* @param string $value
* @param bool $serialize
* @return string
*/
public function encrypt($value, $serialize = true);

/**
* Decrypt the given value.
*
* @param string $payload
* @param bool $unserialize
* @return string
*/
public function decrypt($payload, $unserialize = true);
}

再看看实现类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
<?php

namespace Encryption;

use Contracts\Encryption\DecryptException;
use Contracts\Encryption\Encrypter as EncrypterContract;
use Contracts\Encryption\EncryptException;
use RuntimeException;

class Encrypter implements EncrypterContract
{
/**
* The encryption key.
*
* @var string
*/
protected $key;

/**
* The algorithm used for encryption.
*
* @var string
*/
protected $cipher;

/**
* Create a new encrypter instance.
*
* @param string $key
* @param string $cipher
* @return void
*
* @throws \RuntimeException
*/
public function __construct($key, $cipher = 'AES-128-CBC')
{
$key = (string)$key;

if (static::supported($key, $cipher)) {
$this->key = $key;
$this->cipher = $cipher;
} else {
throw new RuntimeException('The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths.');
}
}

/**
* Determine if the given key and cipher combination is valid.
*
* @param string $key
* @param string $cipher
* @return bool
*/
public static function supported($key, $cipher)
{
$length = mb_strlen($key, '8bit');

return ($cipher === 'AES-128-CBC' && $length === 16) ||
($cipher === 'AES-256-CBC' && $length === 32);
}

/**
* Create a new encryption key for the given cipher.
*
* @param string $cipher
* @return string
*/
public static function generateKey($cipher)
{
return random_bytes($cipher == 'AES-128-CBC' ? 16 : 32);
}

/**
* Encrypt the given value.
*
* @param mixed $value
* @param bool $serialize
* @return string
*
* @throws \Illuminate\Contracts\Encryption\EncryptException
*/
public function encrypt($value, $serialize = true)
{
$iv = random_bytes(openssl_cipher_iv_length($this->cipher));

// First we will encrypt the value using OpenSSL. After this is encrypted we
// will proceed to calculating a MAC for the encrypted value so that this
// value can be verified later as not having been changed by the users.
$value = \openssl_encrypt(
$serialize ? serialize($value) : $value,
$this->cipher, $this->key, 0, $iv
);

if ($value === false) {
throw new EncryptException('Could not encrypt the data.');
}

// Once we get the encrypted value we'll go ahead and base64_encode the input
// vector and create the MAC for the encrypted value so we can then verify
// its authenticity. Then, we'll JSON the data into the "payload" array.
$mac = $this->hash($iv = base64_encode($iv), $value);

$json = json_encode(compact('iv', 'value', 'mac'));

if (json_last_error() !== JSON_ERROR_NONE) {
throw new EncryptException('Could not encrypt the data.');
}

return base64_encode($json);
}

/**
* Encrypt a string without serialization.
*
* @param string $value
* @return string
*/
public function encryptString($value)
{
return $this->encrypt($value, false);
}

/**
* Decrypt the given value.
*
* @param mixed $payload
* @param bool $unserialize
* @return string
*
* @throws \Illuminate\Contracts\Encryption\DecryptException
*/
public function decrypt($payload, $unserialize = true)
{
$payload = $this->getJsonPayload($payload);

$iv = base64_decode($payload['iv']);

// Here we will decrypt the value. If we are able to successfully decrypt it
// we will then unserialize it and return it out to the caller. If we are
// unable to decrypt this value we will throw out an exception message.
$decrypted = \openssl_decrypt(
$payload['value'], $this->cipher, $this->key, 0, $iv
);

if ($decrypted === false) {
throw new DecryptException('Could not decrypt the data.');
}

return $unserialize ? unserialize($decrypted) : $decrypted;
}

/**
* Decrypt the given string without unserialization.
*
* @param string $payload
* @return string
*/
public function decryptString($payload)
{
return $this->decrypt($payload, false);
}

/**
* Create a MAC for the given value.
*
* @param string $iv
* @param mixed $value
* @return string
*/
protected function hash($iv, $value)
{
return hash_hmac('sha256', $iv . $value, $this->key);
}

/**
* Get the JSON array from the given payload.
*
* @param string $payload
* @return array
*
* @throws \Illuminate\Contracts\Encryption\DecryptException
*/
protected function getJsonPayload($payload)
{
$payload = json_decode(base64_decode($payload), true);

// If the payload is not valid JSON or does not have the proper keys set we will
// assume it is invalid and bail out of the routine since we will not be able
// to decrypt the given value. We'll also check the MAC for this encryption.
if (!$this->validPayload($payload)) {
throw new DecryptException('The payload is invalid.');
}

if (!$this->validMac($payload)) {
throw new DecryptException('The MAC is invalid.');
}

return $payload;
}

/**
* Verify that the encryption payload is valid.
*
* @param mixed $payload
* @return bool
*/
protected function validPayload($payload)
{
return is_array($payload) && isset($payload['iv'], $payload['value'], $payload['mac']) &&
strlen(base64_decode($payload['iv'], true)) === openssl_cipher_iv_length($this->cipher);
}

/**
* Determine if the MAC for the given payload is valid.
*
* @param array $payload
* @return bool
*/
protected function validMac(array $payload)
{
$calculated = $this->calculateMac($payload, $bytes = random_bytes(16));

return hash_equals(
hash_hmac('sha256', $payload['mac'], $bytes, true), $calculated
);
}

/**
* Calculate the hash of the given payload.
*
* @param array $payload
* @param string $bytes
* @return string
*/
protected function calculateMac($payload, $bytes)
{
return hash_hmac(
'sha256', $this->hash($payload['iv'], $payload['value']), $bytes, true
);
}

/**
* Get the encryption key.
*
* @return string
*/
public function getKey()
{
return $this->key;
}
}

java 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
package com.mayee;

import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.crypto.Mode;
import cn.hutool.crypto.Padding;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import cn.hutool.crypto.symmetric.AES;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.phprpc.util.PHPSerializer;

import java.lang.reflect.InvocationTargetException;
import java.util.Objects;

/**
* @program: xgc-shumei
* @description: 重构 php 的 laravel 框架中 Encrypter 类的 encrypt 和 decrypt 方法。
* 该类中默认的加/解密方式为 AES-128-CBC,可通过外部文件 app.php 配置其加密方式和秘钥,这里有外部配置 AES-256-CBC。
* AES-256-CBC 对应秘钥字节长度为 32,偏移量直接长度为 16。
* @author: Bobby.Ma
* @create: 2022-02-10 12:16
**/
@Slf4j
public class Encrypter {

/**
* 秘钥
*/
private final String key = "YqrxNCQvKu5hnHu1qrEb1EzmSagWSzEM12aBASV+kG4=";

/*
php 在外部需要配置加密模式 AES-128-CBC 或 AES-256-CBC,并且只支持这两种模式。
但 java 无需指定模式,会根据秘钥长度来自动判断。
*/


/**
* 加密给定的文本
*
* @param value 原文对象
* @return 密文
*/
public String encrypt(Object value) {
byte[] iv = RandomUtil.randomBytes(16);
byte[] serialize;
PHPSerializer ps = new PHPSerializer();
try {
serialize = ps.serialize(value);
} catch (IllegalAccessException | InvocationTargetException e) {
log.error("Could not serialize the data: {}", e.getMessage());
return null;
}
AES aes = new AES(Mode.CBC, Padding.PKCS5Padding, Base64.decode(key), iv);
String encrypt = aes.encryptBase64(serialize);
String mac = this.hash(Base64.encode(iv), encrypt);
JSONObject json = new JSONObject(true);
json.put("iv", Base64.encode(iv));
json.put("value", encrypt);
json.put("mac", mac);
return Base64.encode(json.toJSONString());
}

/**
* 解密给定的文本
*
* @param payload 密文
* @return 原文
*/
public <T> T decrypt(String payload, Class<T> type) {
JSONObject payloadObj = this.getJsonPayload(payload);
byte[] iv = Base64.decode(payloadObj.getString("iv"));
AES aes = new AES(Mode.CBC, Padding.PKCS5Padding, Base64.decode(key), iv);
byte[] decrypt = aes.decrypt(payloadObj.getString("value"));
PHPSerializer ps = new PHPSerializer();
try {
return (T) ps.unserialize(decrypt, type);
} catch (IllegalAccessException | InvocationTargetException e) {
log.error("Could not unserialize the data: {}", e.getMessage());
}
return null;
}

private JSONObject getJsonPayload(String payload) {
JSONObject payloadObj = JSON.parseObject(Base64.decodeStr(payload));
if (!this.validPayload(payloadObj)) {
throw new RuntimeException("The payload is invalid.");
}
if (!this.validMac(payloadObj)) {
throw new RuntimeException("The MAC is invalid.");
}
return payloadObj;
}

/**
* 验证 payload 是否有效
*
* @param payloadObj
* @return
*/
private boolean validPayload(JSONObject payloadObj) {
if (Objects.nonNull(payloadObj)) {
return payloadObj.containsKey("iv") && payloadObj.containsKey("value") && payloadObj.containsKey("mac") &&
Base64.decode(payloadObj.getString("iv")).length == 16;
}
return false;
}

private boolean validMac(JSONObject payloadObj) {
byte[] bytes = RandomUtil.randomBytes(16);
String calculated = this.calculateMac(payloadObj, bytes);
HMac mac = new HMac(HmacAlgorithm.HmacSHA256, bytes);
return Objects.equals(mac.digestHex(payloadObj.getString("mac")), calculated);
}

private String calculateMac(JSONObject payloadObj, byte[] bytes) {
HMac mac = new HMac(HmacAlgorithm.HmacSHA256, bytes);
return mac.digestHex(this.hash(payloadObj.getString("iv"), payloadObj.getString("value")));
}

private String hash(String iv, String value) {
HMac mac = new HMac(HmacAlgorithm.HmacSHA256, Base64.decode(key));
return mac.digestHex(iv + value);
}
}

三、验证

  • Java 版:
1
2
3
4
5
6
7
8
9
10
11
public static void main(String[] args) {
User origin = new User();
origin.setName("bobby");
origin.setId(1);
origin.setAge(26);
Encrypter encrypter = new Encrypter();
String encrypt = encrypter.encrypt(origin);
System.out.println("密文:" + encrypt);
User user = encrypter.decrypt(encrypt, User.class);
System.out.println("原文:" + JSON.toJSONString(user));
}

输出:

1
2
密文:eyJpdiI6InE0ZG1pSDNNYmQ0VVBvSm5vTzhueGc9PSIsInZhbHVlIjoiZE9VK05sVVdwd2x4Z3kvcmgvOXJlMjBXdC9PdmFLaHNUdVZZMjF5QzFoVUtjS09Ud3lBaWhOZmJhSm11ME9PU2hBd3BMOXBFZk9ZMkUySnNhelVMTFdzVjcrSUM0Z1laQVY5KzFoRkhaczA9IiwibWFjIjoiMjE3NzNlN2IwY2VjNjMxMzBlNmI5MWMyYjhkNjAwZTQxMmExYzM1MDg0OTc5YzcwNGRmZDBmZmUxMTdmMzgxNyJ9
原文:{"age":26,"id":1,"name":"bobby"}
  • PHP 版:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
spl_autoload_register(function ($class_name) {
require_once $class_name . '.php';
});

use Encryption\Encrypter;

$key = 'YqrxNCQvKu5hnHu1qrEb1EzmSagWSzEM12aBASV+kG4=';
$arr = [
'name' => 'bobby',
'id' => 1,
'age' => 26
];
$origin = json_encode($arr);

$cipher = new Encrypter(base64_decode($key), 'AES-256-CBC');
$enc_str = $cipher->encrypt($origin);
echo '密文:' . $enc_str . PHP_EOL;
$dec_str = $cipher->decrypt($enc_str . PHP_EOL);
echo '原文:' . $dec_str . PHP_EOL;

输出:

1
2
密文:eyJpdiI6Ik5jeURsVGVaQnZQSytoMGlWTG16NWc9PSIsInZhbHVlIjoiU3VSRnpGTVNaOGROZzNQM2hQbzNiUys2WjNVU3JOMytQb0xJek1DWjZmXC9nVldDQ05sUmJYd3VOVGVSZEhpNmQiLCJtYWMiOiI0MjE4MTZmNGJkMzdlNmI0MTc1MjZlYmRmNTI3NDY3NzVlYTliNmY2NDNlN2Q5N2FmMWQ0ZGZhZjEyNDVlMjlmIn0=
原文:{"name":"bobby","id":1,"age":26}

注:Java 可以解密 PHP 的密文,但 PHP 解密 Java 的密文会提示文件不存在,这是因为 PHP 在解密后反序列化为对象时找不到文件,如果只是对字符串操作则可以相互加解密。


Tip:本文完整示例代码已上传至 Gitee 。代码中php 包下的文件拷贝置 PhpStorm 中可运行验证。