Files
SeedPass/js/packages/core/src/vault/entryOps.ts
T
thePR0M3TH3ANandClaude Opus 5 e3b1325a2b fix(vault): entry ids are never reused -- allocate from a persisted watermark
An entry id doubles as the BIP-85 derivation index for managed_account and
seed entries, which makes it a permanent cryptographic coordinate: "Alice is
account #184" is a recovery claim, and #184 must mean Alice forever. But both
implementations allocated ids as max(live ids)+1, and sync deletion removes
entries via tombstones -- so deleting #184 on one device let the next
creation reissue #184 to a NEW entry, which then re-derived the departed
identity's exact child seed and npub. Tombstone retention (capped at 2048)
made even the evidence expire.

Allocation now comes from a high-watermark persisted in _sync_meta.next_index:
never-decreasing, bumped on every insert/save (explicit-id inserts included),
merged as max(both sides' watermarks, one past any merged entry or tombstone
id), and identical in both implementations -- the merge computation must stay
byte-for-byte the same in sync_conflict.py and sync/merge.ts. _sync_meta was
chosen because both sides already round-trip it (it carries the tombstones),
so old builds preserve the field and no schema version bump is needed.
Legacy vaults heal on their next allocation: the floor folds in tombstone ids
still within retention, and the first save persists the watermark.

Python-side key parsing mirrors the TS regex exactly (ASCII digits, no
leading zeros, JS safe-integer range): str.isdigit() alone accepts Unicode
digits that Number() rejects, which would have made the two implementations
compute different watermarks from the same payload.

Fixtures regenerated from Python; the byte-for-byte entry-creation parity
test now passes with the watermark present, proving TS's insert-time bump
and Python's save-time bump converge on identical indexes. Verified: the
recycle regression tests fail on both pre-fix implementations; 1180 Python,
224 core, 149 CLI tests and all 36 cross-impl checks pass with the fix.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Tkj4rzKBA3uhfgk7kqjsb8
2026-08-18 13:11:50 -04:00

436 lines
13 KiB
TypeScript

