From 5f69ecf6284a8061a2b8fae5b8f98d67a67dad5f Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 16:27:35 +0000 Subject: 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. --- src/access/strict.rs | 224 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 src/access/strict.rs (limited to 'src/access/strict.rs') diff --git a/src/access/strict.rs b/src/access/strict.rs new file mode 100644 index 0000000..f5051a8 --- /dev/null +++ b/src/access/strict.rs @@ -0,0 +1,224 @@ +//! Turning a swarm strict through a remake (`remake::kind::Strict`): until a swarm is +//! strict, the user a change names is its node's word, so whatever its registry, policy, +//! bans and user-signed values hold then may have been planted by any member, plain or +//! signed by a key the planter put there itself. So a strict remake keeps of them only +//! what an operator looked over and signed: a manifest, naming each value kept by its +//! digest, with the strict policy, signed by a key every node is given out of the +//! document (`--strict-keys`). Every node checks it before it votes (`verify`), and +//! builds the next epoch with those values only; what was written after the base reaches +//! it only through replays, judged by the strict policy. +//! +//! A manifest is one line of JSON (`Manifest`), then its signature, armored, as +//! `ssh-keygen -Y sign -n continuum-strict` writes it. + +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use sha2::{Digest, Sha256}; + +use super::{envelope, sshsig}; +use crate::document::{ReadDoc, store}; + +/// What a manifest's signature is for (`sshsig`). +pub(crate) const NAMESPACE: &str = "continuum-strict"; + +/// What an operator signs to turn a swarm strict. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Manifest { + /// The swarm it is for. + pub(crate) swarm: String, + /// The policy the next epoch has (`"values": "signed"`). + pub(crate) policy: JsonValue, + /// The values kept at the places values are signed (`envelope::signed_paths`), each + /// by its digest (`digest`); any other there goes. + pub(crate) keep: Vec, + /// Who signed it, and when (Unix ms). + pub(crate) user: String, + pub(crate) at: u64, +} + +/// A value kept, by its digest. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Kept { + pub(crate) path: Vec, + pub(crate) digest: String, +} + +impl Manifest { + /// Whether it keeps `value` at `path`. + pub(crate) fn keeps(&self, path: &[String], value: &JsonValue) -> bool { + let digest = digest(value); + self.keep + .iter() + .any(|kept| kept.path == path && kept.digest == digest) + } +} + +/// A value's digest: SHA-256 (hex) of its JSON, keys sorted, as the store reads it (an +/// envelope as the string it is). +pub(crate) fn digest(value: &JsonValue) -> String { + let bytes = serde_json::to_vec(&crate::remake::snapshot::canonical(value.clone())) + .expect("JSON serializes"); + Sha256::digest(&bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// The places values are signed in `doc` (`envelope::signed_paths`, `*` as `doc` has +/// them) that hold one, with it, as the store reads it. +pub(crate) fn held(doc: &impl ReadDoc) -> Vec<(Vec, JsonValue)> { + fn expand( + doc: &impl ReadDoc, + pattern: &[String], + at: Vec, + out: &mut Vec<(Vec, JsonValue)>, + ) { + match pattern.split_first() { + None => { + if let Ok(Some(value)) = store::read_path(doc, &at) { + out.push((at, value)); + } + } + Some((segment, rest)) if segment == "*" => { + for key in store::keys_at(doc, &at).ok().flatten().unwrap_or_default() { + let mut next = at.clone(); + next.push(key); + expand(doc, rest, next, out); + } + } + Some((segment, rest)) => { + let mut next = at; + next.push(segment.clone()); + expand(doc, rest, next, out); + } + } + } + let mut out = Vec::new(); + for pattern in envelope::signed_paths(doc) { + expand(doc, &pattern, Vec::new(), &mut out); + } + out.sort_by(|a, b| a.0.cmp(&b.0)); + out.dedup_by(|a, b| a.0 == b.0); + out +} + +/// A manifest for `doc` of `swarm`: keeping every value held at a signed place now, the +/// policy `policy`, for `user` to look over and sign at `at`. Its exact line. +pub(crate) fn manifest_of( + doc: &impl ReadDoc, + swarm: &str, + policy: JsonValue, + user: &str, + at: u64, +) -> String { + let manifest = Manifest { + swarm: swarm.to_owned(), + policy, + keep: held(doc) + .into_iter() + .map(|(path, value)| Kept { + digest: digest(&value), + path, + }) + .collect(), + user: user.to_owned(), + at, + }; + serde_json::to_string(&manifest).expect("a manifest serializes") +} + +/// What `text` (a manifest's line, then its signature) says, unchecked: for building +/// the epoch it names, which every node does alike, whatever keys it was given. +pub(crate) fn read(text: &str) -> Result { + let (line, _) = text + .split_once('\n') + .ok_or("a manifest is its line, then its signature")?; + let manifest: Manifest = + serde_json::from_str(line).map_err(|error| format!("not a manifest: {error}"))?; + if manifest.policy.get("values").and_then(JsonValue::as_str) != Some("signed") { + return Err("its policy isn't strict (\"values\": \"signed\")".to_owned()); + } + Ok(manifest) +} + +/// `text` checked: a manifest (`read`) for `swarm`, signed by one of `keys`. +pub(crate) fn verify( + text: &str, + keys: &[sshsig::PublicKey], + swarm: &str, +) -> Result { + let manifest = read(text)?; + let (line, armored) = text.split_once('\n').expect("read checked it"); + if manifest.swarm != swarm { + return Err(format!("for swarm {}, not this one", manifest.swarm)); + } + let key = sshsig::verify(armored, NAMESPACE, line.as_bytes())?; + if !keys.contains(&key) { + return Err(format!( + "signed by {}, none of this node's --strict-keys", + key.fingerprint() + )); + } + Ok(manifest) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::document::Document; + use crate::document::testing::change; + use ed25519_dalek::SigningKey; + use serde_json::json; + + fn path(text: &str) -> Vec { + text.split('/').map(str::to_owned).collect() + } + + /// A manifest keeps the values held at signed places, by digest; it checks only for + /// its swarm, signed by a key given. + #[test] + fn a_manifest_signed_and_checked() { + let mut doc = Document::new(); + change(&mut doc, |doc| { + store::write_path(doc, &path("_continuum/users/alice"), &json!({"caps": {}})).unwrap(); + store::write_path( + doc, + &path("_continuum/access/userSigned"), + &json!([["config"]]), + ) + .unwrap(); + store::write_path(doc, &path("config"), &json!({"mode": "fast"})).unwrap(); + store::write_path(doc, &path("notes"), &json!(1)).unwrap(); + }); + let policy = json!({"version": 1, "values": "signed"}); + let line = manifest_of(&doc, "s", policy, "alice", 1); + let key = SigningKey::from_bytes(&[3; 32]); + let public = sshsig::PublicKey(key.verifying_key().to_bytes()); + let text = format!("{line}\n{}", sshsig::sign(&key, NAMESPACE, line.as_bytes())); + let manifest = verify(&text, std::slice::from_ref(&public), "s").unwrap(); + let kept: Vec> = manifest.keep.iter().map(|kept| kept.path.clone()).collect(); + assert_eq!( + kept, + vec![ + path("_continuum/access/userSigned"), + path("_continuum/users/alice"), + path("config") + ] + ); + assert!(manifest.keeps(&path("config"), &json!({"mode": "fast"}))); + assert!(!manifest.keeps(&path("config"), &json!({"mode": "slow"}))); + // Another swarm, another key, another namespace, tampered with: no. + assert!(verify(&text, std::slice::from_ref(&public), "t").is_err()); + let other = sshsig::PublicKey(SigningKey::from_bytes(&[4; 32]).verifying_key().to_bytes()); + assert!(verify(&text, &[other], "s").is_err()); + let elsewhere = format!( + "{line}\n{}", + sshsig::sign(&key, "continuum-value", line.as_bytes()) + ); + assert!(verify(&elsewhere, std::slice::from_ref(&public), "s").is_err()); + let tampered = text.replacen("alice", "mallory", 1); + assert!(verify(&tampered, std::slice::from_ref(&public), "s").is_err()); + } +} -- cgit v1.3.1