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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user