/**
* Entry creation — the provision half of the agent-blind lifecycle
* (plan section 9.3).
*
* Parity target: src/seedpass/core/entry_management.py add_* methods. Each
* function appends an entry to the index and returns its id; none of them
* return secret material. Field shapes match Python exactly (the parity test
* rebuilds the Python-generated fixture index from scratch).
*
* Not ported here: Python's index0 event emission (`_system` bookkeeping),
* checksum/backup side effects, and creation-time validation of derived
* SSH/PGP key pairs (that key material is not ported yet).
*/
import { deriveTotpSecret } from "../derive/totp.js";
import { generateFingerprint } from "../derive/fingerprint.js";
import { Bip85 } from "../derive/bip85.js";
import { CURRENT_PASSWORD_GEN_VERSION } from "../derive/password.js";
import type { VaultIndex } from "../schema/entries.js";
type Dict = Record<string, unknown>;
export interface Clock {
nowUnix(): number;
}
export const systemClock: Clock = {
nowUnix: () => Math.floor(Date.now() / 1000),
};
/** Python: datetime.fromtimestamp(ts, tz=utc).isoformat() -> "+00:00" suffix. */
export function isoFromUnix(ts: number): string {
const iso = new Date(Math.trunc(ts) * 1000).toISOString();
return iso.replace(/\.\d{3}Z$/, "+00:00");
}
/**
* max(int keys) + 1, or 0 for an empty index (get_next_index).
*
* Every key must be a plain non-negative integer within the safe range. A
* non-numeric key makes Math.max return NaN and a huge key makes +1 a no-op,
* and `insert` would then assign the same id repeatedly — each new entry
* silently overwriting the last. Such an index can arrive through import or
* a merge, so refuse it loudly rather than destroying entries.
*/
export function nextIndex(index: VaultIndex): number {
const keys = Object.keys(index.entries);
const ids: number[] = [];
for (const key of keys) {
if (!/^(0|[1-9][0-9]*)$/.test(key)) {
throw new Error(
`vault index contains a non-numeric entry id ${JSON.stringify(key)}; ` +
`refusing to allocate a new id that could overwrite an existing entry`,
);
}
const id = Number(key);
if (!Number.isSafeInteger(id)) {
throw new Error(
`vault index contains entry id ${key}, which is outside the safe integer ` +
`range; refusing to allocate a new id`,
);
}
ids.push(id);
}
const liveFloor = ids.length > 0 ? Math.max(...ids) + 1 : 0;
// Never allocate below the high-watermark. Scanning live entries alone is
// not enough: an entry id doubles as the BIP-85 derivation index for
// managed_account (and seed) entries, and sync deletion removes entries via
// tombstones — so max(live)+1 could reissue a deleted #184 to a NEW entry
// that then re-derives the departed identity's exact child seed and npub.
// Tombstone ids are folded in so vaults from before the watermark existed
// heal on their next allocation (for tombstones still within retention).
return Math.max(liveFloor, storedNextIndex(index), tombstoneFloor(index));
}
/** The persisted allocation watermark, 0 when absent (pre-watermark vault). */
function storedNextIndex(index: VaultIndex): number {
const meta = (index as unknown as Dict)["_sync_meta"];
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return 0;
const raw = Number((meta as Dict)["next_index"]);
return Number.isSafeInteger(raw) && raw > 0 ? raw : 0;
}
/** One past the highest tombstoned id. Non-numeric tombstone keys are historical junk and skipped. */
function tombstoneFloor(index: VaultIndex): number {
const meta = (index as unknown as Dict)["_sync_meta"];
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return 0;
const tombstones = (meta as Dict)["tombstones"];
if (typeof tombstones !== "object" || tombstones === null || Array.isArray(tombstones)) return 0;
let floor = 0;
for (const key of Object.keys(tombstones)) {
if (!/^(0|[1-9][0-9]*)$/.test(key)) continue;
const id = Number(key);
if (Number.isSafeInteger(id) && id + 1 > floor) floor = id + 1;
}
return floor;
}
/**
* Record that `id` has been allocated: the watermark becomes at least id+1
* and never decreases. Kept in _sync_meta because both implementations
* already round-trip that block (it carries the tombstones), so old builds
* preserve it without a schema bump.
*/
function bumpNextIndex(index: VaultIndex, id: number): void {
const container = index as unknown as Dict;
const meta =
typeof container["_sync_meta"] === "object" &&
container["_sync_meta"] !== null &&
!Array.isArray(container["_sync_meta"])
? (container["_sync_meta"] as Dict)
: {};
meta["next_index"] = Math.max(storedNextIndex(index), id + 1);
container["_sync_meta"] = meta;
}
/** Next TOTP derivation index: max over totp entries' index field + 1. */
export function nextTotpIndex(index: VaultIndex): number {
const indices = Object.values(index.entries as unknown as Record<string, Dict>)
.filter((e) => e["type"] === "totp" || e["kind"] === "totp")
.map((e) => Math.trunc(Number(e["index"] ?? 0)));
return indices.length > 0 ? Math.max(...indices) + 1 : 0;
}
function stamp(clock: Clock): { now_unix: number; now_iso: string } {
const nowUnix = clock.nowUnix();
return { now_unix: nowUnix, now_iso: isoFromUnix(nowUnix) };
}
function insert(index: VaultIndex, id: number, entry: Dict): string {
const key = String(id);
// Never silently replace an entry: allocation bugs show up here first.
if (Object.prototype.hasOwnProperty.call(index.entries, key)) {
throw new Error(`refusing to overwrite existing entry ${key}`);
}
(index.entries as unknown as Dict)[key] = entry;
// Covers explicit-id inserts too: an entry created at #500 pushes the
// watermark past 500 even though nextIndex never saw it.
bumpNextIndex(index, id);
return key;
}
export interface CommonOptions {
archived?: boolean;
notes?: string;
tags?: string[];
clock?: Clock;
}
export interface AddPasswordOptions extends CommonOptions {
username?: string;
url?: string;
customFields?: Dict[];
policy?: Partial<{
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;
}>;
}
export function addPasswordEntry(
index: VaultIndex,
label: string,
length: number,
opts: AddPasswordOptions = {},
): string {
const id = nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
const entry: Dict = {
label,
length,
gen_version: CURRENT_PASSWORD_GEN_VERSION,
username: opts.username ?? "",
url: opts.url ?? "",
archived: opts.archived ?? false,
type: "password",
kind: "password",
notes: opts.notes ?? "",
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
custom_fields: opts.customFields ?? [],
tags: opts.tags ?? [],
links: [],
};
if (opts.policy && Object.keys(opts.policy).length > 0) {
entry["policy"] = { ...opts.policy };
}
return insert(index, id, entry);
}
export interface AddTotpOptions extends CommonOptions {
period?: number;
digits?: number;
/** Derivation index for deterministic secrets; auto-allocated if omitted. */
index?: number;
}
/** Deterministic TOTP: the secret derives from the parent seed on demand. */
export function addTotpDeterministic(
index: VaultIndex,
label: string,
mnemonic: string,
opts: AddTotpOptions = {},
): string {
const id = nextIndex(index);
const derivationIndex = opts.index ?? nextTotpIndex(index);
// Derive once to validate, exactly as Python does — the value is dropped.
deriveTotpSecret(mnemonic, derivationIndex);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "totp",
kind: "totp",
label,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
index: derivationIndex,
period: opts.period ?? 30,
digits: opts.digits ?? 6,
archived: opts.archived ?? false,
notes: opts.notes ?? "",
tags: opts.tags ?? [],
deterministic: true,
links: [],
});
}
const B32_RE = /^[A-Z2-7]+=*$/;
/** Imported TOTP: stores the provided base32 secret. */
export function addTotpImported(
index: VaultIndex,
label: string,
secret: string,
opts: AddTotpOptions = {},
): string {
const normalized = secret.trim().toUpperCase();
if (!B32_RE.test(normalized)) throw new Error("Invalid TOTP secret");
const id = nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "totp",
kind: "totp",
label,
secret: normalized,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
period: opts.period ?? 30,
digits: opts.digits ?? 6,
archived: opts.archived ?? false,
notes: opts.notes ?? "",
tags: opts.tags ?? [],
deterministic: false,
links: [],
});
}
export function addSshKeyEntry(
index: VaultIndex,
label: string,
opts: CommonOptions & { index?: number } = {},
): string {
const id = opts.index ?? nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "ssh",
kind: "ssh",
index: id,
label,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
notes: opts.notes ?? "",
archived: opts.archived ?? false,
tags: opts.tags ?? [],
custom_fields: [],
links: [],
});
}
export function addNostrKeyEntry(
index: VaultIndex,
label: string,
opts: CommonOptions & { index?: number } = {},
): string {
const id = opts.index ?? nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "nostr",
kind: "nostr",
index: id,
label,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
notes: opts.notes ?? "",
archived: opts.archived ?? false,
tags: opts.tags ?? [],
links: [],
});
}
export function addKeyValueEntry(
index: VaultIndex,
label: string,
key: string,
value: string,
opts: CommonOptions & { customFields?: Dict[] } = {},
): string {
const id = nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "key_value",
kind: "key_value",
label,
key,
value,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
notes: opts.notes ?? "",
archived: opts.archived ?? false,
tags: opts.tags ?? [],
custom_fields: opts.customFields ?? [],
links: [],
});
}
export function addDocumentEntry(
index: VaultIndex,
label: string,
content: string,
opts: CommonOptions & { fileType?: string } = {},
): string {
const id = nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "document",
kind: "document",
label,
content,
file_type: opts.fileType ?? "txt",
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
notes: opts.notes ?? "",
archived: opts.archived ?? false,
tags: opts.tags ?? [],
custom_fields: [],
links: [],
});
}
export function addSeedEntry(
index: VaultIndex,
label: string,
opts: CommonOptions & { index?: number; wordCount?: 12 | 18 | 24 } = {},
): string {
const id = opts.index ?? nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "seed",
kind: "seed",
index: id,
label,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
word_count: opts.wordCount ?? 24,
notes: opts.notes ?? "",
archived: opts.archived ?? false,
tags: opts.tags ?? [],
links: [],
});
}
/** Managed accounts always use a 12-word child seed. */
export function addManagedAccountEntry(
index: VaultIndex,
label: string,
mnemonic: string,
opts: CommonOptions & { index?: number } = {},
): string {
const id = opts.index ?? nextIndex(index);
const child = Bip85.fromMnemonic(mnemonic).deriveMnemonic(id, 12);
const fingerprint = generateFingerprint(child);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "managed_account",
kind: "managed_account",
index: id,
label,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
word_count: 12,
notes: opts.notes ?? "",
fingerprint,
archived: opts.archived ?? false,
tags: opts.tags ?? [],
custom_fields: [],
links: [],
});
}
export function addPgpKeyEntry(
index: VaultIndex,
label: string,
opts: CommonOptions & { index?: number; keyType?: string; userId?: string } = {},
): string {
const id = opts.index ?? nextIndex(index);
const { now_unix, now_iso } = stamp(opts.clock ?? systemClock);
return insert(index, id, {
type: "pgp",
kind: "pgp",
index: id,
label,
modified_ts: now_unix,
date_added: now_iso,
date_modified: now_iso,
key_type: opts.keyType ?? "ed25519",
user_id: opts.userId ?? "",
notes: opts.notes ?? "",
archived: opts.archived ?? false,
tags: opts.tags ?? [],
links: [],
});
}