mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-28 00:12:04 +00:00
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
151 lines
5.9 KiB
TypeScript
151 lines
5.9 KiB
TypeScript
/**
|
|
* Entry-creation parity: rebuild the Python EntryManager-generated fixture
|
|
* index from scratch with the same calls and a pinned clock, and require
|
|
* deep equality. Any drift in field shapes, index allocation, or timestamp
|
|
* formatting fails here.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { entriesIndex, mnemonics } from "@seedpass/test-vectors";
|
|
import {
|
|
addPasswordEntry,
|
|
addTotpDeterministic,
|
|
addTotpImported,
|
|
addSshKeyEntry,
|
|
addNostrKeyEntry,
|
|
addKeyValueEntry,
|
|
addDocumentEntry,
|
|
addSeedEntry,
|
|
addManagedAccountEntry,
|
|
addPgpKeyEntry,
|
|
parseVaultIndex,
|
|
type Clock,
|
|
type VaultIndex,
|
|
} from "@seedpass/core";
|
|
|
|
const FIXED_UNIX = 1700000000;
|
|
const clock: Clock = { nowUnix: () => FIXED_UNIX };
|
|
const MNEMONIC = mnemonics["abandon12"]!;
|
|
|
|
describe("entry creation parity", () => {
|
|
it("rebuilds the fixture index byte-for-byte", () => {
|
|
const index = { schema_version: 4, entries: {} } as unknown as VaultIndex;
|
|
|
|
// Mirrors scripts/generate_ts_port_fixtures.py::_build_entries_index
|
|
addPasswordEntry(index, "example.com", 16, {
|
|
username: "alice",
|
|
url: "https://example.com",
|
|
notes: "password note",
|
|
tags: ["web"],
|
|
clock,
|
|
});
|
|
addTotpDeterministic(index, "example-totp", MNEMONIC, { tags: ["otp"], clock });
|
|
addTotpImported(index, "imported-totp", "JBSWY3DPEHPK3PXP", {
|
|
period: 45,
|
|
digits: 8,
|
|
clock,
|
|
});
|
|
addSshKeyEntry(index, "example-ssh", { notes: "ssh note", clock });
|
|
addNostrKeyEntry(index, "example-nostr", { clock });
|
|
addKeyValueEntry(index, "api-token", "token", "abc123", { tags: ["api"], clock });
|
|
addDocumentEntry(index, "example-doc", "hello fixture world", { clock });
|
|
addSeedEntry(index, "example-seed", { wordCount: 24, clock });
|
|
addManagedAccountEntry(index, "example-managed", MNEMONIC, { clock });
|
|
addPgpKeyEntry(index, "example-pgp", { userId: "[email protected]", clock });
|
|
|
|
expect(JSON.parse(JSON.stringify(index))).toEqual(entriesIndex.entries);
|
|
});
|
|
|
|
it("produces schema-valid entries", () => {
|
|
const index = { schema_version: 4, entries: {} } as unknown as VaultIndex;
|
|
addPasswordEntry(index, "x.com", 20, { clock });
|
|
addTotpDeterministic(index, "x-totp", MNEMONIC, { clock });
|
|
expect(() => parseVaultIndex(JSON.parse(JSON.stringify(index)))).not.toThrow();
|
|
});
|
|
|
|
it("allocates ids and totp derivation indices independently", () => {
|
|
const index = { schema_version: 4, entries: {} } as unknown as VaultIndex;
|
|
addPasswordEntry(index, "a", 16, { clock });
|
|
addPasswordEntry(index, "b", 16, { clock });
|
|
const totpId = addTotpDeterministic(index, "t1", MNEMONIC, { clock });
|
|
expect(totpId).toBe("2");
|
|
const entry = (index.entries as Record<string, { index?: number }>)["2"]!;
|
|
expect(entry.index).toBe(0); // first TOTP derivation index despite id 2
|
|
addTotpDeterministic(index, "t2", MNEMONIC, { clock });
|
|
const entry2 = (index.entries as Record<string, { index?: number }>)["3"]!;
|
|
expect(entry2.index).toBe(1);
|
|
});
|
|
|
|
it("rejects invalid imported TOTP secrets", () => {
|
|
const index = { schema_version: 4, entries: {} } as unknown as VaultIndex;
|
|
expect(() => addTotpImported(index, "bad", "not base32!", { clock })).toThrow(
|
|
"Invalid TOTP secret",
|
|
);
|
|
});
|
|
});
|
|
|
|
import { mergeIndexPayloads, Bip85 } from "@seedpass/core";
|
|
|
|
describe("index allocation watermark", () => {
|
|
function freshIndex(): VaultIndex {
|
|
return { schema_version: 4, entries: {} } as VaultIndex;
|
|
}
|
|
|
|
it("does not recycle an id deleted through a merge", () => {
|
|
// An entry id is a permanent BIP-85 derivation coordinate: reissuing a
|
|
// deleted #2 would hand a NEW entry the departed identity's exact child
|
|
// seed and npub.
|
|
const index = freshIndex();
|
|
addPasswordEntry(index, "web-a", 20, { clock });
|
|
addPasswordEntry(index, "web-b", 20, { clock });
|
|
const managedId = addManagedAccountEntry(index, "alice", MNEMONIC, { clock });
|
|
expect(managedId).toBe("2");
|
|
const departedSeed = Bip85.fromMnemonic(MNEMONIC).deriveMnemonic(2, 12);
|
|
|
|
const incoming = {
|
|
schema_version: 4,
|
|
entries: {
|
|
"2": { kind: "managed_account", label: "alice", modified_ts: FIXED_UNIX + 10, _deleted: true },
|
|
},
|
|
};
|
|
const merged = parseVaultIndex(
|
|
mergeIndexPayloads(index as unknown as Record<string, unknown>, incoming, "remote"),
|
|
);
|
|
expect(merged.entries["2"]).toBeUndefined();
|
|
|
|
const newId = addManagedAccountEntry(merged, "bob", MNEMONIC, { clock });
|
|
expect(newId).toBe("3"); // NOT the tombstoned 2
|
|
const bobSeed = Bip85.fromMnemonic(MNEMONIC).deriveMnemonic(3, 12);
|
|
expect(bobSeed).not.toBe(departedSeed);
|
|
});
|
|
|
|
it("merge carries the higher watermark even with no entries or tombstones behind it", () => {
|
|
const current = {
|
|
schema_version: 4,
|
|
entries: { "0": { kind: "password", label: "a", modified_ts: 50 } },
|
|
_sync_meta: { next_index: 500 },
|
|
};
|
|
const merged = mergeIndexPayloads(current, { schema_version: 4, entries: {} }, "x");
|
|
expect((merged["_sync_meta"] as Record<string, unknown>)["next_index"]).toBe(500);
|
|
});
|
|
|
|
it("an explicit high index pushes the watermark past it", () => {
|
|
const index = freshIndex();
|
|
addManagedAccountEntry(index, "pinned", MNEMONIC, { clock, index: 40 });
|
|
const nextId = addPasswordEntry(index, "after", 20, { clock });
|
|
expect(nextId).toBe("41");
|
|
});
|
|
|
|
it("a legacy vault heals from tombstones still in retention", () => {
|
|
// Pre-watermark vault: no _sync_meta.next_index, but a tombstone for #7.
|
|
const legacy = freshIndex();
|
|
addPasswordEntry(legacy, "a", 20, { clock }); // id 0
|
|
const container = legacy as unknown as Record<string, unknown>;
|
|
container["_sync_meta"] = {
|
|
tombstones: { "7": { deleted_ts: 60, entry_hash: "", event_hash: "" } },
|
|
};
|
|
const id = addPasswordEntry(legacy, "fresh", 20, { clock });
|
|
expect(id).toBe("8"); // past the tombstone, not max(live)+1 = 1
|
|
});
|
|
});
|