From aa81c928cfc5eacac26bdb9c00c89611e732fbb3 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Fri, 7 Aug 2026 22:30:10 +0000 Subject: [PATCH] feat(backup): back up and restore the encrypted vault from the menu --- apps/desktop/__tests__/backup.test.ts | 33 ++++ apps/desktop/src/main/backup.ts | 89 +++++++++++ apps/desktop/src/main/index.ts | 9 +- apps/desktop/src/main/ipc.ts | 21 ++- apps/desktop/src/main/menu.ts | 151 ++++++++++++++++++ apps/desktop/src/main/vault-service.ts | 10 ++ apps/desktop/src/preload/index.ts | 5 + .../renderer/src/screens/SettingsScreen.tsx | 67 +++++++- apps/desktop/src/shared/api.ts | 18 +++ 9 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/__tests__/backup.test.ts create mode 100644 apps/desktop/src/main/backup.ts create mode 100644 apps/desktop/src/main/menu.ts diff --git a/apps/desktop/__tests__/backup.test.ts b/apps/desktop/__tests__/backup.test.ts new file mode 100644 index 0000000..0fe67b1 --- /dev/null +++ b/apps/desktop/__tests__/backup.test.ts @@ -0,0 +1,33 @@ +import { encrypt } from '@decryption/core'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { assertEnvelope, backupName } from '../src/main/backup'; + +const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcrypt-backup-')); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe('backupName', () => { + it('stamps the file so copies sort chronologically', () => { + expect(backupName(new Date(2026, 7, 7, 14, 3))).toBe('dcrypt-vault-2026-08-07-1403.dcrypt'); + }); +}); + +describe('assertEnvelope', () => { + it('accepts a dcrypt envelope', async () => { + const file = path.join(dir, 'good.dcrypt'); + await fs.writeFile(file, encrypt(new TextEncoder().encode('vault'), 'pw', 'interactive')); + await expect(assertEnvelope(file)).resolves.toBeUndefined(); + }, 30_000); + + it('refuses to restore anything that is not one', async () => { + const file = path.join(dir, 'holiday.jpg'); + await fs.writeFile(file, Buffer.alloc(512, 7)); + await expect(assertEnvelope(file)).rejects.toThrow(/not a dcrypt envelope/); + }); +}); diff --git a/apps/desktop/src/main/backup.ts b/apps/desktop/src/main/backup.ts new file mode 100644 index 0000000..c3edd41 --- /dev/null +++ b/apps/desktop/src/main/backup.ts @@ -0,0 +1,89 @@ +import { parseHeader } from '@decryption/core'; +import { BrowserWindow, dialog } from 'electron'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { BackupResult } from '../shared/api'; +import { vaultFilePath,VaultService } from './vault-service'; + +/** `dcrypt-vault-2026-08-07-1432.dcrypt` — sorts chronologically in a folder. */ +export const backupName = (now = new Date()): string => { + const pad = (value: number): string => String(value).padStart(2, '0'); + const stamp = [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + `${pad(now.getHours())}${pad(now.getMinutes())}`, + ].join('-'); + return `dcrypt-vault-${stamp}.dcrypt`; +}; + +/** Rejects anything that is not a dcrypt envelope before it can replace a vault. */ +export const assertEnvelope = async (file: string): Promise => { + parseHeader(new Uint8Array(await fs.readFile(file))); +}; + +/** + * Copies the encrypted vault file wherever the user points. The copy is the + * same sealed envelope as the original: it carries no key material, and only + * the master password opens it. + */ +export const backupVault = async ( + service: VaultService, + window: BrowserWindow | null +): Promise => { + const source = vaultFilePath(); + // flush pending edits so the copy is not a moment behind the UI + await service.flush(); + const options = { + title: 'Back up vault', + defaultPath: backupName(), + filters: [{ name: 'Encrypted vault', extensions: ['dcrypt'] }], + message: 'The backup is encrypted with your master password.', + }; + const { canceled, filePath } = window + ? await dialog.showSaveDialog(window, options) + : await dialog.showSaveDialog(options); + if (canceled || !filePath) return { path: null }; + await fs.copyFile(source, filePath); + return { path: filePath }; +}; + +/** + * Replaces the vault with a chosen backup. The vault is locked first and the + * file it replaces is kept alongside it, so a mistaken restore is recoverable. + */ +export const restoreVault = async ( + service: VaultService, + window: BrowserWindow | null +): Promise => { + const options: Electron.OpenDialogOptions = { + title: 'Restore from backup', + properties: ['openFile'], + filters: [{ name: 'Encrypted vault', extensions: ['dcrypt'] }], + message: 'Choose a vault backup. Your current vault is kept as a copy.', + }; + const { canceled, filePaths } = window + ? await dialog.showOpenDialog(window, options) + : await dialog.showOpenDialog(options); + const chosen = filePaths[0]; + if (canceled || !chosen) return { path: null }; + await assertEnvelope(chosen); + + const target = vaultFilePath(); + await service.lock(); + await fs.mkdir(path.dirname(target), { recursive: true }); + const kept = path.join(path.dirname(target), `replaced-${backupName()}`); + const replaced = await fs + .rename(target, kept) + .then(() => true) + .catch(() => false); + try { + await fs.copyFile(chosen, target); + } catch (err) { + // put the original back rather than leaving no vault at all + if (replaced) await fs.rename(kept, target).catch(() => undefined); + throw err; + } + return { path: chosen, replaced: replaced ? kept : null }; +}; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 59e6a13..1a854b5 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,6 +3,7 @@ import * as path from 'path'; import { CHANNELS } from '../shared/api'; import { registerIpc } from './ipc'; +import { buildMenu } from './menu'; import { VaultService } from './vault-service'; const DEV_SERVER_URL = process.env.ELECTRON_RENDERER_URL; @@ -30,7 +31,8 @@ const createWindow = (): BrowserWindow => { minWidth: 960, minHeight: 600, show: false, - autoHideMenuBar: true, + // the menu carries Back Up Vault, so it stays visible on Windows and Linux + autoHideMenuBar: false, icon: APP_ICON, webPreferences: { preload: path.join(__dirname, '../preload/index.js'), @@ -107,6 +109,11 @@ app.whenReady().then(() => { }); registerIpc(service); + buildMenu(service, () => mainWindow, () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(CHANNELS.lockedEvent); + } + }); mainWindow = createWindow(); watchIdle(); diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 21c36cc..7974cdc 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -2,12 +2,15 @@ import { decryptFromString, encryptToString } from '@decryption/core'; import { decrypt as legacyDecrypt } from '@decryption/cosmology-compat'; import { combineToString, splitToStrings } from '@decryption/shamir'; import { createWallet, deriveAccounts, WordCount } from '@decryption/wallet'; -import { ipcMain } from 'electron'; +import { BrowserWindow, ipcMain, shell } from 'electron'; +import { existsSync } from 'fs'; +import * as path from 'path'; import { CHANNELS, FieldPurpose, ItemKind } from '../shared/api'; import { parseOtpauthUri } from '../shared/otpauth'; +import { backupVault, restoreVault } from './backup'; import { lookupBrandIcons } from './brand-icons'; -import { VaultService } from './vault-service'; +import { vaultFilePath,VaultService } from './vault-service'; const WORD_COUNTS: WordCount[] = [12, 15, 18, 21, 24]; @@ -134,6 +137,20 @@ export const registerIpc = (service: VaultService): void => { service.scheduleSave(); }); + // ─── backup ─── + handle(CHANNELS.backupCreate, (): unknown => + backupVault(service, BrowserWindow.getFocusedWindow()) + ); + handle(CHANNELS.backupRestore, (): unknown => + restoreVault(service, BrowserWindow.getFocusedWindow()) + ); + handle(CHANNELS.backupRevealVault, async (): Promise => { + const file = vaultFilePath(); + // an absent vault has no item to select, so fall back to its folder + if (existsSync(file)) shell.showItemInFolder(file); + else await shell.openPath(path.dirname(file)); + }); + // ─── brand icons (bundled, offline) ─── handle(CHANNELS.iconsLookup, (names: string[]) => lookupBrandIcons(assertStringArray(names))); diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts new file mode 100644 index 0000000..ec4b797 --- /dev/null +++ b/apps/desktop/src/main/menu.ts @@ -0,0 +1,151 @@ +import { app, BrowserWindow, dialog, Menu, MenuItemConstructorOptions, shell } from 'electron'; + +import { backupVault, restoreVault } from './backup'; +import { VaultService } from './vault-service'; + +const isMac = process.platform === 'darwin'; + +/** + * The application menu. Backup lives here rather than only in Settings so it + * is findable where people look for it, and so it works while the vault is + * locked — the file is encrypted either way. + */ +export const buildMenu = ( + service: VaultService, + getWindow: () => BrowserWindow | null, + onLocked: () => void +): void => { + const report = async ( + action: () => Promise<{ path: string | null; replaced?: string | null }>, + describe: (result: { path: string; replaced?: string | null }) => { + message: string; + detail: string; + } + ): Promise => { + const window = getWindow(); + try { + const result = await action(); + if (!result.path) return; + const { message, detail } = describe({ path: result.path, replaced: result.replaced }); + const options = { type: 'info' as const, message, detail, buttons: ['OK'] }; + if (window) await dialog.showMessageBox(window, options); + else await dialog.showMessageBox(options); + } catch (err) { + dialog.showErrorBox('dcrypt', err instanceof Error ? err.message : String(err)); + } + }; + + const backup: MenuItemConstructorOptions = { + label: 'Back Up Vault…', + accelerator: 'CmdOrCtrl+Shift+B', + click: () => + void report( + () => backupVault(service, getWindow()), + ({ path }) => ({ + message: 'Vault backed up', + detail: `Saved an encrypted copy to:\n${path}\n\nIt can only be opened with your master password, so it is safe to keep in iCloud Drive, OneDrive, Dropbox or on a USB stick.`, + }) + ), + }; + + const restore: MenuItemConstructorOptions = { + label: 'Restore from Backup…', + click: () => + void report( + async () => { + const result = await restoreVault(service, getWindow()); + if (result.path) onLocked(); + return result; + }, + ({ path, replaced }) => ({ + message: 'Vault restored', + detail: [ + `Restored from:\n${path}`, + replaced ? `\nThe vault it replaced was kept as:\n${replaced}` : '', + '\nUnlock with the master password that backup was made with.', + ].join('\n'), + }) + ), + }; + + const lock: MenuItemConstructorOptions = { + label: 'Lock Vault', + accelerator: 'CmdOrCtrl+L', + click: () => { + void service.lock().then(onLocked); + }, + }; + + const template: MenuItemConstructorOptions[] = [ + ...(isMac + ? ([ + { + label: app.name, + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideOthers' }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + ] satisfies MenuItemConstructorOptions[]) + : []), + { + label: 'File', + submenu: [ + backup, + restore, + { type: 'separator' }, + lock, + { type: 'separator' }, + isMac ? { role: 'close' } : { role: 'quit' }, + ], + }, + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'selectAll' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'toggleDevTools' }, + { type: 'separator' }, + { role: 'resetZoom' }, + { role: 'zoomIn' }, + { role: 'zoomOut' }, + { type: 'separator' }, + { role: 'togglefullscreen' }, + ], + }, + { + label: 'Window', + submenu: isMac + ? [{ role: 'minimize' }, { role: 'zoom' }, { type: 'separator' }, { role: 'front' }] + : [{ role: 'minimize' }, { role: 'close' }], + }, + { + role: 'help', + submenu: [ + { + label: 'dcrypt on GitHub', + click: () => void shell.openExternal('https://github.com/constructive-io/decryption'), + }, + ], + }, + ]; + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +}; diff --git a/apps/desktop/src/main/vault-service.ts b/apps/desktop/src/main/vault-service.ts index dddd1ea..16193c9 100644 --- a/apps/desktop/src/main/vault-service.ts +++ b/apps/desktop/src/main/vault-service.ts @@ -80,6 +80,16 @@ export class VaultService { }, 2000); } + /** Writes any debounced edits now, so the file on disk matches the UI. */ + async flush(): Promise { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + } + await this.locking; + if (this.vault && !this.vault.isLocked) await this.vault.save(); + } + current(): Vault { if (!this.vault || this.vault.isLocked) { throw new Error('vault is locked'); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6487338..c56e698 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -63,6 +63,11 @@ const api: DcryptApi & { invoke(CHANNELS.wbShamirSplit, secret, shares, threshold), shamirCombine: (shares) => invoke(CHANNELS.wbShamirCombine, shares), }, + backup: { + create: () => invoke(CHANNELS.backupCreate), + restore: () => invoke(CHANNELS.backupRestore), + revealVault: () => invoke(CHANNELS.backupRevealVault), + }, icons: { lookup: (names) => invoke(CHANNELS.iconsLookup, names), }, diff --git a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx index c41afa4..43d0eb7 100644 --- a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx @@ -33,6 +33,38 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { void dcrypt.vault.status().then((status) => setFile(status.file)); }, []); + const folderWord = navigator.userAgent.includes('Mac') + ? 'Finder' + : navigator.userAgent.includes('Windows') + ? 'Explorer' + : 'file manager'; + + const backUp = async () => { + setBusy(true); + try { + const { path } = await dcrypt.backup.create(); + if (path) toast.success(`Encrypted backup saved to ${path}`); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const restore = async () => { + setBusy(true); + try { + const { path } = await dcrypt.backup.restore(); + if (!path) return; + toast.success('Vault restored. Unlock with that backup’s master password.'); + 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.'); @@ -84,11 +116,40 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { Storage The vault lives entirely on this device as one encrypted file. There is no account and no - cloud copy — keep your own backups of this file. + cloud copy. - - {file} + + {file} + + + + + + + Backup + + A backup is a copy of that same encrypted file. It stays sealed with your master password + — nobody who holds the copy can read it without that password, so it is safe to keep in + iCloud Drive, OneDrive, Dropbox, or on a USB stick. + + + +
+ + +
+

+ Restoring locks the vault and replaces it with the backup you choose; the file it replaces + is kept alongside it. Unlock with the master password that backup was made with — changing + your password later does not change older backups. +

diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 33bd9da..9d46968 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -44,6 +44,13 @@ export interface TotpEntry { remaining: number; } +/** Where a backup was written, or which file was restored; null if cancelled. */ +export interface BackupResult { + path: string | null; + /** For a restore, the copy kept of the vault that was replaced. */ + replaced?: string | null; +} + /** * A brand mark for an item, resolved from bundled sets: svgl's full-colour * logo markup where available, otherwise simple-icons' monochrome 24x24 path. @@ -117,6 +124,14 @@ export interface DcryptApi { shamirSplit(secret: string, shares: number, threshold: number): Promise; shamirCombine(shares: string[]): Promise; }; + backup: { + /** Copies the encrypted vault file to a location the user chooses. */ + create(): Promise; + /** Locks the vault and replaces it with a chosen backup. */ + restore(): Promise; + /** Opens the vault's folder in the system file manager. */ + revealVault(): Promise; + }; icons: { lookup(names: string[]): Promise>; }; @@ -164,6 +179,9 @@ export const CHANNELS = { wbLegacyDecrypt: 'workbench:legacy-decrypt', wbShamirSplit: 'workbench:shamir-split', wbShamirCombine: 'workbench:shamir-combine', + backupCreate: 'backup:create', + backupRestore: 'backup:restore', + backupRevealVault: 'backup:reveal-vault', iconsLookup: 'icons:lookup', lockedEvent: 'vault:locked-event', themeGetSystemDark: 'theme:get-system-dark',