diff --git a/cloud/public/index.php b/cloud/public/index.php index 7b16dc1..bf653ac 100644 --- a/cloud/public/index.php +++ b/cloud/public/index.php @@ -30,8 +30,8 @@ $thr = new PdoThresholdRepository($pdo); $audit = new PdoAuditRepository($pdo); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); -$config = new ConfigController($devices, $zones, $thr); -$heartbeat = new HeartbeatController($cfg, $devices); +$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg); +$heartbeat = new HeartbeatController($cfg, $devices, $nonces); $auth = new AuthController($users, $audit, $rate); $csrfCtl = new CsrfController(); $list = new EventListController($events, $sess); @@ -47,7 +47,7 @@ $render = function (string $view): Response { $r = new Router(); $r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now)); -$r->add('GET', '/api/config', fn(Request $q) => $config->get($q)); +$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now)); $r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess)); $r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); diff --git a/cloud/src/Api/ConfigController.php b/cloud/src/Api/ConfigController.php index edf3f0f..785a628 100644 --- a/cloud/src/Api/ConfigController.php +++ b/cloud/src/Api/ConfigController.php @@ -2,17 +2,19 @@ declare(strict_types=1); namespace App\Api; +use App\Config; use App\Http\{Request, Response}; use App\Security\Hmac; -use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository}; +use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository}; final class ConfigController { public function __construct( - private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds + private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds, + private NonceRepository $nonces, private Config $cfg, ) {} - public function get(Request $req): Response + public function get(Request $req, int $now): Response { $deviceId = (string)$req->query('device_id'); $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; @@ -20,6 +22,14 @@ final class ConfigController if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { return Response::json(['error' => 'unauthorized'], 401); } + if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) { + return Response::json(['error' => 'stale'], 401); + } + if ($nonce === '' || $this->nonces->seen($nonce)) { + return Response::json(['error' => 'replay'], 401); + } + $this->nonces->remember($nonce, $now + $this->cfg->nonceTtl()); + return Response::json([ 'version' => $this->devices->configVersion($deviceId), 'zones' => $this->zones->forDevice($deviceId), diff --git a/cloud/src/Api/HeartbeatController.php b/cloud/src/Api/HeartbeatController.php index 1f9cd32..7a4c404 100644 --- a/cloud/src/Api/HeartbeatController.php +++ b/cloud/src/Api/HeartbeatController.php @@ -5,11 +5,11 @@ namespace App\Api; use App\Config; use App\Http\{Request, Response}; use App\Security\Hmac; -use App\Repository\DeviceRepository; +use App\Repository\{DeviceRepository, NonceRepository}; final class HeartbeatController { - public function __construct(private Config $cfg, private DeviceRepository $devices) {} + public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces) {} public function post(Request $req, int $now): Response { @@ -20,6 +20,14 @@ final class HeartbeatController if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { return Response::json(['error' => 'unauthorized'], 401); } + if (abs($now - (int)(float)$ts) > $this->cfg->timestampWindow()) { + return Response::json(['error' => 'stale'], 401); + } + if ($nonce === '' || $this->nonces->seen($nonce)) { + return Response::json(['error' => 'replay'], 401); + } + $this->nonces->remember($nonce, $now + $this->cfg->nonceTtl()); + $this->devices->touchHeartbeat($deviceId, [ 'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0), 'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0), diff --git a/cloud/tests/ApiSyncTest.php b/cloud/tests/ApiSyncTest.php index 776a684..761f411 100644 --- a/cloud/tests/ApiSyncTest.php +++ b/cloud/tests/ApiSyncTest.php @@ -5,14 +5,13 @@ use App\Config; use App\Http\Request; use App\Security\Hmac; use App\Api\{ConfigController, HeartbeatController}; -use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository}; +use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository, InMemoryNonceRepository}; use PHPUnit\Framework\TestCase; final class ApiSyncTest extends TestCase { - private function signed(string $method, string $path, string $secret, array $q = [], array $body = []): Request { + private function signed(string $method, string $path, string $secret, array $q = [], array $body = [], string $nonce = 'n1', string $ts = '1000'): Request { $raw = $body ? json_encode($body) : ''; - $nonce = 'n1'; $ts = '1000'; $sig = Hmac::sign($secret, $nonce, $ts, $raw); return new Request($method, $path, ['X-Nonce' => $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $raw, $q, []); } @@ -20,8 +19,10 @@ final class ApiSyncTest extends TestCase $dev = new InMemoryDeviceRepository(['pi-1' => 'sek'], ['pi-1' => 7]); $zones = new InMemoryZoneRepository(); $zones->save(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); $thr = new InMemoryThresholdRepository(['pi-1' => ['fall_prone_s' => 3.0]]); - $c = new ConfigController($dev, $zones, $thr); - $res = $c->get($this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'])); + $nonces = new InMemoryNonceRepository(); + $cfg = Config::fromArray(['timestamp_window' => 300]); + $c = new ConfigController($dev, $zones, $thr, $nonces, $cfg); + $res = $c->get($this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1']), now: 1000); $this->assertSame(200, $res->status()); $data = json_decode($res->body(), true); $this->assertSame(7, $data['version']); @@ -30,17 +31,58 @@ final class ApiSyncTest extends TestCase } public function testConfigRejectsBadSignature(): void { $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); - $c = new ConfigController($dev, new InMemoryZoneRepository(), new InMemoryThresholdRepository()); - $res = $c->get($this->signed('GET', '/api/config', 'WRONG', ['device_id' => 'pi-1'])); + $c = new ConfigController($dev, new InMemoryZoneRepository(), new InMemoryThresholdRepository(), new InMemoryNonceRepository(), Config::fromArray([])); + $res = $c->get($this->signed('GET', '/api/config', 'WRONG', ['device_id' => 'pi-1']), now: 1000); $this->assertSame(401, $res->status()); } + public function testConfigRejectsReplayedNonce(): void { + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek'], ['pi-1' => 7]); + $zones = new InMemoryZoneRepository(); + $thr = new InMemoryThresholdRepository(); + $nonces = new InMemoryNonceRepository(); + $cfg = Config::fromArray(['timestamp_window' => 300]); + $c = new ConfigController($dev, $zones, $thr, $nonces, $cfg); + $req = $this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'], [], 'replay-nonce', '1000'); + $this->assertSame(200, $c->get($req, now: 1000)->status()); + $this->assertSame(401, $c->get($req, now: 1000)->status()); // same nonce replayed + } + public function testConfigRejectsStaleTimestamp(): void { + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $zones = new InMemoryZoneRepository(); + $thr = new InMemoryThresholdRepository(); + $nonces = new InMemoryNonceRepository(); + $cfg = Config::fromArray(['timestamp_window' => 300]); + $c = new ConfigController($dev, $zones, $thr, $nonces, $cfg); + $req = $this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'], [], 'stale-nonce', '1000'); + $this->assertSame(401, $c->get($req, now: 5000)->status()); // 4000s > 300 window + } public function testHeartbeatStored(): void { $cfg = Config::fromArray([]); $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); - $c = new HeartbeatController($cfg, $dev); + $nonces = new InMemoryNonceRepository(); + $c = new HeartbeatController($cfg, $dev, $nonces); $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000); $this->assertSame(200, $res->status()); $this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']); } + public function testHeartbeatRejectsReplayedNonce(): void { + $cfg = Config::fromArray([]); + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $nonces = new InMemoryNonceRepository(); + $c = new HeartbeatController($cfg, $dev, $nonces); + $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; + $req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-replay', '1000'); + $this->assertSame(200, $c->post($req, now: 1000)->status()); + $this->assertSame(401, $c->post($req, now: 1000)->status()); // same nonce replayed + } + public function testHeartbeatRejectsStaleTimestamp(): void { + $cfg = Config::fromArray([]); + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $nonces = new InMemoryNonceRepository(); + $c = new HeartbeatController($cfg, $dev, $nonces); + $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; + $req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-stale', '1000'); + $this->assertSame(401, $c->post($req, now: 5000)->status()); // 4000s > 300 default window + } }