diff --git a/docs/typescript_migration_guide.md b/docs/typescript_migration_guide.md index 8d83659d..a4e3131c 100644 --- a/docs/typescript_migration_guide.md +++ b/docs/typescript_migration_guide.md @@ -101,6 +101,31 @@ so this only applies if you jump backwards several releases. If you rely on any of those, run both: they operate on the same profile. +## Starting fresh (no existing vault) + +If you have no seed yet, `fingerprint create` generates one — 32 bytes of OS +entropy through BIP-85, the same construction Python uses: + +```bash +export SEEDPASS_APP_DIR=~/.seedpass +export SEEDPASS_PASSWORD='a master password' +seedpass-js fingerprint create --name personal --words 24 --out ~/seed.txt +``` + +The phrase is written to a `0600` file, and an existing file is never +overwritten. `--show` prints it to stdout instead; on an interactive terminal +neither flag is needed. + +**Where the phrase goes is a deliberate choice, not a default.** When stdout +is not a terminal the command refuses to generate anything rather than write +your only copy of the seed into a pipe — a CI log, a captured transcript, an +AI agent's context. Nothing is created in that case, so there is no +half-made profile to clean up. + +Write the phrase down offline and delete the file. It is the only way to +recover the vault: everything else is derived from it, and SeedPass keeps no +copy you can read without it. + ## Recovering from nothing With only your seed phrase and a relay that has your snapshot: diff --git a/js/packages/cli/src/agent.ts b/js/packages/cli/src/agent.ts index 9252cf8d..b85b0f93 100644 --- a/js/packages/cli/src/agent.ts +++ b/js/packages/cli/src/agent.ts @@ -743,6 +743,18 @@ export class AgentClient { }); socket.on("error", (e) => { clearTimeout(timer); + // "No agent running" is an ordinary state, not a fault, and it is the + // first thing a new user hits. Raw `connect ENOENT /…/agent.sock` + // makes them go read the source to find out what to do about it. + const code = (e as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ECONNREFUSED") { + reject( + new Error( + "no session agent is running (start one with 'seedpass-js agent start')", + ), + ); + return; + } reject(e); }); }); diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index 77e12dac..8bed0e8a 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -33,7 +33,19 @@ export function capabilities(): Record { }, profiles: { layout: "python-compatible ~/.seedpass", - commands: ["fingerprint list/add/switch/remove", "config get/set"], + commands: [ + "fingerprint list/create/add/switch/remove", + "config get/set", + ], + seed_generation: { + command: "fingerprint create", + word_counts: [12, 24], + entropy: "32 bytes from the OS CSPRNG, via BIP-85 app 39 index 0", + egress: + "the new phrase goes to --out <0600 file>, --show, or an interactive " + + "terminal; with none of those the command refuses rather than writing " + + "the seed into a pipe", + }, }, tokens: { commands: ["agent token-issue/token-list/token-revoke"], @@ -50,10 +62,14 @@ export function capabilities(): Record { commands: ["agent audit-verify", "agent audit-tail"], chain: "HMAC-SHA256(prev_sig + canonical_payload), keyed by KEY_INDEX", }, + // Automation branches on this list, so it has to describe this build + // rather than an earlier one. SSH and ed25519 PGP entries are at + // byte-for-byte parity with Python and were wrongly listed here. not_yet_ported: [ - "ssh/pgp key material", + "pgp RSA keys (ed25519 is supported; RSA generation is not reproducible)", "index0/atlas", "approval gates and high-risk partitions", + "semantic (vector search) and api (FastAPI server) command groups", ], }; } diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index ae1471a9..1a937f01 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -26,6 +26,7 @@ import { exportBackup, generateFingerprint, assertValidMnemonic, + generateMnemonic, modifyEntry, archiveEntry, restoreEntry, @@ -91,6 +92,25 @@ function parseIntOption(raw: string, name: string, opts: { min?: number; max?: n return value; } +/** + * Normalize a variadic --tags value. + * + * The option is space-separated (`--tags work dev`), but comma-separated is + * what people type, and `--tags work,dev` silently stored the single tag + * "work,dev" — no error, just a tag that never matches a search. Accept both + * spellings, trim, and drop empties so `--tags "a, b"` behaves too. + */ +function normalizeTags(tags: string[]): string[] { + const seen = new Set(); + for (const raw of tags) { + for (const part of raw.split(",")) { + const tag = part.trim(); + if (tag) seen.add(tag); + } + } + return [...seen]; +} + /** Unix timestamps may be large but must still be real integers. */ function parseUnixTime(raw: string): number { return parseIntOption(raw, "--at", { min: 0 }); @@ -441,7 +461,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { const commonOpts = (o: { notes?: string; tags?: string[]; archived?: boolean }) => ({ notes: o.notes ?? "", - ...(o.tags !== undefined && { tags: o.tags }), + ...(o.tags !== undefined && { tags: normalizeTags(o.tags) }), ...(o.archived !== undefined && { archived: o.archived }), }); @@ -615,7 +635,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { addDocumentEntry(vault.index, o.label || base.replace(/\.[^.]*$/, ""), content, { fileType: ext || "txt", notes: o.notes, - ...(o.tags !== undefined && { tags: o.tags }), + ...(o.tags !== undefined && { tags: normalizeTags(o.tags) }), }), ); }, @@ -716,7 +736,7 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ...(o.username !== undefined && { username: o.username }), ...(o.url !== undefined && { url: o.url }), ...(o.notes !== undefined && { notes: o.notes }), - ...(o.tags !== undefined && { tags: o.tags }), + ...(o.tags !== undefined && { tags: normalizeTags(o.tags) }), ...(o.period !== undefined && { period: parseIntOption(o.period, "--period", { min: 1 }) }), ...(o.digits !== undefined && { digits: parseIntOption(o.digits, "--digits", { min: 6, max: 10 }) }), ...(o.key !== undefined && { key: o.key }), @@ -886,6 +906,16 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { ), }), ); + + // Adopt the sink child's exit status as our own. Delivering a secret + // to a command that then failed is not a success: `seedpass-js use + // db-pass --exec ./deploy.sh && echo deployed` printed "deployed" + // after a failed deploy, because we only reported the child's code in + // JSON and always exited 0 ourselves. + const childExit = result["exitCode"]; + if (typeof childExit === "number" && childExit !== 0) { + process.exitCode = childExit > 0 ? childExit : 1; + } }, ); @@ -923,6 +953,73 @@ export function buildProgram(io: ProgramIo = defaultIo): Command { io.out(JSON.stringify({ fingerprint: fp, name: o.name ?? null })); }); + fingerprint + .command("create") + .description("generate a NEW master seed and create a profile from it") + .option("--name ", "human-readable profile name") + .option("--words ", "seed length: 12 or 24", "12") + .option("--show", "print the new seed phrase to stdout") + .option("--out ", "write the new seed phrase to a 0600 file instead of printing") + .action(async (o: { name?: string; words: string; show?: boolean; out?: string }) => { + const app = new AppDir(resolveAppDir((program.opts() as GlobalOpts).appDir)); + const password = process.env["SEEDPASS_PASSWORD"]; + if (!password) throw new Error("SEEDPASS_PASSWORD is not set"); + const words = parseIntOption(o.words, "--words", { min: 12, max: 24 }); + if (words !== 12 && words !== 24) throw new Error("--words must be 12 or 24"); + if (o.show && o.out) throw new Error("--show and --out are mutually exclusive"); + + // Decide how the phrase reaches the human BEFORE generating anything. + // This is the only secret in SeedPass with no other copy: creating the + // profile first and failing to deliver the phrase afterwards would + // leave a vault that nobody — including its owner — can ever reopen. + const toTty = !o.out && !o.show && Boolean(process.stdout.isTTY); + if (!o.out && !o.show && !toTty) { + throw new Error( + "refusing to generate a seed with nowhere safe to put it: stdout is not a " + + "terminal, so the phrase would be captured by whatever is reading this " + + "pipe (a log, a transcript, an AI agent's context). Pass --out to " + + "write it to a 0600 file, or --show if you really want it on stdout.", + ); + } + + const mnemonic = generateMnemonic(words); + + // Deliver first, create second. If the write fails we abort having + // created nothing, rather than orphaning an unrecoverable profile. + if (o.out) { + // "wx" so an existing file is never clobbered, and 0600 from the + // moment it exists rather than chmod'd afterwards. + try { + await writeFile(o.out, mnemonic + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error( + `${o.out} already exists, and overwriting it would destroy whatever seed ` + + `is in it. Choose another path.`, + ); + } + throw e; + } + } else { + io.out(mnemonic); + } + + const fp = await app.createProfile(mnemonic, password, o.name); + io.err( + "Write this seed phrase down and store it offline. It is the ONLY way to " + + "recover this vault: SeedPass derives every secret from it and keeps no " + + "copy you can read without it.", + ); + io.out( + JSON.stringify({ + fingerprint: fp, + name: o.name ?? null, + words, + seed_written_to: o.out ?? null, + }), + ); + }); + fingerprint .command("switch ") .description("set the active profile") diff --git a/js/packages/cli/src/sinks.ts b/js/packages/cli/src/sinks.ts index c3a7d0db..b8a44be8 100644 --- a/js/packages/cli/src/sinks.ts +++ b/js/packages/cli/src/sinks.ts @@ -55,17 +55,57 @@ export function sinkEnv(extra: Record = {}): Record { + 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)); + }); +}); diff --git a/js/packages/cli/test/sinks.test.ts b/js/packages/cli/test/sinks.test.ts new file mode 100644 index 00000000..acd9f730 --- /dev/null +++ b/js/packages/cli/test/sinks.test.ts @@ -0,0 +1,70 @@ +/** + * Sink command-spec parsing. + * + * `--exec 'sh -c "..."'` is the most common sink invocation there is, and + * splitting the spec on whitespace alone broke exactly that shape: sh + * received the fragments of the quoted script as separate argv entries and + * died on an unterminated string. + */ + +import { describe, expect, it } from "vitest"; +import { parseCommandSpec } from "../src/sinks.js"; + +describe("parseCommandSpec", () => { + it("keeps a pre-split argv untouched", () => { + expect(parseCommandSpec(["wc", "-c"])).toEqual(["wc", ["-c"]]); + }); + + it("splits a single quoted spec on whitespace", () => { + expect(parseCommandSpec(["wc -c"])).toEqual(["wc", ["-c"]]); + }); + + it("treats a double-quoted section as one argument", () => { + expect(parseCommandSpec(['sh -c "echo hello world"'])).toEqual([ + "sh", + ["-c", "echo hello world"], + ]); + }); + + it("treats a single-quoted section as one argument", () => { + expect(parseCommandSpec(["sh -c 'echo hello world'"])).toEqual([ + "sh", + ["-c", "echo hello world"], + ]); + }); + + it("supports quotes nested inside the other quote style", () => { + expect(parseCommandSpec(["sh -c \"echo 'hi there'\""])).toEqual([ + "sh", + ["-c", "echo 'hi there'"], + ]); + }); + + it("honours backslash escapes outside single quotes", () => { + expect(parseCommandSpec(["cmd a\\ b"])).toEqual(["cmd", ["a b"]]); + expect(parseCommandSpec(["cmd 'a\\ b'"])).toEqual(["cmd", ["a\\ b"]]); + }); + + it("collapses runs of whitespace", () => { + expect(parseCommandSpec([" wc -c "])).toEqual(["wc", ["-c"]]); + }); + + it("does not expand variables — no shell is involved", () => { + // The secret arrives via the environment; a spec that looks like a shell + // expansion must reach the child literally rather than being substituted + // by us. + expect(parseCommandSpec(["cmd $SEEDPASS_SECRET"])).toEqual([ + "cmd", + ["$SEEDPASS_SECRET"], + ]); + }); + + it("rejects an unterminated quote instead of guessing", () => { + expect(() => parseCommandSpec(['sh -c "oops'])).toThrow(/unterminated double quote/); + expect(() => parseCommandSpec(["sh -c 'oops"])).toThrow(/unterminated single quote/); + }); + + it("rejects an empty command", () => { + expect(() => parseCommandSpec([""])).toThrow(/empty command/); + }); +}); diff --git a/js/packages/core/src/derive/bip85.ts b/js/packages/core/src/derive/bip85.ts index 1bb72847..d7e09643 100644 --- a/js/packages/core/src/derive/bip85.ts +++ b/js/packages/core/src/derive/bip85.ts @@ -11,6 +11,9 @@ import { HDKey } from "@scure/bip32"; import { hmac } from "@noble/hashes/hmac.js"; import { sha512 } from "@noble/hashes/sha2.js"; +// Not node:crypto — core also runs in the browser extension, and noble's +// randomBytes is backed by crypto.getRandomValues in both places. +import { randomBytes } from "@noble/hashes/utils.js"; import { mnemonicToSeedSync, entropyToMnemonic, @@ -57,6 +60,35 @@ export function assertValidMnemonic(mnemonic: string, context = "mnemonic"): voi } } +/** Master-seed word counts SeedPass will generate (parity: SUPPORTED_SEED_WORD_COUNTS). */ +export const SUPPORTED_SEED_WORD_COUNTS = [12, 24] as const; +export type SeedWordCount = (typeof SUPPORTED_SEED_WORD_COUNTS)[number]; +export const DEFAULT_SEED_WORD_COUNT: SeedWordCount = 12; + +/** + * Generate a brand-new master seed phrase, matching + * `PasswordManager.generate_bip85_seed`: 32 bytes of OS entropy, then the + * BIP-85 app-39 child at index 0. + * + * `randomBytes` is called OUTSIDE any try/catch on purpose (Python carries the + * same note, from its entropy audit). Nothing here may catch an entropy + * failure and substitute a value — that is the class of bug that made + * COLDCARD generate predictable seeds. If the CSPRNG fails, the throw + * propagates and the operation aborts, which is the only acceptable outcome. + */ +export function generateMnemonic(wordsNum: SeedWordCount = DEFAULT_SEED_WORD_COUNT): string { + if (!(SUPPORTED_SEED_WORD_COUNTS as readonly number[]).includes(wordsNum)) { + throw new Error( + `seed word count must be one of ${SUPPORTED_SEED_WORD_COUNTS.join(", ")}, got ${wordsNum}`, + ); + } + const masterSeed = randomBytes(32); + const mnemonic = new Bip85(masterSeed).deriveMnemonic(0, wordsNum); + // Belt and braces: never hand back a phrase we could not ourselves reopen. + assertValidMnemonic(mnemonic, "generated mnemonic"); + return mnemonic; +} + const HMAC_KEY = utf8("bip-entropy-from-k"); export interface DeriveEntropyOptions { diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 64cc637c..44121c59 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -10,6 +10,10 @@ export { isValidMnemonic, assertValidMnemonic, canonicalizeMnemonic, + generateMnemonic, + SUPPORTED_SEED_WORD_COUNTS, + DEFAULT_SEED_WORD_COUNT, + type SeedWordCount, type DeriveEntropyOptions, } from "./derive/bip85.js"; export {