//! 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 //! string, `cv1..` (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) //! of the envelope it replaces, empty if none. The signature is the user's, with one //! of its keys (`registry::User::keys`), over the payload's bytes, as //! `ssh-keygen -Y sign -n continuum-value` makes it (`access::sshsig`), its SSHSIG blob. //! //! It travels with the value, through replays and snapshots alike, and anyone can check //! it (`check`): a node before it writes or applies one, a client reading it. `prev` //! keeps a member from putting an older signed value back: an envelope only replaces //! the one it names. A node never writes there but as its user signed it. use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as B64URL}; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; use super::{Right, registry, sshsig}; use crate::document::{ReadDoc, meta, store}; /// What a signature over a payload is for (`sshsig`). pub(crate) const NAMESPACE: &str = "continuum-value"; /// What every envelope starts with. const PREFIX: &str = "cv1"; /// Where the swarm lists the user-signed paths: a list of paths, each a list of /// segments. pub(crate) fn listed_at() -> Vec { meta::path(&[meta::ACCESS, "userSigned"]) } /// 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> { let Ok(Some(JsonValue::Array(entries))) = store::read_path(doc, &listed_at()) else { return Vec::new(); }; entries .iter() .filter_map(|entry| { entry .as_array()? .iter() .map(|segment| segment.as_str().map(str::to_owned)) .collect() }) .collect() } /// The user-signed path `path` is at or within, if any (`listed`). pub(crate) fn signed_path<'a>(listed: &'a [Vec], path: &[String]) -> Option<&'a [String]> { listed .iter() .find(|signed| path.starts_with(signed)) .map(Vec::as_slice) } /// A signed value's payload. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct Payload { pub(crate) path: Vec, pub(crate) value: JsonValue, pub(crate) user: String, pub(crate) at: u64, pub(crate) prev: String, } /// An envelope checked (`check`): what it says, and the key that signed it. #[derive(Debug, Clone, PartialEq)] pub(crate) struct Signed { pub(crate) payload: Payload, pub(crate) key: sshsig::PublicKey, } /// What `prev` names for `envelope`, the one replaced (none: empty). pub(crate) fn digest(envelope: Option<&str>) -> String { envelope.map_or_else(String::new, |envelope| { Sha256::digest(envelope.as_bytes()) .iter() .map(|byte| format!("{byte:02x}")) .collect() }) } /// The envelope of `payload` (its exact bytes) signed by `signature` (armored, as /// `ssh-keygen -Y sign` writes it). pub(crate) fn seal(payload: &[u8], armored: &str) -> Result { let blob = sshsig::dearmor(armored)?; Ok(format!( "{PREFIX}.{}.{}", B64URL.encode(payload), B64URL.encode(blob) )) } /// What `envelope` holds, unchecked: its payload's bytes, the payload, the signature's /// blob. fn open(envelope: &str) -> Result<(Vec, Payload, Vec), String> { let mut parts = envelope.split('.'); let (Some(PREFIX), Some(payload), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { return Err("not a signed value (cv1..)".to_owned()); }; let bytes = B64URL .decode(payload) .map_err(|error| format!("its payload doesn't decode: {error}"))?; let parsed: Payload = serde_json::from_slice(&bytes) .map_err(|error| format!("its payload isn't one: {error}"))?; let signature = B64URL .decode(signature) .map_err(|error| format!("its signature doesn't decode: {error}"))?; Ok((bytes, parsed, signature)) } /// What `envelope` says, unchecked: for showing a value (`check` before taking it). pub(crate) fn read(envelope: &str) -> Result { open(envelope).map(|(_, payload, _)| payload) } /// Checks `envelope` as the value at `path`, replacing `previous` (the envelope there /// before, if any), against the user registry `doc` holds (as the writer saw it): for /// that path, naming what it replaces, signed by one of its user's keys, a user who may /// write there. pub(crate) fn check( doc: &impl ReadDoc, envelope: &str, path: &[String], previous: Option<&str>, ) -> Result { let (bytes, payload, signature) = open(envelope)?; if payload.path != path { return Err(format!( "signed for /{}, not /{}", payload.path.join("/"), path.join("/") )); } if payload.prev != digest(previous) { return Err("it doesn't replace the value there (prev)".to_owned()); } let key = sshsig::verify_blob(&signature, NAMESPACE, &bytes)?; let user = registry::user(doc, &payload.user).ok_or_else(|| format!("no user {}", payload.user))?; if !user.keys.values().any(|known| *known == key) { return Err(format!("{} has no key {}", payload.user, key.fingerprint())); } if !user.caps().allows(Right::Write, path) { return Err(format!( "{} may not write /{}", payload.user, path.join("/") )); } Ok(Signed { payload, key }) } #[cfg(test)] mod tests { use super::*; use crate::document::Document; use crate::document::testing::change; use ed25519_dalek::SigningKey; use serde_json::json; fn path(text: &str) -> Vec { text.split('/').map(str::to_owned).collect() } /// A registry with alice, who may write `config` and holds `key`'s public key, and /// bob, who holds it too but may write nothing. fn registry_with(key: &SigningKey) -> Document { let line = sshsig::PublicKey(key.verifying_key().to_bytes()).line(); let mut doc = Document::new(); change(&mut doc, |doc| { let mut alice = registry::entry("root", 1, &[(path("config"), &[Right::Write][..])]); alice["keys"] = json!({ "laptop": line }); store::write_path(doc, ®istry::user_path("alice"), &alice).unwrap(); let mut bob = registry::entry("root", 1, &[]); bob["keys"] = json!({ "laptop": line }); store::write_path(doc, ®istry::user_path("bob"), &bob).unwrap(); }); doc } fn signed(key: &SigningKey, payload: &Payload) -> String { let bytes = serde_json::to_vec(payload).unwrap(); seal(&bytes, &sshsig::sign(key, NAMESPACE, &bytes)).unwrap() } fn payload(user: &str, at: &str, value: JsonValue, prev: &str) -> Payload { Payload { path: path(at), value, user: user.to_owned(), at: 1, prev: prev.to_owned(), } } /// Signed for its path, replacing what is there, by one of its user's keys, a user /// who may write there: taken; anything else refused. #[test] fn what_an_envelope_must_be() { let key = SigningKey::from_bytes(&[5; 32]); let other = SigningKey::from_bytes(&[6; 32]); let doc = registry_with(&key); let first = signed( &key, &payload("alice", "config", json!({"mode": "fast"}), ""), ); let checked = check(&doc, &first, &path("config"), None).unwrap(); assert_eq!(checked.payload.value, json!({"mode": "fast"})); assert_eq!(read(&first).unwrap().user, "alice"); // Another path, another key, a user who may not, an unknown one. assert!(check(&doc, &first, &path("other"), None).is_err()); let foreign = signed(&other, &payload("alice", "config", json!(1), "")); assert!(check(&doc, &foreign, &path("config"), None).is_err()); let bobs = signed(&key, &payload("bob", "config", json!(1), "")); assert!(check(&doc, &bobs, &path("config"), None).is_err()); let nobodys = signed(&key, &payload("carol", "config", json!(1), "")); assert!(check(&doc, &nobodys, &path("config"), None).is_err()); // Replacing: only what it names. let second = signed( &key, &payload("alice", "config", json!(null), &digest(Some(&first))), ); assert!(check(&doc, &second, &path("config"), Some(&first)).is_ok()); assert!(check(&doc, &second, &path("config"), None).is_err()); assert!(check(&doc, &first, &path("config"), Some(&second)).is_err()); // Tampered with. let tampered = first.replacen("cv1.", "cv1.e30", 1); assert!(check(&doc, &tampered, &path("config"), None).is_err()); assert!(check(&doc, "plain", &path("config"), None).is_err()); } #[test] fn listed_paths() { let mut doc = Document::new(); 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!( signed_path(&listed, &path("config")), Some(&path("config")[..]) ); assert_eq!(signed_path(&listed, &path("a/b/c")), Some(&path("a/b")[..])); assert_eq!(signed_path(&listed, &path("a")), None); } }