aboutsummaryrefslogtreecommitdiffstats
path: root/src/access
diff options
context:
space:
mode:
Diffstat (limited to 'src/access')
-rw-r--r--src/access/mod.rs1
-rw-r--r--src/access/strict.rs224
2 files changed, 225 insertions, 0 deletions
diff --git a/src/access/mod.rs b/src/access/mod.rs
index 2a0077e..feddff0 100644
--- a/src/access/mod.rs
+++ b/src/access/mod.rs
@@ -22,6 +22,7 @@ pub(crate) mod rules;
pub(crate) mod screen;
pub(crate) mod sealed;
pub(crate) mod sshsig;
+pub(crate) mod strict;
pub(crate) mod token;
/// What a capability lets its holder do, at its path and below.
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());
+ }
+}