From f2fbee33e219fdfe7b67878dc8bfa7eeb526fa65 Mon Sep 17 00:00:00 2001 From: tv Date: Sun, 8 Nov 2026 09:33:42 +0000 Subject: access: signed values in before the next is judged Under a strict policy the rules read user data: the value at a signed place (prev) and what holds one. A batch judged at its frontier missed a signed write earlier in the same batch, so two successive signed writes at one path received together were refused on some nodes and taken on others. A change at, within or above a signed path now goes in before the next is judged. Review finding 2. --- src/access/screen.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/access/screen.rs b/src/access/screen.rs index 35a2af7..44d96ec 100644 --- a/src/access/screen.rs +++ b/src/access/screen.rs @@ -367,7 +367,7 @@ pub(crate) fn apply( if let Some(committer) = committer { screened.taken_from.insert(committer.to_owned()); } - if relevant(&paths[index]) { + if relevant(&paths[index]) || reaches_signed(doc, &paths[index]) { // What rules read changed: in before the next is judged. screened.applied += accepted.len(); doc.apply_changes_log_patches(std::mem::take(&mut accepted), log)?; @@ -470,7 +470,9 @@ pub(crate) fn apply_bytes( /// Whether a change touching `paths` changes what rules read: the members, the user /// registry, remake records, the policy and the rest of `_continuum/access`, bans, the -/// protocol data as a whole. +/// protocol data as a whole; and, under a strict policy, values their users sign +/// (`reaches_signed`). A rule that reads anything else (`rules::judge`) must add it +/// here, or a batch judged at its frontier is judged at heads it doesn't hold. fn relevant(paths: &BTreeSet) -> bool { paths.iter().any(|path| match path.as_slice() { [] => true, @@ -487,6 +489,20 @@ fn relevant(paths: &BTreeSet) -> bool { }) } +/// Whether a change touching `paths` changes values their users sign, under a strict +/// policy, at, within or above one (`envelope::reaches_signed`): the rules read the +/// value at a signed place (`prev`) and what holds one, so it goes in before the next +/// is judged. +fn reaches_signed(doc: &Document, paths: &BTreeSet) -> bool { + if !super::policy::of(doc).strict() { + return false; + } + let signed = super::envelope::signed_paths(doc); + paths + .iter() + .any(|path| super::envelope::reaches_signed(&signed, path)) +} + /// `candidates` in an order where every change comes after its dependencies among them /// (ready), and those with a dependency neither in `doc` nor among them, nor ready /// (unready). @@ -645,6 +661,61 @@ mod tests { assert!(screening.refusing("b").is_none()); } + /// Under a strict policy, two signed values written one after the other at the same + /// path, received in one batch: both taken, the second judged with the first in. + #[test] + fn successive_signed_values_in_one_batch() { + use crate::access::{envelope, sshsig}; + let key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]); + let mut theirs = Document::new(); + let mut alice = registry::entry("a", 1, &[(path("config"), &[Right::Write][..])]); + alice["keys"] = json!({ "k": sshsig::PublicKey(key.verifying_key().to_bytes()).line() }); + let writer = Writer { + node: "a", + user: None, + }; + transact(&mut theirs, UnixMillis(1), &writer, |doc| { + store::write_path(doc, &path("_continuum/swarm"), &json!("s"))?; + store::write_path( + doc, + &path("_continuum/access/policy"), + &json!({"version": 1, "values": "signed"}), + )?; + store::write_path( + doc, + &path("_continuum/access/userSigned"), + &json!([["config"]]), + )?; + store::write_path(doc, &path("_continuum/users/alice"), &alice) + }) + .unwrap(); + write(&mut theirs, "a", None, "_continuum/members/a", json!({})); + let sealed = |prev: &str, value: serde_json::Value| { + let payload = envelope::Payload { + path: path("config"), + value, + 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 first = sealed("", json!(1)); + let second = sealed(&envelope::digest(Some(&first)), json!(2)); + write(&mut theirs, "a", Some("alice"), "config", json!(first)); + write(&mut theirs, "a", Some("alice"), "config", json!(second)); + + let mut ours = Document::new(); + let screened = screen(&mut ours, received(&theirs, &[]), &mut Waiting::default()); + let why: Vec<&str> = screened.refused.iter().map(|r| r.why.as_str()).collect(); + assert!(why.is_empty(), "{why:?}"); + assert_eq!( + store::read_path(&ours, &path("config")).unwrap(), + Some(json!(second)) + ); + } + /// A refused change offered again: refused by its hash, not judged or kept again, /// and so is a new one building on it. #[test] -- cgit v1.3.1