Files
2026-09-10 15:19:17 +08:00

92 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service\prescriptionai;
use RuntimeException;
/** Authenticated at-rest encryption; key is never stored in the database. */
final class PrescriptionAiCipher
{
private ?string $secret;
public function __construct(?string $secret = null)
{
$this->secret = $secret;
}
public function encrypt(array $value, string $purpose): string
{
$iv = random_bytes(12);
$tag = '';
$cipher = openssl_encrypt(PrescriptionAiPolicy::canonical($value), 'aes-256-gcm', $this->key(),
OPENSSL_RAW_DATA, $iv, $tag, $purpose);
if ($cipher === false) {
throw new RuntimeException('AI_ANALYSIS_ENCRYPTION_FAILED');
}
return 'v1:' . base64_encode($iv . $tag . $cipher);
}
public function decrypt(string $value, string $purpose): array
{
$bytes = str_starts_with($value, 'v1:') ? base64_decode(substr($value, 3), true) : false;
if ($bytes === false || strlen($bytes) < 30) {
throw new RuntimeException('AI_ANALYSIS_CIPHER_INVALID');
}
$plain = openssl_decrypt(substr($bytes, 28), 'aes-256-gcm', $this->key(), OPENSSL_RAW_DATA,
substr($bytes, 0, 12), substr($bytes, 12, 16), $purpose);
if ($plain === false) {
throw new RuntimeException('AI_ANALYSIS_CIPHER_INVALID');
}
$decoded = json_decode($plain, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
throw new RuntimeException('AI_ANALYSIS_CIPHER_INVALID');
}
return $decoded;
}
private function key(): string
{
if ($this->secret === null) {
$this->secret = (string) config('prescription_analysis.encryption_key', '');
if ($this->secret === '') {
$dir = root_path('runtime') . 'prescription_ai_private';
if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new RuntimeException('AI_ANALYSIS_KEY_UNAVAILABLE');
}
$path = $dir . DIRECTORY_SEPARATOR . 'snapshot.key';
$stream = @fopen($path, 'c+b');
if ($stream === false) {
throw new RuntimeException('AI_ANALYSIS_KEY_UNAVAILABLE');
}
try {
if (!flock($stream, LOCK_EX)) {
throw new RuntimeException('AI_ANALYSIS_KEY_UNAVAILABLE');
}
@chmod($path, 0600);
$key = trim((string) stream_get_contents($stream));
if ($key === '') {
$key = bin2hex(random_bytes(32));
rewind($stream);
if (fwrite($stream, $key) !== strlen($key) || !fflush($stream)) {
throw new RuntimeException('AI_ANALYSIS_KEY_UNAVAILABLE');
}
}
if (!preg_match('/^[a-f0-9]{64}$/', $key)) {
throw new RuntimeException('AI_ANALYSIS_KEY_INVALID');
}
$this->secret = $key;
} finally {
flock($stream, LOCK_UN);
fclose($stream);
}
}
}
if (strlen($this->secret) < 32) {
throw new RuntimeException('AI_ANALYSIS_KEY_INVALID');
}
return hash('sha256', $this->secret, true);
}
}