From 42698f42eec18d24a7378fcbe1ede73add902b71 Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:21 +0100 Subject: access: values sealed for their readers cs1.: a value encrypted with age to its readers' SSH keys, which no node decrypts. _continuum/access/sealed declares paths whose values must be sealed, for named readers or anyone; nodes check the recipients by age's key tags, at the API and on receipt. Auth plan M8, reworked: sealed values instead of containers, readers as users only (nodes hold no keys). --- src/access/mod.rs | 1 + src/access/rules.rs | 93 +++++++++++++ src/access/sealed.rs | 378 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/access/sshsig.rs | 2 +- src/api/store.rs | 11 ++ 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 src/access/sealed.rs (limited to 'src') 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| { + let deps = doc.get_heads(); + let touched: BTreeSet = 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.`, as `age -R ` 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 { + 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, + pub(crate) readers: Option>, +} + +/// What `doc` declares sealed; an entry that doesn't read as one is left out. +pub(crate) fn declared(doc: &impl ReadDoc) -> Vec { + 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> { + 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, String> { + let file = sealed + .strip_prefix(PREFIX) + .ok_or("not a sealed value (cs1.)")?; + 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.)".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 = 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, + ) -> 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 { + 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, ®istry::user_path("alice"), &alice).unwrap(); + let mut bob = registry::entry("root", 1, &[]); + bob["keys"] = json!({ "laptop": key(3).line() }); + store::write_path(doc, ®istry::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 { + pub(crate) fn blob(&self) -> Vec { 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?; -- cgit v1.3.1