diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | 22d3e8148d30887041a030b93b3e6de3f749a6cd (patch) | |
| tree | 27ed95306d078d70e1fd6cf90bbd4482aced1a4a | |
| parent | e12bf8355d4b8318f70dbc964f2c1d3b57b4a038 (diff) | |
access: bans
_continuum/banned/<node ID>: every node refuses the banned node's
changes from then on (judged as the ban stood) and leaves its
handshakes unanswered.
Auth plan M6.
| -rw-r--r-- | docs/access.md | 17 | ||||
| -rw-r--r-- | src/access/bans.rs | 18 | ||||
| -rw-r--r-- | src/access/mod.rs | 1 | ||||
| -rw-r--r-- | src/access/rules.rs | 34 | ||||
| -rw-r--r-- | src/document/meta.rs | 3 | ||||
| -rw-r--r-- | src/swarm/peer/inbound.rs | 20 |
6 files changed, 91 insertions, 2 deletions
diff --git a/docs/access.md b/docs/access.md index ba9caf1..3818b04 100644 --- a/docs/access.md +++ b/docs/access.md @@ -190,7 +190,8 @@ revocation since refuses nothing it allowed then, and every node judges alike. any member. * A user's tokens revoked by that user too (logging out). * Everything else, protocol data or not, by a user the registry lets write there. -* A node that isn't a member may only register itself. +* A node that isn't a member may only register itself; a banned node nothing (Banning + a node, below). With `"signatures": "required"`, a change also needs a signature by the node it names ([`api.md`](api.md), Signed changes): every node then signs every change it makes, and @@ -216,3 +217,17 @@ What it doesn't do yet: A node that doesn't know the policy (another version, or a setting it lacks) takes no writes through the API (`409`) and receives without judging, until it is upgraded. +## Banning a node + +A node can be banned: `_continuum/banned/<node ID>`, written by a user who may write +there (protocol data). Every node then leaves its handshakes unanswered, so it is +neither synced with nor counted a member, and with the policy every node refuses the +changes it commits from then on, judged as the ban stood when they were made (what it +wrote before stays). Deleting the entry lifts the ban. A ban doesn't remove the node +from the member list: forget it too (`DELETE /peers/<node ID>`). + +```sh +curl -X PUT localhost:3000/store/_continuum/banned/<node ID> \ + -H 'Content-Type: application/json' -d '{"by": "alice", "reason": "compromised"}' +``` + diff --git a/src/access/bans.rs b/src/access/bans.rs new file mode 100644 index 0000000..339f6cf --- /dev/null +++ b/src/access/bans.rs @@ -0,0 +1,18 @@ +//! Banned nodes (`_continuum/banned/<node ID>`, `{"by", "at", "reason"}`), written by a +//! user who may write there, as any protocol data: a node refuses a banned node's +//! handshake, so it neither syncs with it nor counts it a member (`swarm::peer`), and +//! with the swarm's policy every node refuses the changes a banned node commits +//! (`access::rules`, judged as the ban stood when they were made). Deleting the entry +//! lifts the ban. + +use crate::document::{ReadDoc, meta, store}; + +/// Where `node`'s ban is. +pub(crate) fn path(node: &str) -> Vec<String> { + meta::path(&[meta::BANNED, node]) +} + +/// Whether `doc` bans `node`. +pub(crate) fn banned(doc: &impl ReadDoc, node: &str) -> bool { + store::read_path(doc, &path(node)).ok().flatten().is_some() +} diff --git a/src/access/mod.rs b/src/access/mod.rs index ea2e65c..4d96f9b 100644 --- a/src/access/mod.rs +++ b/src/access/mod.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::document::meta; +pub(crate) mod bans; pub(crate) mod policy; pub(crate) mod registry; pub(crate) mod rules; diff --git a/src/access/rules.rs b/src/access/rules.rs index e151cbb..c061893 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -9,7 +9,8 @@ //! * A change without dependencies is an epoch's root: the genesis change, the first a //! document holds, or a remake's snapshot, whose hash is the epoch's ID. Nothing else //! is judged of it: it writes the protocol data it starts with. -//! * Any other is its committer's, a member at its dependencies, or registering itself. +//! * Any other is its committer's, a member at its dependencies, or registering itself, +//! and not banned there (`access::bans`). //! * Protocol data (`_continuum`, `document::meta`): a member's entry and its votes by //! that member only (removing an entry also by a user who may write there); epochs //! and the swarm ID in a root only; a remake's decision by the coordinator its @@ -86,6 +87,9 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result let Some(node) = change.committer else { return refuse("it names no node".to_owned()); }; + if super::bans::banned(&at, node) { + return refuse(format!("{node} is banned")); + } let own_entry = meta::path(&[meta::MEMBERS, node]); let member = store::read_path(&at, &own_entry).ok().flatten().is_some(); let registering = change @@ -313,6 +317,34 @@ mod tests { assert!(refused(judged(&doc, "a", Some("alice"), &[tokens]))); } + /// A banned node's changes: refused, as the ban stood when they were made. + #[test] + fn banned_nodes() { + let mut doc = swarm(true); + let before = doc.get_heads(); + write(&mut doc, "_continuum/banned/b", json!({"by": "alice"})); + assert!(refused(judged( + &doc, + "b", + None, + &["_continuum/members/b/alive"] + ))); + let touched = paths(&["_continuum/members/b/alive"]); + let earlier = judge( + &doc, + Epoch::GENESIS, + &Change { + hash: ChangeHash([9; 32]), + deps: &before, + seen: &before, + committer: Some("b"), + user: None, + paths: &touched, + }, + ); + assert!(earlier.is_ok()); + } + /// A node that isn't a member may only register itself. #[test] fn strangers_register_themselves_only() { diff --git a/src/document/meta.rs b/src/document/meta.rs index 5966b3c..8fd2138 100644 --- a/src/document/meta.rs +++ b/src/document/meta.rs @@ -17,6 +17,8 @@ //! * `users/<name>`: the user registry: who the users are, and what each may do //! (`access::registry`). //! * `access/signed`: the paths whose writes are signed (`document::signatures`). +//! * `access/policy`: whether every node judges received changes (`access::policy`). +//! * `banned/<pk>`: banned nodes (`access::bans`). /// The key every protocol path starts with. pub(crate) const ROOT: &str = "_continuum"; @@ -32,6 +34,7 @@ pub(crate) const TOPOLOGY: &str = "topology"; pub(crate) const DISSEMINATION: &str = "dissemination"; pub(crate) const USERS: &str = "users"; pub(crate) const ACCESS: &str = "access"; +pub(crate) const BANNED: &str = "banned"; /// The store path `_continuum/<parts…>`. pub(crate) fn path(parts: &[&str]) -> Vec<String> { diff --git a/src/swarm/peer/inbound.rs b/src/swarm/peer/inbound.rs index e261702..9b5db8f 100644 --- a/src/swarm/peer/inbound.rs +++ b/src/swarm/peer/inbound.rs @@ -75,6 +75,26 @@ pub(crate) async fn handle_message( if !admit(state, &peer, &message).await { return Ok(()); } + // A banned node's handshake goes unanswered, and what we had with it ends + // (`access::bans`). + if matches!( + message, + ParsedMessage::Hello(_) | ParsedMessage::HelloReply(_) + ) && crate::access::bans::banned(&state.doc.read().await.doc, &peer) + { + if let Some(peer_state) = state.get_peer_state(&peer).await { + let mut peer_state = peer_state.lock().await; + if peer_state.standing.is_admitted() { + tracing::warn!(peer = %peer, "Banned peer: no longer synced with"); + } + crate::swarm::topology::change_peer(state, &mut peer_state, |p| { + p.standing = crate::state::Standing::Stranger + }); + peer_state.hello_state = crate::state::HelloState::NotSent; + peer_state.reset_sync(); + } + return Ok(()); + } remember_peer(state, &peer).await; |
