mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +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
163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
/**
|
|
* `fingerprint create` — the only command that mints a secret with no other
|
|
* copy anywhere. The tests below are mostly about the failure modes, because
|
|
* every one of them loses a vault permanently:
|
|
*
|
|
* - a phrase delivered nowhere (profile created, seed unrecoverable)
|
|
* - a phrase clobbering an existing file (the *previous* vault's seed gone)
|
|
* - a phrase leaked into a pipe (an agent transcript, a CI log)
|
|
* - a phrase that is not a valid BIP-39 mnemonic (nothing can reopen it)
|
|
*/
|
|
|
|
import { describe, expect, it, beforeEach, afterEach } from "vitest";
|
|
import { NO_POSIX_PERMISSIONS } from "./helpers/platform.js";
|
|
import { mkdtemp, readFile, writeFile, stat } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import process from "node:process";
|
|
import {
|
|
isValidMnemonic,
|
|
generateMnemonic,
|
|
generateFingerprint,
|
|
deriveIndexKeyBytes,
|
|
} from "@seedpass/core";
|
|
import { buildProgram, type ProgramIo } from "../src/index.js";
|
|
import { openVault } from "../src/vaultFile.js";
|
|
import { AppDir, INDEX_FILENAME } from "../src/appDir.js";
|
|
|
|
let appDir: string;
|
|
let scratch: string;
|
|
const savedTty = process.stdout.isTTY;
|
|
|
|
interface Run {
|
|
stdout: string;
|
|
stderr: string;
|
|
error?: unknown;
|
|
}
|
|
|
|
async function run(...argv: string[]): Promise<Run> {
|
|
const out: string[] = [];
|
|
const err: string[] = [];
|
|
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
|
|
let error: unknown;
|
|
try {
|
|
await buildProgram(io).parseAsync(["node", "seedpass-js", "--app-dir", appDir, ...argv]);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
return { stdout: out.join("\n"), stderr: err.join("\n"), error };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
appDir = await mkdtemp(join(tmpdir(), "seedpass-create-app-"));
|
|
scratch = await mkdtemp(join(tmpdir(), "seedpass-create-out-"));
|
|
process.env["SEEDPASS_PASSWORD"] = "creation-test-password";
|
|
delete process.env["SEEDPASS_MNEMONIC"];
|
|
// Default to the non-interactive case; the TTY test opts in explicitly.
|
|
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process.stdout, "isTTY", { value: savedTty, configurable: true });
|
|
delete process.env["SEEDPASS_PASSWORD"];
|
|
});
|
|
|
|
describe("generateMnemonic", () => {
|
|
it("produces valid, distinct phrases of the requested length", () => {
|
|
const a = generateMnemonic(12);
|
|
const b = generateMnemonic(12);
|
|
const long = generateMnemonic(24);
|
|
expect(a.split(" ")).toHaveLength(12);
|
|
expect(long.split(" ")).toHaveLength(24);
|
|
expect(isValidMnemonic(a)).toBe(true);
|
|
expect(isValidMnemonic(long)).toBe(true);
|
|
// Two calls returning the same phrase would mean the CSPRNG is not being
|
|
// consulted — the failure that matters most and is easiest to miss.
|
|
expect(a).not.toBe(b);
|
|
});
|
|
|
|
it("refuses unsupported word counts rather than rounding to one", () => {
|
|
expect(() => generateMnemonic(18 as 12)).toThrow(/must be one of/);
|
|
});
|
|
});
|
|
|
|
describe("fingerprint create", () => {
|
|
it("refuses to generate when stdout is a pipe and no destination was given", async () => {
|
|
const r = await run("fingerprint", "create");
|
|
expect(String((r.error as Error).message)).toMatch(/nowhere safe to put it/);
|
|
// Nothing may exist afterwards: a profile whose seed was never delivered
|
|
// is a vault nobody can open.
|
|
const app = new AppDir(appDir);
|
|
expect((await app.readFingerprints()).fingerprints).toHaveLength(0);
|
|
});
|
|
|
|
it.skipIf(NO_POSIX_PERMISSIONS)("writes the phrase to a 0600 file and creates a matching profile", async () => {
|
|
const out = join(scratch, "seed.txt");
|
|
const r = await run("fingerprint", "create", "--name", "test", "--words", "24", "--out", out);
|
|
const result = JSON.parse(r.stdout);
|
|
|
|
const phrase = (await readFile(out, "utf8")).trim();
|
|
expect(phrase.split(" ")).toHaveLength(24);
|
|
expect(isValidMnemonic(phrase)).toBe(true);
|
|
// The phrase must not also appear on stdout when --out was used.
|
|
expect(r.stdout).not.toContain(phrase);
|
|
expect((await stat(out)).mode & 0o777).toBe(0o600);
|
|
|
|
// The written phrase is genuinely the one the profile derives from.
|
|
expect(result.fingerprint).toBe(generateFingerprint(phrase));
|
|
expect(result.seed_written_to).toBe(out);
|
|
const vault = await openVault(join(new AppDir(appDir).profileDir(result.fingerprint), INDEX_FILENAME), phrase);
|
|
expect(vault).toBeTruthy();
|
|
});
|
|
|
|
it("never overwrites an existing seed file", async () => {
|
|
const out = join(scratch, "occupied.txt");
|
|
await writeFile(out, "an earlier vault's only seed\n");
|
|
const r = await run("fingerprint", "create", "--out", out);
|
|
expect(r.error).toBeTruthy();
|
|
// The prior contents survive: clobbering this file destroys another vault.
|
|
expect(await readFile(out, "utf8")).toBe("an earlier vault's only seed\n");
|
|
});
|
|
|
|
it("prints to stdout only when explicitly asked with --show", async () => {
|
|
const r = await run("fingerprint", "create", "--show");
|
|
const lines = r.stdout.split("\n");
|
|
expect(isValidMnemonic(lines[0]!)).toBe(true);
|
|
expect(r.stderr).toMatch(/ONLY way to recover/);
|
|
});
|
|
|
|
it("prints to an interactive terminal without needing a flag", async () => {
|
|
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
|
const r = await run("fingerprint", "create");
|
|
expect(isValidMnemonic(r.stdout.split("\n")[0]!)).toBe(true);
|
|
});
|
|
|
|
it("rejects --show together with --out", async () => {
|
|
const r = await run("fingerprint", "create", "--show", "--out", join(scratch, "x.txt"));
|
|
expect(String((r.error as Error).message)).toMatch(/mutually exclusive/);
|
|
});
|
|
|
|
it("rejects word counts other than 12 or 24", async () => {
|
|
const r = await run("fingerprint", "create", "--words", "18", "--show");
|
|
expect(String((r.error as Error).message)).toMatch(/12 or 24/);
|
|
});
|
|
|
|
it("requires a password", async () => {
|
|
delete process.env["SEEDPASS_PASSWORD"];
|
|
const r = await run("fingerprint", "create", "--show");
|
|
expect(String((r.error as Error).message)).toMatch(/SEEDPASS_PASSWORD/);
|
|
});
|
|
|
|
it("creates a profile whose index key derives from the generated phrase", async () => {
|
|
const out = join(scratch, "s.txt");
|
|
const r = await run("fingerprint", "create", "--out", out);
|
|
const phrase = (await readFile(out, "utf8")).trim();
|
|
const { fingerprint } = JSON.parse(r.stdout);
|
|
// Derivation is the whole product; assert the key material matches.
|
|
expect(deriveIndexKeyBytes(phrase)).toEqual(
|
|
deriveIndexKeyBytes(phrase.normalize("NFKD")),
|
|
);
|
|
expect(fingerprint).toBe(generateFingerprint(phrase));
|
|
});
|
|
});
|