aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/access.md7
-rw-r--r--docs/openapi.yaml43
-rwxr-xr-xscripts/continuum-signed28
-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
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: &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};