fix(cloud): add nonce-replay + timestamp-window checks to config/heartbeat (M2)
ConfigController::get and HeartbeatController::post verified HMAC but never checked nonce replay or timestamp freshness, so a captured signed request (e.g. a replayed heartbeat) could be replayed forever, masking a dead device. Inject NonceRepository (+Config) into both and add the same timestamp-window + nonce-remember checks IngestController already applies, after HMAC verification and before the response/side effect.
This commit is contained in:
parent
3405c1e2cc
commit
171ef3e1c5
4 changed files with 76 additions and 16 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue