diff --git a/frontend/src/components/ExportSecretKeyModal.tsx b/frontend/src/components/ExportSecretKeyModal.tsx new file mode 100644 index 0000000..6aba1c4 --- /dev/null +++ b/frontend/src/components/ExportSecretKeyModal.tsx @@ -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('form'); + const [revealed, setRevealed] = useState(null); + const [password, setPassword] = useState(''); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null); + const passwordRef = useRef(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 ( + + {phase === 'form' && ( +
+ + Exporting a secret key creates an audit entry. The key itself is never stored in logs. + + +
+ + setPassword(e.target.value)} + autoComplete="current-password" + disabled={busy} + aria-describedby={error ? 'export-password-error' : undefined} + aria-invalid={error ? true : undefined} + /> + {error && {error}} +
+ +
+ + setReason(e.target.value)} + placeholder="e.g. backup, migration, device transfer" + disabled={busy} + /> +
+ +
+ + +
+
+ )} + + {phase === 'error' && fatal && ( +
+ + {fatal.message} + +
+ +
+
+ )} + + {phase === 'revealed' && revealed && ( +
+ + 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. + + +
+
+ Private key (hex) + {revealed.hex} +
+ +
+ +
+
+ Private key (nsec) + {revealed.nsec} +
+ +
+ +

+ The nsec1… form is what most Nostr wallets and clients import. It encodes + exactly the same key as the hex form above. +

+ +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ShowSecretKeyModal.tsx b/frontend/src/components/ShowSecretKeyModal.tsx deleted file mode 100644 index ae1eb40..0000000 --- a/frontend/src/components/ShowSecretKeyModal.tsx +++ /dev/null @@ -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('loading'); - const [revealed, setRevealed] = useState(null); - const [password, setPassword] = useState(''); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null); - const inputRef = useRef(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 ( - - {phase === 'loading' && } - - {phase === 'unlock' && ( -
- - This profile's keys are password-protected. Enter the vault password to reveal the - secret key. The password itself is never saved. - -
- - setPassword(event.target.value)} - autoComplete="current-password" - autoFocus - aria-describedby={error ? unlockErrorId : undefined} - aria-invalid={error ? true : undefined} - disabled={busy} - /> - {error && {error}} -
-
- - -
-
- )} - - {phase === 'error' && fatal && ( -
- - {fatal.message} - -
- -
-
- )} - - {phase === 'revealed' && revealed && ( -
- - 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. - - -
-
- Private key (hex) - {revealed.hex} -
- -
- -
-
- Private key (nsec) - {revealed.nsec} -
- -
- -

- The nsec1… form is what most Nostr wallets and clients import. It encodes - exactly the same key as the hex form above. -

- -
- -
-
- )} -
- ); -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a343aa2..1bfc078 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -101,7 +101,8 @@ export const api = { unlockVault: (password: string) => call('unlock_vault', { password }), lockVault: () => call('lock_vault'), removeVaultPassword: (password: string) => call('remove_vault_password', { password }), - revealSecretKey: (npub: string) => call('reveal_secret_key', { npub }), + exportSecretKey: (npub: string, password: string, reason: string) => + call('export_secret_key', { npub, password, reason }), pickImages: () => call('pick_image'), uploadImage: (token: string) => call('upload_image', { token }), linkPreview: (url: string) => call('link_preview', { url }), diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index 4831794..60c292b 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -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) { ))} - setRevealTarget(null)} diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 85c7c68..602369a 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -67,7 +67,7 @@ interface AppContextValue { unlockVault: (password: string) => Promise; lockVault: () => Promise; removeVaultPassword: (password: string) => Promise; - revealSecretKey: (npub: string) => Promise; + exportSecretKey: (npub: string, password: string, reason: string) => Promise; pickImages: () => Promise; uploadImage: (token: string) => Promise; linkPreview: (url: string) => Promise; @@ -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, diff --git a/frontend/src/test/ExportSecretKey.test.tsx b/frontend/src/test/ExportSecretKey.test.tsx new file mode 100644 index 0000000..cfbb87b --- /dev/null +++ b/frontend/src/test/ExportSecretKey.test.tsx @@ -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) { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + }); +}); diff --git a/frontend/src/test/ShowSecretKey.test.tsx b/frontend/src/test/ShowSecretKey.test.tsx deleted file mode 100644 index 93337fa..0000000 --- a/frontend/src/test/ShowSecretKey.test.tsx +++ /dev/null @@ -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) { - 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(); - - 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(); - - 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(); - - 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(); - - const dialog = await openReveal(user); - expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument(); - expect(within(dialog).queryByLabelText('Vault password')).not.toBeInTheDocument(); - }); -}); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index be3ae41..f616efc 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -106,7 +106,7 @@ export interface ApiMock { unlockVault: ReturnType; lockVault: ReturnType; removeVaultPassword: ReturnType; - revealSecretKey: ReturnType; + exportSecretKey: ReturnType; pickImages: ReturnType; uploadImage: ReturnType; linkPreview: ReturnType; @@ -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)}`, })), diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index ad1531e..f4f2551 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -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)}` }; diff --git a/src/app.rs b/src/app.rs index 4326f15..b6f9372 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 { + 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 { diff --git a/src/ipc.rs b/src/ipc.rs index 1dd3cc3..cfa1bed 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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 { + // 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)) }