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. --- docs/access.md | 37 +++++++- docs/example.toml | 10 +++ docs/openapi.yaml | 34 ++++++- docs/remakes.md | 5 +- scripts/continuum-signed | 30 ++++++- src/access/mod.rs | 1 + src/access/strict.rs | 224 +++++++++++++++++++++++++++++++++++++++++++++++ src/config/mod.rs | 25 ++++++ src/main.rs | 6 ++ src/node.rs | 5 ++ src/remake/api.rs | 68 ++++++++++++++ src/remake/kind.rs | 114 +++++++++++++++++++++++- src/remake/local.rs | 7 ++ src/remake/mod.rs | 6 ++ src/remake/snapshot.rs | 4 +- src/remake/vote.rs | 54 ++++++++++-- src/simtest/mod.rs | 2 + src/simtest/policy.rs | 113 ++++++++++++++++++++++++ src/state/host.rs | 8 ++ src/state/mod.rs | 4 + 20 files changed, 740 insertions(+), 17 deletions(-) create mode 100644 src/access/strict.rs diff --git a/docs/access.md b/docs/access.md index 5941bdb..9bd99cc 100644 --- a/docs/access.md +++ b/docs/access.md @@ -380,8 +380,11 @@ API changes nothing (`409`). Values there before the policy stay as they are unt replaced: a registry whose entries hold the users' keys turns strict as it is. As it is means trusted as it is: whatever entries, policy and bans it holds then count, plain values and envelopes alike, checked or not, whoever wrote them before (under a policy -that isn't strict, a member may write as any user). Look them over before turning -strict (`continuum-signed verify` on each entry). A value there that isn't an envelope +that isn't strict, a member may write as any user). So a swarm is best strict from its +start (`--bootstrap-keys`, A new swarm, above); one already running turns strict +through a remake (Turning a swarm strict, below), which keeps only what an operator +looked over. Writing the strict policy into it directly trusts it as it is: look it +over first (`continuum-signed verify` on each entry). A value there that isn't an envelope starts its chain anew: any envelope its user once signed for that path replacing nothing (`prev` empty) can be put there again. Without the policy, signed values may be written all the same, and plain ones too. @@ -397,6 +400,36 @@ replacing or deleting what holds one (a map made where there is none yet is fine a member writing to its document directly gets nothing there past the others: the registry, the policy and the bans change only as a user who may change them signed. +### Turning a swarm strict + +A running swarm turns strict through a remake of its own kind, `strict`: compaction +that keeps, at the places values are signed (the registry, `_continuum/access`, the +bans, the paths listed in `userSigned`), only the values an operator looked over and +signed, and sets the strict policy. What was written after its base reaches the new +epoch only by replays, which every node judges by the strict policy: a value planted +before, plain or signed by a key its planter put there itself, is gone unless the +operator kept it. + +Every node is given the keys whose signature it takes on such a remake, out of the +document (`--strict-keys`, `authorized_keys` lines): one an operator holds. A node +votes for a strict remake only with its manifest signed by one of them; without any, +it votes for none (logged, `Not voting for a strict remake`), unless +`--follow-strict-remakes`. Once a majority commits one, every node switches, as with +any remake. + +```sh +continuum-signed strict +``` + +shows the values it would keep (`GET /admin/strict-manifest`: every value at a signed +place now, by its digest, and the policy with `"values": "signed"`), signs the +manifest once confirmed (`ssh-keygen -Y sign -n continuum-strict`, with a key of +`--strict-keys`), and proposes the remake at the heads it was made at (`{"kind": +"strict", "spec": {"manifest": "\n"}, "baseHeads": […]}`). To +keep less, take the manifest from `GET /admin/strict-manifest`, leave out of its `keep` +what should go, and sign that instead. Reading it needs reading `_continuum`, proposing +writing `_continuum/remakes`. + ## Values sealed for their readers Anyone may read the whole document: every member holds it. A value only some may read diff --git a/docs/example.toml b/docs/example.toml index cce8780..2c3a2ab 100644 --- a/docs/example.toml +++ b/docs/example.toml @@ -105,6 +105,16 @@ htpasswd = "/etc/continuum/htpasswd" # together at most. # key-logins-per-user = 100 +# SSH keys, as authorized_keys lines (ssh-ed25519 [comment]), whose signature +# on a strict remake's manifest this node takes: the values kept when the swarm turns +# strict (docs/access.md). Without, it votes for no strict remake, unless +# follow-strict-remakes. +# strict-keys = ["ssh-ed25519 AAAA… alice"] + +# Without strict-keys: vote for strict remakes all the same, taking the manifests as +# the members proposing them sign them. +# follow-strict-remakes = false + # Where the reverse proxies whose user-header is believed connect from: IP addresses (or # ranges, as ADDRESS/BITS), and unix for the API's Unix sockets (bind-addr). On a host # with other users, leave out the addresses and have the proxy use a Unix socket: others diff --git a/docs/openapi.yaml b/docs/openapi.yaml index de62b59..bb017f0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1643,6 +1643,30 @@ paths: '503': $ref: '#/components/responses/Error' + /admin/strict-manifest: + get: + tags: [Remakes] + summary: A manifest turning the swarm strict + description: 'Keeping every value at the places values are signed as it is now (by its digest), with the policy and `"values": "signed"`, for the caller to look over, sign (`ssh-keygen -Y sign -n continuum-strict`) and propose as a `strict` remake at `heads` (`baseHeads`). Needs reading `_continuum`.' + responses: + '200': + description: The manifest's line, the values it keeps, the heads they are at. + content: + application/json: + schema: + type: object + properties: + manifest: { type: string } + values: + type: array + items: + type: object + properties: + path: { type: array, items: { type: string } } + value: {} + heads: { type: array, items: { type: string } } + namespace: { type: string } + /admin/epochs: get: tags: [Remakes] @@ -2878,15 +2902,23 @@ components: properties: kind: type: string - enum: [compact, replace, import] + enum: [compact, replace, import, strict] default: compact spec: description: | `compact`: none. `replace`: `{document: , rules}`. `import`: `{document: , rules}`; its `_continuum` and history are left out, Automerge types are kept. + `strict`: `{manifest: "\n"}`, as + `GET /admin/strict-manifest` gives the line, signed by a key of + `--strict-keys` (`403` from a node that takes no such manifest). oneOf: - type: 'null' + - type: object + required: [manifest] + additionalProperties: false + properties: + manifest: { type: string } - type: object required: [document] additionalProperties: false diff --git a/docs/remakes.md b/docs/remakes.md index b8e0fa6..fc938c7 100644 --- a/docs/remakes.md +++ b/docs/remakes.md @@ -9,8 +9,11 @@ A remake moves the whole swarm to a new document, the next *epoch*. Use one to: * **import** (`import`): new content from an Automerge document, e.g. a backup made with `GET /admin/document`. Unlike `replace`, Automerge types survive (text, counters, bytes, timestamps, floats). +* **strict** (`strict`): the same as `compact`, turning the swarm strict, keeping only + the signed values an operator's manifest names ([`access.md`](access.md), Turning a + swarm strict). -Everything else is the same for all three kinds. All endpoints need the node's basic +Everything else is the same for all the kinds. All endpoints need the node's basic auth; examples leave it out. ## How a remake runs diff --git a/scripts/continuum-signed b/scripts/continuum-signed index 1523094..21eb376 100755 --- a/scripts/continuum-signed +++ b/scripts/continuum-signed @@ -17,6 +17,9 @@ # continuum-signed key-add ID FILE adds (or replaces) key ID of the user's own, # the public key in FILE (.pub), signed # continuum-signed key-remove ID removes key ID of the user's own, signed +# continuum-signed strict turns the swarm strict through a remake: +# shows the values it keeps, signs the manifest +# once confirmed, proposes the remake # # PATH is the store's (`config/mode`), its segments without `/` or characters that need # escaping in a URL. Settings, from the environment: @@ -206,10 +209,34 @@ own_key() { write "_continuum/users/$user" "$entry" "$(jq -r '.signed // empty' "$work/own")" } +# Turns the swarm strict (docs/access.md, Turning a swarm strict): the manifest of the +# values held at signed places now, shown, signed once confirmed (with a key the nodes +# were given, --strict-keys), proposed as a strict remake at the heads it was made at. +strict() { + curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" "$url/admin/strict-manifest" \ + >"$work/strict" || die "no manifest from $url (reading _continuum needed)" + echo "The values kept (any other at a signed place goes):" >&2 + jq -r '.values[] | " /\(.path | join("/")): \(.value | tojson)"' "$work/strict" >&2 + echo "The policy: $(jq -r '.manifest | fromjson | .policy | tojson' "$work/strict")" >&2 + printf 'Sign and propose? [y/N] ' >&2 + read -r answer "$work/manifest" + sign "$work/manifest" continuum-strict + jq -n --rawfile line "$work/manifest" --rawfile signature "$work/manifest.sig" \ + --slurpfile strict "$work/strict" \ + '{kind: "strict", spec: {manifest: ($line + "\n" + $signature)}, + baseHeads: $strict[0].heads}' | + curl -sS --fail-with-body -X POST "$url/admin/remakes" \ + -H "Authorization: Bearer $CONTINUUM_TOKEN" \ + -H 'Content-Type: application/json' --data-binary @- + echo +} + command=${1:-} [ $# -gt 0 ] && shift case $command in - put | delete | get | verify | seal | open | key-add | key-remove) + put | delete | get | verify | seal | open | key-add | key-remove | strict) # One login for all the command's requests, unless given a token. CONTINUUM_TOKEN=${CONTINUUM_TOKEN:-$(login)} [ -n "$CONTINUUM_TOKEN" ] || die "couldn't log in as $user" @@ -225,5 +252,6 @@ case $command in open) [ $# -eq 1 ] || die "open PATH"; open_sealed "$1" ;; key-add) [ $# -eq 2 ] || die "key-add ID FILE"; own_key "$1" "$2" ;; key-remove) [ $# -eq 1 ] || die "key-remove ID"; own_key "$1" ;; + strict) [ $# -eq 0 ] || die "strict takes nothing"; strict ;; *) sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2; exit 2 ;; esac diff --git a/src/access/mod.rs b/src/access/mod.rs index 2a0077e..feddff0 100644 --- a/src/access/mod.rs +++ b/src/access/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod rules; pub(crate) mod screen; pub(crate) mod sealed; pub(crate) mod sshsig; +pub(crate) mod strict; pub(crate) mod token; /// What a capability lets its holder do, at its path and below. 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()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 9279f40..fb707dd 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -179,6 +179,20 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ None, "With bootstrap-users: their SSH keys, as authorized_keys lines (ssh-ed25519 [comment], named by the comment), recorded in their entries: to log in by key and sign values (docs/access.md), so a swarm can be strict from its start.", ), + option( + "strict-keys", + Kind::List, + "KEY,[KEY...]", + None, + "SSH keys, as authorized_keys lines (ssh-ed25519 [comment]), whose signature on a strict remake's manifest this node takes: the values kept when the swarm turns strict (docs/access.md). Without, it votes for no strict remake, unless follow-strict-remakes.", + ), + option( + "follow-strict-remakes", + Kind::Flag, + "BOOL", + Some("false"), + "Without strict-keys: vote for strict remakes all the same, taking the manifests as the members proposing them sign them.", + ), option( "log-format", Kind::Text, @@ -595,6 +609,10 @@ pub(crate) struct Settings { #[serde(default)] pub(crate) bootstrap_keys: Vec, #[serde(default)] + pub(crate) strict_keys: Vec, + #[serde(default)] + pub(crate) follow_strict_remakes: bool, + #[serde(default)] pub(crate) cors_origins: Vec, #[serde(default)] pub(crate) sign_every: crate::document::signatures::SignEvery, @@ -871,6 +889,13 @@ impl Settings { { bail!("{why}"); } + if let Some(why) = self + .strict_keys + .iter() + .find_map(|line| crate::access::sshsig::PublicKey::parse(line).err()) + { + bail!("strict-keys: {why}"); + } if self.genesis && self.recover_from.is_some() { bail!("recover-from starts the swarm from a backup; it can't be combined with genesis"); } diff --git a/src/main.rs b/src/main.rs index 3674998..d0209c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -260,6 +260,12 @@ async fn main() -> Result<()> { token_lifetime: args.token_lifetime, session_lifetime: args.session_lifetime, challenge_lifetime: args.challenge_lifetime, + strict_keys: args + .strict_keys + .iter() + .filter_map(|line| access::sshsig::PublicKey::parse(line).ok()) + .collect(), + follow_strict_remakes: args.follow_strict_remakes, key_logins_per_user: args.key_logins_per_user, cors_origins: args.cors_origins.clone(), files, diff --git a/src/node.rs b/src/node.rs index 8886f68..a097a3a 100644 --- a/src/node.rs +++ b/src/node.rs @@ -78,6 +78,9 @@ pub(crate) struct NodeConfig { /// `--challenge-lifetime`, `--key-logins-per-user`. pub(crate) challenge_lifetime: Duration, pub(crate) key_logins_per_user: usize, + /// `--strict-keys`, `--follow-strict-remakes`. + pub(crate) strict_keys: Vec, + pub(crate) follow_strict_remakes: bool, /// `--cors-origins`. pub(crate) cors_origins: Vec, pub(crate) files: Arc, @@ -171,6 +174,8 @@ impl Node { session_lifetime: config.session_lifetime, challenge_lifetime: config.challenge_lifetime, key_logins_per_user: config.key_logins_per_user, + strict_keys: Arc::new(config.strict_keys), + follow_strict_remakes: config.follow_strict_remakes, cors_origins: Arc::new(config.cors_origins), state_file: config.state_file, document_file: config.document_file, 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( } }; + // 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(host: &H, current: Report, force: &Force) -> Res } } +/// `GET /admin/strict-manifest`. +pub(crate) async fn strict_manifest( + State(host): State, + axum::Extension(user): axum::Extension, +) -> 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 = 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(State(host): State) -> 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": "\n"}`. 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 { + 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 { + 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, + /// Strict remakes this node won't vote for (`vote::cast`), each logged once. + refused_strict: HashSet, /// 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() -> Router { get(api::get::).delete(api::abort::), ) .route("/admin/epochs", get(api::epochs::)) + .route("/admin/strict-manifest", get(api::strict_manifest::)) .route("/admin/quarantine", get(quarantine::list::)) .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 = 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 = 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(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(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(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(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( + 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]; diff --git a/src/simtest/mod.rs b/src/simtest/mod.rs index 0ebb0ae..67ddaff 100644 --- a/src/simtest/mod.rs +++ b/src/simtest/mod.rs @@ -222,6 +222,8 @@ impl Sim { session_lifetime: Duration::from_secs(86400), challenge_lifetime: Duration::from_secs(300), key_logins_per_user: 100, + strict_keys: Vec::new(), + follow_strict_remakes: false, cors_origins: Vec::new(), files: Arc::new(Files::default()), state_file: None, diff --git a/src/simtest/policy.rs b/src/simtest/policy.rs index 7f32695..0d590d7 100644 --- a/src/simtest/policy.rs +++ b/src/simtest/policy.rs @@ -899,3 +899,116 @@ async fn a_write_every_node_would_refuse() { assert!(!holds(&a, "secrets/x").await); assert_eq!(refused(&a).await, 0); } + +/// A swarm turning strict through a remake: what a member planted in the registry before +/// (an entry written plain, as a user who may) is gone unless the operator's manifest +/// keeps it; the policy is strict after. A node without the operator's key doesn't vote +/// for it, and follows once the others commit it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_swarm_turning_strict_through_a_remake() { + use crate::access::{sshsig, strict}; + let key = ed25519_dalek::SigningKey::from_bytes(&[12; 32]); + let public = sshsig::PublicKey(key.verifying_key().to_bytes()); + let line = public.line(); + let sim = Sim::with_identity(92); + let with_key = move |config: &mut crate::node::NodeConfig| config.strict_keys = vec![public]; + let a = sim.start_with("a", &[], Start::Genesis, { + move |config| { + config.first_users = [( + "alice".to_owned(), + [("laptop".to_owned(), line.clone())].into(), + )] + .into(); + with_key(config); + } + }); + let b = sim.start_with("b", &["a"], Start::Join, with_key); + let c = sim.start("c", &["a"], Start::Join); + sim.wait_ready(&[&a, &b, &c], SOON).await; + let nodes = [a.clone(), b.clone(), c.clone()]; + sim.wait_until("every node is registered", SOON, || { + let nodes = nodes.clone(); + async move { + for node in &nodes { + if crate::swarm::members::members(&node.doc.read().await.doc).len() < 3 { + return false; + } + } + true + } + }) + .await; + write_as( + &a, + "alice", + "_continuum/access/policy", + json!({"version": 1, "signatures": "required"}), + ) + .await; + // c plants mallory, as alice, who may: taken, the policy not being strict. + let mallory = crate::access::registry::entry("alice", 1, &crate::access::registry::all_caps()); + write_as(&c, "alice", "_continuum/users/mallory", mallory).await; + sim.wait_converged(&[&a, &b, &c], SOON).await; + assert!(holds(&a, "_continuum/users/mallory").await); + + // The operator's manifest, made on a, keeping everything but mallory. + let (text, heads) = { + let replica = a.doc.read().await; + let policy = json!({"version": 1, "signatures": "required", "values": "signed"}); + let swarm = store::read_path(&replica.doc, &path("_continuum/swarm")) + .unwrap() + .unwrap(); + let made = strict::manifest_of(&replica.doc, swarm.as_str().unwrap(), policy, "alice", 1); + let mut manifest: strict::Manifest = serde_json::from_str(&made).unwrap(); + manifest + .keep + .retain(|kept| kept.path != path("_continuum/users/mallory")); + let line = serde_json::to_string(&manifest).unwrap(); + let signed = sshsig::sign(&key, strict::NAMESPACE, line.as_bytes()); + let heads: Vec = replica + .doc + .get_heads() + .iter() + .map(ToString::to_string) + .collect(); + (format!("{line}\n{signed}"), heads) + }; + let (status, report) = crate::remake::testing::start( + a.clone(), + json!({"kind": "strict", "spec": {"manifest": text}, "baseHeads": heads}), + None, + ) + .await; + assert!(status.is_success(), "proposal refused: {status} {report}"); + sim.wait_until( + "every node is on generation 1", + Duration::from_secs(60), + || { + let nodes = nodes.clone(); + async move { + for node in &nodes { + if node.local_epoch().generation != 1 || !node.is_ready().await { + return false; + } + } + true + } + }, + ) + .await; + for node in [&a, &b, &c] { + let replica = node.doc.read().await; + assert!( + store::read_path(&replica.doc, &path("_continuum/users/mallory")) + .unwrap() + .is_none(), + "mallory planted, kept" + ); + assert!( + store::read_path(&replica.doc, &path("_continuum/users/alice")) + .unwrap() + .is_some() + ); + assert!(crate::access::policy::of(&replica.doc).strict()); + } +} diff --git a/src/state/host.rs b/src/state/host.rs index feda8a9..82b6c7c 100644 --- a/src/state/host.rs +++ b/src/state/host.rs @@ -420,6 +420,14 @@ impl Host for AppState { self.superseded_writes } + fn strict_keys(&self) -> &[crate::access::sshsig::PublicKey] { + &self.strict_keys + } + + fn follows_strict_remakes(&self) -> bool { + self.follow_strict_remakes + } + fn begin_rejoin( &self, target: Epoch, diff --git a/src/state/mod.rs b/src/state/mod.rs index 4fcf413..6e25336 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -161,6 +161,10 @@ pub(crate) struct AppState { /// may make within it (`access::challenge`). pub(crate) challenge_lifetime: Duration, pub(crate) key_logins_per_user: usize, + /// Whose signature on a strict remake's manifest this node takes, and whether it + /// takes any without (`access::strict`). + pub(crate) strict_keys: Arc>, + pub(crate) follow_strict_remakes: bool, /// Origins whose pages may call this node's open endpoints (`api::cors`). pub(crate) cors_origins: Arc>, /// This node's own files, and how much it read and wrote them. -- cgit v1.3.1