mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-30 09:22:03 +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
@@ -37,16 +37,17 @@ until a later milestone.
|
||||
| index0 content merge (`_system.index0`) | P2 | partial | — | Empty-skeleton normalization matches Python; merging populated index0 event logs throws loudly instead of silently dropping data. Full port belongs to the atlas milestone. |
|
||||
| Nostr event layer (NIP-01 ids, BIP-340 sign/verify, framing) | P0 | green | `nostr_events.json` | Event ids match rust-nostr (Python nostr_sdk) fixtures byte-for-byte; fixture signatures verify in TS; TS-signed events reproduce the same ids and verify. REQ/EVENT/EOSE/OK/NOTICE/CLOSED framing round-trips. Fixture signatures are cached across generator runs (BIP-340 aux randomness). WebSocket relay transport: todo (interface milestones). |
|
||||
| Portable backup import/export | P0 | green | `portable_backup.json` | format_version 1, seed-only and plaintext modes; TS imports the Python export, re-exports byte-identically under pinned nonce/timestamp, verifies canonical checksum, rejects tampering/unknown versions. |
|
||||
| Entry creation (provision) | P0 | green | `entries_index.json` | TS `add*Entry` ops rebuild the Python `EntryManager` fixture index byte-for-byte (pinned clock): field shapes, id allocation, independent TOTP derivation-index allocation, ISO timestamp format. Not ported: index0 event emission, modify/archive ops. |
|
||||
| Entry creation (provision) | P0 | green | `entries_index.json` | TS `add*Entry` ops rebuild the Python `EntryManager` fixture index byte-for-byte (pinned clock): field shapes, id allocation, independent TOTP derivation-index allocation, ISO timestamp format. Not ported: index0 event emission. |
|
||||
| Entry modify/archive/links | P0 | green | `entry_mods.json` | TS replays a Python `EntryManager` op sequence (modify with kind-checked field matrix + policy merge, archive/restore, link add/remove with normalization and dedupe) byte-for-byte, including timestamp touching. |
|
||||
| Entry secret retrieval | P0 | green | `entry_secrets.json` | Reveal values match Python retrieval for password (v2), TOTP-at-time, nostr entry nsec (BIP-85 app 39 — NOT the sync client's 1237), seed and managed-account mnemonics. |
|
||||
| CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/<id>`, secrets replaced by `has_*` flags), `entry add password/totp/key-value/document/seed/managed-account/nostr` (provision-blind: returns refs, never the created secret), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default or provisioning output. Not yet: modify/archive, profiles, relay sync, leases/tokens. |
|
||||
| CLI (Milestone 5, agent-blind MVP) | P1 | partial | — | `packages/cli` seedpass-js: capabilities, entry list/get/search (reference-first, `sp://entry/<id>`, secrets replaced by `has_*` flags), `entry add password/totp/key-value/document/seed/managed-account/nostr` (provision-blind: returns refs, never the created secret), `entry reveal` as the only plaintext egress, `use --clipboard/--exec/--stdin-to` sinks (env-var injection, never argv/stdout), vault export/import. Tests assert secrets never appear in default or provisioning output. Also: modify, archive/unarchive, links, totp-codes, util generate-password. Not yet: profiles/config, relay sync, leases/tokens. |
|
||||
|
||||
## Environment coverage
|
||||
|
||||
| Environment | Status |
|
||||
|---|---|
|
||||
| Node 22 (vitest) | green — 143/143 core + 16 CLI |
|
||||
| jsdom browser-like env | green — 143/143 core |
|
||||
| Node 22 (vitest) | green — 148/148 core + 22 CLI |
|
||||
| jsdom browser-like env | green — 148/148 core |
|
||||
| Real Chromium/Firefox (vitest browser mode) | todo — lands with Milestone 6 web app CI |
|
||||
|
||||
## Dependency notes (to expand into docs/typescript_dependency_review.md)
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -80,6 +80,18 @@ export {
|
||||
systemClock,
|
||||
type Clock,
|
||||
} from "./vault/entryOps.js";
|
||||
export {
|
||||
modifyEntry,
|
||||
archiveEntry,
|
||||
restoreEntry,
|
||||
addLink,
|
||||
removeLink,
|
||||
getLinks,
|
||||
normalizeLinks,
|
||||
type ModifyChanges,
|
||||
type EntryLink,
|
||||
type ResolvedLink,
|
||||
} from "./vault/entryMod.js";
|
||||
export { canonicalJson, canonicalHash } from "./sync/canonical.js";
|
||||
export {
|
||||
mergeIndexPayloads,
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Entry modification, archive/restore, and typed links.
|
||||
*
|
||||
* Parity target: src/seedpass/core/entry_management.py modify_entry,
|
||||
* add_link, remove_link, get_links, archive_entry, restore_entry and
|
||||
* _normalize_links. Same allowed-field matrix per kind, same link
|
||||
* normalization (dedupe on target/relation/note), same timestamp touching.
|
||||
* index0 event emission is not ported (matrix: atlas milestone).
|
||||
*/
|
||||
|
||||
import type { VaultIndex } from "../schema/entries.js";
|
||||
import { isoFromUnix, systemClock, type Clock } from "./entryOps.js";
|
||||
|
||||
type Dict = Record<string, unknown>;
|
||||
|
||||
export interface EntryLink {
|
||||
target_id: number;
|
||||
relation: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Canonical, de-duplicated links (EntryManager._normalize_links). */
|
||||
export function normalizeLinks(rawLinks: unknown): EntryLink[] {
|
||||
if (!Array.isArray(rawLinks)) return [];
|
||||
const normalized: EntryLink[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of rawLinks) {
|
||||
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
|
||||
const dict = item as Dict;
|
||||
const targetRaw = dict["target_id"];
|
||||
const targetId =
|
||||
typeof targetRaw === "number"
|
||||
? Math.trunc(targetRaw)
|
||||
: typeof targetRaw === "string" && /^[+-]?\d+$/.test(targetRaw.trim())
|
||||
? parseInt(targetRaw.trim(), 10)
|
||||
: null;
|
||||
if (targetId === null) continue;
|
||||
const relation = String(dict["relation"] ?? "related_to").trim().toLowerCase();
|
||||
const note = String(dict["note"] ?? "").trim();
|
||||
if (targetId < 0 || !relation) continue;
|
||||
const key = JSON.stringify([targetId, relation, note]);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push({ target_id: targetId, relation, note });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export interface ModifyChanges {
|
||||
label?: string;
|
||||
username?: string;
|
||||
url?: string;
|
||||
archived?: boolean;
|
||||
notes?: string;
|
||||
period?: number;
|
||||
digits?: number;
|
||||
key?: string;
|
||||
value?: string;
|
||||
content?: string;
|
||||
file_type?: string;
|
||||
custom_fields?: Dict[];
|
||||
tags?: string[];
|
||||
links?: Dict[];
|
||||
include_special_chars?: boolean;
|
||||
allowed_special_chars?: string;
|
||||
special_mode?: string;
|
||||
exclude_ambiguous?: boolean;
|
||||
min_uppercase?: number;
|
||||
min_lowercase?: number;
|
||||
min_digits?: number;
|
||||
min_special?: number;
|
||||
}
|
||||
|
||||
const COMMON_FIELDS = ["label", "archived", "notes", "custom_fields", "tags", "links"];
|
||||
const POLICY_FIELDS = [
|
||||
"include_special_chars",
|
||||
"allowed_special_chars",
|
||||
"special_mode",
|
||||
"exclude_ambiguous",
|
||||
"min_uppercase",
|
||||
"min_lowercase",
|
||||
"min_digits",
|
||||
"min_special",
|
||||
];
|
||||
|
||||
const ALLOWED_FIELDS: Record<string, Set<string>> = {
|
||||
password: new Set([...COMMON_FIELDS, "username", "url", ...POLICY_FIELDS]),
|
||||
totp: new Set([...COMMON_FIELDS, "period", "digits"]),
|
||||
key_value: new Set([...COMMON_FIELDS, "key", "value"]),
|
||||
managed_account: new Set([...COMMON_FIELDS, "value"]),
|
||||
document: new Set([...COMMON_FIELDS, "content", "file_type"]),
|
||||
ssh: new Set(COMMON_FIELDS),
|
||||
pgp: new Set(COMMON_FIELDS),
|
||||
nostr: new Set(COMMON_FIELDS),
|
||||
seed: new Set(COMMON_FIELDS),
|
||||
};
|
||||
|
||||
function entryDict(index: VaultIndex, id: string): Dict {
|
||||
const entry = (index.entries as unknown as Record<string, Dict>)[id];
|
||||
if (typeof entry !== "object" || entry === null) {
|
||||
throw new Error(`Entry not found: ${id}`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function touch(entry: Dict, clock: Clock): void {
|
||||
if (!("date_added" in entry)) {
|
||||
entry["date_added"] = isoFromUnix(Math.trunc(Number(entry["modified_ts"] ?? 0)));
|
||||
}
|
||||
const now = clock.nowUnix();
|
||||
entry["modified_ts"] = now;
|
||||
entry["date_modified"] = isoFromUnix(now);
|
||||
}
|
||||
|
||||
/** Modify an entry with the Python allowed-field matrix and timestamps. */
|
||||
export function modifyEntry(
|
||||
index: VaultIndex,
|
||||
id: string,
|
||||
changes: ModifyChanges,
|
||||
clock: Clock = systemClock,
|
||||
): void {
|
||||
const entry = entryDict(index, id);
|
||||
const entryType = String(entry["type"] ?? entry["kind"] ?? "password");
|
||||
const allowed = ALLOWED_FIELDS[entryType] ?? new Set<string>();
|
||||
|
||||
const provided = Object.entries(changes).filter(([, v]) => v !== undefined);
|
||||
const invalid = provided.map(([k]) => k).filter((k) => !allowed.has(k));
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`Entry type '${entryType}' does not support fields: ${invalid.sort().join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [field, value] of provided) {
|
||||
if (field === "links") {
|
||||
entry["links"] = normalizeLinks(value);
|
||||
} else if (field === "file_type") {
|
||||
entry["file_type"] = String(value).trim().toLowerCase();
|
||||
} else if (POLICY_FIELDS.includes(field)) {
|
||||
const policy = (entry["policy"] as Dict | undefined) ?? {};
|
||||
policy[field] = field.startsWith("min_") ? Math.trunc(Number(value)) : value;
|
||||
entry["policy"] = policy;
|
||||
} else {
|
||||
entry[field] = value;
|
||||
}
|
||||
}
|
||||
if (changes.archived !== undefined) {
|
||||
delete entry["blacklisted"];
|
||||
}
|
||||
|
||||
touch(entry, clock);
|
||||
}
|
||||
|
||||
export function archiveEntry(index: VaultIndex, id: string, clock: Clock = systemClock): void {
|
||||
modifyEntry(index, id, { archived: true }, clock);
|
||||
}
|
||||
|
||||
export function restoreEntry(index: VaultIndex, id: string, clock: Clock = systemClock): void {
|
||||
modifyEntry(index, id, { archived: false }, clock);
|
||||
}
|
||||
|
||||
/** Create or update a typed relationship between two entries. */
|
||||
export function addLink(
|
||||
index: VaultIndex,
|
||||
id: string,
|
||||
targetId: number,
|
||||
options: { relation?: string; note?: string; clock?: Clock } = {},
|
||||
): EntryLink[] {
|
||||
const src = entryDict(index, id);
|
||||
entryDict(index, String(targetId)); // must exist
|
||||
if (parseInt(id, 10) === Math.trunc(targetId)) {
|
||||
throw new Error("Self-referential links are not allowed");
|
||||
}
|
||||
const relation = String(options.relation ?? "related_to").trim().toLowerCase();
|
||||
const note = String(options.note ?? "").trim();
|
||||
const links = normalizeLinks(src["links"] ?? []);
|
||||
links.push({ target_id: Math.trunc(targetId), relation, note });
|
||||
src["links"] = normalizeLinks(links);
|
||||
touch(src, options.clock ?? systemClock);
|
||||
return src["links"] as EntryLink[];
|
||||
}
|
||||
|
||||
/** Remove links to targetId (optionally only a specific relation). */
|
||||
export function removeLink(
|
||||
index: VaultIndex,
|
||||
id: string,
|
||||
targetId: number,
|
||||
options: { relation?: string; clock?: Clock } = {},
|
||||
): EntryLink[] {
|
||||
const src = entryDict(index, id);
|
||||
const relationNorm =
|
||||
options.relation !== undefined ? options.relation.trim().toLowerCase() : null;
|
||||
const links = normalizeLinks(src["links"] ?? []);
|
||||
const filtered = links.filter((link) => {
|
||||
const matchesTarget = link.target_id === Math.trunc(targetId);
|
||||
const matchesRelation = relationNorm === null || link.relation === relationNorm;
|
||||
return !(matchesTarget && matchesRelation);
|
||||
});
|
||||
if (filtered.length !== links.length) {
|
||||
src["links"] = filtered;
|
||||
touch(src, options.clock ?? systemClock);
|
||||
}
|
||||
return (src["links"] ?? []) as EntryLink[];
|
||||
}
|
||||
|
||||
export interface ResolvedLink extends EntryLink {
|
||||
target_label: string | null;
|
||||
target_kind: string | null;
|
||||
}
|
||||
|
||||
/** Links for an entry with resolved target metadata. */
|
||||
export function getLinks(index: VaultIndex, id: string): ResolvedLink[] {
|
||||
const src = entryDict(index, id);
|
||||
const entries = index.entries as unknown as Record<string, Dict>;
|
||||
return normalizeLinks(src["links"] ?? []).map((link) => {
|
||||
const target = entries[String(link.target_id)];
|
||||
return {
|
||||
...link,
|
||||
target_label: target ? String(target["label"] ?? target["website"] ?? "") : null,
|
||||
target_kind: target ? String(target["type"] ?? target["kind"] ?? "") : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Modification parity: replay the exact op sequence the Python EntryManager
|
||||
* performed in the entry_mods fixture and require byte-for-byte equality.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { entriesIndex, entryMods } from "@seedpass/test-vectors";
|
||||
import {
|
||||
modifyEntry,
|
||||
archiveEntry,
|
||||
restoreEntry,
|
||||
addLink,
|
||||
removeLink,
|
||||
getLinks,
|
||||
normalizeLinks,
|
||||
type Clock,
|
||||
type VaultIndex,
|
||||
} from "@seedpass/core";
|
||||
|
||||
const modClock: Clock = { nowUnix: () => entryMods.mod_unix };
|
||||
|
||||
function creationState(): VaultIndex {
|
||||
return JSON.parse(JSON.stringify(entriesIndex.entries)) as VaultIndex;
|
||||
}
|
||||
|
||||
describe("entry modification parity", () => {
|
||||
it("replays the Python modification sequence byte-for-byte", () => {
|
||||
const index = creationState();
|
||||
|
||||
// Mirrors gen_entry_mods in scripts/generate_ts_port_fixtures.py
|
||||
modifyEntry(
|
||||
index,
|
||||
"0",
|
||||
{
|
||||
username: "alice2",
|
||||
url: "https://example.org",
|
||||
notes: "updated note",
|
||||
tags: ["web", "prod"],
|
||||
min_digits: 3,
|
||||
special_mode: "safe",
|
||||
},
|
||||
modClock,
|
||||
);
|
||||
modifyEntry(index, "1", { period: 60, digits: 8 }, modClock);
|
||||
modifyEntry(index, "5", { label: "api-token-renamed", value: "rotated-value" }, modClock);
|
||||
archiveEntry(index, "6", modClock);
|
||||
restoreEntry(index, "6", modClock);
|
||||
archiveEntry(index, "3", modClock);
|
||||
addLink(index, "0", 1, { relation: "totp", note: "2fa for site", clock: modClock });
|
||||
addLink(index, "0", 5, { relation: "related_to", clock: modClock });
|
||||
addLink(index, "5", 0, { clock: modClock });
|
||||
removeLink(index, "0", 5, { clock: modClock });
|
||||
|
||||
expect(JSON.parse(JSON.stringify(index))).toEqual(entryMods.entries);
|
||||
});
|
||||
|
||||
it("rejects fields the entry kind does not allow", () => {
|
||||
const index = creationState();
|
||||
expect(() => modifyEntry(index, "1", { username: "x" }, modClock)).toThrow(
|
||||
/does not support fields: username/,
|
||||
);
|
||||
expect(() => modifyEntry(index, "0", { period: 60 }, modClock)).toThrow(
|
||||
/does not support fields: period/,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses self-referential links and missing targets", () => {
|
||||
const index = creationState();
|
||||
expect(() => addLink(index, "0", 0, { clock: modClock })).toThrow("Self-referential");
|
||||
expect(() => addLink(index, "0", 99, { clock: modClock })).toThrow("Entry not found");
|
||||
});
|
||||
|
||||
it("resolves link target metadata", () => {
|
||||
const index = creationState();
|
||||
addLink(index, "0", 1, { relation: "totp", clock: modClock });
|
||||
const links = getLinks(index, "0");
|
||||
expect(links).toEqual([
|
||||
{
|
||||
target_id: 1,
|
||||
relation: "totp",
|
||||
note: "",
|
||||
target_label: "example-totp",
|
||||
target_kind: "totp",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes and dedupes links like Python", () => {
|
||||
expect(
|
||||
normalizeLinks([
|
||||
{ target_id: "3", relation: " Related_To ", note: " x " },
|
||||
{ target_id: 3, relation: "related_to", note: "x" },
|
||||
{ target_id: -1, relation: "r" },
|
||||
{ target_id: 4, relation: "" },
|
||||
"junk",
|
||||
]),
|
||||
).toEqual([{ target_id: 3, relation: "related_to", note: "x" }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"description": "Entries index after a modification sequence (modify_entry, archive/restore, add_link/remove_link) applied by the real EntryManager at MOD_UNIX. TS replays the same ops from the creation state and must match.",
|
||||
"entries": {
|
||||
"entries": {
|
||||
"0": {
|
||||
"archived": false,
|
||||
"custom_fields": [],
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:15:00+00:00",
|
||||
"gen_version": 2,
|
||||
"kind": "password",
|
||||
"label": "example.com",
|
||||
"length": 16,
|
||||
"links": [
|
||||
{
|
||||
"note": "2fa for site",
|
||||
"relation": "totp",
|
||||
"target_id": 1
|
||||
}
|
||||
],
|
||||
"modified_ts": 1700000100,
|
||||
"notes": "updated note",
|
||||
"policy": {
|
||||
"min_digits": 3,
|
||||
"special_mode": "safe"
|
||||
},
|
||||
"tags": [
|
||||
"web",
|
||||
"prod"
|
||||
],
|
||||
"type": "password",
|
||||
"url": "https://example.org",
|
||||
"username": "alice2"
|
||||
},
|
||||
"1": {
|
||||
"archived": false,
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:15:00+00:00",
|
||||
"deterministic": true,
|
||||
"digits": 8,
|
||||
"index": 0,
|
||||
"kind": "totp",
|
||||
"label": "example-totp",
|
||||
"links": [],
|
||||
"modified_ts": 1700000100,
|
||||
"notes": "",
|
||||
"period": 60,
|
||||
"tags": [
|
||||
"otp"
|
||||
],
|
||||
"type": "totp"
|
||||
},
|
||||
"2": {
|
||||
"archived": false,
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:13:20+00:00",
|
||||
"deterministic": false,
|
||||
"digits": 8,
|
||||
"kind": "totp",
|
||||
"label": "imported-totp",
|
||||
"links": [],
|
||||
"modified_ts": 1700000000,
|
||||
"notes": "",
|
||||
"period": 45,
|
||||
"secret": "JBSWY3DPEHPK3PXP",
|
||||
"tags": [],
|
||||
"type": "totp"
|
||||
},
|
||||
"3": {
|
||||
"archived": true,
|
||||
"custom_fields": [],
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:15:00+00:00",
|
||||
"index": 3,
|
||||
"kind": "ssh",
|
||||
"label": "example-ssh",
|
||||
"links": [],
|
||||
"modified_ts": 1700000100,
|
||||
"notes": "ssh note",
|
||||
"tags": [],
|
||||
"type": "ssh"
|
||||
},
|
||||
"4": {
|
||||
"archived": false,
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:13:20+00:00",
|
||||
"index": 4,
|
||||
"kind": "nostr",
|
||||
"label": "example-nostr",
|
||||
"links": [],
|
||||
"modified_ts": 1700000000,
|
||||
"notes": "",
|
||||
"tags": [],
|
||||
"type": "nostr"
|
||||
},
|
||||
"5": {
|
||||
"archived": false,
|
||||
"custom_fields": [],
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:15:00+00:00",
|
||||
"key": "token",
|
||||
"kind": "key_value",
|
||||
"label": "api-token-renamed",
|
||||
"links": [
|
||||
{
|
||||
"note": "",
|
||||
"relation": "related_to",
|
||||
"target_id": 0
|
||||
}
|
||||
],
|
||||
"modified_ts": 1700000100,
|
||||
"notes": "",
|
||||
"tags": [
|
||||
"api"
|
||||
],
|
||||
"type": "key_value",
|
||||
"value": "rotated-value"
|
||||
},
|
||||
"6": {
|
||||
"archived": false,
|
||||
"content": "hello fixture world",
|
||||
"custom_fields": [],
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:15:00+00:00",
|
||||
"file_type": "txt",
|
||||
"kind": "document",
|
||||
"label": "example-doc",
|
||||
"links": [],
|
||||
"modified_ts": 1700000100,
|
||||
"notes": "",
|
||||
"tags": [],
|
||||
"type": "document"
|
||||
},
|
||||
"7": {
|
||||
"archived": false,
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:13:20+00:00",
|
||||
"index": 7,
|
||||
"kind": "seed",
|
||||
"label": "example-seed",
|
||||
"links": [],
|
||||
"modified_ts": 1700000000,
|
||||
"notes": "",
|
||||
"tags": [],
|
||||
"type": "seed",
|
||||
"word_count": 24
|
||||
},
|
||||
"8": {
|
||||
"archived": false,
|
||||
"custom_fields": [],
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:13:20+00:00",
|
||||
"fingerprint": "DC982FD44E32F59A",
|
||||
"index": 8,
|
||||
"kind": "managed_account",
|
||||
"label": "example-managed",
|
||||
"links": [],
|
||||
"modified_ts": 1700000000,
|
||||
"notes": "",
|
||||
"tags": [],
|
||||
"type": "managed_account",
|
||||
"word_count": 12
|
||||
},
|
||||
"9": {
|
||||
"archived": false,
|
||||
"date_added": "2023-11-14T22:13:20+00:00",
|
||||
"date_modified": "2023-11-14T22:13:20+00:00",
|
||||
"index": 9,
|
||||
"key_type": "ed25519",
|
||||
"kind": "pgp",
|
||||
"label": "example-pgp",
|
||||
"links": [],
|
||||
"modified_ts": 1700000000,
|
||||
"notes": "",
|
||||
"tags": [],
|
||||
"type": "pgp",
|
||||
"user_id": "[email protected]"
|
||||
}
|
||||
},
|
||||
"schema_version": 4
|
||||
},
|
||||
"fixed_unix": 1700000000,
|
||||
"fixture_version": 1,
|
||||
"mnemonic_id": "abandon12",
|
||||
"mod_unix": 1700000100
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
"bip85_entropy.json",
|
||||
"delta_replay.json",
|
||||
"entries_index.json",
|
||||
"entry_mods.json",
|
||||
"entry_secrets.json",
|
||||
"fingerprints.json",
|
||||
"index_keys.json",
|
||||
@@ -24,6 +25,6 @@
|
||||
],
|
||||
"fixture_version": 1,
|
||||
"generator": "scripts/generate_ts_port_fixtures.py",
|
||||
"python_commit": "e91d5d0be2355c4be78b8c455ddf87b66d795036",
|
||||
"python_commit": "774c992153af57e30601a5aa067a2fa7ba81a3af",
|
||||
"redaction_policy": "All fixtures derive from public BIP-39 test mnemonics (abandon.../legal.../zoo...). Secret-shaped outputs are generated test material, never from a live profile."
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import deltaReplayJson from "../fixtures/delta_replay.json";
|
||||
import portableBackupJson from "../fixtures/portable_backup.json";
|
||||
import entrySecretsJson from "../fixtures/entry_secrets.json";
|
||||
import nostrEventsJson from "../fixtures/nostr_events.json";
|
||||
import entryModsJson from "../fixtures/entry_mods.json";
|
||||
|
||||
export interface Bip39Case {
|
||||
id: string;
|
||||
@@ -231,6 +232,13 @@ export const nostrEvents = nostrEventsJson as {
|
||||
events: { name: string; event: SignedNostrEvent }[];
|
||||
};
|
||||
|
||||
export const entryMods = entryModsJson as {
|
||||
fixed_unix: number;
|
||||
mod_unix: number;
|
||||
mnemonic_id: string;
|
||||
entries: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const legacyPayloads = legacyPayloadsJson as {
|
||||
mnemonic_id: string;
|
||||
plaintext_utf8: string;
|
||||
|
||||
@@ -335,8 +335,12 @@ def gen_index_keys() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _build_entries_index() -> dict:
|
||||
"""Build a real entries index with one entry of each kind, timestamps pinned."""
|
||||
def _build_entries_index(post_add=None) -> dict:
|
||||
"""Build a real entries index with one entry of each kind, timestamps pinned.
|
||||
|
||||
``post_add(em)`` runs extra EntryManager operations before the index is
|
||||
read back (used by the modification-parity fixture).
|
||||
"""
|
||||
from seedpass.core.backup import BackupManager
|
||||
from seedpass.core.config_manager import ConfigManager
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
@@ -385,6 +389,9 @@ def _build_entries_index() -> dict:
|
||||
em.add_managed_account("example-managed", mnemonic)
|
||||
em.add_pgp_key("example-pgp", mnemonic, user_id="[email protected]")
|
||||
|
||||
if post_add is not None:
|
||||
post_add(em)
|
||||
|
||||
index = vault.load_index()
|
||||
entries = {
|
||||
k: v for k, v in index.items() if k != "_system"
|
||||
@@ -978,6 +985,51 @@ def gen_nostr_events(snapshot: dict, delta_replay: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
MOD_UNIX = FIXED_UNIX + 100
|
||||
|
||||
|
||||
def gen_entry_mods() -> dict:
|
||||
"""Apply a modification sequence via the real EntryManager and capture
|
||||
the result; TS replicates the same ops and must match byte-for-byte."""
|
||||
from seedpass.core.entry_management import EntryManager
|
||||
|
||||
def mods(em: EntryManager) -> None:
|
||||
# Later timestamp so touched entries visibly differ from creation
|
||||
EntryManager._now_unix = staticmethod(lambda: MOD_UNIX)
|
||||
em.modify_entry(
|
||||
0,
|
||||
username="alice2",
|
||||
url="https://example.org",
|
||||
notes="updated note",
|
||||
tags=["web", "prod"],
|
||||
min_digits=3,
|
||||
special_mode="safe",
|
||||
)
|
||||
em.modify_entry(1, period=60, digits=8)
|
||||
em.modify_entry(5, label="api-token-renamed", value="rotated-value")
|
||||
em.archive_entry(6)
|
||||
em.restore_entry(6)
|
||||
em.archive_entry(3)
|
||||
em.add_link(0, 1, relation="totp", note="2fa for site")
|
||||
em.add_link(0, 5, relation="related_to")
|
||||
em.add_link(5, 0)
|
||||
em.remove_link(0, 5)
|
||||
|
||||
entries = _build_entries_index(post_add=mods)
|
||||
return {
|
||||
"description": (
|
||||
"Entries index after a modification sequence (modify_entry, "
|
||||
"archive/restore, add_link/remove_link) applied by the real "
|
||||
"EntryManager at MOD_UNIX. TS replays the same ops from the "
|
||||
"creation state and must match."
|
||||
),
|
||||
"fixed_unix": FIXED_UNIX,
|
||||
"mod_unix": MOD_UNIX,
|
||||
"mnemonic_id": PRIMARY,
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
def gen_kdf_metadata() -> dict:
|
||||
return {
|
||||
"description": (
|
||||
@@ -1029,6 +1081,7 @@ def main() -> None:
|
||||
files["nostr_snapshot.json"] = gen_nostr_snapshot(vault_fixture["payload_b64"])
|
||||
files["portable_backup.json"] = gen_portable_backup(entries_fixture["entries"])
|
||||
files["entry_secrets.json"] = gen_entry_secrets()
|
||||
files["entry_mods.json"] = gen_entry_mods()
|
||||
files["sync_merge.json"] = gen_sync_merge()
|
||||
files["delta_replay.json"] = gen_delta_replay()
|
||||
files["nostr_events.json"] = gen_nostr_events(
|
||||
|
||||
Reference in new issue
Block a user