ed8b10330c
gates / gates (push) Successful in 2m44s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
95 lines
7.5 KiB
Markdown
95 lines
7.5 KiB
Markdown
# 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.
|