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:
Avi 2026-08-04 12:09:30 -05:00
commit 8eb6685281
17 changed files with 594 additions and 54 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

@ -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,
],
);

View file

@ -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 () => {

View file

@ -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 () => {

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

View file

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

View file

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