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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8091-you-rename-an-ssh-key-from-the-terminal → main 5 commits fork 2 threads · 2 unresolved
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 | ||
| 195 | 195 | once signed in on the web; after that the terminal signs them straight in as |
| 196 | 196 | that account, as for anyone with a key. |
| 197 | 197 | |
| 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. | |
| 199 | 199 | |
| 200 | 200 | 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`). |
| 201 | 201 |
src/actions/users.ts +21
| @@ -11,7 +11,9 @@ import { addPasskey, findPasskey } from "../core/passkeys.ts"; | ||
| 11 | 11 | import { |
| 12 | 12 | addKey, |
| 13 | 13 | deleteKey, |
| 14 | keyName, | |
| 14 | 15 | parseAuthorizedKey, |
| 16 | renameKey, | |
| 15 | 17 | type UserKey, |
| 16 | 18 | } from "../core/keys.ts"; |
| 17 | 19 | import { turnstileVerifier } from "../core/turnstile.ts"; |
| @@ -446,6 +448,25 @@ export function deleteSshKey( | ||
| 446 | 448 | return ok(); |
| 447 | 449 | } |
| 448 | 450 | |
| 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 | ||
| 449 | 470 | /** |
| 450 | 471 | * Record an interface preference (the terminal's theme, today). Authorized |
| 451 | 472 | * 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([ | ||
| 132 | 132 | "user.updated", |
| 133 | 133 | "user.key_added", |
| 134 | 134 | "user.key_removed", |
| 135 | "user.key_renamed", | |
| 135 | 136 | "issue.unblocked", |
| 136 | 137 | "issue.confirmed", |
| 137 | 138 | "capacity.changed", |
src/core/keys.ts +39 −1
| @@ -177,7 +177,7 @@ export function addKey( | ||
| 177 | 177 | ).run( |
| 178 | 178 | keyId, |
| 179 | 179 | userId, |
| 180 | name.trim().slice(0, 100) || parsed.algo, | |
| 180 | keyName(fitKeyName(name)) ?? parsed.algo, | |
| 181 | 181 | parsed.algo, |
| 182 | 182 | Buffer.from(parsed.blob).toString("base64"), |
| 183 | 183 | fp, |
| @@ -213,6 +213,44 @@ export function deleteKey( | ||
| 213 | 213 | return row ? toKey(row) : undefined; |
| 214 | 214 | } |
| 215 | 215 | |
| 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 { | |
| 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 | ||
| 216 | 254 | export interface KeyMatch { |
| 217 | 255 | readonly userId: string; |
| 218 | 256 | readonly keyId: string; |
src/tui/screens/sshkeys.ts +28 −6
| @@ -1,12 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { addSshKey, deleteSshKey } from "../../actions/users.ts"; | |
| 9 | import { addSshKey, deleteSshKey, renameSshKey } from "../../actions/users.ts"; | |
| 10 | 10 | import { listKeys } from "../../core/keys.ts"; |
| 11 | 11 | import { keyWords } from "../../http/mcp/shared.ts"; |
| 12 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -31,7 +31,7 @@ export function sshKeysScreen(f: Session, ctx: Ctx): Screen { | ||
| 31 | 31 | |
| 32 | 32 | const screen: Screen = { |
| 33 | 33 | 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")], | |
| 35 | 35 | render(area, s) { |
| 36 | 36 | const c = ctx.theme.c; |
| 37 | 37 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| @@ -65,6 +65,28 @@ export function sshKeysScreen(f: Session, ctx: Ctx): Screen { | ||
| 65 | 65 | ); |
| 66 | 66 | return true; |
| 67 | 67 | } |
| 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 | } | |
| 68 | 90 | if (e.key === "a") { |
| 69 | 91 | ctx.push( |
| 70 | 92 | 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", "rtloverride"]) { | |
| 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", () => { | ||
| 42 | 42 | |
| 43 | 43 | function boot(user: User) { |
| 44 | 44 | // Wide enough that the About card's hint line reaches `e egress`. |
| 45 | const terminal = headlessTerminal(180, 32); | |
| 45 | const terminal = headlessTerminal(200, 32); | |
| 46 | 46 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; |
| 47 | 47 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); |
| 48 | 48 | app.start(); |
test/tui-sshkeys.test.ts +26
| @@ -155,6 +155,32 @@ describe("SSH keys in the terminal", () => { | ||
| 155 | 155 | h.stop(); |
| 156 | 156 | }); |
| 157 | 157 | |
| 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 | ||
| 158 | 184 | test("d removes the selected key only after a confirm", async () => { |
| 159 | 185 | const h = boot("alice"); |
| 160 | 186 | await h.open(); |
new above
offline
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.