diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md index d58303ae..b39f5d9d 100644 --- a/.planning/RELEASE-1.7.121-TASKS.md +++ b/.planning/RELEASE-1.7.121-TASKS.md @@ -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:`. 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:`) — reachable on *every* host IP | `scripts/container-specs.sh`, `first-boot-containers.sh` | +| FIPS mesh | daemon binds `[fips0-ULA]:` and raw-TCP-forwards to `127.0.0.1:` | `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:` 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:`, the daemon cannot + bind `:` 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:;` 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` (`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` 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". --- diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index c3b01efe..c3d75410 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -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, @@ -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, @@ -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, diff --git a/core/archipelago/src/api/rpc/handshake.rs b/core/archipelago/src/api/rpc/handshake.rs index d1275b95..cf696c60 100644 --- a/core/archipelago/src/api/rpc/handshake.rs +++ b/core/archipelago/src/api/rpc/handshake.rs @@ -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; diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index 57e2dcfa..d298a64e 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -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, ) -> Result> { 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(); diff --git a/neode-ui/src/api/__tests__/rpc-client.test.ts b/neode-ui/src/api/__tests__/rpc-client.test.ts index 8901ec4b..0ec242c5 100644 --- a/neode-ui/src/api/__tests__/rpc-client.test.ts +++ b/neode-ui/src/api/__tests__/rpc-client.test.ts @@ -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() diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 5f147a10..6bb70fff 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -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 = { 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 = { did, trust_level: trustLevel } + if (password) params.password = password return this.call({ method: 'federation.set-trust', - params: { did, trust_level: trustLevel }, + params, }) } diff --git a/neode-ui/src/views/Federation.vue b/neode-ui/src/views/Federation.vue index aed6d3bb..dc01c548 100644 --- a/neode-ui/src/views/Federation.vue +++ b/neode-ui/src/views/Federation.vue @@ -223,6 +223,15 @@ @confirm="confirmPresenceSign" @cancel="showPresenceSignModal = false" /> + + @@ -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) | null = null + +function promptForTrustPassword(context: string, action: (password: string) => Promise) { + 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' } } diff --git a/neode-ui/src/views/federation/NodeDetailModal.vue b/neode-ui/src/views/federation/NodeDetailModal.vue index 9dc07f81..1fd7ded7 100644 --- a/neode-ui/src/views/federation/NodeDetailModal.vue +++ b/neode-ui/src/views/federation/NodeDetailModal.vue @@ -26,7 +26,7 @@
+

+ Granted via: {{ trustSourceLabel }} +

Added

@@ -130,7 +133,7 @@ diff --git a/neode-ui/src/views/federation/types.ts b/neode-ui/src/views/federation/types.ts index 33e76fa4..bb679e2d 100644 --- a/neode-ui/src/views/federation/types.ts +++ b/neode-ui/src/views/federation/types.ts @@ -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 {