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:
parent
7e3bac345c
commit
7ca1d14dcb
23 changed files with 1540 additions and 43 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
84
frontend/src/components/UnlockModal.tsx
Normal file
84
frontend/src/components/UnlockModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
frontend/src/components/VaultPasswordModal.tsx
Normal file
178
frontend/src/components/VaultPasswordModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
149
frontend/src/test/VaultPassword.test.tsx
Normal file
149
frontend/src/test/VaultPassword.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue