diff options
Diffstat (limited to 'src/access/envelope.rs')
| -rw-r--r-- | src/access/envelope.rs | 254 |
1 files changed, 254 insertions, 0 deletions
diff --git a/src/access/envelope.rs b/src/access/envelope.rs new file mode 100644 index 0000000..a875dca --- /dev/null +++ b/src/access/envelope.rs @@ -0,0 +1,254 @@ +//! 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.<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) +//! 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<String> { + 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<Vec<String>> { + 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<String>], 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<String>, + 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<String, String> { + 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<u8>, Payload, Vec<u8>), 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.<payload>.<signature>)".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<Payload, String> { + 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<Signed, String> { + 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<String> { + 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); + } +} |
