__<22-char-base62>.<32-char-base62-secret> * e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN * * The key_id (everything before the dot) is stored in plain text in * the database as the lookup key. The secret is NEVER stored in plain * text — only the argon2id hash is persisted. The full key is shown * to the user exactly once at creation time. */ class api_key_generator { /** Base62 alphabet (0-9, A-Z, a-z). Avoids + / = of base64. */ public const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; /** Characters permitted in the public key_id portion. */ public const KEY_ID_RANDOM_LENGTH = 22; /** Characters in the secret portion. */ public const SECRET_LENGTH = 32; /** * Build the public key_id portion: __. */ public static function generateKeyId(string $env = 'live'): string { $env = self::normaliseEnv($env); $prefix = self::prefix(); $random = self::randomBase62(self::KEY_ID_RANDOM_LENGTH); return $prefix . '_' . $env . '_' . $random; } /** * Generate the secret portion (32-char base62). */ public static function generateSecret(): string { return self::randomBase62(self::SECRET_LENGTH); } /** * Join key_id and secret with a single dot. */ public static function formatKey(string $keyId, string $secret): string { if ($keyId === '' || strpos($keyId, '.') !== false) { throw new \InvalidArgumentException('key_id must not contain a dot'); } if ($secret === '' || strpos($secret, '.') !== false) { throw new \InvalidArgumentException('secret must not contain a dot'); } return $keyId . '.' . $secret; } /** * Hash the full key (or just the secret) using argon2id. */ public static function hash(string $plain): string { if ($plain === '') { throw new \InvalidArgumentException('Cannot hash an empty value'); } $hash = password_hash($plain, PASSWORD_ARGON2ID); if ($hash === false) { throw new \RuntimeException('Failed to hash with argon2id'); } return $hash; } /** * Verify a plaintext key against a stored argon2id hash. */ public static function verify(string $plain, string $hash): bool { if ($plain === '' || $hash === '') { return false; } try { return password_verify($plain, $hash); } catch (\Throwable) { return false; } } /** * Split a full "key_id.secret" string back into its parts. * * The key_id may contain underscores (as separators between * prefix/env/random) and must be base62 + underscores. The * secret must be strictly base62 with no separators. * * @return array{key_id:string, secret:string}|null * null if the input is malformed. */ public static function parseKey(string $full): ?array { $full = trim($full); if ($full === '' || strpos($full, '.') === false) { return null; } // Split on the FIRST dot only — secrets are base62 and contain // no dots, so there's exactly one separator. $parts = explode('.', $full, 2); if (count($parts) !== 2) { return null; } [$keyId, $secret] = $parts; $keyId = trim($keyId); $secret = trim($secret); if ($keyId === '' || $secret === '') { return null; } // The key_id is "__" — base62 with // underscore separators. The secret is pure base62. if (!self::isKeyId($keyId) || !self::isBase62($secret)) { return null; } return ['key_id' => $keyId, 'secret' => $secret]; } /** * Validate a key_id string: base62 with optional underscore * separators. Exposed for testing. */ public static function isKeyId(string $value): bool { if ($value === '') { return false; } return preg_match('/^[0-9A-Za-z_]+$/', $value) === 1; } /** * Configurable prefix (default: "truck"). Reads from * `config('api_key.prefix', 'truck')` if available, otherwise the * default. Always lowercased and stripped of separators. */ public static function prefix(): string { $default = 'truck'; $value = $default; if (function_exists('config')) { try { $candidate = config('api_key.prefix', $default); if (is_string($candidate) && $candidate !== '') { $value = $candidate; } } catch (\Throwable) { $value = $default; } } $value = strtolower(trim((string)$value)); $value = preg_replace('/[^a-z0-9_]/', '', $value) ?? ''; if ($value === '') { $value = $default; } return $value; } /** * @internal — exposed for testing. */ public static function randomBase62(int $length): string { if ($length < 1) { throw new \InvalidArgumentException('Length must be positive'); } $alphabet = self::ALPHABET; $alphabetMax = strlen($alphabet) - 1; // 61 $out = ''; $bytesNeeded = (int)ceil($length * 1.3) + 8; $bytes = random_bytes($bytesNeeded); $byteIndex = 0; while (strlen($out) < $length) { if (!isset($bytes[$byteIndex])) { $bytes = random_bytes($bytesNeeded); $byteIndex = 0; } // Mask off 0xC0 to get a value 0-63, then reject > 61 to // avoid modulo bias. $byte = ord($bytes[$byteIndex]); $byteIndex++; $value = $byte & 0x3F; if ($value > $alphabetMax) { continue; } $out .= $alphabet[$value]; } return $out; } /** * @internal — exposed for testing. */ public static function isBase62(string $value): bool { if ($value === '') { return false; } return preg_match('/^[0-9A-Za-z]+$/', $value) === 1; } private static function normaliseEnv(string $env): string { $trimmed = strtolower(trim($env)); $sanitised = preg_replace('/[^a-z0-9_-]/', '', $trimmed) ?? ''; // If the input contained characters outside the allowed // set, the sanitised result will differ from the trimmed // input — in that case fall back to "live" rather than // echoing a mangled version. Empty / whitespace-only input // also falls back to "live". if ($sanitised === '' || $sanitised !== $trimmed) { return 'live'; } return $sanitised; } }