aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/access/mod.rs1
-rw-r--r--src/access/strict.rs224
-rw-r--r--src/config/mod.rs25
-rw-r--r--src/main.rs6
-rw-r--r--src/node.rs5
-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
-rw-r--r--src/simtest/mod.rs2
-rw-r--r--src/simtest/policy.rs113
-rw-r--r--src/state/host.rs8
-rw-r--r--src/state/mod.rs4
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.