From 7fed3e4478ad28151fab288d569a1268c1f3e4cf Mon Sep 17 00:00:00 2001 From: tv Date: Sat, 7 Nov 2026 22:35:21 +0100 Subject: api: signed values at the store PUT with application/continuum-signed writes an envelope where values are signed (403/409 saying why); reads open envelopes and name the signer (x-continuum-signed-by), ?raw=true shows them. Under strict, unsigned writes reaching a signed value and the users API are 409. Auth plan M7. --- src/api/store.rs | 273 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 267 insertions(+), 6 deletions(-) (limited to 'src/api/store.rs') 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, + /// 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, /// With `?conflicts=true`: those on the way (then always JSON). conflicts: Option>, + /// The user who signed it, if it is a value its user signed, opened (`opened`). + signer: Option, } impl Read { fn respond(self) -> Result { - 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); + } + 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, signed: &[Vec]) -> 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; } - show(&self.held, self.shown, self.json.as_ref()) + *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, 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, + sign: Sign, + headers: &HeaderMap, + body: &Bytes, +) -> Result { + 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 { 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 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 { + 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 + )); + } +} -- cgit v1.3.1