mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-08 13:52:29 +00:00
TS port slice 7: entry modify/archive/links + util generate-password
- core entryMod: modify_entry port with the per-kind allowed-field matrix, policy merging, blacklisted-field cleanup and timestamp touching; archive/restore; typed links (add/remove/get with Python's normalization, dedupe on target/relation/note, self-link and missing-target refusal). - Parity fixture entry_mods.json: the real Python EntryManager applies a 10-op modification sequence at a later pinned clock; TS replays the same ops from the creation state and matches byte-for-byte. - CLI: entry modify/archive/unarchive/links/link-add/link-remove, entry totp-codes (explicit egress, honors --at and archived state), util generate-password (Python parity: index 0 + gen v1 defaults, full policy flags — v1/v2 outputs verified against password fixtures). - capabilities: egress command list updated. - 148 core parity tests (Node + jsdom) + 22 CLI tests green. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
1 parent
774c992153
commit
4f2b8a79bf
11 files changed
+868
-9
No files matched your search
@@ -18,14 +18,18 @@ export function capabilities(): Record<string, unknown> {
|
||||
agent_surface: {
|
||||
reference_scheme: "sp://entry/<id>",
|
||||
reference_first_output: true,
|
||||
plaintext_egress_commands: ["entry reveal"],
|
||||
plaintext_egress_commands: [
|
||||
"entry reveal",
|
||||
"entry totp-codes",
|
||||
"util generate-password",
|
||||
],
|
||||
sinks: ["clipboard", "exec-env", "stdin-to"],
|
||||
exec_env_var: "SEEDPASS_SECRET",
|
||||
},
|
||||
not_yet_ported: [
|
||||
"ssh/pgp key material",
|
||||
"relay sync commands",
|
||||
"profile management",
|
||||
"profile and config management",
|
||||
"index0/atlas",
|
||||
"leases and scoped tokens (design: plan section 9.3)",
|
||||
],
|
||||
|
||||
@@ -18,6 +18,18 @@ import {
|
||||
importBackup,
|
||||
exportBackup,
|
||||
generateFingerprint,
|
||||
modifyEntry,
|
||||
archiveEntry,
|
||||
restoreEntry,
|
||||
addLink,
|
||||
removeLink,
|
||||
getLinks,
|
||||
generatePassword,
|
||||
Bip85,
|
||||
totpCodeAt,
|
||||
deriveTotpSecret,
|
||||
type ModifyChanges,
|
||||
type PasswordPolicy,
|
||||
addPasswordEntry,
|
||||
addTotpDeterministic,
|
||||
addTotpImported,
|
||||
@@ -256,6 +268,137 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||
await finishAdd(vault, id);
|
||||
});
|
||||
|
||||
entry
|
||||
.command("modify <refOrQuery>")
|
||||
.description("update entry fields (kind-checked); prints the new metadata")
|
||||
.option("--label <text>")
|
||||
.option("--username <text>")
|
||||
.option("--url <text>")
|
||||
.option("--notes <text>")
|
||||
.option("--tags <tag...>")
|
||||
.option("--period <n>")
|
||||
.option("--digits <n>")
|
||||
.option("--key <text>")
|
||||
.option("--value <text>", "new secret value (key_value/managed_account)")
|
||||
.option("--content <text>", "new document content")
|
||||
.option("--file-type <ext>")
|
||||
.action(
|
||||
async (
|
||||
refOrQuery: string,
|
||||
o: {
|
||||
label?: string; username?: string; url?: string; notes?: string;
|
||||
tags?: string[]; period?: string; digits?: string; key?: string;
|
||||
value?: string; content?: string; fileType?: string;
|
||||
},
|
||||
) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
const changes: ModifyChanges = {
|
||||
...(o.label !== undefined && { label: o.label }),
|
||||
...(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.period !== undefined && { period: Number(o.period) }),
|
||||
...(o.digits !== undefined && { digits: Number(o.digits) }),
|
||||
...(o.key !== undefined && { key: o.key }),
|
||||
...(o.value !== undefined && { value: o.value }),
|
||||
...(o.content !== undefined && { content: o.content }),
|
||||
...(o.fileType !== undefined && { file_type: o.fileType }),
|
||||
};
|
||||
if (Object.keys(changes).length === 0) throw new Error("no changes given");
|
||||
modifyEntry(vault.index, hit.id, changes);
|
||||
await saveVault(vault);
|
||||
io.out(JSON.stringify(entryMetadata(hit.id, vault.index.entries[hit.id]!), null, 2));
|
||||
},
|
||||
);
|
||||
|
||||
entry
|
||||
.command("archive <refOrQuery>")
|
||||
.description("archive an entry")
|
||||
.action(async (refOrQuery: string) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
archiveEntry(vault.index, hit.id);
|
||||
await saveVault(vault);
|
||||
io.out(JSON.stringify({ ref: hit.ref, archived: true }));
|
||||
});
|
||||
|
||||
entry
|
||||
.command("unarchive <refOrQuery>")
|
||||
.description("restore an archived entry")
|
||||
.action(async (refOrQuery: string) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
restoreEntry(vault.index, hit.id);
|
||||
await saveVault(vault);
|
||||
io.out(JSON.stringify({ ref: hit.ref, archived: false }));
|
||||
});
|
||||
|
||||
entry
|
||||
.command("links <refOrQuery>")
|
||||
.description("list an entry's links with resolved targets")
|
||||
.action(async (refOrQuery: string) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
io.out(JSON.stringify(getLinks(vault.index, hit.id), null, 2));
|
||||
});
|
||||
|
||||
entry
|
||||
.command("link-add <refOrQuery> <target>")
|
||||
.description("link an entry to another")
|
||||
.option("--relation <name>", "relation type", "related_to")
|
||||
.option("--note <text>", "link note", "")
|
||||
.action(async (refOrQuery: string, target: string, o: { relation: string; note: string }) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
const targetHit = resolveEntry(vault.index, target);
|
||||
const links = addLink(vault.index, hit.id, Number(targetHit.id), {
|
||||
relation: o.relation,
|
||||
note: o.note,
|
||||
});
|
||||
await saveVault(vault);
|
||||
io.out(JSON.stringify({ ref: hit.ref, links }, null, 2));
|
||||
});
|
||||
|
||||
entry
|
||||
.command("link-remove <refOrQuery> <target>")
|
||||
.description("remove links to a target entry")
|
||||
.option("--relation <name>", "only remove this relation")
|
||||
.action(async (refOrQuery: string, target: string, o: { relation?: string }) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const hit = resolveEntry(vault.index, refOrQuery);
|
||||
const targetHit = resolveEntry(vault.index, target);
|
||||
const links = removeLink(vault.index, hit.id, Number(targetHit.id), {
|
||||
...(o.relation !== undefined && { relation: o.relation }),
|
||||
});
|
||||
await saveVault(vault);
|
||||
io.out(JSON.stringify({ ref: hit.ref, links }, null, 2));
|
||||
});
|
||||
|
||||
entry
|
||||
.command("totp-codes")
|
||||
.description("PLAINTEXT EGRESS: current codes for all active TOTP entries")
|
||||
.option("--at <timestamp>", "unix time to compute codes at")
|
||||
.action(async (cmdOpts: { at?: string }) => {
|
||||
const vault = await openFromOptions(program.opts());
|
||||
const ts = cmdOpts.at !== undefined ? Number(cmdOpts.at) : Math.floor(Date.now() / 1000);
|
||||
const rows = Object.entries(vault.index.entries)
|
||||
.filter(([, e]) => e.kind === "totp" && !e.archived)
|
||||
.map(([id, e]) => {
|
||||
const totp = e as { secret?: string; index?: number; period: number; digits: number; label: string };
|
||||
const secret = totp.secret ?? deriveTotpSecret(vault.mnemonic, totp.index ?? 0);
|
||||
return {
|
||||
ref: `sp://entry/${id}`,
|
||||
label: totp.label,
|
||||
code: totpCodeAt(secret, ts, totp.period, totp.digits),
|
||||
period: totp.period,
|
||||
seconds_remaining: totp.period - (ts % totp.period),
|
||||
};
|
||||
});
|
||||
io.out(JSON.stringify(rows, null, 2));
|
||||
});
|
||||
|
||||
entry
|
||||
.command("reveal <refOrQuery>")
|
||||
.description("PLAINTEXT EGRESS: print the secret to stdout")
|
||||
@@ -313,6 +456,50 @@ export function buildProgram(io: ProgramIo = defaultIo): Command {
|
||||
},
|
||||
);
|
||||
|
||||
const util = program.command("util").description("utility commands");
|
||||
|
||||
util
|
||||
.command("generate-password")
|
||||
.description("derive a password (Python parity: index 0, gen v1 by default)")
|
||||
.option("--length <n>", "password length", "24")
|
||||
.option("--index <n>", "derivation index", "0")
|
||||
.option("--gen-version <v>", "generation version (1 or 2)", "1")
|
||||
.option("--no-special", "exclude special characters")
|
||||
.option("--allowed-special-chars <set>")
|
||||
.option("--special-mode <mode>")
|
||||
.option("--exclude-ambiguous")
|
||||
.option("--min-uppercase <n>")
|
||||
.option("--min-lowercase <n>")
|
||||
.option("--min-digits <n>")
|
||||
.option("--min-special <n>")
|
||||
.action(
|
||||
(o: {
|
||||
length: string; index: string; genVersion: string; special: boolean;
|
||||
allowedSpecialChars?: string; specialMode?: string; excludeAmbiguous?: boolean;
|
||||
minUppercase?: string; minLowercase?: string; minDigits?: string; minSpecial?: string;
|
||||
}) => {
|
||||
const policy: PasswordPolicy = {
|
||||
...(o.special === false && { includeSpecialChars: false }),
|
||||
...(o.allowedSpecialChars !== undefined && { allowedSpecialChars: o.allowedSpecialChars }),
|
||||
...(o.specialMode !== undefined && { specialMode: o.specialMode }),
|
||||
...(o.excludeAmbiguous !== undefined && { excludeAmbiguous: o.excludeAmbiguous }),
|
||||
...(o.minUppercase !== undefined && { minUppercase: Number(o.minUppercase) }),
|
||||
...(o.minLowercase !== undefined && { minLowercase: Number(o.minLowercase) }),
|
||||
...(o.minDigits !== undefined && { minDigits: Number(o.minDigits) }),
|
||||
...(o.minSpecial !== undefined && { minSpecial: Number(o.minSpecial) }),
|
||||
};
|
||||
const bip85 = Bip85.fromMnemonic(requireMnemonic());
|
||||
io.out(
|
||||
generatePassword(bip85, {
|
||||
length: Number(o.length),
|
||||
index: Number(o.index),
|
||||
genVersion: Number(o.genVersion),
|
||||
policy,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const vaultCmd = program.command("vault").description("vault import/export");
|
||||
|
||||
vaultCmd
|
||||
|
||||
@@ -14,6 +14,7 @@ import process from "node:process";
|
||||
import {
|
||||
entriesIndex,
|
||||
entrySecrets,
|
||||
passwordV1Cases,
|
||||
passwordV2Cases,
|
||||
totpCases,
|
||||
mnemonics,
|
||||
@@ -247,6 +248,90 @@ describe("sink delivery keeps secrets out of CLI output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("modification commands", () => {
|
||||
let modPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "seedpass-mod-"));
|
||||
modPath = join(dir, "vault.enc");
|
||||
await writeFile(modPath, await readFile(vaultPath));
|
||||
});
|
||||
|
||||
it("entry modify updates fields and bumps timestamps", async () => {
|
||||
const before = JSON.parse(
|
||||
(await run("--vault", modPath, "entry", "get", "example.com")).stdout,
|
||||
);
|
||||
const r = await run(
|
||||
"--vault", modPath, "entry", "modify", "example.com",
|
||||
"--username", "carol", "--tags", "web", "staging",
|
||||
);
|
||||
const row = JSON.parse(r.stdout);
|
||||
expect(row.username).toBe("carol");
|
||||
expect(row.tags).toEqual(["web", "staging"]);
|
||||
expect(row.modified_ts).toBeGreaterThan(before.modified_ts);
|
||||
});
|
||||
|
||||
it("entry modify rejects fields the kind does not allow", async () => {
|
||||
const r = await run(
|
||||
"--vault", modPath, "entry", "modify", "example-totp", "--username", "x",
|
||||
);
|
||||
expect(String((r.error as Error).message)).toContain("does not support fields");
|
||||
});
|
||||
|
||||
it("archive hides from totp-codes; unarchive restores", async () => {
|
||||
await run("--vault", modPath, "entry", "archive", "example-totp");
|
||||
const archived = JSON.parse(
|
||||
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
|
||||
) as { label: string }[];
|
||||
expect(archived.map((x) => x.label)).not.toContain("example-totp");
|
||||
|
||||
await run("--vault", modPath, "entry", "unarchive", "example-totp");
|
||||
const restored = JSON.parse(
|
||||
(await run("--vault", modPath, "entry", "totp-codes", "--at", "1700000000")).stdout,
|
||||
) as { label: string; code: string }[];
|
||||
const det = restored.find((x) => x.label === "example-totp")!;
|
||||
expect(det.code).toBe(entrySecrets.totp_entry_1_code_at["1700000000"]);
|
||||
});
|
||||
|
||||
it("link-add/links/link-remove round-trip with resolved targets", async () => {
|
||||
await run(
|
||||
"--vault", modPath, "entry", "link-add", "example.com", "example-totp",
|
||||
"--relation", "totp", "--note", "2fa",
|
||||
);
|
||||
const links = JSON.parse(
|
||||
(await run("--vault", modPath, "entry", "links", "example.com")).stdout,
|
||||
);
|
||||
expect(links).toEqual([
|
||||
{ target_id: 1, relation: "totp", note: "2fa", target_label: "example-totp", target_kind: "totp" },
|
||||
]);
|
||||
const after = await run(
|
||||
"--vault", modPath, "entry", "link-remove", "example.com", "example-totp",
|
||||
);
|
||||
expect(JSON.parse(after.stdout).links).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("util generate-password", () => {
|
||||
it("matches the v1 fixture at default index/version", async () => {
|
||||
const expected = passwordV1Cases.find(
|
||||
(c) => c.policy === "default" && c.length === 16 && c.index === 0,
|
||||
)!.password;
|
||||
const r = await run("util", "generate-password", "--length", "16");
|
||||
expect(r.stdout).toBe(expected);
|
||||
});
|
||||
|
||||
it("matches v2 and policy fixtures", async () => {
|
||||
const v2 = passwordV2Cases.find(
|
||||
(c) => c.policy === "safe_special" && c.length === 40 && c.index === 2,
|
||||
)!.password;
|
||||
const r = await run(
|
||||
"util", "generate-password", "--length", "40", "--index", "2",
|
||||
"--gen-version", "2", "--special-mode", "safe",
|
||||
);
|
||||
expect(r.stdout).toBe(v2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("vault export/import", () => {
|
||||
it("exports an encrypted portable backup the importer accepts", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "seedpass-export-"));
|
||||
|
||||
Reference in new issue
Block a user