aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-09 02:24:51 +0000
committertv <tv@krebsco.de>2026-11-09 02:24:51 +0000
commit8a6ed7cc711e797542a531091b3bdbc73e5f61e4 (patch)
treea599731bdab3b3cbe07a8bf1d42887da907a2ea7
parent764d3e07400854b333583b2efaa1c9eae18ae5e1 (diff)
dashboard: keys edited, the last removed with a password, failures kept
The credentials dialog listed a user's keys whole, to add one or remove one but the last, and any failure replaced the dialog with the error, the form lost. * A key is shown short: its type, the first and last four characters of what tells it apart (an Ed25519 key's base64 after the part all of them share), its comment; the whole line in its tooltip. * A key can be edited, its name and its line: saved under the name, a new name written first and the old one removed. * A user with a password on this node (`--htpasswd`) may remove its last key: it logs in by the password. GET /auth/keys says so (`password`), and DELETE /auth/keys/{id} takes it then; the rules no longer keep the last key on every node (no other node can tell who has a password where; under a strict policy the signed entry keeps one still). * What fails says why beside where it was asked (the add form, the edited row, the removed one), which stays as it was, to be set right and tried again.
-rw-r--r--docs/access.md6
-rw-r--r--docs/dashboard.md16
-rw-r--r--docs/openapi.yaml6
-rw-r--r--src/access/rules.rs31
-rw-r--r--src/api/auth.rs7
-rw-r--r--src/api/dashboard/dashboard.html225
-rw-r--r--src/api/mod.rs7
-rw-r--r--src/api/users.rs26
8 files changed, 231 insertions, 93 deletions
diff --git a/docs/access.md b/docs/access.md
index 2df7270..4bb5945 100644
--- a/docs/access.md
+++ b/docs/access.md
@@ -36,8 +36,10 @@ 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, but never
- its last key removed (`409`): one who may manage users may.
+ node takes that from the user itself, as from one who may manage users. Its last key
+ it removes only on a node where it has a password (`--htpasswd`), which it logs in
+ by then (`409` elsewhere); under a strict policy, never (it signs its entry with
+ one).
## A new swarm
diff --git a/docs/dashboard.md b/docs/dashboard.md
index 2966c79..2bd126a 100644
--- a/docs/dashboard.md
+++ b/docs/dashboard.md
@@ -25,9 +25,15 @@ dashboard is open; it ends with the token's lifetime, or with "Log out of this
browser" on the login page, or "log out" in the user's menu on the dashboard (its
name, top right), which ends this browser's session only. Where the page got in by
what it can't end, the browser's password or the proxy's, "log out" is greyed, and
-says how instead. The menu's "credentials…" shows the user's keys, to add one (its
-`.pub` line) or remove one but the last; under a strict policy, how to sign them anew
-(`continuum-signed key-add`); and where its password is: in each node's `--htpasswd`. The page and the login page run their
+says how instead. The menu's "credentials…" shows the user's keys, each by its name
+and as much of the key as fits the dialog, cut in the middle where it doesn't (its end
+and comment always shown, the whole in its tooltip), and `edit` at the right: its name
+and `.pub` line in fields, to save, or to delete with the red `Delete` (on a second
+click from half a second to 3 s after the first, as in the document box; the last key
+only where the user has a password on this node, which it logs in by then); or one
+added below; what fails says why where it was asked, to be set right and tried again. Under a
+strict policy, it says how to sign them anew (`continuum-signed key-add`); and where
+the password is: in each node's `--htpasswd`. The page and the login page run their
own script only (a content security policy naming it by its hash), and no other page
may frame them. The API listens on localhost unless `--bind-addr` says otherwise;
to look at a node on another machine, forward the port (`ssh -L 3000:localhost:3000
@@ -241,7 +247,9 @@ measured.
this node knows of them. The browser must reach that dashboard: not one behind NAT, on
Tor alone, or plain `http` from an `https` page; the node allows this page's origin
as a member's dashboard (`--cors-origins` for others). Subscriptions are kept in the
- browser, and handed over with the rest.
+ browser, and handed over with the rest; going to another node's dashboard, the node
+ left is subscribed to there (this node itself never is: its links and messages are
+ the page's own).
* **Right-click a node**: its menu: subscribing to its links (or not), and `go to its
dashboard`, greyed where the node publishes none, and on this node's own.
* **Go to a node's dashboard** (its menu): in this page, where the node publishes one
diff --git a/docs/openapi.yaml b/docs/openapi.yaml
index 4a49d2b..efda5e2 100644
--- a/docs/openapi.yaml
+++ b/docs/openapi.yaml
@@ -981,7 +981,7 @@ paths:
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`).
+ description: Your keys, your registry entry as recorded, under a strict policy the envelope holding it (`signed`), which signing it anew names (`prev`), and whether you have a password on this node (`password`), so you may remove your last key here.
responses:
'200':
description: Yours.
@@ -993,6 +993,7 @@ paths:
keys: { type: object, additionalProperties: { type: string } }
entry: { type: object }
signed: { type: string, nullable: true }
+ password: { type: boolean }
'404': { description: No entry of yours. }
/auth/keys/{id}:
@@ -1015,10 +1016,11 @@ paths:
delete:
tags: [Auth]
summary: Remove one of your keys
+ description: Your last only with a password on this node (`--htpasswd`).
responses:
'204': { description: Done. }
'404': { description: No entry of yours, or no such key. }
- '409': { description: Strict policy. }
+ '409': { description: Strict policy, or your last key and no password on this node. }
/auth/whoami:
get:
diff --git a/src/access/rules.rs b/src/access/rules.rs
index ac7377f..d8162a6 100644
--- a/src/access/rules.rs
+++ b/src/access/rules.rs
@@ -279,13 +279,14 @@ pub(crate) fn judge_reading(
{
true
}
- // A user's own keys (`/auth/keys`), whatever else it may do, one left at
- // least.
+ // A user's own keys (`/auth/keys`), whatever else it may do; its last too
+ // (the node it asks keeps that from one without a password there, which no
+ // other node can tell).
(Some(meta::USERS), Some(owner))
if path.get(3).map(String::as_str) == Some("keys")
&& change.user == Some(owner) =>
{
- keys_left(&at, owner, change.ops)
+ true
}
// A node forgotten while alive cancels its own record (`swarm::peer::forget`).
(Some(meta::FORGOTTEN), Some(owner)) => owner == node || user_may(path),
@@ -531,30 +532,6 @@ fn made_the_epoch(at: &At<'_>, epoch: Epoch, id: &str, node: &str) -> bool {
== Some(node)
}
-/// 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"]))
diff --git a/src/api/auth.rs b/src/api/auth.rs
index a5a987c..ac70b82 100644
--- a/src/api/auth.rs
+++ b/src/api/auth.rs
@@ -154,6 +154,13 @@ impl Range {
}
impl AuthState {
+ /// Whether `user` has a password on this node (`--htpasswd`): it logs in by it.
+ pub(crate) fn has_password(&self, user: &str) -> bool {
+ self.users
+ .as_ref()
+ .is_some_and(|users| users.contains_key(user))
+ }
+
/// From the options; None if there is neither an htpasswd file nor a user header
/// (no users: no HTTP API, for a relay).
pub(crate) fn from_settings(
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index c4e6276..0be3526 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -258,6 +258,22 @@
#dialog table { border-collapse: collapse; width: 100%; }
#dialog td { padding: 2px 6px 2px 0; vertical-align: top; }
#dialog td.key { font-family: var(--mono, monospace); word-break: break-all; color: var(--muted); }
+ /* The keys as wide as the dialog lets them, never wider: the name and the actions
+ their widths, the key the rest, cut in the middle (`keyLine`) as much as it must. */
+ #dialog table.keys { table-layout: fixed; }
+ #dialog table.keys col.name { width: 7em; }
+ #dialog table.keys col.actions { width: 4em; }
+ #dialog table.keys td.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ #dialog table.keys td.actions { text-align: right; padding-right: 0; }
+ /* Being edited: its fields and buttons across the row. */
+ #dialog .key-edit { display: flex; gap: 6px; align-items: baseline; margin: 2px 0; }
+ #dialog .key-edit .key-name { width: 7em; flex: none; }
+ #dialog .key-edit .key-value { flex: 1; min-width: 0; }
+ #dialog table.keys td.key { word-break: normal; overflow: hidden; }
+ .key-line { display: flex; min-width: 0; }
+ .key-line .start { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
+ min-width: 0; }
+ .key-line .end { flex: none; white-space: pre; }
#dialog form { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px; }
#dialog input { font: inherit; }
#dialog input[name="line"] { flex: 1; min-width: 16em; }
@@ -281,8 +297,9 @@
.editor input[type=text], .editor textarea { width: 100%; font: inherit; color: var(--fg);
background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 2px 4px; }
.editor button { font: inherit; }
- .editor button.delete { color: #fff; background: var(--bad); border: 1px solid var(--bad);
- border-radius: 3px; }
+ .editor button.delete, #dialog button.delete { color: #fff; background: var(--bad);
+ border: 1px solid var(--bad); border-radius: 3px; }
+ #dialog button.delete:disabled { opacity: .5; }
.tree details.editing > :not(summary):not(.editor) { display: none; }
.tree .leaf[title] { cursor: text; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
@@ -454,13 +471,19 @@ const handOver = (() => {
// The nodes whose own links this page reads from them (`follow`), kept in the browser,
// and how each goes: declared before anything draws.
const SUBSCRIPTIONS = "continuum-subscriptions";
+// Opened from another node's page (`handOver`), that node is subscribed to too: its
+// links and messages were that page's own, and are another's here.
const subscriptions = (() => {
+ let kept = new Set();
try {
- const kept = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
- return new Set(Array.isArray(kept) ? kept.filter(id => typeof id === "string") : []);
- } catch (_) {
- return new Set();
+ const listed = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
+ kept = new Set(Array.isArray(listed) ? listed.filter(id => typeof id === "string") : []);
+ } catch (_) { /* none kept */ }
+ if (handOver && typeof handOver.from === "string" && !kept.has(handOver.from)) {
+ kept.add(handOver.from);
+ try { localStorage.setItem(SUBSCRIPTIONS, JSON.stringify([...kept])); } catch (_) {}
}
+ return kept;
})();
// Each subscription followed: `status` (`connecting`, `live`, `unreachable`), `why`
// not, its latest `report` and when it came (`at`), and how to stop it (`stop`).
@@ -1635,6 +1658,31 @@ async function readStore(path, shallow) {
const EMPTY = { map: "{}", list: "[]", text: "\"\"" };
const EMPTY_VALUE = { map: () => ({}), list: () => [], text: () => "" };
+// A red `Delete` that acts on a second click, from half a second to 3 s after the first
+// (`Delete: sure?`): a double-click doesn't delete. `act()`: whether it did; if not, the
+// button is `Delete` again.
+function deleteButton(act) {
+ const button = el("button", { type: "button", class: "delete" }, "Delete");
+ let sure = null, askedAt = 0;
+ button.addEventListener("click", async () => {
+ if (sure === null) {
+ button.textContent = "Delete: sure?";
+ askedAt = performance.now();
+ sure = setTimeout(() => { sure = null; button.textContent = "Delete"; }, 3000);
+ return;
+ }
+ if (performance.now() - askedAt < 500) return;
+ clearTimeout(sure);
+ button.disabled = true;
+ if (!(await act())) {
+ sure = null;
+ button.textContent = "Delete";
+ button.disabled = false;
+ }
+ });
+ return button;
+}
+
// --- Editing: `Edit value` in an entry's menu turns it into a field holding its contents;
// saved with PUT /store/<path> (the whole value, objects too) ---
// A string is edited as text (sent as text/plain), anything else as JSON; "JSON"
@@ -1701,21 +1749,9 @@ function editor(path, value, done, options = {}) {
};
save.addEventListener("click", submit);
cancel.addEventListener("click", () => done(false));
- // Editing what's there: it can be deleted too (DELETE /store/<path>), after a second
- // click to be sure, not too soon after the first: a double-click doesn't delete.
- const remove = options.extra ? null : el("button", { type: "button", class: "delete" },
- "Delete");
- let sure = null, askedAt = 0;
- remove?.addEventListener("click", async () => {
- if (sure === null) {
- remove.textContent = "Delete: sure?";
- askedAt = performance.now();
- sure = setTimeout(() => { sure = null; remove.textContent = "Delete"; }, 3000);
- return;
- }
- if (performance.now() - askedAt < 500) return;
- clearTimeout(sure);
- remove.disabled = save.disabled = true;
+ // Editing what's there: it can be deleted too (DELETE /store/<path>, `deleteButton`).
+ const remove = options.extra ? null : deleteButton(async () => {
+ save.disabled = true;
message.className = "note";
message.textContent = "Deleting…";
try {
@@ -1726,11 +1762,11 @@ function editor(path, value, done, options = {}) {
throw new Error("HTTP " + response.status + (why ? ": " + why : ""));
}
done("deleted", response);
+ return true;
} catch (error) {
- sure = null;
- remove.textContent = "Delete";
- remove.disabled = save.disabled = false;
+ save.disabled = false;
fail("Not deleted: " + error.message);
+ return false;
}
});
// Enter in the short field saves; in the area, Ctrl or ⌘ and Enter does.
@@ -2291,6 +2327,8 @@ function handOff(id, handed) {
pinned: [...pinned],
view: { ...view },
focused: focused === id ? null : focused,
+ // The node this page is of: the next page subscribes to it.
+ from: state ? state.node.id : undefined,
settings,
legend: !document.getElementById("legends").hidden,
figures: [...open],
@@ -2592,8 +2630,10 @@ async function logOut() {
location.assign("../login");
}
}
-// The user's credentials: its keys, added and removed (`/auth/keys`), but the last;
-// under a strict policy, how to sign them anew; where its password is.
+// The user's credentials: its keys (`/auth/keys`): each edited (its name, its line),
+// removed (its last only where it has a password on this node, which it logs in by
+// then), or one added; under a strict policy, how to sign them anew; where its password
+// is. What fails says so where it was asked, which stays as it was, to be set right.
async function credentials() {
const password = el("p", {}, "Your password is in each node's password file " +
"(--htpasswd): its operator changes it there.");
@@ -2607,49 +2647,134 @@ async function credentials() {
return;
}
const keys = Object.entries(answer.keys || {});
- const rows = keys.map(([id, line]) => {
- const remove = el("button", { type: "button" }, "remove");
- if (answer.signed || keys.length < 2) {
- remove.disabled = true;
- remove.title = answer.signed ? "signed: see below" : "your last key: add another first";
- }
- remove.addEventListener("click", () => changeKey(id, null));
- return el("tr", {}, el("td", {}, id), el("td", { class: "key" }, line), el("td", {}, remove));
- });
+ const signed = !!answer.signed;
+ // The columns' widths set once, whatever its rows (one edited spans them all).
+ const table = el("table", { class: "keys" }, el("colgroup", {},
+ el("col", { class: "name" }), el("col", {}), el("col", { class: "actions" })));
+ const row = (id, line) => {
+ const tr = el("tr", {});
+ // Its name, as much of the key as fits, and `edit`, at the right.
+ const shown = () => {
+ const edit = el("button", { type: "button" }, "edit");
+ if (signed) {
+ edit.disabled = true;
+ edit.title = "signed: see below";
+ }
+ edit.addEventListener("click", editing);
+ tr.replaceChildren(el("td", { class: "name", title: id }, id),
+ el("td", { class: "key", title: line }, keyLine(line)),
+ el("td", { class: "actions" }, edit));
+ };
+ // Its name and line in fields, across the row; saved under the name (a new name:
+ // written under it, then the old one removed), or deleted (`deleteButton`; its last
+ // only where the user has a password on this node).
+ const editing = () => {
+ const name = el("input", { size: 12, spellcheck: "false", class: "key-name" });
+ name.value = id;
+ const value = el("input", { spellcheck: "false", class: "key-value" });
+ value.value = line;
+ const save = el("button", { type: "button" }, "save");
+ const cancel = el("button", { type: "button" }, "cancel");
+ const failed = el("span", { class: "error" });
+ const remove = deleteButton(async () => {
+ save.disabled = true;
+ const why = await keyRequest(id, null);
+ if (why === null) { credentials(); return true; }
+ save.disabled = false;
+ failed.textContent = "Not deleted: " + why;
+ return false;
+ });
+ if (keys.length < 2 && !answer.password) {
+ remove.disabled = true;
+ remove.title = "your last key, and you have no password on this node: add another first";
+ }
+ const submit = async () => {
+ const to = name.value.trim(), text = value.value.trim();
+ if (to === "" || text === "") { failed.textContent = "A name and a key, please."; return; }
+ if (to !== id && keys.some(([other]) => other === to)) {
+ failed.textContent = "\"" + to + "\" is a key of yours already."; return;
+ }
+ save.disabled = true;
+ failed.textContent = "";
+ let why = await keyRequest(to, text);
+ if (why === null && to !== id) {
+ const old = await keyRequest(id, null);
+ if (old !== null) why = "saved as \"" + to + "\", but \"" + id + "\" not removed: " + old;
+ }
+ if (why === null) credentials();
+ else { save.disabled = false; failed.textContent = "Not saved: " + why; }
+ };
+ save.addEventListener("click", submit);
+ cancel.addEventListener("click", shown);
+ for (const field of [name, value]) {
+ field.addEventListener("keydown", event => {
+ if (event.key === "Enter") { event.preventDefault(); submit(); }
+ else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); shown(); }
+ });
+ }
+ tr.replaceChildren(el("td", { colspan: "3" },
+ el("div", { class: "key-edit" }, name, value),
+ el("div", { class: "key-edit" }, save, cancel, remove, failed)));
+ value.focus();
+ };
+ shown();
+ return tr;
+ };
+ table.append(...keys.map(([id, line]) => row(id, line)));
const parts = [password, el("h3", {}, "Your keys"),
- keys.length ? el("table", {}, ...rows) : el("p", { class: "note" }, "none")];
- if (answer.signed) {
+ keys.length ? table : el("p", { class: "note" }, "none")];
+ if (signed) {
parts.push(el("p", {}, "This swarm has its users sign their entries: change your " +
"keys by signing yours anew, with a key you hold: continuum-signed key-add NAME " +
"FILE.pub, continuum-signed key-remove NAME."));
} else {
+ const failed = el("p", { class: "error" });
const form = el("form", {},
el("input", { name: "id", placeholder: "name (laptop)", required: "", size: 12 }),
el("input", { name: "line", placeholder: "ssh-ed25519 AAAA… (its .pub)", required: "",
spellcheck: "false" }),
el("button", {}, "add"));
- form.addEventListener("submit", event => {
+ form.addEventListener("submit", async event => {
event.preventDefault();
- changeKey(form.elements.id.value.trim(), form.elements.line.value.trim());
+ const id = form.elements.id.value.trim(), line = form.elements.line.value.trim();
+ if (keys.some(([other]) => other === id)) {
+ failed.textContent = "\"" + id + "\" is a key of yours already: edit it instead.";
+ return;
+ }
+ failed.textContent = "";
+ const why = await keyRequest(id, line);
+ if (why === null) credentials();
+ else failed.textContent = "Not added: " + why;
});
- parts.push(form);
+ parts.push(form, failed);
}
say("Credentials", ...parts);
}
-// Key `id` of the user's set to `line`, or removed (`line` null); the dialog anew.
-async function changeKey(id, line) {
+// A key as much of it as fits: its type and what tells it apart (an Ed25519 key's
+// base64 after the part every one of them shares), cut in the middle where it doesn't
+// fit, its last eight characters and its comment always shown.
+const ED25519_COMMON = "AAAAC3NzaC1lZDI1NTE5AAAAI";
+function keyLine(line) {
+ const [type = "", data = "", ...comment] = line.trim().split(/\s+/);
+ const own = data.startsWith(ED25519_COMMON) ? data.slice(ED25519_COMMON.length) : data;
+ const cut = Math.max(0, own.length - 8);
+ return el("span", { class: "key-line" },
+ el("span", { class: "start" }, type + " " + own.slice(0, cut)),
+ el("span", { class: "end" }, own.slice(cut) + (comment.length ? " " + comment.join(" ") : "")));
+}
+// Key `id` of the user's set to `line`, or removed (`line` null): null if done, else why
+// not.
+async function keyRequest(id, line) {
const init = line === null ? { method: "DELETE" }
: { method: "PUT", headers: { "Content-Type": "text/plain" }, body: line };
- const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init))
- .catch(error => ({ ok: false, statusText: error.message, json: async () => ({}) }));
- if (!response.ok) {
+ try {
+ const response = await fetch("../auth/keys/" + encodeURIComponent(id), withToken(init));
+ if (response.ok) return null;
const answer = await response.json().catch(() => ({}));
- say("Credentials", el("p", { class: "error" },
- (line === null ? "Couldn't remove " : "Couldn't add ") + id + ": " +
- (answer.error || response.status + " " + response.statusText)));
- return;
+ return answer.error || response.status + " " + response.statusText;
+ } catch (error) {
+ return error.message;
}
- credentials();
}
async function obtainTokens() {
clearTimeout(tokenTimer);
diff --git a/src/api/mod.rs b/src/api/mod.rs
index 0dde808..5c7c8e7 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -114,7 +114,12 @@ pub(crate) fn router(state: AppState, auth_state: AuthState, debug_api: bool) ->
state.clone(),
access::authorize,
))
- .layer(middleware::from_fn_with_state(auth_state, basic_auth))
+ .layer(middleware::from_fn_with_state(
+ auth_state.clone(),
+ basic_auth,
+ ))
+ // Who has a password here (`/auth/keys`: a user's last key goes only then).
+ .layer(axum::Extension(auth_state))
// Outside authentication: a token, if the request brings one, names the user.
.layer(middleware::from_fn_with_state(state.clone(), login::bearer))
// Outermost: pages of other origins, and their preflights, which bring no
diff --git a/src/api/users.rs b/src/api/users.rs
index 1dda33b..d6950af 100644
--- a/src/api/users.rs
+++ b/src/api/users.rs
@@ -24,8 +24,10 @@
//! * `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`).
+//! `DELETE /auth/keys/{id}` removes one, the last only of a user with a password on
+//! this node (`--htpasswd`, `password` in `GET /auth/keys`), which logs in by it. 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
@@ -142,6 +144,7 @@ fn shown(doc: &impl crate::document::ReadDoc, name: &str) -> Option<JsonValue> {
pub(crate) async fn own_keys(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
+ Extension(auth): Extension<super::auth::AuthState>,
) -> Result<Json<JsonValue>, ApiError> {
let replica = state.doc.read().await;
let path = registry::user_path(&user.0);
@@ -157,6 +160,7 @@ pub(crate) async fn own_keys(
"keys": entry.get("keys").cloned().unwrap_or_else(|| json!({})),
"entry": entry,
"signed": signed,
+ "password": auth.has_password(&user.0),
})))
}
@@ -168,24 +172,28 @@ pub(crate) async fn put_own_key(
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
+ own_key(&state, &user.0, id, Some(key.line()), false).await
}
/// `DELETE /auth/keys/{id}`.
pub(crate) async fn delete_own_key(
State(state): State<AppState>,
Extension(user): Extension<AuthenticatedUser>,
+ Extension(auth): Extension<super::auth::AuthState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
- own_key(&state, &user.0, id, None).await
+ let password = auth.has_password(&user.0);
+ own_key(&state, &user.0, id, None, password).await
}
-/// `user`'s key `id` set to `line`, or removed: one change, as `user`.
+/// `user`'s key `id` set to `line`, or removed: one change, as `user`. Its last key only
+/// with `password` (it has one here, and logs in by it).
async fn own_key(
state: &AppState,
user: &str,
id: String,
line: Option<String>,
+ password: bool,
) -> 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 . _ @ -"));
@@ -216,7 +224,7 @@ async fn own_key(
Some("no entry of yours".to_owned()),
));
}
- if line.is_none() {
+ if line.is_none() && !password {
let mut keys = entry.clone();
keys.push("keys".to_owned());
let others = store::keys_at(&*doc, &keys)?
@@ -227,7 +235,11 @@ async fn own_key(
if others == 0 {
return Err(ApiError(
StatusCode::CONFLICT,
- Some("your last key: add another first".to_owned()),
+ Some(
+ "your last key, and you have no password on this node: add \
+ another first"
+ .to_owned(),
+ ),
));
}
}