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:
thePR0M3TH3ANandClaude Fable 5 committed 2026-08-14 13:14:25 -04:00
1 parent 774c992153
commit 4f2b8a79bf
11 files changed
+868 -9

No files matched your search

+5 -4
View File
@@ -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)
+6 -2
View File
@@ -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)",
],
+187
View File
@@ -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
+85
View File
@@ -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-"));
+12
View File
@@ -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,
+223
View File
@@ -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,
};
});
}
+99
View File
@@ -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."
}
+8
View File
@@ -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;
+55 -2
View File
@@ -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(