## Summary
API key foundation for the Truck Wash API: data model, key generation,
and scope system.
Three atomic commits, one PR:
- **TRU-143** — `api_keys` table + repository wrapper
- **TRU-144** — Key generation + argon2id hashing
- **TRU-145** — Scope registry with role bindings
## Linear
- TRU-143 — [Backend] API key data model + storage schema
- TRU-144 — [Backend] Key generation + argon2id hashing
- TRU-145 — [Backend] Scope system + role bindings
## Files
**Production code:**
- `services/nginx/app/classes/api_key_schema_bootstrap.php` — idempotent
`CREATE TABLE IF NOT EXISTS` for `api_keys`
- `services/nginx/app/classes/api_key_repository.php` — `create`,
`findActiveByKeyId`, `findById`, `revoke`, `delete`, `listForCustomer`,
`touchLastUsed`
- `services/nginx/app/classes/api_key_generator.php` — `generateKeyId`,
`generateSecret`, `formatKey`, `hash` (argon2id), `verify`, `parseKey`
- `services/nginx/app/classes/auth/scope_registry.php` — canonical scope
constants + role defaults + `hasScope` / `expand` / `matches` /
`isValid`
-
`services/nginx/app/database/migrations/2026_08_17_000001_create_api_keys_table.php`
— human-readable migration record
**Tests (all new, all passing):**
- `tests/Unit/Auth/ApiKeyGeneratorTest.php` — 15 tests
- `tests/Unit/Auth/ApiKeyRepositoryTest.php` — 11 tests
- `tests/Unit/Auth/ScopeRegistryTest.php` — 24 tests
**Totals:** 50 new tests, 140 assertions. Full unit suite: 1329 passed
(up from 1279 baseline), 0 new failures (10 pre-existing unrelated
failures remain).
## Schema
```sql
CREATE TABLE api_keys (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
key_id VARCHAR(64) NOT NULL,
key_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role VARCHAR(32) NOT NULL,
scopes JSON NULL,
customer_id BIGINT UNSIGNED NULL,
created_by BIGINT UNSIGNED NULL,
last_used_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
revoked_at TIMESTAMP NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_api_keys_key_id (key_id),
INDEX idx_api_keys_customer (customer_id),
INDEX idx_api_keys_key_hash (key_hash),
INDEX idx_api_keys_revoked (revoked_at),
INDEX idx_api_keys_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
```
## Key format
```
<prefix>_<env>_<22-char-base62>.<32-char-base62-secret>
e.g. truck_live_aBcD1234XyZ5678mnOpQrSt.uVwXyZ0123456789aBcDeFgHiJkLmN
```
- `key_id` is stored in plain text (the lookup key).
- The secret is **never** stored in plain text — only the argon2id hash.
- The full key is shown to the user exactly once at creation.
- Prefix is configurable via `config('api_key.prefix', 'truck')`.
## Role → scope defaults
| Role | Scopes |
|---|---|
| superuser | `*` |
| admin | `customer:*`, `booking:*`, `subuser:*`, `invoice:*` |
| customer | `customer:read`, `booking:read`, `invoice:read` |
| subuser | `booking:read`, `booking:write` |
Wildcard support: `*` matches everything; `resource:*` matches every
action on a resource.
## Design notes
- **No migration framework** — this codebase uses
`*_schema_bootstrap.php` files for idempotent table creation. The
`database/migrations/` file is kept as a human-readable change record.
- **No Eloquent** — the repository is a thin wrapper over the existing
mysqli `$db` global, matching the pattern in `classes/orders_o.php`,
`classes/invoice_store.php`, etc.
- **Coexistence with TRU-149** — `classes/auth/scope_registry.php`
(TRU-145) is the canonical implementation; the local `app\auth\Scope`
stub from `feat/TRU-149-route-scopes` is documented in the class header
as the thing this will replace once that branch merges. The two can
coexist in the meantime.
- **"Self" / "assigned" qualifiers** (e.g. "customer can only read their
own bookings") are intentionally **out of scope** here — they live in
the resolver layer that maps an authenticated principal to a
customer/subuser record. Scopes only encode "can the caller read
bookings at all".
- **No secrets in code** — the generator uses `random_bytes()` with
rejection sampling (no modulo bias). No test fixtures contain real keys.
## Checklist
- [x] All new tests pass (50/50)
- [x] No new test failures in the full unit suite
- [x] No PHP syntax errors
- [x] No committed secrets
- [x] No modifications to existing test files
- [x] No modifications to `openclaw.json` or any config files
---------
Co-authored-by: TRU-198 Subagent <subagent@openhands.dev>
228 lines
7.0 KiB
PHP
228 lines
7.0 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
/**
|
|
* Static utility for generating, formatting, hashing, and parsing
|
|
* API keys.
|
|
*
|
|
* Key format: <prefix>_<env>_<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: <prefix>_<env>_<random>.
|
|
*/
|
|
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 "<prefix>_<env>_<random>" — 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;
|
|
}
|
|
}
|