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. --- src/access/rules.rs | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/access/screen.rs | 25 +++++-- 2 files changed, 219 insertions(+), 7 deletions(-) (limited to 'src/access') 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()), }) } -- cgit v1.3.1