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); } }