aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/access/envelope.rs52
-rw-r--r--src/access/rules.rs42
-rw-r--r--src/api/mod.rs5
-rw-r--r--src/api/store.rs11
-rw-r--r--src/api/users.rs103
-rw-r--r--src/swarm/refusals.rs2
6 files changed, 212 insertions, 3 deletions
diff --git a/src/access/envelope.rs b/src/access/envelope.rs
index e0c8909..7cd0083 100644
--- a/src/access/envelope.rs
+++ b/src/access/envelope.rs
@@ -207,7 +207,7 @@ pub(crate) fn check(
if !user.keys.values().any(|known| *known == key) {
return Err(format!("{} has no key {}", payload.user, key.fingerprint()));
}
- if !user.caps().allows(Right::Write, path) {
+ if !user.caps().allows(Right::Write, path) && !own_keys_only(doc, &payload, &user, path) {
return Err(format!(
"{} may not write /{}",
payload.user,
@@ -217,6 +217,33 @@ pub(crate) fn check(
Ok(Signed { payload, key })
}
+/// Whether `payload`, signed by its user (`user`, enabled), is that user's own entry
+/// with only its keys changed from the one at `path` in `doc`: a user manages its own
+/// keys, whatever else it may do.
+fn own_keys_only(
+ doc: &impl ReadDoc,
+ payload: &Payload,
+ user: &registry::User,
+ path: &[String],
+) -> bool {
+ if user.disabled || path != registry::user_path(&payload.user) {
+ return false;
+ }
+ let Ok(Some(mut before)) = store::read_plain(doc, path) else {
+ return false;
+ };
+ let mut after = payload.value.clone();
+ for entry in [&mut before, &mut after] {
+ match entry.as_object_mut() {
+ Some(entry) => {
+ entry.remove("keys");
+ }
+ None => return false,
+ }
+ }
+ before == after
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -307,6 +334,29 @@ mod tests {
assert!(check(&doc, &elsewhere, &path("config"), None).is_err());
}
+ /// A user signs its own entry with its keys changed, though it may not write the
+ /// registry; not with anything else changed, nor another's.
+ #[test]
+ fn users_sign_their_own_keys() {
+ let key = SigningKey::from_bytes(&[5; 32]);
+ let doc = registry_with(&key);
+ let entry = store::read_plain(&doc, &registry::user_path("alice"))
+ .unwrap()
+ .unwrap();
+ let line =
+ sshsig::PublicKey(SigningKey::from_bytes(&[8; 32]).verifying_key().to_bytes()).line();
+ let mut rekeyed = entry.clone();
+ rekeyed["keys"]["phone"] = json!(line);
+ let at = "_continuum/users/alice";
+ let signed_as = |user: &str, value: JsonValue| signed(&key, &payload(user, at, value, ""));
+ assert!(check(&doc, &signed_as("alice", rekeyed.clone()), &path(at), None).is_ok());
+ let mut more = rekeyed.clone();
+ more["caps"] = json!({"x": {"path": [], "rights": ["write"]}});
+ assert!(check(&doc, &signed_as("alice", more), &path(at), None).is_err());
+ // bob, holding the same key, for alice: not his entry.
+ assert!(check(&doc, &signed_as("bob", rekeyed), &path(at), None).is_err());
+ }
+
#[test]
fn listed_paths() {
let mut doc = Document::new();
diff --git a/src/access/rules.rs b/src/access/rules.rs
index 3eca1c9..450addb 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -193,6 +193,13 @@ pub(crate) fn judge(doc: &Document, epoch: Epoch, change: &Change<'_>) -> Result
|| user_may(path)
|| user_may(&meta::path(&[meta::USERS]))
}
+ // A user's own keys (`/auth/keys`), whatever else it may do.
+ (Some(meta::USERS), Some(owner))
+ if path.get(3).map(String::as_str) == Some("keys")
+ && change.user == Some(owner) =>
+ {
+ true
+ }
// A node forgotten while alive cancels its own record (`swarm::peer::forget`).
(Some(meta::FORGOTTEN), Some(owner)) => owner == node || user_may(path),
(Some(meta::REMAKES), Some(id)) => match path.get(3).map(String::as_str) {
@@ -788,6 +795,41 @@ mod tests {
assert!(decide(Some("carol"), "committed", 0).is_ok());
}
+ /// A user's own keys: its own to change, whatever else it may do; another's, or the
+ /// rest of its own entry, not.
+ #[test]
+ fn users_change_their_own_keys() {
+ let doc = swarm(true);
+ assert!(
+ judged(
+ &doc,
+ "a",
+ Some("bob"),
+ &["_continuum/users/bob/keys/laptop"]
+ )
+ .is_ok()
+ );
+ assert!(judged(&doc, "a", Some("bob"), &["_continuum/users/bob/keys"]).is_ok());
+ assert!(refused(judged(
+ &doc,
+ "a",
+ Some("bob"),
+ &["_continuum/users/alice/keys/laptop"]
+ )));
+ assert!(refused(judged(
+ &doc,
+ "a",
+ Some("bob"),
+ &["_continuum/users/bob/caps"]
+ )));
+ assert!(refused(judged(
+ &doc,
+ "a",
+ None,
+ &["_continuum/users/bob/keys/laptop"]
+ )));
+ }
+
/// A node that isn't a member may only register itself.
#[test]
fn strangers_register_themselves_only() {
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 0e7fc21..43d3c20 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -74,6 +74,11 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
)
.route("/auth/logout", axum::routing::post(login::logout))
.route("/auth/whoami", get(login::whoami))
+ .route("/auth/keys", get(users::own_keys))
+ .route(
+ "/auth/keys/{id}",
+ axum::routing::put(users::put_own_key).delete(users::delete_own_key),
+ )
.route("/admin/users", get(users::list).post(users::add_several))
.route("/admin/users/{name}/tokens", delete(users::revoke_tokens))
.route(
diff --git a/src/api/store.rs b/src/api/store.rs
index 81d0046..16dd966 100644
--- a/src/api/store.rs
+++ b/src/api/store.rs
@@ -464,7 +464,16 @@ pub(crate) async fn put_store_entry(
return Err(StatusCode::SERVICE_UNAVAILABLE.into());
}
- let path = parse_write_path(&state, &user, &access, &key, "PUT")?;
+ // A user's own entry, signed by it: its keys are its own to change, whatever else it
+ // may do (`envelope::check` takes nothing else changed).
+ let own_entry = media_type(&headers) == SIGNED
+ && parse_key_path(&key)
+ .is_ok_and(|path| path == crate::access::registry::user_path(&user.0));
+ let path = if own_entry {
+ crate::access::registry::user_path(&user.0)
+ } else {
+ parse_write_path(&state, &user, &access, &key, "PUT")?
+ };
let sign = Sign::Write {
path: path.clone(),
asked: query.signed,
diff --git a/src/api/users.rs b/src/api/users.rs
index 8972566..122fc4b 100644
--- a/src/api/users.rs
+++ b/src/api/users.rs
@@ -18,6 +18,15 @@
//! one exists. How a swarm without a registry gets one, the caller among those who
//! may manage users.
//!
+//! A user manages its own keys (`keys`, `authorized_keys` lines), whatever it may do
+//! otherwise:
+//!
+//! * `GET /auth/keys`: the caller's keys, its entry, and the envelope holding it under a
+//! strict policy (`signed`), to sign the next one against.
+//! * `PUT /auth/keys/{id}`: one added or replaced (the line as the body);
+//! `DELETE /auth/keys/{id}` removes one. Under a strict policy `409`: the user signs
+//! its entry with the keys changed, nothing else (`continuum-signed key-add`).
+//!
//! Looking needs `read` on `_continuum/users`, changing `write` on it (in a swarm
//! without a registry, being in `--protocol-writers`). Every change is one change to
//! the document, logged, named by who made it, and refused (`409`) where it would lock
@@ -129,6 +138,100 @@ fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> {
Some(entry)
}
+/// `GET /auth/keys`.
+pub(crate) async fn own_keys(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+) -> Result<Json<JsonValue>, ApiError> {
+ let replica = state.doc.read().await;
+ let path = registry::user_path(&user.0);
+ let entry = store::read_plain(&replica.doc, &path)
+ .ok()
+ .flatten()
+ .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, Some("no entry of yours".to_owned())))?;
+ let signed = match store::read_path(&replica.doc, &path) {
+ Ok(Some(JsonValue::String(text))) if text.starts_with("cv1.") => Some(text),
+ _ => None,
+ };
+ Ok(Json(json!({
+ "keys": entry.get("keys").cloned().unwrap_or_else(|| json!({})),
+ "entry": entry,
+ "signed": signed,
+ })))
+}
+
+/// `PUT /auth/keys/{id}`.
+pub(crate) async fn put_own_key(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+ Path(id): Path<String>,
+ body: String,
+) -> Result<StatusCode, ApiError> {
+ let key = crate::access::sshsig::PublicKey::parse(body.trim()).map_err(|why| bad(&why))?;
+ own_key(&state, &user.0, id, Some(key.line())).await
+}
+
+/// `DELETE /auth/keys/{id}`.
+pub(crate) async fn delete_own_key(
+ State(state): State<AppState>,
+ Extension(user): Extension<AuthenticatedUser>,
+ Path(id): Path<String>,
+) -> Result<StatusCode, ApiError> {
+ own_key(&state, &user.0, id, None).await
+}
+
+/// `user`'s key `id` set to `line`, or removed: one change, as `user`.
+async fn own_key(
+ state: &AppState,
+ user: &str,
+ id: String,
+ line: Option<String>,
+) -> Result<StatusCode, ApiError> {
+ if !registry::valid_name(&id) {
+ return Err(bad("not a key's name: 1 to 64 of A-Z a-z 0-9 . _ @ -"));
+ }
+ if !state.is_ready().await {
+ return Err(StatusCode::SERVICE_UNAVAILABLE.into());
+ }
+ let entry = registry::user_path(user);
+ let mut path = entry.clone();
+ path.extend(["keys".to_owned(), id.clone()]);
+ let adding = line.is_some();
+ 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): sign yours with the keys \
+ changed (continuum-signed key-add, key-remove)"
+ .to_owned(),
+ ),
+ ));
+ }
+ if store::read_path(&*doc, &entry)?.is_none() {
+ return Err(ApiError(
+ StatusCode::NOT_FOUND,
+ Some("no entry of yours".to_owned()),
+ ));
+ }
+ match line {
+ Some(line) => store::write_path(doc, &path, &json!(line))?,
+ None => match store::delete_path(doc, &path, false) {
+ Ok(()) => {}
+ Err(crate::document::store::StoreError::NotFound) => {
+ return Err(ApiError(StatusCode::NOT_FOUND, None));
+ }
+ Err(error) => return Err(error.into()),
+ },
+ }
+ Ok(())
+ })
+ .await?;
+ info!(user = %user, key = %id, "{}", if adding { "Set own key" } else { "Removed own key" });
+ Ok(StatusCode::NO_CONTENT)
+}
+
/// `GET /admin/users`.
pub(crate) async fn list(State(state): State<AppState>) -> Json<JsonValue> {
let replica = state.doc.read().await;
diff --git a/src/swarm/refusals.rs b/src/swarm/refusals.rs
index 2ba7908..c1949eb 100644
--- a/src/swarm/refusals.rs
+++ b/src/swarm/refusals.rs
@@ -18,8 +18,8 @@ use std::time::Duration;
use tracing::{debug, warn};
-use crate::document::replica::Epoch;
use crate::document::ChangeHash;
+use crate::document::replica::Epoch;
use crate::state::AppState;
use crate::transport::{ParsedMessage, RefusalsMessage};