mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-05 04:12:03 +00:00
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]>
This commit is contained in:
1 parent
0dfeca8a50
commit
b0e2a309a4
9 files changed
+466
-9
No files matched your search
@@ -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<string>();
|
||||
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 <name>", "human-readable profile name")
|
||||
.option("--words <n>", "seed length: 12 or 24", "12")
|
||||
.option("--show", "print the new seed phrase to stdout")
|
||||
.option("--out <file>", "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 <file> 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 <fp>")
|
||||
.description("set the active profile")
|
||||
|
||||
Reference in new issue
Block a user