Files
SeedPass/js/packages/cli/test/seedCreation.test.ts
T
thePR0M3TH3ANandClaude Fable 5 b0e2a309a4 feat: generate a master seed, and fix three bugs found by using the CLI
Driving the CLI from an empty app directory turned up a gap that no test
covered: there was no way to create a vault. Every path assumed you already
had a seed and told you to export SEEDPASS_MNEMONIC, so a new user could not
get started at all. Python has generate_bip85_seed; the port did not.

fingerprint create fills that in with the same construction Python uses --
32 bytes of OS entropy through BIP-85 app 39 index 0. The entropy call sits
outside any try/catch on purpose, carried over from Python's entropy audit:
nothing may catch a CSPRNG failure and substitute a value.

Where the phrase goes is a deliberate choice rather than a default. This is
the one secret in SeedPass with no other copy, so:

  - the destination is validated BEFORE generating, and delivery happens
    BEFORE the profile is created -- a failure at either point leaves
    nothing behind rather than a vault whose seed nobody has;
  - --out writes 0600 with O_EXCL, so an existing file is never clobbered;
  - with no destination and a non-TTY stdout it refuses outright, instead of
    writing the only copy of a seed into a pipe -- a log, a transcript, an
    agent's context.

Also fixed, all found the same way:

  - use --exec always exited 0, so chaining a deploy command after it with
    && reported success even when the deploy failed. The sink child's exit
    status is now the CLI's own.
  - the sink command spec split on whitespace and ignored quotes, so
    --exec 'sh -c "..."' -- the most common form there is -- handed sh the
    fragments of the script and died on an unterminated string.
  - --tags work,dev silently stored a single tag named "work,dev" that no
    search would ever match. Commas and spaces both separate now.

Two normal states reported raw errno: a stopped agent said
'connect ENOENT /.../agent.sock', and an occupied --out path said EEXIST.

The capability map claimed ssh/pgp were unported. Both work and are at
parity with Python; automation branches on that list, so it now describes
this build.

Verified against Python on a TS-generated seed: same fingerprint, same
password, same TOTP code. 320 JS tests and 36/36 cross-impl checks pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-15 14:35:39 -04:00

162 lines
6.5 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 { 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("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));
});
});