Update: 2026-07-30 02:27:45
This commit is contained in:
@@ -1,62 +1,182 @@
|
||||
<?php
|
||||
/**
|
||||
* HMAC Request Signature Middleware
|
||||
*
|
||||
* Verifies that incoming requests are signed with a shared secret,
|
||||
* preventing replay attacks and ensuring request integrity.
|
||||
*
|
||||
* HMAC Request Signature Middleware (per-device)
|
||||
*
|
||||
* Proves a request came from a device that completed login on this account, and
|
||||
* that nobody altered it in flight. This is defence in depth on top of the JWT:
|
||||
* a stolen bearer token alone is not enough to sign a request.
|
||||
*
|
||||
* Client must send:
|
||||
* X-Timestamp: Unix timestamp (seconds)
|
||||
* X-HMAC-Signature: HMAC-SHA256(timestamp + "." + raw_body, HMAC_SECRET_KEY)
|
||||
* X-Timestamp: milliseconds since epoch
|
||||
* X-Signature: HMAC-SHA256("METHOD:path:timestamp[:json_body]", device_secret)
|
||||
*
|
||||
* The device_secret is issued at login and stored encrypted (reversibly) in
|
||||
* user_devices - it CANNOT be bcrypt-hashed, because the server has to be able
|
||||
* to recompute the same HMAC the client computed.
|
||||
*
|
||||
* Rollout: enforcement is controlled by HMAC_ENFORCE in .env.
|
||||
* HMAC_ENFORCE=false (default) -> 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\Security;
|
||||
use App\Core\Database;
|
||||
use App\Core\Encryption;
|
||||
|
||||
final class HmacMiddleware
|
||||
{
|
||||
/**
|
||||
* @param int $maxAgeSeconds Max age for replay attack window (default: 5 minutes)
|
||||
* @param array $decoded The decoded JWT payload from AuthMiddleware::check().
|
||||
* @param int $maxAgeSeconds Replay window (default: 5 minutes).
|
||||
*/
|
||||
public static function verify(int $maxAgeSeconds = 300): void
|
||||
public static function verify(array $decoded, int $maxAgeSeconds = 300): void
|
||||
{
|
||||
$headers = getallheaders();
|
||||
$signature = $headers['X-HMAC-Signature'] ?? $headers['x-hmac-signature'] ?? '';
|
||||
$timestamp = $headers['X-Timestamp'] ?? $headers['x-timestamp'] ?? '';
|
||||
$enforce = strtolower((string)env('HMAC_ENFORCE', 'false')) === 'true';
|
||||
|
||||
// 1. Ensure both headers are present
|
||||
if (empty($signature) || empty($timestamp)) {
|
||||
json_error('Missing HMAC signature or timestamp', 401);
|
||||
$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 is numeric
|
||||
// 2. Validate timestamp format.
|
||||
if (!ctype_digit((string)$timestamp)) {
|
||||
json_error('Invalid timestamp format', 401);
|
||||
}
|
||||
|
||||
// 3. Replay attack prevention — reject stale requests
|
||||
$age = abs(time() - (int)$timestamp);
|
||||
if ($age > $maxAgeSeconds) {
|
||||
json_error('Request expired. Check your system clock.', 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. Build the expected signature
|
||||
$body = file_get_contents('php://input');
|
||||
$payload = $timestamp . '.' . $body;
|
||||
$secret = env('HMAC_SECRET_KEY');
|
||||
// 4. Look up this device's secret.
|
||||
$deviceId = $decoded['device_id'] ?? null;
|
||||
$userId = $decoded['user_id'] ?? null;
|
||||
|
||||
if (!$secret || strlen($secret) < 32) {
|
||||
error_log('FATAL: HMAC_SECRET_KEY is missing or too short in .env');
|
||||
json_error('Server configuration error', 500);
|
||||
if (!$deviceId || !$userId) {
|
||||
if ($enforce) {
|
||||
json_error('Signed requests require a registered device', 401);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Verify using constant-time comparison (prevents timing attacks)
|
||||
if (!Security::verifySignature($payload, $signature, $secret)) {
|
||||
error_log("HMAC verification failed for " . ($_SERVER['REQUEST_URI'] ?? ''));
|
||||
json_error('Invalid request signature', 401);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user