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:
유창욱 2026-07-12 09:19:32 +09:00
parent 3405c1e2cc
commit 171ef3e1c5
4 changed files with 76 additions and 16 deletions

View file

@ -30,8 +30,8 @@ $thr = new PdoThresholdRepository($pdo);
$audit = new PdoAuditRepository($pdo); $audit = new PdoAuditRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr); $config = new ConfigController($devices, $zones, $thr, $nonces, $cfg);
$heartbeat = new HeartbeatController($cfg, $devices); $heartbeat = new HeartbeatController($cfg, $devices, $nonces);
$auth = new AuthController($users, $audit, $rate); $auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController(); $csrfCtl = new CsrfController();
$list = new EventListController($events, $sess); $list = new EventListController($events, $sess);
@ -47,7 +47,7 @@ $render = function (string $view): Response {
$r = new Router(); $r = new Router();
$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $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('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('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', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? '')));
$r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); $r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess));

View file

@ -2,17 +2,19 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Api; namespace App\Api;
use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Hmac; use App\Security\Hmac;
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository}; use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository};
final class ConfigController final class ConfigController
{ {
public function __construct( 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'); $deviceId = (string)$req->query('device_id');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; $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)) { if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401); 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([ return Response::json([
'version' => $this->devices->configVersion($deviceId), 'version' => $this->devices->configVersion($deviceId),
'zones' => $this->zones->forDevice($deviceId), 'zones' => $this->zones->forDevice($deviceId),

View file

@ -5,11 +5,11 @@ namespace App\Api;
use App\Config; use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Hmac; use App\Security\Hmac;
use App\Repository\DeviceRepository; use App\Repository\{DeviceRepository, NonceRepository};
final class HeartbeatController 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 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)) { if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) {
return Response::json(['error' => 'unauthorized'], 401); 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, [ $this->devices->touchHeartbeat($deviceId, [
'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0), 'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0),
'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0), 'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0),

View file

@ -5,14 +5,13 @@ use App\Config;
use App\Http\Request; use App\Http\Request;
use App\Security\Hmac; use App\Security\Hmac;
use App\Api\{ConfigController, HeartbeatController}; use App\Api\{ConfigController, HeartbeatController};
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository}; use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository, InMemoryNonceRepository};
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class ApiSyncTest extends 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) : ''; $raw = $body ? json_encode($body) : '';
$nonce = 'n1'; $ts = '1000';
$sig = Hmac::sign($secret, $nonce, $ts, $raw); $sig = Hmac::sign($secret, $nonce, $ts, $raw);
return new Request($method, $path, ['X-Nonce' => $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $raw, $q, []); 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]); $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]]']); $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]]); $thr = new InMemoryThresholdRepository(['pi-1' => ['fall_prone_s' => 3.0]]);
$c = new ConfigController($dev, $zones, $thr); $nonces = new InMemoryNonceRepository();
$res = $c->get($this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'])); $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()); $this->assertSame(200, $res->status());
$data = json_decode($res->body(), true); $data = json_decode($res->body(), true);
$this->assertSame(7, $data['version']); $this->assertSame(7, $data['version']);
@ -30,17 +31,58 @@ final class ApiSyncTest extends TestCase
} }
public function testConfigRejectsBadSignature(): void { public function testConfigRejectsBadSignature(): void {
$dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']);
$c = new ConfigController($dev, new InMemoryZoneRepository(), new InMemoryThresholdRepository()); $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'])); $res = $c->get($this->signed('GET', '/api/config', 'WRONG', ['device_id' => 'pi-1']), now: 1000);
$this->assertSame(401, $res->status()); $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 { public function testHeartbeatStored(): void {
$cfg = Config::fromArray([]); $cfg = Config::fromArray([]);
$dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); $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]; $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); $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000);
$this->assertSame(200, $res->status()); $this->assertSame(200, $res->status());
$this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']); $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
}
} }