aboutsummaryrefslogtreecommitdiffstats
path: root/src/remake/vote.rs
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 16:27:35 +0000
committertv <tv@krebsco.de>2026-11-08 17:40:49 +0100
commit5f69ecf6284a8061a2b8fae5b8f98d67a67dad5f (patch)
tree4af21471a3b7d76ba61cbe60455385774708aacb /src/remake/vote.rs
parentd0b773065c441d3e153d9d53bca20ead655b17e7 (diff)
remake: turning a running swarm strict, keeping what an operator signed
Until a swarm is strict, the user a change names is its node's word, so any member could have planted registry entries, policy, bans or user-signed values as any user, plain or signed by a key it put there itself; writing the strict policy trusted all of it as it was. A remake of a new kind, `strict`, turns a swarm strict instead: * Its content is a manifest (access::strict): the swarm, the strict policy, and each value kept at the places values are signed, by the SHA-256 of its JSON, signed by an operator (ssh-keygen -Y sign -n continuum-strict). * Every node builds the next epoch as compaction does, keeping at the signed places only the values the manifest names, as they were at the base, and writing its policy (RemakeKind::protocol, the one kind that changes protocol data). Writes after the base reach the new epoch only by replays, judged by the strict policy. * A node votes for one only with its manifest signed by one of --strict-keys, keys given out of the document; without any, for none (logged once, `Not voting for a strict remake`), unless --follow-strict-remakes. A coordinator proposes only what it would vote for (403). Once a majority commits one, every node switches, as with any remake: refusing to switch would only cut a node off. * GET /admin/strict-manifest gives the manifest keeping everything held now, the values for review and the heads they are at; continuum-signed strict shows them, signs once confirmed and proposes at those heads. The sim a_swarm_turning_strict_through_a_remake: a member plants a registry entry as a manager; the operator's manifest leaves it out; a and b, given the key, commit; c, without, follows; the entry is gone and the policy strict everywhere.
Diffstat (limited to 'src/remake/vote.rs')
-rw-r--r--src/remake/vote.rs54
1 files changed, 47 insertions, 7 deletions
diff --git a/src/remake/vote.rs b/src/remake/vote.rs
index 12c1333..c231afa 100644
--- a/src/remake/vote.rs
+++ b/src/remake/vote.rs
@@ -38,7 +38,7 @@ use super::Host;
use super::content;
use super::records::{self, Decision, EpochRecord, Phase, Record, Register, Vote};
use crate::clock::UnixMillis;
-use crate::document::store::StoreError;
+use crate::document::store::{self, StoreError};
/// A majority of `electorate` members.
pub(crate) fn quorum(electorate: usize) -> usize {
@@ -281,7 +281,7 @@ pub(super) async fn cast<H: Host>(host: &H) {
// Usually there is nothing to vote on: look first, without writing.
let (voter, wanted) = (me.clone(), epoch.clone());
let now = host.now_millis();
- let (due, missed) = host
+ let (due, missed, swarm) = host
.read_doc(move |doc| {
let all = records::load_all(doc);
let register = records::load_register(doc);
@@ -294,9 +294,17 @@ pub(super) async fn cast<H: Host>(host: &H) {
return None;
}
let content = record.proposal.content.clone();
- Some((id, content))
+ Some((id, content, record.proposal.kind.clone()))
});
- (due, missed)
+ let swarm = store::read_path(
+ doc,
+ &crate::document::meta::path(&[crate::document::meta::SWARM]),
+ )
+ .ok()
+ .flatten()
+ .and_then(|swarm| swarm.as_str().map(str::to_owned))
+ .unwrap_or_default();
+ (due, missed, swarm)
})
.await;
for id in missed {
@@ -311,13 +319,24 @@ pub(super) async fn cast<H: Host>(host: &H) {
}
}
}
- let Some((chosen, content)) = due else {
+ let Some((chosen, content, kind)) = due else {
return;
};
// Only with the content in hand: a committed remake must have it on a majority.
- if let Some(reference) = &content
- && content::available_or_fetch(host, reference).is_none()
+ let bytes = match &content {
+ Some(reference) => match content::available_or_fetch(host, reference) {
+ Some(bytes) => Some(bytes),
+ None => return,
+ },
+ None => None,
+ };
+ // A strict remake: only with its manifest signed by a key this node was given.
+ if kind == "strict"
+ && let Err(why) = strict_taken(host, bytes.as_deref().map(Vec::as_slice), &swarm)
{
+ if host.remake().refuses_strict(&chosen) {
+ warn!(remake = %chosen, why = %why, "Not voting for a strict remake");
+ }
return;
}
// Spread over half a keepalive interval, so later voters see the earlier votes
@@ -355,6 +374,27 @@ pub(super) async fn cast<H: Host>(host: &H) {
}
}
+/// Whether this node takes a strict remake's manifest (`content`, for `swarm`): signed
+/// by one of its `--strict-keys`; without any, if it follows strict remakes all the same
+/// (`--follow-strict-remakes`). Why not, if not.
+pub(super) fn strict_taken<H: Host>(
+ host: &H,
+ content: Option<&[u8]>,
+ swarm: &str,
+) -> Result<(), String> {
+ let text = content
+ .and_then(|bytes| std::str::from_utf8(bytes).ok())
+ .ok_or("no manifest")?;
+ if host.strict_keys().is_empty() {
+ return if host.follows_strict_remakes() {
+ crate::access::strict::read(text).map(|_| ())
+ } else {
+ Err("this node has no --strict-keys (nor --follow-strict-remakes)".to_owned())
+ };
+ }
+ crate::access::strict::verify(text, host.strict_keys(), swarm).map(|_| ())
+}
+
/// A random duration below `limit`.
fn random_part(limit: Duration) -> Duration {
let mut bytes = [0u8; 8];