Add password-encrypted vault

- Encrypt stored secret keys with AES-256-GCM under an Argon2id-derived key;
  the vault stays plaintext until a password is set (Settings -> Storage or
  the CLI set-password command)
- Only secret keys are encrypted; labels and npubs stay readable so profiles
  can be browsed while the vault is locked
- Backend: crypto module, Vault.crypto metadata, unlock/lock/set/remove
  password on App, VaultLocked/WrongPassword errors, secret resolution on the
  publish path
- IPC: set_vault_password, unlock_vault, lock_vault, remove_vault_password
- CLI: set-password, remove-password, unlock; create/publish prompt when the
  vault is locked (NFM_PASSWORD env or hidden prompt, never argv)
- GUI: unlock banner + modal on locked vaults, protect/change/remove password
  in Settings, password field styling
- Tests: Rust (argon2/AES round-trips, vault lifecycle) and Vitest (unlock
  flow, set/change/remove password), all green
This commit is contained in:
Avi 2026-08-03 19:00:41 -05:00
commit 7ca1d14dcb
23 changed files with 1540 additions and 43 deletions

View file

@ -2,6 +2,9 @@ import { useState } from 'react';
import { Sidebar } from './components/Sidebar';
import { Spinner } from './components/Spinner';
import { Alert } from './components/Alert';
import { Button } from './components/Button';
import { Icon } from './components/Icon';
import { UnlockModal } from './components/UnlockModal';
import { HomeScreen } from './screens/HomeScreen';
import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen';
@ -15,6 +18,7 @@ function Shell() {
const { state, loading, bootstrapError } = useApp();
const [screen, setScreen] = useState<Screen>('home');
const [createOpen, setCreateOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme);
@ -38,10 +42,28 @@ function Shell() {
);
}
const vaultLocked = state?.vault_locked ?? false;
return (
<div className="app-shell">
<Sidebar screen={screen} onNavigate={setScreen} />
<main className="main" id="main-content">
{vaultLocked && (
<div className="lock-banner">
<Alert tone="warning" title="Vault is locked">
<div className="lock-banner-row">
<span>
Your profile keys are password-protected. Publishing and creating profiles need an
unlocked vault; everything else still works.
</span>
<Button variant="secondary" size="sm" onClick={() => setUnlockOpen(true)}>
<Icon name="shield" size={15} />
Unlock vault
</Button>
</div>
</Alert>
</div>
)}
{screen === 'home' && (
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
)}
@ -51,6 +73,7 @@ function Shell() {
{screen === 'settings' && <SettingsScreen />}
</main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
</div>
);
}

View file

@ -0,0 +1,84 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useApp } from '../state/AppProvider';
import { Button } from './Button';
import { ErrorText } from './ErrorText';
import { Modal } from './Modal';
interface UnlockModalProps {
open: boolean;
onClose: () => void;
}
export function UnlockModal({ open, onClose }: UnlockModalProps) {
const { unlockVault } = useApp();
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const errorId = 'unlock-vault-error';
useEffect(() => {
if (open) {
setPassword('');
setError(null);
setBusy(false);
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [open]);
const canSubmit = password.length > 0 && !busy;
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) {
return;
}
setError(null);
setBusy(true);
try {
await unlockVault(password);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setPassword('');
setBusy(false);
inputRef.current?.focus();
}
};
return (
<Modal open={open} title="Unlock your vault" onClose={onClose}>
<form onSubmit={onSubmit} noValidate>
<p>
Your profile keys are protected by a password. Enter it to unlock this session the
password itself is never saved.
</p>
<div className="field">
<label htmlFor="unlock-password">Password</label>
<input
ref={inputRef}
id="unlock-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
disabled={busy}
/>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
{busy ? 'Unlocking…' : 'Unlock'}
</Button>
</div>
</form>
</Modal>
);
}

View file

