aboutsummaryrefslogtreecommitdiffstats
path: root/src/api
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 15:43:45 +0000
committertv <tv@krebsco.de>2026-11-08 16:49:56 +0100
commited27acb707d940c5b796b1079d3d74698484aeba (patch)
tree2761073c72f91ad8f3f0ed4b38844bb8ab8d14a2 /src/api
parent1cdf5ce6e2a935ec17f800706aa8f289036d90f7 (diff)
users: a user manages its own keys
Only a user who may manage users could change a user's keys: a user couldn't add a new laptop's key, or remove a lost one. A user now manages its own keys, whatever else it may do: * GET /auth/keys: its keys, its entry, and the envelope holding it under a strict policy (`signed`), to sign against. * PUT /auth/keys/{id} with an authorized_keys line, DELETE /auth/keys/{id}: one change, as the user. The rules take a write of _continuum/users/<name>/keys/… from the user it names. * Under a strict policy, the user signs its entry with only its keys changed, with a key it holds already (envelope::check takes that from the user itself), PUT to its own entry even without write on the registry; continuum-signed key-add and key-remove do so.
Diffstat (limited to 'src/api')
-rw-r--r--src/api/mod.rs5
-rw-r--r--src/api/store.rs11
-rw-r--r--src/api/users.rs103
3 files changed, 118 insertions, 1 deletions
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;