a present signature is verified and a bad * one is rejected, but a missing signature is * allowed through. Lets older app builds keep * working while the new build rolls out. * HMAC_ENFORCE=true -> a signature is mandatory. */ declare(strict_types=1); namespace App\Middleware; use App\Core\Database; use App\Core\Encryption; final class HmacMiddleware { /** * @param array $decoded The decoded JWT payload from AuthMiddleware::check(). * @param int $maxAgeSeconds Replay window (default: 5 minutes). */ public static function verify(array $decoded, int $maxAgeSeconds = 300): void { $enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true'; $headers = getallheaders(); $signature = $headers['X-Signature'] ?? $headers['x-signature'] ?? $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? ''; $timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? ''; // 1. Missing headers: hard fail only when enforcing. if ($signature === '' || $timestamp === '') { if ($enforce) { json_error('Missing request signature', 401); } return; } // 2. Validate timestamp format. if (!ctype_digit((string)$timestamp)) { json_error('Invalid timestamp format', 401); } // 3. Replay prevention. The client sends milliseconds; older callers may // send seconds, so normalise by magnitude rather than guessing. $ts = (int)$timestamp; $tsSeconds = $ts > 100000000000 ? intdiv($ts, 1000) : $ts; if (abs(time() - $tsSeconds) > $maxAgeSeconds) { json_error('Request expired. Check your device clock.', 401); } // 4. Look up this device's secret. $deviceId = $decoded['device_id'] ?? null; $userId = $decoded['user_id'] ?? null; if (!$deviceId || !$userId) { if ($enforce) { json_error('Signed requests require a registered device', 401); } return; } $secret = self::deviceSecret((string)$userId, (string)$deviceId); if ($secret === null) { if ($enforce) { json_error('Unknown device. Please sign in again.', 401); } return; } // 5. Rebuild the signing payload exactly as the client does. $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); $body = file_get_contents('php://input'); // index.php accepts both clean URLs (/api/v1/batches/create) and the // ?route=v1/batches/create form, which produce different REQUEST_URIs for // the same endpoint. Accept either so the signature does not depend on // how the deployment happens to rewrite URLs. $paths = array_unique(array_filter([ self::requestPath(), self::normalisePath((string)($_GET['route'] ?? '')), ])); $candidates = []; foreach ($paths as $path) { if ($body !== '' && $body !== false) { $candidates[] = "{$method}:{$path}:{$timestamp}:{$body}"; } // GET/multipart requests sign without a body. $candidates[] = "{$method}:{$path}:{$timestamp}"; } foreach ($candidates as $payload) { $expected = hash_hmac('sha256', $payload, $secret); if (hash_equals($expected, strtolower($signature))) { return; // Verified. } } error_log('HMAC verification failed for ' . ($_SERVER['REQUEST_URI'] ?? '')); json_error('Invalid request signature', 401); } /** * The path the client signed. Dio signs options.path, i.e. the endpoint * relative to the API base URL ("batches/finalize"), without a query string. */ private static function requestPath(): string { $uri = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH) ?: ''; return self::normalisePath($uri); } /** * Strip the leading slash and any deployment prefix so the signed value * matches what the client hashed. */ private static function normalisePath(string $raw): string { $path = ltrim(trim($raw), '/'); foreach (['api/v1/', 'api/', 'v1/'] as $prefix) { if (str_starts_with($path, $prefix)) { $path = substr($path, strlen($prefix)); break; } } return $path; } /** * Decrypt the stored per-device secret, or null if the device is unknown. */ private static function deviceSecret(string $userId, string $deviceId): ?string { try { $db = Database::getInstance(); $stmt = $db->prepare(" SELECT device_secret FROM user_devices WHERE user_id = ? AND device_fingerprint = ? AND is_trusted = 1 LIMIT 1 "); $stmt->execute([$userId, $deviceId]); $stored = $stmt->fetchColumn(); if (!$stored) { return null; } // Legacy rows hold a bcrypt hash, which is one-way and therefore // unusable for HMAC. Treat those devices as un-signable until the // user logs in again on the new build. if (str_starts_with((string)$stored, '$2y$') || str_starts_with((string)$stored, '$2a$')) { return null; } $plain = Encryption::decrypt((string)$stored); return $plain === false ? null : $plain; } catch (\Throwable $e) { error_log('[HmacMiddleware] device secret lookup failed: ' . $e->getMessage()); return null; } } }