diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/access/envelope.rs | 45 | ||||
| -rw-r--r-- | src/access/mod.rs | 5 | ||||
| -rw-r--r-- | src/access/policy.rs | 1 | ||||
| -rw-r--r-- | src/api/store.rs | 273 | ||||
| -rw-r--r-- | src/api/users.rs | 12 |
5 files changed, 317 insertions, 19 deletions
diff --git a/src/access/envelope.rs b/src/access/envelope.rs index 7244a3b..1804b98 100644 --- a/src/access/envelope.rs +++ b/src/access/envelope.rs @@ -72,15 +72,39 @@ pub(crate) fn signed_paths(doc: &impl ReadDoc) -> Vec<Vec<String>> { /// its `*` segments as `path` has them. pub(crate) fn signed_path(signed: &[Vec<String>], path: &[String]) -> Option<Vec<String>> { signed.iter().find_map(|pattern| { - (path.len() >= pattern.len() - && pattern - .iter() - .zip(path) - .all(|(segment, key)| segment == "*" || segment == key)) - .then(|| path[..pattern.len()].to_vec()) + (path.len() >= pattern.len() && agree(pattern, path)) + .then(|| path[..pattern.len()].to_vec()) }) } +/// Whether `path` is where a user-signed value goes, of `signed` (`signed_paths`). +pub(crate) fn is_signed(signed: &[Vec<String>], path: &[String]) -> bool { + signed + .iter() + .any(|pattern| pattern.len() == path.len() && agree(pattern, path)) +} + +/// Whether a user-signed value of `signed` may be below `path` (`signed_paths`). +pub(crate) fn above_signed(signed: &[Vec<String>], path: &[String]) -> bool { + signed + .iter() + .any(|pattern| pattern.len() > path.len() && agree(pattern, path)) +} + +/// Whether a write at `path` reaches a user-signed value of `signed` (`signed_paths`): +/// at one, within one, or above one. +pub(crate) fn reaches_signed(signed: &[Vec<String>], path: &[String]) -> bool { + signed.iter().any(|pattern| agree(pattern, path)) +} + +/// Whether `pattern` and `path` agree as far as both go (`*`: any key). +fn agree(pattern: &[String], path: &[String]) -> bool { + pattern + .iter() + .zip(path) + .all(|(segment, key)| segment == "*" || segment == key) +} + /// A signed value's payload. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct Payload { @@ -280,5 +304,14 @@ mod tests { signed_path(&signed, &path("_continuum/access/policy")), Some(path("_continuum/access/policy")) ); + assert!(is_signed(&signed, &path("_continuum/users/alice"))); + assert!(!is_signed(&signed, &path("_continuum/users/alice/caps"))); + assert!(above_signed(&signed, &path("_continuum/users"))); + assert!(above_signed(&signed, &path("a"))); + assert!(!above_signed(&signed, &path("a/b"))); + assert!(reaches_signed(&signed, &path("a"))); + assert!(reaches_signed(&signed, &path("a/b/c"))); + assert!(!reaches_signed(&signed, &path("a/c"))); + assert!(!reaches_signed(&signed, &path("_continuum/members/x"))); } } diff --git a/src/access/mod.rs b/src/access/mod.rs index b58355e..bdb5124 100644 --- a/src/access/mod.rs +++ b/src/access/mod.rs @@ -14,16 +14,11 @@ use serde::{Deserialize, Serialize}; use crate::document::meta; pub(crate) mod bans; -// Values signed by their users: what the store and the rules use; until then, the tests. -#[cfg_attr(not(test), allow(dead_code))] pub(crate) mod envelope; pub(crate) mod policy; pub(crate) mod registry; pub(crate) mod rules; pub(crate) mod screen; -// Users' keys and signatures: what signed values and logins by key use; until then, -// the tests. -#[cfg_attr(not(test), allow(dead_code))] pub(crate) mod sshsig; pub(crate) mod token; diff --git a/src/access/policy.rs b/src/access/policy.rs index 8efedff..85b562a 100644 --- a/src/access/policy.rs +++ b/src/access/policy.rs @@ -51,7 +51,6 @@ impl Policy { /// Whether values at the user-signed paths need their users' signatures /// (`values: "signed"`, `access::envelope::signed_paths`). - #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn strict(&self) -> bool { matches!(self, Policy::Enforced { strict: true, .. }) } diff --git a/src/api/store.rs b/src/api/store.rs index 7307d92..b5baa23 100644 --- a/src/api/store.rs +++ b/src/api/store.rs @@ -22,6 +22,7 @@ use tracing::{debug, error, instrument, warn}; use super::access::{Access, Before, Visible, prune, prune_shallow, registry_kept}; use super::auth::AuthenticatedUser; use super::{ApiError, store_status}; +use crate::access::envelope; use crate::document::meta; use crate::document::store::{self, StoreError, paths_related}; use crate::document::typed::{self, JsonForm, Kind, TimestampFormat, Val}; @@ -44,6 +45,10 @@ pub(crate) struct ReadQuery { /// Read as the document was at these heads (comma-separated hex, as /// `x-continuum-heads` gives them), not as it is now (`document::at`). at: Option<String>, + /// Values their users signed as they are stored, envelopes (`access::envelope`), not + /// opened (`opened`). + #[serde(default)] + raw: bool, } impl ReadQuery { @@ -163,6 +168,7 @@ async fn read( } let accept = headers.get(ACCEPT).cloned(); let want_conflicts = query.conflicts; + let raw = query.raw; let at = query.at()?; let (response, source, heads) = state @@ -175,6 +181,7 @@ async fn read( &path, accept.as_ref(), want_conflicts, + raw, &access )) }, @@ -188,6 +195,7 @@ async fn read( shown: Shown::Json(JsonForm::default()), json: Some(JsonValue::Object(Default::default())), conflicts: want_conflicts.then(Vec::new), + signer: None, } .respond(), Ok(None) => Err(StatusCode::NOT_FOUND.into()), @@ -206,27 +214,79 @@ struct Read { json: Option<JsonValue>, /// With `?conflicts=true`: those on the way (then always JSON). conflicts: Option<Vec<store::ConflictReport>>, + /// The user who signed it, if it is a value its user signed, opened (`opened`). + signer: Option<String>, } impl Read { fn respond(self) -> Result<Response, ApiError> { - if let Some(conflicts) = self.conflicts { + let mut response = if let Some(conflicts) = self.conflicts { let value = self.json.unwrap_or(JsonValue::Null); let body = serde_json::json!({ "value": value, "conflicts": conflicts }); - return show(&self.held, Shown::Json(JsonForm::default()), Some(&body)); + show(&self.held, Shown::Json(JsonForm::default()), Some(&body))? + } else { + show(&self.held, self.shown, self.json.as_ref())? + }; + if let Some(signer) = self.signer + && let Ok(value) = HeaderValue::from_str(&signer) + { + response.headers_mut().insert(SIGNED_BY_HEADER, value); } - show(&self.held, self.shown, self.json.as_ref()) + Ok(response) } } +/// Header naming the user who signed a value read (`access::envelope`). +const SIGNED_BY_HEADER: &str = "x-continuum-signed-by"; + +/// What a value read is, its signed values opened (`opened`). +enum Opened { + Plain, + /// A value its user signed, this user. + By(String), + /// A signed `null`: nothing. + Deleted, +} + +/// The values in `json` (at `at`) their users signed (`access::envelope`, at `signed`) +/// opened: each envelope as the value it holds, unchecked (it was checked before it went +/// in), a signed `null` as nothing. What `json` itself is. +fn opened(json: &mut JsonValue, at: &mut Vec<String>, signed: &[Vec<String>]) -> Opened { + if let JsonValue::String(text) = json + && envelope::is_signed(signed, at) + { + let Ok(payload) = envelope::read(text) else { + return Opened::Plain; + }; + if payload.value.is_null() { + return Opened::Deleted; + } + *json = payload.value; + return Opened::By(payload.user); + } + if let JsonValue::Object(entries) = json + && envelope::above_signed(signed, at) + { + entries.retain(|key, value| { + at.push(key.clone()); + let kept = !matches!(opened(value, at, signed), Opened::Deleted); + at.pop(); + kept + }); + } + Opened::Plain +} + /// What `path` holds, read to show as `accept` asks; `406` if it can't be. Of a place /// `access` lets its user read only partly, what it may (`access::prune`), as JSON; -/// `403` for a native form, or if nothing of it is. +/// `403` for a native form, or if nothing of it is. Values their users signed opened +/// (`opened`), unless `raw`, or shown as typed JSON or natively. fn read_shown( doc: &impl crate::document::ReadDoc, path: &[String], accept: Option<&HeaderValue>, conflicts: bool, + raw: bool, access: &Access, ) -> Result<Option<Read>, ApiError> { let failed = |e| store_status(e, "Failed to read store path"); @@ -252,6 +312,23 @@ fn read_shown( None, ), }; + let mut json = json; + let mut signer = None; + let openable = match shown { + Shown::Json(form) => !form.typed, + Shown::Text => true, + Shown::Native(_) => false, + }; + if !raw + && openable + && let Some(value) = json.as_mut() + { + match opened(value, &mut path.to_vec(), &envelope::signed_paths(doc)) { + Opened::Plain => {} + Opened::By(user) => signer = Some(user), + Opened::Deleted => return Ok(None), + } + } let (json, conflicts) = match (access.visible(path), shown) { (Visible::All, _) => (json, conflicts), (_, Shown::Native(_)) => return Err(StatusCode::FORBIDDEN.into()), @@ -276,6 +353,7 @@ fn read_shown( shown, json, conflicts, + signer, })) } @@ -394,6 +472,9 @@ pub(crate) async fn put_store_entry( if let Some(redirect) = to_positive(&state, &path, false, None, raw_query.as_deref()).await? { return Ok(redirect); } + if media_type(&headers) == SIGNED { + return put_signed(&state, &user, path, sign, &headers, &body).await; + } let body = body_value(&headers, &body)?; let accept = headers.get(ACCEPT).cloned(); let writing = store::Writing::new(body.sticky, body.timestamps, query.retype); @@ -402,12 +483,20 @@ pub(crate) async fn put_store_entry( let writer = user.0.clone(); let ((retyped, read), heads) = state .mutate_doc_for(Some(user.0.clone()), sign.clone(), move |doc| { + unsigned_kept_out(&*doc, &path)?; let before = Before::of(&*doc, &writer); let retyped = store::write_val_path(doc, &path, &body.value, writing) .map_err(|e| store_status(e, "Failed to write store value"))?; registry_kept(&*doc, &path, before, &writer)?; // What was written, shown back to whoever wrote it. - let read = read_shown(&*doc, &path, accept.as_ref(), false, &Access::Everything)?; + let read = read_shown( + &*doc, + &path, + accept.as_ref(), + false, + false, + &Access::Everything, + )?; Ok::<_, ApiError>((retyped, read)) }) .await?; @@ -416,6 +505,103 @@ pub(crate) async fn put_store_entry( Ok(with_retyped(with_heads(read.respond()?, &heads), &retyped)) } +/// A signed value's media type (`access::envelope`): its payload's JSON, the exact bytes +/// signed, on the first line; then its user's signature of them, armored, as +/// `ssh-keygen -Y sign -n continuum-value` writes it. +const SIGNED: &str = "application/continuum-signed"; + +/// `PUT` of a value its user signed (`SIGNED`), at a path for one +/// (`envelope::signed_paths`; `409` elsewhere): taken if the authenticated user signed it, +/// for this path, replacing what is there (`409` if it names another: read again and +/// sign anew), with one of its keys, and may write there (`envelope::check`; `403` if +/// not). Written as its envelope, and answered with the value, as `PUT` is. +async fn put_signed( + state: &AppState, + user: &AuthenticatedUser, + path: Vec<String>, + sign: Sign, + headers: &HeaderMap, + body: &Bytes, +) -> Result<Response, ApiError> { + let bad = |error: String| ApiError(StatusCode::BAD_REQUEST, Some(error)); + let text = std::str::from_utf8(body).map_err(|_| bad("not UTF-8".to_owned()))?; + let (payload, armored) = text + .split_once('\n') + .ok_or_else(|| bad("its payload's line, then its signature".to_owned()))?; + let sealed = envelope::seal(payload.as_bytes(), armored).map_err(bad)?; + let signer = envelope::read(&sealed).map_err(bad)?.user; + if signer != user.0 { + return Err(ApiError( + StatusCode::FORBIDDEN, + Some(format!("signed by {signer}, not by {}", user.0)), + )); + } + let accept = headers.get(ACCEPT).cloned(); + let writer = user.0.clone(); + let (read, heads) = state + .mutate_doc_for(Some(user.0.clone()), sign, move |doc| { + if !envelope::is_signed(&envelope::signed_paths(&*doc), &path) { + return Err(ApiError( + StatusCode::CONFLICT, + Some(format!( + "/{} holds no value its user signs (docs/access.md)", + path.join("/") + )), + )); + } + let previous = match store::read_path(&*doc, &path) + .map_err(|e| store_status(e, "Failed to read store path"))? + { + Some(JsonValue::String(text)) if envelope::read(&text).is_ok() => Some(text), + _ => None, + }; + if envelope::read(&sealed).map_err(bad)?.prev != envelope::digest(previous.as_deref()) { + return Err(ApiError( + StatusCode::CONFLICT, + Some("it doesn't replace the value there (prev): read it again".to_owned()), + )); + } + envelope::check(&*doc, &sealed, &path, previous.as_deref()) + .map_err(|error| ApiError(StatusCode::FORBIDDEN, Some(error)))?; + let before = Before::of(&*doc, &writer); + store::write_path(doc, &path, &JsonValue::String(sealed)) + .map_err(|e| store_status(e, "Failed to write store value"))?; + registry_kept(&*doc, &path, before, &writer)?; + read_shown( + &*doc, + &path, + accept.as_ref(), + false, + false, + &Access::Everything, + ) + }) + .await?; + // A signed `null`: nothing there now. + match read { + Some(read) => Ok(with_heads(read.respond()?, &heads)), + None => Ok(with_heads(StatusCode::NO_CONTENT.into_response(), &heads)), + } +} + +/// `409` for a write at `path` that its user didn't sign, under a strict policy +/// (`access::policy`), where it would reach a value its user signs, at, within or above +/// it (`access::envelope`): there, a signed `PUT` only. +fn unsigned_kept_out(doc: &impl crate::document::ReadDoc, path: &[String]) -> Result<(), ApiError> { + if crate::access::policy::of(doc).strict() + && envelope::reaches_signed(&envelope::signed_paths(doc), path) + { + return Err(ApiError( + StatusCode::CONFLICT, + Some(format!( + "/{} reaches values their users sign (strict policy): PUT each signed, as {SIGNED}", + path.join("/") + )), + )); + } + Ok(()) +} + /// `response` saying where a write changed types (`RETYPED_HEADER`), if it did. fn with_retyped(mut response: Response, retyped: &[store::Retyped]) -> Response { if !retyped.is_empty() { @@ -661,6 +847,16 @@ struct WriteBody { timestamps: TimestampFormat, } +/// A request's media type (`Content-Type` without parameters, lowercase); JSON if none. +fn media_type(headers: &HeaderMap) -> String { + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("application/json"); + let media = content_type.split(';').next().unwrap_or(content_type); + media.trim().to_ascii_lowercase() +} + fn body_value(headers: &HeaderMap, body: &Bytes) -> Result<WriteBody, ApiError> { let content_type = headers .get(CONTENT_TYPE) @@ -742,6 +938,7 @@ pub(crate) async fn post_store_entry( let writer = user.0.clone(); let (value, heads) = state .mutate_doc_for(Some(user.0.clone()), sign.clone(), move |doc| { + unsigned_kept_out(&*doc, &path)?; let before = Before::of(&*doc, &writer); let value = store::increment_path(doc, &path, by) .map_err(|e| store_status(e, "Failed to increment a counter"))?; @@ -778,11 +975,19 @@ pub(crate) async fn post_store_entry( let writer = user.0.clone(); let ((retyped, read), heads) = state .mutate_doc_for(Some(user.0.clone()), sign.clone(), move |doc| { + unsigned_kept_out(&*doc, &path)?; let before = Before::of(&*doc, &writer); let retyped = store::cast_path(doc, &path, to, timestamps) .map_err(|e| store_status(e, "Failed to cast a store value"))?; registry_kept(&*doc, &path, before, &writer)?; - let read = read_shown(&*doc, &path, accept.as_ref(), false, &Access::Everything)?; + let read = read_shown( + &*doc, + &path, + accept.as_ref(), + false, + false, + &Access::Everything, + )?; Ok::<_, ApiError>((retyped, read)) }) .await?; @@ -794,6 +999,7 @@ pub(crate) async fn post_store_entry( let writer = user.0.clone(); let ((), heads) = state .mutate_doc_for(Some(user.0.clone()), sign.clone(), move |doc| { + unsigned_kept_out(&*doc, &path)?; let before = Before::of(&*doc, &writer); store::insert_val_path(doc, &path, &value.value) .map_err(|e| store_status(e, "Failed to insert into a list"))?; @@ -849,6 +1055,7 @@ pub(crate) async fn delete_store_entry( let writer = user.0.clone(); let ((), heads) = state .mutate_doc_for(Some(user.0.clone()), sign.clone(), move |doc| { + unsigned_kept_out(&*doc, &path)?; let before = Before::of(&*doc, &writer); store::delete_path_pruning(doc, &path, prune) .map_err(|e| store_status(e, "Failed to delete store value"))?; @@ -1085,3 +1292,57 @@ fn stream_event_line(key: &str, value: Option<&JsonValue>) -> std::io::Result<By line.push(b'\n'); Ok(Bytes::from(line)) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn envelope_of(value: JsonValue) -> String { + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + let payload = json!({"path": ["x"], "value": value, "user": "alice", "at": 1, "prev": ""}); + format!("cv1.{}.c2ln", URL_SAFE_NO_PAD.encode(payload.to_string())) + } + + fn path(text: &str) -> Vec<String> { + text.split('/').map(str::to_owned).collect() + } + + /// Envelopes where values are signed open as what they hold, signed `null`s go; + /// strings elsewhere, and what isn't an envelope, stay as they are. + #[test] + fn signed_values_open() { + let signed = vec![path("users/*"), path("config")]; + let mut read = json!({ + "users": { + "alice": envelope_of(json!({"caps": {}})), + "bob": envelope_of(JsonValue::Null), + "carol": "plain", + }, + "config": envelope_of(json!(7)), + "other": envelope_of(json!(8)), + }); + assert!(matches!( + opened(&mut read, &mut Vec::new(), &signed), + Opened::Plain + )); + assert_eq!( + read["users"], + json!({"alice": {"caps": {}}, "carol": "plain"}) + ); + assert_eq!(read["config"], json!(7)); + assert_eq!(read["other"], json!(envelope_of(json!(8)))); + + let mut one = json!(envelope_of(json!("v"))); + assert!(matches!( + opened(&mut one, &mut path("config"), &signed), + Opened::By(user) if user == "alice" + )); + assert_eq!(one, json!("v")); + let mut gone = json!(envelope_of(JsonValue::Null)); + assert!(matches!( + opened(&mut gone, &mut path("users/bob"), &signed), + Opened::Deleted + )); + } +} diff --git a/src/api/users.rs b/src/api/users.rs index 3b8174d..8972566 100644 --- a/src/api/users.rs +++ b/src/api/users.rs @@ -122,7 +122,7 @@ fn may_manage(state: &AppState, user: &str, access: &Access) -> Result<(), ApiEr /// A user's entry as the API shows it: as recorded, with its name. fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> { - let mut entry = store::read_path(doc, ®istry::user_path(name)).ok()??; + let mut entry = store::read_plain(doc, ®istry::user_path(name)).ok()??; if let Some(object) = entry.as_object_mut() { object.insert("name".to_owned(), json!(name)); } @@ -168,6 +168,16 @@ async fn change( let since = state.clock.now_millis().as_u64(); state .mutate_doc_for(Some(user.to_owned()), Sign::Protocol, move |doc| { + if crate::access::policy::of(&*doc).strict() { + return Err(ApiError( + StatusCode::CONFLICT, + Some( + "users sign their entries (strict policy): PUT each signed to \ + /store/_continuum/users/<name> (docs/access.md)" + .to_owned(), + ), + )); + } let before = Before::of(&*doc, &writer); f(doc, &by, since)?; registry_kept(&*doc, ®istry::users_dir(), before, &writer) |
