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 }),
|
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
|
||||||
lockVault: () => call<AppState>('lock_vault'),
|
lockVault: () => call<AppState>('lock_vault'),
|
||||||
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
|
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'),
|
pickImages: () => call<PickedImage[]>('pick_image'),
|
||||||
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
|
uploadImage: (token: string) => call<UploadedImage>('upload_image', { token }),
|
||||||
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
|
linkPreview: (url: string) => call<LinkPreview | null>('link_preview', { url }),
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { Icon } from '../components/Icon';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { ProfileEditModal } from '../components/ProfileEditModal';
|
import { ProfileEditModal } from '../components/ProfileEditModal';
|
||||||
import { ImportProfileModal } from './ImportProfileModal';
|
import { ImportProfileModal } from './ImportProfileModal';
|
||||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
import { ExportSecretKeyModal } from '../components/ExportSecretKeyModal';
|
||||||
import { formatDate, shortenNpub } from '../lib/format';
|
import { formatDate, shortenNpub } from '../lib/format';
|
||||||
import type { MetadataPublishReport } from '../lib/types';
|
import type { MetadataPublishReport } from '../lib/types';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
@ -347,7 +347,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ShowSecretKeyModal
|
<ExportSecretKeyModal
|
||||||
open={revealTarget !== null}
|
open={revealTarget !== null}
|
||||||
profile={revealTarget}
|
profile={revealTarget}
|
||||||
onClose={() => setRevealTarget(null)}
|
onClose={() => setRevealTarget(null)}
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,7 @@ interface AppContextValue {
|
||||||
unlockVault: (password: string) => Promise<AppState>;
|
unlockVault: (password: string) => Promise<AppState>;
|
||||||
lockVault: () => Promise<AppState>;
|
lockVault: () => Promise<AppState>;
|
||||||
removeVaultPassword: (password: string) => Promise<AppState>;
|
removeVaultPassword: (password: string) => Promise<AppState>;
|
||||||
revealSecretKey: (npub: string) => Promise<RevealedKey>;
|
exportSecretKey: (npub: string, password: string, reason: string) => Promise<RevealedKey>;
|
||||||
pickImages: () => Promise<PickedImage[]>;
|
pickImages: () => Promise<PickedImage[]>;
|
||||||
uploadImage: (token: string) => Promise<UploadedImage>;
|
uploadImage: (token: string) => Promise<UploadedImage>;
|
||||||
linkPreview: (url: string) => Promise<LinkPreview | null>;
|
linkPreview: (url: string) => Promise<LinkPreview | null>;
|
||||||
|
|
@ -283,7 +283,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
(password: string) => applyState(api.removeVaultPassword(password)),
|
(password: string) => applyState(api.removeVaultPassword(password)),
|
||||||
[applyState],
|
[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 pickImages = useCallback(() => api.pickImages(), []);
|
||||||
const uploadImage = useCallback((token: string) => api.uploadImage(token), []);
|
const uploadImage = useCallback((token: string) => api.uploadImage(token), []);
|
||||||
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
|
const linkPreview = useCallback((url: string) => api.linkPreview(url), []);
|
||||||
|
|
@ -338,7 +342,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
unlockVault,
|
unlockVault,
|
||||||
lockVault,
|
lockVault,
|
||||||
removeVaultPassword,
|
removeVaultPassword,
|
||||||
revealSecretKey,
|
exportSecretKey,
|
||||||
pickImages,
|
pickImages,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
linkPreview,
|
linkPreview,
|
||||||
|
|
@ -395,7 +399,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
unlockVault,
|
unlockVault,
|
||||||
lockVault,
|
lockVault,
|
||||||
removeVaultPassword,
|
removeVaultPassword,
|
||||||
revealSecretKey,
|
exportSecretKey,
|
||||||
pickImages,
|
pickImages,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
linkPreview,
|
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>;
|
unlockVault: ReturnType<typeof vi.fn>;
|
||||||
lockVault: ReturnType<typeof vi.fn>;
|
lockVault: ReturnType<typeof vi.fn>;
|
||||||
removeVaultPassword: ReturnType<typeof vi.fn>;
|
removeVaultPassword: ReturnType<typeof vi.fn>;
|
||||||
revealSecretKey: ReturnType<typeof vi.fn>;
|
exportSecretKey: ReturnType<typeof vi.fn>;
|
||||||
pickImages: ReturnType<typeof vi.fn>;
|
pickImages: ReturnType<typeof vi.fn>;
|
||||||
uploadImage: ReturnType<typeof vi.fn>;
|
uploadImage: ReturnType<typeof vi.fn>;
|
||||||
linkPreview: ReturnType<typeof vi.fn>;
|
linkPreview: ReturnType<typeof vi.fn>;
|
||||||
|
|
@ -213,7 +213,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
||||||
encrypted_storage: false,
|
encrypted_storage: false,
|
||||||
vault_locked: false,
|
vault_locked: false,
|
||||||
})),
|
})),
|
||||||
revealSecretKey: vi.fn(async (npub: string) => ({
|
exportSecretKey: vi.fn(async (npub: string) => ({
|
||||||
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
|
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
|
||||||
nsec: `nsec1${npub.slice(5)}`,
|
nsec: `nsec1${npub.slice(5)}`,
|
||||||
})),
|
})),
|
||||||
|
|
|
||||||
|
|
@ -437,16 +437,48 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'reveal_secret_key': {
|
case 'export_secret_key': {
|
||||||
if (state.encrypted_storage && state.vault_locked) {
|
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(
|
throw Object.assign(
|
||||||
new Error('Your vault is locked. Enter your password to unlock it.'),
|
new Error('That profile is not stored on this computer.'),
|
||||||
{ code: 'vault_locked' },
|
{ code: 'profile_not_found' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const npub = String(params.npub);
|
|
||||||
if (!state.profiles.some((p) => p.npub === npub)) {
|
// External signer profiles cannot export secret keys
|
||||||
throw new Error('That profile is not stored on this computer.');
|
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);
|
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||||
return { hex, nsec: `nsec1${npub.slice(5)}` };
|
return { hex, nsec: `nsec1${npub.slice(5)}` };
|
||||||
|
|
|
||||||
77
src/app.rs
77
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<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.
|
/// 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.
|
/// Returns the restored profile summary, or an error if there is no undo history.
|
||||||
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
|
pub fn undo_delete(&mut self) -> Result<ProfileSummary, AppError> {
|
||||||
|
|
|
||||||
33
src/ipc.rs
33
src/ipc.rs
|
|
@ -132,6 +132,13 @@ pub enum Request {
|
||||||
RevealSecretKey {
|
RevealSecretKey {
|
||||||
npub: String,
|
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.
|
/// Sign a NIP-98 auth event for the active profile, for uploading media.
|
||||||
UploadAuth {
|
UploadAuth {
|
||||||
url: String,
|
url: String,
|
||||||
|
|
@ -727,7 +734,33 @@ async fn run_with_app(app: &mut App, request: Request) -> Result<serde_json::Val
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::RevealSecretKey { npub } => {
|
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())?;
|
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))
|
Ok(json!(revealed))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue