//! 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()); } }