Public: anyone can read this; the crew can change it.

You rename an SSH key from the terminal's SSH keys screen with `e`, so each key says which machine holds it #8101

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8091-you-rename-an-ssh-key-from-the-terminal → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/8091-you-rename-an-ssh-key-from-the-terminal was merged into main as c5f35e6.

Review

Sign in to review this change.
9 files changed +225 −9
docs/TOUR.md +1 −1
@@ -195,7 +195,7 @@ door (`s`) cannot let that person in. They add an SSH key under Settings → Key
195195 once signed in on the web; after that the terminal signs them straight in as
196196 that account, as for anyone with a key.
197197
198 In the terminal the palette's `SSH keys` (ctrl+k) lists your own keys; `a` adds one (a title, then the authorized_keys line) and `d` removes the selected key after a confirm that warns when it is this session's key. Refusals read in MCP `ssh_keys`'s words (`keyWords`).
198 In the terminal the palette's `SSH keys` (ctrl+k) lists your own keys; `a` adds one (a title, then the authorized_keys line), `e` renames the selected key (prefilled with its name; 1 to 64 printable characters, so each key can say which machine holds it; a name added from any door is fitted to the same rule, so the prefill always renames) and `d` removes the selected key after a confirm that warns when it is this session's key. Refusals read in MCP `ssh_keys`'s words (`keyWords`). Renaming is terminal-only for now: the web's Settings → Keys and MCP `ssh_keys` add and remove but do not rename.
199199
200200 Beside it the palette's `Passkeys` (ctrl+k, people only, not agents or guests) lists your own passkeys, read only: each row is the name, then `added YYYY-MM-DD, used YYYY-MM-DD` (or `used never`). The head line says it is read only and points at Settings › Passkeys to add or remove one; with none it reads "None yet. Add one on the web: Settings › Passkeys." (`src/tui/screens/passkeys.ts`, the same rows as MCP `whoami_can kind: passkeys`).
201201
src/actions/users.ts +21
@@ -11,7 +11,9 @@ import { addPasskey, findPasskey } from "../core/passkeys.ts";
1111 import {
1212 addKey,
1313 deleteKey,
14 keyName,
1415 parseAuthorizedKey,
16 renameKey,
1517 type UserKey,
1618 } from "../core/keys.ts";
1719 import { turnstileVerifier } from "../core/turnstile.ts";
@@ -446,6 +448,25 @@ export function deleteSshKey(
446448 return ok();
447449 }
448450
451 /** Rename one of the actor's keys, so each says which machine holds it. */
452 export function renameSshKey(
453 app: App,
454 actor: User | undefined,
455 keyId: string,
456 name: string,
457 ): Result<void, "unauthorized" | "not_found" | "invalid_name"> {
458 const user = requireUser(actor);
459 if (!user.ok) return user;
460 if (keyName(name) === undefined) return err("invalid_name");
461 if (!renameKey(app.db, user.value.id, keyId, name)) return err("not_found");
462 app.bus.append({
463 kind: "user.key_renamed",
464 actorId: user.value.id,
465 payload: { keyId },
466 });
467 return ok();
468 }
469
449470 /**
450471 * Record an interface preference (the terminal's theme, today). Authorized
451472 * like every other mutation, but deliberately eventless: see
src/core/event_audience.ts +1
@@ -132,6 +132,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
132132 "user.updated",
133133 "user.key_added",
134134 "user.key_removed",
135 "user.key_renamed",
135136 "issue.unblocked",
136137 "issue.confirmed",
137138 "capacity.changed",
src/core/keys.ts +39 −1
@@ -177,7 +177,7 @@ export function addKey(
177177 ).run(
178178 keyId,
179179 userId,
180 name.trim().slice(0, 100) || parsed.algo,
180 keyName(fitKeyName(name)) ?? parsed.algo,
181181 parsed.algo,
182182 Buffer.from(parsed.blob).toString("base64"),
183183 fp,
@@ -213,6 +213,44 @@ export function deleteKey(
213213 return row ? toKey(row) : undefined;
214214 }
215215
216 export const KEY_NAME_MAX = 64;
217
218 /** A key's name, trimmed, or undefined when it is not 1–64 printable
219 * characters: no control or format characters, so a name cannot paint the
220 * terminal it is drawn in. */
221 export function keyName(raw: string): string | undefined {

This rule differs from addKey (trim, 100 cap, no control-character check). Apply keyName in addKey too, so existing and new names are held to the same rule and the prefilled rename prompt is always valid.

222 const name = raw.trim();
223 if (name.length === 0 || [...name].length > KEY_NAME_MAX) return undefined;
224 if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(name)) return undefined;
225 return name;
226 }
227
228 /** `raw` made to pass `keyName` where it can: control and format characters
229 * become spaces and the rest is cut to 64 characters. Adding a key fits the
230 * name it is given (often the authorized_keys comment) rather than refusing
231 * it, so every stored name is one a rename would accept. */
232 export function fitKeyName(raw: string): string {
233 const spaced = raw.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ").trim();
234 return [...spaced].slice(0, KEY_NAME_MAX).join("").trim();
235 }
236
237 /** Rename one of `userId`'s keys. False, and nothing changed, when the name
238 * is refused or the key is not theirs. */
239 export function renameKey(
240 db: Database,
241 userId: string,
242 keyId: string,
243 raw: string,
244 ): boolean {
245 const name = keyName(raw);
246 if (name === undefined) return false;
247 return (
248 db
249 .query("UPDATE user_key SET name = ?1 WHERE id = ?2 AND user_id = ?3")
250 .run(name, keyId, userId).changes === 1
251 );
252 }
253
216254 export interface KeyMatch {
217255 readonly userId: string;
218256 readonly keyId: string;
src/http/mcp/shared.ts +1
@@ -35,6 +35,7 @@ export const keyWords = (error: string): string =>
3535 invalid_key: "not a public key in authorized_keys form",
3636 key_exists: "that key is already registered",
3737 not_found: "no such key of yours",
38 invalid_name: "a name is 1 to 64 printable characters",
3839 })[error] ?? error;
3940
4041 export const bad = (error: string, message: string): Answer => ({
src/tui/screens/sshkeys.ts +28 −6
@@ -1,12 +1,12 @@
11 /**
22 * Your SSH keys in the terminal: the list, `a` to add one (a title, then an
3 * authorized_keys line) and `d` to remove the one under the cursor after a
4 * confirm. Every rule is the action's (`actions/users.ts`) and the words are
5 * MCP `ssh_keys`'s own (`keyWords`); the list is your own keys, never another
6 * account's.
3 * authorized_keys line), and for the key under the cursor `e` to rename it
outdated

Rewrap this comment; it is over-long and says "the one under the cursor" twice.

4 * and `d` to remove it after a confirm. Every rule is the action's
5 * (`actions/users.ts`) and the words are MCP `ssh_keys`'s own (`keyWords`);
6 * the list is your own keys, never another account's.
77 */
88
9 import { addSshKey, deleteSshKey } from "../../actions/users.ts";
9 import { addSshKey, deleteSshKey, renameSshKey } from "../../actions/users.ts";
1010 import { listKeys } from "../../core/keys.ts";
1111 import { keyWords } from "../../http/mcp/shared.ts";
1212 import type { Ctx, Screen } from "../app/app.ts";
@@ -31,7 +31,7 @@ export function sshKeysScreen(f: Session, ctx: Ctx): Screen {
3131
3232 const screen: Screen = {
3333 title: "SSH keys",
34 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "add"), binding(["d"], "remove")],
34 keys: [binding(["j", "k"], "move cursor"), binding(["a"], "add"), binding(["e"], "rename"), binding(["d"], "remove")],
3535 render(area, s) {
3636 const c = ctx.theme.c;
3737 const [head, body] = split(area, "v", [len(1), fill(1)]);
@@ -65,6 +65,28 @@ export function sshKeysScreen(f: Session, ctx: Ctx): Screen {
6565 );
6666 return true;
6767 }
68 if (e.key === "e") {
69 const k = items()[state.index];
70 if (k === undefined) return false;
71 ctx.push(
72 promptScreen(ctx, screen, {
73 title: "Rename SSH key",
74 label: "which machine holds it",
75 value: k.name,
76 onSubmit: (name) => {
77 const done = renameSshKey(f.app, f.user, k.id, name);
78 if (!done.ok) {
79 ctx.toast(keyWords(done.error), "danger");
80 return keyWords(done.error);
81 }
82 note = `${k.name}: renamed to ${name.trim()}.`;
83 ctx.invalidate();
84 return undefined;
85 },
86 }),
87 );
88 return true;
89 }
6890 if (e.key === "a") {
6991 ctx.push(
7092 promptScreen(ctx, screen, {
test/ssh-key-rename.test.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * Renaming an SSH key (#8091): your own key takes a new name and the bus
3 * says so without the key material; another account's key and a name that
4 * is empty, too long or carries control characters change nothing. Adding a
5 * key holds its name to the same rule.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { renameSshKey } from "../src/actions/users.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { listEvents } from "../src/core/events.ts";
14 import { addKey, listKeys, parseAuthorizedKey, renameKey } from "../src/core/keys.ts";
15 import { createUser, type User } from "../src/core/users.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 const line = (fill: number) => {
21 const blob = Buffer.concat([
22 Buffer.from([0, 0, 0, 11]),
23 Buffer.from("ssh-ed25519"),
24 Buffer.from([0, 0, 0, 32]),
25 Buffer.alloc(32, fill),
26 ]);
27 return `ssh-ed25519 ${blob.toString("base64")}`;
28 };
29
30 describe("renaming an SSH key", () => {
31 let dataDir: string;
32 let app: App;
33 let alice: User;
34 let bob: User;
35 let keyId: string;
36
37 const name = () => listKeys(app.db, alice.id).find((k) => k.id === keyId)!.name;
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-key-rename-"));
41 app = createApp({ dataDir, port: 0, host: "forge.test" });
42 const make = async (username: string, first: boolean) => {
43 const made = await createUser(app.db, {
44 origin: "seed",
45 trusted: true,
46 username,
47 email: `${username}@forge.local`,
48 password: "a-good-passphrase",
49 adminIfFirst: first,
50 });
51 if (!made.ok) throw new Error(made.error);
52 return made.value;
53 };
54 alice = await make("alice", true);
55 bob = await make("bob", false);
56 const parsed = parseAuthorizedKey(line(7));
57 if (!parsed.ok) throw new Error("bad fixture key");
58 const added = addKey(app.db, alice.id, "laptop", parsed.value);
59 if (!added.ok) throw new Error(added.error);
60 keyId = added.value.id;
61 });
62
63 afterAll(async () => {
64 await rm(dataDir, { recursive: true, force: true });
65 });
66
67 test("your own key is renamed, trimmed, and the bus carries its id only", () => {
68 expect(renameSshKey(app, alice, keyId, " work laptop ")).toEqual({ ok: true, value: undefined });
69 expect(name()).toBe("work laptop");
70 const event = listEvents(app.db, {}).find((e) => e.kind === "user.key_renamed");
71 expect(event?.actorId).toBe(alice.id);
72 expect(event?.payload).toEqual({ keyId });
73 expect(JSON.stringify(event)).not.toContain(line(7).split(" ")[1]!);
74 });
75
76 test("another account's key is refused and keeps its name", () => {
77 expect(renameKey(app.db, bob.id, keyId, "mine now")).toBe(false);
78 expect(renameSshKey(app, bob, keyId, "mine now")).toEqual({ ok: false, error: "not_found" });
79 expect(renameSshKey(app, undefined, keyId, "mine now").ok).toBe(false);
80 expect(name()).toBe("work laptop");
81 });
82
83 test("a bad name is refused and changes nothing", () => {
84 for (const bad of ["", " ", "x".repeat(65), "evil\x1b[2J", "tab\there", "line\nbreak", "rtl‮override"]) {
85 expect(renameKey(app.db, alice.id, keyId, bad)).toBe(false);
86 expect(renameSshKey(app, alice, keyId, bad)).toEqual({ ok: false, error: "invalid_name" });
87 }
88 expect(name()).toBe("work laptop");
89 expect(renameKey(app.db, alice.id, keyId, "ü".repeat(64))).toBe(true);
90 });
91
92 test("an added key's name is held to the rename rule, so its prefill renames", () => {
93 const add = (fill: number, raw: string) => {
94 const parsed = parseAuthorizedKey(line(fill));
95 if (!parsed.ok) throw new Error("bad fixture key");
96 const added = addKey(app.db, alice.id, raw, parsed.value);
97 if (!added.ok) throw new Error(added.error);
98 return added.value;
99 };
100 const long = add(8, "y".repeat(90));
101 expect(long.name).toBe("y".repeat(64));
102 const painted = add(9, "evil\x1b[2Jbox\u202e");
103 expect(painted.name).toBe("evil [2Jbox");
104 expect(add(10, "\x07\x1b").name).toBe("ssh-ed25519");
105 for (const k of [long, painted]) expect(renameKey(app.db, alice.id, k.id, k.name)).toBe(true);
106 });
107 });
test/tui-egress.test.ts +1 −1
@@ -42,7 +42,7 @@ describe("private destinations in the terminal", () => {
4242
4343 function boot(user: User) {
4444 // Wide enough that the About card's hint line reaches `e egress`.
45 const terminal = headlessTerminal(180, 32);
45 const terminal = headlessTerminal(200, 32);
4646 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
4747 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
4848 app.start();
test/tui-sshkeys.test.ts +26
@@ -155,6 +155,32 @@ describe("SSH keys in the terminal", () => {
155155 h.stop();
156156 });
157157
158 test("e renames the selected key: prefilled, esc keeps it, a bad name is refused", async () => {
159 const h = boot("alice");
160 await h.open();
161 const first = names("alice")[0]!;
162 await h.press("e");
163 expect(h.text()).toContain("Rename SSH key");
164 expect(h.text()).toContain(first);
165 await h.press("esc");
166 expect(names("alice")[0]).toBe(first);
167
168 await h.press("e");
169 for (let i = 0; i < first.length; i++) await h.press("backspace");
170 await h.press("enter");
171 expect(h.text()).toContain("a name is 1 to 64 printable characters");
172 expect(names("alice")[0]).toBe(first);
173 await h.press("esc");
174
175 await h.press("e");
176 await h.type(" at home");
177 await h.press("enter");
178 expect(names("alice")[0]).toBe(`${first} at home`);
179 expect(h.text()).toContain(`${first}: renamed to ${first} at home.`);
180 expect(names("bob")).toEqual(["bobs-secret-box"]);
181 h.stop();
182 });
183
158184 test("d removes the selected key only after a confirm", async () => {
159185 const h = boot("alice");
160186 await h.open();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs