diff options
| author | tv <tv@krebsco.de> | 2026-11-08 16:15:39 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-08 17:36:11 +0100 |
| commit | 63c2272a4feb890277ed1362b48a3524d0b07165 (patch) | |
| tree | d69b836320a72f70ba15b10aec6c32b578f1f9f8 | |
| parent | a7c70346b448f493991df1e035beaa4bde113b4f (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.md | 3 | ||||
| -rw-r--r-- | src/access/envelope.rs | 17 | ||||
| -rw-r--r-- | src/access/rules.rs | 103 | ||||
| -rw-r--r-- | src/api/users.rs | 16 |
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) { |
