aboutsummaryrefslogtreecommitdiffstats
path: root/src/access
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/access
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/access')
-rw-r--r--src/access/envelope.rs52
-rw-r--r--src/access/rules.rs42
2 files changed, 93 insertions, 1 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() {