feat(security): require the node password to grant Trusted
Demo images / Build & push demo images (push) Successful in 4m22s

Promotion to Trusted is a privilege escalation — a Trusted peer can read
node state, be deployed to, and is exempt from the `!= Untrusted` gates
federation/DWN/messaging use. It must therefore cost a fresh proof that
the person at the keyboard is the operator, not merely that a session
cookie exists. Same reasoning as node.rotate-identity and TOTP setup,
both of which already re-verify.

Both entry points are covered:

- `federation.invite` gates on the RESOLVED level, not on an explicit
  request for Trusted: "Link Your Nodes" sends no `trust_level` at all
  and falls through to the Trusted default. The invite is a bearer grant
  of Trusted to whoever redeems it, so minting it IS the escalation.
  Observer invites are untouched.
- `federation.set-trust` gates only when the peer is not already
  Trusted, so the dropdown re-emitting its own value doesn't demand a
  password for a no-op.

Demotion is deliberately NOT gated: making something less privileged
must never be harder than leaving it alone, or the safe action becomes
the inconvenient one.

The backend is the sole authority on what counts as an escalation — it
returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and
retries only on that, so the rule lives in exactly one place and the
frontend never pre-judges. TrustPasswordModal.vue (modelled on
RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps
back to the node's real level on change, since a cancelled or failed
promotion would otherwise leave the dropdown displaying a level the node
never accepted.

The operator path stamps TrustSource::Manual; set_trust_level grew an
`Option<TrustSource>` so automatic adjustments (the discovery-handshake
demotion safety net) pass None and leave the recorded provenance alone
rather than laundering an uninvited-join peer into looking approved.

Follow-up, deliberately out of scope: `federation.join` also reaches
Trusted when redeeming someone else's Trusted invite, with no re-auth.

Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-03 13:07:21 -04:00
co-authored by Claude Opus 5
parent f0b71f86aa
commit 24ce8b39e8
10 changed files with 587 additions and 32 deletions
+195 -13
View File
@@ -27,6 +27,143 @@ Status key: **DONE** (committed) · **READY** (written, not yet committed/tested
- Scope note: `fips/app_ports.rs` holds the mesh allowlist; `is_peer_allowed_path` in
`server.rs` holds the peer HTTP allowlist. Neither currently authenticates app ports.
#### Research — umbrelOS (verified from their docs/source, 2026-08-03)
umbrelOS solves this **architecturally, not per-app**: the app's own port is never
published. Each app gets a sidecar `app_proxy` container that owns the published port and
forwards to the app on the internal network.
- `containers/app-proxy` is described as *"a transparent HTTP proxy to add authentication
to Umbrel apps"* — **every** HTTP request and WebSocket upgrade passes through it and
has its session token checked.
- Tokens come from a separate `app-auth` service; the proxy talks to it over a local port
(default 2000) with a shared secret (`UMBREL_AUTH_SECRET`). Two JWTs exist: an **API
token** in localStorage (`{loggedIn: true}`) for the dashboard's own API, and a
**proxy token** in an **HttpOnly cookie** (`{proxyToken: true}`) for app access. Both
HS256, 7-day expiry.
- Unauthenticated requests are redirected to the login screen.
- Per-app escape hatches, all env vars on the proxy: `PROXY_AUTH_ADD` (bool, **default
true** — so apps are protected unless opted out), `PROXY_AUTH_WHITELIST` (paths exempt,
e.g. `/public/*`), `PROXY_AUTH_BLACKLIST` (paths that must be authed, e.g. `/admin/*`).
- Known friction worth designing around: apps with their own login (Frigate, and the
`PROXY_AUTH_ADD=false` tracker issue) end up double-authenticating, and non-browser API
clients (Home Assistant hitting an app's API) break because they have no cookie. Any
gate we build needs a story for machine clients, not just browsers.
**The lesson for us:** the reason umbrel doesn't have this bug class is that there is no
unauthenticated path to bind to in the first place. Our apps publish their own ports
directly, so a gate bolted onto one transport leaves the others open — which is exactly
the shape of the `/lnd-connect-info` + `/bitcoin-rpc/` leaks. The fix likely has to move
the port binding, not just add a check.
#### Reproduced ON archi-dev-box, 2026-08-03 — baseline before the fix
No session cookie, over the Tailscale IP `100.69.68.39`:
```
port 18083 HTTP 200 LND - Archipelago
port 8334 HTTP 200
port 8175 HTTP 200 Fedimint Guardian - Archipelago
port 8336 HTTP 200 FIPS Mesh
port 8090 HTTP 200
port 7777 HTTP 200
```
`ss -tlnp` confirms these are bound `0.0.0.0`, so the same responses are served on the LAN
IP and every other host address. Re-run this exact loop after the fix: every one must
become the login page, and the ports listed as protocol exemptions (item 1b) must be the
*only* ones still answering.
#### Exposure map — how app ports are reachable TODAY (verified in source, 2026-08-03)
All four transports converge on `127.0.0.1:<app_port>`. This is the whole reason the fix
is tractable: it is **one gate, not four**.
| Transport | Path to the app | Code |
|---|---|---|
| LAN / Tailscale | container publishes the port on the host (`--network host`, so `0.0.0.0:<port>`) — reachable on *every* host IP | `scripts/container-specs.sh`, `first-boot-containers.sh` |
| FIPS mesh | daemon binds `[fips0-ULA]:<port>` and raw-TCP-forwards to `127.0.0.1:<port>` | `server.rs:1130` `app_port_v6_relay_loop` |
| FIPS firewall | `tcp dport { …APP_LAUNCH_PORTS… } accept` drop-in opens them all | `fips/config.rs:274`, `fips/app_ports.rs` |
| Tor | `HiddenServicePort 80 127.0.0.1:<local_port>` per service | `api/rpc/tor/mod.rs:243` |
#### Design decision (operator, 2026-08-03)
**Gate app UIs + bearer tokens; protocol ports exempt.** HTTP app UIs get the login gate
(app name + icon, 2FA honoured). Protocol ports (LND gRPC 10009 + REST, electrum 50002,
bitcoin p2p 8333) stay open but MUST be declared `auth: none` with a rationale in the
manifest, so the exceptions are a grep rather than a discovery — see item 1b. Per-app
long-lived bearer tokens cover machine clients that speak HTTP (Home Assistant). **Zeus
and electrum wallets keep working untouched** — that was the deciding constraint.
The gate lives in the **daemon**, not a per-app sidecar container (umbrel's `app_proxy`
model): rootless, no extra containers per app, one place to update, and it can reuse the
existing `app_port_v6_relay_loop` rather than fight it.
#### ⚠️ Trap found while designing — an nft-only gate FAILS OPEN
The obvious implementation is an nft redirect of inbound app-port traffic to the gate.
But `/etc/fips/fips.nft` is **provisioned out-of-band** and `fips/config.rs:290` treats its
absence as a no-op (`if try_exists("/etc/fips/fips.nft")`). A gate shipped as a `fips.d`
drop-in would therefore be **silently absent on every node without the hardening
baseline** — i.e. it fails open, which is exactly the failure class this item exists to
close.
Two viable shapes, both fail-closed:
- **(a) Apps bind loopback only**, daemon owns every external bind. Airtight, the true
umbrel model, but requires touching each app's own listen config (nginx.conf etc.).
Note you *cannot* half-do this: while an app holds `0.0.0.0:<port>`, the daemon cannot
bind `<lan-ip>:<port>` at all.
- **(b) Daemon owns a dedicated `archipelago-appgate` nft table** with its own
default-deny + redirect, independent of whether `fips.nft` exists, and refuses to start
/ alarms loudly if it cannot install it. Non-invasive to apps.
#### Enabler found — `PortMapping.bind` already does half of (a)
`core/container/src/manifest.rs:518``PortMapping` has a `bind` field, documented as
*"Host address to bind the publish to. Empty = all interfaces (0.0.0.0). Set `127.0.0.1`
to keep a port host-local"*. So for **bridge apps that declare `ports:`**, going
loopback-only is a **manifest edit, not app surgery**, and the daemon can then own the
external bind. That is most of the catalog.
The exception is **host-networked apps** (`security.network_policy: host``lnd-ui`,
`bitcoin-ui`, `electrs-ui`): host networking bypasses port mapping entirely, so `bind` has
no effect and `ports:` is deliberately empty. Those bind whatever their internal nginx
binds. We build those images ourselves, so the fix is a `listen 127.0.0.1:<port>;` change
in each `docker/*-ui/nginx.conf` — still no third-party surgery.
Watch the rootless trap documented at `manifest.rs:532`: a publish bound to an address the
host cannot actually bind crash-loops the whole unit (took bitcoin down fleet-wide on .228,
2026-07-09). Loopback binds are explicitly always accepted without probing, so this
direction is safe.
**Tor needs separate handling either way**: the onion connects *from* localhost, so a
redirect that exempts loopback will not catch it. `HiddenServicePort` must be repointed at
the gate, and since that mapping loses the original destination port, each app needs its
own gate port (or an HTTP-level Host mapping).
#### Primitives that already exist — do NOT build these from scratch
The gate is mostly assembly, not invention:
| Need | Existing API |
|---|---|
| Read the session cookie off a request | `session::extract_session_cookie(&HeaderMap) -> Option<String>` (`session.rs:479`) |
| Validate a session | `SessionStore::validate(&token) -> bool` (`session.rs:194`) |
| **Honour 2FA** | Already modelled: `create_pending(totp_secret)` (`:176`) + `upgrade_to_full` (`:247`). A session still pending 2FA **fails `validate()`**, so the gate gets 2FA for free by calling `validate` — no TOTP code in the gate itself |
| **Machine-client bearer tokens** | `device_tokens::create/verify` (`device_tokens.rs:63/:90`) — long-lived, minted from an authenticated session, only the SHA-256 persisted, plaintext returned once. Built for the companion pairing QR; needs **per-app scoping** added for this use |
| Rate limiting | `device_tokens` verification already rides `auth.login`'s limiter |
So the new code is: the listener/redirect, the app-identification step (which app is this port?),
the login page render (app name + icon), and per-app scoping on `device_tokens`.
#### Research — StartOS: **NOT YET VERIFIED**
Their public docs cover the *addressing* model (per-service `.onion` and `.local`
addresses, an explicit "make public" opt-in for clearnet) but do not state whether a
universal auth layer sits in front of service interfaces, and the source could not be
read from this box (`gh` is not installed, and raw GitHub paths 404'd). **Do not assume
they delegate auth to each service — read `Start9Labs/start-os` before designing.**
### 2. Filebrowser ships an insecure default login — **OPEN**
- Change the default credential **without breaking the dashboard's Cloud view**, which
authenticates to filebrowser on the user's behalf.
@@ -51,23 +188,44 @@ Two independent fail-open paths granted `Trusted` without any operator decision:
- Added `FederatedNode.trust_source` (`invite` | `uninvited-join` | `transitive-merge` |
`manual`, `None` = pre-existing/unknown) so existing grants are **auditable**. Per
operator decision: existing peers are **left alone, not auto-demoted**.
- **Still to do:** surface `trust_source` in `federation.list-nodes` + the UI so the
operator can actually review the `None`/`uninvited-join` population.
- `trust_source` is now **surfaced** in `federation.list-nodes` (as an explicit `null`
when unknown, not omitted — "recorded before this was tracked" is the population that
needs review, so the UI must be able to tell it apart from a field it didn't read) and
rendered under the trust dropdown in the node detail modal as "Granted via:".
### 3b. Granting Trusted must require the node password — **OPEN**
### 3b. Granting Trusted must require the node password — **DONE** (uncommitted at time of writing)
> "to make someone trusted must require the node password to generate the code or change
> in the modal dropdown when you click a node" — operator, 2026-08-03
Re-authentication on privilege escalation. Two entry points, both must be covered:
Re-authentication on privilege escalation. Both entry points are covered:
- **Minting a Trusted invite** (`federation.invite` with `trust_level: "trusted"`) —
"Link Your Nodes" mints Trusted today with no re-auth.
- **Changing a node's level in the UI dropdown** (`federation.set-trust-level` /
`handlers.rs:342`) — promoting Observer → Trusted.
- **Minting a Trusted invite** (`federation.invite`) — gated on the **resolved** level,
which matters because "Link Your Nodes" sends no `trust_level` at all and falls through
to the `Trusted` default. The invite is a bearer grant of Trusted to whoever redeems
it, so minting it *is* the escalation. Observer invites are untouched.
- **Changing a node's level in the UI dropdown** (`federation.set-trust`) — gated only
when the peer is **not already** Trusted, so the dropdown re-emitting its own value
doesn't demand a password for a no-op.
Demotion must NOT require the password: making something less privileged should never be
harder than leaving it. Grant `TrustSource::Manual` on the operator path so the audit
trail distinguishes it from the capped automatic ones.
Demotion is NOT gated: making something less privileged must never be harder than leaving
it, or the safe action becomes the inconvenient one. The operator path stamps
`TrustSource::Manual`; `set_trust_level` grew an `Option<TrustSource>` so automatic
adjustments (the discovery-handshake demotion safety net) pass `None` and leave the
recorded provenance alone rather than laundering an `uninvited-join` peer into looking
operator-approved.
Wiring: the backend is the sole authority on what counts as an escalation — it returns a
`PASSWORD_REQUIRED:` prefixed error, and the UI prompts and retries only on that. The
frontend never pre-judges, so the rule lives in exactly one place.
`TrustPasswordModal.vue` (modelled on `RotateDidModal.vue`) serves both flows.
`NodeDetailModal`'s select now snaps back to the node's real level on change, because a
cancelled or failed promotion would otherwise leave the dropdown displaying a level the
node never accepted.
**Follow-up, deliberately not done here:** `federation.join` also grants Trusted (when
redeeming someone else's Trusted invite) with no re-auth. It is an explicit operator
paste rather than a UI toggle, and was outside the two entry points specified — but it is
the third way a node reaches Trusted and should be reviewed.
---
@@ -104,8 +262,32 @@ trail distinguishes it from the capped automatic ones.
Known groundwork: the signed catalog (`releases/app-catalog.json`, `sign-catalog.sh`),
catalog→manifest runtime reload, `package.update` RPC, `check-app-catalog-drift.py`,
and `scripts/image-versions.sh` pinning.
- Needs: registry-version awareness per app, a diff of what changed (UI vs app vs both),
the modal + detail-page affordance, and distinct iconography for the three cases.
#### What already exists (verified in source, 2026-08-03) — the operator was right
The whole update *pipeline* is built and is already independent of OTA:
- `package.check-updates` (`api/rpc/package/update.rs:180`) refreshes the signed catalog
and hot-reloads manifests when it changed — no daemon restart, no OTA involved.
- `package.update` (`spawn_package_update`), `package.versions`, `package.set-config`
version pinning, and `execute_update` (stop → pull → remove → recreate → verify).
- Version awareness: `app_catalog::catalog_versions(app_id)` vs `installed_version()`
(`api/rpc/package/set_config.rs:46`).
- Frontend: `AppCard.vue` already renders an Update button off `pkg['available-update']`
(`:48`, `:128`) and emits `update`.
#### What is actually MISSING (this is the real scope of item 6)
1. **The UI-vs-app-vs-both distinction does not exist.** `available-update` is a single
version string — nothing classifies whether the change is the app image, its `*-ui`
image, or both. This is the core of the operator's ask ("a different graphic for just
ui, app, or both together") and needs a backend change, not just an icon.
⚠️ Compounding factor: per `reference_app_ui_delivery_model`, `*-ui` apps are **not in
the signed catalog** at all — so "is there a UI update" cannot be answered from the
catalog today. That gap has to be closed first or the UI half is unanswerable.
2. **The modal** (Update now / Cancel) — the card currently updates on click, no confirm.
3. **The detail-page affordance** — same treatment as the card.
4. **Button copy**: "See update" rather than "Update".
---