Files
nl-admin-api/app/Core/DatabaseEncryptor.php
2026-08-10 15:51:00 +08:00

129 lines
4.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Core;
use Exception;
use RuntimeException;
/**
* 库内敏感字段 AES-256-CBC 加解密
* 密文格式nl_ase_256_ + base64(iv + ciphertext)
* 密钥派生hash('sha256', ENCRYPT_KEY, true) → 32 字节
* 读取时兼容历史前缀 ***(迁移期)
*/
class DatabaseEncryptor
{
/** 新密文前缀(入库统一用这个) */
public const PREFIX = 'nl_ase_256_';
/** 历史密文前缀(仅解密兼容) */
public const LEGACY_PREFIX = '***';
private string $cipherMethod = 'AES-256-CBC';
private string $secretKey;
private int $ivLength;
/**
* @param string $key 来自 config('nl.encrypt_key') / env ENCRYPT_KEY
*/
public function __construct(string $key)
{
$key = trim($key);
if ($key === '') {
throw new RuntimeException('AES 加密密钥未配置(请设置 ENCRYPT_KEY');
}
$this->secretKey = hash('sha256', $key, true);
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
if ($ivLength === false) {
throw new RuntimeException('不支持的加密算法:' . $this->cipherMethod);
}
$this->ivLength = $ivLength;
}
/**
* 加密后入库;空串不加密,已是密文则原样返回避免二次加密
*
* @param string|null $data 明文
* @throws Exception
*/
public function encrypt($data): string
{
if ($data === null || $data === '') {
return '';
}
$data = (string) $data;
if (self::isEncrypted($data)) {
return $data;
}
$iv = openssl_random_pseudo_bytes($this->ivLength);
if ($iv === false) {
throw new RuntimeException('生成 IV 失败');
}
$encryptedData = openssl_encrypt($data, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
if ($encryptedData === false) {
throw new RuntimeException('加密失败:' . (openssl_error_string() ?: 'unknown'));
}
return self::PREFIX . base64_encode($iv . $encryptedData);
}
/**
* 从库内密文解密;无已知前缀视为明文原样返回
*
* @param string|null $base64EncodedEncryptedData 库内值
* @throws Exception
*/
public function decrypt($base64EncodedEncryptedData): string
{
if ($base64EncodedEncryptedData === null || $base64EncodedEncryptedData === '') {
return '';
}
$base64EncodedEncryptedData = (string) $base64EncodedEncryptedData;
$prefix = self::detectPrefix($base64EncodedEncryptedData);
if ($prefix === null) {
return $base64EncodedEncryptedData;
}
$payload = substr($base64EncodedEncryptedData, strlen($prefix));
$decodedData = base64_decode($payload, true);
if ($decodedData === false) {
throw new RuntimeException('Base64 解码失败');
}
if (strlen($decodedData) <= $this->ivLength) {
throw new RuntimeException('密文长度非法');
}
$iv = substr($decodedData, 0, $this->ivLength);
$encryptedData = substr($decodedData, $this->ivLength);
$decryptedData = openssl_decrypt($encryptedData, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
if ($decryptedData === false) {
throw new RuntimeException('解密失败:' . (openssl_error_string() ?: 'unknown'));
}
return $decryptedData;
}
/**
* 是否已是库内密文(含历史前缀)
*/
public static function isEncrypted(?string $value): bool
{
return self::detectPrefix($value) !== null;
}
/**
* 识别密文前缀;新前缀优先
*/
public static function detectPrefix(?string $value): ?string
{
if (!is_string($value) || $value === '') {
return null;
}
if (str_starts_with($value, self::PREFIX)) {
return self::PREFIX;
}
if (str_starts_with($value, self::LEGACY_PREFIX)) {
return self::LEGACY_PREFIX;
}
return null;
}
}