Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand Down
33 changes: 31 additions & 2 deletions apps/desktop/src/main/vault-service.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { Vault } from '@decryption/vault';
import { appstash, resolve } from 'appstash';
import { existsSync } from 'fs';
import { existsSync, 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 => {
Expand Down Expand Up @@ -80,6 +83,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<void> {
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<void> {
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<void> {
if (this.saveTimer) {
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
116 changes: 116 additions & 0 deletions apps/desktop/src/renderer/src/screens/SettingsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' },
Expand All @@ -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));
Expand Down Expand Up @@ -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.');
Expand Down Expand Up @@ -184,6 +224,82 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => {
</Button>
</CardContent>
</Card>

<Card>
<CardHeader>
<CardTitle>Database</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={rebuild} disabled={busy}>
{busy ? 'Working…' : 'Rebuild database'}
</Button>
</CardContent>
</Card>

<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">Erase all data</CardTitle>
<CardDescription>
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.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="destructive" onClick={() => setEraseOpen(true)} disabled={busy}>
Erase all data…
</Button>
</CardContent>
</Card>

<Dialog
open={eraseOpen}
onOpenChange={(open) => {
setEraseOpen(open);
if (!open) setErasePhrase('');
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Erase all data?</DialogTitle>
<DialogDescription>
This permanently deletes {file} and every other dcrypt file on this device. It cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-2">
<Label htmlFor="erase-phrase">
Type {ERASE_PHRASE} to confirm
</Label>
<Input
id="erase-phrase"
value={erasePhrase}
onChange={(e) => setErasePhrase(e.target.value)}
autoFocus
autoComplete="off"
/>
</DialogPanel>
<DialogFooter>
<Button variant="outline" onClick={() => setEraseOpen(false)} disabled={busy}>
Cancel
</Button>
<Button
variant="destructive"
onClick={eraseAll}
disabled={busy || erasePhrase !== ERASE_PHRASE}
>
{busy ? 'Erasing…' : 'Erase everything'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
6 changes: 6 additions & 0 deletions apps/desktop/src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export interface DcryptApi {
lock(): Promise<void>;
save(): Promise<void>;
changePassphrase(next: string): Promise<void>;
/** Re-deploys the pgpm module into a fresh database, keeping every item. */
rebuild(): Promise<void>;
/** Deletes the vault and every other file dcrypt keeps on this machine. */
eraseAll(): Promise<void>;
};
items: {
list(options?: { kind?: ItemKind; folderId?: string; trashed?: boolean }): Promise<VaultItem[]>;
Expand Down Expand Up @@ -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',
Expand Down
50 changes: 50 additions & 0 deletions packages/vault/__tests__/vault.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading
Loading