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 | 41657a4d749b1f1d44663ff6504f2e784f091537 (patch) | |
| tree | 3f4f866f60988696bf047fa718351457a3309b75 /src/access | |
| parent | a3e936154e07ba3b95157d26ffeae9364f3d5cef (diff) | |
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.
Diffstat (limited to 'src/access')
| -rw-r--r-- | src/access/bans.rs | 2 | ||||
| -rw-r--r-- | src/access/envelope.rs | 58 | ||||
| -rw-r--r-- | src/access/policy.rs | 58 | ||||
| -rw-r--r-- | src/access/registry.rs | 4 |
4 files changed, 97 insertions, 25 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<String> { /// 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.<payload>.<signature>` (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<String> { /// 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<Vec<String>> { - 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<Vec<String>> { .collect() } -/// The user-signed path `path` is at or within, if any (`listed`). -pub(crate) fn signed_path<'a>(listed: &'a [Vec<String>], 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<Vec<String>> { + [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<Vec<String>> { + 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<String>], path: &[String]) -> Option<Vec<String>> { + 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<User> { - 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, |
