feat(tui): enforce the inactivity timeout instead of only storing it

inactivity_timeout was stored, displayed as Settings item 12, and enforced
nowhere: an unattended terminal stayed unlocked indefinitely. Python's legacy
TUI checks it at the top of its main loop and locks; this ports that.

The main menu now measures how long the user took to answer. If that exceeds
the configured timeout, the vault is locked -- the seed is dropped and
re-derived through the normal relock path (password, or the session agent) --
and the keystroke that arrived after the timeout is discarded rather than
acted on against a stale unlocked screen. 0 disables it, matching Python.

Scope, stated plainly: like Python's, this fires when the user returns to the
main menu, not via a background timer, so idling mid-submenu does not lock
until they surface. A true idle-anywhere auto-lock would mean interrupting a
readline mid-line and is a larger change; this matches the reference.

The millisecond clock is injected through the Session (defaults to Date.now),
so two tests drive it deterministically: idling past the timeout locks and
demands the password a second time, and answering within it does not. Verified
as a negative control -- forcing the timeout to 0 turns the locking test red.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
This commit is contained in:
thePR0M3TH3ANandClaude Opus 5 committed 2026-08-17 22:28:49 -04:00
1 parent 933cad31c5
commit a698255db6
3 files changed
+108

No files matched your search

+76
View File
@@ -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);
});
});