feat(security): require the node password to grant Trusted
Demo images / Build & push demo images (push) Successful in 4m22s
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:
co-authored by
Claude Opus 5
parent
f0b71f86aa
commit
24ce8b39e8
@@ -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".
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -51,10 +51,48 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Error prefix the frontend keys on to know it should prompt for the node
|
||||
/// password and retry, rather than surface the message as a dead end.
|
||||
pub(in crate::api::rpc) const PASSWORD_REQUIRED_PREFIX: &str = "PASSWORD_REQUIRED";
|
||||
|
||||
impl RpcHandler {
|
||||
/// Re-authenticate the operator before granting `Trusted`.
|
||||
///
|
||||
/// A Trusted peer can read node state, be deployed to, and is exempt from
|
||||
/// the `!= Untrusted` gates federation/DWN/messaging use — so granting it
|
||||
/// is a privilege escalation and must cost a fresh proof that the person
|
||||
/// at the keyboard is the operator, not merely that a session cookie
|
||||
/// exists. This is the same reasoning as `node.rotate-identity` and 2FA
|
||||
/// setup, both of which already re-verify.
|
||||
///
|
||||
/// Only ever called on the way UP. Demotion stays ungated: making
|
||||
/// something less privileged must never be harder than leaving it alone,
|
||||
/// or the safe action becomes the inconvenient one.
|
||||
async fn verify_operator_password(&self, params: Option<&serde_json::Value>) -> Result<()> {
|
||||
let password = params
|
||||
.and_then(|p| p.get("password"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if password.is_empty() {
|
||||
anyhow::bail!("{PASSWORD_REQUIRED_PREFIX}: node password required to grant Trusted");
|
||||
}
|
||||
|
||||
if !self.auth_manager.verify_password(password).await? {
|
||||
anyhow::bail!("Password verification failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// federation.invite — Generate an invite code containing our DID + onion for a peer.
|
||||
/// Optional param `trust_level`: "trusted" (default, "Link Your Nodes") or
|
||||
/// "observer" ("Invite a Peer") — the level BOTH sides assign for this invite.
|
||||
///
|
||||
/// Minting a **Trusted** invite requires the node password (param
|
||||
/// `password`): the invite is a bearer grant of Trusted to whoever
|
||||
/// redeems it, so it is the escalation, not the later redemption.
|
||||
/// Observer invites are unchanged.
|
||||
pub(in crate::api::rpc) async fn handle_federation_invite(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -71,6 +109,13 @@ impl RpcHandler {
|
||||
.transpose()?
|
||||
.unwrap_or(TrustLevel::Trusted);
|
||||
|
||||
// Note this covers the DEFAULT too: "Link Your Nodes" sends no
|
||||
// `trust_level` and lands on Trusted above, so the gate must key off
|
||||
// the resolved level rather than an explicit request for Trusted.
|
||||
if trust_level == TrustLevel::Trusted {
|
||||
self.verify_operator_password(params.as_ref()).await?;
|
||||
}
|
||||
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
let did = identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
|
||||
let onion = data.server_info.tor_address.clone().unwrap_or_default();
|
||||
@@ -272,6 +317,15 @@ impl RpcHandler {
|
||||
if let Some(at) = &n.last_sync_error_at {
|
||||
obj["last_sync_error_at"] = serde_json::json!(at);
|
||||
}
|
||||
// How this peer's trust level came to be. Emitted as an
|
||||
// explicit null when unknown rather than omitted: "recorded
|
||||
// before provenance was tracked" is the population the
|
||||
// operator most needs to review, so the UI must be able to
|
||||
// distinguish it from a field it simply didn't read.
|
||||
obj["trust_source"] = match &n.trust_source {
|
||||
Some(src) => serde_json::to_value(src).unwrap_or(serde_json::Value::Null),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
obj
|
||||
})
|
||||
.collect();
|
||||
@@ -323,6 +377,10 @@ impl RpcHandler {
|
||||
}
|
||||
|
||||
/// federation.set-trust — Change trust level for a federated node.
|
||||
///
|
||||
/// Promoting a node TO `Trusted` requires the node password (param
|
||||
/// `password`). Demotion and no-op re-sets do not: see
|
||||
/// `verify_operator_password` for why the gate is one-directional.
|
||||
pub(in crate::api::rpc) async fn handle_federation_set_trust(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
@@ -348,7 +406,32 @@ impl RpcHandler {
|
||||
),
|
||||
};
|
||||
|
||||
federation::set_trust_level(&self.config.data_dir, did, trust).await?;
|
||||
// Gate the ESCALATION only. Comparing against the node's current level
|
||||
// means a re-set of an already-Trusted peer (the dropdown re-emitting
|
||||
// its own value) doesn't pointlessly demand a password, while every
|
||||
// path that actually raises a peer to Trusted does.
|
||||
if trust == TrustLevel::Trusted {
|
||||
let already_trusted = federation::load_nodes(&self.config.data_dir)
|
||||
.await?
|
||||
.iter()
|
||||
.any(|n| n.did == did && n.trust_level == TrustLevel::Trusted);
|
||||
if !already_trusted {
|
||||
self.verify_operator_password(Some(¶ms)).await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp Manual: this is the one path where a human chose the level, so
|
||||
// an audit of `trust_source` can tell it apart from the automatic
|
||||
// grants that `UninvitedJoin` / `TransitiveMerge` mark.
|
||||
federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
did,
|
||||
trust,
|
||||
Some(federation::TrustSource::Manual),
|
||||
)
|
||||
.await?;
|
||||
|
||||
info!(did = %did, trust = %trust, "Operator set federation trust level");
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"updated": true,
|
||||
|
||||
@@ -350,10 +350,14 @@ impl RpcHandler {
|
||||
// lands on Observer; keep this explicit demotion as a
|
||||
// safety net for legacy Trusted-only invite codes — the
|
||||
// discovery flow should never auto-trust.
|
||||
// `None` source: this is an automatic safety-net
|
||||
// demotion, not an operator decision, so it must
|
||||
// not overwrite how the peer actually got here.
|
||||
let _ = crate::federation::set_trust_level(
|
||||
&self.config.data_dir,
|
||||
&node.did,
|
||||
crate::federation::TrustLevel::Observer,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel};
|
||||
use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLevel, TrustSource};
|
||||
|
||||
pub(crate) const FEDERATION_DIR: &str = "federation";
|
||||
pub(crate) const NODES_FILE: &str = "nodes.json";
|
||||
@@ -392,10 +392,19 @@ async fn untombstone_did_inner(data_dir: &Path, did: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Change a federated node's trust level, optionally recording HOW the change
|
||||
/// came about.
|
||||
///
|
||||
/// `source` is `Some(TrustSource::Manual)` on the operator RPC path so an
|
||||
/// audit of `trust_source` can tell a deliberate grant apart from the levels
|
||||
/// the automatic paths assign. Pass `None` for automatic adjustments that are
|
||||
/// not operator decisions (e.g. the discovery-handshake demotion safety net) —
|
||||
/// those must leave the recorded provenance alone rather than claim one.
|
||||
pub async fn set_trust_level(
|
||||
data_dir: &Path,
|
||||
did: &str,
|
||||
trust: TrustLevel,
|
||||
source: Option<TrustSource>,
|
||||
) -> Result<Vec<FederatedNode>> {
|
||||
let _guard = FEDERATION_STORE_LOCK.lock().await;
|
||||
let mut nodes = load_nodes_inner(data_dir).await?;
|
||||
@@ -404,6 +413,9 @@ pub async fn set_trust_level(
|
||||
.find(|n| n.did == did)
|
||||
.ok_or_else(|| anyhow::anyhow!("No federated node with DID {}", did))?;
|
||||
node.trust_level = trust;
|
||||
if let Some(source) = source {
|
||||
node.trust_source = Some(source);
|
||||
}
|
||||
save_nodes_inner(data_dir, &nodes).await?;
|
||||
Ok(nodes)
|
||||
}
|
||||
@@ -661,12 +673,55 @@ mod tests {
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer)
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
}
|
||||
|
||||
/// The operator RPC path stamps `Manual`, so an audit of `trust_source`
|
||||
/// can separate a deliberate grant from the levels the automatic paths
|
||||
/// (`UninvitedJoin`, `TransitiveMerge`) assign on their own authority.
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level_records_manual_source() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
|
||||
.await
|
||||
.unwrap();
|
||||
let nodes = set_trust_level(
|
||||
dir.path(),
|
||||
"did:key:z1",
|
||||
TrustLevel::Trusted,
|
||||
Some(TrustSource::Manual),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Trusted);
|
||||
assert_eq!(nodes[0].trust_source, Some(TrustSource::Manual));
|
||||
}
|
||||
|
||||
/// An automatic adjustment must not claim a provenance it doesn't have:
|
||||
/// passing `None` leaves whatever was recorded before intact, so the
|
||||
/// discovery-handshake demotion can't launder an `UninvitedJoin` peer
|
||||
/// into looking operator-approved.
|
||||
#[tokio::test]
|
||||
async fn test_set_trust_level_none_source_preserves_provenance() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut node = make_node("did:key:z1", "a.onion");
|
||||
node.trust_source = Some(TrustSource::UninvitedJoin);
|
||||
add_node(dir.path(), node).await.unwrap();
|
||||
|
||||
let nodes = set_trust_level(dir.path(), "did:key:z1", TrustLevel::Observer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(nodes[0].trust_level, TrustLevel::Observer);
|
||||
assert_eq!(
|
||||
nodes[0].trust_source,
|
||||
Some(TrustSource::UninvitedJoin),
|
||||
"an automatic level change must not rewrite how the peer got here"
|
||||
);
|
||||
}
|
||||
|
||||
/// The .198 v1.7.103 update-bricking race (see `update.rs`'s
|
||||
/// `UPDATE_OP_LOCK`) had the same shape as this test: two concurrent
|
||||
/// mutators sharing one on-disk file with no coordination. Here,
|
||||
@@ -695,7 +750,7 @@ mod tests {
|
||||
async move { add_node(&dir_a, make_node("did:key:zB", "b.onion")).await },
|
||||
);
|
||||
let trust_task = tokio::spawn(async move {
|
||||
set_trust_level(&dir_b, "did:key:zA", TrustLevel::Observer).await
|
||||
set_trust_level(&dir_b, "did:key:zA", TrustLevel::Observer, None).await
|
||||
});
|
||||
add_task.await.unwrap().unwrap();
|
||||
trust_task.await.unwrap().unwrap();
|
||||
|
||||
@@ -486,6 +486,18 @@ describe('RPCClient convenience methods', () => {
|
||||
expect(getLastMethod()).toBe('federation.invite')
|
||||
})
|
||||
|
||||
it('federationInvite omits password when none is given', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationInvite forwards the password for a trusted invite', async () => {
|
||||
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
|
||||
await rpcClient.federationInvite('trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({ trust_level: 'trusted', password: 'hunter2' })
|
||||
})
|
||||
|
||||
it('federationJoin calls federation.join', async () => {
|
||||
mockSuccess({ joined: true, node: {} })
|
||||
await rpcClient.federationJoin('invite-code')
|
||||
@@ -510,6 +522,22 @@ describe('RPCClient convenience methods', () => {
|
||||
expect(getLastMethod()).toBe('federation.set-trust')
|
||||
})
|
||||
|
||||
it('federationSetTrust omits password on demotion', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'observer' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'observer')
|
||||
expect(getLastParams()).not.toHaveProperty('password')
|
||||
})
|
||||
|
||||
it('federationSetTrust forwards the password when promoting', async () => {
|
||||
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
|
||||
await rpcClient.federationSetTrust('did:key:z', 'trusted', 'hunter2')
|
||||
expect(getLastParams()).toMatchObject({
|
||||
did: 'did:key:z',
|
||||
trust_level: 'trusted',
|
||||
password: 'hunter2',
|
||||
})
|
||||
})
|
||||
|
||||
it('federationSyncState calls federation.sync-state', async () => {
|
||||
mockSuccess({ synced: 1, failed: 0, results: [] })
|
||||
await rpcClient.federationSyncState()
|
||||
|
||||
@@ -781,12 +781,18 @@ class RPCClient {
|
||||
}
|
||||
|
||||
// Federation
|
||||
/** Minting a `trusted` invite requires the node password — the backend
|
||||
* rejects it with a `PASSWORD_REQUIRED` error until one is supplied.
|
||||
* Observer invites never need one. */
|
||||
async federationInvite(
|
||||
trustLevel: 'trusted' | 'observer' = 'trusted'
|
||||
trustLevel: 'trusted' | 'observer' = 'trusted',
|
||||
password?: string,
|
||||
): Promise<{ code: string; did: string; onion: string; trust_level: string }> {
|
||||
const params: Record<string, unknown> = { trust_level: trustLevel }
|
||||
if (password) params.password = password
|
||||
return this.call({
|
||||
method: 'federation.invite',
|
||||
params: { trust_level: trustLevel },
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -855,13 +861,19 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
/** Promotion TO `trusted` requires the node password — the backend rejects
|
||||
* it with a `PASSWORD_REQUIRED` error until one is supplied. Demotion is
|
||||
* never gated: making a peer less privileged must stay easy. */
|
||||
async federationSetTrust(
|
||||
did: string,
|
||||
trustLevel: 'trusted' | 'observer' | 'untrusted',
|
||||
password?: string,
|
||||
): Promise<{ updated: boolean; did: string; trust_level: string }> {
|
||||
const params: Record<string, unknown> = { did, trust_level: trustLevel }
|
||||
if (password) params.password = password
|
||||
return this.call({
|
||||
method: 'federation.set-trust',
|
||||
params: { did, trust_level: trustLevel },
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,15 @@
|
||||
@confirm="confirmPresenceSign"
|
||||
@cancel="showPresenceSignModal = false"
|
||||
/>
|
||||
|
||||
<TrustPasswordModal
|
||||
:visible="showTrustPassword"
|
||||
:context="trustPasswordContext"
|
||||
:busy="trustPasswordBusy"
|
||||
:error="trustPasswordError"
|
||||
@confirm="submitTrustPassword"
|
||||
@close="closeTrustPassword"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -243,9 +252,10 @@ import JoinModal from './federation/JoinModal.vue'
|
||||
import PendingRequestsPanel from './federation/PendingRequestsPanel.vue'
|
||||
import DiscoverModal from './federation/DiscoverModal.vue'
|
||||
import PresenceSignModal from './federation/PresenceSignModal.vue'
|
||||
import TrustPasswordModal from './federation/TrustPasswordModal.vue'
|
||||
import type { FederatedNode, DwnStatus, SyncResult } from './federation/types'
|
||||
import type { PendingPeerRequest } from '@/api/rpc-client'
|
||||
import { nodeName, timeAgo } from './federation/utils'
|
||||
import { nodeName, nodeNameFromDid, timeAgo } from './federation/utils'
|
||||
|
||||
const transportStore = useTransportStore()
|
||||
const appStore = useAppStore()
|
||||
@@ -529,15 +539,73 @@ function handleGenerateInvite(type: 'trusted' | 'observer') {
|
||||
generateInvite()
|
||||
}
|
||||
|
||||
/** The backend is the only authority on whether a given change is an
|
||||
* escalation, so the UI never pre-judges: it attempts the call and prompts
|
||||
* only when the backend says a password is required. That keeps demotions —
|
||||
* and no-op re-sets of an already-Trusted peer — free of a pointless prompt
|
||||
* without the frontend having to duplicate the rule. */
|
||||
function isPasswordRequired(e: unknown): boolean {
|
||||
return e instanceof Error && e.message.includes('PASSWORD_REQUIRED')
|
||||
}
|
||||
|
||||
const showTrustPassword = ref(false)
|
||||
const trustPasswordContext = ref('')
|
||||
const trustPasswordBusy = ref(false)
|
||||
const trustPasswordError = ref('')
|
||||
let pendingTrustAction: ((password: string) => Promise<void>) | null = null
|
||||
|
||||
function promptForTrustPassword(context: string, action: (password: string) => Promise<void>) {
|
||||
trustPasswordContext.value = context
|
||||
trustPasswordError.value = ''
|
||||
pendingTrustAction = action
|
||||
showTrustPassword.value = true
|
||||
}
|
||||
|
||||
function closeTrustPassword() {
|
||||
showTrustPassword.value = false
|
||||
trustPasswordError.value = ''
|
||||
trustPasswordBusy.value = false
|
||||
pendingTrustAction = null
|
||||
}
|
||||
|
||||
async function submitTrustPassword(password: string) {
|
||||
if (!pendingTrustAction) return
|
||||
try {
|
||||
trustPasswordBusy.value = true
|
||||
trustPasswordError.value = ''
|
||||
await pendingTrustAction(password)
|
||||
closeTrustPassword()
|
||||
} catch (e) {
|
||||
// Keep the failure inside the modal so the operator can retry in place
|
||||
// rather than losing the pending action to the page-level banner.
|
||||
trustPasswordError.value = e instanceof Error ? e.message : 'Password verification failed'
|
||||
} finally {
|
||||
trustPasswordBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws so both the first attempt and the password retry can
|
||||
* route the error to the right place. */
|
||||
async function requestInvite(password?: string) {
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value, password)
|
||||
inviteCode.value = result.code
|
||||
}
|
||||
|
||||
async function generateInvite() {
|
||||
try {
|
||||
generatingInvite.value = true
|
||||
error.value = ''
|
||||
// The invite type is not cosmetic: it sets the trust level the invite
|
||||
// grants both sides ("Invite a Peer" = observer, "Link Your Nodes" = trusted)
|
||||
const result = await rpcClient.federationInvite(inviteType.value)
|
||||
inviteCode.value = result.code
|
||||
await requestInvite()
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
promptForTrustPassword(
|
||||
'This invite grants Trusted access to whoever redeems it — full read of this node\'s state, and the ability to deploy apps to it. Confirm with your node password.',
|
||||
requestInvite,
|
||||
)
|
||||
return
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : 'Failed to generate invite'
|
||||
} finally {
|
||||
generatingInvite.value = false
|
||||
@@ -578,14 +646,27 @@ async function syncAll() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw call — throws; see `requestInvite`. */
|
||||
async function requestTrustChange(did: string, level: string, password?: string) {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted', password)
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTrust(did: string, level: string) {
|
||||
try {
|
||||
await rpcClient.federationSetTrust(did, level as 'trusted' | 'observer' | 'untrusted')
|
||||
await loadNodes()
|
||||
if (selectedNode.value?.did === did) {
|
||||
selectedNode.value = nodes.value.find(n => n.did === did) ?? null
|
||||
}
|
||||
await requestTrustChange(did, level)
|
||||
} catch (e) {
|
||||
if (isPasswordRequired(e)) {
|
||||
const name = nodeNameFromDid(did, nodes.value)
|
||||
promptForTrustPassword(
|
||||
`Granting ${name} Trusted lets it read this node's state and deploy apps to it. Confirm with your node password.`,
|
||||
(password) => requestTrustChange(did, level, password),
|
||||
)
|
||||
return
|
||||
}
|
||||
error.value = e instanceof Error ? e.message : 'Failed to update trust level'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<select
|
||||
:value="node.trust_level"
|
||||
@change="emit('change-trust', node!.did, ($event.target as HTMLSelectElement).value)"
|
||||
@change="onTrustChange"
|
||||
class="bg-black/30 text-white text-sm rounded px-2 py-1 border border-white/10"
|
||||
>
|
||||
<option value="trusted">Trusted</option>
|
||||
@@ -34,6 +34,9 @@
|
||||
<option value="untrusted">Blocked</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-xs text-white/40 mt-2">
|
||||
<span class="text-white/30">Granted via:</span> {{ trustSourceLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="bg-white/5 rounded-lg p-3">
|
||||
<p class="text-xs text-white/40 mb-1">Added</p>
|
||||
@@ -130,7 +133,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { FederatedNode } from './types'
|
||||
import { formatBytes, formatUptime } from './utils'
|
||||
|
||||
@@ -156,6 +159,32 @@ const emit = defineEmits<{
|
||||
const confirmRemove = ref(false)
|
||||
const deployAppId = ref('')
|
||||
|
||||
const TRUST_SOURCE_LABELS: Record<string, string> = {
|
||||
invite: 'An invite you minted',
|
||||
'uninvited-join': 'Joined without an invite — capped at Observer',
|
||||
'transitive-merge': 'Advertised by another peer — capped at Observer',
|
||||
manual: 'You set it here',
|
||||
}
|
||||
|
||||
/** Unknown provenance is stated plainly rather than hidden: a peer recorded
|
||||
* before this was tracked is precisely the one worth a second look. */
|
||||
const trustSourceLabel = computed(
|
||||
() => TRUST_SOURCE_LABELS[props.node?.trust_source ?? ''] ?? 'Unknown — recorded before this was tracked',
|
||||
)
|
||||
|
||||
/** Snap the select back to the node's actual level immediately. Promoting to
|
||||
* Trusted asks for the node password, and the operator may cancel or get it
|
||||
* wrong — without this the dropdown would keep displaying a level the node
|
||||
* never accepted. On success the parent reloads and the prop drives the new
|
||||
* value back in. */
|
||||
function onTrustChange(event: Event) {
|
||||
const select = event.target as HTMLSelectElement
|
||||
const level = select.value
|
||||
if (!props.node) return
|
||||
select.value = props.node.trust_level
|
||||
emit('change-trust', props.node.did, level)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
confirmRemove.value = false
|
||||
deployAppId.value = ''
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="visible" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="handleClose">
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
|
||||
<div class="glass-card p-6 max-w-md w-full relative z-10">
|
||||
<h3 class="text-lg font-semibold text-white mb-2">Confirm Trusted Access</h3>
|
||||
<p class="text-sm text-white/60 mb-4">{{ context }}</p>
|
||||
<input
|
||||
ref="passwordInput"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
placeholder="Enter your node password to confirm"
|
||||
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
|
||||
@keyup.enter="submit"
|
||||
/>
|
||||
<p v-if="error" class="text-red-400 text-xs mb-3">{{ error }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button @click="handleClose" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
|
||||
<button
|
||||
@click="submit"
|
||||
:disabled="busy || !password"
|
||||
class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30 disabled:opacity-50"
|
||||
>
|
||||
{{ busy ? 'Verifying…' : 'Grant Trusted' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
/** What is about to be granted, in the operator's terms. */
|
||||
context: string
|
||||
busy: boolean
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [password: string]
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
const passwordInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function submit() {
|
||||
if (!password.value || props.busy) return
|
||||
emit('confirm', password.value)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
password.value = ''
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Never leave the password sitting in memory once the modal is dismissed,
|
||||
// and put the cursor where the operator has to type anyway.
|
||||
watch(() => props.visible, async (val) => {
|
||||
if (!val) {
|
||||
password.value = ''
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
passwordInput.value?.focus()
|
||||
})
|
||||
</script>
|
||||
@@ -40,6 +40,13 @@ export interface FederatedNode {
|
||||
last_sync_error?: string
|
||||
/** RFC 3339 timestamp of last_sync_error. */
|
||||
last_sync_error_at?: string
|
||||
/**
|
||||
* How this peer's trust level came to be what it is. `null` means it was
|
||||
* recorded before provenance was tracked — which is exactly the population
|
||||
* worth reviewing, since it may include grants made by the fail-open paths
|
||||
* that `uninvited-join` / `transitive-merge` now cap at Observer.
|
||||
*/
|
||||
trust_source?: 'invite' | 'uninvited-join' | 'transitive-merge' | 'manual' | null
|
||||
}
|
||||
|
||||
export interface DwnStatus {
|
||||
|
||||
Reference in New Issue
Block a user