Target Architecture — Secure, Dual-Profile¶
The security-and-deployment design that realises the architecture brief. It does not replace the logical system-architecture.md (data flow + layers); it layers security, the dual deployment profile, and the evolution plan on top of that pipeline.
⚠️ Implementation status (2026-06-14): partially built. The Minimum Safe Increment has shipped and is e2e-verified — WS
/liveauth (token + Origin/CSWSH check + server-authorised session, no moretestdefault), per-device MQTT credentials + topic ACLs (server/mosquitto/), and theid_mismatchreject. Still to-be-built (treat as design, not present): the remote path (overlay-first, ADR-0012), retention/purge, roster/consent, OS FDE, and the remaining controls below. Build order: the evolution plan (§11). Reviewed by the architecture board (2026-06-14); field network decided in ADR-0013. Hard gate: do not track real children until the field AP is isolated (ADR-0013) and the firmwareWIFI_PASSplaceholder is removed (done — secrets now in NVS, ADR-0014; flash encryption is the remaining gate item).
Drivers, in priority order: ① security (don't get hacked — children's live location is the asset) → ② real-time performance (<1 s on-site) → ③ cost (€0 local, €5–20/mo cloud). Decisions below are justified against this order; where two conflict, the higher-priority driver wins and the trade is stated.
Decisions taken (owner can override — these resolve the brief's open questions): | # | Question | Decision (default) | |---|---|---| | 1 | Principals | Coach + club admin. Parent access is out of MVP (enlarges authz + consent model). | | 2 | Retention of raw minor location | 30 days local, then auto-purge to aggregates (configurable). | | 3 | Data residency (Profile B) | EU-region VPS (GDPR). | | 4 | MQTT transport security | Plaintext on an isolated field LAN; broker never internet-facing (relay design). Device TLS not required. | | 5 | Credential provisioning | Per-device MQTT credential + WiFi PSK in NVS via serial enrollment (ADR-0014); coach accounts issued by admin. |
1. The defining decision: local core + cloud relay¶
The pipeline that produces the live view always runs on the field box (broker + Bun server + SQLite). That box is Profile A and runs with zero internet dependency. Profile B does not move the pipeline to the cloud — it adds a small relay on a VPS that the field box connects to outbound, and which authenticates remote coaches and re-fans-out the live stream. See ADR-0006.
Why this is the right spine, against all three drivers:
- Security ① — Devices never touch the internet (they only reach the local broker on an isolated LAN), so there is no ESP32-to-public-broker TLS to get wrong and no device attack surface online. The field box opens no inbound internet ports (the relay link is outbound-only), so the field side has zero inbound attack surface. Exactly one component is internet-exposed — the relay — and it is small and hardenable.
- Performance ② — The on-site coach is served by the local server over the LAN, so the < 1 s budget ([NFR-RT-1]) is met regardless of internet. The internet hop is only on the remote path, which is explicitly a softer target.
- Cost ③ — One tiny VPS, aggregates only in the cloud (raw stays local) → smallest instance, least data-at-rest to protect and pay for.
It also makes the A↔B switch trivial: Profile A is Profile B with the relay turned off.
2. Context diagrams (both profiles)¶
Profile A — field-local (development + match day, offline):
isolated field LAN — NO internet
┌──────────────┐ WiFi / MQTT ┌─────────────────────────────────┐ LAN WSS ┌──────────────┐
│ 10–20 │ ───(plaintext,──▶│ FIELD BOX (laptop / mini-PC) │ ──(<1 s)──▶ │ on-site coach │
│ wearables │ per-device │ mosquitto + Bun/Elysia + SQLite │ │ tablet (LAN) │
│ (ESP32+GNSS) │ creds + ACL) │ + Prometheus/Grafana (localhost)│ └──────────────┘
└──────────────┘ └─────────────────────────────────┘
no inbound ports · raw data local
Profile B — adds the cloud relay (remote access for one club):
isolated field LAN internet (TLS only)
┌──────────┐ MQTT ┌───────────┐ outbound mTLS WSS ┌───────────────────────────┐ WSS/HTTPS ┌────────────┐
│ wearables│ ───────▶│ FIELD BOX │ ════════════════════▶│ VPS (EU): Caddy(TLS:443) │ ◀──────────│ remote │
└──────────┘ │ (as above)│ (field dials OUT; │ → relay (auth + authz │ login + │ coach │
└─────┬─────┘ no inbound holes) │ + rooms + audit) │ authz └────────────┘
│ also serves on-site │ → aggregate DB (no raw) │
▼ coach on LAN (<1 s) └───────────────────────────┘
on-site coach only :443 public · raw never leaves the field
3. Container view & the A↔B seam¶
FIELD BOX (same binary in A and B) ───────────────────────────────┐
│ firmware → [mosquitto] (per-device creds + topic ACL) │
│ │ plaintext on isolated LAN │
│ ▼ │
│ ingest.ts ─ validate · identity-reconcile · enrich ── │──▶ db.ts (bun:sqlite, raw, FDE volume)
│ │ │
│ ├──▶ local WS /live (on-site coach, <1 s) │
│ ├──▶ metrics.ts /metrics (localhost only) │
│ └──▶ relay-forwarder ── if RELAY_ENABLED ───────┐│ outbound mTLS WSS
└──────────────────────────────────────────────────────────────── ││
▼▼
VPS / RELAY (same codebase, MODE=relay) ──────────────────────────────────┐
│ Caddy (auto-HTTPS, :443, rate-limit, security headers) │
│ → relay: authN (argon2+session) · authZ (club/session) · rooms │
│ · audit log · live re-fan-out · post-session review UI │
│ → aggregate DB (per-player/session summaries; NO raw 10 Hz) │
└──────────────────────────────────────────────────────────────────────────┘
The seam is one flag. RELAY_ENABLED (with RELAY_URL + RELAY_CLIENT_CERT):
- unset → Profile A: the relay-forwarder never starts; no outbound connection; nothing internet-facing. Pure local, offline.
- set → Profile B: the same field binary additionally forwards
{event:telemetry}envelopes outbound to the relay. Ingest, local WS, persistence, and metrics are byte-for-byte identical. The relay is the same codebase run withMODE=relay— no fork, shared WS-envelope and auth libraries.
4. Threat model (STRIDE)¶
Each threat → mitigation → enforcement point → profile. Right-sized to 10–20 players / one club (scale envelope); no control implies multi-tenant or cluster infrastructure.
Status caveat: the Enforced at column is the target enforcement point. The Minimum Safe Increment has shipped (WS auth, per-device MQTT creds/ACL,
id_mismatchreject — e2e-verified); still not implemented: DoS sanity-bounds, the remote path, retention/erasure, and the audit log. Read those rows as design intent, not current posture (see the banner above).
| STRIDE | Threat | Mitigation | Enforced at | Profile |
|---|---|---|---|---|
| Spoofing | Forged device publishes another player's telemetry | Per-device broker credential + topic ACL (player/{own}/# only); server trusts topic-derived identity, rejects packet/topic id mismatch (dropped{reason="id_mismatch"}) |
mosquitto ACL + ingest.ts |
A+B |
| S | Attacker impersonates a coach | Password (argon2id) + signed HTTP-only session cookie; no anonymous WS | relay authN | B |
| S | Rogue field box connects to relay | mTLS client cert provisioned per club | relay TLS | B |
| Tampering | MITM on telemetry over the internet | TLS (WSS) on every internet hop; persistent conns (handshake amortised) | Caddy / relay | B |
| T | Tamper data at rest | OS full-disk encryption on the field box; encrypted off-host backups; SQLite WAL integrity | field box / VPS | A+B |
| Repudiation | "Who viewed this child's location?" | Append-only audit log of auth + session-view events | relay | B |
| Info disclosure | Children's live location world-readable (the #1 risk) | No anonymous WS/MQTT anywhere; /metrics+/health bound to localhost / behind edge; relay requires auth |
everywhere | A+B |
| I | Eavesdropping in transit | TLS on all internet hops; plaintext only on the isolated field LAN | Caddy / relay | B |
| I | DB / backup leak reveals named child locations | Pseudonymous playerId on wire + in DB; name↔id roster kept separate & access-controlled; raw stays local; cloud holds aggregates only; encryption at rest; retention purge |
wire contract + db + roster | A+B |
| DoS | Device firehose / absurd coordinates | Range/sanity bounds + per-device rate cap (drop + count); message_size_limit, max_inflight |
ingest.ts + mosquitto |
A+B |
| D | Internet flood on the relay | Rate-limit auth endpoints, connection caps, fail2ban; only :443 exposed | Caddy + firewall | B |
| EoP | Coach reads another session/club | Server-side authZ on every WS subscribe and history query — never trust client-claimed session | relay authZ | B |
| E | Compromised dependency | Pinned bun.lock, minimal deps (zero-dep metrics already), review on bump |
build | A+B |
5. Data model, retention, erasure, encryption¶
Identity is pseudonymous by construction. The wire contract and telemetry table carry only an opaque
playerId (e.g. p07). The playerId → child name + parental consent mapping lives in a separate,
access-controlled roster (local, encrypted) — so a telemetry-DB leak is not a named-child location leak
(data minimisation, §4a).
| Store | Lives | Contents | Protection |
|---|---|---|---|
telemetry (raw 10 Hz) |
field box only | pseudonymous fixes | FDE volume; 30-day retention → auto-purge |
session_aggregate |
field box + relay | per-player/session summaries (metric-definitions) | FDE / TLS; long-lived |
roster |
field box only | playerId → name, consent record, erasure flag |
separate encrypted file, access-controlled |
audit_log |
relay | who viewed which session, when | append-only |
- Retention & purge: a scheduled job deletes raw fixes older than
RETENTION_DAYS(default 30), leaving aggregates. Bounds storage and breach blast-radius. See ADR-0010. Shipped 2026-06-14 — hourly batched sweep inserver/src/retention.ts, self-reporting viaft_oldest_raw_fix_age_seconds+ sweep liveness/failure metrics. - Right to erasure: a
purge-player <playerId>operation removes raw + aggregates + roster + the cloud aggregate copy for that player. Shipped 2026-06-14 (raw only):server/purge-player.tserases the rawtelemetryrows withsecure_deletebyte-zeroing; aggregates/roster/cloud copies don't exist yet, so the CLI is extended when they do. - Encryption at rest: OS full-disk encryption (FileVault/LUKS) on the field box and VPS — the pragmatic,
€0, owned control.
bun:sqlitehas no native page encryption; SQLCipher is noted as a future option if FDE is insufficient. Backups areage/gpg-encrypted before leaving the host. - Encryption in transit: TLS on every internet hop; plaintext MQTT only on the isolated field LAN.
6. AuthN / AuthZ¶
- Principals:
admin(club),coach. Parents are out of MVP — per-child parent views need per-player ACLs and stricter consent; flagged for a later phase. - AuthN (humans): username + argon2id password, HTTP-only/Secure/SameSite session cookie, CSRF tokens
on state-changing routes. A full OAuth/IdP for ~20 users is over-engineering
(§5); optional TOTP for
admin. See ADR-0008. - AuthN (machine): field box ↔ relay is mTLS (client cert = club identity), not a human password.
- AuthZ: roles
{admin, coach}; a coach is scoped to assigned sessions. Every relay WS-subscribe and every history/aggregate query is checked server-side against the authenticated principal — the client's claimedsessionIdis never trusted. On the isolated-LAN local view, the same auth path applies with a single default coach account (a fully-open local view is acceptable only on a physically controlled LAN — stated, not assumed). - Secrets: relay holds password hashes + the mTLS CA; field box holds its client cert + per-device MQTT creds; firmware carries only its own device credential, in NVS (ADR-0014). All via env/secret files (server side) or NVS (firmware) — never committed.
7. MQTT security¶
- Per-device credentials (username/password or client cert) — no shared secret across wearables;
provisioned into NVS via a serial enrollment console, not baked into source
(ADR-0014); the MQTT username is the
PLAYER_ID. - Topic ACLs (mosquitto): device
p07may publish only tofootball-trackers/session/+/player/p07/#and subscribe only to its own/cmd. A forged/compromised device therefore cannot spoof another player. - Server identity reconciliation (to build):
ingest.tsrecoverssession/playerfrom the topic today, but does not yet compare them to the packet body. Make the topic authoritative and reject packets whose bodypl/iddisagree (new counted drop reasonid_mismatch). - Transport: plaintext on the isolated field LAN is acceptable (LAN trust boundary; devices can't reach the internet); the broker is never internet-facing. Broker TLS (server cert or PSK) is an optional add-on only if the field LAN is shared/untrusted. This is the decision that avoids ESP32 TLS cost — see ADR-0007.
- Abuse bounds:
ingest.tsdrops out-of-range lat/lon and absurd speeds and caps per-device rate; broker setsmessage_size_limitandmax_inflight_messages.
8. Profile B network topology¶
internet ──▶ :443 (HTTPS/WSS) ──▶ Caddy ──▶ relay (localhost)
:22 (SSH, key-only, IP-allowlist/VPN)
everything else: default-deny (ufw/nftables)
field box ──▶ outbound mTLS WSS to wss://relay.<club>/ingest (no inbound port on field side)
coach ──▶ https://relay.<club> → login → WSS /live
- VPS: single small EU instance (~1 vCPU / 1–2 GB), GDPR data residency.
- Edge: Caddy — automatic HTTPS via Let's Encrypt (€0), HSTS/CSP/security headers, auth-endpoint rate limiting, WS origin checks. See ADR-0009.
- Exposure: only :443 public; SSH key-only and IP-restricted; firewall default-deny inbound.
/metrics&/health: localhost-bound (Prometheus scrapes locally); never serve positions on a public endpoint.- Backups: nightly
age-encrypted aggregate dump off-host; field-box raw DB backed up encrypted locally. - Hardening: fail2ban on SSH + Caddy; minimal package surface; unattended security updates.
9. Performance budget¶
On-site live path (Profile A — the < 1 s SLO, [NFR-RT-1]):
| Hop | Typical |
|---|---|
| GNSS fix age (10 Hz, autoPVT) | 50–100 ms |
| WiFi device → AP → broker | 5–20 ms |
| mosquitto (QoS0) | < 5 ms |
| ingest (validate+enrich+persist+publish; measured p99 < 50 ms) | 5–50 ms |
| local WS fan-out → canvas render | 5–20 ms |
| Total on-site | ~70–195 ms ≪ 1 s (TLS/auth not on this LAN path) |
Remote path (Profile B — best-effort, proposed target < 2 s): local pipeline (~100 ms) + field→relay outbound WSS (internet RTT ~20–100 ms) + relay fan-out + coach internet RTT (~20–100 ms) → typically ~300–700 ms. TLS does not blow the budget: connections are persistent, so the handshake is one-time and per-message cost is just symmetric crypto.
Back-pressure: 200 msg/s is trivial. Policy = latest-wins, fire-and-forget (QoS0 / [NFR-RES-2] — a dropped 10 Hz frame is invisible): per-client outbound queue is bounded; if a remote socket saturates, drop its stale frames (never block the event loop or other clients) and disconnect if it stays saturated. The relay applies the same drop policy.
10. Cost model (Profile B)¶
| Item | Cost |
|---|---|
| VPS (small EU instance) | €5–20/mo |
| TLS (Let's Encrypt) | €0 |
| Domain | ~€1/mo (~€10/yr) |
| Encrypted backups / object storage (aggregates) | €0–2/mo |
| Total | ~€6–22/mo — inside the ceiling |
Data growth: packet ≈ 120 B. Raw = up to 200 msg/s × 5400 s ≈ ~70–200 MB per 90-min session, local only; 30-day retention at a few sessions/week stays ~1–2 GB on the field box. The cloud stores aggregates only — a few KB/player/session → MBs per season → fits the smallest VPS disk. No per-seat/per-player cost.
11. Evolution plan (MVP vs later — no big-bang)¶
Ordered so each phase makes the next exposure safe:
- Phase 0 — done: local pipeline + observability (verified, no hardware).
- Phase 1 — local hardening (safe Profile A): ✅ per-device MQTT creds + topic ACLs, ✅ server
id_mismatchreconcile, ✅ WS/liveauth (token + Origin + server-authorised session) — all shipped & e2e-verified 2026-06-14; pseudonymousplayerIdconfirmed. ✅ firmware secrets (WiFi PSK + per-device MQTT creds) moved to NVS via a serial enrollment console (ADR-0014) — theWIFI_PASSplaceholder is gone (firmware pending hardware flash). Still to do: dedicated isolated AP (ADR-0013), OS FDE on the field box, retention/purge job, named local coach login. - Phase 2 — remote access, overlay-first (ADR-0012):
field box + remote coach on a WireGuard/Tailscale tailnet; coach reaches the existing
/live(+ future review) behind the same WS auth. The bespokeMODE=relay+ Caddy + mTLS path is deferred (built only if a no-client-install browser URL is needed; then it needs relay observability, cert lifecycle, audit log, encrypted backups). - Phase 3 — privacy/ops polish: consent records +
purge-playererasure tooling; aggregate-only cloud persistence; auth-anomaly alerts; optional SQLCipher / broker TLS if the LAN is untrusted. - Later — only if triggered: TimescaleDB swap via the
db.tsseam (ADR-0011); trigger = sustained throughput or session/club count beyond the current envelope (none of which exists now).
12. Anti-scope (deliberately NOT built)¶
Per the scale envelope — building any of these here would be a failure, not thoroughness:
- No multi-tenant / multi-club infrastructure, no tenant isolation layer.
- No horizontal scale, Kafka/NATS, Kubernetes, service mesh, or load-balancer farm.
- No microservices split (in-process ingest+WS is correct at this scale).
- No managed message bus / cloud-native data lake.
- No heavyweight IdP/OAuth for ~20 users; no per-seat SaaS.
- No parent-facing access in MVP (deferred — enlarges authz + consent).
- No device-to-internet TLS (the relay design removes the need); no custom CA beyond a simple club mTLS pair.
Proposed NFR additions¶
These security NFRs are introduced by this design and should be folded into non-functional-requirements.md:
| ID | Requirement | Target |
|---|---|---|
| NFR-SEC-1 | No anonymous access to location data | enforced at broker, local WS, and relay |
| NFR-SEC-2 | Per-device identity + topic ACL; reject spoofed playerId |
mosquitto ACL + ingest.ts |
| NFR-SEC-3 | TLS on every internet hop | Caddy + mTLS field↔relay |
| NFR-SEC-4 | Encryption at rest for location data | OS FDE (SQLCipher later) |
| NFR-SEC-5 | Retention limit + right-to-erasure for minors' data | 30-day purge + purge-player |
| NFR-SEC-6 | Audit log of location-data access | relay append-only log |
| NFR-SEC-7 | Field box exposes no inbound internet port | outbound-only relay link |
| NFR-RT-2 | Remote (off-site) live view | best-effort < 2 s (softer than NFR-RT-1) |
References: architecture brief · system-architecture.md · observability.md · ADR-0006–0014. NFR codes per non-functional-requirements.md.