chore: file T-1 — per-device token for the gateway
Test, Build and Push / test-gateway (push) Successful in 14s
Test, Build and Push / release (push) Skipped
Test, Build and Push / build-gateway (push) Skipped

The first ticket on this board: /ws/voice and /devices accept any
client on the LAN, which reaches Tatlock and its Home Assistant
controls. Found in the workspace security sweep of 2026-09-23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 13:45:19 +02:00
co-authored by Claude Opus 5.5
parent f36f0fe431
commit fc7f0cc1fd
3 changed files with 40 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'description', NULL, 'The gateway accepts any client. `/ws/voice` and `/devices` check no credential, so anything on the LAN (or the tailnet) can open `ws://192.168.86.149:8600/ws/voice` and talk to Tatlock through the gateway — including Tatlock''s Home Assistant controls — or list the devices. Found in the workspace security sweep on 2026-09-23 by reading the gateway source: no token, auth or secret handling anywhere in `gateway/src/desklock_gateway/`.
Port 8600 has to stay LAN-open: it is the one address the firmware connects to (`GATEWAY_WS_URI` in `firmware/main/secrets.h`). So the fix is a credential, not a lockdown.
## Shape
- A per-device token, provisioned into firmware through `secrets.h` (already the secrets path, and already gitignored — check), sent on the WebSocket handshake (an `Authorization: Bearer` header, or a first frame if the ESP-IDF client makes headers awkward).
- The gateway compares it in constant time against its configured set (one token per device, so one can be revoked alone) and closes the socket on a mismatch before any audio or text is processed.
- `/devices` requires the same credential, or an admin one.
- Tokens reach the gateway container as a Portainer stack variable, never in the tracked stack file.
- The protocol change is recorded in `docs/architecture.md` (standing rule: device–gateway protocol changes update it).
## Rollout without a dark period
Gateway first in a mode that accepts both token and no-token and logs which each device used; then flash the devices; then make the token required. Verify: a connection without a token is refused, each flashed device still converses, and `/devices` without a credential is refused.
## Tests
A socket without a token is closed before the first frame is read; a wrong token likewise; a correct one converses; comparison is constant-time. Each mutation-checked.', NULL, '2026-09-23 11:45:03', '2026-09-23 11:45:03.865', '2026-09-23 11:45:03.865', NULL, '93027bc356d0bbc60ae0e38b9afdeb88', 2) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -0,0 +1 @@
INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'T-1', '2026-09-23 11:45:03.721', '2026-09-23 11:45:03.721', NULL, '4549dc91ac440f4b47a95e9072256904', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at;
+20
View File
@@ -0,0 +1,20 @@
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'story', NULL, 'Gateway: per-device token on /ws/voice and /devices', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-09-23 11:45:03.720', '2026-09-23 11:45:03.720', NULL, '7ef01f11d6709f6e094634f5aacc6789', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06GCW5B4X073XJCWS4QTHRAVSG', 'story', NULL, 'Gateway: per-device token on /ws/voice and /devices', 'The gateway accepts any client. `/ws/voice` and `/devices` check no credential, so anything on the LAN (or the tailnet) can open `ws://192.168.86.149:8600/ws/voice` and talk to Tatlock through the gateway — including Tatlock''s Home Assistant controls — or list the devices. Found in the workspace security sweep on 2026-09-23 by reading the gateway source: no token, auth or secret handling anywhere in `gateway/src/desklock_gateway/`.
Port 8600 has to stay LAN-open: it is the one address the firmware connects to (`GATEWAY_WS_URI` in `firmware/main/secrets.h`). So the fix is a credential, not a lockdown.
## Shape
- A per-device token, provisioned into firmware through `secrets.h` (already the secrets path, and already gitignored — check), sent on the WebSocket handshake (an `Authorization: Bearer` header, or a first frame if the ESP-IDF client makes headers awkward).
- The gateway compares it in constant time against its configured set (one token per device, so one can be revoked alone) and closes the socket on a mismatch before any audio or text is processed.
- `/devices` requires the same credential, or an admin one.
- Tokens reach the gateway container as a Portainer stack variable, never in the tracked stack file.
- The protocol change is recorded in `docs/architecture.md` (standing rule: device–gateway protocol changes update it).
## Rollout without a dark period
Gateway first in a mode that accepts both token and no-token and logs which each device used; then flash the devices; then make the token required. Verify: a connection without a token is refused, each flashed device still converses, and `/devices` without a credential is refused.
## Tests
A socket without a token is closed before the first frame is read; a wrong token likewise; a correct one converses; comparison is constant-time. Each mutation-checked.', 'backlog', 'high', NULL, NULL, NULL, '2026-09-23 11:45:03.720', '2026-09-23 11:45:03.865', NULL, 'de24fb675828ba55b4fdd3defd406cdb', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;