Add reveal-secret-key after unlock (CLI + GUI)
- profiles::reveal_secret_key returns a key in hex and nsec1... forms, gated on an unlocked vault (VaultLocked when encrypted + locked) - IPC: reveal_secret_key method; error replies now carry a machine-readable code field (ErrorKind as snake_case, e.g. vault_locked) - CLI: show-secret <npub> prompts for the vault password when locked - GUI: 'Secret key' button per profile card opens a modal showing hex + nsec with copy buttons; locked vaults ask for the password inline before revealing - Tests: Rust (reveal plaintext/encrypted/locked) and Vitest (reveal flow, lock-then-unlock), all green
This commit is contained in:
parent
ec2eb6c092
commit
8eb6685281
17 changed files with 594 additions and 54 deletions
|
|
@ -14,7 +14,8 @@ export type IconName =
|
|||
| 'info'
|
||||
| 'shield'
|
||||
| 'publish'
|
||||
| 'external';
|
||||
| 'external'
|
||||
| 'key';
|
||||
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
home: (
|
||||
|
|
@ -85,6 +86,14 @@ const PATHS: Record<IconName, ReactNode> = {
|
|||
<path d="M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5" />
|
||||
</>
|
||||
),
|
||||
key: (
|
||||
<>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.6 12.4 21 2" />
|
||||
<path d="M16 8l3 3" />
|
||||
<path d="M18 6l2 2" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
interface IconProps {
|
||||
|
|
|
|||
188
frontend/src/components/ShowSecretKeyModal.tsx
Normal file
188
frontend/src/components/ShowSecretKeyModal.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
ProfileSummary,
|
||||
PublishReport,
|
||||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
} from './types';
|
||||
|
||||
|
|
@ -19,18 +20,21 @@ declare global {
|
|||
/** A safe error with an optional expandable technical detail. */
|
||||
export class BackendError extends Error {
|
||||
readonly details?: string | null;
|
||||
/** Machine-readable kind, e.g. `vault_locked`. */
|
||||
readonly code?: string | null;
|
||||
|
||||
constructor(message: string, details?: string | null) {
|
||||
constructor(message: string, details?: string | null, code?: string | null) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
this.details = details;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const envelope = (await window.backend.request(method, params)) as BackendResponse<T>;
|
||||
if (envelope.status === 'error') {
|
||||
throw new BackendError(envelope.message, envelope.details);
|
||||
throw new BackendError(envelope.message, envelope.details, envelope.code);
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
|
@ -59,5 +63,6 @@ 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 }),
|
||||
copyText: (text: string) => window.backend.copyText(text),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -56,6 +56,21 @@ export interface AppState {
|
|||
settings: Settings;
|
||||
}
|
||||
|
||||
/** A secret key revealed after the vault is unlocked. */
|
||||
export interface RevealedKey {
|
||||
/** 64-character lowercase hex form. */
|
||||
hex: string;
|
||||
/** Bech32 `nsec1...` form, what most wallets and clients import. */
|
||||
nsec: string;
|
||||
}
|
||||
|
||||
/** Wire envelope returned by the Rust backend. */
|
||||
export type BackendResponse<T> =
|
||||
{ status: 'ok'; data: T } | { status: 'error'; message: string; details?: string | null };
|
||||
| { status: 'ok'; data: T }
|
||||
| {
|
||||
status: 'error';
|
||||
/** Machine-readable kind, e.g. `vault_locked`. */
|
||||
code?: string;
|
||||
message: string;
|
||||
details?: string | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CopyButton } from '../components/CopyButton';
|
|||
import { EmptyState } from '../components/EmptyState';
|
||||
import { ErrorText } from '../components/ErrorText';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||
import { formatDate, shortenNpub } from '../lib/format';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
const [selecting, setSelecting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
|
||||
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
||||
|
||||
const profiles = state?.profiles ?? [];
|
||||
const shorten = state?.settings.shorten_npub ?? true;
|
||||
|
|
@ -96,6 +98,14 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
|
||||
<div className="profile-card-actions">
|
||||
<CopyButton text={profile.npub} label="public key" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRevealTarget({ label: profile.label, npub: profile.npub })}
|
||||
>
|
||||
<Icon name="key" size={16} />
|
||||
Secret key
|
||||
</Button>
|
||||
{profile.is_active ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
Selected
|
||||
|
|
@ -114,6 +124,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ShowSecretKeyModal
|
||||
open={revealTarget !== null}
|
||||
profile={revealTarget}
|
||||
onClose={() => setRevealTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
ProfileSummary,
|
||||
PublishReport,
|
||||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
Theme,
|
||||
} from '../lib/types';
|
||||
|
|
@ -47,6 +48,7 @@ interface AppContextValue {
|
|||
unlockVault: (password: string) => Promise<AppState>;
|
||||
lockVault: () => Promise<AppState>;
|
||||
removeVaultPassword: (password: string) => Promise<AppState>;
|
||||
revealSecretKey: (npub: string) => Promise<RevealedKey>;
|
||||
copyText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +158,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
(password: string) => applyState(api.removeVaultPassword(password)),
|
||||
[applyState],
|
||||
);
|
||||
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
|
||||
|
||||
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||
|
||||
|
|
@ -183,6 +186,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
unlockVault,
|
||||
lockVault,
|
||||
removeVaultPassword,
|
||||
revealSecretKey,
|
||||
copyText,
|
||||
}),
|
||||
[
|
||||
|
|
@ -206,6 +210,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
unlockVault,
|
||||
lockVault,
|
||||
removeVaultPassword,
|
||||
revealSecretKey,
|
||||
copyText,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -44,8 +44,9 @@ describe('App', () => {
|
|||
await screen.findByRole('heading', { name: 'Profiles' });
|
||||
|
||||
const body = document.body.textContent ?? '';
|
||||
expect(body).not.toMatch(/secret/i);
|
||||
expect(body).not.toMatch(/nsec1/i);
|
||||
expect(body).not.toMatch(/\b[0-9a-f]{64}\b/i);
|
||||
expect(body).not.toMatch(/secret_key/i);
|
||||
});
|
||||
|
||||
it('applies and persists the selected dark theme', async () => {
|
||||
|
|
|
|||
|
|
@ -16,9 +16,11 @@ describe('ProfilesScreen', () => {
|
|||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Created /i).length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// No actual key material is shown until a user asks to reveal it.
|
||||
const body = document.body.textContent ?? '';
|
||||
expect(body).not.toMatch(/secret/i);
|
||||
expect(body).not.toMatch(/nsec1/i);
|
||||
expect(body).not.toMatch(/\b[0-9a-f]{64}\b/i);
|
||||
expect(body).not.toMatch(/secret_key/i);
|
||||
});
|
||||
|
||||
it('selects a profile when the Select button is clicked', async () => {
|
||||
|
|
|
|||
87
frontend/src/test/ShowSecretKey.test.tsx
Normal file
87
frontend/src/test/ShowSecretKey.test.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -90,6 +90,7 @@ export interface ApiMock {
|
|||
unlockVault: ReturnType<typeof vi.fn>;
|
||||
lockVault: ReturnType<typeof vi.fn>;
|
||||
removeVaultPassword: ReturnType<typeof vi.fn>;
|
||||
revealSecretKey: ReturnType<typeof vi.fn>;
|
||||
copyText: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
/** Current state object backing init/getState. */
|
||||
|
|
@ -176,6 +177,10 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
|||
encrypted_storage: false,
|
||||
vault_locked: false,
|
||||
})),
|
||||
revealSecretKey: vi.fn(async (npub: string) => ({
|
||||
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
|
||||
nsec: `nsec1${npub.slice(5)}`,
|
||||
})),
|
||||
copyText: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export interface FakeBackend {
|
|||
/** Relay URLs that fail connection tests. */
|
||||
relayErrors: Set<string>;
|
||||
/** Per-method canned error override. */
|
||||
nextErrors: Record<string, { message: string; details?: string }>;
|
||||
nextErrors: Record<string, { message: string; details?: string; code?: string }>;
|
||||
}
|
||||
|
||||
export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||
|
|
@ -38,8 +38,8 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
api: {
|
||||
async request(method, params = {}) {
|
||||
if (backend.nextErrors[method]) {
|
||||
const { message, details } = backend.nextErrors[method];
|
||||
return { status: 'error', message, details };
|
||||
const { message, details, code } = backend.nextErrors[method];
|
||||
return { status: 'error', message, details, code };
|
||||
}
|
||||
try {
|
||||
const data = await dispatch(method, params);
|
||||
|
|
@ -52,6 +52,10 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
error instanceof Error && 'details' in error
|
||||
? (error as { details?: string }).details
|
||||
: undefined,
|
||||
code:
|
||||
error instanceof Error && 'code' in error
|
||||
? (error as { code?: string }).code
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
@ -205,6 +209,21 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
return next;
|
||||
}
|
||||
|
||||
case 'reveal_secret_key': {
|
||||
if (state.encrypted_storage && state.vault_locked) {
|
||||
throw Object.assign(
|
||||
new Error('Your vault is locked. Enter your password to unlock it.'),
|
||||
{ code: 'vault_locked' },
|
||||
);
|
||||
}
|
||||
const npub = String(params.npub);
|
||||
if (!state.profiles.some((p) => p.npub === npub)) {
|
||||
throw new Error('That profile is not stored on this computer.');
|
||||
}
|
||||
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||
return { hex, nsec: `nsec1${npub.slice(5)}` };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue