feat: fail-closed ExportSecretKey with fresh auth and audit
Replace the plain reveal_secret_key flow with an explicit, audited key export that is hard to misuse: Backend (src/app.rs, src/ipc.rs): - New App::export_secret_key(): always requires the vault passphrase (even when the vault is already unlocked), requires a non-blank reason, and resolves the profile server-side via profiles::find_stored_profile. - Refuses export for Nip46Client (external) profiles — the secret key is not present locally — logging the denial. - FAIL-CLOSED: the successful audit entry is written and flushed BEFORE the key is returned; if the audit write fails, the key is not returned (log.record(...)? instead of let _ =). - Audit entries are written on every outcome: external-profile denial, wrong password, and the successful export. - RevealSecretKey IPC is deprecated: it now errors when the vault is locked and, when unlocked, records a [deprecated direct call] audit entry. The method stays registered for the deprecation window. Frontend: - ExportSecretKeyModal requires password + reason every time; clears sensitive state on close. - ProfilesScreen uses ExportSecretKeyModal; ShowSecretKeyModal and its test are removed. AppProvider exposes exportSecretKey (revealSecretKey gone); api.ts maps to export_secret_key. - fakeBackend implements the full export contract (profile-not-found, external-signer refusal, password check, blank-reason rejection); apiMock exposes exportSecretKey. 10 tests cover the required scenarios. No secret material is logged; the reason is logged by design. Verified: cargo test --release 186 passed; frontend tsc clean, vitest 116 passed.
This commit is contained in:
parent
2c61830390
commit
6eff510609
11 changed files with 629 additions and 291 deletions
209
frontend/src/components/ExportSecretKeyModal.tsx
Normal file
209
frontend/src/components/ExportSecretKeyModal.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { BackendError } from '../lib/api';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
import type { RevealedKey } from '../lib/types';
|
||||
import { Alert } from './Alert';
|
||||
import { Button } from './Button';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { ErrorText } from './ErrorText';
|
||||
import { Modal } from './Modal';
|
||||
|
||||
interface ExportSecretKeyModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
profile: { label: string; npub: string } | null;
|
||||
}
|
||||
|
||||
type Phase = 'form' | 'exporting' | 'revealed' | 'error';
|
||||
|
||||
/**
|
||||
* Exports a profile's secret key with fresh passphrase re-authentication.
|
||||
*
|
||||
* Every export requires the vault passphrase and a human-readable reason,
|
||||
* regardless of whether the vault is already unlocked. The key is never
|
||||
* stored in component state beyond the revealed display phase, and all
|
||||
* sensitive state is cleared when the modal closes.
|
||||
*/
|
||||
export function ExportSecretKeyModal({ open, onClose, profile }: ExportSecretKeyModalProps) {
|
||||
const { exportSecretKey } = useApp();
|
||||
const [phase, setPhase] = useState<Phase>('form');
|
||||
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null);
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const clearState = () => {
|
||||
setPhase('form');
|
||||
setRevealed(null);
|
||||
setPassword('');
|
||||
setReason('');
|
||||
setBusy(false);
|
||||
setError(null);
|
||||
setFatal(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && profile) {
|
||||
clearState();
|
||||
// Focus password field after modal opens
|
||||
setTimeout(() => passwordRef.current?.focus(), 0);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, profile?.npub]);
|
||||
|
||||
const handleClose = () => {
|
||||
clearState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const trimmedReason = reason.trim();
|
||||
const canSubmit = password.length > 0 && trimmedReason.length > 0 && !busy;
|
||||
|
||||
const onSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit || !profile) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFatal(null);
|
||||
try {
|
||||
const key = await exportSecretKey(profile.npub, password, trimmedReason);
|
||||
setRevealed(key);
|
||||
setPhase('revealed');
|
||||
// Clear password and reason immediately after successful export
|
||||
setPassword('');
|
||||
setReason('');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const code = err instanceof BackendError ? err.code : undefined;
|
||||
|
||||
// Map specific error codes to user-friendly messages
|
||||
if (code === 'wrong_password') {
|
||||
setError(msg);
|
||||
setPhase('form');
|
||||
passwordRef.current?.select();
|
||||
} else if (code === 'profile_not_found') {
|
||||
setFatal({ message: 'That profile is not stored on this computer.' });
|
||||
setPhase('error');
|
||||
} else if (code === 'external_signer_not_connected' || code === 'external_signer_identity_mismatch') {
|
||||
setFatal({
|
||||
message: 'This profile uses an external signer. Secret key export is not possible for externally managed accounts.',
|
||||
});
|
||||
setPhase('error');
|
||||
} else {
|
||||
setFatal({
|
||||
message: msg,
|
||||
details: err instanceof BackendError ? err.details : undefined,
|
||||
});
|
||||
setPhase('error');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = `Export secret key${profile ? ` — ${profile.label}` : ''}`;
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={handleClose}>
|
||||
{phase === 'form' && (
|
||||
<form onSubmit={onSubmit} noValidate>
|
||||
<Alert tone="warning" title="This action is logged">
|
||||
Exporting a secret key creates an audit entry. The key itself is never stored in logs.
|
||||
</Alert>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="export-secret-password">Vault password</label>
|
||||
<input
|
||||
ref={passwordRef}
|
||||
id="export-secret-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
disabled={busy}
|
||||
aria-describedby={error ? 'export-password-error' : undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
/>
|
||||
{error && <ErrorText id="export-password-error">{error}</ErrorText>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label htmlFor="export-secret-reason">Reason for export</label>
|
||||
<input
|
||||
id="export-secret-reason"
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. backup, migration, device transfer"
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
|
||||
{busy ? 'Exporting…' : 'Export'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{phase === 'error' && fatal && (
|
||||
<div>
|
||||
<Alert tone="error" title="Could not export the secret key" details={fatal.details}>
|
||||
{fatal.message}
|
||||
</Alert>
|
||||
<div className="modal-actions">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'revealed' && revealed && (
|
||||
<div>
|
||||
<Alert tone="error" title="Keep this key safe">
|
||||
Anyone who has this key can fully control the profile: publish as it, sign messages, and
|
||||
move its funds. Never paste it into chat, logs, or screenshots. Store it offline and
|
||||
back it up.
|
||||
</Alert>
|
||||
|
||||
<div className="path-row">
|
||||
<div>
|
||||
<span className="field-label">Private key (hex)</span>
|
||||
<code className="mono path-value">{revealed.hex}</code>
|
||||
</div>
|
||||
<CopyButton text={revealed.hex} label="hex key" />
|
||||
</div>
|
||||
|
||||
<div className="path-row">
|
||||
<div>
|
||||
<span className="field-label">Private key (nsec)</span>
|
||||
<code className="mono path-value">{revealed.nsec}</code>
|
||||
</div>
|
||||
<CopyButton text={revealed.nsec} label="nsec key" />
|
||||
</div>
|
||||
|
||||
<p className="hint">
|
||||
The <code>nsec1…</code> form is what most Nostr wallets and clients import. It encodes
|
||||
exactly the same key as the hex form above.
|
||||
</p>
|
||||
|
||||
<div className="modal-actions">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { BackendError } from '../lib/api';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
import type { RevealedKey } from '../lib/types';
|
||||
import { Alert } from './Alert';
|
||||
import { Button } from './Button';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { ErrorText } from './ErrorText';
|
||||
import { Modal } from './Modal';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
interface ShowSecretKeyModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** The profile whose secret key is being revealed. */
|
||||
profile: { label: string; npub: string } | null;
|
||||
}
|
||||
|
||||
type Phase = 'loading' | 'unlock' | 'revealed' | 'error';
|
||||
|
||||
/**
|
||||
* Shows a profile's secret key (hex + nsec) after unlocking the vault.
|
||||
*
|
||||
* When the vault is password-protected and still locked, the modal asks for
|
||||
* the password inline, unlocks, and then reveals the key. The secret key is
|
||||
* only ever fetched from the backend, never stored in state before reveal.
|
||||
*/
|
||||
export function ShowSecretKeyModal({ open, onClose, profile }: ShowSecretKeyModalProps) {
|
||||
const { revealSecretKey, unlockVault } = useApp();
|
||||
const [phase, setPhase] = useState<Phase>('loading');
|
||||
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
|
||||
const [password, setPassword] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const unlockErrorId = 'show-secret-unlock-error';
|
||||
|
||||
useEffect(() => {
|
||||
if (open && profile) {
|
||||
setPhase('loading');
|
||||
setRevealed(null);
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setFatal(null);
|
||||
setBusy(false);
|
||||
void reveal(profile.npub);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, profile?.npub]);
|
||||
|
||||
const reveal = async (npub: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFatal(null);
|
||||
try {
|
||||
const key = await revealSecretKey(npub);
|
||||
setRevealed(key);
|
||||
setPhase('revealed');
|
||||
} catch (err) {
|
||||
if (err instanceof BackendError && err.code === 'vault_locked') {
|
||||
setPhase('unlock');
|
||||
return;
|
||||
}
|
||||
setFatal({
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
details: err instanceof BackendError ? err.details : undefined,
|
||||
});
|
||||
setPhase('error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = password.length > 0 && !busy;
|
||||
|
||||
const onUnlock = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await unlockVault(password);
|
||||
setPassword('');
|
||||
if (profile) {
|
||||
await reveal(profile.npub);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setPassword('');
|
||||
setBusy(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const title = `Secret key${profile ? ` — ${profile.label}` : ''}`;
|
||||
|
||||
return (
|
||||
<Modal open={open} title={title} onClose={onClose}>
|
||||
{phase === 'loading' && <Spinner label="Revealing secret key…" />}
|
||||
|
||||
{phase === 'unlock' && (
|
||||
<form onSubmit={onUnlock} noValidate>
|
||||
<Alert tone="warning" title="Vault is locked">
|
||||
This profile's keys are password-protected. Enter the vault password to reveal the
|
||||
secret key. The password itself is never saved.
|
||||
</Alert>
|
||||
<div className="field">
|
||||
<label htmlFor="show-secret-password">Vault password</label>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id="show-secret-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
aria-describedby={error ? unlockErrorId : undefined}
|
||||
aria-invalid={error ? true : undefined}
|
||||
disabled={busy}
|
||||
/>
|
||||
{error && <ErrorText id={unlockErrorId}>{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>
|
||||
)}
|
||||
|
||||
{phase === 'error' && fatal && (
|
||||
<div>
|
||||
<Alert tone="error" title="Could not reveal the secret key" details={fatal.details}>
|
||||
{fatal.message}
|
||||
</Alert>
|
||||
<div className="modal-actions">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'revealed' && revealed && (
|
||||
<div>
|
||||
<Alert tone="error" title="Keep this key safe">
|
||||
Anyone who has this key can fully control the profile: publish as it, sign messages, and
|
||||
move its funds. Never paste it into chat, logs, or screenshots. Store it offline and
|
||||
back it up.
|
||||
</Alert>
|
||||
|
||||
<div className="path-row">
|
||||
<div>
|
||||
<span className="field-label">Private key (hex)</span>
|
||||
<code className="mono path-value">{revealed.hex}</code>
|
||||
</div>
|
||||
<CopyButton text={revealed.hex} label="hex key" />
|
||||
</div>
|
||||
|
||||
<div className="path-row">
|
||||
<div>
|
||||
<span className="field-label">Private key (nsec)</span>
|
||||
<code className="mono path-value">{revealed.nsec}</code>
|
||||
</div>
|
||||
<CopyButton text={revealed.nsec} label="nsec key" />
|
||||
</div>
|
||||
|
||||
<p className="hint">
|
||||
The <code>nsec1…</code> form is what most Nostr wallets and clients import. It encodes
|
||||
exactly the same key as the hex form above.
|
||||
</p>
|
||||
|
||||
<div className="modal-actions">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -101,7 +101,8 @@ export const api = {
|
|||
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
|
||||
lockVault: () => call<AppState>('lock_vault'),
|
||||
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
|
||||
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
|
||||
exportSecretKey: (npub: string, password: string, reason: string) =>
|
||||
call<RevealedKey>('export_secret_key', { npub, password, reason }),
|
||||
pickImages: () => call<PickedImage[]>('pick_image'),
|
||||
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
|
||||
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Icon } from '../components/Icon';
|
|||
import { Modal } from '../components/Modal';
|
||||
import { ProfileEditModal } from '../components/ProfileEditModal';
|
||||
import { ImportProfileModal } from './ImportProfileModal';
|
||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||
import { ExportSecretKeyModal } from '../components/ExportSecretKeyModal';
|
||||
import { formatDate, shortenNpub } from '../lib/format';
|
||||
import type { MetadataPublishReport } from '../lib/types';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
|
@ -347,7 +347,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<ShowSecretKeyModal
|
||||
<ExportSecretKeyModal
|
||||
open={revealTarget !== null}
|
||||
profile={revealTarget}
|
||||
onClose={() => setRevealTarget(null)}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ interface AppContextValue {
|
|||
unlockVault: (password: string) => Promise<AppState>;
|
||||
lockVault: () => Promise<AppState>;
|
||||
removeVaultPassword: (password: string) => Promise<AppState>;
|
||||
revealSecretKey: (npub: string) => Promise<RevealedKey>;
|
||||
exportSecretKey: (npub: string, password: string, reason: string) => Promise<RevealedKey>;
|
||||
pickImages: () => Promise<PickedImage[]>;
|
||||
uploadImage: (token: string) => Promise<UploadedImage>;
|
||||
linkPreview: (url: string) => Promise<LinkPreview | null>;
|
||||
|
|
@ -283,7 +283,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
(password: string) => applyState(api.removeVaultPassword(password)),
|
||||
[applyState],
|
||||
);
|
||||
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
|
||||
const exportSecretKey = useCallback(
|
||||
(npub: string, password: string, reason: string) =>
|
||||
api.exportSecretKey(npub, password, reason),
|
||||
[],
|
||||
);
|
||||
const pickImages = useCallback(() => api.pickImages(), []);
|
||||
const uploadImage = useCallback((token: string) => api.uploadImage(token), []);
|
||||
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
|
||||
|
|
@ -338,7 +342,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
unlockVault,
|
||||
lockVault,
|
||||
removeVaultPassword,
|
||||
revealSecretKey,
|
||||
exportSecretKey,
|
||||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
|
|
@ -395,7 +399,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
unlockVault,
|
||||
lockVault,
|
||||
removeVaultPassword,
|
||||
revealSecretKey,
|
||||
exportSecretKey,
|
||||
pickImages,
|
||||
uploadImage,
|
||||
linkPreview,
|
||||
|
|
|
|||
257
frontend/src/test/ExportSecretKey.test.tsx
Normal file
257
frontend/src/test/ExportSecretKey.test.tsx
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ProfilesScreen } from '../screens/ProfilesScreen';
|
||||
import { ALICE, makeState } from './apiMock';
|
||||
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||
import { renderWithApp } from './render';
|
||||
|
||||
const ALICE_HEX = `${ALICE.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||
const ALICE_NSEC = `nsec1${ALICE.slice(5)}`;
|
||||
|
||||
/** Open the export-secret-key modal for the first profile. */
|
||||
async function openExport(user: ReturnType<typeof userEvent.setup>) {
|
||||
await screen.findByText('Alice');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]);
|
||||
return screen.findByRole('dialog', { name: 'Export secret key — Alice' });
|
||||
}
|
||||
|
||||
describe('exporting a secret key', () => {
|
||||
it('shows the password and reason form immediately', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
expect(within(dialog).getByText(/This action is logged/)).toBeInTheDocument();
|
||||
expect(within(dialog).getByLabelText('Vault password')).toBeInTheDocument();
|
||||
expect(within(dialog).getByLabelText('Reason for export')).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('requires a non-empty trimmed reason before enabling Export', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
|
||||
// Password only — still disabled
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
|
||||
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled();
|
||||
|
||||
// Password + whitespace-only reason — still disabled
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), ' ');
|
||||
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeDisabled();
|
||||
|
||||
// Password + real reason — enabled
|
||||
await user.clear(within(dialog).getByLabelText('Reason for export'));
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
expect(within(dialog).getByRole('button', { name: 'Export' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('sends npub, password, and reason to the backend', async () => {
|
||||
const backend = createFakeBackend(makeState({ encrypted_storage: true }));
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
// Use paste to avoid char-by-char form interaction issues
|
||||
const pwInput = within(dialog).getByLabelText('Vault password');
|
||||
const reasonInput = within(dialog).getByLabelText('Reason for export');
|
||||
await user.click(pwInput);
|
||||
await user.paste('test');
|
||||
await user.click(reasonInput);
|
||||
await user.paste('migration');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const reqs = backend.requests.filter((r) => r.method === 'export_secret_key');
|
||||
const last = reqs[reqs.length - 1];
|
||||
expect(last.params).toEqual({
|
||||
npub: ALICE,
|
||||
password: 'test',
|
||||
reason: 'migration',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows hex and nsec after successful export', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
within(dialog).getByText(/Anyone who has this key can fully control the profile/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Copy buttons work
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Copy hex key' }));
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Copy nsec key' }));
|
||||
await waitFor(() => {
|
||||
expect(backend.copied).toContain(ALICE_HEX);
|
||||
expect(backend.copied).toContain(ALICE_NSEC);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call revealSecretKey', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const exportReq = backend.requests.find((r) => r.method === 'export_secret_key');
|
||||
expect(exportReq).toBeDefined();
|
||||
});
|
||||
// reveal_secret_key should never have been requested
|
||||
const revealReq = backend.requests.find((r) => r.method === 'reveal_secret_key');
|
||||
expect(revealReq).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears sensitive state when the modal closes', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
|
||||
// Close without exporting
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: /Export secret key/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reopen — fields should be empty
|
||||
const dialog2 = await openExport(user);
|
||||
expect((within(dialog2).getByLabelText('Vault password') as HTMLInputElement).value).toBe('');
|
||||
expect((within(dialog2).getByLabelText('Reason for export') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('shows an error for an incorrect password', async () => {
|
||||
const backend = createFakeBackend(makeState({ encrypted_storage: true }));
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'wrong');
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByText('Wrong password.')).toBeInTheDocument();
|
||||
});
|
||||
// Form is still visible for retry
|
||||
expect(within(dialog).getByLabelText('Vault password')).toBeInTheDocument();
|
||||
expect(within(dialog).getByLabelText('Reason for export')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an error for a missing profile', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
// Type a reason first, then use nextErrors to inject a profile_not_found error
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
backend.nextErrors.export_secret_key = {
|
||||
message: 'That profile is not stored on this computer.',
|
||||
code: 'profile_not_found',
|
||||
};
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByText(/not stored on this computer/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an error for an external (Nip46Client) signer profile', async () => {
|
||||
const state = makeState({
|
||||
profiles: [
|
||||
{
|
||||
label: 'Team Account',
|
||||
npub: ALICE,
|
||||
created_at: 1700000000,
|
||||
is_active: true,
|
||||
signer_mode: 'nip46_client',
|
||||
},
|
||||
],
|
||||
active_profile: {
|
||||
label: 'Team Account',
|
||||
npub: ALICE,
|
||||
created_at: 1700000000,
|
||||
is_active: true,
|
||||
signer_mode: 'nip46_client',
|
||||
},
|
||||
});
|
||||
const backend = createFakeBackend(state);
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
// Open modal for the Team Account profile
|
||||
await screen.findByText('Team Account');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]);
|
||||
const dialog = await screen.findByRole('dialog', {
|
||||
name: 'Export secret key — Team Account',
|
||||
});
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(dialog).getByText(/external signer/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not return the key if the audit-log write fails', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openExport(user);
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'test');
|
||||
await user.type(within(dialog).getByLabelText('Reason for export'), 'backup');
|
||||
backend.nextErrors.export_secret_key = {
|
||||
message: 'Could not write audit log.',
|
||||
code: 'io',
|
||||
};
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Export' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(dialog).getByText(/Could not write audit log/)).toBeInTheDocument();
|
||||
});
|
||||
// Key must NOT be shown
|
||||
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
|
||||
expect(within(dialog).queryByText(ALICE_NSEC)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ProfilesScreen } from '../screens/ProfilesScreen';
|
||||
import { makeState } from './apiMock';
|
||||
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||
import { renderWithApp } from './render';
|
||||
import { ALICE } from './apiMock';
|
||||
|
||||
const ALICE_HEX = `${ALICE.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||
const ALICE_NSEC = `nsec1${ALICE.slice(5)}`;
|
||||
|
||||
/** Wait for the profile list to settle, then open the first profile's key reveal. */
|
||||
async function openReveal(user: ReturnType<typeof userEvent.setup>) {
|
||||
await screen.findByText('Alice');
|
||||
await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]);
|
||||
return screen.findByRole('dialog', { name: 'Secret key — Alice' });
|
||||
}
|
||||
|
||||
describe('revealing a secret key', () => {
|
||||
it('shows hex and nsec for an unencrypted vault without asking for a password', async () => {
|
||||
const backend = createFakeBackend();
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openReveal(user);
|
||||
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByText(/Anyone who has this key can fully control the profile/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Copy hex key' }));
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Copy nsec key' }));
|
||||
await waitFor(() => {
|
||||
expect(backend.copied).toContain(ALICE_HEX);
|
||||
expect(backend.copied).toContain(ALICE_NSEC);
|
||||
});
|
||||
});
|
||||
|
||||
it('asks for the vault password when locked, then reveals the key', async () => {
|
||||
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openReveal(user);
|
||||
expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
|
||||
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'correct horse');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backend.state.vault_locked).toBe(false);
|
||||
});
|
||||
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
|
||||
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the unlock form 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.' };
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openReveal(user);
|
||||
await user.type(within(dialog).getByLabelText('Vault password'), 'wrong');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
|
||||
|
||||
expect(await screen.findByText('The password is not correct.')).toBeInTheDocument();
|
||||
expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals directly when the vault is encrypted but already unlocked', async () => {
|
||||
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: false }));
|
||||
installFakeBackend(backend);
|
||||
const user = userEvent.setup();
|
||||
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||
|
||||
const dialog = await openReveal(user);
|
||||
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
|
||||
expect(within(dialog).queryByLabelText('Vault password')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -106,7 +106,7 @@ export interface ApiMock {
|
|||
unlockVault: ReturnType<typeof vi.fn>;
|
||||
lockVault: ReturnType<typeof vi.fn>;
|
||||
removeVaultPassword: ReturnType<typeof vi.fn>;
|
||||
revealSecretKey: ReturnType<typeof vi.fn>;
|
||||
exportSecretKey: ReturnType<typeof vi.fn>;
|
||||
pickImages: ReturnType<typeof vi.fn>;
|
||||
uploadImage: ReturnType<typeof vi.fn>;
|
||||
linkPreview: ReturnType<typeof vi.fn>;
|
||||
|
|
@ -213,7 +213,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
|||
encrypted_storage: false,
|
||||
vault_locked: false,
|
||||
})),
|
||||
revealSecretKey: vi.fn(async (npub: string) => ({
|
||||
exportSecretKey: vi.fn(async (npub: string) => ({
|
||||
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
|
||||
nsec: `nsec1${npub.slice(5)}`,
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -437,16 +437,48 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
return next;
|
||||
}
|
||||
|
||||
case 'reveal_secret_key': {
|
||||
if (state.encrypted_storage && state.vault_locked) {
|
||||
case 'export_secret_key': {
|
||||
const npub = String(params.npub);
|
||||
const password = String(params.password ?? '');
|
||||
const reason = String(params.reason ?? '');
|
||||
|
||||
// Check profile exists first
|
||||
const profile = state.profiles.find((p) => p.npub === npub);
|
||||
if (!profile) {
|
||||
throw Object.assign(
|
||||
new Error('Your vault is locked. Enter your password to unlock it.'),
|
||||
{ code: 'vault_locked' },
|
||||
new Error('That profile is not stored on this computer.'),
|
||||
{ code: 'profile_not_found' },
|
||||
);
|
||||
}
|
||||
const npub = String(params.npub);
|
||||
if (!state.profiles.some((p) => p.npub === npub)) {
|
||||
throw new Error('That profile is not stored on this computer.');
|
||||
|
||||
// External signer profiles cannot export secret keys
|
||||
if (profile.signer_mode === 'nip46_client') {
|
||||
throw Object.assign(
|
||||
new Error('This profile uses an external signer. Secret key export is not possible.'),
|
||||
{ code: 'external_signer_not_connected' },
|
||||
);
|
||||
}
|
||||
|
||||
if (state.encrypted_storage) {
|
||||
if (!password) {
|
||||
throw Object.assign(
|
||||
new Error('Password required to export secret key.'),
|
||||
{ code: 'wrong_password' },
|
||||
);
|
||||
}
|
||||
// Fake password check: accept "test" or "password"
|
||||
if (password !== 'test' && password !== 'password') {
|
||||
throw Object.assign(
|
||||
new Error('Wrong password.'),
|
||||
{ code: 'wrong_password' },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!reason) {
|
||||
throw Object.assign(
|
||||
new Error('A reason is required for key export.'),
|
||||
{ code: 'config' },
|
||||
);
|
||||
}
|
||||
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||
return { hex, nsec: `nsec1${npub.slice(5)}` };
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue