From 70ff0ec9e50bf506cc6d80b929a81e350289f6cc Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:21 +0100 Subject: access: signed values judged on receipt Inspection gives each operation's kind; under strict every node refuses, at a signed place, anything but an envelope that checks and replaces the one there (deleting first as replacing does), anything within one, and above one anything replacing what holds one. Auth plan M7. --- docs/access.md | 9 ++- src/access/rules.rs | 201 +++++++++++++++++++++++++++++++++++++++++++++++- src/access/screen.rs | 25 ++++-- src/api/store.rs | 6 +- src/document/inspect.rs | 97 +++++++++++++++++++++-- 5 files changed, 319 insertions(+), 19 deletions(-) diff --git a/docs/access.md b/docs/access.md index fa0b47d..20fa23d 100644 --- a/docs/access.md +++ b/docs/access.md @@ -261,6 +261,9 @@ 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. Without the policy, signed values may be written all the same, and plain ones too. -What it doesn't do yet: **judge signed values on receipt.** Each node checks the -values its users write through it; a member writing to its document directly could -still write there. +Every node judges the changes it receives alike (Changes judged on every node, above), +as their writer saw the registry: at such a place, only a string that is an envelope +that checks, replacing the one there then; nothing within one; and above one, nothing +replacing or deleting what holds one (a map made where there is none yet is fine). So +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. diff --git a/src/access/rules.rs b/src/access/rules.rs index 4df0ba8..1ce22ee 100644 --- a/src/access/rules.rs +++ b/src/access/rules.rs @@ -17,6 +17,8 @@ //! proposal names, the rest of a record by any member; a user's tokens revoked by that //! user too (logging out); the rest by a user who may write there. //! * User data: by a user who may write there. +//! * Under a strict policy (`access::policy`), the values their users sign +//! (`access::envelope`) only as they signed them (`signed_values`). //! //! In a swarm without a user registry (`access::registry`), whatever a user would need, //! any member may. @@ -26,7 +28,7 @@ use std::collections::BTreeSet; use crate::access::Right; use crate::access::registry; use crate::document::at::At; -use crate::document::inspect::Path; +use crate::document::inspect::{Action, Op, Path}; use crate::document::meta; use crate::document::replica::Epoch; use crate::document::{ChangeHash, Document, store}; @@ -46,6 +48,8 @@ pub(crate) struct Change<'a> { pub(crate) user: Option<&'a str>, /// What it touches (`inspect::Inspector`). pub(crate) paths: &'a BTreeSet, + /// Its operations, where each is and what it does there (`inspect::Inspector::ops`). + pub(crate) ops: &'a [Op], } /// Why a change is refused. @@ -150,9 +154,87 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result return refuse(format!("{} may not write {}", who(change), shown(path))); } } + if super::policy::of(&at).strict() { + signed_values(&at, change.ops).or_else(refuse)?; + } + Ok(()) +} + +/// Under a strict policy, `ops` change the values their users sign (`access::envelope`) +/// only as they signed them, judged at `at`, as their writer saw it: at such a place, a +/// string put there that is an envelope by a user who may write there, replacing the +/// one there (`envelope::check`), and nothing else (but deleting what is there first, +/// as a write replacing it does); nothing within one; above one, +/// nothing replacing or deleting what holds one (a map made where there is none yet, +/// what is inside a list or text, which holds none). +fn signed_values(at: &At<'_>, ops: &[Op]) -> Result<(), String> { + let signed = super::envelope::signed_paths(at); + for op in ops { + let path = &op.path; + if op.action == Action::Nowhere { + return Err("it writes in an object nowhere to be found".to_owned()); + } + if super::envelope::is_signed(&signed, path) { + let replaced = || { + ops.iter().any(|other| { + other.path == *path && matches!(other.action, Action::PutString(_)) + }) + }; + let envelope = match &op.action { + Action::PutString(envelope) => envelope, + // Cleared for the string put there, which is checked. + Action::Delete if replaced() => continue, + _ => return Err(format!("{} holds a value its user signs", shown(path))), + }; + let previous = match store::read_path(at, path) { + Ok(Some(serde_json::Value::String(text))) + if super::envelope::read(&text).is_ok() => + { + Some(text) + } + _ => None, + }; + super::envelope::check(at, envelope, path, previous.as_deref()) + .map_err(|why| format!("{}: {why}", shown(path)))?; + } else if super::envelope::signed_path(&signed, path).is_some() { + return Err(format!("{} is within a value its user signs", shown(path))); + } else if op.action != Action::Within + && super::envelope::above_signed(&signed, path) + && holds_signed(at, path, &signed) + { + return Err(format!("{} holds values their users sign", shown(path))); + } + } Ok(()) } +/// Whether what is at `path` in `at` holds a value at a place for one its user signs, +/// of `signed` (`envelope::signed_paths`), signed or from before the policy; one that +/// doesn't read, as if it did. +fn holds_signed(at: &At<'_>, path: &[String], signed: &[Vec]) -> bool { + fn within(value: &serde_json::Value, place: &mut Vec, signed: &[Vec]) -> bool { + if super::envelope::is_signed(signed, place) { + return true; + } + match value { + serde_json::Value::Object(entries) if super::envelope::above_signed(signed, place) => { + entries.iter().any(|(key, value)| { + place.push(key.clone()); + let holds = within(value, place, signed); + place.pop(); + holds + }) + } + _ => false, + } + } + match store::read_path(at, path) { + Ok(Some(value)) => within(&value, &mut path.to_vec(), signed), + Ok(None) => false, + Err(_) => true, + } +} + /// Who wrote it, for a refusal. fn who(change: &Change<'_>) -> String { match (change.committer, change.user) { @@ -246,6 +328,7 @@ mod tests { committer: Some(committer), user, paths: &touched, + ops: &[], }, ) } @@ -335,6 +418,7 @@ mod tests { committer: Some("b"), user: None, paths: &touched, + ops: &[], }, ); assert!(earlier.is_ok()); @@ -399,6 +483,7 @@ mod tests { committer: Some("a"), user: Some("alice"), paths: &touched, + ops: &[], }, ) }; @@ -408,6 +493,119 @@ mod tests { assert_eq!(change(&[ChangeHash([7; 32])]), Err(Refusal::Unready)); } + /// Under a strict policy, a value its user signs only as signed: an envelope that + /// checks, replacing the one there; nothing else there, nothing within one, nothing + /// above replacing what holds one. + #[test] + fn signed_values_as_signed() { + use crate::access::{envelope, sshsig}; + let key = ed25519_dalek::SigningKey::from_bytes(&[3; 32]); + let mut doc = swarm(false); + let mut alice = registry::entry( + "a", + 1, + &[ + (Vec::new(), &[Right::Write][..]), + (path("_continuum"), &[Right::Write][..]), + ], + ); + alice["keys"] = json!({ "k": sshsig::PublicKey(key.verifying_key().to_bytes()).line() }); + write(&mut doc, "_continuum/users/alice", alice); + write( + &mut doc, + "_continuum/access/userSigned", + json!([["config"], ["a", "b"]]), + ); + write( + &mut doc, + "_continuum/access/policy", + json!({"version": 1, "values": "signed"}), + ); + let sealed = |at: &str, prev: &str| { + let payload = envelope::Payload { + path: path(at), + value: json!({"mode": "fast"}), + user: "alice".to_owned(), + at: 1, + prev: prev.to_owned(), + }; + let bytes = serde_json::to_vec(&payload).unwrap(); + envelope::seal(&bytes, &sshsig::sign(&key, envelope::NAMESPACE, &bytes)).unwrap() + }; + let judged = |doc: &Document, ops: Vec| { + let deps = doc.get_heads(); + let touched: BTreeSet = ops.iter().map(|op| op.path.clone()).collect(); + judge( + doc, + Epoch::GENESIS, + &Change { + hash: ChangeHash([9; 32]), + deps: &deps, + seen: &deps, + committer: Some("a"), + user: Some("alice"), + paths: &touched, + ops: &ops, + }, + ) + }; + let op = |at: &str, action: Action| Op { + path: path(at), + action, + }; + let first = sealed("config", ""); + assert!(judged(&doc, vec![op("config", Action::PutString(first.clone()))]).is_ok()); + assert!(refused(judged( + &doc, + vec![op("config", Action::PutString("plain".to_owned()))] + ))); + assert!(refused(judged( + &doc, + vec![op("config", Action::PutString(sealed("other", "")))] + ))); + assert!(refused(judged(&doc, vec![op("config", Action::Delete)]))); + assert!(refused(judged( + &doc, + vec![ + op("config", Action::Delete), + op("config", Action::PutString("plain".to_owned())) + ] + ))); + assert!(refused(judged(&doc, vec![op("config/x", Action::Other)]))); + // Replacing: only the one there. + write(&mut doc, "config", json!(first)); + let second = sealed("config", &envelope::digest(Some(&first))); + assert!(judged(&doc, vec![op("config", Action::PutString(second.clone()))]).is_ok()); + // As a write replacing it makes it: what is there deleted, then the string put. + assert!( + judged( + &doc, + vec![ + op("config", Action::Delete), + op("config", Action::PutString(second)) + ] + ) + .is_ok() + ); + assert!(refused(judged( + &doc, + vec![op("config", Action::PutString(sealed("config", "")))] + ))); + // Above: a map made where there is none, not what holds a signed value. + assert!(judged(&doc, vec![op("a", Action::MakeMap)]).is_ok()); + assert!(refused(judged( + &doc, + vec![op("_continuum/users", Action::Delete)] + ))); + assert!(refused(judged( + &doc, + vec![op("_continuum/users/alice/caps", Action::Other)] + ))); + // Elsewhere: as without the policy. + assert!(judged(&doc, vec![op("notes/x", Action::PutString("x".to_owned()))]).is_ok()); + assert!(refused(judged(&doc, vec![op("", Action::Nowhere)]))); + } + /// Roots: the first change of a swarm's first epoch, a later epoch's snapshot. #[test] fn roots() { @@ -423,6 +621,7 @@ mod tests { committer: None, user: None, paths: &touched, + ops: &[], }, ) }; diff --git a/src/access/screen.rs b/src/access/screen.rs index 507de1d..736e04a 100644 --- a/src/access/screen.rs +++ b/src/access/screen.rs @@ -4,8 +4,8 @@ //! //! A change is judged at the heads its writer saw. Those of a batch aren't in the //! document yet, so one is judged at the changes of the document its batch ancestors -//! build on: what rules read there (the members, the user registry, remake proposals) -//! is what its writer saw, unless one of those ancestors changed it. So a change that +//! build on: what rules read there (the members, the user registry, remake proposals, +//! the policy, bans) is what its writer saw, unless one of those ancestors changed it. So a change that //! does (`relevant`) is applied before the next is judged: the rest go in at once. //! //! A change whose dependencies are neither in the document nor among those received @@ -19,7 +19,7 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use anyhow::Result; use super::rules::{self, Refusal}; -use crate::document::inspect::{Inspector, Path}; +use crate::document::inspect::{Inspector, Op, Path}; use crate::document::meta; use crate::document::replica::{Epoch, user_of}; use crate::document::signatures::Signatures; @@ -133,7 +133,11 @@ pub(crate) fn apply( let decoded: Vec = ready.iter().map(|change| change.decode()).collect(); - let paths = Inspector::new(doc).paths(&decoded); + let ops: Vec> = Inspector::new(doc).ops(&decoded); + let paths: Vec> = ops + .iter() + .map(|ops| ops.iter().map(|op| op.path.clone()).collect()) + .collect(); // Who made each: its author, or that of its actor's first change (in the document, // or among these). let mut authors: HashMap = HashMap::new(); @@ -216,6 +220,7 @@ pub(crate) fn apply( committer, user: user.as_deref(), paths: &paths[index], + ops: &ops[index], }, ); match judged { @@ -285,13 +290,21 @@ pub(crate) fn apply_bytes( } /// Whether a change touching `paths` changes what rules read: the members, the user -/// registry, remake records, the protocol data as a whole. +/// registry, remake records, the policy and the rest of `_continuum/access`, bans, the +/// protocol data as a whole. fn relevant(paths: &BTreeSet) -> bool { paths.iter().any(|path| match path.as_slice() { [] => true, [root, ..] if root != meta::ROOT => false, [_] => true, - [_, part, ..] => [meta::MEMBERS, meta::USERS, meta::REMAKES].contains(&part.as_str()), + [_, part, ..] => [ + meta::MEMBERS, + meta::USERS, + meta::REMAKES, + meta::ACCESS, + meta::BANNED, + ] + .contains(&part.as_str()), }) } diff --git a/src/api/store.rs b/src/api/store.rs index b5baa23..e61a09d 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -564,7 +564,11 @@ async fn put_signed( envelope::check(&*doc, &sealed, &path, previous.as_deref()) .map_err(|error| ApiError(StatusCode::FORBIDDEN, Some(error)))?; let before = Before::of(&*doc, &writer); - store::write_path(doc, &path, &JsonValue::String(sealed)) + // A plain string, whatever was there: what every node checks it as + // (`access::rules`). + let envelope = Val::Scalar(ScalarValue::Str(sealed.into())); + let writing = store::Writing::new(store::Sticky::No, TimestampFormat::default(), true); + store::write_val_path(doc, &path, &envelope, writing) .map_err(|e| store_status(e, "Failed to write store value"))?; registry_kept(&*doc, &path, before, &writer)?; read_shown( diff --git a/src/document/inspect.rs b/src/document/inspect.rs index b39386e..d395008 100644 --- a/src/document/inspect.rs +++ b/src/document/inspect.rs @@ -11,9 +11,6 @@ //! change lacks a dependency, and can't be applied anyway) is placed at the root, which //! is within every path's reach. -// Judging changes on receipt is what uses it; until then, the benchmark and tests. -#![cfg_attr(not(test), allow(dead_code))] - use std::collections::{BTreeSet, HashMap}; use automerge::legacy::{Key, ObjectId, OpId, OpType}; @@ -24,6 +21,31 @@ use super::{Document, ReadDoc}; /// A store path, by segments. pub(crate) type Path = Vec; +/// An operation, as rules see it (`Inspector::ops`): where, and what it does there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Op { + /// What it touches. + pub(crate) path: Path, + pub(crate) action: Action, +} + +/// What an operation does at its path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Action { + /// Makes a map there. + MakeMap, + /// Puts a string there. + PutString(String), + /// Deletes what is there. + Delete, + /// Changes what is there, a list or text it is in (`Place::in_list`). + Within, + /// In an object nowhere to be found (`Inspector::known`). + Nowhere, + /// Anything else there: makes another object, puts another scalar, increments. + Other, +} + /// Where an object is: its path, cut at the first list or text on the way /// (`in_list`: it is inside one, or is one). #[derive(Debug, Clone, PartialEq, Eq)] @@ -79,10 +101,20 @@ impl<'a> Inspector<'a> { } } - /// The paths each of `changes` touches, in their order. The objects they make are - /// placed first, whatever their order, so a change is judged the same however the - /// changes came. + /// The paths each of `changes` touches, in their order (`ops`): for the benchmark + /// and tests. + #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn paths(&mut self, changes: &[ExpandedChange]) -> Vec> { + self.ops(changes) + .into_iter() + .map(|ops| ops.into_iter().map(|op| op.path).collect()) + .collect() + } + + /// The operations of each of `changes`, in their order, as rules see them. The + /// objects they make are placed first, whatever their order, so a change is judged + /// the same however the changes came. + pub(crate) fn ops(&mut self, changes: &[ExpandedChange]) -> Vec> { // The objects made, by the operation that made them: placed as their parents // are, until no more can be (a parent made later in the list, placed in a later // round). @@ -119,9 +151,23 @@ impl<'a> Inspector<'a> { .operations .iter() .map(|op| match self.known(&op.obj) { - Some(place) => place.touched(&op.key), + Some(place) => Op { + path: place.touched(&op.key), + action: match &op.action { + _ if place.in_list || op.insert => Action::Within, + OpType::Make(automerge::ObjType::Map) => Action::MakeMap, + OpType::Put(automerge::ScalarValue::Str(text)) => { + Action::PutString(text.to_string()) + } + OpType::Delete => Action::Delete, + _ => Action::Other, + }, + }, // Nowhere to be found: within every path's reach. - None => Vec::new(), + None => Op { + path: Vec::new(), + action: Action::Nowhere, + }, }) .collect() }) @@ -214,6 +260,41 @@ mod tests { assert_eq!(backwards, all); } + /// What each operation does where: a map made, a string put, a deletion, what is + /// inside a list, anything else. + #[test] + fn what_operations_do() { + let mut doc = Document::new(); + change(&mut doc, |doc| { + let config = doc.put_object(ROOT, "config", ObjType::Map).unwrap(); + doc.put(&config, "mode", "fast").unwrap(); + doc.put(&config, "level", 3).unwrap(); + doc.delete(&config, "mode").unwrap(); + let queue = doc.put_object(ROOT, "queue", ObjType::List).unwrap(); + doc.insert(&queue, 0, "job").unwrap(); + }); + let empty = Document::new(); + let ops = Inspector::new(&empty).ops(&decoded(&doc, &[])); + let actions: Vec<(Path, Action)> = ops[0] + .iter() + .map(|op| (op.path.clone(), op.action.clone())) + .collect(); + assert_eq!( + actions, + vec![ + (path(&["config"]), Action::MakeMap), + ( + path(&["config", "mode"]), + Action::PutString("fast".to_owned()) + ), + (path(&["config", "level"]), Action::Other), + (path(&["config", "mode"]), Action::Delete), + (path(&["queue"]), Action::Other), + (path(&["queue"]), Action::Within), + ] + ); + } + /// Inside a list or text, a path stops at it: items move. #[test] fn lists_and_text_stop_paths() { -- cgit v1.3.1