mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-29 17:02:05 +00:00
The TUI had a QR action that answered "QR rendering is not available in this
build". The stated reason was that a QR library is not worth a dependency in a
process holding unlocked seeds — which is true, and is why this implements the
encoder instead: byte mode, versions 1-40, all four error-correction levels,
zero dependencies.
A wrong QR code is worse than no QR code. It scans cleanly, yields the wrong
secret, and the user finds out when their 2FA stops working — so this is
verified rather than eyeballed:
- The block-structure table was extracted programmatically from qrcode's
RS_BLOCK_TABLE rather than transcribed. 160 rows of three-number triples
are exactly the kind of data a human copies wrong once and never notices.
- qr.json holds reference matrices from that library, and the tests compare
EVERY module — 10 cases spanning all four EC levels and versions 1 to 17,
including past version 7 where version-information blocks start being
written.
- Generation moved into scripts/generate_ts_port_fixtures.py, so the
fixture-drift CI job proves the committed fixture came from the committed
generator, like every other fixture. Regenerating produced byte-identical
output for all pre-existing fixtures.
Two things are pinned so the comparison is between implementations rather than
between configurations. Byte mode is forced, because the reference
auto-selects alphanumeric for an all-uppercase string — a different encoding
of the same text, not a different answer. And the mask is pinned, because the
two disagree about mask SELECTION: the spec scores the finished symbol, while
the reference scores one with its format modules blanked and its dark module
light (makeImpl(test=True, ...)), which is not a legal symbol. All eight masks
produce valid scannable codes, so the selection rule is checked as a property
instead of reproducing a known quirk to make an equality assertion pass. At a
pinned mask the matrices are identical, which is what establishes that the
data encoding, Reed-Solomon, interleaving, placement, format and version
information are all correct.
In the TUI, TOTP entries encode their otpauth:// URI so an authenticator can
consume them; everything else encodes its own value. Secret Mode suppresses
the code entirely — a QR code is the secret in a form a camera reads from
across a room, so the setting that exists to keep secrets off the screen
applies here more than anywhere — and an entry too large to encode says so
rather than looking unavailable.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
696 lines
25 KiB
TypeScript
696 lines
25 KiB
TypeScript
/**
|
|
* The interactive menus, driven by a scripted UI.
|
|
*
|
|
* Two things are being checked. First, that the menu tree matches Python's
|
|
* legacy TUI — the items, their order, and "blank goes back" — since that is
|
|
* the navigation the port exists to reproduce. Second, that the agent-blind
|
|
* rule survives the move to a screen that shows the whole vault: listings
|
|
* carry metadata only, and a stored secret appears solely through the action
|
|
* whose purpose is to produce it.
|
|
*/
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { mkdtemp, writeFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import process from "node:process";
|
|
import { mnemonics } from "@seedpass/test-vectors";
|
|
import {
|
|
generateFingerprint,
|
|
deriveIndexKeyBytes,
|
|
encryptV3,
|
|
utf8,
|
|
addPasswordEntry,
|
|
addKeyValueEntry,
|
|
addTotpDeterministic,
|
|
exportBackup,
|
|
type VaultIndex,
|
|
} from "@seedpass/core";
|
|
import { runTui } from "../src/tui/app.js";
|
|
import { AppDir, INDEX_FILENAME, PARENT_SEED_FILENAME } from "../src/appDir.js";
|
|
import { openVault } from "../src/vaultFile.js";
|
|
import { mutateConfig } from "../src/configFile.js";
|
|
import type { Ui } from "../src/tui/console.js";
|
|
|
|
const MNEMONIC = mnemonics["abandon12"]!;
|
|
const FINGERPRINT = generateFingerprint(MNEMONIC);
|
|
const STORED_SECRET = "stored-ci-token-value";
|
|
|
|
let appDir: string;
|
|
|
|
/**
|
|
* A UI that answers prompts from a script.
|
|
*
|
|
* Running out of answers ends the session rather than hanging: a test that
|
|
* mis-counts its inputs should fail with the transcript in hand, not time out.
|
|
*/
|
|
class ScriptedUi implements Ui {
|
|
readonly lines: string[] = [];
|
|
readonly prompts: string[] = [];
|
|
private i = 0;
|
|
|
|
constructor(private readonly answers: string[]) {}
|
|
|
|
say(line = ""): void {
|
|
this.lines.push(line);
|
|
}
|
|
clear(): void {
|
|
this.lines.push("\f");
|
|
}
|
|
async ask(prompt: string): Promise<string> {
|
|
this.prompts.push(prompt);
|
|
if (this.i >= this.answers.length) {
|
|
// Blank is "go back"/"exit" everywhere, so this unwinds the menus.
|
|
return "";
|
|
}
|
|
const answer = this.answers[this.i++]!;
|
|
this.lines.push(`${prompt}${answer}`);
|
|
return answer;
|
|
}
|
|
async askHidden(prompt: string): Promise<string> {
|
|
const answer = await this.ask(prompt);
|
|
// Record that it was asked, never what was typed.
|
|
this.lines[this.lines.length - 1] = `${prompt}<hidden>`;
|
|
return answer;
|
|
}
|
|
|
|
/** Everything drawn, with styling removed so assertions read plainly. */
|
|
get text(): string {
|
|
// eslint-disable-next-line no-control-regex
|
|
return this.lines.join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
|
}
|
|
/** Everything drawn since the last screen clear. */
|
|
get screen(): string {
|
|
const last = this.lines.lastIndexOf("\f");
|
|
// eslint-disable-next-line no-control-regex
|
|
return this.lines.slice(last + 1).join("\n").replace(/\x1b\[[0-9;]*m/g, "");
|
|
}
|
|
}
|
|
|
|
async function run(...answers: string[]): Promise<ScriptedUi> {
|
|
const ui = new ScriptedUi(answers);
|
|
await runTui({ appDir }, ui);
|
|
return ui;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
appDir = await mkdtemp(join(tmpdir(), "seedpass-menus-"));
|
|
const app = new AppDir(appDir);
|
|
const index = { schema_version: 4, entries: {} } as VaultIndex;
|
|
addPasswordEntry(index, "github.com", 20, { username: "adam" });
|
|
addPasswordEntry(index, "gitlab.com", 16, {});
|
|
addKeyValueEntry(index, "ci-token", "CI_TOKEN", STORED_SECRET);
|
|
addTotpDeterministic(index, "email-2fa", MNEMONIC);
|
|
|
|
await app.mutateFingerprints((data) => {
|
|
data.fingerprints.push(FINGERPRINT);
|
|
data.names[FINGERPRINT] = "daily";
|
|
data.last_used = FINGERPRINT;
|
|
});
|
|
const dir = app.profileDir(FINGERPRINT);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(
|
|
join(dir, INDEX_FILENAME),
|
|
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(index))),
|
|
);
|
|
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete process.env["SEEDPASS_MNEMONIC"];
|
|
});
|
|
|
|
/**
|
|
* Import guard: a plaintext backup carries no cryptographic binding to the
|
|
* seed it was taken from, so importing one into the wrong profile succeeds
|
|
* and then re-derives every secret from the WRONG seed. The TUI has to say so
|
|
* before it happens, because afterwards nothing looks broken.
|
|
*/
|
|
describe("importing a backup from another profile", () => {
|
|
const FOREIGN = mnemonics["legal12"]!;
|
|
|
|
async function writeForeignBackup(): Promise<string> {
|
|
const wrapper = await exportBackup(
|
|
{ schema_version: 4, entries: {} },
|
|
{ mnemonic: FOREIGN, fingerprint: generateFingerprint(FOREIGN), encrypt: false },
|
|
);
|
|
const path = join(appDir, "foreign-backup.json");
|
|
await writeFile(path, JSON.stringify(wrapper));
|
|
return path;
|
|
}
|
|
|
|
it("warns which profile the backup belongs to and abandons on refusal", async () => {
|
|
const path = await writeForeignBackup();
|
|
const ui = await run("7", "8", path, "n", "", "");
|
|
expect(ui.text).toContain(`belongs to profile ${generateFingerprint(FOREIGN)}`);
|
|
expect(ui.text).toContain("DIFFERENT");
|
|
|
|
// The vault is untouched: all four seeded entries still there.
|
|
const vault = await openVault(
|
|
join(new AppDir(appDir).profileDir(FINGERPRINT), INDEX_FILENAME),
|
|
MNEMONIC,
|
|
);
|
|
expect(Object.keys(vault.index.entries)).toHaveLength(4);
|
|
});
|
|
|
|
it("still allows it when the user confirms twice", async () => {
|
|
const path = await writeForeignBackup();
|
|
// "y" to the foreign-profile warning, then "y" to the replace-vault
|
|
// confirmation the import already had.
|
|
const ui = await run("7", "8", path, "y", "y", "", "");
|
|
expect(ui.text).toContain("Imported 0 entries");
|
|
});
|
|
});
|
|
|
|
describe("additional backup location", () => {
|
|
it("actually writes a copy there, having said it would", async () => {
|
|
const extra = await mkdtemp(join(tmpdir(), "seedpass-tui-extra-"));
|
|
// Settings > Set additional backup location > path, then add an entry so
|
|
// there is a mutation to snapshot.
|
|
const ui = await run(
|
|
"7", "10", extra, "", "",
|
|
"1", "1", "backup-me.example", "", "", "", "", "", "", "",
|
|
);
|
|
expect(ui.text).toContain(`Additional backups will be written to ${extra}`);
|
|
|
|
const mirrored = await readdir(extra);
|
|
expect(mirrored).toHaveLength(1);
|
|
expect(mirrored[0]).toMatch(
|
|
new RegExp(`^${FINGERPRINT}_entries_db_backup_\\d+\\.json\\.enc$`),
|
|
);
|
|
});
|
|
|
|
it("tells the user when the configured location stops working", async () => {
|
|
const app = new AppDir(appDir);
|
|
const blocker = join(app.profileDir(FINGERPRINT), "blocker-file");
|
|
await writeFile(blocker, "x");
|
|
const ui = await run(
|
|
"7", "10", join(blocker, "sub"), "", "",
|
|
"1", "1", "still-added.example", "", "", "", "", "", "", "",
|
|
);
|
|
expect(ui.text).toContain("Additional backup location failed");
|
|
|
|
// The mutation itself still committed.
|
|
const vault = await openVault(
|
|
join(app.profileDir(FINGERPRINT), INDEX_FILENAME),
|
|
MNEMONIC,
|
|
);
|
|
const labels = Object.values(vault.index.entries).map(
|
|
(e) => (e as unknown as Record<string, unknown>)["label"],
|
|
);
|
|
expect(labels).toContain("still-added.example");
|
|
});
|
|
});
|
|
|
|
describe("QR display", () => {
|
|
it("renders a scannable code for a TOTP entry, encoding the otpauth URI", async () => {
|
|
// Retrieve > email-2fa > q(QR) > confirm
|
|
const ui = await run("2", "email-2fa", "q", "y", "", "", "");
|
|
// Half-block glyphs are how a square module is drawn in a terminal cell.
|
|
expect(ui.text).toMatch(/[█▄▀]/);
|
|
const qrLines = ui.text.split("\n").filter((l) => /^[█▄▀ ]{20,}$/.test(l));
|
|
expect(qrLines.length).toBeGreaterThan(8);
|
|
});
|
|
|
|
it("refuses to draw a QR code while Secret Mode is on", async () => {
|
|
// A QR code is the secret in a form a camera reads across a room, so the
|
|
// setting whose whole purpose is keeping secrets off the screen has to
|
|
// win here.
|
|
const app = new AppDir(appDir);
|
|
const fp = FINGERPRINT;
|
|
await mutateConfig(app.profileDir(fp), MNEMONIC, (cfg) => {
|
|
cfg["secret_mode_enabled"] = true;
|
|
});
|
|
const ui = await run("2", "email-2fa", "q", "", "", "");
|
|
expect(ui.text).toContain("Secret Mode is on");
|
|
expect(ui.text).not.toMatch(/[█▄▀]{20,}/);
|
|
await mutateConfig(app.profileDir(fp), MNEMONIC, (cfg) => {
|
|
cfg["secret_mode_enabled"] = false;
|
|
});
|
|
});
|
|
|
|
it("draws nothing if the user declines the warning", async () => {
|
|
const ui = await run("2", "email-2fa", "q", "n", "", "", "");
|
|
expect(ui.text).not.toMatch(/[█▄▀]{20,}/);
|
|
});
|
|
});
|
|
|
|
describe("main menu", () => {
|
|
it("offers Python's eight items, in Python's order", async () => {
|
|
const ui = await run();
|
|
for (const [n, label] of [
|
|
["1", "Add Entry"],
|
|
["2", "Retrieve Entry"],
|
|
["3", "Search Entries"],
|
|
["4", "List Entries"],
|
|
["5", "Modify an Existing Entry"],
|
|
["6", "2FA Codes"],
|
|
["7", "Settings"],
|
|
["8", "List Archived"],
|
|
] as const) {
|
|
expect(ui.text).toContain(`${n}.`);
|
|
expect(ui.text).toContain(label);
|
|
}
|
|
});
|
|
|
|
it("exits on a blank choice", async () => {
|
|
const ui = new ScriptedUi([""]);
|
|
expect(await runTui({ appDir }, ui)).toBe(0);
|
|
});
|
|
|
|
it("rejects an unknown choice without leaving the menu", async () => {
|
|
const ui = await run("99", "");
|
|
expect(ui.text).toContain("Invalid choice");
|
|
});
|
|
});
|
|
|
|
describe("settings", () => {
|
|
it("lists all eighteen items in Python's order", async () => {
|
|
const ui = await run("7");
|
|
const expected = [
|
|
"Profiles",
|
|
"Nostr",
|
|
"Change password",
|
|
"Verify Script Checksum",
|
|
"Generate Script Checksum",
|
|
"Backup Parent Seed",
|
|
"Export database",
|
|
"Import database",
|
|
"Export 2FA codes",
|
|
"Set additional backup location",
|
|
"KDF strength & benchmark",
|
|
"Set inactivity timeout",
|
|
"Lock Vault",
|
|
"Stats",
|
|
"Toggle Secret Mode",
|
|
"Toggle Offline Mode",
|
|
"Toggle Quick Unlock",
|
|
"Semantic Index",
|
|
];
|
|
for (const label of expected) expect(ui.text).toContain(label);
|
|
// Numbering must line up with Python's, or muscle memory picks the wrong
|
|
// item — 13 is Lock Vault there and must be here too.
|
|
expect(ui.text).toMatch(/13\.\s+Lock Vault/);
|
|
expect(ui.text).toMatch(/18\.\s+Semantic Index/);
|
|
});
|
|
|
|
it("opens the Profiles submenu with its five items", async () => {
|
|
const ui = await run("7", "1");
|
|
for (const label of [
|
|
"Switch Seed Profile",
|
|
"Add a New Seed Profile",
|
|
"Remove an Existing Seed Profile",
|
|
"List All Seed Profiles",
|
|
"Set Seed Profile Name",
|
|
]) {
|
|
expect(ui.text).toContain(label);
|
|
}
|
|
});
|
|
|
|
it("opens the Nostr submenu with its nine items", async () => {
|
|
const ui = await run("7", "2");
|
|
for (const label of [
|
|
"Backup to Nostr",
|
|
"Restore from Nostr",
|
|
"View current relays",
|
|
"Add a relay URL",
|
|
"Remove a relay by number",
|
|
"Reset to default relays",
|
|
"Display Nostr Public Key",
|
|
"Reset Nostr sync state",
|
|
"Start fresh Nostr namespace",
|
|
]) {
|
|
expect(ui.text).toContain(label);
|
|
}
|
|
});
|
|
|
|
it("toggles Secret Mode and persists it", async () => {
|
|
const ui = await run("7", "15", "", "", "");
|
|
expect(ui.text).toContain("Secret Mode is now ON");
|
|
const { loadConfig } = await import("../src/configFile.js");
|
|
const cfg = await loadConfig(new AppDir(appDir).profileDir(FINGERPRINT), MNEMONIC);
|
|
expect(cfg["secret_mode_enabled"]).toBe(true);
|
|
});
|
|
|
|
it("reports stats without printing any stored secret", async () => {
|
|
const ui = await run("7", "14", "");
|
|
expect(ui.text).toContain("Total entries: 4");
|
|
expect(ui.text).toContain("password: 2");
|
|
expect(ui.text).toContain("key_value: 1");
|
|
expect(ui.text).not.toContain(STORED_SECRET);
|
|
});
|
|
|
|
it("adds and removes a relay", async () => {
|
|
const added = await run("7", "2", "4", "wss://relay.example", "", "", "", "");
|
|
expect(added.text).toContain("Relay added");
|
|
const { loadConfig } = await import("../src/configFile.js");
|
|
const cfg = await loadConfig(new AppDir(appDir).profileDir(FINGERPRINT), MNEMONIC);
|
|
expect(cfg["relays"]).toContain("wss://relay.example");
|
|
});
|
|
|
|
it("refuses a relay URL that is not a websocket URL", async () => {
|
|
const ui = await run("7", "2", "4", "https://not-a-relay.example", "", "", "", "");
|
|
expect(ui.text).toContain("must start with ws:// or wss://");
|
|
});
|
|
});
|
|
|
|
describe("listing and details", () => {
|
|
it("lists entries by index, type and label", async () => {
|
|
const ui = await run("4", "1", "");
|
|
expect(ui.text).toContain("0.");
|
|
expect(ui.text).toContain("Password - github.com");
|
|
expect(ui.text).toContain("Key Value - ci-token");
|
|
// The stored secret must not be in a listing.
|
|
expect(ui.text).not.toContain(STORED_SECRET);
|
|
});
|
|
|
|
it("filters the list by entry type", async () => {
|
|
// 4 = List Entries, 8 = Key Value (1 is All, then the nine types in order).
|
|
const ui = await run("4", "8", "");
|
|
expect(ui.text).toContain("ci-token");
|
|
expect(ui.text).not.toContain("github.com");
|
|
});
|
|
|
|
it("shows entry details with stored secrets reduced to flags", async () => {
|
|
const ui = await run("4", "1", "2", "");
|
|
expect(ui.text).toContain("ci-token");
|
|
expect(ui.text).toContain("has_value");
|
|
expect(ui.text).not.toContain(STORED_SECRET);
|
|
});
|
|
|
|
it("offers the entry actions Python offers", async () => {
|
|
const ui = await run("4", "1", "0", "");
|
|
for (const label of [
|
|
"Archive",
|
|
"Add Note",
|
|
"Add Custom Field",
|
|
"Add Hidden Field",
|
|
"Edit",
|
|
"Edit Tags",
|
|
]) {
|
|
expect(ui.text).toContain(label);
|
|
}
|
|
});
|
|
|
|
it("shows a stored secret only when asked", async () => {
|
|
const before = await run("4", "1", "2", "");
|
|
expect(before.text).not.toContain(STORED_SECRET);
|
|
const after = await run("4", "1", "2", "s", "", "");
|
|
expect(after.text).toContain(STORED_SECRET);
|
|
});
|
|
|
|
it("offers document export only for documents", async () => {
|
|
const ui = await run("4", "1", "0", "");
|
|
expect(ui.text).not.toContain("Export Document to File");
|
|
});
|
|
});
|
|
|
|
describe("search and retrieve", () => {
|
|
it("finds entries by label", async () => {
|
|
const ui = await run("3", "git", "");
|
|
expect(ui.text).toContain("github.com");
|
|
expect(ui.text).toContain("gitlab.com");
|
|
expect(ui.text).not.toContain("ci-token");
|
|
});
|
|
|
|
it("says so when nothing matches", async () => {
|
|
const ui = await run("3", "nothing-matches-this", "");
|
|
expect(ui.text).toContain("No matching entries found");
|
|
});
|
|
|
|
it("retrieves by index and by label", async () => {
|
|
expect((await run("2", "0", "")).text).toContain("github.com");
|
|
expect((await run("2", "ci-token", "")).text).toContain("CI_TOKEN");
|
|
});
|
|
});
|
|
|
|
describe("mutations", () => {
|
|
async function reopen(): Promise<VaultIndex> {
|
|
const vault = await openVault(
|
|
join(new AppDir(appDir).profileDir(FINGERPRINT), INDEX_FILENAME),
|
|
MNEMONIC,
|
|
);
|
|
return vault.index;
|
|
}
|
|
|
|
it("adds a password entry through the menus", async () => {
|
|
// 1 Add Entry, 1 Password, then label/length/username/url/notes/tags.
|
|
await run("1", "1", "new-site.example", "24", "bob", "", "", "work,prod", "", "");
|
|
const index = await reopen();
|
|
const added = Object.values(index.entries).find((e) => e.label === "new-site.example");
|
|
expect(added).toBeTruthy();
|
|
expect(added!["length"]).toBe(24);
|
|
expect(added!["username"]).toBe("bob");
|
|
expect(added!["tags"]).toEqual(["work", "prod"]);
|
|
});
|
|
|
|
it("adds a key/value entry without echoing the value", async () => {
|
|
const ui = await run("1", "7", "deploy", "DEPLOY_TOKEN", "sup3r-s3cret", "", "");
|
|
expect(ui.text).not.toContain("sup3r-s3cret");
|
|
const index = await reopen();
|
|
const added = Object.values(index.entries).find((e) => e.label === "deploy");
|
|
expect(added!["value"]).toBe("sup3r-s3cret");
|
|
});
|
|
|
|
it("refuses a non-numeric password length rather than storing NaN", async () => {
|
|
const ui = await run("1", "1", "bad", "not-a-number", "", "");
|
|
expect(ui.text).toContain("whole number");
|
|
const index = await reopen();
|
|
expect(Object.values(index.entries).find((e) => e.label === "bad")).toBeUndefined();
|
|
});
|
|
|
|
it("archives an entry and lists it under List Archived", async () => {
|
|
await run("4", "1", "0", "a", "", "", "", "");
|
|
const index = await reopen();
|
|
expect(index.entries["0"]!["archived"]).toBe(true);
|
|
const archived = await run("8", "");
|
|
expect(archived.text).toContain("github.com");
|
|
});
|
|
|
|
it("edits a label through Edit", async () => {
|
|
await run("4", "1", "0", "e", "l", "renamed.example", "", "", "", "");
|
|
const index = await reopen();
|
|
expect(index.entries["0"]!.label).toBe("renamed.example");
|
|
});
|
|
|
|
it("adds a note", async () => {
|
|
await run("4", "1", "0", "n", "a useful note", "", "", "", "");
|
|
const index = await reopen();
|
|
expect(index.entries["0"]!["notes"]).toBe("a useful note");
|
|
});
|
|
|
|
it("adds a hidden custom field without echoing its value", async () => {
|
|
const ui = await run("4", "1", "0", "h", "recovery", "hidden-field-value", "", "", "", "");
|
|
expect(ui.text).not.toContain("hidden-field-value");
|
|
const index = await reopen();
|
|
const fields = index.entries["0"]!["custom_fields"] as Record<string, unknown>[];
|
|
expect(fields).toEqual([
|
|
{ label: "recovery", value: "hidden-field-value", is_hidden: true },
|
|
]);
|
|
});
|
|
|
|
it("exports the database to a file", async () => {
|
|
const dest = join(appDir, "backup.json");
|
|
const ui = await run("7", "7", dest, "", "", "");
|
|
expect(ui.text).toContain("Exported to");
|
|
const wrapper = JSON.parse(await readFile(dest, "utf8"));
|
|
expect(wrapper.format_version).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("2FA codes", () => {
|
|
it("shows a code for each TOTP entry", async () => {
|
|
const ui = await run("6", "", "");
|
|
expect(ui.text).toContain("email-2fa");
|
|
expect(ui.text).toMatch(/\d{6}/);
|
|
expect(ui.text).toContain("s left");
|
|
});
|
|
});
|
|
|
|
describe("KDF strength setting takes effect", () => {
|
|
it("Change password re-wraps the parent seed at the configured iterations", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-kdf-"));
|
|
const app = new AppDir(dir);
|
|
const PW = "orig-pw";
|
|
const fp = await app.createProfile(MNEMONIC, PW, "kdf-test"); // default 200000
|
|
const seedPath = join(app.profileDir(fp), PARENT_SEED_FILENAME);
|
|
|
|
const before = JSON.parse(await readFile(seedPath, "utf8"));
|
|
expect(before.kdf.params.iterations).toBe(200000);
|
|
|
|
// Settings(7) > KDF(11) > no benchmark > 300000 > Enter, then
|
|
// Change password(3) > old > new > confirm > Enter > back > exit.
|
|
const ui = new ScriptedUi([
|
|
"7", "11", "n", "300000", "",
|
|
"3", PW, "new-pw", "new-pw", "",
|
|
"", "",
|
|
]);
|
|
await runTui({ appDir: dir, fingerprint: fp }, ui);
|
|
|
|
const after = JSON.parse(await readFile(seedPath, "utf8"));
|
|
expect(after.kdf.params.iterations).toBe(300000);
|
|
// The setting is not cosmetic: the new password opens the re-wrapped seed.
|
|
expect(await app.decryptParentSeed(fp, "new-pw")).toBe(MNEMONIC);
|
|
});
|
|
|
|
it("a new profile is wrapped at the active profile's configured iterations", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-kdf2-"));
|
|
const app = new AppDir(dir);
|
|
const fp = await app.createProfile(MNEMONIC, "pw", "primary");
|
|
|
|
// Set KDF to 250000, then add a second profile from a generated seed.
|
|
const ui = new ScriptedUi([
|
|
"7", "11", "n", "250000", "", // Settings > KDF > 250000
|
|
"1", // Settings > Profiles
|
|
"2", // Profiles > Add
|
|
"1", "12", // generate, 12 words
|
|
"second", // profile name
|
|
"np", "np", // new password x2
|
|
"", // pause after showing seed
|
|
"y", // "have you written it down?"
|
|
"", // pause after created
|
|
"", "", "", // back out: profiles, settings, main
|
|
]);
|
|
await runTui({ appDir: dir, fingerprint: fp }, ui);
|
|
|
|
// Find the profile that is not the primary and check its wrap strength.
|
|
const reg = await app.readFingerprints();
|
|
const others = reg.fingerprints.filter((f) => f !== fp);
|
|
expect(others.length).toBe(1);
|
|
const seed = JSON.parse(
|
|
await readFile(join(app.profileDir(others[0]!), PARENT_SEED_FILENAME), "utf8"),
|
|
);
|
|
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);
|
|
});
|
|
});
|
|
|
|
import { stat, chmod } from "node:fs/promises";
|
|
|
|
describe("secret-bearing exports get a fresh 0600 file", () => {
|
|
it("re-creates a pre-existing world-readable file as 0600 on 2FA export", async () => {
|
|
const dest = join(appDir, "totp-export.json");
|
|
// An attacker (or just history) left a 0644 file at the destination.
|
|
// writeFile(mode) would keep 0644 — every TOTP secret world-readable.
|
|
await writeFile(dest, "old contents");
|
|
await chmod(dest, 0o644);
|
|
|
|
const ui = await run("7", "9", dest, "y", "", "", "");
|
|
expect(ui.text).toContain("Exported 1 2FA entries");
|
|
|
|
const mode = (await stat(dest)).mode & 0o777;
|
|
expect(mode).toBe(0o600);
|
|
const body = JSON.parse(await readFile(dest, "utf8"));
|
|
expect(body.entries[0].label).toBe("email-2fa");
|
|
});
|
|
|
|
it("declining the overwrite leaves the existing file untouched", async () => {
|
|
const dest = join(appDir, "keep-me.json");
|
|
await writeFile(dest, "precious");
|
|
|
|
const ui = await run("7", "9", dest, "n", "", "", "");
|
|
expect(ui.text).toContain("Nothing was written");
|
|
expect(await readFile(dest, "utf8")).toBe("precious");
|
|
});
|
|
});
|
|
|
|
describe(".seedpass export naming in the TUI", () => {
|
|
it("appends .seedpass to an extensionless destination", async () => {
|
|
const dest = join(appDir, "my-backup");
|
|
const ui = await run("7", "7", dest, "", "", "");
|
|
expect(ui.text).toContain(`Exported to ${dest}.seedpass`);
|
|
const wrapper = JSON.parse(await readFile(`${dest}.seedpass`, "utf8"));
|
|
expect(wrapper.format_version).toBe(1);
|
|
});
|
|
|
|
it("a directory destination gets the generated self-identifying name", async () => {
|
|
const ui = await run("7", "7", appDir, "", "", "");
|
|
const match = ui.text.match(/Exported to (.*seedpass-[0-9A-F]{16}-\d{8}\.seedpass)/);
|
|
expect(match).not.toBeNull();
|
|
const wrapper = JSON.parse(await readFile(match![1]!, "utf8"));
|
|
expect(wrapper.format_version).toBe(1);
|
|
});
|
|
|
|
it("an explicit extension is respected as typed", async () => {
|
|
const dest = join(appDir, "explicit.json");
|
|
const ui = await run("7", "7", dest, "", "", "");
|
|
expect(ui.text).toContain(`Exported to ${dest}`);
|
|
expect(ui.text).not.toContain(`${dest}.seedpass`);
|
|
});
|
|
});
|