diff --git a/apps/desktop/__tests__/biometric.test.ts b/apps/desktop/__tests__/biometric.test.ts new file mode 100644 index 0000000..19f2d9c --- /dev/null +++ b/apps/desktop/__tests__/biometric.test.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dcrypt-unlock-')); +const prompts: string[] = []; +let touchId = true; + +// the fingerprint path is macOS-only, and the tests run wherever CI runs +const realPlatform = process.platform; +Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + +// the OS store, stood in for by a reversible transform: what matters here is +// that the file is not the password, and that a prompt gates reading it back +vi.mock('electron', () => ({ + app: { getName: () => 'dcrypt' }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(value).reverse(), + decryptString: (data: Buffer) => Buffer.from(data).reverse().toString(), + }, + systemPreferences: { + canPromptTouchID: () => touchId, + promptTouchID: async (reason: string) => { + prompts.push(reason); + }, + }, +})); + +vi.mock('../src/main/vault-service', () => ({ appDataPath: () => dir })); + +const { biometricStatus, enrol, forget, unlockSecret } = await import('../src/main/biometric'); + +const secretFile = path.join(dir, 'config', 'unlock.bin'); + +beforeEach(async () => { + prompts.length = 0; + touchId = true; + await forget(); +}); + +afterAll(async () => { + Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true }); + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe('remembered unlock', () => { + it('has nothing to give until a password is enrolled', async () => { + expect(biometricStatus().enrolled).toBe(false); + expect(await unlockSecret('unlock')).toBeNull(); + expect(prompts).toEqual([]); + }); + + it('returns the password only after the OS prompt', async () => { + await enrol('open sesame'); + expect(biometricStatus().enrolled).toBe(true); + expect(await unlockSecret('unlock your dcrypt vault')).toBe('open sesame'); + expect(prompts).toEqual(['unlock your dcrypt vault']); + }); + + it('never writes the password to disk in the clear', async () => { + await enrol('open sesame'); + const stored = await fs.readFile(secretFile); + expect(stored.toString()).not.toContain('open sesame'); + }); + + it('forgets it, leaving the password the only way in', async () => { + await enrol('open sesame'); + await forget(); + expect(biometricStatus().enrolled).toBe(false); + expect(await unlockSecret('unlock')).toBeNull(); + }); + + it('still unlocks where there is a credential store but no fingerprint', async () => { + touchId = false; + await enrol('open sesame'); + expect(biometricStatus().biometric).toBe(false); + expect(await unlockSecret('unlock')).toBe('open sesame'); + expect(prompts).toEqual([]); + }); +}); diff --git a/apps/desktop/__tests__/clipboard.test.ts b/apps/desktop/__tests__/clipboard.test.ts new file mode 100644 index 0000000..a0aef65 --- /dev/null +++ b/apps/desktop/__tests__/clipboard.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let board = ''; + +vi.mock('electron', () => ({ + clipboard: { + writeText: (value: string) => { + board = value; + }, + readText: () => board, + clear: () => { + board = ''; + }, + }, +})); + +const { clearClipboardTimer, copyWithTimeout } = await import('../src/main/clipboard'); + +beforeEach(() => { + board = ''; + vi.useFakeTimers(); +}); + +afterEach(() => { + clearClipboardTimer(); + vi.useRealTimers(); +}); + +describe('copyWithTimeout', () => { + it('copies, then takes the secret back off', () => { + copyWithTimeout('cnc_live_sk_secret', 30); + expect(board).toBe('cnc_live_sk_secret'); + vi.advanceTimersByTime(30_000); + expect(board).toBe(''); + }); + + it('leaves whatever the user copied since alone', () => { + copyWithTimeout('cnc_live_sk_secret', 30); + board = 'a shopping list'; + vi.advanceTimersByTime(30_000); + expect(board).toBe('a shopping list'); + }); + + it('clears only the newest secret when two are copied', () => { + copyWithTimeout('first', 30); + vi.advanceTimersByTime(20_000); + copyWithTimeout('second', 30); + vi.advanceTimersByTime(10_000); + expect(board).toBe('second'); + vi.advanceTimersByTime(20_000); + expect(board).toBe(''); + }); +}); diff --git a/apps/desktop/src/main/backup.ts b/apps/desktop/src/main/backup.ts index c3edd41..eed7620 100644 --- a/apps/desktop/src/main/backup.ts +++ b/apps/desktop/src/main/backup.ts @@ -4,6 +4,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import type { BackupResult } from '../shared/api'; +import { forget as forgetUnlockKey } from './biometric'; import { vaultFilePath,VaultService } from './vault-service'; /** `dcrypt-vault-2026-08-07-1432.dcrypt` — sorts chronologically in a folder. */ @@ -85,5 +86,8 @@ export const restoreVault = async ( if (replaced) await fs.rename(kept, target).catch(() => undefined); throw err; } + // the restored vault may well have a different password than the one this + // machine remembers, and a remembered password that fails is a dead end + await forgetUnlockKey(); return { path: chosen, replaced: replaced ? kept : null }; }; diff --git a/apps/desktop/src/main/biometric.ts b/apps/desktop/src/main/biometric.ts new file mode 100644 index 0000000..202f0eb --- /dev/null +++ b/apps/desktop/src/main/biometric.ts @@ -0,0 +1,64 @@ +import { app, safeStorage, systemPreferences } from 'electron'; +import { existsSync, promises as fs } from 'fs'; +import * as path from 'path'; + +import type { BiometricStatus } from '../shared/api'; +import { appDataPath } from './vault-service'; + +/** + * The master password, sealed by the OS credential store — Keychain on macOS, + * DPAPI on Windows, libsecret/kwallet on Linux — so unlocking can be a + * fingerprint instead of typing it. It is the same secret either way: what + * biometrics change is who is asked, not what protects the vault. The vault + * file itself is untouched, and still opens with the password anywhere else. + */ +const secretFile = (): string => path.join(appDataPath(), 'config', 'unlock.bin'); + +/** macOS is the only platform Electron gives a biometric prompt for. */ +const canPrompt = (): boolean => + process.platform === 'darwin' && systemPreferences.canPromptTouchID(); + +export const biometricStatus = (): BiometricStatus => ({ + available: safeStorage.isEncryptionAvailable(), + biometric: canPrompt(), + enrolled: existsSync(secretFile()), + store: + process.platform === 'darwin' + ? 'Keychain' + : process.platform === 'win32' + ? 'Credential Manager' + : 'the system keyring', +}); + +/** + * Remember the password for this machine. Refuses when the OS store is not + * usable rather than falling back to anything weaker — on Linux that means no + * keyring is running, and writing the password in the clear would be worse + * than making the user type it. + */ +export const enrol = async (passphrase: string): Promise => { + if (!safeStorage.isEncryptionAvailable()) { + throw new Error( + `${app.getName()} cannot reach ${biometricStatus().store} to store the password safely` + ); + } + const file = secretFile(); + await fs.mkdir(path.dirname(file), { recursive: true }); + await fs.writeFile(file, safeStorage.encryptString(passphrase), { mode: 0o600 }); +}; + +export const forget = async (): Promise => { + await fs.rm(secretFile(), { force: true }); +}; + +/** + * The remembered password, after the OS has satisfied itself about who is + * asking. Null when nothing is enrolled; a refused or cancelled prompt throws, + * so the caller can say so rather than silently offering the password field. + */ +export const unlockSecret = async (reason: string): Promise => { + const file = secretFile(); + if (!existsSync(file)) return null; + if (canPrompt()) await systemPreferences.promptTouchID(reason); + return safeStorage.decryptString(await fs.readFile(file)); +}; diff --git a/apps/desktop/src/main/clipboard.ts b/apps/desktop/src/main/clipboard.ts new file mode 100644 index 0000000..d9ee2be --- /dev/null +++ b/apps/desktop/src/main/clipboard.ts @@ -0,0 +1,27 @@ +import { clipboard } from 'electron'; + +/** + * Copying happens in the main process because the renderer's async clipboard + * needs a secure context and a read permission it is never granted: a packaged + * build loads over `file://`, where `navigator.clipboard` is not there at all. + */ +let pending: NodeJS.Timeout | null = null; + +/** Put a secret on the clipboard, and take it back off after a while. */ +export const copyWithTimeout = (value: string, seconds: number): void => { + if (pending) clearTimeout(pending); + clipboard.writeText(value); + pending = setTimeout(() => { + pending = null; + // only if it is still ours — clearing what the user copied since would be rude + if (clipboard.readText() === value) clipboard.clear(); + }, seconds * 1000); + pending.unref(); +}; + +/** Wipe a copied secret now, e.g. when the vault locks. */ +export const clearClipboardTimer = (): void => { + if (!pending) return; + clearTimeout(pending); + pending = null; +}; diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index dfb8970..3951d0d 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -17,7 +17,9 @@ import { } from '../shared/api'; import { parseOtpauthUri } from '../shared/otpauth'; import { backupVault, restoreVault } from './backup'; +import { biometricStatus, enrol, forget as forgetUnlockKey, unlockSecret } from './biometric'; import { lookupBrandIcons } from './brand-icons'; +import { clearClipboardTimer, copyWithTimeout } from './clipboard'; import { vaultFilePath,VaultService } from './vault-service'; const WORD_COUNTS: WordCount[] = [12, 15, 18, 21, 24]; @@ -39,11 +41,16 @@ export const registerIpc = (service: VaultService): void => { // ─── vault lifecycle ─── handle(CHANNELS.vaultStatus, () => service.status()); handle(CHANNELS.vaultUnlock, (passphrase: string) => service.unlock(assertString(passphrase))); - handle(CHANNELS.vaultLock, () => service.lock()); + handle(CHANNELS.vaultLock, () => { + clearClipboardTimer(); + return service.lock(); + }); handle(CHANNELS.vaultSave, () => service.current().save()); - handle(CHANNELS.vaultChangePassphrase, (next: string) => - service.current().changePassphrase(assertString(next)) - ); + handle(CHANNELS.vaultChangePassphrase, async (next: string) => { + await service.current().changePassphrase(assertString(next)); + // a remembered password that no longer opens the vault is worse than none + if (biometricStatus().enrolled) await enrol(next); + }); handle(CHANNELS.vaultRebuild, () => service.rebuild()); handle(CHANNELS.vaultEraseAll, () => service.eraseAll()); @@ -161,6 +168,26 @@ export const registerIpc = (service: VaultService): void => { else await shell.openPath(path.dirname(file)); }); + // ─── unlocking without the password ─── + handle(CHANNELS.unlockKeyStatus, () => biometricStatus()); + handle(CHANNELS.unlockKeyEnrol, async (passphrase: string) => { + // prove it opens the vault before promising the user it will + await service.unlock(assertString(passphrase)); + await enrol(passphrase); + }); + handle(CHANNELS.unlockKeyForget, () => forgetUnlockKey()); + handle(CHANNELS.unlockKeyUnlock, async (): Promise => { + const passphrase = await unlockSecret('unlock your dcrypt vault'); + if (!passphrase) return false; + await service.unlock(passphrase); + return true; + }); + + // ─── clipboard ─── + handle(CHANNELS.clipboardCopy, (value: string, seconds?: number) => { + copyWithTimeout(assertString(value), seconds === undefined ? 30 : assertInt(seconds, 1, 3600)); + }); + // ─── brand icons (bundled, offline) ─── handle(CHANNELS.iconsLookup, (names: string[]) => lookupBrandIcons(assertStringArray(names))); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2d269e0..cbf952c 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -85,6 +85,15 @@ const api: DcryptApi & { restore: () => invoke(CHANNELS.backupRestore), revealVault: () => invoke(CHANNELS.backupRevealVault), }, + unlockKey: { + status: () => invoke(CHANNELS.unlockKeyStatus), + enrol: (passphrase) => invoke(CHANNELS.unlockKeyEnrol, passphrase), + forget: () => invoke(CHANNELS.unlockKeyForget), + unlock: () => invoke(CHANNELS.unlockKeyUnlock), + }, + clipboard: { + copy: (value, seconds) => invoke(CHANNELS.clipboardCopy, value, seconds), + }, icons: { lookup: (names) => invoke(CHANNELS.iconsLookup, names), }, diff --git a/apps/desktop/src/renderer/src/lib/ipc.ts b/apps/desktop/src/renderer/src/lib/ipc.ts index 6da55e3..7396da2 100644 --- a/apps/desktop/src/renderer/src/lib/ipc.ts +++ b/apps/desktop/src/renderer/src/lib/ipc.ts @@ -13,12 +13,11 @@ declare global { export const dcrypt: RendererApi = window.dcrypt; -/** Copy to clipboard and clear it after a timeout so secrets don't linger. */ +/** + * Copy to clipboard and clear it after a timeout so secrets don't linger. The + * main process does the work: `navigator.clipboard` needs a secure context and + * a read permission, and a packaged build serves the UI from `file://`. + */ export const copyWithTimeout = (value: string, seconds = 30): void => { - void navigator.clipboard.writeText(value); - setTimeout(() => { - void navigator.clipboard.readText().then((current) => { - if (current === value) void navigator.clipboard.writeText(''); - }); - }, seconds * 1000); + void dcrypt.clipboard.copy(value, seconds); }; diff --git a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx index 7cb465a..d6dc4f3 100644 --- a/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/SettingsScreen.tsx @@ -21,6 +21,7 @@ import { Tabs, TabsList, TabsTrigger } from '@constructive-io/ui/tabs'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; +import type { BiometricStatus } from '../../../shared/api'; import { dcrypt } from '../lib/ipc'; import { ThemeMode } from '../lib/theme'; import { useThemeMode } from '../lib/theme-context'; @@ -42,11 +43,48 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { const [busy, setBusy] = useState(false); const [eraseOpen, setEraseOpen] = useState(false); const [erasePhrase, setErasePhrase] = useState(''); + const [unlockKey, setUnlockKey] = useState(null); + const [remember, setRemember] = useState(''); useEffect(() => { void dcrypt.vault.status().then((status) => setFile(status.file)); + void dcrypt.unlockKey.status().then(setUnlockKey); }, []); + const enrol = async () => { + setBusy(true); + try { + await dcrypt.unlockKey.enrol(remember); + setRemember(''); + setUnlockKey(await dcrypt.unlockKey.status()); + toast.success('This machine will remember your master password'); + } catch (err) { + // the password is checked by opening the vault, so a wrong one lands here + toast.error( + err instanceof Error && /passphrase/i.test(err.message) + ? 'That is not your master password.' + : err instanceof Error + ? err.message + : String(err) + ); + } finally { + setBusy(false); + } + }; + + const forgetUnlockKey = async () => { + setBusy(true); + try { + await dcrypt.unlockKey.forget(); + setUnlockKey(await dcrypt.unlockKey.status()); + toast.success('Forgotten — the master password is needed again'); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + const folderWord = navigator.userAgent.includes('Mac') ? 'Finder' : navigator.userAgent.includes('Windows') @@ -197,6 +235,45 @@ export const SettingsScreen = ({ onLocked }: { onLocked: () => void }) => { + {unlockKey?.available && ( + + + + {unlockKey.biometric ? 'Touch ID' : `Unlock with ${unlockKey.store}`} + + + {unlockKey.biometric + ? `Your master password is kept in ${unlockKey.store} and released by your fingerprint, so you do not have to type it on this Mac.` + : `Your master password is kept in ${unlockKey.store}, so you do not have to type it on this machine.`}{' '} + The vault file is unchanged: on any other machine, and after “Forget + it” here, only the password opens it. + + + + {unlockKey.enrolled ? ( + + ) : ( + <> +
+ + setRemember(e.target.value)} + /> +
+ + + )} +
+
+ )} + Master password diff --git a/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx b/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx index d320599..17802c9 100644 --- a/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx +++ b/apps/desktop/src/renderer/src/screens/UnlockScreen.tsx @@ -2,8 +2,10 @@ import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert'; import { Button } from '@constructive-io/ui/button'; import { Input } from '@constructive-io/ui/input'; import { Label } from '@constructive-io/ui/label'; -import { FormEvent, useEffect, useState } from 'react'; +import { Fingerprint } from 'lucide-react'; +import { FormEvent, useCallback, useEffect, useState } from 'react'; +import type { BiometricStatus } from '../../../shared/api'; import { dcrypt } from '../lib/ipc'; /** @@ -23,11 +25,35 @@ export const UnlockScreen = ({ const [exists, setExists] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const [unlockKey, setUnlockKey] = useState(null); useEffect(() => { void dcrypt.vault.status().then((status) => setExists(status.exists)); }, []); + const useRemembered = useCallback(async () => { + setError(''); + setBusy(true); + try { + if (await dcrypt.unlockKey.unlock()) onUnlocked(); + else setBusy(false); + } catch (err) { + // a cancelled prompt is a choice, not a failure: fall back to the field + const text = err instanceof Error ? err.message : String(err); + if (!/cancel/i.test(text)) setError(text); + setBusy(false); + } + }, [onUnlocked]); + + useEffect(() => { + if (exists !== true || unlockKey !== null) return; + void dcrypt.unlockKey.status().then((status) => { + setUnlockKey(status); + // offering the fingerprint the moment the doors appear is the whole point + if (status.enrolled && status.biometric) void useRemembered(); + }); + }, [exists, unlockKey, useRemembered]); + useEffect(() => onWorkingChange?.(busy), [busy, onWorkingChange]); const creating = exists === false; @@ -130,6 +156,12 @@ export const UnlockScreen = ({ + {unlockKey?.enrolled && ( + + )} ); }; diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index f06c0b3..213936a 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -48,6 +48,18 @@ export interface TotpEntry { remaining: number; } +/** What this machine can offer instead of typing the master password. */ +export interface BiometricStatus { + /** Whether the OS credential store can seal a secret at all. */ + available: boolean; + /** Whether unlocking can be gated by a fingerprint (macOS Touch ID). */ + biometric: boolean; + /** Whether a password is already remembered on this machine. */ + enrolled: boolean; + /** What to call the store in the UI: "Keychain", "Credential Manager", … */ + store: string; +} + /** Where a backup was written, or which file was restored; null if cancelled. */ export interface BackupResult { path: string | null; @@ -181,6 +193,18 @@ export interface DcryptApi { /** Opens the vault's folder in the system file manager. */ revealVault(): Promise; }; + unlockKey: { + status(): Promise; + /** Remember the master password on this machine, sealed by the OS store. */ + enrol(passphrase: string): Promise; + forget(): Promise; + /** Unlock using the remembered password; false when none is remembered. */ + unlock(): Promise; + }; + clipboard: { + /** Copy a secret, and clear it again after `seconds`. */ + copy(value: string, seconds?: number): Promise; + }; icons: { lookup(names: string[]): Promise>; }; @@ -244,6 +268,11 @@ export const CHANNELS = { backupCreate: 'backup:create', backupRestore: 'backup:restore', backupRevealVault: 'backup:reveal-vault', + unlockKeyStatus: 'unlock-key:status', + unlockKeyEnrol: 'unlock-key:enrol', + unlockKeyForget: 'unlock-key:forget', + unlockKeyUnlock: 'unlock-key:unlock', + clipboardCopy: 'clipboard:copy', iconsLookup: 'icons:lookup', lockedEvent: 'vault:locked-event', themeGetSystemDark: 'theme:get-system-dark',