本文目录导读:

这是一个涉及安全与用户体验平衡的复杂问题,在 PHP 中实现“用户自定义解密”通常有两种截然不同的理解:
- 【低风险】 用户提供解密密钥,系统使用该密钥解密 系统已知算法 加密的数据(如用户自己的私有文件)。
- 【高风险】 用户提供 自定义的算法逻辑(如自定义的加解密函数),系统执行该逻辑。
绝大多数合理的业务需求属于第一种,第二种极易导致远程代码执行(RCE)漏洞,几乎不应在公开产品中实现。
下面分别讨论这两种实现方式及其安全实践。
用户提供密钥,解密系统已知算法的数据(推荐)
这是最常用且相对安全的模式,用户加密了自己的笔记,系统存储密文,用户查看时必须输入密码来解密。
安全核心原则
- 绝不使用用户输入的密码直接作为加解密密钥。 必须通过密钥派生函数(KDF) 处理。
- 绝不使用
MCRYPT(已废弃)。 使用openssl或sodium扩展。 - 强制使用 AEAD 模式(如 GCM 或 ChaCha20-Poly1305),确保数据完整性和真实性。
- 使用安全的随机数生成器(CSPRNG) 生成 IV 和盐。
代码示例(使用 Sodium - 推荐,PHP 7.2+ 内置)
<?php
/**
* 用户自定义解密(用户提供密码)
* 使用 libsodium 进行安全加解密
*/
// 1. 加密阶段(通常在用户保存数据时执行)
function encryptUserData(string $plaintext, string $userPassword): array
{
// 生成一个随机盐(用于密码派生,防止彩虹表攻击)
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
// 使用 Argon2id 算法(当前最先进的 KDF)将用户密码派生成 256-bit 密钥
$key = sodium_crypto_pwhash(
SODIUM_CRYPTO_SECRETBOX_KEYBYTES, // 密钥长度(32字节)
$userPassword,
$salt,
SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, // 运算次数(交互式场景)
SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE // 内存限制
);
// 生成随机 nonce(相当于 IV,12 字节)
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
// 加密(使用 XSalsa20-Poly1305,自带身份验证)
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);
// 返回密文、nonce 和盐(盐和 nonce 不需要保密,但必须保存以用于解密)
return [
'ciphertext' => base64_encode($nonce . $ciphertext), // 合并 & base64 编码
'salt' => base64_encode($salt),
];
}
// 2. 解密阶段(用户请求解密时)
function decryptUserData(string $encodedData, string $saltBase64, string $userPassword): ?string
{
// 解码
$salt = base64_decode($saltBase64);
$decoded = base64_decode($encodedData);
// 提取 nonce(前 12 字节)和密文(剩余部分)
$nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit');
$ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit');
// 使用相同的 KDF 参数重建密钥
$key = sodium_crypto_pwhash(
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
$userPassword,
$salt,
SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE,
SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE
);
// 解密(如果密码错误或数据被篡改,返回 false)
$plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
if ($plaintext === false) {
return null; // 解密失败(密码错误或数据损坏)
}
return $plaintext;
}
// --- 使用示例 ---
$userPassword = 'MySecurePassword123!';
$originalData = 'Hello, this is sensitive user data.';
// 加密
$encrypted = encryptUserData($originalData, $userPassword);
// 模拟用户输入密码进行解密
$decrypted = decryptUserData($encrypted['ciphertext'], $encrypted['salt'], $userPassword);
echo $decrypted; // 输出:Hello, this is sensitive user data.
// 如果尝试错误密码
$wrongPassword = 'WrongPassword!';
$failed = decryptUserData($encrypted['ciphertext'], $encrypted['salt'], $wrongPassword);
var_dump($failed); // 输出:NULL
数据库存储结构
| 字段名 | 类型 | 说明 |
|---|---|---|
user_id |
INT | 用户 ID |
data_id |
INT | 数据 ID |
encrypted_data |
TEXT | Base64 编码的 {nonce + ciphertext} |
salt |
VARCHAR(64) | Base64 编码的盐 |
encryption_version |
VARCHAR(10) | 'sodium_v1',便于未来升级算法 |
用户提供自定义算法逻辑(极度危险,通常应禁止)
这种方式允许用户上传 PHP 代码片段,系统调用 eval() 或 create_function() 来执行业务逻辑。这是最严重的远程代码执行(RCE)漏洞之一。
为什么几乎不应该这样做?
- 无法防御:即使对代码进行词法分析或沙盒,高级攻击者总能找到绕过方式(如利用
getenv()、file_get_contents()、extract()等函数)。 - 合规问题:任何安全审计都无法通过。
- 性能与稳定性:用户可以执行死循环或消耗大量内存。
如果你一定要做(仅限内部工具、CTF 或高度受控环境)
你需要构建一个无函数白名单沙盒,但这极其困难且不完美,一个相对好一些(但仍然危险)的简化方案如下:
<?php
// 极其危险的示例!仅用于理解原理,不推荐用于生产环境
function customDecrypt(string $ciphertext, string $userAlgorithmCode, string $userKey): ?string
{
// WARNING: 下面是高危操作
// 1. 可以尝试禁用危险函数(但防御力很弱)
$dangerousFunctions = ['exec', 'system', 'shell_exec', 'passthru', 'eval', 'assert', 'file_put_contents', 'unlink', 'dl'];
foreach ($dangerousFunctions as $func) {
if (stripos($userAlgorithmCode, $func) !== false) {
throw new Exception("Security violation: forbidden function detected.");
}
}
// 2. 创建匿名函数(本质还是 eval)
$decryptFunction = null;
try {
// 注意:create_function 在 PHP 8.0+ 已废弃,这里仅为演示
// 更危险的正规方式:$decryptFunction = eval('return function($data, $key) { ' . $userAlgorithmCode . ' };');
$decryptFunction = eval('return function($data, $key) { ' . $userAlgorithmCode . ' };');
} catch (\Throwable $e) {
throw new Exception("Failed to compile user algorithm.");
}
// 3. 执行用户提供的代码
return $decryptFunction($ciphertext, $userKey);
}
// 用户提供的恶意代码(极其危险)
$evilCode = '
// 可以执行任意系统命令
$output = shell_exec("cat /etc/passwd");
// ... 或者只是简单解密
return openssl_decrypt($data, "aes-256-cbc", $key, 0, "1234567890123456");
';
// 调用
// $result = customDecrypt("some_ciphertext", $evilCode, "some_key");
// 如果调用上述代码,系统将被完全控制
?>
替代方案(如果必须使用自定义算法):
- 算法选择器 + 预制参数:用户只能从
['AES-256-GCM', 'ChaCha20-Poly1305']中选择,并且可以输入自己的 Key 和 IV。 - 外部脚本引擎:通过
proc_open()调用独立的二进制程序(如 Python 脚本或 OpenSSL 命令行),并严格限制其参数和权限,但这也相当复杂且存在风险。
总结与最佳实践
| 功能 | 实现路径 | 安全性 | 复杂度 | 建议 |
|---|---|---|---|---|
| 用户提供密码 | KDF(Argon2)+ Sodium | 非常高(如果正确实现) | 中等 | 强烈推荐 |
| 用户选择算法 | 白名单算法列表 + 用户参数 | 中等(需防止参数篡改) | 低 | 可以考虑 |
| 用户提供代码 | eval() / 沙盒 |
极低(高风险) | 极高 | 绝对不要 |
关键行动点:
- 数据库不存密钥:只存储盐、nonce 和密文。
- 使用现代密码学库:用
libsodium(sodium_*函数)代替 OpenSSL(AES 模式复杂且容易出错)。 - 防暴力破解:对密码尝试进行频率限制(Rate Limiting)。
- 前端做混淆:如果必须让用户输入自定义算法(例如一种密码玩具),在前端(JavaScript)实现并仅传递结果到 PHP,PHP 不执行任何用户自定义逻辑。