mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 16:02:05 +00:00
Secret input: - Values that are secrets can now be read from stdin: `entry add key-value --stdin`, `entry add totp --secret-stdin`, `entry add document --stdin`, and `entry modify --value-stdin`. Arguments are visible in shell history and process listings, so the positional forms now warn (without echoing the secret they are warning about) rather than silently accepting. Packaging (the published package was broken): - `exports` pointed at src/index.ts while `files` shipped only dist/, so an installed package had no entry point; exports now points at the bundle. - A `workspace:*` runtime dependency would have failed any real install. The bundle inlines everything, so the package now declares no runtime dependencies at all. Verified by packing the tarball, installing it into a clean project and running the binary -- and CI now does the same on every change, because this class of mistake is invisible to build and test. - The workspace globs cover apps/* so a future extension cannot sit outside CI. - @types/node aligned with the advertised Node 22 runtime. Release and supply chain: - release-integrity now builds the CLI bundle into dist/ before checksums are generated and signed, so the executable TypeScript artifact is covered by the same manifest and cosign signature as the Python ones. - A new javascript-supply-chain job runs `pnpm audit` and produces a CycloneDX SBOM; the JS tree previously had neither. Also documents two findings that are protocol-level and shared with Python, where changing the port alone would break convergence: tombstone retention allowing deletion replay after the 2048-entry cap, and SSH/PGP sharing a derivation domain (same index yields the same Ed25519 key). Both need a versioned change in both implementations and are recorded in the compatibility matrix rather than silently patched here. 193 core + 70 CLI tests green; pnpm audit clean. Co-Authored-By: Claude Fable 5 <[email protected]>
152 lines
6.1 KiB
TypeScript
152 lines
6.1 KiB
TypeScript
/**
|
|
* Durability and input-validation guarantees for vault writes.
|
|
*
|
|
* From a security review: a bad numeric option once persisted NaN as JSON
|
|
* null and made every later open fail; writes truncated the target in place
|
|
* with no lock, so a crash or a concurrent command could lose the vault.
|
|
*/
|
|
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { mkdtemp, readFile, writeFile, stat, chmod } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import process from "node:process";
|
|
import { mnemonics, entriesIndex } from "@seedpass/test-vectors";
|
|
import { deriveIndexKeyBytes, encryptV3, utf8 } from "@seedpass/core";
|
|
import { buildProgram, AppDir, type ProgramIo } from "../src/index.js";
|
|
|
|
const MNEMONIC = mnemonics["abandon12"]!;
|
|
let vaultPath: string;
|
|
let dir: string;
|
|
|
|
async function run(...argv: string[]): Promise<{ stdout: string; error?: unknown }> {
|
|
const out: string[] = [];
|
|
const io: ProgramIo = { out: (l: string) => out.push(l), err: () => {} };
|
|
let error: unknown;
|
|
const saved = process.env["SEEDPASS_MNEMONIC"];
|
|
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
|
|
try {
|
|
await buildProgram(io).parseAsync(["node", "seedpass-js", ...argv]);
|
|
} catch (e) {
|
|
error = e;
|
|
} finally {
|
|
if (saved === undefined) delete process.env["SEEDPASS_MNEMONIC"];
|
|
else process.env["SEEDPASS_MNEMONIC"] = saved;
|
|
}
|
|
return { stdout: out.join("\n"), error };
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "seedpass-durability-"));
|
|
vaultPath = join(dir, "vault.enc");
|
|
await writeFile(
|
|
vaultPath,
|
|
await encryptV3(deriveIndexKeyBytes(MNEMONIC), utf8(JSON.stringify(entriesIndex.entries))),
|
|
);
|
|
});
|
|
|
|
describe("numeric input validation", () => {
|
|
it("rejects a non-numeric length instead of persisting NaN", async () => {
|
|
const before = await readFile(vaultPath);
|
|
const r = await run("--vault", vaultPath, "entry", "add", "password", "bad", "--length", "not-a-number");
|
|
expect(String((r.error as Error).message)).toContain("whole number");
|
|
// The vault is untouched and still opens
|
|
expect(await readFile(vaultPath)).toEqual(before);
|
|
expect((await run("--vault", vaultPath, "entry", "list")).error).toBeUndefined();
|
|
});
|
|
|
|
it("rejects out-of-range and fractional values", async () => {
|
|
for (const bad of ["0", "5", "999", "16.5", "1e3", "", " "]) {
|
|
const r = await run("--vault", vaultPath, "entry", "add", "password", "bad", "--length", bad);
|
|
expect(r.error, `--length ${JSON.stringify(bad)} should be refused`).toBeTruthy();
|
|
}
|
|
const totp = await run(
|
|
"--vault", vaultPath, "entry", "add", "totp", "bad", "--period", "abc",
|
|
);
|
|
expect(String((totp.error as Error).message)).toContain("whole number");
|
|
});
|
|
|
|
it("still accepts valid values", async () => {
|
|
const r = await run("--vault", vaultPath, "entry", "add", "password", "good", "--length", "24");
|
|
expect(r.error).toBeUndefined();
|
|
expect(JSON.parse(r.stdout).length).toBe(24);
|
|
});
|
|
});
|
|
|
|
describe("atomic, permission-correct writes", () => {
|
|
it("leaves no temp files and keeps the vault at 0600", async () => {
|
|
await chmod(vaultPath, 0o644); // simulate a loosened file
|
|
await run("--vault", vaultPath, "entry", "add", "key-value", "perm", "k", "v");
|
|
const info = await stat(vaultPath);
|
|
// writeFile's mode only applies on creation, so this must be repaired
|
|
expect(info.mode & 0o077).toBe(0);
|
|
const leftovers = (await import("node:fs/promises")).readdir(dir);
|
|
expect((await leftovers).filter((f) => f.includes(".tmp"))).toEqual([]);
|
|
expect(existsSync(`${vaultPath}.lock`)).toBe(false);
|
|
});
|
|
|
|
it("survives concurrent writers without losing entries", async () => {
|
|
const labels = ["c1", "c2", "c3", "c4", "c5"];
|
|
await Promise.all(
|
|
labels.map((label) =>
|
|
run("--vault", vaultPath, "entry", "add", "key-value", label, "k", "v"),
|
|
),
|
|
);
|
|
const rows = JSON.parse((await run("--vault", vaultPath, "entry", "list")).stdout) as {
|
|
label: string;
|
|
}[];
|
|
const seen = rows.map((r) => r.label);
|
|
// Without locking, concurrent read-modify-write cycles silently drop
|
|
// entries; every one of these must be present.
|
|
for (const label of labels) expect(seen).toContain(label);
|
|
});
|
|
});
|
|
|
|
describe("secrets on the command line", () => {
|
|
it("warns when a secret arrives as an argument", async () => {
|
|
const out: string[] = [];
|
|
const err: string[] = [];
|
|
const io: ProgramIo = { out: (l) => out.push(l), err: (l) => err.push(l) };
|
|
const saved = process.env["SEEDPASS_MNEMONIC"];
|
|
process.env["SEEDPASS_MNEMONIC"] = MNEMONIC;
|
|
try {
|
|
await buildProgram(io).parseAsync([
|
|
"node", "seedpass-js", "--vault", vaultPath,
|
|
"entry", "add", "key-value", "argv-secret", "k", "exposed-on-argv",
|
|
]);
|
|
} finally {
|
|
if (saved === undefined) delete process.env["SEEDPASS_MNEMONIC"];
|
|
else process.env["SEEDPASS_MNEMONIC"] = saved;
|
|
}
|
|
expect(err.join("\n")).toContain("shell history");
|
|
// The warning must not repeat the secret it is warning about
|
|
expect(err.join("\n")).not.toContain("exposed-on-argv");
|
|
});
|
|
});
|
|
|
|
describe("profile path validation", () => {
|
|
it("refuses a fingerprint that is not 16 hex characters", async () => {
|
|
const app = new AppDir(dir);
|
|
expect(() => app.profileDir("../../etc")).toThrow(/invalid profile fingerprint/);
|
|
await expect(app.removeProfile("../../etc")).rejects.toThrow(/invalid profile fingerprint/);
|
|
await expect(app.switchProfile("nope")).rejects.toThrow(/invalid profile fingerprint/);
|
|
});
|
|
|
|
it("ignores malformed entries in the fingerprint registry", async () => {
|
|
const appRoot = await mkdtemp(join(tmpdir(), "seedpass-registry-"));
|
|
await writeFile(
|
|
join(appRoot, "fingerprints.json"),
|
|
JSON.stringify({
|
|
fingerprints: ["../../evil", "0123456789ABCDEF"],
|
|
last_used: "../../evil",
|
|
names: {},
|
|
}),
|
|
);
|
|
const app = new AppDir(appRoot);
|
|
const data = await app.readFingerprints();
|
|
expect(data.fingerprints).toEqual(["0123456789ABCDEF"]);
|
|
expect(data.last_used).toBeNull();
|
|
});
|
|
});
|