aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/access/bans.rs2
-rw-r--r--src/access/envelope.rs58
-rw-r--r--src/access/policy.rs58
-rw-r--r--src/access/registry.rs4
-rw-r--r--src/document/signatures.rs2
-rw-r--r--src/document/store.rs53
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<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,
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<Vec<String>> {
let at: Vec<String> = 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, &current, 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<Option<JsonValue>, 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.<payload>.<signature>`) holds; None if it isn't one.
+fn enveloped(text: &str) -> Option<JsonValue> {
+ 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(