clock = $clock === null ? static fn (): int => time() : \Closure::fromCallable($clock); } public function issue(int $sessionId, string $tokenHash, string $challenge): string { return $this->locked(function () use ($sessionId, $tokenHash, $challenge): string { foreach (array_slice(glob($this->directory . '/*.json') ?: [], 0, 200) as $path) { $old = $this->read($path); if ($old !== null && (int) ($old['expires'] ?? 0) <= ($this->clock)()) { unlink($path); } } $ticket = bin2hex(random_bytes(32)); $record = ['session_id' => $sessionId, 'token_hash' => $tokenHash, 'challenge' => $challenge, 'expires' => ($this->clock)() + 30]; $path = $this->path($ticket); $file = fopen($path, 'x+b'); if ($file === false) { throw new RuntimeException('Hospital handoff storage unavailable'); } try { chmod($path, 0600); $json = json_encode($record, JSON_THROW_ON_ERROR); if (fwrite($file, $json) !== strlen($json) || !fflush($file)) { throw new RuntimeException('Hospital handoff storage unavailable'); } } catch (\Throwable $error) { fclose($file); unlink($path); throw $error; } fclose($file); return $ticket; }); } public function consume(string $ticket, string $verifier): array { if (!preg_match('/^[a-f0-9]{64}$/D', $ticket) || !preg_match('/^[A-Za-z0-9._~-]{43,128}$/D', $verifier)) { throw new RuntimeException('Hospital handoff invalid'); } return $this->locked(function () use ($ticket, $verifier): array { $path = $this->path($ticket); $record = $this->read($path); $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); if ($record === null || !is_int($record['expires'] ?? null) || $record['expires'] <= ($this->clock)() || !is_int($record['session_id'] ?? null) || $record['session_id'] < 1 || !is_string($record['token_hash'] ?? null) || !preg_match('/^[a-f0-9]{64}$/D', $record['token_hash']) || !is_string($record['challenge'] ?? null) || !hash_equals($record['challenge'], $challenge)) { throw new RuntimeException('Hospital handoff invalid'); } // The global private lock covers open/read/delete, so simultaneous exchanges cannot replay. if (!unlink($path)) { throw new RuntimeException('Hospital handoff consumption failed'); } return $record; }); } private function read(string $path): ?array { if (!is_file($path) || is_link($path) || filesize($path) > 4096) { return null; } try { $record = json_decode((string) file_get_contents($path), true, 8, JSON_THROW_ON_ERROR); } catch (\JsonException $error) { return null; } return is_array($record) ? $record : null; } private function path(string $ticket): string { return $this->directory . '/' . hash('sha256', $ticket) . '.json'; } private function locked(callable $operation): mixed { if (is_link($this->directory) || (!is_dir($this->directory) && !mkdir($this->directory, 0700, true) && !is_dir($this->directory))) { throw new RuntimeException('Hospital handoff storage unavailable'); } chmod($this->directory, 0700); $path = $this->directory . '/.lock'; if (is_link($path)) { throw new RuntimeException('Hospital handoff storage unavailable'); } $lock = fopen($path, 'c+b'); if ($lock === false) { throw new RuntimeException('Hospital handoff storage unavailable'); } chmod($path, 0600); try { if (!flock($lock, LOCK_EX)) { throw new RuntimeException('Hospital handoff storage unavailable'); } return $operation(); } finally { flock($lock, LOCK_UN); fclose($lock); } } }