listOf($user)) >= self::MAX_PER_ACCOUNT) { throw new TooManyAgentTokensException('An account has at most ' . self::MAX_PER_ACCOUNT . ' agent tokens'); } $token = self::PREFIX . bin2hex(random_bytes(32)); $now = $this->now(); $record = [ 'id' => bin2hex(random_bytes(8)), 'username' => $user, 'name' => trim($name), 'createdAt' => $now, 'expiresAt' => $now + max(1, min(self::MAX_DAYS, $days)) * 86400, 'usedAt' => null, ]; $this->write($this->dir . '/' . hash('sha256', $token) . '.json', $record); return self::shown($record) + ['token' => $token]; } /** * The tokens of `$user` that have not expired, oldest first, without the tokens themselves. * * @return list */ public function listOf(string $user): array { $found = []; foreach ($this->records() as $record) { if (strcasecmp($record['username'], $user) === 0) { $found[] = self::shown($record); } } usort($found, static fn(array $a, array $b): int => $a['createdAt'] <=> $b['createdAt']); return $found; } /** Ends one token of `$user`; false when they have none by that id. */ public function revoke(string $user, string $id): bool { foreach ($this->records() as $path => $record) { if ($record['id'] === $id && strcasecmp($record['username'], $user) === 0) { return @unlink($path); } } return false; } /** Ends every token of `$user`, and answers how many there were. */ public function endAllOf(string $user): int { $ended = 0; foreach ($this->records() as $path => $record) { if (strcasecmp($record['username'], $user) === 0 && @unlink($path)) { $ended++; } } return $ended; } /** The account a token belongs to, or null when it is unknown, malformed or expired. */ public function verify(string $token): ?string { if (!preg_match('/^' . self::PREFIX . '[0-9a-f]{64}$/', $token)) { return null; } $path = $this->dir . '/' . hash('sha256', $token) . '.json'; $record = self::read($path); if ($record === null) { return null; } $now = $this->now(); if ($record['expiresAt'] <= $now) { @unlink($path); return null; } if ($record['usedAt'] === null || $now - $record['usedAt'] >= self::USE_RESOLUTION_SECONDS) { $record['usedAt'] = $now; $this->write($path, $record); } return $record['username']; } /** A ticket for `$user`'s tab to get onto the relay with. */ public function issueTicket(string $user): string { $this->sweepTickets(); $ticket = bin2hex(random_bytes(32)); $this->write($this->ticketDir . '/' . hash('sha256', $ticket) . '.json', [ 'username' => $user, 'createdAt' => $this->now(), ]); return $ticket; } /** The account a ticket was issued to, once: a second redemption finds nothing. */ public function redeemTicket(string $ticket): ?string { if (!preg_match('/^[0-9a-f]{64}$/', $ticket)) { return null; } $path = $this->ticketDir . '/' . hash('sha256', $ticket) . '.json'; // Moved aside before it is read: of two redemptions at once, one gets the file. $taken = $path . '.taken-' . bin2hex(random_bytes(4)); if (!@rename($path, $taken)) { return null; } $raw = file_get_contents($taken); @unlink($taken); $data = $raw === false ? null : json_decode($raw, true); if (!is_array($data) || !is_string($data['username'] ?? null) || !is_int($data['createdAt'] ?? null)) { return null; } return $this->now() - $data['createdAt'] <= self::TICKET_SECONDS ? $data['username'] : null; } /** Throws out the tickets nobody redeemed. */ private function sweepTickets(): void { foreach (glob($this->ticketDir . '/*.json*') ?: [] as $path) { if ($this->now() - (int) @filemtime($path) > self::TICKET_SECONDS) { @unlink($path); } } } /** * Every stored token by its path; expired ones are dropped on the way. * * @return array */ private function records(): array { $records = []; foreach (glob($this->dir . '/*.json') ?: [] as $path) { $record = self::read($path); if ($record === null) { continue; } if ($record['expiresAt'] <= $this->now()) { @unlink($path); continue; } $records[$path] = $record; } return $records; } /** @return array{id:string,username:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}|null */ private static function read(string $path): ?array { $raw = is_file($path) ? file_get_contents($path) : false; $data = $raw === false ? null : json_decode($raw, true); if ( !is_array($data) || !is_string($data['id'] ?? null) || !is_string($data['username'] ?? null) || !is_string($data['name'] ?? null) || !is_int($data['createdAt'] ?? null) || !is_int($data['expiresAt'] ?? null) || !(is_int($data['usedAt'] ?? null) || ($data['usedAt'] ?? null) === null) ) { return null; } return $data; } /** @return array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:?int} */ private static function shown(array $record): array { return [ 'id' => $record['id'], 'name' => $record['name'], 'createdAt' => $record['createdAt'], 'expiresAt' => $record['expiresAt'], 'usedAt' => $record['usedAt'], ]; } private function write(string $path, array $data): void { $dir = dirname($path); if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) { throw new RuntimeException("Cannot create {$dir}"); } AtomicFile::write($path, (string) json_encode($data, JSON_UNESCAPED_SLASHES), AtomicFile::HIDDEN, 'agent token'); } private function now(): int { return $this->clock === null ? time() : ($this->clock)(); } } final class TooManyAgentTokensException extends RuntimeException {}