diff --git a/REPORT-hub-tokens.md b/REPORT-hub-tokens.md index a4ad7e0f..cd222701 100644 --- a/REPORT-hub-tokens.md +++ b/REPORT-hub-tokens.md @@ -1,98 +1,59 @@ -# REPORT — hub access tokens (hub v0.152.0), 2026-10-15 +# REPORT — hub v0.153.0: no token sends, mints or reveals a household credential or link (2026-10-15) + +The previous report (hub v0.152.0, access tokens, deploy and CC's switch to its token): +`git show 5d78569b:REPORT-hub-tokens.md`. ## For the operator — plain words -**What a token can do.** A token lets a script use the hub without your password. You manage tokens on -**Configuration → Access tokens**. You give a name, tick what it may do, and pick 30, 90, 180 or 365 days. The hub -shows the token once. It keeps only a scrambled form. Every use is logged with the token's name. +**What I did.** Your ruling (13:20, R-1055 = A) is built. No token can now: +- resend a claim code, +- create a customer, +- edit a customer (the edit can change the household's e-mail), +- create a config from a box's report. -**What no token can do.** Show a box's emergency root password. Show a household's passphrase or controller key. -Delete or reset anything. Change the password. Create or revoke tokens. Reach the box API (`/api/v1`). +These need you, signed in with the browser. Three were already password-only and stay so: the self-bind link, a new +passphrase, binding an appliance. „Customers: write" keeps only block, unblock and geo disable. -**Is CC using its token now?** Yes. Hub 0.152.0 is live (your „yes", 2026-10-15). With the token „cc-releases": -- a floor save with the same values (0.313.0, MinAgent 0.155.0) worked, and the hub database shows it; -- the vouch was refused: „missing scope vouch:write"; -- the customer page and the emergency-password reveal were refused: „session only". -All three boxes reported to the new hub after the restart. +**Did it work?** Yes. A new test gives a token with every permission to all seven routes. Each answers 403. The +household's e-mail and passphrase stay the same, and no customer is created. I broke the rule on purpose twice; the +test failed both times. All hub tests pass. + +**Is it live?** No. Hub 0.153.0 waits for your „yes". **What you do now.** -1. Change the hub password. CC no longer needs it. -2. Delete the `HUB_PW` line in `~/.config/credentials` on DooPlex. CC does not delete it. -3. Your token is wider than the plan: it can also change off-site windows, re-issue off-site keys, freeze off-site, - and approve OS updates. CC uses only the floor. Re-create it narrower if you want; CC will not widen or narrow it. -4. Decide R-1055: should „resend claim" need your password? My pick: yes. -5. Proof-install clean-up (discarding an unclaimed appliance) is password-only now. CC will ask you to press Discard. +1. Say „yes" to deploy hub 0.153.0. +2. Decide R-1059: a token with „Off-site: write" (yours has it) can re-issue a household's off-site password. It never + sees the password, but the backup can stop until the box picks the new one up. Keep it, or password-only? + My pick: password-only. -**Side effect.** Each floor save (two today, both with the same value) mailed you „demo-hp's own floor 0.311.0 -blocks the global floor". That is the old item: clear demo-hp's own floor. +## The Customers-write routes, checked one by one (`hub/internal/web/routes.go`) -**Was the hub ever open without a password?** The code allowed it. The live hub was never open (it asked for a login, -and the manifest carries a password hash). Fixed: no password now means every operator page refuses. - -## What was built - -- Token store `api_tokens` (SHA-256 only), the guard in `RequireAuth`, the route table (`hub/internal/web/routes.go`), - the Configuration card, the box API's refusal of tokens, the box API's route table (`hub/internal/api/routes.go`). -- Design: `documentation/architecture/05-hub-architecture.md` §17 (read first, with §16). Also `04` §6.2, `13` §4/§5, - `09` §3 decision 251. -- Go gate in `hub/`: `go build ./... && go vet ./... && go test ./...` → rc 0. - -## Red-proofs — each guard broken on purpose, its test seen failing, then restored - -Evidence: `documentation/audits/hub-tokens-2026-10-15/red-proof/` (one file per case; each shows `=== RUN` for the test). - -| # | guard broken | test | failed with | -|---|---|---|---| -| 1 | operator-action entry removed from the route table | `TestRouteTable_MatchesServeHTTPSwitch` | route 21: switch has `/hosts/…/operator-action`, table has `/request-logs` | -| 2 | session-only allowed for tokens | `TestToken_SessionOnlyRoutesRefuseEveryToken` | `POST /` with an all-scope token: 200 | -| 3 | expiry not checked | `TestToken_NoTokenUnknownExpiredRevoked401` | expired token: 200, want 401 | -| 4 | group:read accepted on a write route | `TestToken_ScopeDecides` | floor:read token on floor save: 303; the floor changed | -| 5 | plain token stored | `TestAPIToken_StoredRowHasNoPlainToken` | t.db holds the plain token | -| 6 | old empty-password skip restored | `TestRequireAuth_NoPasswordRefusesEveryOperatorRoute` | no password: GET / → 200 | -| 7 | Basic passes without the header | `TestToken_CSRFRuleUnchangedForBasic` | Basic without X-Felhom-Operator: 303 | -| 8 | handler's own token check removed | `TestToken_CannotManageTokens` | handleTokenCreate under a token: 200; 2 rows | -| 9 | `GET /customers` removed from the API table | `TestAPIRouteTable_MatchesSwitch` | route 36 mismatch | -| 10 | `requireGlobalKey` returns true | `TestAPIRoutes_NoCredential` | GET /customers with no credential: 200 | -| 11 | `fhp_` check removed from the API | `TestAPI_AccessTokenIsRefusedEverywhere` | POST /report with an access token: 401 (not the 403 refusal) | -| 12 | main mounts `ServeHTTP` bare | `TestWebMountAlwaysAuthenticated` | 0 via Handler(), 1 other | - -## Claims in the task that turned out wrong - -- „Hungarian UI, same style as the page": the Configuration page and the whole hub UI are English (the design system: - operator surfaces are English). The card is English, like the page. -- „Mail hold — read: the held mails": the hub keeps no held mail; a hold drops it (`internal/mailhold`). Mail hold has - only a write tick (release). Releases: vouch has only a write tick too — the Day-0 manifest has no page of its own. -- „`dr-recipe.json` if it carries a secret": it carries none (`handleDRRecipeDownload`: „secret-free"); it is - `customers:read`. -- Not in the task, found: the customer page embeds the owner passphrase and the controller API key, and the config - preview carries the controller key — both session only. - -## Recommendations not followed (one line each) - -- „Customers write: resend claim" — followed as written, but a stolen `customers:write` token can then redirect a - household's claim code (edit e-mail, resend). Filed R-1055 for your decision; my pick is password-only. -- „Off-site read: key audit" — followed; it is a POST that runs the check now and changes no key. - -## Scripts and runbooks moved from the password to the token (Part C4 — done) - -| where | what | scope it needs | +| route | now | reason | |---|---|---| -| `skills/felhom-build-deploy/SKILL.md` release step 5 | global floor save | `floor:write` — the CC token has it | -| `skills/felhom-build-deploy/SKILL.md` ISO section | discard a proof install's appliance | **session only** — no token can; stays yours or stays the password until you place it | -| `~/git/misc-scripts/gitea-image-prune.sh` | reads floor, golden, agent from the Configuration page | `floor:read` — the CC token has it | -| `documentation/runbooks/RUNBOOK-manual-build.md` §prune | describes the script above | — | +| `POST /configs/{id}/claim-resend` | session only | sends a fresh claim code to the household's e-mail | +| `POST /configs/{id}/edit` | session only | can change the household's e-mail; a new e-mail auto-mails the self-bind link | +| `POST /configs/new` | session only | mints the owner passphrase and controller API key; takes the e-mail; mails the self-bind link | +| `POST /customers/{id}/create-config` | session only | mints the owner passphrase and controller API key | +| `POST /customers/{id}/selfbind-link` | session only (since v0.152.0) | mints and mails a self-bind capability link | +| `POST /configs/{id}/regen-password` | session only (since v0.152.0) | mints a new owner passphrase | +| `POST /appliances/{id}/bind` | session only (since v0.152.0) | delivers the household's passphrase to the box that asks | +| `POST /customers/{id}/block`, `/unblock`, `/geo/disable` | `customers:write` | no credential, link or e-mail | -Moved: the skill's floor step (the recipe was run verbatim: 303 `flash=floor_set`), `gitea-image-prune.sh` (misc-scripts; -its test passes, a live dry-run read 6 in-use versions through the token, deleted 0), the runbook line. The appliance -discard is password-only: the skill now says to ask you. Nothing CC runs reads `HUB_PW` any more. +The GET forms (`/configs/new`, `/configs/{id}/edit`) stay `customers:read`: they show no passphrase or key (checked in +the templates). -## Register +## Proof -Before 155 · after 156 · opened 3 (R-1054 global API key's future, R-1055 claim-resend chain, R-1056 five routes -with no group) · closed 2 (R-952, R-1051). +- Test `TestToken_CredentialAndLinkRoutesAreSessionOnly` (`hub/internal/web/tokens_test.go`). +- Red-proofs: claim-resend put back in the group → the handler was reached (502, no claim engine in the test); + edit put back → 200. Files: `documentation/audits/hub-tokens-2026-10-15/red-proof/13-claim-resend.txt`, `14-config-edit.txt`. +- `go build ./... && go vet ./... && go test ./...` in `hub/` → rc 0. + +## Records + +`09` §3 decision 252; `05` §17.2, §17.3, §17.5; hub CHANGELOG v0.153.0; STATUS. +Register: before 157 · after 157 · opened 1 (R-1059, off-site re-issue) · closed 1 (R-1055). ## Teardown -Machine: nothing provisioned. Host: nothing. Hub: v0.152.0 deployed (manifest `2184897`); two floor saves with the -unchanged value; no test token created by CC (the only token is the operator's). Evidence: -`documentation/audits/hub-tokens-2026-10-15/` (`deploy.txt`, `C3-token-live.txt`, `red-proof/`). +Nothing provisioned. No live change: the hub still runs 0.152.0. diff --git a/STATUS.md b/STATUS.md index f826bac2..3bf10a79 100644 --- a/STATUS.md +++ b/STATUS.md @@ -44,7 +44,7 @@ ## Waiting on you — do - **Change the hub password now — CC no longer needs it.** CC uses its token „cc-releases" (tested live: the floor works; the vouch and the password-only pages are refused). Then delete the `HUB_PW` line in `~/.config/credentials` on DooPlex (CC does not delete it). Your token is wider than planned (it can also change off-site windows and approve OS updates) — revoke and re-create it narrower if you want. If you do nothing: CC still holds a password that can do everything. -- **Decide (R-1055):** a token with „Customers: write" can change a household's e-mail and resend its claim code to it. Keep that, or make „resend claim" password-only? **My pick: password-only** (one line). If you do nothing: it stays as you wrote it; nobody holds such a token now. +- **Hub 0.153.0 is ready (your ruling 13:20): no token can resend a claim code, create or edit a customer, or make a passphrase or a link.** May I deploy it? Say „yes". If not: on the live hub a token with „Customers: write" can still do those four. **Decide (R-1059):** a token with „Off-site: write" (yours has it) can re-issue a household's off-site password — it never sees it, but the backup can stop until the box picks the new one up. Keep, or password-only? **My pick: password-only.** If you do nothing: it stays. - **Remove the test server „plex-k6” from your Plex account** (plex.tv → Settings → Authorized Devices). The claim test passed and the server is gone from Tester 1. If not: a dead server stays listed in your Plex apps. - System poster needs a refresh: every public name answers only over https (http → 301). - **Clear demo-hp's own floor** (hub → Demo HP → floor: empty → Save floor). It still reads 0.311.0. If not: the next global raise passes demo-hp by. diff --git a/documentation/architecture/05-hub-architecture.md b/documentation/architecture/05-hub-architecture.md index 034971e7..7c94b065 100644 --- a/documentation/architecture/05-hub-architecture.md +++ b/documentation/architecture/05-hub-architecture.md @@ -551,7 +551,7 @@ Configuration, which `floor:read` opens; the mail hold is a banner, and held mai | `overview` | dashboard, customer list (`/configs`), hosts list, apps, off-site page, system page | — | | `floor` | Configuration page, global-floor impact | global floor, per-customer floor | | `vouch` | — | `POST /configuration/artifacts` (golden, agent, MinAgent) | -| `customers` | new/edit forms, DR recipe (secret-free) | create, edit, block/unblock, geo disable, create-config, claim resend | +| `customers` | new/edit forms, DR recipe (secret-free) | block/unblock, geo disable (create, edit, create-config, claim resend: session only since v0.153.0, decision 252) | | `hosts` | host page, log bundles, log tails | operator actions, log-bundle and log-tail requests | | `offsite` | run the key check now (it reads, changes no key) | window grant, weekly windows on/off, off-site and PBS-DR re-issue, freeze | | `os` | the fleet queue (`/os/fleet`) | approve (guest, Docker, Proxmox, kernel), ring, on/off | @@ -602,13 +602,13 @@ method (405), so a token gets 403. | `/customers/{id}/unblock` | — | `customers:write` | | | `/customers/{id}/geo/disable` | — | `customers:write` | | | `/customers/{id}/floor` | — | `floor:write` | | -| `/customers/{id}/create-config` | — | `customers:write` | | +| `/customers/{id}/create-config` | — | **session only** | Mints the household's owner passphrase and controller API key (operator ruling 2026-10-15 13:20, `09` §3 252). | | `/customers/{id}/dr-recipe.json` | `customers:read` | — | | | `/customers/{id}/request-log-tail` | — | `hosts:write` | | | `/customers/{id}/log-tail/{x}` | `hosts:read` | — | | | `/customers/{id}` | **session only** | **session only** | The customer page embeds the owner passphrase and the controller API key (customer_unified.html). | | `/configs` | `overview:read` | — | | -| `/configs/new` | `customers:read` | `customers:write` | | +| `/configs/new` | `customers:read` | **session only** | Create mints the passphrase + API key, takes the household's e-mail and mails the self-bind link (ruling 13:20). | | `/configuration/global-floor/impact` | `floor:read` | — | | | `/configuration/global-floor` | — | `floor:write` | | | `/configuration/artifacts` | — | `vouch:write` | | @@ -617,7 +617,7 @@ method (405), so a token gets 403. | `/configuration/tokens` | — | **session only** | | | `/configuration/tokens/{id}/revoke` | — | **session only** | | | `/configs/{id}/delete` | **session only** | **session only** | | -| `/configs/{id}/edit` | `customers:read` | `customers:write` | | +| `/configs/{id}/edit` | `customers:read` | **session only** | Edit can change the household's e-mail — and a new e-mail auto-mails the self-bind link (ruling 13:20). | | `/offsite/remove-unpinned/{id}` | — | **session only** | | | `/offsite/abandon-cancel/{id}` | — | **session only** | Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only. | | `/offsite/window-grant/{id}` | — | `offsite:write` | | @@ -625,7 +625,7 @@ method (405), so a token gets 403. | `/os/{id}` | `os:read` | `os:write` | | | `/offsite/key-audit` | — | `offsite:read` | Runs the daily key check now: it reads keys and reports, it changes none. | | `/configs/{id}/offsite-reissue` | — | `offsite:write` | | -| `/configs/{id}/claim-resend` | — | `customers:write` | | +| `/configs/{id}/claim-resend` | — | **session only** | Sends a fresh claim code to the household's e-mail (R-1055, operator ruling 2026-10-15 13:20). | | `/configs/{id}/pbsdr-reissue` | — | `offsite:write` | | | `/configs/{id}/offsite-freeze` | — | `offsite:write` | | | `/configs/{id}/preview` | **session only** | — | The preview is the generated controller config — it carries the controller API key. | @@ -633,7 +633,9 @@ method (405), so a token gets 403. | `/configs/{id}/reset` | **session only** | **session only** | | | `/configs/{id}` | `overview:read` | `overview:read` | | -**Session only, by reason:** a secret is shown or minted (reveal recovery credential; the customer page — it embeds the +**Session only, by reason:** a household credential or link is sent, minted or its e-mail changed (claim resend, +customer create and edit, create-config — `09` §3 decision 252, `TestToken_CredentialAndLinkRoutesAreSessionOnly`); a +secret is shown or minted (reveal recovery credential; the customer page — it embeds the owner passphrase and the controller API key; the config preview — the generated controller config carries that key; regen-password; the self-bind link; an appliance bind — it delivers the passphrase to the box that asks); something is deleted or reset (customer delete and its preview, reset and its preview, host delete and its impact, off-site @@ -659,9 +661,13 @@ replace the global key yet (R-1054). vouched golden needs a declared MinAgent, R-472) — and read the overview pages. It cannot see any secret, delete or reset anything, vouch a golden or agent, release the mail hold, or touch a box's API. Revoke on the card; it stops at once (no cache). -- **A `customers:write` token** can change a household's e-mail and then resend its claim code — the code goes to the - new address. That is a takeover path for the household's dashboard password; it is in the group because the operator - table put „resend claim" there. Decision open (R-1055). +- **A `customers:write` token** could, in v0.152.0, change a household's e-mail and then resend its claim code there — a + takeover path. Closed in v0.153.0 (operator ruling 2026-10-15 13:20, `09` §3 decision 252, R-1055): every route that + sends, mints or reveals a household credential or link, or changes the e-mail, is session only. The group keeps block, + unblock and geo disable. +- **An `offsite:write` token** can re-issue a household's off-site or PBS-DR credential (a fresh one-time secret staged + for the box). It never sees the secret; it can break a household's backup until the box picks the new one up. Open + for the operator (R-1059). - **No token** can reach the break-glass password, the escrow, the off-site keys, or any destructive verb. ### 17.6 No operator password configured = closed diff --git a/documentation/architecture/09-update-architecture.md b/documentation/architecture/09-update-architecture.md index 1b2a212a..7f2f2118 100644 --- a/documentation/architecture/09-update-architecture.md +++ b/documentation/architecture/09-update-architecture.md @@ -939,6 +939,12 @@ its length, and both fixes cost something the household would notice — operato reset, the password, the tokens. CC gets one token, `overview:read` + `floor:write` — not the vouch (decision 250 item 3). **Until that token works live, CC may use the operator password ONLY to set the floor.** After it works, the operator changes the password so CC no longer holds it. Amends decision 250 item 2's *Route* sentence (R-1051). +252. **No token sends, mints or reveals a household credential or link, or changes a household's e-mail.** *Operator + ruling 2026-10-15 13:20 (R-1055 = A).* Session only (the operator signed in with the browser), never by any token: + claim resend, customer create (`/configs/new`), customer edit (it changes the e-mail, and a new e-mail mails the + self-bind link), create-config from a report (mints passphrase + API key) — plus the three already session only: + the self-bind link, regen-password, appliance bind. „Customers: write" keeps block, unblock and geo disable. Pinned by + `TestToken_CredentialAndLinkRoutesAreSessionOnly` (hub v0.153.0, `05` §17.3). Amends decision 251's groups. ### 2026-10-15 (07:48–08:20) — bake first; Plex through its own key; HSTS one day (recorded before the media build) diff --git a/documentation/architecture/13-threat-model.md b/documentation/architecture/13-threat-model.md index ddcd4eaa..9c6efac4 100644 --- a/documentation/architecture/13-threat-model.md +++ b/documentation/architecture/13-threat-model.md @@ -65,7 +65,7 @@ OS packages (exact versions, apt-signed), the installer script (by tag, R-980). | **added (R-1007):** controller → an app's own job queue | holds — nothing new | the `after_unclean_boot` commands run inside the app's container through the docker socket the controller already holds, and read the app's database with a read-only session: no key into the household's account, nothing a compromised controller could not already do (exec in any app, read its database). The household's lost-file names stay on the box (`app.yaml`, the app page); the operator event carries counts only, and a failing command's output is never logged (security review of controller `8d862e4`, fixed in `3b85a0c`) | | **added:** agent ↔ root on the host | holds by the sudoers | every root verb is one regex-anchored line or a root wrapper that re-checks its input (`03` §3); agent v0.156.1 adds one delete: `felhom-vzdump-partial-clean` removes only a DEAD half-written vzdump of the named guest on a `dir` storage (no vzdump on the host, nothing holding it open) — a compromised agent gains the deletion of a leftover that is not a backup | | agent ↔ hub | holds for destructive ops | signed jobs verified in the documented order; unsigned spec has no size bounds (R-984) | -| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. Open: a `customers:write` token can redirect a claim code (R-1055); the global API key is still one all-powerful bearer (R-1054) | +| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. No token sends, mints or reveals a household credential or link since v0.153.0 (`09` §3 decision 252, R-1055 closed); open: an `offsite:write` token can re-issue a household's off-site credential (R-1059); the global API key is still one all-powerful bearer (R-1054) | | 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) | diff --git a/documentation/audits/hub-tokens-2026-10-15/red-proof/13-claim-resend.txt b/documentation/audits/hub-tokens-2026-10-15/red-proof/13-claim-resend.txt new file mode 100644 index 00000000..5130ccfe --- /dev/null +++ b/documentation/audits/hub-tokens-2026-10-15/red-proof/13-claim-resend.txt @@ -0,0 +1,6 @@ +=== RUN TestToken_CredentialAndLinkRoutesAreSessionOnly + tokens_test.go:579: POST /configs/c1/claim-resend (sends a claim code to the household's e-mail) with an all-scope token: 502 "Claim engine is not configured on this hub\n", want 403 session only +--- FAIL: TestToken_CredentialAndLinkRoutesAreSessionOnly (0.05s) +FAIL +FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.072s +FAIL \ No newline at end of file diff --git a/documentation/audits/hub-tokens-2026-10-15/red-proof/14-config-edit.txt b/documentation/audits/hub-tokens-2026-10-15/red-proof/14-config-edit.txt new file mode 100644 index 00000000..9643e4b2 --- /dev/null +++ b/documentation/audits/hub-tokens-2026-10-15/red-proof/14-config-edit.txt @@ -0,0 +1,6 @@ +=== RUN TestToken_CredentialAndLinkRoutesAreSessionOnly + tokens_test.go:579: POST /configs/c1/edit (changes the household's e-mail (a new e-mail mails the self-bind link)) with an all-scope token: 200 "\n\n
\n \n \n