mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 17:02:05 +00:00
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:
1 parent
933cad31c5
commit
a698255db6
3 files changed
+108
No files matched your search
@@ -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<number>
|
||||
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`);
|
||||
|
||||
@@ -79,6 +79,8 @@ export interface Session {
|
||||
config: Record<string, unknown>;
|
||||
/** Re-prompt for the master password; used by Lock Vault and profile switch. */
|
||||
relock: (fingerprint: string) => Promise<string>;
|
||||
/** 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<number> {
|
||||
const timeoutMs = inactivityTimeoutMs(s);
|
||||
for (;;) {
|
||||
title(s, "Main Menu");
|
||||
menu(s.ui, [
|
||||
@@ -260,7 +273,23 @@ export async function mainMenu(s: Session): Promise<number> {
|
||||
{ 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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user