mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
The platform workflow's first run produced seventeen failures on Windows and macOS, where the JavaScript suite had never executed. Two were real defects in shipped code. A CONTAINMENT CHECK THAT REFUSED EVERYTHING ON WINDOWS `resolveWithinProfile` compared `target.startsWith(root + "/")` with a hardcoded forward slash. On Windows `resolve()` returns `C:\profile\file.txt` and the comparison asked for `C:\profile/`, so it matched nothing: document export and import refused every path, including legitimate ones. It failed CLOSED, so nothing leaked — the feature simply did not work. Now uses `relative()`, which needs no separator of our choosing. A COMMAND PARSER THAT ATE WINDOWS PATHS `parseCommandSpec` treated backslash as a POSIX escape unconditionally, so `--exec "C:\Program Files\tool.exe"` was spawned as the command `C:Program` with an argument `Filestool.exe`. Every Windows path handed to --exec or --stdin-to was mangled before the spawn. This is the SECOND instance of this exact bug today — the Python TUI palette had it too — which is what a whole class looks like rather than a one-off. `windows` is an explicit parameter, not a `process.platform` read, so both branches are tested from either platform. That parameter is the actual fix for the class: the reason these survived is that the Windows branch could only ever run on Windows. THE REST WERE TESTS, NOT CODE - `/bin/true` does not exist on macOS (it is /usr/bin/true), so a spawn failed with ENOENT and the refusal looked like the daemon's crash-safety fix having regressed. Now uses process.execPath, which exists everywhere by definition. - The oversized-request test treated its own EPIPE as fatal. The daemon caps the line, answers and hangs up; on macOS that hangup lands while the client is still pushing the remaining megabyte. Correct server behaviour was failing the test on one platform and passing on the other. - Three sink tests wrote `#!/bin/sh` files. Shebangs and /bin/sh do not exist on Windows. Now Node scripts run by the interpreter already running the tests. - Eight `0600` assertions and two clipboard tests are guarded by documented constants in test/helpers/platform.ts. Windows has no POSIX mode bits and the runner has no clipboard. The skips state a gap rather than closing it, and TODO.md now records that the Windows permissions question grew from two Python tests to ten across both languages — including the vault index and the semantic index, which hold secrets. With TypeScript becoming the only implementation that is a shipping question, not a test-suite one. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01Vtx94nXDmNgXEeThUZP4QN
701 lines
29 KiB
TypeScript
701 lines
29 KiB
TypeScript
/**
|
|
* CLI behavior tests against a fixture vault.
|
|
*
|
|
* The load-bearing assertions are the agent-blind properties from plan
|
|
* section 9.3: default output never contains secret values; `reveal` is the
|
|
* only command that prints one; sinks deliver without printing.
|
|
*/
|
|
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { mkdtemp, writeFile, readFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import process from "node:process";
|
|
import {
|
|
entriesIndex,
|
|
entrySecrets,
|
|
passwordV1Cases,
|
|
passwordV2Cases,
|
|
totpCases,
|
|
mnemonics,
|
|
} from "@seedpass/test-vectors";
|
|
import { deriveIndexKeyBytes, encryptV3, generateFingerprint, utf8 } from "@seedpass/core";
|
|
import { buildProgram, type ProgramIo } from "../src/index.js";
|
|
|
|
const MNEMONIC = mnemonics["abandon12"]!;
|
|
let vaultPath: string;
|
|
|
|
interface RunResult {
|
|
stdout: string;
|
|
stderr: string;
|
|
error?: unknown;
|
|
}
|
|
|
|
async function run(...argv: string[]): Promise<RunResult> {
|
|
const out: string[] = [];
|
|
const err: string[] = [];
|
|
const io: ProgramIo = {
|
|
out: (l: string) => out.push(l),
|
|
err: (l: string) => err.push(l),
|
|
};
|
|
const program = buildProgram(io);
|
|
let error: unknown;
|
|
try {
|
|
await program.parseAsync(["node", "seedpass-js", ...argv]);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
return { stdout: out.join("\n"), stderr: err.join("\n"), error };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
|
|
// Hermetic app dir: without this, any code path that touches the DEFAULT
|
|
// profile directory silently depends on the developer's real ~/.seedpass —
|
|
// which is exactly how a "vault import --vault x needs a default profile"
|
|
// bug passed locally for months while failing in CI, where no ~/.seedpass
|
|
// exists. Tests must fail the way CI fails.
|
|
process.env["SEEDPASS_APP_DIR"] = await mkdtemp(join(tmpdir(), "seedpass-cli-appdir-"));
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-cli-"));
|
|
vaultPath = join(dir, "vault.enc");
|
|
const key = deriveIndexKeyBytes(MNEMONIC);
|
|
const payload = await encryptV3(key, utf8(JSON.stringify(entriesIndex.entries)));
|
|
await writeFile(vaultPath, payload);
|
|
});
|
|
|
|
describe("capabilities", () => {
|
|
it("reports the agent surface", async () => {
|
|
const r = await run("capabilities");
|
|
const caps = JSON.parse(r.stdout);
|
|
expect(caps.agent_surface.reference_first_output).toBe(true);
|
|
expect(caps.protocol.entry_schema_version).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("reference-first output (agent-blind default)", () => {
|
|
it("entry list returns refs and metadata for all entries, no secret values", async () => {
|
|
const r = await run("--vault", vaultPath, "entry", "list");
|
|
const rows = JSON.parse(r.stdout) as Record<string, unknown>[];
|
|
expect(rows).toHaveLength(10);
|
|
for (const row of rows) {
|
|
expect(String(row["ref"])).toMatch(/^sp:\/\/entry\/\d+$/);
|
|
expect(row).not.toHaveProperty("secret");
|
|
expect(row).not.toHaveProperty("value");
|
|
expect(row).not.toHaveProperty("content");
|
|
}
|
|
// The imported TOTP secret must not appear anywhere in the output
|
|
expect(r.stdout).not.toContain("JBSWY3DPEHPK3PXP");
|
|
// ...but its presence is flagged
|
|
const importedTotp = rows.find((row) => row["label"] === "imported-totp")!;
|
|
expect(importedTotp["has_secret"]).toBe(true);
|
|
});
|
|
|
|
it("entry get resolves by label, id, and ref identically", async () => {
|
|
const byLabel = await run("--vault", vaultPath, "entry", "get", "example.com");
|
|
const byId = await run("--vault", vaultPath, "entry", "get", "0");
|
|
const byRef = await run("--vault", vaultPath, "entry", "get", "sp://entry/0");
|
|
expect(byLabel.stdout).toBe(byId.stdout);
|
|
expect(byId.stdout).toBe(byRef.stdout);
|
|
const row = JSON.parse(byRef.stdout);
|
|
expect(row.label).toBe("example.com");
|
|
expect(row.kind).toBe("password");
|
|
});
|
|
|
|
it("entry search matches tags and never leaks values", async () => {
|
|
const r = await run("--vault", vaultPath, "entry", "search", "api");
|
|
const rows = JSON.parse(r.stdout) as Record<string, unknown>[];
|
|
expect(rows.map((x) => x["label"])).toEqual(["api-token"]);
|
|
expect(r.stdout).not.toContain("abc123");
|
|
});
|
|
});
|
|
|
|
describe("plaintext egress is explicit and correct", () => {
|
|
it("reveal derives the same password as the Python fixture", async () => {
|
|
// Vault entry 0: example.com, length 16, gen_version 2, derivation index 0
|
|
const expected = passwordV2Cases.find(
|
|
(c) => c.policy === "default" && c.length === 16 && c.index === 0,
|
|
)!.password;
|
|
const r = await run("--vault", vaultPath, "entry", "reveal", "example.com");
|
|
expect(r.stdout).toBe(expected);
|
|
});
|
|
|
|
it("reveal computes deterministic TOTP codes at a pinned time", async () => {
|
|
const fixture = totpCases.find((c) => c.mnemonic_id === "abandon12" && c.index === 0)!;
|
|
const r = await run(
|
|
"--vault", vaultPath, "entry", "reveal", "example-totp", "--at", "1700000000",
|
|
);
|
|
expect(r.stdout).toBe(fixture.codes_at["1700000000"]);
|
|
});
|
|
|
|
it("reveal prints an imported key_value secret", async () => {
|
|
const r = await run("--vault", vaultPath, "entry", "reveal", "api-token");
|
|
expect(r.stdout).toBe("abc123");
|
|
});
|
|
|
|
it("reveal matches every Python-computed entry secret", async () => {
|
|
expect((await run("--vault", vaultPath, "entry", "reveal", "example.com")).stdout).toBe(
|
|
entrySecrets.password_entry_0,
|
|
);
|
|
expect(
|
|
(await run("--vault", vaultPath, "entry", "reveal", "example-totp", "--at", "1700000000"))
|
|
.stdout,
|
|
).toBe(entrySecrets.totp_entry_1_code_at["1700000000"]);
|
|
// Nostr entries use BIP-85 app 39 (not the sync client's app 1237)
|
|
expect((await run("--vault", vaultPath, "entry", "reveal", "example-nostr")).stdout).toBe(
|
|
entrySecrets.nostr_entry_4.nsec,
|
|
);
|
|
expect((await run("--vault", vaultPath, "entry", "reveal", "example-seed")).stdout).toBe(
|
|
entrySecrets.seed_entry_7_mnemonic,
|
|
);
|
|
expect((await run("--vault", vaultPath, "entry", "reveal", "example-managed")).stdout).toBe(
|
|
entrySecrets.managed_entry_8_mnemonic,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("provisioning is agent-blind", () => {
|
|
let writablePath: string;
|
|
|
|
beforeAll(async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-provision-"));
|
|
writablePath = join(dir, "vault.enc");
|
|
await writeFile(writablePath, await readFile(vaultPath));
|
|
});
|
|
|
|
it("entry add password returns a reference, never the derived password", async () => {
|
|
const r = await run(
|
|
"--vault", writablePath, "entry", "add", "password", "new-site.example",
|
|
"--length", "16", "--username", "bob",
|
|
);
|
|
const row = JSON.parse(r.stdout);
|
|
expect(row.ref).toBe("sp://entry/10");
|
|
expect(row.kind).toBe("password");
|
|
expect(row.gen_version).toBe(2);
|
|
|
|
// The derived password exists and reveals correctly, but appeared
|
|
// nowhere in the provisioning output.
|
|
const reveal = await run("--vault", writablePath, "entry", "reveal", "sp://entry/10");
|
|
expect(reveal.stdout).toHaveLength(16);
|
|
expect(r.stdout).not.toContain(reveal.stdout);
|
|
});
|
|
|
|
it("entry add totp --secret stores but never echoes the imported secret", async () => {
|
|
const secret = "GEZDGNBVGY3TQOJQ";
|
|
const r = await run(
|
|
"--vault", writablePath, "entry", "add", "totp", "imported-2", "--secret", secret,
|
|
);
|
|
const row = JSON.parse(r.stdout);
|
|
expect(row.has_secret).toBe(true);
|
|
expect(r.stdout).not.toContain(secret);
|
|
});
|
|
|
|
it("entry add key-value persists across reopen", async () => {
|
|
await run(
|
|
"--vault", writablePath, "entry", "add", "key-value", "deploy-token", "token", "s3cr3t-value",
|
|
);
|
|
const list = await run("--vault", writablePath, "entry", "list");
|
|
const rows = JSON.parse(list.stdout) as { label: string }[];
|
|
expect(rows.map((x) => x.label)).toContain("deploy-token");
|
|
expect(list.stdout).not.toContain("s3cr3t-value");
|
|
const reveal = await run("--vault", writablePath, "entry", "reveal", "deploy-token");
|
|
expect(reveal.stdout).toBe("s3cr3t-value");
|
|
});
|
|
|
|
it("entry add managed-account records the child fingerprint only", async () => {
|
|
const r = await run("--vault", writablePath, "entry", "add", "managed-account", "child-acct");
|
|
const row = JSON.parse(r.stdout);
|
|
expect(row.fingerprint).toMatch(/^[0-9A-F]{16}$/);
|
|
expect(row.word_count).toBe(12);
|
|
// The child mnemonic is derivable but was never printed
|
|
const reveal = await run("--vault", writablePath, "entry", "reveal", "child-acct");
|
|
expect(reveal.stdout.split(" ")).toHaveLength(12);
|
|
expect(r.stdout).not.toContain(reveal.stdout);
|
|
});
|
|
});
|
|
|
|
describe("sink delivery keeps secrets out of CLI output", () => {
|
|
it("use --exec injects SEEDPASS_SECRET into the child env only", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-sink-"));
|
|
const capture = join(dir, "captured.txt");
|
|
// A Node script run by the interpreter already running these tests,
|
|
// rather than a `#!/bin/sh` file. Shebangs and /bin/sh do not exist on
|
|
// Windows, so this whole describe block failed there the first time the
|
|
// suite ran on it. `process.execPath` exists on every platform by
|
|
// definition, and quoting the spec lets a path with spaces survive.
|
|
const script = join(dir, "cap.cjs");
|
|
await writeFile(
|
|
script,
|
|
`require("fs").writeFileSync(${JSON.stringify(capture)}, process.env.SEEDPASS_SECRET ?? "");`,
|
|
);
|
|
|
|
const spec = `"${process.execPath}" "${script}"`;
|
|
const r = await run("--vault", vaultPath, "use", "api-token", "--exec", spec);
|
|
const delivered = JSON.parse(r.stdout);
|
|
expect(delivered.sink).toBe("exec");
|
|
expect(delivered.exitCode).toBe(0);
|
|
expect(delivered.ref).toBe("sp://entry/5");
|
|
// The secret reached the child...
|
|
expect(await readFile(capture, "utf8")).toBe("abc123");
|
|
// ...and never appeared in the CLI's own output
|
|
expect(r.stdout).not.toContain("abc123");
|
|
expect(r.stderr).not.toContain("abc123");
|
|
});
|
|
|
|
it("use --stdin-to pipes the secret to the command's stdin", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-sink-"));
|
|
const capture = join(dir, "captured.txt");
|
|
const script = join(dir, "cap.cjs");
|
|
await writeFile(
|
|
script,
|
|
`let b = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", (d) => { b += d; });
|
|
process.stdin.on("end", () => require("fs").writeFileSync(${JSON.stringify(capture)}, b));`,
|
|
);
|
|
|
|
const spec = `"${process.execPath}" "${script}"`;
|
|
const r = await run("--vault", vaultPath, "use", "api-token", "--stdin-to", spec);
|
|
expect(JSON.parse(r.stdout).sink).toBe("stdin");
|
|
expect(await readFile(capture, "utf8")).toBe("abc123");
|
|
expect(r.stdout).not.toContain("abc123");
|
|
});
|
|
|
|
it("accepts a quoted command spec so flags survive option parsing", async () => {
|
|
// Regression: commander's variadic options stop at the next "-token",
|
|
// so `--stdin-to wc -c` lost the flag. A quoted spec is the documented
|
|
// way to include flags.
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-spec-"));
|
|
const capture = join(dir, "captured.txt");
|
|
const script = join(dir, "cap.cjs");
|
|
await writeFile(
|
|
script,
|
|
`let b = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", (d) => { b += d; });
|
|
process.stdin.on("end", () =>
|
|
require("fs").writeFileSync(${JSON.stringify(capture)}, process.argv[2] + " " + b));`,
|
|
);
|
|
const r = await run(
|
|
"--vault", vaultPath, "use", "api-token",
|
|
"--stdin-to", `"${process.execPath}" "${script}" -flagged`,
|
|
);
|
|
expect(JSON.parse(r.stdout).sink).toBe("stdin");
|
|
expect(await readFile(capture, "utf8")).toBe("-flagged abc123");
|
|
});
|
|
|
|
it("use refuses ambiguous sink selection", async () => {
|
|
const r = await run(
|
|
"--vault", vaultPath, "use", "api-token", "--clipboard", "--stdin-to", "cat",
|
|
);
|
|
expect(String((r.error as Error).message)).toContain("exactly one sink");
|
|
});
|
|
});
|
|
|
|
describe("modification commands", () => {
|
|
let modPath: string;
|
|
|
|
beforeAll(async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-mod-"));
|
|
modPath = join(dir, "vault.enc");
|
|
await writeFile(modPath, await readFile(vaultPath));
|
|
});
|
|
|
|
it("entry modify updates fields and bumps timestamps", async () => {
|
|
const before = JSON.parse(
|
|
(await run("--vault", modPath, "entry", "get", "example.com")).stdout,
|
|
);
|
|
const r = await run(
|
|
"--vault", modPath, "entry", "modify", "example.com",
|
|
"--username", "carol", "--tags", "web", "staging",
|
|
);
|
|
const row = JSON.parse(r.stdout);
|
|
expect(row.username).toBe("carol");
|
|
expect(row.tags).toEqual(["web", "staging"]);
|
|
expect(row.modified_ts).toBeGreaterThan(before.modified_ts);
|
|
});
|
|
|
|
it("entry modify rejects fields the kind does not allow", async () => {
|
|
const r = await run(
|
|
"--vault", modPath, "entry", "modify", "example-totp", "--username", "x",
|
|
);
|
|
expect(String((r.error as Error).message)).toContain("does not support fields");
|
|
});
|
|
|
|
it("archive hides from totp-codes; unarchive restores", async () => {
|
|
await run("--vault", modPath, "entry", "archive", "example-totp");
|
|
const archived = JSON.parse(
|
|
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
|
|
) as { label: string }[];
|
|
expect(archived.map((x) => x.label)).not.toContain("example-totp");
|
|
|
|
await run("--vault", modPath, "entry", "unarchive", "example-totp");
|
|
const restored = JSON.parse(
|
|
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
|
|
) as { label: string; code: string }[];
|
|
const det = restored.find((x) => x.label === "example-totp")!;
|
|
expect(det.code).toBe(entrySecrets.totp_entry_1_code_at["1700000000"]);
|
|
});
|
|
|
|
it("link-add/links/link-remove round-trip with resolved targets", async () => {
|
|
await run(
|
|
"--vault", modPath, "entry", "link-add", "example.com", "example-totp",
|
|
"--relation", "totp", "--note", "2fa",
|
|
);
|
|
const links = JSON.parse(
|
|
(await run("--vault", modPath, "entry", "links", "example.com")).stdout,
|
|
);
|
|
expect(links).toEqual([
|
|
{ target_id: 1, relation: "totp", note: "2fa", target_label: "example-totp", target_kind: "totp" },
|
|
]);
|
|
const after = await run(
|
|
"--vault", modPath, "entry", "link-remove", "example.com", "example-totp",
|
|
);
|
|
expect(JSON.parse(after.stdout).links).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("util generate-password", () => {
|
|
it("matches the v1 fixture at default index/version", async () => {
|
|
const expected = passwordV1Cases.find(
|
|
(c) => c.policy === "default" && c.length === 16 && c.index === 0,
|
|
)!.password;
|
|
const r = await run("util", "generate-password", "--length", "16");
|
|
expect(r.stdout).toBe(expected);
|
|
});
|
|
|
|
it("matches v2 and policy fixtures", async () => {
|
|
const v2 = passwordV2Cases.find(
|
|
(c) => c.policy === "safe_special" && c.length === 40 && c.index === 2,
|
|
)!.password;
|
|
const r = await run(
|
|
"util", "generate-password", "--length", "40", "--index", "2",
|
|
"--gen-version", "2", "--special-mode", "safe",
|
|
);
|
|
expect(r.stdout).toBe(v2);
|
|
});
|
|
});
|
|
|
|
describe("vault export/import", () => {
|
|
it("exports an encrypted portable backup and inspects it without writing", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-export-"));
|
|
const dest = join(dir, "backup.json");
|
|
const exportResult = await run("--vault", vaultPath, "vault", "export", dest);
|
|
expect(JSON.parse(exportResult.stdout).encrypted).toBe(true);
|
|
|
|
const importResult = await run("vault", "import", dest, "--inspect");
|
|
const summary = JSON.parse(importResult.stdout);
|
|
expect(summary.schema_version).toBe(4);
|
|
expect(summary.entry_count).toBe(10);
|
|
expect(summary.written).toBe(false);
|
|
});
|
|
|
|
it("import actually restores into a vault and guards non-empty targets", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-import-"));
|
|
const backup = join(dir, "backup.json");
|
|
await run("--vault", vaultPath, "vault", "export", backup);
|
|
|
|
// Fresh empty vault: import writes without --yes
|
|
const target = join(dir, "target.enc");
|
|
await writeFile(
|
|
target,
|
|
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify({ schema_version: 4, entries: {} }))),
|
|
);
|
|
const first = await run("--vault", target, "vault", "import", backup);
|
|
expect(JSON.parse(first.stdout).entry_count).toBe(10);
|
|
const listed = JSON.parse((await run("--vault", target, "entry", "list")).stdout);
|
|
expect(listed).toHaveLength(10);
|
|
|
|
// Now non-empty: refuses without --yes, proceeds with it
|
|
const guarded = await run("--vault", target, "vault", "import", backup);
|
|
expect(String((guarded.error as Error).message)).toContain("--yes");
|
|
const forced = await run("--vault", target, "vault", "import", backup, "--yes");
|
|
expect(JSON.parse(forced.stdout).entry_count).toBe(10);
|
|
});
|
|
|
|
/**
|
|
* A plaintext backup has no cryptographic binding to the seed it came from
|
|
* — the wrong profile can read it perfectly well. Importing one re-derives
|
|
* every password, SSH key, PGP key and seed from the TARGET profile's seed,
|
|
* so the user ends up with a vault full of entries whose secrets differ
|
|
* from the ones the backup was taken to preserve. Nothing errors on the way
|
|
* through, which is what makes it worth refusing.
|
|
*/
|
|
it("refuses a plaintext backup from a different seed unless told otherwise", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-foreign-"));
|
|
const foreignMnemonic = mnemonics["legal12"]!;
|
|
const saved = process.env["SEEDPASS_MNEMONIC"];
|
|
|
|
// A genuine backup of a genuinely different profile.
|
|
const foreignVault = join(dir, "foreign.enc");
|
|
await writeFile(
|
|
foreignVault,
|
|
await encryptV3(
|
|
deriveIndexKeyBytes(foreignMnemonic),
|
|
utf8(JSON.stringify({ schema_version: 4, entries: {} })),
|
|
),
|
|
);
|
|
const backup = join(dir, "foreign-backup.json");
|
|
process.env["SEEDPASS_MNEMONIC"] = foreignMnemonic;
|
|
const exported = await run(
|
|
"--vault", foreignVault, "vault", "export", backup, "--plaintext",
|
|
);
|
|
expect(JSON.parse(exported.stdout).encrypted).toBe(false);
|
|
process.env["SEEDPASS_MNEMONIC"] = saved!;
|
|
|
|
const target = join(dir, "target.enc");
|
|
await writeFile(
|
|
target,
|
|
await encryptV3(
|
|
deriveIndexKeyBytes(MNEMONIC),
|
|
utf8(JSON.stringify({ schema_version: 4, entries: {} })),
|
|
),
|
|
);
|
|
|
|
const refused = await run("--vault", target, "vault", "import", backup);
|
|
const message = String((refused.error as Error).message);
|
|
expect(message).toContain("belongs to profile");
|
|
expect(message).toContain("--allow-fingerprint-mismatch");
|
|
|
|
// Still possible on purpose — the point is that it cannot happen by
|
|
// accident, not that it is forbidden.
|
|
const allowed = await run(
|
|
"--vault", target, "vault", "import", backup, "--allow-fingerprint-mismatch",
|
|
);
|
|
expect(allowed.error).toBeUndefined();
|
|
expect(JSON.parse(allowed.stdout).entry_count).toBe(0);
|
|
});
|
|
|
|
it("names entries that share a BIP-85 derivation index on import", async () => {
|
|
// ssh@N and pgp@N are the same Ed25519 key. Neither implementation can
|
|
// create that state, so an import is one of the ways it arrives — and the
|
|
// user needs telling, because the two entries look independent.
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-collide-"));
|
|
const source = join(dir, "source.enc");
|
|
await writeFile(
|
|
source,
|
|
await encryptV3(
|
|
deriveIndexKeyBytes(MNEMONIC),
|
|
utf8(
|
|
JSON.stringify({
|
|
schema_version: 4,
|
|
entries: {
|
|
"3": { kind: "ssh", type: "ssh", label: "deploy", index: 3, notes: "", tags: [], archived: false, modified_ts: 1700000000 },
|
|
"4": { kind: "pgp", type: "pgp", label: "sign", index: 3, notes: "", tags: [], archived: false, modified_ts: 1700000000 },
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
const backup = join(dir, "collide.json");
|
|
await run("--vault", source, "vault", "export", backup);
|
|
|
|
const inspected = JSON.parse(
|
|
(await run("--vault", source, "vault", "import", backup, "--inspect")).stdout,
|
|
);
|
|
expect(inspected.derivation_collisions).toHaveLength(1);
|
|
expect(inspected.derivation_collisions[0].severity).toBe("identical-key");
|
|
expect(inspected.derivation_collisions[0].index).toBe(3);
|
|
|
|
// And the dedicated check reports it on an existing vault, with a
|
|
// non-zero exit status for scripts.
|
|
const checked = JSON.parse(
|
|
(await run("--vault", source, "util", "check-derivation")).stdout,
|
|
);
|
|
expect(checked.collisions).toHaveLength(1);
|
|
expect(checked.collisions[0].message).toContain("SAME private key");
|
|
});
|
|
|
|
it("stays silent about derivation on a normally created vault", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-nocollide-"));
|
|
const dest = join(dir, "backup.json");
|
|
await run("--vault", vaultPath, "vault", "export", dest);
|
|
const inspected = JSON.parse(
|
|
(await run("vault", "import", dest, "--inspect")).stdout,
|
|
);
|
|
expect(inspected.derivation_collisions).toEqual([]);
|
|
const checked = JSON.parse(
|
|
(await run("--vault", vaultPath, "util", "check-derivation")).stdout,
|
|
);
|
|
expect(checked.collisions).toEqual([]);
|
|
});
|
|
|
|
it("reports the backup's own fingerprint and mode when inspecting", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-inspect-"));
|
|
const dest = join(dir, "backup.json");
|
|
await run("--vault", vaultPath, "vault", "export", dest);
|
|
const summary = JSON.parse((await run("vault", "import", dest, "--inspect")).stdout);
|
|
expect(summary.encryption_mode).toBe("seed-only");
|
|
expect(summary.fingerprint).toBe(generateFingerprint(MNEMONIC));
|
|
});
|
|
});
|
|
|
|
describe("document import/export", () => {
|
|
it("imports a file and exports it back, matching Python's naming rules", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-doc-"));
|
|
const src = join(dir, "meeting notes.md");
|
|
const body = "# Notes\n\nrecovery phrase is NOT here\n";
|
|
await writeFile(src, body);
|
|
|
|
const vaultCopy = join(dir, "vault.enc");
|
|
await writeFile(vaultCopy, await readFile(vaultPath));
|
|
|
|
const imported = JSON.parse(
|
|
(await run("--vault", vaultCopy, "entry", "import-document", src)).stdout,
|
|
);
|
|
expect(imported.kind).toBe("document");
|
|
expect(imported.label).toBe("meeting notes");
|
|
expect(imported.file_type).toBe("md");
|
|
// The body is a secret: it must not appear in provisioning output
|
|
expect(imported).not.toHaveProperty("content");
|
|
|
|
const outDir = join(dir, "out");
|
|
const exported = JSON.parse(
|
|
(await run(
|
|
"--vault", vaultCopy, "entry", "export-document", "meeting notes", "--out", outDir,
|
|
)).stdout,
|
|
);
|
|
// Spaces collapse to "_" exactly as Python sanitizes them
|
|
expect(exported.exported).toBe(join(outDir, "meeting_notes.md"));
|
|
expect(await readFile(exported.exported, "utf8")).toBe(body);
|
|
|
|
const again = await run(
|
|
"--vault", vaultCopy, "entry", "export-document", "meeting notes", "--out", outDir,
|
|
);
|
|
expect(String((again.error as Error).message)).toContain("already exists");
|
|
|
|
const forced = await run(
|
|
"--vault", vaultCopy, "entry", "export-document", "meeting notes",
|
|
"--out", outDir, "--overwrite",
|
|
);
|
|
expect(JSON.parse(forced.stdout).exported).toBe(join(outDir, "meeting_notes.md"));
|
|
});
|
|
|
|
it("refuses to export a non-document entry", async () => {
|
|
const r = await run("--vault", vaultPath, "entry", "export-document", "example.com");
|
|
expect(String((r.error as Error).message)).toContain("not a document entry");
|
|
});
|
|
});
|
|
|
|
describe("exit behaviour", () => {
|
|
it("prints help without reporting it as an error when stdout is not a terminal", async () => {
|
|
// Bare invocation launches the interactive mode on a terminal, but there
|
|
// is no terminal to drive here, so it falls back to the help. Two bugs
|
|
// met on this path: exitOverride() makes commander throw when it prints
|
|
// help, and the handler reported that throw as "error: (outputHelp)".
|
|
const out: string[] = [];
|
|
const err: string[] = [];
|
|
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
|
|
const previous = process.exitCode;
|
|
let thrown: unknown;
|
|
try {
|
|
await buildProgram(io).parseAsync(["node", "seedpass-js"]);
|
|
} catch (e) {
|
|
thrown = e;
|
|
}
|
|
const printed = [...out, ...err].join("\n");
|
|
expect(thrown).toBeUndefined();
|
|
expect(printed).toContain("Usage: seedpass-js");
|
|
expect(printed).not.toContain("outputHelp");
|
|
expect(process.exitCode).toBe(1);
|
|
process.exitCode = previous;
|
|
});
|
|
});
|
|
|
|
describe("error messages", () => {
|
|
it("explains a corrupt or foreign vault file instead of leaking a codec error", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-corrupt-"));
|
|
const broken = join(dir, "broken.enc");
|
|
await writeFile(broken, "not a seedpass vault");
|
|
const r = await run("--vault", broken, "entry", "list");
|
|
const msg = String((r.error as Error).message);
|
|
expect(msg).toContain("could not decrypt vault");
|
|
expect(msg).toContain("different seed");
|
|
});
|
|
});
|
|
|
|
describe("unknown entry kinds at the CLI surface", () => {
|
|
let foreignVault: string;
|
|
const FOREIGN = {
|
|
kind: "bitlogin_org",
|
|
type: "bitlogin_org",
|
|
label: "Acme Corporation",
|
|
modified_ts: 1700000123,
|
|
bitlogin: { admins: ["npub1aaaa"], api_token: "sk-live-visible-if-leaked" },
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-cli-foreign-"));
|
|
foreignVault = join(dir, "vault.enc");
|
|
const key = deriveIndexKeyBytes(MNEMONIC);
|
|
const index = {
|
|
schema_version: 4,
|
|
entries: {
|
|
"0": {
|
|
type: "key_value", kind: "key_value", label: "api", key: "k", value: "v",
|
|
archived: false, notes: "", tags: [], links: [],
|
|
},
|
|
"1": FOREIGN,
|
|
},
|
|
};
|
|
await writeFile(foreignVault, await encryptV3(key, utf8(JSON.stringify(index))));
|
|
});
|
|
|
|
it("lists a vault containing a foreign record, values redacted", async () => {
|
|
const r = await run("--vault", foreignVault, "entry", "list");
|
|
expect(r.error).toBeUndefined();
|
|
const rows = JSON.parse(r.stdout) as Record<string, unknown>[];
|
|
const foreign = rows.find((row) => row["kind"] === "bitlogin_org")!;
|
|
expect(foreign).toBeDefined();
|
|
expect(foreign["label"]).toBe("Acme Corporation");
|
|
// Fields this build does not recognize are reported as present, never
|
|
// dumped: another application's data is not ours to hand out.
|
|
expect(foreign["has_bitlogin"]).toBe(true);
|
|
expect(r.stdout).not.toContain("sk-live-visible-if-leaked");
|
|
expect(r.stdout).not.toContain("npub1aaaa");
|
|
});
|
|
|
|
it("refuses to reveal a foreign record instead of guessing", async () => {
|
|
const r = await run("--vault", foreignVault, "entry", "reveal", "sp://entry/1");
|
|
expect(String((r.error as Error)?.message ?? r.stderr)).toMatch(/unsupported entry kind/);
|
|
expect(r.stdout).not.toContain("sk-live");
|
|
});
|
|
|
|
it("mutating a neighbour entry does not disturb the foreign record", async () => {
|
|
const r = await run("--vault", foreignVault, "entry", "add", "password", "new-site", "--length", "20");
|
|
expect(r.error).toBeUndefined();
|
|
const list = await run("--vault", foreignVault, "entry", "list");
|
|
const rows = JSON.parse(list.stdout) as Record<string, unknown>[];
|
|
// Allocation skipped past the foreign record's id.
|
|
expect(rows.map((row) => row["id"])).toContain("2");
|
|
expect(rows.find((row) => row["kind"] === "bitlogin_org")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe(".seedpass export naming", () => {
|
|
it("a directory target gets the generated seedpass-<fp>-<date>.seedpass name", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
|
|
const r = await run("--vault", vaultPath, "vault", "export", dir);
|
|
expect(r.error).toBeUndefined();
|
|
const dest = String(JSON.parse(r.stdout).exported);
|
|
expect(dest.startsWith(dir)).toBe(true);
|
|
expect(dest).toMatch(/seedpass-[0-9A-F]{16}-\d{8}\.seedpass$/);
|
|
// And the file restores like any other backup: same wrapper shape.
|
|
const wrapper = JSON.parse(await readFile(dest, "utf8"));
|
|
expect(wrapper.format_version).toBe(1);
|
|
});
|
|
|
|
it("an explicit file path is used exactly as typed (scripts depend on it)", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
|
|
const exact = join(dir, "my-backup.json");
|
|
const r = await run("--vault", vaultPath, "vault", "export", exact);
|
|
expect(String(JSON.parse(r.stdout).exported)).toBe(exact);
|
|
});
|
|
|
|
it("an extensionless explicit path is also used exactly as typed", async () => {
|
|
const dir = await mkdtemp(join(tmpdir(), "seedpass-name-"));
|
|
const bare = join(dir, "backup");
|
|
const r = await run("--vault", vaultPath, "vault", "export", bare);
|
|
expect(String(JSON.parse(r.stdout).exported)).toBe(bare);
|
|
});
|
|
});
|