mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 08:22:03 +00:00
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]>
162 lines
6.5 KiB
TypeScript
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));
|
|
});
|
|
});
|