diff options
| author | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-07 22:35:21 +0100 |
| commit | 70ff0ec9e50bf506cc6d80b929a81e350289f6cc (patch) | |
| tree | 14c66ce846229889abd98acf98ceb645754b7e91 /src/access/rules.rs | |
| parent | 645bae8fb8065a353237d24aff274009f37d694e (diff) | |
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.
Diffstat (limited to 'src/access/rules.rs')
| -rw-r--r-- | src/access/rules.rs | 201 |
1 files changed, 200 insertions, 1 deletions
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<Path>, + /// 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<String>]) -> bool { + fn within(value: &serde_json::Value, place: &mut Vec<String>, signed: &[Vec<String>]) -> 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<Op>| { + let deps = doc.get_heads(); + let touched: BTreeSet<Path> = 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: &[], }, ) }; |
