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
82 changes: 82 additions & 0 deletions apps/desktop/__tests__/biometric.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
53 changes: 53 additions & 0 deletions apps/desktop/__tests__/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
4 changes: 4 additions & 0 deletions apps/desktop/src/main/backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 };
};
64 changes: 64 additions & 0 deletions apps/desktop/src/main/biometric.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<string | null> => {
const file = secretFile();
if (!existsSync(file)) return null;
if (canPrompt()) await systemPreferences.promptTouchID(reason);
return safeStorage.decryptString(await fs.readFile(file));
};
27 changes: 27 additions & 0 deletions apps/desktop/src/main/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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;
};
35 changes: 31 additions & 4 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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());

Expand Down Expand Up @@ -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<boolean> => {
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)));

Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
13 changes: 6 additions & 7 deletions apps/desktop/src/renderer/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Loading
Loading