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:
Avi 2026-09-03 09:50:32 -05:00
commit 6eff510609
11 changed files with 629 additions and 291 deletions

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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 }),

View file

@ -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)}

View file

@ -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,

View 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();
});
});

View file

@ -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();
});
});

View file

@ -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)}`,
})),

View file

@ -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)}` };

View file

@ -138,6 +138,83 @@ impl App {
}
}
/// Export a profile's secret key with fresh re-authentication.
///
/// Always requires `password` to be provided, even if the vault is
/// currently unlocked for the session. This is a deliberate security
/// decision: every export is an explicit, logged, authenticated action.
///
/// Returns the revealed key (hex + nsec) on success.
pub fn export_secret_key(
&mut self,
npub: &str,
password: &str,
reason: &str,
is_deprecated: bool,
) -> Result<profiles::RevealedKey, AppError> {
if reason.trim().is_empty() && !is_deprecated {
return Err(AppError::config("A reason is required for key export."));
}
// Check profile exists and is not externally managed
let stored = profiles::find_stored_profile(&self.vault, npub)?;
let signer_mode = stored.signer_mode;
if signer_mode == SignerMode::Nip46Client {
if let Some(ref mut log) = self.audit_log {
let _ = log.record(
npub,
crate::audit::AuditAction::KeyExport,
reason,
false,
Some("Profile uses external signer; key export not possible".to_string()),
);
}
return Err(AppError::external_signer_not_connected());
}
// Derive key from password and verify
let export_key = if let Some(crypto) = self.vault.crypto.as_ref() {
let key = derive_with(crypto, password)?;
if !crypto::verify(&key, &crypto.verifier) {
if let Some(ref mut log) = self.audit_log {
let _ = log.record(
npub,
crate::audit::AuditAction::KeyExport,
reason,
false,
Some("Authentication failed".to_string()),
);
}
return Err(AppError::wrong_password());
}
Some(key)
} else {
// No vault password set; password param is ignored
None
};
// Decrypt the secret key
let revealed = profiles::reveal_secret_key(&self.vault, npub, export_key.as_ref())?;
// Audit the successful export — MUST succeed before returning the key.
// If the audit log cannot be written, the key is not returned (fail-closed).
if let Some(ref mut log) = self.audit_log {
log.record(
npub,
crate::audit::AuditAction::KeyExport,
if is_deprecated {
"[deprecated direct call]"
} else {
reason
},
true,
None,
)?;
}
Ok(revealed)
}
/// Undo the last profile deletion, restoring the profile to the vault.
/// Returns the restored profile summary, or an error if there is no undo history.
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {

View file

@ -132,6 +132,13 @@ pub enum Request {
RevealSecretKey {
npub: String,
},
/// Export a profile's secret key with fresh re-authentication and audit logging.
/// Always requires the vault passphrase, even if already unlocked.
ExportSecretKey {
npub: String,
password: String,
reason: String,
},
/// Sign a NIP-98 auth event for the active profile, for uploading media.
UploadAuth {
url: String,
@ -727,7 +734,33 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
}
Request::RevealSecretKey { npub } => {
// DEPRECATED path: only works when vault is already unlocked for
// this session. ExportSecretKey requires fresh auth always.
if app.is_locked() {
return Err(AppError::config(
"This method is deprecated. Use export_secret_key with a password instead.",
));
}
let revealed = profiles::reveal_secret_key(&app.vault, &npub, app.vault_key())?;
// Audit the deprecated call
if let Some(ref mut log) = app.audit_log {
let _ = log.record(
&npub,
crate::audit::AuditAction::KeyExport,
"[deprecated direct call]",
true,
None,
);
}
Ok(json!(revealed))
}
Request::ExportSecretKey {
npub,
password,
reason,
} => {
let revealed = app.export_secret_key(&npub, &password, &reason, false)?;
Ok(json!(revealed))
}