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
@@ -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(&params)).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;