aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md32
-rw-r--r--docs/api.md30
-rw-r--r--docs/openapi.yaml45
-rw-r--r--src/access/envelope.rs45
-rw-r--r--src/access/mod.rs5
-rw-r--r--src/access/policy.rs1
-rw-r--r--src/api/store.rs273
-rw-r--r--src/api/users.rs12
8 files changed, 416 insertions, 27 deletions
diff --git a/docs/access.md b/docs/access.md
index 682b22b..0366e26 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -29,6 +29,9 @@ password hashes: every member reads the document).
* **`disabled`**: the entry stays (who wrote what stays readable), and gives nothing.
An entry that doesn't read as above gives less, never more: a capability that
doesn't read is left out, and anything but `false` disables.
+* **`keys`**: the user's SSH keys, each by a name of its choosing, as an
+ `authorized_keys` line has it (`"laptop": "ssh-ed25519 AAAA…"`; Ed25519 only): what
+ it signs values with (Values signed by their users, below).
## A new swarm
@@ -231,3 +234,32 @@ curl -X PUT localhost:3000/store/_continuum/banned/<node ID> \
-H 'Content-Type: application/json' -d '{"by": "alice", "reason": "compromised"}'
```
+## Values signed by their users
+
+A member's node signs its changes, but the user a change names is its node's word: a
+member could write as any user. A swarm can have its users sign the values that
+matter themselves, with their SSH keys, so no member can write them but as a user
+signed them: the policy's `"values": "signed"` (strict; it requires signatures too).
+
+```sh
+curl -X PUT localhost:3000/store/_continuum/access/policy \
+ -H 'Content-Type: application/json' -d '{"version": 1, "values": "signed"}'
+```
+
+Signed then are what decides who may do what (every user's entry, every entry of
+`_continuum/access`, every ban), and the paths listed at `_continuum/access/userSigned`,
+a list of paths as capabilities name them, `*` for any one key:
+`[["config"], ["servers", "*", "secret"]]`. Each such value is an envelope, one string
+(`cv1.<payload>.<signature>`): the payload names the path, the value (`null`: deleted),
+the user, when, and the envelope it replaces (`prev`), and the signature is the user's
+over it, as `ssh-keygen -Y sign -n continuum-value` makes it, with one of its `keys`.
+A node takes one only from that user, for that path, replacing what is there, by one
+of the user's keys, and if the user may write there ([`api.md`](api.md), Signed values);
+it writes nothing else there, nor within or above such a value (`409`), and the users
+API changes nothing (`409`). Values there before the policy stay as they are until
+replaced: a registry whose entries hold the users' keys turns strict as it is. Without
+the policy, signed values may be written all the same, and plain ones too.
+
+What it doesn't do yet: **judge signed values on receipt.** Each node checks the
+values its users write through it; a member writing to its document directly could
+still write there.
diff --git a/docs/api.md b/docs/api.md
index 5d94fac..92289bf 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -272,6 +272,36 @@ a second (SIGNATURES), to each peer that wasn't sent them yet.
The signatures a node has are kept in its document file (`--document-file`) with the
document.
+## Signed values
+
+Where the swarm has its users sign values ([`access.md`](access.md), Values signed by
+their users), a user writes one by signing it with its SSH key: the payload, one line
+of JSON, then the signature, as `application/continuum-signed`.
+
+```sh
+# The envelope there, if any, and what replaces it names: its SHA-256 (none: "").
+read=$(curl -sf -u alice -H 'Accept: text/plain' 'localhost:3000/store/config?raw=true')
+prev=${read:+$(printf %s "$read" | sha256sum | cut -d' ' -f1)}
+printf '{"path":["config"],"value":{"mode":"fast"},"user":"alice","at":%s,"prev":"%s"}' \
+ "$(date +%s000)" "$prev" > payload
+ssh-keygen -Y sign -f ~/.ssh/id_ed25519 -n continuum-value payload
+{ cat payload; echo; cat payload.sig; } | curl -X PUT localhost:3000/store/config \
+ -u alice -H 'Content-Type: application/continuum-signed' --data-binary @-
+```
+
+The node writes it as its envelope if the authenticated user signed it (`403` if not),
+for this path, with one of its keys, may write there (`403`), and it replaces what is
+there (`prev`, the SHA-256 of the envelope it replaces; `409` if it names another:
+read again and sign anew), and only where values are signed (`409` elsewhere). It
+answers with the value, as a `PUT` does; a signed `null` deletes, with `204`. Under
+the policy, every other write reaching a signed value, at it, within it or above it,
+is `409`, and so is the users API's.
+
+A read opens envelopes: each shows as the value it holds, a signed `null` as nothing,
+and a read of one names its signer (`x-continuum-signed-by`). `?raw=true` shows them as
+they are stored, envelopes, which anyone can check; so do typed JSON and native reads,
+and streams (`/stream`).
+
## Protocol data
`_continuum` holds what the nodes coordinate through: the swarm ID, the member registry,
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 1954dc0..6abfc6d 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -98,11 +98,22 @@ paths:
timestamps as `rfc3339` (the default), `unix`, `unix-ms` or `http-date`.
Concurrent writes resolve as follows: concurrent maps merge, anything else
keeps the newest write.
+
+ A value its user signed (`api.md`, Signed values) shows as the value it holds
+ (a signed `null` as nothing), and a read of one names its signer in
+ `x-continuum-signed-by`; as typed JSON, natively and with `raw`, as its
+ envelope.
parameters:
- $ref: '#/components/parameters/conflicts'
- $ref: '#/components/parameters/heads'
- $ref: '#/components/parameters/shallow'
- $ref: '#/components/parameters/at'
+ - name: raw
+ in: query
+ description: Values their users signed as stored, as envelopes (`cv1.<payload>.<signature>`), to check them.
+ schema:
+ type: boolean
+ default: false
responses:
'200':
$ref: '#/components/responses/Value'
@@ -146,6 +157,11 @@ paths:
`.float`, `.boolean`, `.counter` (decimal or `true`/`false`), or
`application/vnd.continuum.timestamp; format=rfc3339|unix|unix-ms|http-date`;
a body that isn't one is `400`, saying why.
+
+ A value its user signed (`application/continuum-signed`: the payload's line of
+ JSON, then its armored SSH signature; `api.md`, Signed values) is written as its
+ envelope where values are signed: by the authenticated user, for this path,
+ with one of its keys, replacing what is there. A signed `null` deletes (`204`).
parameters:
- name: retype
in: query
@@ -172,17 +188,27 @@ paths:
schema:
type: string
example: '2026-11-01T21:50:20.345Z'
+ application/continuum-signed:
+ schema:
+ type: string
+ example: |
+ {"path":["config"],"value":{"mode":"fast"},"user":"alice","at":1762400000000,"prev":""}
+ -----BEGIN SSH SIGNATURE-----
+ U1NIU0lH…
+ -----END SSH SIGNATURE-----
responses:
'200':
$ref: '#/components/responses/Value'
+ '204':
+ description: A signed `null` written; nothing there now.
'400':
description: Invalid path, malformed JSON or invalid UTF-8.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
- description: The user may not write here (its capabilities, docs/access.md; in a swarm without a user registry, protocol data `_continuum` and the user isn't in `protocol-writers`), or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable).
+ description: The user may not write here (its capabilities, docs/access.md; in a swarm without a user registry, protocol data `_continuum` and the user isn't in `protocol-writers`), or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable); a signed value not by this user, or whose signature doesn't check (the message says why).
'409':
- description: The path crosses an existing non-map value, or with `retype=false` the write would change a type (the message says where).
+ description: The path crosses an existing non-map value, or with `retype=false` the write would change a type (the message says where); a value not signed reaching one its user signs under a strict policy; a signed value where values aren't signed, or replacing another than the one there (read again).
'415':
description: Content-Type is none the API takes (`application/json`, the default; `text/plain`; a type's own, `api.md`, Types).
'500':
@@ -274,7 +300,7 @@ paths:
'307':
description: A negative index, made positive (`Location`).
'409':
- description: Not a list there, or the index is past its end (the message says which, and the length); with `increment`, not a counter there; with `cast`, no cast possible (the message says why).
+ description: Not a list there, or the index is past its end (the message says which, and the length); with `increment`, not a counter there; with `cast`, no cast possible (the message says why); under a strict policy, reaching a value its user signs.
'415':
description: Content-Type is none the API takes (`application/json`, the default; `text/plain`; a type's own, `api.md`, Types).
'503':
@@ -307,6 +333,8 @@ paths:
description: The user may not write here (its capabilities, docs/access.md; in a swarm without a user registry, protocol data `_continuum` and the user isn't in `protocol-writers`), or epoch metadata or the swarm ID (`_continuum/epochs`, `_continuum/swarm`, never writable).
'404':
description: No value at this path.
+ '409':
+ description: Under a strict policy, reaching a value its user signs (`PUT` a signed `null` instead).
'500':
description: Deleting failed.
'503':
@@ -914,7 +942,7 @@ paths:
'401':
$ref: '#/components/responses/Unauthorized'
'403': { description: May not manage users. }
- '409': { description: A user exists, or the caller wouldn't manage users in a new registry. }
+ '409': { description: "A user exists, the caller wouldn't manage users in a new registry, or the policy has users sign their entries (strict, docs/access.md)." }
/admin/users/{name}:
parameters:
@@ -947,7 +975,7 @@ paths:
'201': { description: Added. }
'400': { description: Not a user name, or a capability that isn't one. }
'403': { description: May not manage users. }
- '409': { description: The user exists. }
+ '409': { description: 'The user exists, or the policy has users sign their entries (strict, docs/access.md).' }
delete:
tags: [Users]
summary: Remove a user
@@ -956,7 +984,7 @@ paths:
'204': { description: Removed. }
'403': { description: May not manage users. }
'404': { description: No such user. }
- '409': { description: It would keep the caller from managing users. }
+ '409': { description: It would keep the caller from managing users, or the policy has users sign their entries (strict, docs/access.md). }
/admin/users/{name}/disabled:
parameters:
@@ -973,7 +1001,7 @@ paths:
'204': { description: Done. }
'403': { description: May not manage users. }
'404': { description: No such user. }
- '409': { description: It would keep the caller from managing users. }
+ '409': { description: It would keep the caller from managing users, or the policy has users sign their entries (strict, docs/access.md). }
/admin/users/{name}/caps:
parameters:
@@ -998,6 +1026,7 @@ paths:
'400': { description: A capability that isn't one. }
'403': { description: May not manage users. }
'404': { description: No such user. }
+ '409': { description: The policy has users sign their entries (strict, docs/access.md). }
/admin/users/{name}/caps/{id}:
parameters:
@@ -1010,7 +1039,7 @@ paths:
'204': { description: Revoked. }
'403': { description: May not manage users. }
'404': { description: No such capability. }
- '409': { description: It would keep the caller from managing users. }
+ '409': { description: It would keep the caller from managing users, or the policy has users sign their entries (strict, docs/access.md). }
/admin/dashboard:
get:
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, &registry::user_path(name)).ok()??;
+ let mut entry = store::read_plain(doc, &registry::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, &registry::users_dir(), before, &writer)