From 9883b1eb6ca6ba86d0897382f22df8c1fb95c1ff Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 7 Aug 2026 23:55:57 +0000 Subject: [PATCH 1/2] feat: erase all local data, and rebuild the vault database in place --- apps/desktop/src/main/ipc.ts | 2 + apps/desktop/src/main/vault-service.ts | 32 ++- apps/desktop/src/preload/index.ts | 2 + .../renderer/src/screens/SettingsScreen.tsx | 116 +++++++++++ apps/desktop/src/shared/api.ts | 6 + packages/vault/__tests__/vault.test.ts | 50 +++++ packages/vault/src/vault.ts | 195 +++++++++++++++--- 7 files changed, 378 insertions(+), 25 deletions(-) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 7974cdc..dd37c97 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -36,6 +36,8 @@ export const registerIpc = (service: VaultService): void => { handle(CHANNELS.vaultChangePassphrase, (next: string) => service.current().changePassphrase(assertString(next)) ); + handle(CHANNELS.vaultRebuild, () => service.rebuild()); + handle(CHANNELS.vaultEraseAll, () => service.eraseAll()); // ─── items ─── handle(CHANNELS.itemsList, (options?: { kind?: ItemKind; folderId?: string; trashed?: boolean }) => diff --git a/apps/desktop/src/main/vault-service.ts b/apps/desktop/src/main/vault-service.ts index 16193c9..437a9a6 100644 --- a/apps/desktop/src/main/vault-service.ts +++ b/apps/desktop/src/main/vault-service.ts @@ -1,14 +1,18 @@ import { Vault } from '@decryption/vault'; import { appstash, resolve } from 'appstash'; import { existsSync } from 'fs'; +import { promises as fs } from 'fs'; import * as path from 'path'; import type { TotpEntry, VaultStatus } from '../shared/api'; const APP_NAME = 'dcrypt'; +/** Root of everything dcrypt keeps on this machine: vault, keychain, identity. */ +export const appDataPath = (): string => appstash(APP_NAME, { ensure: true }); + export const vaultFilePath = (): string => - resolve(appstash(APP_NAME, { ensure: true }), 'data', 'db') + path.sep + 'vault.dcrypt'; + resolve(appDataPath(), 'data', 'db') + path.sep + 'vault.dcrypt'; /** Locate the dcrypt-vault pgpm module in dev (workspace) and packaged builds. */ export const vaultModulePath = (): string => { @@ -80,6 +84,32 @@ export class VaultService { }, 2000); } + /** + * Re-runs the pgpm deploy into a fresh database and moves every row across, + * so a vault created by an earlier module version picks up schema changes. + */ + async rebuild(): Promise { + await this.flush(); + await this.current().rebuild(vaultModulePath()); + } + + /** + * Locks, then deletes every file dcrypt owns. The next launch starts at the + * create-vault screen, which deploys the pgpm module again from scratch. + */ + async eraseAll(): Promise { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + // drop the database without persisting it: the file is about to go + const vault = this.vault; + this.vault = null; + await this.locking; + if (vault) await vault.discard(); + await fs.rm(appDataPath(), { recursive: true, force: true }); + } + /** Writes any debounced edits now, so the file on disk matches the UI. */ async flush(): Promise { if (this.saveTimer) { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index c56e698..a7d5737 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -15,6 +15,8 @@ const api: DcryptApi & { lock: () => invoke(CHANNELS.vaultLock), save: () => invoke(CHANNELS.vaultSave), changePassphrase: (next) => invoke(CHANNELS.vaultChangePassphrase, next), + rebuild: () => invoke(CHANNELS.vaultRebuild), + eraseAll: () => invoke(CHANNELS.vaultEraseAll), }, items: { list: (options) => invoke(CHANNELS.itemsList, options), diff --git a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx index 43d0eb7..c1cfea5 100644 --- a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx @@ -6,6 +6,15 @@ import { CardHeader, CardTitle, } from '@constructive-io/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogTitle, +} from '@constructive-io/ui/dialog'; import { Input } from '@constructive-io/ui/input'; import { Label } from '@constructive-io/ui/label'; import { Tabs, TabsList, TabsTrigger } from '@constructive-io/ui/tabs'; @@ -16,6 +25,9 @@ import { dcrypt } from '../lib/ipc'; import { ThemeMode } from '../lib/theme'; import { useThemeMode } from '../lib/theme-context'; +/** Typed verbatim before anything is deleted. */ +const ERASE_PHRASE = 'ERASE'; + const THEME_MODES: { value: ThemeMode; label: string }[] = [ { value: 'system', label: 'System' }, { value: 'light', label: 'Light' }, @@ -28,6 +40,8 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { const [next, setNext] = useState(''); const [confirm, setConfirm] = useState(''); const [busy, setBusy] = useState(false); + const [eraseOpen, setEraseOpen] = useState(false); + const [erasePhrase, setErasePhrase] = useState(''); useEffect(() => { void dcrypt.vault.status().then((status) => setFile(status.file)); @@ -65,6 +79,32 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { } }; + const rebuild = async () => { + setBusy(true); + try { + await dcrypt.vault.rebuild(); + toast.success('Database rebuilt. Every item was carried over.'); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const eraseAll = async () => { + setBusy(true); + try { + await dcrypt.vault.eraseAll(); + setEraseOpen(false); + setErasePhrase(''); + onLocked(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const changePassphrase = async () => { if (next.length < 8) { toast.error('Choose a master password of at least 8 characters.'); @@ -184,6 +224,82 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { + + + + Database + + Your items live in a local Postgres database that is deployed the first time you create a + vault. Rebuilding deploys it again from scratch and moves every item, folder, tag and code + across — useful after an app update changes the schema. Values move without being + decrypted, and your master password still opens the result. + + + + + + + + + + Erase all data + + Deletes the vault, every stored password and code, the keychain and your identity file — + everything dcrypt keeps on this device. There is no undo and no cloud copy to recover + from; only a backup you made yourself can bring it back. dcrypt then starts fresh, as if + newly installed. + + + + + + + + { + setEraseOpen(open); + if (!open) setErasePhrase(''); + }} + > + + + Erase all data? + + This permanently deletes {file} and every other dcrypt file on this device. It cannot be + undone. + + + + + setErasePhrase(e.target.value)} + autoFocus + autoComplete="off" + /> + + + + + + + ); }; diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 9d46968..86b51ad 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -71,6 +71,10 @@ export interface DcryptApi { lock(): Promise; save(): Promise; changePassphrase(next: string): Promise; + /** Re-deploys the pgpm module into a fresh database, keeping every item. */ + rebuild(): Promise; + /** Deletes the vault and every other file dcrypt keeps on this machine. */ + eraseAll(): Promise; }; items: { list(options?: { kind?: ItemKind; folderId?: string; trashed?: boolean }): Promise; @@ -146,6 +150,8 @@ export const CHANNELS = { vaultLock: 'vault:lock', vaultSave: 'vault:save', vaultChangePassphrase: 'vault:change-passphrase', + vaultRebuild: 'vault:rebuild', + vaultEraseAll: 'vault:erase-all', itemsList: 'items:list', itemsGet: 'items:get', itemsCreate: 'items:create', diff --git a/packages/vault/__tests__/vault.test.ts b/packages/vault/__tests__/vault.test.ts index 7277877..b4c500e 100644 --- a/packages/vault/__tests__/vault.test.ts +++ b/packages/vault/__tests__/vault.test.ts @@ -127,6 +127,56 @@ describe('Vault', () => { await reopened.lock(); }); + it('rebuilds the database and carries every row across', async () => { + const file = path.join(dir, 'rebuild.dcrypt'); + const vault = await Vault.open({ file, passphrase: PASSPHRASE, modulePath: MODULE_PATH, kdf: FAST }); + + const parent = await vault.createFolder('Personal'); + const child = await vault.createFolder('Banking', parent.id); + const login = await vault.createItem('login', 'Bank', child.id); + await vault.setField(login.id, 'username', 'username', 'dan', false); + await vault.setField(login.id, 'password', 'password', 'correct horse battery staple'); + await vault.addUrl(login.id, 'https://bank.example'); + await vault.tagItem(login.id, 'money'); + await vault.setFavorite(login.id, true); + const code = await vault.createItem('totp', 'Bank 2FA'); + await vault.setField(code.id, 'seed', 'totp_seed', 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'); + const before = await vault.totpCode(code.id); + + await vault.rebuild(MODULE_PATH); + + // same ids, same ciphertext, same passphrase — nothing was re-keyed + expect((await vault.listItems()).map((item) => item.id).sort()).toEqual( + [login.id, code.id].sort() + ); + expect(await vault.revealField(login.id, 'password')).toBe('correct horse battery staple'); + expect(await vault.revealField(login.id, 'username')).toBe('dan'); + expect(await vault.listUrls(login.id)).toEqual(['https://bank.example']); + expect((await vault.listTags(login.id)).map((tag) => tag.name)).toEqual(['money']); + expect((await vault.getItem(login.id))!.favorite).toBe(true); + expect((await vault.getItem(login.id))!.folderId).toBe(child.id); + expect(await vault.totpCode(code.id)).toBe(before); + const folders = await vault.listFolders(); + expect(folders.find((folder) => folder.id === child.id)!.parentId).toBe(parent.id); + + await vault.lock(); + const reopened = await Vault.open({ file, passphrase: PASSPHRASE, modulePath: MODULE_PATH, kdf: FAST }); + expect(await reopened.revealField(login.id, 'password')).toBe('correct horse battery staple'); + await reopened.lock(); + }); + + it('discards without persisting, for erase-all', async () => { + const file = path.join(dir, 'discard.dcrypt'); + const vault = await Vault.open({ file, passphrase: PASSPHRASE, modulePath: MODULE_PATH, kdf: FAST }); + await vault.createItem('note', 'Written after the last save'); + await vault.discard(); + expect(vault.isLocked).toBe(true); + + const reopened = await Vault.open({ file, passphrase: PASSPHRASE, modulePath: MODULE_PATH, kdf: FAST }); + expect(await reopened.listItems()).toHaveLength(0); + await reopened.lock(); + }); + it('records reveals in the audit log', async () => { const file = path.join(dir, 'audit.dcrypt'); const vault = await Vault.open({ file, passphrase: PASSPHRASE, modulePath: MODULE_PATH, kdf: FAST }); diff --git a/packages/vault/src/vault.ts b/packages/vault/src/vault.ts index 1f0bb25..dd84f21 100644 --- a/packages/vault/src/vault.ts +++ b/packages/vault/src/vault.ts @@ -104,30 +104,7 @@ export class Vault { extensions: { pgcrypto }, }); } else { - const handle = await registerPglite({ - extensions: { pgcrypto }, - extensionSql: ['CREATE EXTENSION IF NOT EXISTS pgcrypto;'], - }); - try { - const proj = new PgpmPackage(modulePath); - await proj.deploy( - getEnvOptions({ - pg: { database: 'postgres' }, - deployment: { fast: true, usePlan: true, cache: false }, - }), - proj.getModuleName() - ); - } catch (error) { - await teardownPgPools(); - await handle.close(); - throw error; - } - // evict the cached pool so the next open never reaches this instance - await teardownPgPools(); - handle.unregister(); - // the adapter's PGlite type is resolved through the ESM declarations while - // this CJS build resolves the CTS ones — identical runtime class - db = handle.db as unknown as PGlite; + db = await Vault.deployFresh(modulePath); await db.query( 'INSERT INTO dcrypt_vault.meta (key, value) VALUES ($1, $2)', [DB_KEY_SALT_META, bytesToHex(randomBytes(16))] @@ -145,6 +122,58 @@ export class Vault { return vault; } + /** Runs `pgpm deploy` of the dcrypt-vault module into an empty PGlite. */ + private static async deployFresh(modulePath: string): Promise { + const handle = await registerPglite({ + extensions: { pgcrypto }, + extensionSql: ['CREATE EXTENSION IF NOT EXISTS pgcrypto;'], + }); + try { + const proj = new PgpmPackage(modulePath); + await proj.deploy( + getEnvOptions({ + pg: { database: 'postgres' }, + deployment: { fast: true, usePlan: true, cache: false }, + }), + proj.getModuleName() + ); + } catch (error) { + await teardownPgPools(); + await handle.close(); + throw error; + } + // evict the cached pool so the next open never reaches this instance + await teardownPgPools(); + handle.unregister(); + // the adapter's PGlite type is resolved through the ESM declarations while + // this CJS build resolves the CTS ones — identical runtime class + return handle.db as unknown as PGlite; + } + + /** + * Re-deploys the pgpm module into a fresh database and copies every row + * across, so a vault created by an older module picks up schema changes. + * Values move as ciphertext and the key salt is preserved, so no plaintext + * is materialised and the master passphrase still opens the result. + */ + async rebuild(modulePath: string): Promise { + const old = this.database; + const next = await Vault.deployFresh(modulePath); + try { + for (const spec of COPY_ORDER) { + await copyTable(old, next, spec); + } + // folders were inserted detached to satisfy their self-reference + await reattachFolders(old, next); + } catch (error) { + await next.close(); + throw error; + } + this.db = next; + await old.close(); + await this.save(); + } + private static async readDbKeySalt(db: PGlite): Promise { const result = await db.query<{ value: string }>( 'SELECT value FROM dcrypt_vault.meta WHERE key = $1', @@ -183,6 +212,15 @@ export class Vault { async lock(): Promise { if (!this.db) return; await this.save(); + await this.discard(); + } + + /** + * Close and forget the database *without* persisting it — for erasing the + * vault, where writing the snapshot back out would be pointless or wrong. + */ + async discard(): Promise { + if (!this.db) return; await this.db.close(); this.db = null; this.snapshotKey?.key.fill(0); @@ -467,6 +505,115 @@ export class Vault { } } +interface CopySpec { + table: string; + columns: string[]; + /** bytea columns, moved as hex so ciphertext survives the round trip. */ + binary?: string[]; + /** Enum columns, read as text and cast back on insert. */ + casts?: Record; + /** Columns forced to null on insert, set in a second pass. */ + detach?: string[]; + onConflict?: string; +} + +/** Every table a vault owns, ordered so foreign keys are satisfied as we go. */ +const COPY_ORDER: CopySpec[] = [ + { table: 'meta', columns: ['key', 'value'], onConflict: '(key) DO UPDATE SET value = EXCLUDED.value' }, + { + table: 'folders', + columns: ['id', 'name', 'parent_id', 'created_at'], + detach: ['parent_id'], + }, + { + table: 'items', + columns: [ + 'id', + 'kind', + 'title', + 'folder_id', + 'favorite', + 'created_at', + 'updated_at', + 'deleted_at', + ], + casts: { kind: 'dcrypt_vault.item_kind' }, + }, + { + table: 'fields', + columns: [ + 'id', + 'item_id', + 'name', + 'purpose', + 'value_enc', + 'concealed', + 'created_at', + 'updated_at', + ], + binary: ['value_enc'], + casts: { purpose: 'dcrypt_vault.field_purpose' }, + }, + { + table: 'password_history', + columns: ['id', 'field_id', 'value_enc', 'replaced_at'], + binary: ['value_enc'], + }, + { table: 'tags', columns: ['id', 'name'], onConflict: '(name) DO NOTHING' }, + { table: 'item_tags', columns: ['item_id', 'tag_id'], onConflict: 'DO NOTHING' }, + { table: 'urls', columns: ['id', 'item_id', 'url'], onConflict: 'DO NOTHING' }, + { + table: 'audit_log', + columns: ['id', 'item_id', 'field_name', 'action', 'occurred_at'], + }, +]; + +const copyTable = async (from: PGlite, to: PGlite, spec: CopySpec): Promise => { + const binary = new Set(spec.binary ?? []); + const detached = new Set(spec.detach ?? []); + const selected = spec.columns + .map((column) => { + if (binary.has(column)) return `encode(${column}, 'hex') AS ${column}`; + if (spec.casts?.[column]) return `${column}::text AS ${column}`; + return column; + }) + .join(', '); + const rows = await from.query>( + `SELECT ${selected} FROM dcrypt_vault.${spec.table}` + ); + if (!rows.rows.length) return; + + const placeholders = spec.columns + .map((column, index) => { + const slot = `$${index + 1}`; + if (binary.has(column)) return `decode(${slot}, 'hex')`; + const cast = spec.casts?.[column]; + return cast ? `${slot}::${cast}` : slot; + }) + .join(', '); + const conflict = spec.onConflict ? ` ON CONFLICT ${spec.onConflict}` : ''; + const sql = `INSERT INTO dcrypt_vault.${spec.table} (${spec.columns.join(', ')}) VALUES (${placeholders})${conflict}`; + for (const row of rows.rows) { + await to.query( + sql, + spec.columns.map((column) => (detached.has(column) ? null : (row[column] ?? null))) + ); + } +}; + +/** Second pass for folders, whose parent may be inserted after the child. */ +const reattachFolders = async (from: PGlite, to: PGlite): Promise => { + const rows = await from.query<{ id: string; parent_id: string | null }>( + 'SELECT id, parent_id FROM dcrypt_vault.folders WHERE parent_id IS NOT NULL' + ); + for (const row of rows.rows) { + await to.query('UPDATE dcrypt_vault.folders SET parent_id = $2 WHERE id = $1', [ + row.id, + row.parent_id, + ]); + } +}; + /** Derive the per-value encryption key from the master passphrase. */ export const deriveDbKey = (passphrase: string, saltHex: string): string => bytesToHex(hkdf(sha256, utf8ToBytes(passphrase), utf8ToBytes(saltHex), utf8ToBytes(DB_KEY_INFO), 32)); From a5bc1387818fe47d8ab555455576b0d24a4f9656 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 7 Aug 2026 23:56:07 +0000 Subject: [PATCH 2/2] chore: single fs import in the vault service --- apps/desktop/src/main/vault-service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/desktop/src/main/vault-service.ts b/apps/desktop/src/main/vault-service.ts index 437a9a6..bb50344 100644 --- a/apps/desktop/src/main/vault-service.ts +++ b/apps/desktop/src/main/vault-service.ts @@ -1,7 +1,6 @@ import { Vault } from '@decryption/vault'; import { appstash, resolve } from 'appstash'; -import { existsSync } from 'fs'; -import { promises as fs } from 'fs'; +import { existsSync, promises as fs } from 'fs'; import * as path from 'path'; import type { TotpEntry, VaultStatus } from '../shared/api';