aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-07 22:35:21 +0100
committertv <tv@krebsco.de>2026-11-07 22:35:21 +0100
commit22d3e8148d30887041a030b93b3e6de3f749a6cd (patch)
tree27ed95306d078d70e1fd6cf90bbd4482aced1a4a /src
parente12bf8355d4b8318f70dbc964f2c1d3b57b4a038 (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.
Diffstat (limited to 'src')
-rw-r--r--src/access/bans.rs18
-rw-r--r--src/access/mod.rs1
-rw-r--r--src/access/rules.rs34
-rw-r--r--src/document/meta.rs3
-rw-r--r--src/swarm/peer/inbound.rs20
5 files changed, 75 insertions, 1 deletions
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;