@ -0,0 +1,178 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useApp } from '../state/AppProvider';
import { Button } from './Button';
import { ErrorText } from './ErrorText';
import { Modal } from './Modal';
export type VaultPasswordMode = 'set' | 'change' | 'remove';
interface VaultPasswordModalProps {
open: boolean;
mode: VaultPasswordMode;
onClose: () => void;
}
export function VaultPasswordModal({ open, mode, onClose }: VaultPasswordModalProps) {
const { setVaultPassword, removeVaultPassword } = useApp();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const firstRef = useRef<HTMLInputElement>(null);
const errorId = 'vault-password-error';
useEffect(() => {
if (open) {
setCurrent('');
setNext('');
setConfirm('');
setError(null);
setMessage(null);
setBusy(false);
const frame = requestAnimationFrame(() => firstRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [open, mode]);
const isRemove = mode === 'remove';
const needsCurrent = mode === 'change' || mode === 'remove';
const canSubmit =
!busy && (isRemove ? current.length > 0 : next.length > 0 && confirm.length > 0);
const title = isRemove
? 'Remove vault password'
: mode === 'change'
? 'Change vault password'
: 'Encrypt your vault';
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) {
return;
}
setError(null);
setMessage(null);
if (!isRemove && next !== confirm) {
setError('The passwords do not match.');
return;
}
setBusy(true);
try {
if (isRemove) {
await removeVaultPassword(current);
setMessage('Password removed. Keys are stored in plaintext again.');
} else {
await setVaultPassword(needsCurrent ? current : null, next);
setMessage('Vault password set. Your stored keys are now encrypted.');
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<Modal open={open} title={title} onClose={onClose}>
<form onSubmit={onSubmit} noValidate>
{isRemove ? (
<p>
This removes password protection and stores your secret keys in plaintext on this
computer, readable by anyone with access to your user account.
</p>
) : mode === 'change' ? (
<p>
Your keys will be re-encrypted with the new password. The old password will stop working
immediately.
</p>
) : (
<p>
Your profile keys will be encrypted with AES-256 before being saved to disk. You will
need this password every time you use the app, so keep it safe.
</p>
)}
{needsCurrent && (
<div className="field">
<label htmlFor="vault-current-password">Current password</label>
<input
ref={firstRef}
id="vault-current-password"
type="password"
value={current}
onChange={(event) => setCurrent(event.target.value)}
autoComplete="current-password"
aria-describedby={isRemove && error ? errorId : undefined}
aria-invalid={isRemove && error ? true : undefined}
disabled={busy}
/>
{isRemove && error && <ErrorText id={errorId}>{error}</ErrorText>}
</div>
)}
{!isRemove && (
<>
<div className="field">
<label htmlFor="vault-new-password">
{mode === 'change' ? 'New password' : 'Password'}
</label>
<input
ref={needsCurrent ? undefined : firstRef}
id="vault-new-password"
type="password"
value={next}
onChange={(event) => setNext(event.target.value)}
autoComplete="new-password"
disabled={busy}
/>
</div>
<div className="field">
<label htmlFor="vault-confirm-password">Repeat password</label>
<input
id="vault-confirm-password"
type="password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
autoComplete="new-password"
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
disabled={busy}
/>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
<p className="hint">At least 8 characters.</p>
</div>
</>
)}
{message && (
<div className="alert alert-success" role="status">
<div className="alert-body">{message}</div>
</div>
)}
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={busy}>
{message ? 'Close' : 'Cancel'}
</Button>
{!message && (
<Button
variant={isRemove ? 'danger' : 'primary'}
type="submit"
loading={busy}
disabled={!canSubmit}
>
{isRemove
? 'Remove password'
: mode === 'change'
? 'Change password'
: 'Encrypt vault'}
</Button>
)}
</div>
</form>
</Modal>
);
}

View file

@ -51,5 +51,13 @@ export const api = {
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => call<Settings>('settings_update', patch),
backupNow: () => call<{ backup_path: string }>('backup_now'),
setVaultPassword: (currentPassword: string | null, newPassword: string) =>
call<AppState>('set_vault_password', {
current_password: currentPassword,
new_password: newPassword,
}),
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
lockVault: () => call<AppState>('lock_vault'),
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
copyText: (text: string) => window.backend.copyText(text),
};

View file

@ -48,6 +48,8 @@ export interface AppState {
vault_path: string;
settings_path: string;
encrypted_storage: boolean;
/** True when the vault is encrypted and has not been unlocked this session. */
vault_locked: boolean;
migrated_from: string | null;
active_profile: ProfileSummary | null;
profiles: ProfileSummary[];

View file

@ -4,12 +4,14 @@ import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton';
import { Icon } from '../components/Icon';
import { Toggle } from '../components/Toggle';
import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal';
import type { Theme } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SettingsScreen() {
const { state, updateSettings, backupNow } = useApp();
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null);
const settings = state?.settings;
const vaultPath = state?.vault_path ?? '';
@ -111,13 +113,34 @@ export function SettingsScreen() {
Your profiles, including private keys, are stored in this file. It is readable only by
your user account.
</p>
{!encrypted && (
{encrypted ? (
<Alert tone="success" title="Storage is encrypted">
Profile keys are protected with a password (AES-256). You unlock the vault at the
start of each session.
</Alert>
) : (
<Alert tone="warning" title="Storage is not encrypted">
Profile data is saved in plaintext on this computer (as in the original CLI). Anyone
with access to your user account can read your keys. A password-encrypted vault is
planned for a future version.
Profile keys are saved in plaintext on this computer, readable by anyone with access
to your user account. Protect them with a password.
</Alert>
)}
<div className="settings-inline">
<Button
variant="secondary"
onClick={() => setPasswordModal(encrypted ? 'change' : 'set')}
>
<Icon name="shield" size={16} />
{encrypted ? 'Change password' : 'Protect with a password'}
</Button>
{encrypted && (
<Button variant="ghost" onClick={() => setPasswordModal('remove')}>
Remove password
</Button>
)}
</div>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onBackup()}>
<Icon name="shield" size={16} />
@ -163,6 +186,11 @@ export function SettingsScreen() {
</div>
</section>
</div>
<VaultPasswordModal
open={passwordModal !== null}
mode={passwordModal ?? 'set'}
onClose={() => setPasswordModal(null)}
/>
</div>
);
}

View file

@ -43,6 +43,10 @@ interface AppContextValue {
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => Promise<Settings>;
backupNow: () => Promise<{ backup_path: string }>;
setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise<AppState>;
unlockVault: (password: string) => Promise<AppState>;
lockVault: () => Promise<AppState>;
removeVaultPassword: (password: string) => Promise<AppState>;
copyText: (text: string) => Promise<void>;
}
@ -132,6 +136,27 @@ export function AppProvider({ children }: { children: ReactNode }) {
);
const backupNow = useCallback(() => api.backupNow(), []);
const applyState = useCallback(async (fresh: Promise<AppState>) => {
const next = await fresh;
setState(next);
return next;
}, []);
const setVaultPassword = useCallback(
(currentPassword: string | null, newPassword: string) =>
applyState(api.setVaultPassword(currentPassword, newPassword)),
[applyState],
);
const unlockVault = useCallback(
(password: string) => applyState(api.unlockVault(password)),
[applyState],
);
const lockVault = useCallback(() => applyState(api.lockVault()), [applyState]);
const removeVaultPassword = useCallback(
(password: string) => applyState(api.removeVaultPassword(password)),
[applyState],
);
const copyText = useCallback((text: string) => api.copyText(text), []);
useThemeSync(state?.settings.theme);
@ -154,6 +179,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayTest,
updateSettings,
backupNow,
setVaultPassword,
unlockVault,
lockVault,
removeVaultPassword,
copyText,
}),
[
@ -173,6 +202,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayTest,
updateSettings,
backupNow,
setVaultPassword,
unlockVault,
lockVault,
removeVaultPassword,
copyText,
],
);

View file

@ -540,6 +540,7 @@ a {
input[type='text'],
input[type='search'],
input[type='password'],
select,
textarea {
font: inherit;
@ -552,6 +553,8 @@ textarea {
}
input[type='text']:focus-visible,
input[type='search']:focus-visible,
input[type='password']:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
@ -1243,6 +1246,23 @@ select {
padding: 12px 14px;
}
.lock-banner {
padding: 14px 22px 0;
}
.lock-banner .alert {
max-width: 1080px;
margin: 0 auto;
}
.lock-banner-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.path-row > div {
min-width: 0;
}

View file

@ -0,0 +1,149 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { SettingsScreen } from '../screens/SettingsScreen';
import { makeState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { renderWithApp } from './render';
function renderApp(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
return { user: userEvent.setup(), backend };
}
describe('vault encryption', () => {
describe('SettingsScreen', () => {
it('offers to protect an unencrypted vault', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
expect(await screen.findByText('Storage is not encrypted')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Protect with a password/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Change password/i })).not.toBeInTheDocument();
});
it('encrypts the vault through the modal', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Protect with a password/i }));
expect(await screen.findByRole('dialog', { name: 'Encrypt your vault' })).toBeInTheDocument();
await user.type(screen.getByLabelText('Password'), 'correct horse');
await user.type(screen.getByLabelText('Repeat password'), 'correct horse');
await user.click(screen.getByRole('button', { name: /Encrypt vault/i }));
await waitFor(() => {
expect(backend.state.encrypted_storage).toBe(true);
});
expect(backend.state.vault_locked).toBe(false);
expect(
await screen.findByText(/Vault password set. Your stored keys are now encrypted/),
).toBeInTheDocument();
});
it('rejects mismatched passwords', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Protect with a password/i }));
await user.type(screen.getByLabelText('Password'), 'one password');
await user.type(screen.getByLabelText('Repeat password'), 'another password');
await user.click(screen.getByRole('button', { name: /Encrypt vault/i }));
expect(await screen.findByText('The passwords do not match.')).toBeInTheDocument();
expect(backend.state.encrypted_storage).toBe(false);
});
it('shows change and remove actions for an encrypted vault', async () => {
const backend = createFakeBackend(
makeState({ encrypted_storage: true, vault_locked: false }),
);
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
expect(await screen.findByText('Storage is encrypted')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Change password/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Remove password/i })).toBeInTheDocument();
});
it('removes password protection', async () => {
const backend = createFakeBackend(
makeState({ encrypted_storage: true, vault_locked: false }),
);
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Remove password/i }));
const dialog = await screen.findByRole('dialog', { name: 'Remove vault password' });
await user.type(screen.getByLabelText('Current password'), 'correct horse');
await user.click(within(dialog).getByRole('button', { name: 'Remove password' }));
await waitFor(() => {
expect(backend.state.encrypted_storage).toBe(false);
});
expect(
await screen.findByText(/Password removed. Keys are stored in plaintext again/),
).toBeInTheDocument();
});
});
describe('App unlock flow', () => {
it('shows an unlock banner for a locked vault and unlocks it', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
const { user } = renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
expect(screen.getByText('Vault is locked')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Unlock vault/i }));
const dialog = await screen.findByRole('dialog', { name: 'Unlock your vault' });
await user.type(screen.getByLabelText('Password'), 'correct horse');
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
await waitFor(() => {
expect(backend.state.vault_locked).toBe(false);
});
await waitFor(() => {
expect(screen.queryByText('Vault is locked')).not.toBeInTheDocument();
});
expect(dialog).not.toBeInTheDocument();
});
it('keeps the banner when an incorrect password is reported', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
backend.nextErrors.unlock_vault = { message: 'The password is not correct.' };
const { user } = renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
await user.click(screen.getByRole('button', { name: /Unlock vault/i }));
const dialog = await screen.findByRole('dialog', { name: 'Unlock your vault' });
await user.type(screen.getByLabelText('Password'), 'wrong password');
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
expect(await screen.findByText('The password is not correct.')).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Unlock your vault' })).toBeInTheDocument();
expect(screen.getByText('Vault is locked')).toBeInTheDocument();
});
it('does not show the banner for an unlocked vault', async () => {
const backend = createFakeBackend(
makeState({ encrypted_storage: true, vault_locked: false }),
);
renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
expect(screen.queryByText('Vault is locked')).not.toBeInTheDocument();
});
});
});

