aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/access/mod.rs1
-rw-r--r--src/access/rules.rs93
-rw-r--r--src/access/sealed.rs378
-rw-r--r--src/access/sshsig.rs2
-rw-r--r--src/api/store.rs11
5 files changed, 484 insertions, 1 deletions
diff --git a/src/access/mod.rs b/src/access/mod.rs
index 73ae7c9..2a0077e 100644
--- a/src/access/mod.rs
+++ b/src/access/mod.rs
@@ -20,6 +20,7 @@ pub(crate) mod policy;
pub(crate) mod registry;
pub(crate) mod rules;
pub(crate) mod screen;
+pub(crate) mod sealed;
pub(crate) mod sshsig;
pub(crate) mod token;
diff --git a/src/access/rules.rs b/src/access/rules.rs
index 1ce22ee..f285328 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -19,6 +19,8 @@
//! * 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`).
+//! * Where the swarm declares values sealed (`access::sealed`), only those sealed as
+//! declared (`sealed_values`).
//!
//! In a swarm without a user registry (`access::registry`), whatever a user would need,
//! any member may.
@@ -157,6 +159,40 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result
if super::policy::of(&at).strict() {
signed_values(&at, change.ops).or_else(refuse)?;
}
+ let declared = super::sealed::declared(&at);
+ if !declared.is_empty() {
+ sealed_values(&at, &declared, change.ops).or_else(refuse)?;
+ }
+ Ok(())
+}
+
+/// Where values are declared sealed (`declared`), `ops` write only sealed values as
+/// declared, judged at `at`, as their writer saw it (`sealed::check`): at such a place, a
+/// string put there that is one (or deleting what is there); nothing within one; nothing
+/// in an object nowhere to be found, which might be one.
+fn sealed_values(
+ at: &At<'_>,
+ declared: &[super::sealed::Declared],
+ ops: &[Op],
+) -> Result<(), String> {
+ 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 let Some(place) = super::sealed::place(declared, path) {
+ match &op.action {
+ Action::PutString(text) => {
+ super::sealed::check(at, place, &serde_json::Value::String(text.clone()))
+ .map_err(|why| format!("{}: {why}", shown(path)))?;
+ }
+ Action::Delete => {}
+ _ => return Err(format!("{} holds a sealed value", shown(path))),
+ }
+ } else if super::sealed::within(declared, path) {
+ return Err(format!("{} is within a sealed value", shown(path)));
+ }
+ }
Ok(())
}
@@ -606,6 +642,63 @@ mod tests {
assert!(refused(judged(&doc, vec![op("", Action::Nowhere)])));
}
+ /// Where values are declared sealed: only sealed ones, as declared, with or without
+ /// a strict policy.
+ #[test]
+ fn sealed_values_as_declared() {
+ use crate::access::sealed::testing::sealed_for;
+ use crate::access::sshsig::PublicKey;
+ let key = PublicKey([8; 32]);
+ let mut doc = swarm(true);
+ let mut alice = registry::entry("a", 2, &[(Vec::new(), &[Right::Write][..])]);
+ alice["keys"] = json!({ "k": key.line() });
+ write(&mut doc, "_continuum/users/alice", alice);
+ write(
+ &mut doc,
+ "_continuum/access/sealed",
+ json!([{"path": ["secrets", "*"], "readers": ["alice"]}]),
+ );
+ let judged = |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 sealed = sealed_for(&[key]);
+ assert!(judged(vec![op("secrets/db", Action::PutString(sealed))]).is_ok());
+ assert!(judged(vec![op("secrets/db", Action::Delete)]).is_ok());
+ assert!(refused(judged(vec![op(
+ "secrets/db",
+ Action::PutString("hunter2".to_owned())
+ )])));
+ let stray = sealed_for(&[key, PublicKey([9; 32])]);
+ assert!(refused(judged(vec![op(
+ "secrets/db",
+ Action::PutString(stray)
+ )])));
+ assert!(refused(judged(vec![op("secrets/db", Action::MakeMap)])));
+ assert!(refused(judged(vec![op(
+ "secrets/db/password",
+ Action::Other
+ )])));
+ assert!(judged(vec![op("notes/x", Action::PutString("plain".to_owned()))]).is_ok());
+ }
+
/// Roots: the first change of a swarm's first epoch, a later epoch's snapshot.
#[test]
fn roots() {
diff --git a/src/access/sealed.rs b/src/access/sealed.rs
new file mode 100644
index 0000000..7a8896f
--- /dev/null
+++ b/src/access/sealed.rs
@@ -0,0 +1,378 @@
+//! Values sealed for their readers: encrypted, with age, to the SSH keys of the users
+//! who may read them (`registry::User::keys`), so only they open them: every node syncs,
+//! stores and remakes a sealed value as the string it is, and none decrypts it. A sealed
+//! value is `cs1.<the age file, base64url, unpadded>`, as `age -R <their keys>` writes
+//! it (binary, not armored).
+//!
+//! Any value anywhere may be sealed, for anyone. Where the swarm declares it
+//! (`_continuum/access/sealed`, `declared`), every value must be: a list of
+//! `{"path": [...], "readers": ["alice", …]}`, `*` in a path for any one key. Without
+//! `readers`, a sealed value for anyone; with them, one sealed for exactly their keys, as
+//! the registry has them when it is written. A node checks that without decrypting: an
+//! age file names each `ssh-ed25519` recipient by a tag, the first four bytes of the
+//! SHA-256 of its key (`tag`). So a forgotten reader or a stray recipient is refused
+//! (`check`), at the API and, with the swarm's policy, by every node on receipt
+//! (`access::rules`). A writer who means to leak the value has it anyway: it guards
+//! against mistakes, not against the writer.
+
+use std::collections::BTreeSet;
+
+use base64::{
+ Engine,
+ engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD as B64URL},
+};
+use serde_json::Value as JsonValue;
+use sha2::{Digest, Sha256};
+
+use super::{envelope, registry, sshsig};
+use crate::document::{ReadDoc, meta, store};
+
+/// What every sealed value starts with.
+const PREFIX: &str = "cs1.";
+/// How an age file starts.
+const AGE_HEADER: &str = "age-encryption.org/v1";
+/// The recipient stanzas of SSH Ed25519 keys.
+const SSH_ED25519: &str = "ssh-ed25519";
+
+/// Where the swarm declares the sealed paths.
+pub(crate) fn declared_at() -> Vec<String> {
+ meta::path(&[meta::ACCESS, "sealed"])
+}
+
+/// A path whose values are sealed, and for whom, if it says.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct Declared {
+ pub(crate) path: Vec<String>,
+ pub(crate) readers: Option<Vec<String>>,
+}
+
+/// What `doc` declares sealed; an entry that doesn't read as one is left out.
+pub(crate) fn declared(doc: &impl ReadDoc) -> Vec<Declared> {
+ let Ok(Some(JsonValue::Array(entries))) = store::read_plain(doc, &declared_at()) else {
+ return Vec::new();
+ };
+ entries
+ .iter()
+ .filter_map(|entry| {
+ let strings = |value: &JsonValue| -> Option<Vec<String>> {
+ value
+ .as_array()?
+ .iter()
+ .map(|segment| segment.as_str().map(str::to_owned))
+ .collect()
+ };
+ let path = strings(entry.get("path")?)?;
+ let readers = match entry.get("readers") {
+ None => None,
+ Some(readers) => Some(strings(readers)?),
+ };
+ Some(Declared { path, readers })
+ })
+ .collect()
+}
+
+/// The declaration `path` is the place of, if any: of the same length, `*` for any key.
+pub(crate) fn place<'a>(declared: &'a [Declared], path: &[String]) -> Option<&'a Declared> {
+ declared
+ .iter()
+ .find(|declared| declared.path.len() == path.len() && agree(&declared.path, path))
+}
+
+/// Whether `path` is within a declared place (below it).
+pub(crate) fn within(declared: &[Declared], path: &[String]) -> bool {
+ declared
+ .iter()
+ .any(|declared| path.len() > declared.path.len() && agree(&declared.path, path))
+}
+
+/// Whether a declared place may be at or below `path`.
+fn reaches(declared: &[Declared], path: &[String]) -> bool {
+ declared.iter().any(|declared| agree(&declared.path, path))
+}
+
+fn agree(pattern: &[String], path: &[String]) -> bool {
+ pattern
+ .iter()
+ .zip(path)
+ .all(|(segment, key)| segment == "*" || segment == key)
+}
+
+/// How an age file names `key` as a recipient: the first four bytes of the SHA-256 of
+/// its SSH encoding, base64, unpadded.
+pub(crate) fn tag(key: &sshsig::PublicKey) -> String {
+ STANDARD_NO_PAD.encode(&Sha256::digest(key.blob())[..4])
+}
+
+/// The tags of the `ssh-ed25519` recipients `sealed` is for, if it is a sealed value;
+/// why not, if not, or if it is for recipients of another kind (but grease stanzas,
+/// which age adds to keep parsers honest).
+pub(crate) fn recipients(sealed: &str) -> Result<BTreeSet<String>, String> {
+ let file = sealed
+ .strip_prefix(PREFIX)
+ .ok_or("not a sealed value (cs1.<age file>)")?;
+ let bytes = B64URL
+ .decode(file)
+ .map_err(|_| "not a sealed value: it doesn't decode".to_owned())?;
+ let mut lines = bytes.split(|byte| *byte == b'\n');
+ if lines.next() != Some(AGE_HEADER.as_bytes()) {
+ return Err("not a sealed value: not an age file (binary, not armored)".to_owned());
+ }
+ let mut tags = BTreeSet::new();
+ for line in lines {
+ if line.starts_with(b"--- ") {
+ return Ok(tags);
+ }
+ let Some(stanza) = line.strip_prefix(b"-> ") else {
+ continue;
+ };
+ let stanza = std::str::from_utf8(stanza)
+ .map_err(|_| "not a sealed value: its header isn't text".to_owned())?;
+ let mut args = stanza.split(' ');
+ match args.next() {
+ Some(SSH_ED25519) => {
+ let tag = args
+ .next()
+ .ok_or("not a sealed value: a stanza without a tag")?;
+ tags.insert(tag.to_owned());
+ }
+ Some(kind) if kind.ends_with("-grease") => {}
+ Some(kind) => return Err(format!("sealed for a recipient of another kind ({kind})")),
+ None => return Err("not a sealed value: an empty stanza".to_owned()),
+ }
+ }
+ Err("not a sealed value: its header doesn't end".to_owned())
+}
+
+/// Checks `value`, written at the place of `declared`, against the registry `doc` holds
+/// (as its writer saw it): a sealed value, or, signed by its user (`access::envelope`),
+/// one holding a sealed value or `null` (deleted); with readers declared, sealed for
+/// exactly their keys.
+pub(crate) fn check(
+ doc: &impl ReadDoc,
+ declared: &Declared,
+ value: &JsonValue,
+) -> Result<(), String> {
+ let JsonValue::String(text) = value else {
+ return Err("a sealed value is a string (cs1.<age file>)".to_owned());
+ };
+ let sealed = if text.starts_with("cv1.") {
+ match envelope::read(text)?.value {
+ JsonValue::Null => return Ok(()),
+ JsonValue::String(sealed) => sealed,
+ _ => return Err("signed, but not sealed".to_owned()),
+ }
+ } else {
+ text.clone()
+ };
+ let found = recipients(&sealed)?;
+ let Some(readers) = &declared.readers else {
+ return Ok(());
+ };
+ let wanted: BTreeSet<String> = readers
+ .iter()
+ .filter_map(|reader| registry::user(doc, reader))
+ .flat_map(|user| user.keys.into_values())
+ .map(|key| tag(&key))
+ .collect();
+ if let Some(missing) = wanted.difference(&found).next() {
+ let owner = readers
+ .iter()
+ .find(|reader| {
+ registry::user(doc, reader)
+ .is_some_and(|user| user.keys.values().any(|key| tag(key) == *missing))
+ })
+ .cloned()
+ .unwrap_or_default();
+ return Err(format!("not sealed for {owner}'s key {missing}"));
+ }
+ if let Some(stray) = found.difference(&wanted).next() {
+ return Err(format!("sealed for a key of none of its readers ({stray})"));
+ }
+ Ok(())
+}
+
+/// Checks what is at `path` in `doc` after a write there: nothing within a declared
+/// place but its value, and every value at one (at `path` or below it) as `check` says.
+/// Why not, if not.
+pub(crate) fn kept(doc: &impl ReadDoc, path: &[String]) -> Result<(), String> {
+ let declared = declared(doc);
+ if declared.is_empty() || !reaches(&declared, path) {
+ return Ok(());
+ }
+ if within(&declared, path) {
+ return Err(format!("/{} is within a sealed value", path.join("/")));
+ }
+ fn walk(
+ doc: &impl ReadDoc,
+ declared: &[Declared],
+ value: &JsonValue,
+ at: &mut Vec<String>,
+ ) -> Result<(), String> {
+ if let Some(place) = place(declared, at) {
+ return check(doc, place, value).map_err(|why| format!("/{}: {why}", at.join("/")));
+ }
+ if let JsonValue::Object(entries) = value {
+ for (key, value) in entries {
+ at.push(key.clone());
+ if reaches(declared, at) {
+ walk(doc, declared, value, at)?;
+ }
+ at.pop();
+ }
+ }
+ Ok(())
+ }
+ match store::read_path(doc, path) {
+ Ok(Some(value)) => walk(doc, &declared, &value, &mut path.to_vec()),
+ Ok(None) => Ok(()),
+ Err(error) => Err(error.to_string()),
+ }
+}
+
+#[cfg(test)]
+pub(crate) mod testing {
+ use super::*;
+
+ /// A sealed value whose header is as age writes it for `keys`: what nodes read of
+ /// it (the body, which they don't, is made up).
+ pub(crate) fn sealed_for(keys: &[sshsig::PublicKey]) -> String {
+ let mut file = format!("{AGE_HEADER}\n");
+ for key in keys {
+ file.push_str(&format!(
+ "-> {SSH_ED25519} {} AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n",
+ tag(key)
+ ));
+ }
+ file.push_str("-> x-grease 1 2\n\n--- AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n");
+ let mut bytes = file.into_bytes();
+ bytes.extend_from_slice(&[0, 1, 2, 3]);
+ format!("{PREFIX}{}", B64URL.encode(bytes))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::testing::sealed_for;
+ use super::*;
+ use crate::document::Document;
+ use crate::document::testing::change;
+ use serde_json::json;
+
+ fn path(text: &str) -> Vec<String> {
+ text.split('/').map(str::to_owned).collect()
+ }
+
+ fn key(seed: u8) -> sshsig::PublicKey {
+ sshsig::PublicKey(
+ ed25519_dalek::SigningKey::from_bytes(&[seed; 32])
+ .verifying_key()
+ .to_bytes(),
+ )
+ }
+
+ /// Alice with two keys, bob with one; `secrets/*` sealed for both, `notes` for
+ /// anyone.
+ fn swarm() -> Document {
+ let mut doc = Document::new();
+ change(&mut doc, |doc| {
+ let mut alice = registry::entry("root", 1, &[]);
+ alice["keys"] = json!({ "laptop": key(1).line(), "phone": key(2).line() });
+ store::write_path(doc, &registry::user_path("alice"), &alice).unwrap();
+ let mut bob = registry::entry("root", 1, &[]);
+ bob["keys"] = json!({ "laptop": key(3).line() });
+ store::write_path(doc, &registry::user_path("bob"), &bob).unwrap();
+ store::write_path(
+ doc,
+ &declared_at(),
+ &json!([
+ {"path": ["secrets", "*"], "readers": ["alice", "bob"]},
+ {"path": ["notes"]},
+ {"path": "junk"},
+ ]),
+ )
+ .unwrap();
+ });
+ doc
+ }
+
+ /// The tag of a key, as age computes it, of OpenSSH's test key.
+ #[test]
+ fn tags_as_age_has_them() {
+ let key = sshsig::PublicKey::parse(
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIlizSIN3DQV78VPNjVvvwjgf+P5HqbPYCYu3BOMdjAE",
+ )
+ .unwrap();
+ let digest = Sha256::digest(key.blob());
+ assert_eq!(tag(&key), STANDARD_NO_PAD.encode(&digest[..4]));
+ assert_eq!(tag(&key).len(), 6);
+ }
+
+ #[test]
+ fn declarations() {
+ let doc = swarm();
+ let declared = declared(&doc);
+ assert_eq!(declared.len(), 2);
+ assert!(place(&declared, &path("secrets/db")).is_some());
+ assert!(place(&declared, &path("secrets")).is_none());
+ assert!(within(&declared, &path("secrets/db/x")));
+ assert!(place(&declared, &path("notes")).unwrap().readers.is_none());
+ }
+
+ /// Sealed for exactly its readers' keys, or for anyone where none are declared;
+ /// signed and sealed alike; anything else refused.
+ #[test]
+ fn what_a_sealed_value_must_be() {
+ let doc = swarm();
+ let declared = declared(&doc);
+ let secrets = place(&declared, &path("secrets/db")).unwrap();
+ let notes = place(&declared, &path("notes")).unwrap();
+ let all = sealed_for(&[key(1), key(2), key(3)]);
+ assert!(check(&doc, secrets, &json!(all)).is_ok());
+ // A key forgotten, a stray one.
+ assert!(check(&doc, secrets, &json!(sealed_for(&[key(1), key(3)]))).is_err());
+ assert!(
+ check(
+ &doc,
+ secrets,
+ &json!(sealed_for(&[key(1), key(2), key(3), key(4)]))
+ )
+ .is_err()
+ );
+ // Plain, not a string, not age.
+ assert!(check(&doc, secrets, &json!("hunter2")).is_err());
+ assert!(check(&doc, secrets, &json!({"password": "hunter2"})).is_err());
+ assert!(check(&doc, secrets, &json!("cs1.bm90IGFnZQ")).is_err());
+ // For anyone.
+ assert!(check(&doc, notes, &json!(sealed_for(&[key(9)]))).is_ok());
+ assert!(check(&doc, notes, &json!("plain")).is_err());
+ // Signed: holding a sealed value, or null.
+ let signed = |value: JsonValue| {
+ let payload =
+ json!({"path": ["notes"], "value": value, "user": "alice", "at": 1, "prev": ""});
+ format!("cv1.{}.c2ln", B64URL.encode(payload.to_string()))
+ };
+ assert!(check(&doc, notes, &json!(signed(json!(sealed_for(&[key(9)]))))).is_ok());
+ assert!(check(&doc, notes, &json!(signed(JsonValue::Null))).is_ok());
+ assert!(check(&doc, notes, &json!(signed(json!("plain")))).is_err());
+ }
+
+ /// After a write: every declared place at or below it as `check` says, nothing
+ /// within one.
+ #[test]
+ fn what_a_write_keeps() {
+ let mut doc = swarm();
+ let all = sealed_for(&[key(1), key(2), key(3)]);
+ change(&mut doc, |doc| {
+ store::write_path(doc, &path("secrets"), &json!({"db": all, "other": all})).unwrap();
+ store::write_path(doc, &path("elsewhere"), &json!("plain")).unwrap();
+ });
+ assert!(kept(&doc, &path("secrets")).is_ok());
+ assert!(kept(&doc, &path("elsewhere")).is_ok());
+ change(&mut doc, |doc| {
+ store::write_path(doc, &path("secrets/web"), &json!("plain")).unwrap();
+ });
+ assert!(kept(&doc, &path("secrets")).is_err());
+ assert!(kept(&doc, &path("secrets/web")).is_err());
+ assert!(kept(&doc, &path("secrets/db/x")).is_err());
+ }
+}
diff --git a/src/access/sshsig.rs b/src/access/sshsig.rs
index b40da0d..6091528 100644
--- a/src/access/sshsig.rs
+++ b/src/access/sshsig.rs
@@ -53,7 +53,7 @@ impl PublicKey {
Ok(PublicKey(key))
}
- fn blob(&self) -> Vec<u8> {
+ pub(crate) fn blob(&self) -> Vec<u8> {
let mut blob = Vec::new();
string(&mut blob, KEY_TYPE.as_bytes());
string(&mut blob, &self.0);
diff --git a/src/api/store.rs b/src/api/store.rs
index e61a09d..101957a 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -487,6 +487,7 @@ pub(crate) async fn put_store_entry(
let before = Before::of(&*doc, &writer);
let retyped = store::write_val_path(doc, &path, &body.value, writing)
.map_err(|e| store_status(e, "Failed to write store value"))?;
+ sealed_kept(&*doc, &path)?;
registry_kept(&*doc, &path, before, &writer)?;
// What was written, shown back to whoever wrote it.
let read = read_shown(
@@ -570,6 +571,7 @@ async fn put_signed(
let writing = store::Writing::new(store::Sticky::No, TimestampFormat::default(), true);
store::write_val_path(doc, &path, &envelope, writing)
.map_err(|e| store_status(e, "Failed to write store value"))?;
+ sealed_kept(&*doc, &path)?;
registry_kept(&*doc, &path, before, &writer)?;
read_shown(
&*doc,
@@ -588,6 +590,12 @@ async fn put_signed(
}
}
+/// `409` for a write at `path` that leaves a value not sealed as the swarm declares
+/// (`access::sealed::kept`).
+fn sealed_kept(doc: &impl crate::document::ReadDoc, path: &[String]) -> Result<(), ApiError> {
+ crate::access::sealed::kept(doc, path).map_err(|why| ApiError(StatusCode::CONFLICT, Some(why)))
+}
+
/// `409` for a write at `path` that its user didn't sign, under a strict policy
/// (`access::policy`), where it would reach a value its user signs, at, within or above
/// it (`access::envelope`): there, a signed `PUT` only.
@@ -946,6 +954,7 @@ pub(crate) async fn post_store_entry(
let before = Before::of(&*doc, &writer);
let value = store::increment_path(doc, &path, by)
.map_err(|e| store_status(e, "Failed to increment a counter"))?;
+ sealed_kept(&*doc, &path)?;
registry_kept(&*doc, &path, before, &writer)?;
Ok::<_, ApiError>(value)
})
@@ -983,6 +992,7 @@ pub(crate) async fn post_store_entry(
let before = Before::of(&*doc, &writer);
let retyped = store::cast_path(doc, &path, to, timestamps)
.map_err(|e| store_status(e, "Failed to cast a store value"))?;
+ sealed_kept(&*doc, &path)?;
registry_kept(&*doc, &path, before, &writer)?;
let read = read_shown(
&*doc,
@@ -1007,6 +1017,7 @@ pub(crate) async fn post_store_entry(
let before = Before::of(&*doc, &writer);
store::insert_val_path(doc, &path, &value.value)
.map_err(|e| store_status(e, "Failed to insert into a list"))?;
+ sealed_kept(&*doc, &path)?;
registry_kept(&*doc, &path, before, &writer)
})
.await?;