6.0 KiB
Architecture Part 13 — Threat model
Status: draft (security review 2026-10-12,
audits/security-review-2026-10-12/). Marks as in01: [DESIGN] a decision taken; [FACT] an observation with a citation. Unmarked = not yet classified. Findings named here asR-…are register rows; for open ones the detail is held off-git.
1. Starting fact
[FACT] The controller, agent, felhom.eu and catalog repositories are readable by anyone without a login
(measured 2026-10-12; homelab-manifests is private). Assume the attacker has read all product source. A guard
that depends on the code being secret is not a guard here. Whether the repositories stay public is the operator's
decision (R-925).
2. Assets, most harmful loss first
- Household app data — photos, documents, passwords in the household's apps (the customer guest, its data drive, and the backups).
- Household logins — the dashboard password, family members, each app's admin.
- Box credentials — the box's hub API key, the local-API token, the tunnel token, the agent's Proxmox token, the vaulted break-glass root credential.
- Hub data — customer records and reports, escrowed (sealed) backup keys, Cloudflare tokens, the global key.
- Operator signing keys — they decide what a box accepts (R-924).
- Off-site backups and their keys — ciphertext on ep0 and the Storage Box.
- Gitea, the registry and the installer — they decide what code runs on every box (R-971, R-979, R-980).
3. Attackers and what they start with
| attacker | starts with | |
|---|---|---|
| a | a stranger on the internet | the public source; the tunnel addresses of every app and dashboard; the hub's public name (from an allowed country) |
| b | someone on the household LAN | the box's LAN address; the host's and the guest's LAN ports (measured: SSH, the Proxmox UI, DNS, rpcbind) |
| c | a household member with a family account | a family session |
| d | a bad catalog app (upstream image turned bad) | code in one container on the guest's shared Docker daemon |
| e | a compromised controller | root in the guest, the docker socket, the local-API token, the box's hub key |
| f | a compromised hub (or a copy of its database) | every box's hub key, sealed escrow blobs, Cloudflare tokens |
| g | a compromised Gitea / registry / build | push to the catalog, the images and the installer tag |
| h | a thief with the box | the disks |
| i | Cloudflare | the plaintext of tunnel traffic — accepted (R-904) |
4. Entry points
How enumerated (re-runnable): none of the three uses a router library; each registers a few ServeMux patterns
and dispatches in switch blocks, so the cases were parsed from source, and auth was read by hand per handler. The
scripts are held with the review's working notes.
| component | routes | no credential | listeners |
|---|---|---|---|
| controller | 231 (+8 in setup mode) | 21, of which 7 change state, each behind its own password, code or token | port 8080 on the guest's Docker networks (not published); mail relay 2525/2465/2526 |
| hub | 119 (1 health, 53 API, 65 web) | appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health | one ingress, path /, geo filter (HU, IE, LAN) |
| agent | 37 local-API routes | 0 | local API on the island bridge; WireGuard; poke (tunnel only); felhom-sshd (tunnel only, measured) |
Outbound fetches that bring code or config onto a box: the catalog (git, main), app images, the four infra
images, the controller image (by tag), controller.yaml from the hub, the agent binary and config bundle (signed),
OS packages (exact versions, apt-signed), the installer script (by tag, R-980).
5. The trust table of 01 §5, checked
| boundary | verdict | evidence |
|---|---|---|
| end-user ↔ apps | holds, with gaps | traefik routing and header stripping hold; homepage has the Docker socket and no gate (R-981) |
| customer ↔ controller UI | partly | bcrypt and session hold; small gaps (R-983); family sessions survive a reset (R-976) |
| controller ↔ agent | partly | every route bound to the caller's guest; a compromised controller can still flush restore points (R-973) and void the recovery code (R-978) |
| agent ↔ hub | holds for destructive ops | signed jobs verified in the documented order; unsigned spec has no size bounds (R-984) |
| controller ↔ hub | did not hold until 2026-10-12 | the global key was public (R-950, rotated) and three routes needed no key (R-951, fixed); per-box keys are bound to their box (R-955) |
| box ↔ PBS | holds as written | ciphertext only; the zero-knowledge wording is stronger than a live box allows (R-985) |
| guest ↔ Proxmox host | holds | the guest has no Proxmox credentials (measured) |
| hub ↔ Cloudflare API | holds for the zone check | tokens are stored unsealed in the hub database (R-975); R-137 open |
| added: app ↔ app (shared Docker daemon) | partly | separate containers and networks; any docker-socket app is root in the guest (R-981) |
| added: app ↔ controller | partly | apps share a network with the controller; dashboard routes still need a session |
| added: LAN ↔ box | partly | root password SSH on the host from the LAN (R-982) |
| added: hub ↔ box (inbound) | does not hold | 01 §4 says the box exposes no control endpoint; a hub-key route can replace the box's settings (R-970) |
| added: catalog ↔ every box | design gap | a catalog push reaches every box with no check on the box (R-971) |
6. What we accept (do not re-report)
- Cloudflare can read tunnel traffic — R-904,
09§3 decision 184. - Signing keys on DooPlex with no passphrase until the first sale — R-924,
04§3.1 (operator ruling 1). - Public source — the operator's decision (R-925).
- A pasted Cloudflare token is checked for its zone only; the recipe guards the write side —
09§3 decision 226. - Per-name family sign-in lock —
01§5 (a stranger can lock one member out for 10 minutes; same shape as R-775).