Files
felhom.eu/documentation/audits/night-burndown-2026-10-06/design-R-861.md
T

95 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# R-861 — the three sudoers leftovers — design proposal (burn-down night 2026-10-06, no code)
Baselines read: felhom-agent `74b5eae`, felhom.eu `8e2dc204`. Architecture: `03-host-agent.md` §3.1 (the group table)
and §11, `11-os-updates.md` §5.4.2 (the config bundle), `09` §3 decision 122. Read only; nothing ran on a box.
## 1. The problem
After agent v0.146.1 a compromised agent PROCESS (user `felhom-agent`) no longer becomes host root: measured 2026-10-05,
29 attack lines refused, `sudo -l` 93/93 on both demo boxes (`audits/hub-safety-2026-10-05/partF/`). Three paths were
left open on purpose and named in `03` §3.1. The row asks the operator: accept them, or close them before the first
paying customer? The question for each is: **what does an attacker who already owns the agent process gain through it,
beyond what the agent already has?**
What the agent already has (the baseline): its Proxmox token holds `VM.Backup`, `VM.Allocate`, `VM.Config.*`,
`VM.PowerMgmt`, `Pool.Allocate` on `/pool/felhom` (`felhom.eu/scripts/felhom-host-install.sh:309`). So it can already
back up a customer guest and restore it into a scratch guest (the restore-test does exactly that), stop and start felhom
guests, and it writes the guest's `bootstrap.json` itself. **The household's data is already in its reach, offline.**
## 2. What the code does today (read in source)
**(a) The controller image ref.** `FELHOM_CONTROLLERSWAP` allows `pct exec <vmid> -- tee /etc/felhom-controller-image`
(`felhom-agent/configs/felhom-agent.sudoers:122`). The ref goes in on STDIN (`internal/localapi/controllerswap.go:150`).
The regex `controllerImageRe` (`controllerswap.go:36`) is the agent's OWN check — a compromised agent skips it, and sudo
cannot see stdin. The guest's bootstrap unit then runs `docker run … "$IMAGE"` with the docker socket, the read-only
bootstrap dir and `/mnt` (`configs/build-golden.sh:316,355-362`).
**Correction to `03` §3.1:** the table says *"a chosen (pinned-registry) image ref"*. That is not true: only the
`docker image inspect` line is pinned (`sudoers:119`); the `tee` content is free, so the bootstrap pulls and runs **any
image from any registry**. Gain over the baseline: a LIVE foothold in the guest with the docker socket (guest root, the
household's running apps and its LAN), and it survives agent restarts until the next swap. Not host root.
**(b) The felhom-op SSH key.** `felhom-priv-apply sshd-key` installs one plain key line (no `command=`/`from=`;
`configs/felhom-priv-apply:60-61`) from a file the agent staged; the key itself comes from the hub, unsigned. A
compromised agent can therefore put its own key on `felhom-op`. `felhom-op`'s sudo (`configs/felhom-op.sudoers`) is
scoped: restart wg/agent/sshd, `pct list`, and `pct start|stop|unlock [0-9]*` — **on any guest of the host**, not only
the felhom pool. Gain over the baseline: power control of NON-felhom guests on a BYO host (the household's own other
VMs), and an interactive login. Not host root. (Side note: these `pct` lines still use the `*` glob, which matches
spaces — the R-861 shape 1; no harmful `pct start/stop` option is known, so this is hygiene, not a hole.)
**(c) The escrow ceremony.** The root child (`FELHOM_ESCROW`, `sudoers:283-284`) returns the recovery code R on the
agent's stdout pipe (`internal/localapi/escrow_ceremony.go:19-29,93`); the agent holds R in memory for one claim
(`:140-147`). The agent's own hub key may read this box's escrow blob (`hub/internal/api/handler.go:252,1357-1372`,
self-scoped). So a compromised agent can learn R, fetch the blob and unwrap this box's PBS encryption key (and the
identity bundle). Gain over the baseline: **off-box** decryption of this box's off-site archives, which lasts after the
compromise is cleaned up, until the key is rotated. One box only; not root.
## 3. Options
**(a)**
- **A1. A checking wrapper verb.** `felhom-priv-apply controller-image <vmid>`: as root, read stdin, require
`^gitea\.dooplex\.hu/admin/felhom-controller:[0-9]+\.[0-9]+\.[0-9]+$`, then write the guest file. The sudoers `tee` line
is removed. Same mechanism as decision 122 (b); delivered by the signed config bundle. Cost: ~1–2 h (one verb, its
Python tests, the Go call path, `TestManifestCoveredBySudoers`, the capability probe). What can go wrong: the old
agent binary still calls `tee` → the bundle must follow the agent update (the usual order). Leaves: a chosen OLD
controller version from our registry (a downgrade) — still possible.
- **A2. Check in the guest.** The bootstrap script refuses a ref outside the pattern. Cost: a golden change, and
existing guests keep the old script until re-baked — slow to reach the fleet.
- **A3. Accept.** Write the corrected sentence in `03` §3.1.
**(b)**
- **B1. Sign the key.** The operator signs the felhom-op key with the operator key; `felhom-os-apply` verifies as root.
Cost: medium; every key rotation needs the operator's offline signature.
- **B2. Narrow felhom-op's sudo** to anchored regexes (`^start [0-9]+$` …) — hygiene only; it does not stop the key swap.
Cost: ~30 min, rides the bundle.
- **B3. Accept** (felhom-op is not root; the gain is power control of guests).
**(c)**
- **C1. R bypasses the agent.** The root child writes R straight into the guest (to the controller), so the agent never
sees it. Cost: a ceremony redesign across agent and controller; touches the escrow promise to the household.
- **C2. Accept** — the ceremony is designed so the box handles K once; the residual is "a compromised agent can read
this one box's backups off-site", which `03` §3.1 already names.
## 4. The pick — PROPOSAL for the operator, not a decision
- **(a) A1, before the first paying customer.** It is the only one of the three that gives a live, persistent foothold
next to the household's running apps, and the fix uses a mechanism already built and measured. Fix the `03` sentence
in the same commit.
- **(b) B3 now, B2 as hygiene** with the next bundle; B1 later if the OOB door is ever opened wider.
- **(c) C2.** C1 changes an escrow promise and is a redesign — not before the first customer.
## 5. First slice and its proof (for A1)
- Build: verb `controller-image` in `configs/felhom-priv-apply` (stdin ≤ 256 bytes, the regex, a numeric vmid, then
`pct exec <vmid> -- tee` as root); sudoers: remove `tee /etc/felhom-controller-image$`, add
`/usr/local/sbin/felhom-priv-apply ^controller-image [0-9]+$`; `controllerswap.go:150` calls the verb.
- Red test first: `configs/test_felhom_priv_apply.py` — `controller-image 9201` with stdin
`docker.io/library/alpine:latest` must exit 3; with `gitea.dooplex.hu/admin/felhom-controller:0.301.0` exit 0. And
`TestSudoersRefusesTheR861Injections` gains `pct exec 9201 -- tee /etc/felhom-controller-image` as a REFUSED line —
it fails on today's sudoers.
- Live proof on scratch 9202 after a bundle there (not tonight): `sudo -l -U felhom-agent` lists no `tee`; a managed
controller update still swaps (positive observable: the new controller version in `docker ps` AND the hub's host
report — two channels); a hand-fed `alpine` ref is refused (journal tag `felhom-priv-apply`).
## 6. Open questions for the operator
1. **(a)** Close the free image ref before the first paying customer (A1, ~1–2 h, ships with a bundle)? If you do
nothing: a compromised agent can run any container next to the household's apps, with the docker socket.
2. **(b)+(c)** Accept both for the first customers (felhom-op is not root; the escrow residual is one box's off-site
backups)? If you do nothing: they stay open, named in `03` §3.1, as today.