From 4f2b8a79bf910ab3bfe38641e530f85909c2ec17 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:14:25 -0400 Subject: [PATCH] TS port slice 7: entry modify/archive/links + util generate-password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/typescript_port_compatibility_matrix.md | 9 +- js/packages/cli/src/capabilities.ts | 8 +- js/packages/cli/src/program.ts | 187 +++++++++++++++ js/packages/cli/test/cli.test.ts | 85 +++++++ js/packages/core/src/index.ts | 12 + js/packages/core/src/vault/entryMod.ts | 223 ++++++++++++++++++ js/packages/core/test/entryMod.test.ts | 99 ++++++++ .../test-vectors/fixtures/entry_mods.json | 186 +++++++++++++++ .../test-vectors/fixtures/manifest.json | 3 +- js/packages/test-vectors/src/index.ts | 8 + scripts/generate_ts_port_fixtures.py | 57 ++++- 11 files changed, 868 insertions(+), 9 deletions(-) create mode 100644 js/packages/core/src/vault/entryMod.ts create mode 100644 js/packages/core/test/entryMod.test.ts create mode 100644 js/packages/test-vectors/fixtures/entry_mods.json diff --git a/docs/typescript_port_compatibility_matrix.md b/docs/typescript_port_compatibility_matrix.md index 0215ee34..ad7a8ae7 100644 --- a/docs/typescript_port_compatibility_matrix.md +++ b/docs/typescript_port_compatibility_matrix.md @@ -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/`, 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/`, 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) diff --git a/js/packages/cli/src/capabilities.ts b/js/packages/cli/src/capabilities.ts index 11c9fe10..c71c4b45 100644 --- a/js/packages/cli/src/capabilities.ts +++ b/js/packages/cli/src/capabilities.ts @@ -18,14 +18,18 @@ export function capabilities(): Record { agent_surface: { reference_scheme: "sp://entry/", 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)", ], diff --git a/js/packages/cli/src/program.ts b/js/packages/cli/src/program.ts index fe49ea08..1bf032c5 100644 --- a/js/packages/cli/src/program.ts +++ b/js/packages/cli/src/program.ts @@ -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 ") + .description("update entry fields (kind-checked); prints the new metadata") + .option("--label ") + .option("--username ") + .option("--url ") + .option("--notes ") + .option("--tags ") + .option("--period ") + .option("--digits ") + .option("--key ") + .option("--value ", "new secret value (key_value/managed_account)") + .option("--content ", "new document content") + .option("--file-type ") + .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 ") + .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 ") + .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 ") + .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 ") + .description("link an entry to another") + .option("--relation ", "relation type", "related_to") + .option("--note ", "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 ") + .description("remove links to a target entry") + .option("--relation ", "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 ", "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 ") .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 ", "password length", "24") + .option("--index ", "derivation index", "0") + .option("--gen-version ", "generation version (1 or 2)", "1") + .option("--no-special", "exclude special characters") + .option("--allowed-special-chars ") + .option("--special-mode ") + .option("--exclude-ambiguous") + .option("--min-uppercase ") + .option("--min-lowercase ") + .option("--min-digits ") + .option("--min-special ") + .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 diff --git a/js/packages/cli/test/cli.test.ts b/js/packages/cli/test/cli.test.ts index 7e2a12f0..55e1281c 100644 --- a/js/packages/cli/test/cli.test.ts +++ b/js/packages/cli/test/cli.test.ts @@ -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-")); diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index f4115ca6..389a2c51 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -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, diff --git a/js/packages/core/src/vault/entryMod.ts b/js/packages/core/src/vault/entryMod.ts new file mode 100644 index 00000000..3618b730 --- /dev/null +++ b/js/packages/core/src/vault/entryMod.ts @@ -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; + +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(); + 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> = { + 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)[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(); + + 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; + 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, + }; + }); +} diff --git a/js/packages/core/test/entryMod.test.ts b/js/packages/core/test/entryMod.test.ts new file mode 100644 index 00000000..94bdd0d6 --- /dev/null +++ b/js/packages/core/test/entryMod.test.ts @@ -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" }]); + }); +}); diff --git a/js/packages/test-vectors/fixtures/entry_mods.json b/js/packages/test-vectors/fixtures/entry_mods.json new file mode 100644 index 00000000..84ad079f --- /dev/null +++ b/js/packages/test-vectors/fixtures/entry_mods.json @@ -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": "fixture@example.com" + } + }, + "schema_version": 4 + }, + "fixed_unix": 1700000000, + "fixture_version": 1, + "mnemonic_id": "abandon12", + "mod_unix": 1700000100 +} diff --git a/js/packages/test-vectors/fixtures/manifest.json b/js/packages/test-vectors/fixtures/manifest.json index 1743eabd..6f828133 100644 --- a/js/packages/test-vectors/fixtures/manifest.json +++ b/js/packages/test-vectors/fixtures/manifest.json @@ -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." } diff --git a/js/packages/test-vectors/src/index.ts b/js/packages/test-vectors/src/index.ts index 2cf1b88c..da899447 100644 --- a/js/packages/test-vectors/src/index.ts +++ b/js/packages/test-vectors/src/index.ts @@ -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; +}; + export const legacyPayloads = legacyPayloadsJson as { mnemonic_id: string; plaintext_utf8: string; diff --git a/scripts/generate_ts_port_fixtures.py b/scripts/generate_ts_port_fixtures.py index 0b9a4a67..ebdd8602 100644 --- a/scripts/generate_ts_port_fixtures.py +++ b/scripts/generate_ts_port_fixtures.py @@ -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="fixture@example.com") + 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(