92 lines
3.4 KiB
PHP
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);
|
|
}
|
|
}
|