diff options
| author | tv <tv@krebsco.de> | 2026-11-08 15:43:45 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 16:49:56 +0100 |
| commit | ed27acb707d940c5b796b1079d3d74698484aeba (patch) | |
| tree | 2761073c72f91ad8f3f0ed4b38844bb8ab8d14a2 | |
| parent | 1cdf5ce6e2a935ec17f800706aa8f289036d90f7 (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.
| -rw-r--r-- | docs/access.md | 7 | ||||
| -rw-r--r-- | docs/openapi.yaml | 43 | ||||
| -rwxr-xr-x | scripts/continuum-signed | 28 | ||||
| -rw-r--r-- | src/access/envelope.rs | 52 | ||||
| -rw-r--r-- | src/access/rules.rs | 42 | ||||
| -rw-r--r-- | src/api/mod.rs | 5 | ||||
| -rw-r--r-- | src/api/store.rs | 11 | ||||
| -rw-r--r-- | src/api/users.rs | 103 | ||||
| -rw-r--r-- | src/swarm/refusals.rs | 2 |
9 files changed, 286 insertions, 7 deletions
diff --git a/docs/access.md b/docs/access.md index 3541efd..5983c7c 100644 --- a/docs/access.md +++ b/docs/access.md @@ -31,7 +31,12 @@ password hashes: every member reads the document). 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). + it signs values with (Values signed by their users, below) and logs in with. A user + manages its own, whatever else it may do: `GET /auth/keys`, `PUT /auth/keys/<name>` + with the line, `DELETE /auth/keys/<name>`; under a strict policy, by signing its + entry with only its keys changed (`continuum-signed key-add laptop + ~/.ssh/id_ed25519.pub`, `key-remove laptop`), with a key it holds already. Every + node takes that from the user itself, as from one who may manage users. ## A new swarm diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2ebf7fc..de62b59 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -977,6 +977,49 @@ paths: '204': { description: Done. } '409': { description: No user registry; tokens are taken until they expire. } + /auth/keys: + get: + tags: [Auth] + summary: Your keys + description: Your keys, your registry entry as recorded, and under a strict policy the envelope holding it (`signed`), which signing it anew names (`prev`). + responses: + '200': + description: Yours. + content: + application/json: + schema: + type: object + properties: + keys: { type: object, additionalProperties: { type: string } } + entry: { type: object } + signed: { type: string, nullable: true } + '404': { description: No entry of yours. } + + /auth/keys/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + put: + tags: [Auth] + summary: Add or replace one of your keys + description: Whatever else you may do. Under a strict policy `409`; sign your entry with the keys changed instead (`continuum-signed key-add`). + requestBody: + content: + text/plain: + schema: { type: string } + example: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA… laptop + responses: + '204': { description: Done. } + '400': { description: Not an Ed25519 public key, or not a key's name. } + '404': { description: No entry of yours. } + '409': { description: Strict policy. } + delete: + tags: [Auth] + summary: Remove one of your keys + responses: + '204': { description: Done. } + '404': { description: No entry of yours, or no such key. } + '409': { description: Strict policy. } + /auth/whoami: get: tags: [Auth] diff --git a/scripts/continuum-signed b/scripts/continuum-signed index 6dd1f4c..1523094 100755 --- a/scripts/continuum-signed +++ b/scripts/continuum-signed @@ -14,6 +14,9 @@ # continuum-signed seal PATH JSON writes JSON at PATH sealed for its readers, # signed # continuum-signed open PATH the JSON sealed at PATH, opened +# continuum-signed key-add ID FILE adds (or replaces) key ID of the user's own, +# the public key in FILE (.pub), signed +# continuum-signed key-remove ID removes key ID of the user's own, signed # # PATH is the store's (`config/mode`), its segments without `/` or characters that need # escaping in a URL. Settings, from the environment: @@ -88,10 +91,11 @@ raw() { "$url/store/$1?raw=true" || true } -# Writes JSON $2 at $1, signed: replacing the envelope there, if any (`prev`). +# Writes JSON $2 at $1, signed: replacing the envelope there, if any (`prev`; $3, if +# given, instead of reading it). write() { [ -n "$user" ] || die "who signs? set CONTINUUM_USER" - current=$(raw "$1") + if [ $# -ge 3 ]; then current=$3; else current=$(raw "$1"); fi case $current in cv1.*) prev=$(printf %s "$current" | sha256sum | cut -d' ' -f1) ;; *) prev= ;; @@ -186,10 +190,26 @@ verify() { echo "$1: signed by $signer at $(jq -r .at "$work/payload")" } +# The user's own entry with key $1 set to the line in file $2, or removed (no $2), +# signed: a user manages its own keys, whatever else it may do (GET /auth/keys gives +# its entry and the envelope holding it). +own_key() { + curl -sf -H "Authorization: Bearer $CONTINUUM_TOKEN" "$url/auth/keys" >"$work/own" || + die "no entry of $user's" + if [ $# -ge 2 ]; then + line=$(head -n 1 "$2") || die "no key in $2" + entry=$(jq -c --arg id "$1" --arg line "$line" '.entry | .keys[$id] = $line' "$work/own") + else + jq -e --arg id "$1" '.keys | has($id)' "$work/own" >/dev/null || die "no key $1" + entry=$(jq -c --arg id "$1" '.entry | del(.keys[$id])' "$work/own") + fi + write "_continuum/users/$user" "$entry" "$(jq -r '.signed // empty' "$work/own")" +} + command=${1:-} [ $# -gt 0 ] && shift case $command in - put | delete | get | verify | seal | open) + put | delete | get | verify | seal | open | key-add | key-remove) # One login for all the command's requests, unless given a token. CONTINUUM_TOKEN=${CONTINUUM_TOKEN:-$(login)} [ -n "$CONTINUUM_TOKEN" ] || die "couldn't log in as $user" @@ -203,5 +223,7 @@ case $command in verify) [ $# -eq 1 ] || die "verify PATH"; verify "$1" ;; seal) [ $# -eq 2 ] || die "seal PATH JSON"; seal "$1" "$2" ;; open) [ $# -eq 1 ] || die "open PATH"; open_sealed "$1" ;; + key-add) [ $# -eq 2 ] || die "key-add ID FILE"; own_key "$1" "$2" ;; + key-remove) [ $# -eq 1 ] || die "key-remove ID"; own_key "$1" ;; *) sed -n '2,/^$/s/^# \{0,1\}//p' "$0" >&2; exit 2 ;; esac 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: ®istry::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, ®istry::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}; |
