diff --git a/js/packages/cli/src/tui/app.ts b/js/packages/cli/src/tui/app.ts index 0f6689da..a47951e7 100644 --- a/js/packages/cli/src/tui/app.ts +++ b/js/packages/cli/src/tui/app.ts @@ -24,6 +24,8 @@ import { mainMenu, type Session } from "./menus.js"; export interface TuiOptions { appDir?: string; fingerprint?: string; + /** Millisecond clock, injectable for tests; defaults to Date.now. */ + clock?: () => number; } function isInteractive(): boolean { @@ -97,6 +99,7 @@ export async function runTui(opts: TuiOptions, injectedUi?: Ui): Promise vault: await openVault(join(app.profileDir(fingerprint), INDEX_FILENAME), mnemonic), config: await loadConfig(app.profileDir(fingerprint), mnemonic), relock, + clock: opts.clock ?? (() => Date.now()), }; } catch (e) { process.stderr.write(`seedpass-js: ${(e as Error).message}\n`); diff --git a/js/packages/cli/src/tui/menus.ts b/js/packages/cli/src/tui/menus.ts index 7c538b69..1174a426 100644 --- a/js/packages/cli/src/tui/menus.ts +++ b/js/packages/cli/src/tui/menus.ts @@ -79,6 +79,8 @@ export interface Session { config: Record; /** Re-prompt for the master password; used by Lock Vault and profile switch. */ relock: (fingerprint: string) => Promise; + /** Milliseconds since epoch. Injectable so inactivity locking is testable. */ + clock: () => number; } // ---------------------------------------------------------------- utilities @@ -247,7 +249,18 @@ async function askInt( // ------------------------------------------------------------- main menu +/** + * Inactivity timeout in milliseconds, from config (seconds). 0 disables it. + * Parity with Python's inactivity_timeout, which the legacy TUI also checks + * at the top of its main loop. + */ +function inactivityTimeoutMs(s: Session): number { + const raw = Number(s.config["inactivity_timeout"] ?? 0); + return Number.isFinite(raw) && raw > 0 ? raw * 1000 : 0; +} + export async function mainMenu(s: Session): Promise { + const timeoutMs = inactivityTimeoutMs(s); for (;;) { title(s, "Main Menu"); menu(s.ui, [ @@ -260,7 +273,23 @@ export async function mainMenu(s: Session): Promise { { key: "7", label: "Settings" }, { key: "8", label: "List Archived" }, ]); + const promptedAt = s.clock(); const choice = await s.ui.ask("Enter your choice (1-8) or press Enter to exit: "); + // If the user was away from the main menu longer than the timeout, lock + // the vault and require the password again before acting on their input. + // The seed is dropped and re-derived; the keystroke that arrived after the + // timeout is discarded rather than obeyed on a stale, unlocked screen. + if (timeoutMs > 0 && s.clock() - promptedAt > timeoutMs) { + warn(s.ui, "Session timed out. Vault locked."); + try { + const mnemonic = await s.relock(s.fingerprint); + s.vault = await openVault(s.vault.path, mnemonic); + } catch (e) { + fail(s.ui, `Vault remains locked: ${(e as Error).message}`); + return 1; + } + continue; + } if (!choice) return 0; try { switch (choice) { diff --git a/js/packages/cli/test/tuiMenus.test.ts b/js/packages/cli/test/tuiMenus.test.ts index 74576d26..60d7691a 100644 --- a/js/packages/cli/test/tuiMenus.test.ts +++ b/js/packages/cli/test/tuiMenus.test.ts @@ -446,3 +446,79 @@ describe("KDF strength setting takes effect", () => { expect(seed.kdf.params.iterations).toBe(250000); }); }); + +import { saveConfig, defaultConfig } from "../src/configFile.js"; + +/** A UI that advances a fake clock by a scripted number of seconds per prompt. */ +function clockedUi(steps: Array<{ ans: string; advance: number }>): { + ui: Ui; + now: () => number; + prompts: string[]; + lines: string[]; +} { + let clock = 0; + let i = 0; + const prompts: string[] = []; + const lines: string[] = []; + const strip = (t: string) => t.replace(/\x1b\[[0-9;]*m/g, ""); // eslint-disable-line no-control-regex + const ui: Ui = { + say(line = "") { + lines.push(strip(line)); + }, + clear() {}, + async ask(p: string) { + prompts.push(strip(p)); + const step = steps[i++] ?? { ans: "", advance: 0 }; + clock += step.advance * 1000; + return step.ans; + }, + async askHidden(p: string) { + return this.ask(p); + }, + }; + return { ui, now: () => clock, prompts, lines }; +} + +describe("inactivity timeout locks the vault", () => { + async function profileWithTimeout(seconds: number): Promise<{ dir: string; app: AppDir; fp: string; pw: string }> { + const dir = await mkdtemp(join(tmpdir(), "seedpass-idle-")); + const app = new AppDir(dir); + const pw = "idle-pw"; + const fp = await app.createProfile(MNEMONIC, pw, "idle"); + await saveConfig(app.profileDir(fp), MNEMONIC, { ...defaultConfig(), inactivity_timeout: seconds }); + return { dir, app, fp, pw }; + } + + it("locks and re-prompts for the password when the user idles past the timeout", async () => { + const { dir, fp, pw } = await profileWithTimeout(5); + delete process.env["SEEDPASS_MNEMONIC"]; // force relock to prompt + + const { ui, now, prompts, lines } = clockedUi([ + { ans: pw, advance: 0 }, // initial unlock prompt at startup + { ans: "4", advance: 10 }, // idle 10s at the main menu (> 5s): must lock + { ans: pw, advance: 0 }, // password at the re-lock prompt + { ans: "", advance: 1 }, // answer promptly this time: exit + ]); + const code = await runTui({ appDir: dir, fingerprint: fp, clock: now }, ui); + + expect(code).toBe(0); + expect(lines.join("\n")).toContain("Session timed out. Vault locked."); + // The password was demanded twice: once at startup, once after the timeout. + expect(prompts.filter((p) => /Master password/.test(p)).length).toBe(2); + }); + + it("does not lock when the user answers within the timeout", async () => { + const { dir, fp, pw } = await profileWithTimeout(5); + delete process.env["SEEDPASS_MNEMONIC"]; + + const { ui, now, prompts, lines } = clockedUi([ + { ans: pw, advance: 0 }, // initial unlock + { ans: "", advance: 2 }, // 2s at the main menu (< 5s): no lock, then exit + ]); + const code = await runTui({ appDir: dir, fingerprint: fp, clock: now }, ui); + + expect(code).toBe(0); + expect(lines.join("\n")).not.toContain("Session timed out"); + expect(prompts.filter((p) => /Master password/.test(p)).length).toBe(1); + }); +});