aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-08 16:15:39 +0000
committertv <tv@krebsco.de>2026-11-08 17:36:11 +0100
commit63c2272a4feb890277ed1362b48a3524d0b07165 (patch)
treed69b836320a72f70ba15b10aec6c32b578f1f9f8
parenta7c70346b448f493991df1e035beaa4bde113b4f (diff)
users: a user can't remove its own last key
A user managing its own keys (1168) could remove the last, and then neither sign nor log in by key. It can't now: DELETE /auth/keys/{id} of the last answers 409, the rules take a user's change of its own keys only with one left after it, and under a strict policy its own entry signed only with one key at least. One who may manage users still may.
-rw-r--r--docs/access.md3
-rw-r--r--src/access/envelope.rs17
-rw-r--r--src/access/rules.rs103
-rw-r--r--src/api/users.rs16
4 files changed, 112 insertions, 27 deletions
diff --git a/docs/access.md b/docs/access.md
index a8dfed4..5941bdb 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -36,7 +36,8 @@ password hashes: every member reads the document).
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.
+ node takes that from the user itself, as from one who may manage users, but never
+ its last key removed (`409`): one who may manage users may.
## A new swarm
diff --git a/src/access/envelope.rs b/src/access/envelope.rs
index 7cd0083..e99a06e 100644
--- a/src/access/envelope.rs
+++ b/src/access/envelope.rs
@@ -218,8 +218,8 @@ pub(crate) fn check(
}
/// 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.
+/// with only its keys changed from the one at `path` in `doc`, one left at least: a user
+/// manages its own keys, whatever else it may do.
fn own_keys_only(
doc: &impl ReadDoc,
payload: &Payload,
@@ -232,6 +232,15 @@ fn own_keys_only(
let Ok(Some(mut before)) = store::read_plain(doc, path) else {
return false;
};
+ // One key left at least: a user can't sign, nor log in by key, without one.
+ if payload
+ .value
+ .get("keys")
+ .and_then(JsonValue::as_object)
+ .is_none_or(serde_json::Map::is_empty)
+ {
+ return false;
+ }
let mut after = payload.value.clone();
for entry in [&mut before, &mut after] {
match entry.as_object_mut() {
@@ -355,6 +364,10 @@ mod tests {
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());
+ // Her keys all gone: no.
+ let mut keyless = entry.clone();
+ keyless["keys"] = json!({});
+ assert!(check(&doc, &signed_as("alice", keyless), &path(at), None).is_err());
}
#[test]
diff --git a/src/access/rules.rs b/src/access/rules.rs
index 450addb..4e4e6e9 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -193,12 +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.
+ // A user's own keys (`/auth/keys`), whatever else it may do, one left at
+ // least.
(Some(meta::USERS), Some(owner))
if path.get(3).map(String::as_str) == Some("keys")
&& change.user == Some(owner) =>
{
- true
+ keys_left(&at, owner, change.ops)
}
// A node forgotten while alive cancels its own record (`swarm::peer::forget`).
(Some(meta::FORGOTTEN), Some(owner)) => owner == node || user_may(path),
@@ -479,6 +480,30 @@ fn aborts_expired(at: &At<'_>, id: &str, change: &Change<'_>) -> bool {
change.time >= settle_until.saturating_add(grace.saturating_mul(2))
}
+/// Whether `owner` has a key left after `ops`, its keys at `at` as they change them.
+fn keys_left(at: &At<'_>, owner: &str, ops: &[Op]) -> bool {
+ let keys = meta::path(&[meta::USERS, owner, "keys"]);
+ let mut left: BTreeSet<String> = store::keys_at(at, &keys)
+ .ok()
+ .flatten()
+ .unwrap_or_default()
+ .into_iter()
+ .collect();
+ for op in ops.iter().filter(|op| op.path.starts_with(&keys)) {
+ match (op.path.get(keys.len()), &op.action) {
+ (None, _) => left.clear(),
+ (Some(id), Action::Delete) => {
+ left.remove(id);
+ }
+ (Some(id), _) if op.path.len() == keys.len() + 1 => {
+ left.insert(id.clone());
+ }
+ _ => {}
+ }
+ }
+ !left.is_empty()
+}
+
/// The coordinator remake `id`'s proposal names, at `at`.
fn coordinator(at: &At<'_>, id: &str) -> Option<String> {
let proposal = store::read_path(at, &meta::path(&[meta::REMAKES, id, "proposal"]))
@@ -795,38 +820,68 @@ 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.
+ /// A user's own keys: its own to change, whatever else it may do, one left at least;
+ /// 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"]
+ let mut doc = swarm(true);
+ let judged_ops = |doc: &Document, user: Option<&str>, ops: Vec<Op>| {
+ let deps = doc.get_heads();
+ let touched: BTreeSet<Path> = ops.iter().map(|op| op.path.clone()).collect();
+ judge(
+ doc,
+ Epoch::GENESIS,
+ &Change {
+ hash: ChangeHash([9; 32]),
+ deps: &deps,
+ seen: &deps,
+ committer: Some("a"),
+ user,
+ paths: &touched,
+ ops: &ops,
+ time: 0,
+ },
)
- .is_ok()
- );
- assert!(judged(&doc, "a", Some("bob"), &["_continuum/users/bob/keys"]).is_ok());
- assert!(refused(judged(
+ };
+ let op = |at: &str, action: Action| Op {
+ path: path(at),
+ action,
+ };
+ let put = |at: &str| op(at, Action::PutString("ssh-ed25519 AAAA".to_owned()));
+ let bobs = "_continuum/users/bob/keys/laptop";
+ assert!(judged_ops(&doc, Some("bob"), vec![put(bobs)]).is_ok());
+ assert!(refused(judged_ops(
&doc,
- "a",
Some("bob"),
- &["_continuum/users/alice/keys/laptop"]
+ vec![put("_continuum/users/alice/keys/laptop")]
)));
- assert!(refused(judged(
+ assert!(refused(judged_ops(
&doc,
- "a",
Some("bob"),
- &["_continuum/users/bob/caps"]
+ vec![op("_continuum/users/bob/caps", Action::MakeMap)]
)));
- assert!(refused(judged(
+ assert!(refused(judged_ops(&doc, None, vec![put(bobs)])));
+ // With one key: another added, one of two removed; not the last.
+ write(&mut doc, bobs, json!("ssh-ed25519 AAAA"));
+ let phone = "_continuum/users/bob/keys/phone";
+ assert!(judged_ops(&doc, Some("bob"), vec![put(phone)]).is_ok());
+ assert!(refused(judged_ops(
&doc,
- "a",
- None,
- &["_continuum/users/bob/keys/laptop"]
+ Some("bob"),
+ vec![op(bobs, Action::Delete)]
+ )));
+ assert!(
+ judged_ops(
+ &doc,
+ Some("bob"),
+ vec![put(phone), op(bobs, Action::Delete)]
+ )
+ .is_ok()
+ );
+ assert!(refused(judged_ops(
+ &doc,
+ Some("bob"),
+ vec![op("_continuum/users/bob/keys", Action::Delete)]
)));
}
diff --git a/src/api/users.rs b/src/api/users.rs
index 122fc4b..1dda33b 100644
--- a/src/api/users.rs
+++ b/src/api/users.rs
@@ -197,6 +197,7 @@ async fn own_key(
let mut path = entry.clone();
path.extend(["keys".to_owned(), id.clone()]);
let adding = line.is_some();
+ let id_kept = id.clone();
state
.mutate_doc_for(Some(user.to_owned()), Sign::Protocol, move |doc| {
if crate::access::policy::of(&*doc).strict() {
@@ -215,6 +216,21 @@ async fn own_key(
Some("no entry of yours".to_owned()),
));
}
+ if line.is_none() {
+ let mut keys = entry.clone();
+ keys.push("keys".to_owned());
+ let others = store::keys_at(&*doc, &keys)?
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|other| *other != id_kept)
+ .count();
+ if others == 0 {
+ return Err(ApiError(
+ StatusCode::CONFLICT,
+ Some("your last key: add another first".to_owned()),
+ ));
+ }
+ }
match line {
Some(line) => store::write_path(doc, &path, &json!(line))?,
None => match store::delete_path(doc, &path, false) {