//! Turning a swarm strict through a remake (`remake::kind::Strict`): until a swarm is //! strict, the user a change names is its node's word, so whatever its registry, policy, //! bans and user-signed values hold then may have been planted by any member, plain or //! signed by a key the planter put there itself. So a strict remake keeps of them only //! what an operator looked over and signed: a manifest, naming each value kept by its //! digest, with the strict policy, signed by a key every node is given out of the //! document (`--strict-keys`). Every node checks it before it votes (`verify`), and //! builds the next epoch with those values only; what was written after the base reaches //! it only through replays, judged by the strict policy. //! //! A manifest is one line of JSON (`Manifest`), then its signature, armored, as //! `ssh-keygen -Y sign -n continuum-strict` writes it. use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; use super::{envelope, sshsig}; use crate::document::{ReadDoc, store}; /// What a manifest's signature is for (`sshsig`). pub(crate) const NAMESPACE: &str = "continuum-strict"; /// What an operator signs to turn a swarm strict. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Manifest { /// The swarm it is for. pub(crate) swarm: String, /// The epoch it is for, and the heads it was made at: the base a remake taking it /// must have, so it serves once, for the values it names as they were then. pub(crate) epoch: crate::document::replica::EpochRecord, pub(crate) base: Vec, /// The policy the next epoch has (`"values": "signed"`). pub(crate) policy: JsonValue, /// The values kept at the places values are signed (`envelope::signed_paths`), each /// by its digest (`digest`); any other there goes. pub(crate) keep: Vec, /// Who signed it, and when (Unix ms). pub(crate) user: String, pub(crate) at: u64, } /// A value kept, by its digest. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Kept { pub(crate) path: Vec, pub(crate) digest: String, } impl Manifest { /// Whether it is for a remake of `epoch` at `base` (hex heads): why not, if not. pub(crate) fn for_base( &self, epoch: &crate::document::replica::EpochRecord, base: Option<&[String]>, ) -> Result<(), String> { if &self.epoch != epoch { return Err(format!( "made for epoch {}:{}, not this one", self.epoch.generation, self.epoch.id )); } let sorted = |heads: &[String]| { let mut heads = heads.to_vec(); heads.sort(); heads }; match base { Some(base) if sorted(base) == sorted(&self.base) => Ok(()), _ => Err("made at other heads than the remake's base".to_owned()), } } /// Whether it keeps `value` at `path`. pub(crate) fn keeps(&self, path: &[String], value: &JsonValue) -> bool { let digest = digest(value); self.keep .iter() .any(|kept| kept.path == path && kept.digest == digest) } } /// A value's digest: SHA-256 (hex) of its JSON, keys sorted, as the store reads it (an /// envelope as the string it is). pub(crate) fn digest(value: &JsonValue) -> String { let bytes = serde_json::to_vec(&crate::remake::snapshot::canonical(value.clone())) .expect("JSON serializes"); Sha256::digest(&bytes) .iter() .map(|byte| format!("{byte:02x}")) .collect() } /// The places values are signed in `doc` (`envelope::signed_paths`, `*` as `doc` has /// them) that hold one, with it, as the store reads it. pub(crate) fn held(doc: &impl ReadDoc) -> Vec<(Vec, JsonValue)> { fn expand( doc: &impl ReadDoc, pattern: &[String], at: Vec, out: &mut Vec<(Vec, JsonValue)>, ) { match pattern.split_first() { None => { if let Ok(Some(value)) = store::read_path(doc, &at) { out.push((at, value)); } } Some((segment, rest)) if segment == "*" => { for key in store::keys_at(doc, &at).ok().flatten().unwrap_or_default() { let mut next = at.clone(); next.push(key); expand(doc, rest, next, out); } } Some((segment, rest)) => { let mut next = at; next.push(segment.clone()); expand(doc, rest, next, out); } } } let mut out = Vec::new(); for pattern in envelope::signed_paths(doc) { expand(doc, &pattern, Vec::new(), &mut out); } out.sort_by(|a, b| a.0.cmp(&b.0)); out.dedup_by(|a, b| a.0 == b.0); out } /// A manifest for `doc` (at heads `base`, of `epoch` of `swarm`): keeping every value /// held at a signed place now, the policy `policy`, for `user` to look over and sign at /// `at`. Its exact line. pub(crate) fn manifest_of( doc: &impl ReadDoc, swarm: &str, epoch: crate::document::replica::EpochRecord, base: &[crate::document::ChangeHash], policy: JsonValue, user: &str, at: u64, ) -> String { let mut base: Vec = base.iter().map(ToString::to_string).collect(); base.sort(); let manifest = Manifest { swarm: swarm.to_owned(), epoch, base, policy, keep: held(doc) .into_iter() .map(|(path, value)| Kept { digest: digest(&value), path, }) .collect(), user: user.to_owned(), at, }; serde_json::to_string(&manifest).expect("a manifest serializes") } /// What `text` (a manifest's line, then its signature) says, unchecked: for building /// the epoch it names, which every node does alike, whatever keys it was given. pub(crate) fn read(text: &str) -> Result { let (line, _) = text .split_once('\n') .ok_or("a manifest is its line, then its signature")?; let manifest: Manifest = serde_json::from_str(line).map_err(|error| format!("not a manifest: {error}"))?; if manifest.policy.get("values").and_then(JsonValue::as_str) != Some("signed") { return Err("its policy isn't strict (\"values\": \"signed\")".to_owned()); } // A strict swarm without a registry would let every member do anything. let users = crate::access::registry::users_dir(); if !manifest .keep .iter() .any(|kept| kept.path.len() == users.len() + 1 && kept.path.starts_with(&users)) { return Err("it keeps no user".to_owned()); } Ok(manifest) } /// `text` checked: a manifest (`read`) for `swarm`, signed by one of `keys`. pub(crate) fn verify( text: &str, keys: &[sshsig::PublicKey], swarm: &str, ) -> Result { let manifest = read(text)?; let (line, armored) = text.split_once('\n').expect("read checked it"); if manifest.swarm != swarm { return Err(format!("for swarm {}, not this one", manifest.swarm)); } let key = sshsig::verify(armored, NAMESPACE, line.as_bytes())?; if !keys.contains(&key) { return Err(format!( "signed by {}, none of this node's --strict-keys", key.fingerprint() )); } Ok(manifest) } #[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 manifest keeps the values held at signed places, by digest; it checks only for /// its swarm, signed by a key given. #[test] fn a_manifest_signed_and_checked() { let mut doc = Document::new(); change(&mut doc, |doc| { store::write_path(doc, &path("_continuum/users/alice"), &json!({"caps": {}})).unwrap(); store::write_path( doc, &path("_continuum/access/userSigned"), &json!([["config"]]), ) .unwrap(); store::write_path(doc, &path("config"), &json!({"mode": "fast"})).unwrap(); store::write_path(doc, &path("notes"), &json!(1)).unwrap(); }); let policy = json!({"version": 1, "values": "signed"}); let epoch = crate::document::replica::EpochRecord::from(crate::document::replica::Epoch::GENESIS); let heads = doc.get_heads(); let line = manifest_of(&doc, "s", epoch.clone(), &heads, policy, "alice", 1); let key = SigningKey::from_bytes(&[3; 32]); let public = sshsig::PublicKey(key.verifying_key().to_bytes()); let text = format!("{line}\n{}", sshsig::sign(&key, NAMESPACE, line.as_bytes())); let manifest = verify(&text, std::slice::from_ref(&public), "s").unwrap(); let kept: Vec> = manifest.keep.iter().map(|kept| kept.path.clone()).collect(); assert_eq!( kept, vec![ path("_continuum/access/userSigned"), path("_continuum/users/alice"), path("config") ] ); assert!(manifest.keeps(&path("config"), &json!({"mode": "fast"}))); assert!(!manifest.keeps(&path("config"), &json!({"mode": "slow"}))); // Another swarm, another key, another namespace, tampered with: no. assert!(verify(&text, std::slice::from_ref(&public), "t").is_err()); let other = sshsig::PublicKey(SigningKey::from_bytes(&[4; 32]).verifying_key().to_bytes()); assert!(verify(&text, &[other], "s").is_err()); let elsewhere = format!( "{line}\n{}", sshsig::sign(&key, "continuum-value", line.as_bytes()) ); assert!(verify(&elsewhere, std::slice::from_ref(&public), "s").is_err()); let tampered = text.replacen("alice", "mallory", 1); assert!(verify(&tampered, std::slice::from_ref(&public), "s").is_err()); // For its epoch and base only. let base: Vec = heads.iter().map(ToString::to_string).collect(); assert!(manifest.for_base(&epoch, Some(&base)).is_ok()); assert!(manifest.for_base(&epoch, None).is_err()); assert!(manifest.for_base(&epoch, Some(&[])).is_err()); let later = crate::document::replica::EpochRecord { generation: 1, id: "ab".to_owned(), }; assert!(manifest.for_base(&later, Some(&base)).is_err()); } }