From 41657a4d749b1f1d44663ff6504f2e784f091537 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:21 +0100 Subject: access: strict policy, signed paths "values": "signed" (strict, requiring signatures too): the registry, _continuum/access, bans and the paths in _continuum/access/userSigned (* for any key) hold values their users signed. Envelopes read as the values they hold where the node reads protocol data. Auth plan M7. --- src/access/bans.rs | 2 +- src/access/envelope.rs | 58 +++++++++++++++++++++++++++++++++++----------- src/access/policy.rs | 58 +++++++++++++++++++++++++++++++++++++++------- src/access/registry.rs | 4 ++-- src/document/signatures.rs | 2 +- src/document/store.rs | 53 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 26 deletions(-) diff --git a/src/access/bans.rs b/src/access/bans.rs index 339f6cf..c4fad1f 100644 --- a/src/access/bans.rs +++ b/src/access/bans.rs @@ -14,5 +14,5 @@ pub(crate) fn path(node: &str) -> Vec { /// Whether `doc` bans `node`. pub(crate) fn banned(doc: &impl ReadDoc, node: &str) -> bool { - store::read_path(doc, &path(node)).ok().flatten().is_some() + store::read_plain(doc, &path(node)).ok().flatten().is_some() } diff --git a/src/access/envelope.rs b/src/access/envelope.rs index a875dca..7244a3b 100644 --- a/src/access/envelope.rs +++ b/src/access/envelope.rs @@ -1,5 +1,6 @@ -//! Values signed by their user: at a path the swarm lists as user-signed -//! (`_continuum/access/userSigned`, `listed`), the value is a signed envelope, one +//! Values signed by their user: under a strict policy (`access::policy`), at what +//! decides who may do what and at the paths the swarm lists as user-signed +//! (`_continuum/access/userSigned`, `listed`; `signed_paths`), the value is a signed envelope, one //! string, `cv1..` (each base64url, unpadded). The payload is JSON: //! the `path` it is for (segments), the `value` (`null`: deleted), the `user` who //! signed it, when (`at`, Unix ms, by the signer's clock), and `prev`: the SHA-256 (hex) @@ -34,7 +35,7 @@ pub(crate) fn listed_at() -> Vec { /// The paths `doc` lists as user-signed; an entry that isn't a list of strings is left /// out. pub(crate) fn listed(doc: &impl ReadDoc) -> Vec> { - let Ok(Some(JsonValue::Array(entries))) = store::read_path(doc, &listed_at()) else { + let Ok(Some(JsonValue::Array(entries))) = store::read_plain(doc, &listed_at()) else { return Vec::new(); }; entries @@ -49,12 +50,35 @@ pub(crate) fn listed(doc: &impl ReadDoc) -> Vec> { .collect() } -/// The user-signed path `path` is at or within, if any (`listed`). -pub(crate) fn signed_path<'a>(listed: &'a [Vec], path: &[String]) -> Option<&'a [String]> { - listed +/// What decides who may do what, whose values are always user-signed under a strict +/// policy (`access::policy`): every user's entry, every entry of `_continuum/access`, +/// every ban. A `*` segment stands for any one key. +fn deciding() -> Vec> { + [meta::USERS, meta::ACCESS, meta::BANNED] .iter() - .find(|signed| path.starts_with(signed)) - .map(Vec::as_slice) + .map(|part| meta::path(&[part, "*"])) + .collect() +} + +/// The paths whose values their users sign under a strict policy: what decides who may +/// do what (`deciding`), and those `doc` lists (`listed`). +pub(crate) fn signed_paths(doc: &impl ReadDoc) -> Vec> { + let mut paths = deciding(); + paths.extend(listed(doc)); + paths +} + +/// The user-signed path `path` is at or within, if any, of `signed` (`signed_paths`): +/// its `*` segments as `path` has them. +pub(crate) fn signed_path(signed: &[Vec], path: &[String]) -> Option> { + signed.iter().find_map(|pattern| { + (path.len() >= pattern.len() + && pattern + .iter() + .zip(path) + .all(|(segment, key)| segment == "*" || segment == key)) + .then(|| path[..pattern.len()].to_vec()) + }) } /// A signed value's payload. @@ -242,13 +266,19 @@ mod tests { change(&mut doc, |doc| { store::write_path(doc, &listed_at(), &json!([["config"], ["a", "b"], "junk"])).unwrap(); }); - let listed = listed(&doc); - assert_eq!(listed, vec![path("config"), path("a/b")]); + assert_eq!(listed(&doc), vec![path("config"), path("a/b")]); + let signed = signed_paths(&doc); + assert_eq!(signed_path(&signed, &path("config")), Some(path("config"))); + assert_eq!(signed_path(&signed, &path("a/b/c")), Some(path("a/b"))); + assert_eq!(signed_path(&signed, &path("a")), None); + assert_eq!( + signed_path(&signed, &path("_continuum/users/alice/caps")), + Some(path("_continuum/users/alice")) + ); + assert_eq!(signed_path(&signed, &path("_continuum/users")), None); assert_eq!( - signed_path(&listed, &path("config")), - Some(&path("config")[..]) + signed_path(&signed, &path("_continuum/access/policy")), + Some(path("_continuum/access/policy")) ); - assert_eq!(signed_path(&listed, &path("a/b/c")), Some(&path("a/b")[..])); - assert_eq!(signed_path(&listed, &path("a")), None); } } diff --git a/src/access/policy.rs b/src/access/policy.rs index f8ccfc2..8efedff 100644 --- a/src/access/policy.rs +++ b/src/access/policy.rs @@ -6,7 +6,11 @@ //! `{"version": 1, "signatures": "optional"}`: rules enforced; a change is its claimed //! committer's whether or not a signature proves it (`document::signatures`). //! `"required"`: a change is applied once a signature by its committer vouches for it, -//! and waits until then; every node signs every change it makes. A policy +//! and waits until then; every node signs every change it makes. `"values": "signed"` +//! (strict): signatures required, and what decides who may do what (the user registry, +//! the policy and the rest of `_continuum/access`, bans) and the paths listed in +//! `_continuum/access/userSigned` hold values their users signed (`access::envelope`), +//! so no member writes there but as a user signed it. A policy //! this node doesn't know (another version, or a setting it lacks) stops it from taking //! writes through the API, and it receives without judging: it can't judge as the //! others do, so it writes nothing they might refuse, until it is upgraded. @@ -30,7 +34,7 @@ pub(crate) enum Policy { Open, /// Rules by path enforced; with signatures `required`, only on changes a signature /// proves. - Enforced { required: bool }, + Enforced { required: bool, strict: bool }, /// One this node doesn't know, and why. Unknown(String), } @@ -42,13 +46,20 @@ impl Policy { /// Whether changes need a signature (`signatures: "required"`). pub(crate) fn required(&self) -> bool { - matches!(self, Policy::Enforced { required: true }) + matches!(self, Policy::Enforced { required: true, .. }) + } + + /// Whether values at the user-signed paths need their users' signatures + /// (`values: "signed"`, `access::envelope::signed_paths`). + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn strict(&self) -> bool { + matches!(self, Policy::Enforced { strict: true, .. }) } } /// The policy `doc` holds. pub(crate) fn of(doc: &impl ReadDoc) -> Policy { - let value = match store::read_path(doc, &path()) { + let value = match store::read_plain(doc, &path()) { Ok(Some(value)) => value, Ok(None) => return Policy::Open, Err(error) => return Policy::Unknown(format!("it doesn't read: {error}")), @@ -62,9 +73,21 @@ pub(crate) fn of(doc: &impl ReadDoc) -> Policy { .map_or("none".to_owned(), JsonValue::to_string) )); } + let strict = match value.get("values").and_then(JsonValue::as_str) { + None | Some("plain") => false, + Some("signed") => true, + Some(other) => return Policy::Unknown(format!("values {other:?}")), + }; match value.get("signatures").and_then(JsonValue::as_str) { - None | Some("optional") => Policy::Enforced { required: false }, - Some("required") => Policy::Enforced { required: true }, + // Signed values: signed changes too. + None | Some("optional") => Policy::Enforced { + required: strict, + strict, + }, + Some("required") => Policy::Enforced { + required: true, + strict, + }, Some(other) => Policy::Unknown(format!("signatures {other:?}")), } } @@ -87,7 +110,10 @@ mod tests { #[test] fn what_the_policy_says() { assert_eq!(of(&Document::new()), Policy::Open); - let optional = Policy::Enforced { required: false }; + let optional = Policy::Enforced { + required: false, + strict: false, + }; assert_eq!(with(json!({"version": 1})), optional); assert_eq!( with(json!({"version": 1, "signatures": "optional"})), @@ -95,8 +121,24 @@ mod tests { ); assert_eq!( with(json!({"version": 1, "signatures": "required"})), - Policy::Enforced { required: true } + Policy::Enforced { + required: true, + strict: false + } + ); + assert_eq!( + with(json!({"version": 1, "values": "signed"})), + Policy::Enforced { + required: true, + strict: true + } ); + assert!(with(json!({"version": 1, "values": "signed"})).strict()); + assert!(!with(json!({"version": 1, "signatures": "required"})).strict()); + assert!(matches!( + with(json!({"version": 1, "values": "sometimes"})), + Policy::Unknown(_) + )); assert!(matches!(with(json!({"version": 2})), Policy::Unknown(_))); assert!(matches!( with(json!({"version": 1, "signatures": "sometimes"})), diff --git a/src/access/registry.rs b/src/access/registry.rs index caa263d..618402b 100644 --- a/src/access/registry.rs +++ b/src/access/registry.rs @@ -128,10 +128,10 @@ impl User { /// The entry of user `name`, if it has one. pub(crate) fn user(doc: &impl ReadDoc, name: &str) -> Option { - let value = store::read_path(doc, &user_path(name)).ok()??; + let value = store::read_plain(doc, &user_path(name)).ok()??; let mut user = value.is_object().then(|| User::from_json(&value))?; // A damaged one revokes every token. - user.tokens_not_before = match store::read_path(doc, &logouts_path(name)) { + user.tokens_not_before = match store::read_plain(doc, &logouts_path(name)) { Ok(None) => 0, Ok(Some(at)) => at.as_u64().unwrap_or(u64::MAX), Err(_) => u64::MAX, diff --git a/src/document/signatures.rs b/src/document/signatures.rs index 63b19a5..5d8731b 100644 --- a/src/document/signatures.rs +++ b/src/document/signatures.rs @@ -67,7 +67,7 @@ fn related(signed: &[String], path: &[String]) -> bool { /// strings is left out. pub(crate) fn listed(doc: &Document) -> Vec> { let at: Vec = SIGNED_PATHS.iter().map(|s| (*s).to_owned()).collect(); - let Ok(Some(serde_json::Value::Array(entries))) = super::store::read_path(doc, &at) else { + let Ok(Some(serde_json::Value::Array(entries))) = super::store::read_plain(doc, &at) else { return Vec::new(); }; entries diff --git a/src/document/store.rs b/src/document/store.rs index 778307f..c946fef 100644 --- a/src/document/store.rs +++ b/src/document/store.rs @@ -454,6 +454,30 @@ fn read_path_inner( Ok(Some(resolved_to_json(doc, ¤t, form, log)?)) } +/// [`read_path`] of a value its user may have signed (`access::envelope`): an envelope +/// read as the value it holds, unchecked (the policy checked it before it went in), a +/// signed `null` as nothing. +pub(crate) fn read_plain( + doc: &impl ReadDoc, + path: &[String], +) -> Result, StoreError> { + Ok(match read_path(doc, path)? { + Some(JsonValue::String(text)) if text.starts_with("cv1.") => { + enveloped(&text).filter(|value| !value.is_null()) + } + other => other, + }) +} + +/// The value an envelope (`cv1..`) holds; None if it isn't one. +fn enveloped(text: &str) -> Option { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let payload = text.split('.').nth(1)?; + let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?; + let mut payload: JsonValue = serde_json::from_slice(&bytes).ok()?; + Some(payload.get_mut("value")?.take()) +} + /// Reads a path; the empty path is the whole store. pub(crate) fn read_path( doc: &impl ReadDoc, @@ -1673,6 +1697,35 @@ mod tests { at.split('/').map(str::to_owned).collect() } + /// An envelope reads as the value it holds, a signed `null` as nothing, anything + /// else as it is. + #[test] + fn plain_reads_open_envelopes() { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let sealed = |value: JsonValue| { + let payload = + json!({"path": ["a"], "value": value, "user": "alice", "at": 1, "prev": ""}); + format!("cv1.{}.c2ln", URL_SAFE_NO_PAD.encode(payload.to_string())) + }; + let mut doc = crate::document::Document::new(); + try_change(&mut doc, |doc| { + write_path(doc, &path("a"), &json!(sealed(json!({"mode": "fast"}))))?; + write_path(doc, &path("gone"), &json!(sealed(JsonValue::Null)))?; + write_path(doc, &path("plain"), &json!("cv1-ish")) + }) + .unwrap(); + assert_eq!( + read_plain(&doc, &path("a")).unwrap(), + Some(json!({"mode": "fast"})) + ); + assert_eq!(read_plain(&doc, &path("gone")).unwrap(), None); + assert_eq!( + read_plain(&doc, &path("plain")).unwrap(), + Some(json!("cv1-ish")) + ); + assert_eq!(read_plain(&doc, &path("none")).unwrap(), None); + } + #[test] fn changes_name_the_paths_their_operations_touch() { fn touched( -- cgit v1.3.1