View file

@ -37,6 +37,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
encrypted_storage: false,
vault_locked: false,
migrated_from: null,
active_profile: alice,
profiles: [alice, bob],
@ -85,6 +86,10 @@ export interface ApiMock {
relayTest: ReturnType<typeof vi.fn>;
settingsUpdate: ReturnType<typeof vi.fn>;
backupNow: ReturnType<typeof vi.fn>;
setVaultPassword: ReturnType<typeof vi.fn>;
unlockVault: ReturnType<typeof vi.fn>;
lockVault: ReturnType<typeof vi.fn>;
removeVaultPassword: ReturnType<typeof vi.fn>;
copyText: ReturnType<typeof vi.fn>;
};
/** Current state object backing init/getState. */
@ -159,6 +164,18 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
backupNow: vi.fn(async () => ({
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1',
})),
setVaultPassword: vi.fn(async () => ({
...state,
encrypted_storage: true,
vault_locked: false,
})),
unlockVault: vi.fn(async () => ({ ...state, vault_locked: false })),
lockVault: vi.fn(async () => ({ ...state, vault_locked: true })),
removeVaultPassword: vi.fn(async () => ({
...state,
encrypted_storage: false,
vault_locked: false,
})),
copyText: vi.fn(async () => undefined),
};

View file

@ -175,6 +175,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'backup_now':
return { backup_path: `${state.vault_path}.backup-1` };
case 'set_vault_password': {
const next: AppState = { ...state, encrypted_storage: true, vault_locked: false };
backend.setState(next);
return next;
}
case 'unlock_vault': {
if (!state.encrypted_storage) {
throw new Error('Your vault is not encrypted.');
}
const next: AppState = { ...state, vault_locked: false };
backend.setState(next);
return next;
}
case 'lock_vault': {
if (!state.encrypted_storage) {
throw new Error('Your vault is not encrypted.');
}
const next: AppState = { ...state, vault_locked: true };
backend.setState(next);
return next;
}
case 'remove_vault_password': {
const next: AppState = { ...state, encrypted_storage: false, vault_locked: false };
backend.setState(next);
return next;
}
default:
throw new Error(`Unknown method: ${method}`);
}