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

You change your password from the terminal's Sessions screen with `p`: current and new asked, never echoed, through the action Settings uses #8196

Merged opened this

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

Merged

mason/forge:mason/8127-you-change-your-password-from-the-termin → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/8127-you-change-your-password-from-the-termin was merged into main as de21085.

Review

Sign in to review this change.
6 files changed +334 −27
README.md +1 −1
@@ -543,7 +543,7 @@ This page is the front door. Everything else is a file of its own under
543543
544544 What the crew's tokens bought is on the board (`/board/returns`), in MCP `returns`, and in the terminal: the palette's Usage carries a Returns line (dollars, runs, changes landed, cost per landed change; `[`/`]` move between 1, 7, 30 and 90 days), and Usage of a repository (`owner/name`) narrows it to one app. On the forge-wide Usage, `f` opens the fleet graph (what `/fleet/graph` shows): one sparkline row per repository for runs and for spend, each with its total at the right, over 24 h by hour or 14 d by day (`w` toggles); past eight series the smallest share "N others", and a short terminal ends with a "+N more rows" line. Who may see spend is the action's rule (`src/actions/fleet-graph.ts`), not the screen's. Owner facts only (`src/actions/returns.ts`); see [`docs/OPERATING.md`](docs/OPERATING.md).
545545
546 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in").
546 The terminal's palette has `Signed in` for anyone signed in: your browsers, tokens and this terminal's key, with when each was last seen; `d` signs the selected browser out after a confirm, as `/settings` and MCP `sessions` do, and on a token revokes it after a confirm (Keep is the default) through the same `deleteToken` action as Settings and the API, so it stops working at once (`docs/CREW.md`, "Where you are signed in"). `p` there changes your password: it asks the current one, the new one and the new one again, each masked and never echoed, and changes it through `changePassword`, the action behind Settings, so a wrong current password, a throttled account, a new one under 10 characters or over the cap is refused in Settings' words (the length is refused at the new-password prompt, before the confirm); an agent signs in with a key and is not offered `p`.
547547
548548 The release keeper (`src/keeper/`, #7557) cuts, promotes and verifies releases on the forge's own one-minute timer, with no model. It is off until `FORGE_RELEASE_KEEPER=on`, and the pod's service account may `get` and `patch` the forge Deployment.
549549
src/actions/users.ts +16 −1
@@ -3,7 +3,7 @@
33 * an account authenticates with over the SSH listener.
44 */
55
6 import { withinPasswordCap } from "../core/hash-gate.ts";
6 import { PASSWORD_TOO_LONG, withinPasswordCap } from "../core/hash-gate.ts";
77 import { emailChanged, welcome } from "./verify.ts";
88 import type { App } from "../app.ts";
99 import type { Credential } from "../auth/webauthn.ts";
@@ -362,6 +362,21 @@ export type ChangePasswordError =
362362 | "weak_password"
363363 | "password_too_long";
364364
365 /**
366 * What a refused password change reads as, one sentence each: Settings ›
367 * Password and the terminal's Sessions screen say the same words.
368 */
369 export const CHANGE_PASSWORD_WORDS: Record<ChangePasswordError, string> = {
370 unauthorized: "Something went wrong.",
371 bad_credentials: "That isn't your current password.",
372 throttled: "Too many attempts. Try again in a minute.",
373 weak_password: "Passwords need at least 10 characters.",
374 password_too_long: PASSWORD_TOO_LONG,
375 };
376
377 /** What a changed password reads as, on every surface. */
378 export const PASSWORD_CHANGED_WORDS = "Password changed. Existing sessions stay signed in.";
379
365380 /** Replace the password; the current one must be presented and verified. */
366381 export async function changePassword(
367382 app: App,
src/core/users.ts +1 −1
@@ -91,7 +91,7 @@ export const hiddenFrom = (user: User, viewer: User | undefined): boolean =>
9191
9292 // The minimum counts characters, the cap (`hash-gate.ts`) counts bytes: the
9393 // hash reads bytes, and a person never meets the difference at these sizes.
94 const MIN_PASSWORD = 10;
94 export const MIN_PASSWORD = 10;
9595 const MAX_EMAIL = 254;
9696 const MAX_DISPLAY_NAME = 80;
9797
src/tui/screens/signins.ts +77 −3
@@ -11,14 +11,28 @@
1111 * a scope, and for admin a second explicit `y`. The secret is shown once on a
1212 * screen of its own and forgotten when that screen is left. Only a person
1313 * minting from a terminal session may: an agent is not offered `m`.
14 * `p` changes your password through the action `changePassword` (what
15 * Settings › Password and the API use, so the sign-in throttle applies and
16 * `user.updated` is emitted): the current one, the new one and the new one
17 * again, each in a masked prompt. The typed passwords live only in the
18 * prompts' closures for the call; refusals read in Settings' words. An agent
19 * is not offered `p`, as it is not offered `m`.
1420 * This terminal is held by its key, not a browser session, and cannot be
1521 * ended from here.
1622 */
1723
1824 import { accountSignIns, endAccountSession } from "../../actions/signins.ts";
19 import { createToken, deleteToken } from "../../actions/users.ts";
25 import {
26 CHANGE_PASSWORD_WORDS,
27 PASSWORD_CHANGED_WORDS,
28 changePassword,
29 createToken,
30 deleteToken,
31 } from "../../actions/users.ts";
32 import { withinPasswordCap } from "../../core/hash-gate.ts";
2033 import { listKeys } from "../../core/keys.ts";
2134 import { ADMIN_TOKEN_WORDS, type TokenScope } from "../../core/tokens.ts";
35 import { MIN_PASSWORD } from "../../core/users.ts";
2236 import type { Ctx, Screen } from "../app/app.ts";
2337 import { binding } from "../app/keys.ts";
2438 import { style } from "../term/style.ts";
@@ -222,9 +236,63 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
222236 ctx.push(askName(user.isAdmin));
223237 };
224238
239 /** Calls the action; the passwords are arguments only, never kept here. */
240 const change = async (current: string, next: string): Promise<void> => {
241 const changed = await changePassword(f.app, f.user, { current, next, source: f.identity.remoteAddress });
242 note = changed.ok ? PASSWORD_CHANGED_WORDS : CHANGE_PASSWORD_WORDS[changed.error];
243 ctx.invalidate();
244 };
245
246 const askAgain = (current: string, next: string) =>
247 later(ctx, () =>
248 promptScreen(ctx, screen, {
249 title: "Change your password",
250 label: "New password, again",
251 mask: true,
252 onSubmit: (again) => (again === next ? change(current, next) : "The two new passwords don't match."),
253 }),
254 );
255
256 const askNext = (current: string) =>
257 later(ctx, () =>
258 promptScreen(ctx, screen, {
outdated

A new password shorter than 10 characters is accepted here and only refused after the confirm prompt. Refuse it here with CHANGE_PASSWORD_WORDS.weak_password.

259 title: "Change your password",
260 label: "New password",
261 mask: true,
262 // Refused here, in Settings' words, so a weak one is not typed twice;
263 // the action checks again.
264 onSubmit: (next) => {
265 if (next === "") return "Type a new password.";
266 if (next.length < MIN_PASSWORD) return CHANGE_PASSWORD_WORDS.weak_password;
267 if (!withinPasswordCap(next)) return CHANGE_PASSWORD_WORDS.password_too_long;
268 return askAgain(current, next);
269 },
270 }),
271 );
272
273 const askCurrent = () =>
274 promptScreen(ctx, screen, {
275 title: "Change your password",
276 label: "Current password",
277 mask: true,
278 onSubmit: (current) => (current === "" ? "Type your current password." : askNext(current)),
279 });
280
281 const rotate = (): void => {
282 const user = f.user;
283 // An agent signs in with a key, not a password; a guest has no account.
284 if (user === undefined || user.kind === "agent") return;
285 ctx.push(askCurrent());
286 };
287
225288 const screen: Screen = {
226289 title: "Signed in",
227 keys: [binding(["j", "k"], "move cursor"), binding(["d"], "sign a browser out or revoke a token"), binding(["m"], "make a token")],
290 keys: [
291 binding(["j", "k"], "move cursor"),
292 binding(["d"], "sign a browser out or revoke a token"),
293 binding(["m"], "make a token"),
294 binding(["p"], "change your password"),
295 ],
228296 render(area, s) {
229297 const c = ctx.theme.c;
230298 const all = signInsOf(f);
@@ -234,7 +302,9 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
234302 ? note
235303 : f.user === undefined
236304 ? "Sign in to see where you are signed in."
237 : "Where you are signed in. d signs a browser out or revokes a token, m makes a token.";
305 : f.user.kind === "agent"
306 ? "Where you are signed in. d signs a browser out or revokes a token."
307 : "Where you are signed in. d signs a browser out or revokes a token, m makes a token, p changes your password.";
238308 s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
239309 drawList(s, body!, c, state, rows(all));
240310 },
@@ -247,6 +317,10 @@ export function signInsScreen(f: Session, ctx: Ctx): Screen {
247317 make();
248318 return true;
249319 }
320 if (e.key === "p") {
321 rotate();
322 return true;
323 }
250324 if (e.key !== "d") return false;
251325 const picked = all[state.index];
252326 if (picked === undefined) return false;
src/web/pages/account.ts +7 −21
@@ -9,16 +9,17 @@
99 * has been read once.
1010 */
1111
12 import { PASSWORD_TOO_LONG } from "../../core/hash-gate.ts";
1312 import { resendVerification } from "../../actions/verify.ts";
1413 import { clientAddress } from "../../http/peer.ts";
1514 import { emailVerified } from "../../core/verify.ts";
1615 import {
1716 addSshKey,
17 CHANGE_PASSWORD_WORDS,
1818 changePassword,
1919 createToken,
2020 deleteSshKey,
2121 deleteToken,
22 PASSWORD_CHANGED_WORDS,
2223 updateProfile,
2324 } from "../../actions/users.ts";
2425 import {
@@ -509,29 +510,14 @@ export async function passwordUpdate(ctx: Ctx): Promise<Response> {
509510 source: clientAddress(ctx.request),
510511 });
511512 if (!changed.ok) {
512 const error =
513 changed.error === "bad_credentials"
514 ? {
515 field: "current" as const,
516 text: "That isn't your current password.",
517 }
518 : changed.error === "throttled"
519 ? {
520 field: "current" as const,
521 text: "Too many attempts. Try again in a minute.",
522 }
523 : changed.error === "password_too_long"
524 ? { field: "next" as const, text: PASSWORD_TOO_LONG }
525 : changed.error === "weak_password"
526 ? {
527 field: "next" as const,
528 text: "Passwords need at least 10 characters.",
529 }
530 : { field: "current" as const, text: "Something went wrong." };
513 const error = {
514 field: changed.error === "weak_password" || changed.error === "password_too_long" ? ("next" as const) : ("current" as const),
515 text: CHANGE_PASSWORD_WORDS[changed.error],
516 };
531517 return accountShell(ctx, "password", "Password", passwordView(error), 422);
532518 }
533519 return flashTo(ctx, "/settings/password", {
534 message: "Password changed. Existing sessions stay signed in.",
520 message: PASSWORD_CHANGED_WORDS,
535521 });
536522 }
537523
test/tui-signins-password.test.ts added +232
@@ -0,0 +1,232 @@
1 /**
2 * Changing your password from the terminal (#8127): `p` on the Sessions
3 * screen asks the current password, the new one and the new one again, each
4 * masked, and changes it through the action Settings uses — after which the
5 * new password signs in and the old one does not. A wrong current password
6 * reads in Settings' words and changes nothing; a new password under 10
7 * characters or over the cap is refused at its own prompt, and a throttled
8 * account reads as Settings does; the typed text never appears in any frame a
9 * run painted; an agent is not offered `p`.
10 */
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12 import { mkdtemp, rm } from "node:fs/promises";
13 import { tmpdir } from "node:os";
14 import { join } from "node:path";
15 import { CHANGE_PASSWORD_WORDS, PASSWORD_CHANGED_WORDS } from "../src/actions/users.ts";
16 import { createApp, type App as Forge } from "../src/app.ts";
17 import { MAX_PASSWORD } from "../src/core/hash-gate.ts";
18 import { admitSignin } from "../src/core/signin-window.ts";
19 import { createUser, getUserByName } from "../src/core/users.ts";
20 import { createForgeApp } from "../src/tui/forge.ts";
21 import { signInsScreen } from "../src/tui/screens/signins.ts";
22 import { Session } from "../src/tui/session.ts";
23 import { headlessTerminal } from "../src/tui/term/terminal.ts";
24 import { quietLogs } from "./helpers/globals.ts";
25 import { settleApp } from "./helpers/tui.ts";
26
27 quietLogs();
28
29 const OLD = "a-good-passphrase";
30 const NEW = "zebra-quokka-lantern-91";
31 const WRONG = "not-my-password-x7";
32
33 describe("changing your password from the terminal", () => {
34 let dataDir: string;
35 let forge: Forge;
36
37 function boot(username: string) {
38 // Every frame this run painted, so a leak in any of them is caught.
39 const frames: string[] = [];
40 const terminal = headlessTerminal(140, 32);
41 const user = getUserByName(forge.db, username)!;
42 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
43 app.start();
44 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
45 const text = () => {
46 const t = app.screen.toText();
47 frames.push(t);
48 return t;
49 };
50 return {
51 frames,
52 text,
53 press: async (...keys: string[]) => {
54 keys.forEach((k) => key(k));
55 await settleApp(app);
56 text();
57 },
58 type: async (word: string) => {
59 for (const ch of word) {
60 key(ch);
61 await settleApp(app);
62 text();
63 }
64 },
65 open: async () => {
66 key("k", true);
67 await settleApp(app);
68 for (const ch of "Signed in") key(ch);
69 await settleApp(app);
70 key("enter");
71 await settleApp(app);
72 text();
73 },
74 stop: () => app.stop(),
75 };
76 }
77
78 /** No typed password, nor a long enough piece of one, in any frame. */
79 const noLeak = (frames: readonly string[], ...typed: string[]) => {
80 expect(frames.length).toBeGreaterThan(5);
81 for (const frame of frames)
82 for (const word of typed) {
83 expect(frame).not.toContain(word);
84 expect(frame).not.toContain(word.slice(0, 8));
85 }
86 };
87
88 const signsIn = async (username: string, password: string) =>
89 (await new Session(forge, { sshUsername: "t", remoteAddress: "10.0.0.9" }).signIn(username, password)).ok;
90
91 beforeAll(async () => {
92 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-signins-password-"));
93 forge = createApp({ dataDir, port: 0 });
94 const made = await createUser(forge.db, {
95 origin: "seed",
96 trusted: true,
97 username: "alice",
98 email: "[email protected]",
99 password: OLD,
100 adminIfFirst: false,
101 });
102 if (!made.ok) throw new Error(made.error);
103 const bob = await createUser(forge.db, {
104 origin: "seed",
105 trusted: true,
106 username: "bob",
107 email: "[email protected]",
108 password: OLD,
109 adminIfFirst: false,
110 });
111 if (!bob.ok) throw new Error(bob.error);
112 });
113 afterAll(async () => {
114 forge.close();
115 await rm(dataDir, { recursive: true, force: true });
116 });
117
118 test("a wrong current password is refused in Settings' words, and the password is unchanged", async () => {
119 const h = boot("alice");
120 await h.open();
121 expect(h.text()).toContain("p changes your password");
122 await h.press("p");
123 expect(h.text()).toContain("Current password");
124 await h.type(WRONG);
125 expect(h.text()).toContain("•".repeat(WRONG.length));
126 await h.press("enter");
127 await h.type(NEW);
128 await h.press("enter");
129 await h.type(NEW);
130 await h.press("enter");
131 expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.bad_credentials);
132 h.stop();
133 noLeak(h.frames, WRONG, NEW);
134 expect(await signsIn("alice", OLD)).toBe(true);
135 expect(await signsIn("alice", NEW)).toBe(false);
136 });
137
138 test("new passwords that differ keep the prompt open and change nothing", async () => {
139 const h = boot("alice");
140 await h.open();
141 await h.press("p");
142 await h.type(OLD);
143 await h.press("enter");
144 await h.type(NEW);
145 await h.press("enter");
146 await h.type(`${NEW}-typo`);
147 await h.press("enter");
148 expect(h.text()).toContain("The two new passwords don't match.");
149 await h.press("esc");
150 h.stop();
151 noLeak(h.frames, OLD, NEW);
outdated

This test relies on frames pushed by earlier tests in the file, so it fails or passes on order. Drive its own run, or fold the frame assertion into each test. Also add a weak-password case from the terminal.

152 expect(await signsIn("alice", OLD)).toBe(true);
153 });
154
155 test("current, new and new again change it: the new one signs in, the old one does not", async () => {
156 const h = boot("alice");
157 await h.open();
158 await h.press("p");
159 await h.type(OLD);
160 await h.press("enter");
161 expect(h.text()).toContain("New password");
162 await h.type(NEW);
163 await h.press("enter");
164 expect(h.text()).toContain("New password, again");
165 await h.type(NEW);
166 await h.press("enter");
167 expect(h.text()).toContain(PASSWORD_CHANGED_WORDS);
168 h.stop();
169 noLeak(h.frames, OLD, NEW);
170 expect(await signsIn("alice", NEW)).toBe(true);
171 expect(await signsIn("alice", OLD)).toBe(false);
172 });
173
174 test("a new password under 10 characters is refused at its own prompt, and nothing changes", async () => {
175 const h = boot("bob");
176 await h.open();
177 await h.press("p");
178 await h.type(OLD);
179 await h.press("enter");
180 await h.type("short-pw9");
181 await h.press("enter");
182 expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.weak_password);
183 expect(h.text()).not.toContain("New password, again");
184 await h.press("esc");
185 h.stop();
186 noLeak(h.frames, OLD, "short-pw9");
187 expect(await signsIn("bob", OLD)).toBe(true);
188 });
189
190 test("a new password over the cap is refused at its own prompt", async () => {
191 const h = boot("bob");
192 await h.open();
193 await h.press("p");
194 await h.type(OLD);
195 await h.press("enter");
196 await h.press(..."x".repeat(MAX_PASSWORD + 1));
197 await h.press("enter");
198 expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.password_too_long);
199 expect(h.text()).not.toContain("New password, again");
200 await h.press("esc");
201 h.stop();
202 expect(await signsIn("bob", OLD)).toBe(true);
203 });
204
205 test("a throttled account is refused in Settings' words, and nothing changes", async () => {
206 // Fill bob's window from the terminal's own address, as wrong guesses would.
207 for (let i = 0; i < 1000 && admitSignin(forge.db, "bob", "127.0.0.1").admitted; i++);
208 const h = boot("bob");
209 await h.open();
210 await h.press("p");
211 await h.type(OLD);
212 await h.press("enter");
213 await h.type(NEW);
214 await h.press("enter");
215 await h.type(NEW);
216 await h.press("enter");
217 expect(h.text()).toContain(CHANGE_PASSWORD_WORDS.throttled);
218 h.stop();
219 noLeak(h.frames, OLD, NEW);
220 expect(await signsIn("bob", OLD)).toBe(true);
221 });
222
223 test("an agent is not offered p, and p on its screen pushes nothing", () => {
224 const alice = getUserByName(forge.db, "alice")!;
225 const agent = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user: { ...alice, kind: "agent" } });
226 const pushed: unknown[] = [];
227 const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never;
228 const screen = signInsScreen(agent, ctx);
229 screen.key?.({ type: "key", key: "p", ctrl: false, alt: false, shift: false } as never);
230 expect(pushed).toEqual([]);
231 });
232 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs