diff options
Diffstat (limited to 'src/access/strict.rs')
| -rw-r--r-- | src/access/strict.rs | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/src/access/strict.rs b/src/access/strict.rs new file mode 100644 index 0000000..f5051a8 --- /dev/null +++ b/src/access/strict.rs @@ -0,0 +1,224 @@ +//! 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 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<Kept>, + /// 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<String>, + pub(crate) digest: String, +} + +impl Manifest { + /// 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<String>, JsonValue)> { + fn expand( + doc: &impl ReadDoc, + pattern: &[String], + at: Vec<String>, + out: &mut Vec<(Vec<String>, 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` 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, + policy: JsonValue, + user: &str, + at: u64, +) -> String { + let manifest = Manifest { + swarm: swarm.to_owned(), + 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<Manifest, String> { + 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()); + } + 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<Manifest, String> { + 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<String> { + 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 line = manifest_of(&doc, "s", 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<Vec<String>> = 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()); + } +} |
