diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/mod.rs | 1 | ||||
| -rw-r--r-- | src/access/strict.rs | 224 | ||||
| -rw-r--r-- | src/config/mod.rs | 25 | ||||
| -rw-r--r-- | src/main.rs | 6 | ||||
| -rw-r--r-- | src/node.rs | 5 | ||||
| -rw-r--r-- | src/remake/api.rs | 68 | ||||
| -rw-r--r-- | src/remake/kind.rs | 114 | ||||
| -rw-r--r-- | src/remake/local.rs | 7 | ||||
| -rw-r--r-- | src/remake/mod.rs | 6 | ||||
| -rw-r--r-- | src/remake/snapshot.rs | 4 | ||||
| -rw-r--r-- | src/remake/vote.rs | 54 | ||||
| -rw-r--r-- | src/simtest/mod.rs | 2 | ||||
| -rw-r--r-- | src/simtest/policy.rs | 113 | ||||
| -rw-r--r-- | src/state/host.rs | 8 | ||||
| -rw-r--r-- | src/state/mod.rs | 4 |
15 files changed, 629 insertions, 12 deletions
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<Kept>, + /// 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<String>, + 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<String>, JsonValue)> { + fn expand( + doc: &impl ReadDoc, + pattern: &[String], + at: Vec<String>, + out: &mut Vec<(Vec<String>, 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<Manifest, String> { + 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<Manifest, String> { + 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<String> { + 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<Vec<String>> = 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 @@ -180,6 +180,20 @@ pub(crate) const OPTIONS: &[OptionSpec] = &[ "With bootstrap-users: their SSH keys, as authorized_keys lines (ssh-ed25519 <base64> [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 <base64> [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, "auto|text|journal", @@ -595,6 +609,10 @@ pub(crate) struct Settings { #[serde(default)] pub(crate) bootstrap_keys: Vec<String>, #[serde(default)] + pub(crate) strict_keys: Vec<String>, + #[serde(default)] + pub(crate) follow_strict_remakes: bool, + #[serde(default)] pub(crate) cors_origins: Vec<String>, #[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<crate::access::sshsig::PublicKey>, + pub(crate) follow_strict_remakes: bool, /// `--cors-origins`. pub(crate) cors_origins: Vec<String>, pub(crate) files: Arc<Files>, @@ -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<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]; 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<String> = 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<Vec<crate::access::sshsig::PublicKey>>, + pub(crate) follow_strict_remakes: bool, /// Origins whose pages may call this node's open endpoints (`api::cors`). pub(crate) cors_origins: Arc<Vec<String>>, /// This node's own files, and how much it read and wrote them. |
