aboutsummaryrefslogtreecommitdiffstats
path: root/src/remake
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
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')
-rw-r--r--src/remake/api.rs68
-rw-r--r--src/remake/kind.rs114
-rw-r--r--src/remake/local.rs7
-rw-r--r--src/remake/mod.rs6
-rw-r--r--src/remake/snapshot.rs4
-rw-r--r--src/remake/vote.rs54
6 files changed, 241 insertions, 12 deletions
diff --git a/src/remake/api.rs b/src/remake/api.rs
index 7888c37..79b551f 100644
--- a/src/remake/api.rs
+++ b/src/remake/api.rs
@@ -8,6 +8,11 @@
//! * `GET /admin/remakes` lists all remakes.
//! * `GET /admin/remakes/{id}` reports one; `?wait=true` long-polls until decided
//! (bounded by the proposal's own expiry).
+//! * `GET /admin/strict-manifest`: a manifest turning the swarm strict (`access::strict`),
+//! keeping every value at the places values are signed as they are now, for the
+//! caller to look over and sign; with those values and the heads they are at, the
+//! base to propose it with (`{"kind": "strict", "spec": {"manifest": …}, "baseHeads":
+//! …}`).
//! * `GET /admin/epochs` shows the local epoch, the archived previous ones and whether
//! the node is catching up with a newer epoch from a peer.
//! * `DELETE /admin/remakes/{id}` asks the coordinator to abort. Only possible while
@@ -374,6 +379,29 @@ async fn start_kind<H: Host>(
}
};
+ // A strict remake: proposed only with a manifest this node would vote for.
+ if kind.name() == "strict" {
+ let swarm = host
+ .read_doc(|doc| {
+ crate::document::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()
+ })
+ .await;
+ if let Err(why) = super::vote::strict_taken(&host, prepared.content.as_deref(), &swarm) {
+ return error_response(
+ StatusCode::FORBIDDEN,
+ format!("this node takes no such manifest: {why}"),
+ None,
+ );
+ }
+ }
+
if kind.base_at_commit() && request.base_heads.is_some() {
return error_response(
StatusCode::BAD_REQUEST,
@@ -782,6 +810,46 @@ async fn abort_expired<H: Host>(host: &H, current: Report, force: &Force) -> Res
}
}
+/// `GET /admin/strict-manifest`.
+pub(crate) async fn strict_manifest<H: Host>(
+ State(host): State<H>,
+ axum::Extension(user): axum::Extension<crate::api::auth::AuthenticatedUser>,
+) -> Response {
+ let at = host.now_millis().as_u64();
+ let caller = user.0.clone();
+ let (manifest, values, heads) = host
+ .read_doc(move |doc| {
+ let read = |path: &[&str]| {
+ crate::document::store::read_plain(doc, &crate::document::meta::path(path))
+ .ok()
+ .flatten()
+ };
+ let swarm = read(&[crate::document::meta::SWARM])
+ .and_then(|swarm| swarm.as_str().map(str::to_owned))
+ .unwrap_or_default();
+ let mut policy = match read(&[crate::document::meta::ACCESS, "policy"]) {
+ Some(JsonValue::Object(policy)) => JsonValue::Object(policy),
+ _ => json!({"version": 1}),
+ };
+ policy["values"] = json!("signed");
+ let manifest = crate::access::strict::manifest_of(doc, &swarm, policy, &caller, at);
+ let values: Vec<JsonValue> = crate::access::strict::held(doc)
+ .into_iter()
+ .map(|(path, value)| json!({ "path": path, "value": value }))
+ .collect();
+ let heads = records::encode_heads(&doc.get_heads());
+ (manifest, values, heads)
+ })
+ .await;
+ Json(json!({
+ "manifest": manifest,
+ "values": values,
+ "heads": heads,
+ "namespace": crate::access::strict::NAMESPACE,
+ }))
+ .into_response()
+}
+
/// `GET /admin/epochs`: the local epoch, the previous epochs this node still keeps, and
/// an ongoing catch-up with a peer's newer epoch (`null` if none).
pub(crate) async fn epochs<H: Host>(State(host): State<H>) -> Response {
diff --git a/src/remake/kind.rs b/src/remake/kind.rs
index a8d80d8..a429828 100644
--- a/src/remake/kind.rs
+++ b/src/remake/kind.rs
@@ -11,11 +11,13 @@
//! ([`RemakeKind::translate`]).
//!
//! Kinds: [`Compact`] (same content, no history), [`Replace`] (an operator's document
-//! in store form, with rules for where later writes go) and [`Import`] (the same with
-//! an Automerge document, e.g. one exported with `GET /admin/document`).
+//! in store form, with rules for where later writes go), [`Import`] (the same with
+//! an Automerge document, e.g. one exported with `GET /admin/document`) and [`Strict`]
+//! (compaction turning the swarm strict, keeping only the signed values a manifest
+//! names).
//!
-//! Protocol data under `_continuum` is not a kind's business: the generic part carries
-//! it forward and replays it unchanged.
+//! Protocol data under `_continuum` is the generic part's: it carries it forward and
+//! replays it unchanged; a strict remake alone changes it ([`RemakeKind::protocol`]).
use std::path::Path;
@@ -77,6 +79,17 @@ pub(crate) trait RemakeKind: Send + Sync + 'static {
/// Every node: where a user path written after the base goes.
fn translate(&self, path: &[String], translation: &JsonValue) -> Translation;
+ /// Every node: changes the protocol data carried into `target` from `old_at_base`.
+ /// Deterministic too. Nothing, for every kind but [`Strict`].
+ fn protocol(
+ &self,
+ _old_at_base: &Document,
+ _content: Option<&[u8]>,
+ _target: &mut Transaction<'_>,
+ ) -> Result<(), StoreError> {
+ Ok(())
+ }
+
/// Whether the coordinator fixes the base when it commits instead of when it
/// proposes (no `baseHeads` then). Right for a kind whose content is the old
/// document at the base: the later the base, the fewer writes every switching node
@@ -92,6 +105,7 @@ pub(crate) fn by_name(name: &str) -> Option<&'static dyn RemakeKind> {
"compact" => Some(&Compact),
"replace" => Some(&Replace),
"import" => Some(&Import),
+ "strict" => Some(&Strict),
_ => None,
}
}
@@ -180,6 +194,98 @@ impl RemakeKind for Compact {
}
}
+/// Turning strict (`access::strict`): the same content, no history, as [`Compact`],
+/// with the strict policy of the manifest, and at the places values are signed only the
+/// values it names, as they were at the base. The manifest is the content; every node
+/// checks its signature against its `--strict-keys` before it votes (`remake::vote`),
+/// but builds from it unchecked, as all do alike.
+///
+/// Spec: `{"manifest": "<its line>\n<its signature>"}`. The base is the proposal's
+/// (`baseHeads`, those the manifest was made at).
+pub(crate) struct Strict;
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct StrictSpec {
+ manifest: String,
+}
+
+impl Strict {
+ fn manifest(content: Option<&[u8]>) -> Result<crate::access::strict::Manifest, StoreError> {
+ let content = content.ok_or_else(|| anyhow!("strict without its manifest"))?;
+ let text = std::str::from_utf8(content).map_err(|_| anyhow!("manifest not UTF-8"))?;
+ crate::access::strict::read(text).map_err(|why| anyhow!(why).into())
+ }
+
+ /// Removes from `target` what `old_at_base` holds at the signed places the
+ /// manifest doesn't name, of those `wanted` picks.
+ fn keep_named(
+ old_at_base: &Document,
+ manifest: &crate::access::strict::Manifest,
+ target: &mut Transaction<'_>,
+ wanted: impl Fn(&[String]) -> bool,
+ ) -> Result<(), StoreError> {
+ for (path, value) in crate::access::strict::held(old_at_base) {
+ if wanted(&path) && !manifest.keeps(&path, &value) {
+ match store::delete_path(target, &path, false) {
+ Ok(()) | Err(StoreError::NotFound) => {}
+ Err(error) => return Err(error),
+ }
+ }
+ }
+ Ok(())
+ }
+}
+
+impl RemakeKind for Strict {
+ fn name(&self) -> &'static str {
+ "strict"
+ }
+
+ fn propose(&self, spec: &JsonValue) -> Result<Prepared, String> {
+ let spec: StrictSpec =
+ serde_json::from_value(spec.clone()).map_err(|error| error.to_string())?;
+ crate::access::strict::read(&spec.manifest)?;
+ Ok(Prepared {
+ content: Some(spec.manifest.into_bytes()),
+ translation: JsonValue::Null,
+ })
+ }
+
+ fn build(
+ &self,
+ old_at_base: &Document,
+ content: Option<&[u8]>,
+ target: &mut Transaction<'_>,
+ ) -> Result<(), StoreError> {
+ let manifest = Self::manifest(content)?;
+ store::materialize_into(old_at_base, target, &meta::is_protocol)?;
+ Self::keep_named(old_at_base, &manifest, target, |path| {
+ !meta::is_protocol(path)
+ })
+ }
+
+ fn translate(&self, _path: &[String], _translation: &JsonValue) -> Translation {
+ // Judged by the strict policy, as every replay is (`remake::delta`).
+ Translation::Same
+ }
+
+ fn protocol(
+ &self,
+ old_at_base: &Document,
+ content: Option<&[u8]>,
+ target: &mut Transaction<'_>,
+ ) -> Result<(), StoreError> {
+ let manifest = Self::manifest(content)?;
+ Self::keep_named(old_at_base, &manifest, target, meta::is_protocol)?;
+ store::write_path(
+ target,
+ &meta::path(&[meta::ACCESS, "policy"]),
+ &canonical(manifest.policy),
+ )
+ }
+}
+
/// Replacement: the new document is supplied by an operator, in store form (the JSON
/// `GET /store` returns), and may be structured entirely differently. Writes made after
/// the base go where the remake's rules say; a path no rule covers is quarantined.
diff --git a/src/remake/local.rs b/src/remake/local.rs
index e13c6fd..cdf2d6f 100644
--- a/src/remake/local.rs
+++ b/src/remake/local.rs
@@ -66,6 +66,8 @@ struct Inner {
/// Remakes this node could have voted for but saw expire first (`vote::cast`), each
/// logged once.
missed: HashSet<String>,
+ /// Strict remakes this node won't vote for (`vote::cast`), each logged once.
+ refused_strict: HashSet<String>,
/// The actors this node writes or wrote with in the current document: this run's,
/// and those of earlier runs it knows of (`--document-file`). Its own writes, for a
/// rejoin. A switch or rejoin starts the list over with the new document's actor, so
@@ -232,6 +234,11 @@ impl RemakeState {
self.lock().missed.insert(remake.to_owned())
}
+ /// Notes that this node won't vote for strict remake `remake`: true the first time.
+ pub(crate) fn refuses_strict(&self, remake: &str) -> bool {
+ self.lock().refused_strict.insert(remake.to_owned())
+ }
+
fn lock(&self) -> MutexGuard<'_, Inner> {
self.inner.lock_or_recover()
}
diff --git a/src/remake/mod.rs b/src/remake/mod.rs
index 840322a..a075c69 100644
--- a/src/remake/mod.rs
+++ b/src/remake/mod.rs
@@ -330,6 +330,11 @@ pub(crate) trait Host: Clone + Send + Sync + 'static {
/// What a replay does with writes a newer one superseded (`--superseded-writes`).
fn superseded(&self) -> Superseded;
+ /// Whose signature on a strict remake's manifest this node takes (`--strict-keys`),
+ /// and whether it votes for one without any (`--follow-strict-remakes`).
+ fn strict_keys(&self) -> &[crate::access::sshsig::PublicKey];
+ fn follows_strict_remakes(&self) -> bool;
+
/// Applies a change downloaded from peers (`fetch_change`), then publishes it like
/// a change received by sync.
fn apply_change(
@@ -411,6 +416,7 @@ pub(crate) fn router<H: Host>() -> Router<H> {
get(api::get::<H>).delete(api::abort::<H>),
)
.route("/admin/epochs", get(api::epochs::<H>))
+ .route("/admin/strict-manifest", get(api::strict_manifest::<H>))
.route("/admin/quarantine", get(quarantine::list::<H>))
.route(
"/admin/quarantine/{id}",
diff --git a/src/remake/snapshot.rs b/src/remake/snapshot.rs
index 7938b53..ee0a2cd 100644
--- a/src/remake/snapshot.rs
+++ b/src/remake/snapshot.rs
@@ -308,6 +308,8 @@ pub(crate) fn build(
{
store::copy_path(&base, &mut target, &meta::path(&[key.as_str()]))?;
}
+ // What the kind does to it (a strict remake keeps only what its manifest names).
+ kind.protocol(&base, params.content, &mut target)?;
let mut base_heads: Vec<String> = params.base_heads.iter().map(|h| h.to_string()).collect();
base_heads.sort();
@@ -369,7 +371,7 @@ fn snapshot_actor(remake_id: &str, generation: u64) -> ActorId {
/// Rebuilds every object with sorted keys, so the result is the same whichever key
/// order serde_json happens to use (it keeps insertion order with `preserve_order`).
-pub(super) fn canonical(value: JsonValue) -> JsonValue {
+pub(crate) fn canonical(value: JsonValue) -> JsonValue {
match value {
JsonValue::Object(map) => {
let sorted: BTreeMap<String, JsonValue> = map
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];