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

@ -1,4 +1,4 @@
# Checkpoint — Password-encrypted vault (2026-08-03)
# Checkpoint — Secret-key reveal after unlock (2026-08-04)
A stopping point you can return to if this session is closed. Everything below was
verified green at the moment this file was written.
@ -6,73 +6,87 @@ verified green at the moment this file was written.
## Where things are
- Project: `/home/avi/Projects/skills/nost-feed-manager`
- Git repo: `master` @ `7e3bac3` ("Add Nostr Feed Manager: Rust backend with Electron + React GUI")
- The encryption work is **uncommitted** — all changes are in the working tree.
- Also relevant: `/home/avi/Projects/nostr_backend/nostr_backendmanager.md` (old-CLI docs, untouched),
and `/home/avi/Projects/nostr_backend/vlog-website/` (separate, untouched).
- Git repo: `master`
- The reveal feature is **uncommitted** — all changes are in the working tree.
- `/home/avi/Projects/nostr_backend/nostr_backendmanager.md` (old-CLI docs) has been updated
to match reality; it lives outside this repo so it is not part of the commit.
- `/home/avi/Projects/nostr_backend/vlog-website/` (separate, untouched).
## What was completed: password-encrypted vault
## What was completed in this session: reveal a secret key after unlock
- Secret keys are now encrypted at rest with **AES-256-GCM** under a key derived via **Argon2id**
from the user's password. Vaults stay plaintext until a password is set (opt-in).
- Encryption only covers the secret keys; labels/npubs stay readable so profiles can be browsed
while the vault is locked. The derived key lives only in memory for the session.
Building on the existing password-encrypted vault (AES-256-GCM + Argon2id), owners can now view a
profile's secret key after entering the vault password:
## Files changed (19 modified, 4 new)
- Backend: `profiles::reveal_secret_key` returns the key in both hex and `nsec1...` forms, gated on
an unlocked vault (`VaultLocked` when encrypted + locked). New CLI command `show-secret <npub>`,
which prompts for the password when locked (via `NFM_PASSWORD` env or hidden prompt).
- IPC: new `reveal_secret_key { npub }` method. Error replies now carry a machine-readable `code`
field (ErrorKind serialised as snake_case, e.g. `vault_locked`), so the GUI can branch without
string-matching on user-facing messages.
- GUI: a "Secret key" button on every profile card opens `ShowSecretKeyModal`, which shows hex +
nsec with copy buttons and a warning. When the vault is locked the modal asks for the password
inline, unlocks, then reveals.
- Key is only ever fetched after unlock; never stored in state before reveal.
## Files changed (16 modified, 2 new)
Modified:
- `Cargo.toml`, `Cargo.lock` — added `argon2`, `aes-gcm`, `base64`, `getrandom`, `rpassword`
- `README.md` — documented the new feature + CLI commands
- `src/lib.rs`, `src/app.rs`, `src/vault.rs`, `src/profiles.rs`, `src/publish.rs`,
`src/ipc.rs`, `src/main.rs`, `src/errors.rs`
- `frontend/src/App.tsx`, `frontend/src/lib/api.ts`, `frontend/src/lib/types.ts`,
`frontend/src/state/AppProvider.tsx`, `frontend/src/screens/SettingsScreen.tsx`,
`frontend/src/styles.css`, `frontend/src/test/apiMock.ts`, `frontend/src/test/fakeBackend.ts`
- `README.md` — documented the reveal feature + `show-secret`
- `src/errors.rs``ErrorKind` now serialises as snake_case for the IPC error code
- `src/profiles.rs``RevealedKey`, `reveal_secret_key`, `profile_label` + tests
- `src/ipc.rs``RevealSecretKey` request, `code` on error replies
- `src/main.rs``show-secret` CLI command
- `frontend/src/components/Icon.tsx` — new `key` icon
- `frontend/src/lib/api.ts``revealSecretKey`, `BackendError.code`
- `frontend/src/lib/types.ts``RevealedKey`, error `code` in `BackendResponse`
- `frontend/src/screens/ProfilesScreen.tsx` — "Secret key" button per profile
- `frontend/src/state/AppProvider.tsx``revealSecretKey` in context
- `frontend/src/test/{App,ProfilesScreen,apiMock,fakeBackend}` — updated for new UI + error codes
New:
- `src/crypto.rs` — Argon2id KDF + AES-256-GCM encrypt/decrypt + password verifier
- `frontend/src/components/UnlockModal.tsx`
- `frontend/src/components/VaultPasswordModal.tsx`
- `frontend/src/test/VaultPassword.test.tsx`
- `frontend/src/components/ShowSecretKeyModal.tsx`
- `frontend/src/test/ShowSecretKey.test.tsx`
Also updated (outside repo): `/home/avi/Projects/nostr_backend/nostr_backendmanager.md`.
## New backend API (IPC + CLI)
IPC methods: `set_vault_password { current_password?, new_password }`,
`unlock_vault { password }`, `lock_vault`, `remove_vault_password { password }`.
IPC: `reveal_secret_key { npub }``{ hex, nsec }`. Error replies now include
`"code": "vault_locked"` (etc.) alongside `message`/`details`.
CLI: `set-password`, `remove-password`, `unlock`; `create`/`publish` auto-prompt when locked.
Passwords come from `NFM_PASSWORD` env var or a hidden terminal prompt — never argv.
Min password length: 8 chars.
CLI: `show-secret <npub>` prints hex + nsec after unlocking. `create`, `publish`, and
`show-secret` all auto-prompt for the vault password when it is encrypted.
## How it was verified (all green)
```
cargo test # 50 passed
cargo test # 55 passed
cargo clippy --all-targets # clean
cargo fmt --check # clean
cargo build --release # builds
npm run typecheck # clean (frontend/)
npm run lint # clean (pre-existing module warning only)
npm run format:check # clean
npm test # 52 passed (10 files)
npm test # 56 passed (11 files)
```
Plus a manual end-to-end CLI smoke test: create → set-password → vault file shows only base64
ciphertext → wrong password rejected → correct password creates encrypted profile. Temp data
was cleaned up (`/tmp/nfm-e2e` removed).
Plus a manual IPC end-to-end smoke test: reveal on plaintext → OK; set-password → lock →
reveal returns `code: "vault_locked"`; unlock → reveal returns the same hex. Temp data cleaned up.
## How to resume
1. Open the repo: `cd /home/avi/Projects/skills/nost-feed-manager`
2. Inspect the diff: `git diff` (work is still uncommitted)
3. To try it: `cargo build --release` then
`XDG_DATA_HOME=/tmp/nfm-smoke ./target/release/nostr-manager-backend create "Alice"`,
`NFM_PASSWORD=... ./target/release/nostr-manager-backend set-password`
3. To try it:
- CLI: `cargo build --release`, then
`XDG_DATA_HOME=/tmp/nfm-smoke ./target/release/nostr-manager-backend create "Alice"` and
`./target/release/nostr-manager-backend show-secret <npub>`
- GUI: `cd frontend && npm start`, Profiles → "Secret key" on a card
4. Re-run verification with the commands above.
## Outstanding / next steps (if you continue)
- Decide whether to **commit** the work (nothing is committed yet).
- `nostr_backendmanager.md` still lists "password-based vault encryption" as a future item and was
left untouched — it may deserve updating to match reality.
- No lock-screen gate: browsing works while locked; only create/publish require unlocking (intended).
- Decide whether to **commit** the reveal work (nothing is committed yet).
- Consider a small UI hint that profiles with an unencrypted vault can be revealed with no prompt.
- Relay defaults are currently `relay.damus.io` (503 upstream) and `relay.nostr.band` (timeout);
user's local settings already point at `nos.lol` + `relay.primal.net` instead.

View file

@ -17,6 +17,8 @@ runs in the Rust backend, which the GUI talks to over a JSON-lines IPC channel.
- Password-protected vault: secret keys are encrypted at rest with AES-256-GCM under an
Argon2id-derived key. Without a password set, the vault is stored in plaintext (readable only by
your user account) and this is disclosed in the app
- Reveal a profile's secret key (hex + `nsec1...`) from the app or the CLI — only after the vault
password is entered, so keys stay encrypted at rest
## Architecture
@ -92,13 +94,15 @@ cargo run --release -- settings get|set theme|confirm|shorten
cargo run --release -- set-password # encrypt the vault (or change its password)
cargo run --release -- remove-password # remove vault encryption
cargo run --release -- unlock # verify the vault password for this process
cargo run --release -- show-secret <npub> # reveal a profile's secret key (hex + nsec)
cargo run --release -- info # show storage locations and version
cargo run --release -- serve # JSON-lines IPC server (used by the GUI)
```
Passwords are read from the `NFM_PASSWORD` environment variable when set, otherwise you are
prompted interactively. They are never accepted as command-line arguments. `create` and `publish`
prompt for the vault password automatically when the vault is encrypted.
prompt for the vault password automatically when the vault is encrypted, and so does `show-secret`
the key is only displayed after the vault password is entered.
## Storage and migration

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

View file

@ -1,7 +1,12 @@
use serde::Serialize;
use std::fmt;
/// Categorises errors so callers can tailor behaviour without string matching.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
///
/// Serialised as a snake_case string (e.g. `vault_locked`) so the GUI can make
/// machine-readable decisions without parsing user-facing messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorKind {
/// Filesystem read/write problems.
Io,

View file

@ -75,6 +75,11 @@ pub enum Request {
RemoveVaultPassword {
password: String,
},
/// Reveal a profile's secret key (hex + nsec). Requires an unlocked vault
/// when the vault is password-protected.
RevealSecretKey {
npub: String,
},
}
/// A reply envelope carrying either data or a safe user-facing error.
@ -85,6 +90,9 @@ pub enum Reply<T> {
data: T,
},
Error {
/// Machine-readable kind, e.g. `vault_locked`. Useful for the GUI to
/// branch on without matching on the human message.
code: String,
message: String,
details: Option<String>,
},
@ -124,6 +132,7 @@ pub async fn serve() -> Result<(), AppError> {
Ok(envelope) => envelope,
Err(e) => {
let reply = Reply::<serde_json::Value>::Error {
code: "bad_request".to_string(),
message: "The request could not be understood.".to_string(),
details: Some(e.to_string()),
};
@ -169,12 +178,25 @@ async fn handle(app: &mut App, request: Request) -> Reply<serde_json::Value> {
match result {
Ok(value) => Reply::Ok { data: value },
Err(err) => Reply::Error {
code: error_code(&err),
message: err.message().to_string(),
details: err.details().map(str::to_string),
},
}
}
/// A stable machine-readable string for an error, sent with every error reply.
fn error_code(err: &AppError) -> String {
serde_json::to_value(err.kind())
.and_then(|value| {
value
.as_str()
.map(str::to_string)
.ok_or_else(|| serde_json::Error::io(std::io::Error::other("not a string")))
})
.unwrap_or_else(|_| "error".to_string())
}
async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
match request {
Request::Init | Request::GetState => Ok(json!(app.state_view())),
@ -255,6 +277,11 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
Ok(json!(app.state_view()))
}
Request::RevealSecretKey { npub } => {
let revealed = profiles::reveal_secret_key(&app.vault, &npub, app.vault_key())?;
Ok(json!(revealed))
}
Request::SettingsUpdate {
theme,
confirm_before_publish,

View file

@ -30,6 +30,7 @@ Commands:
set-password Encrypt the vault with a password (or change it)
remove-password Remove the vault password (keys back to plaintext)
unlock Verify the vault password for this process
show-secret <npub> Show a profile's secret key (hex + nsec) after unlocking
info Show storage locations and version
serve Run the JSON-lines IPC server
@ -62,6 +63,7 @@ async fn main() -> ExitCode {
"set-password" => cli_set_password(),
"remove-password" => cli_remove_password(),
"unlock" => cli_unlock(),
"show-secret" => cli_show_secret(&args),
"info" => cli_info(),
"help" | "--help" | "-h" => {
println!("{USAGE}");
@ -319,6 +321,23 @@ fn cli_unlock() -> Result<String, AppError> {
Ok("Vault unlocked.".to_string())
}
/// Reveal a profile's secret key. When the vault is encrypted, the password is
/// required before the key is shown.
fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
let npub = args
.get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend show-secret <npub>"))?;
let app = load_app_with_unlock()?;
let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?;
let label = profiles::profile_label(&app.vault, npub).unwrap_or(npub);
Ok(format!(
"Secret key for \"{label}\" ({npub}):\n hex: {}\n nsec: {}",
revealed.hex, revealed.nsec
))
}
fn cli_info() -> Result<String, AppError> {
let app = App::load()?;
let mut lines = vec![

View file

@ -16,6 +16,17 @@ pub struct ProfileSummary {
pub is_active: bool,
}
/// A secret key revealed after the vault is unlocked, in both the raw hex and
/// `nsec1...` bech32 forms. This is the only shape of secret material that is
/// ever returned to callers.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct RevealedKey {
/// 64-character lowercase hex form.
pub hex: String,
/// Bech32 `nsec1...` form, what most wallets and clients import.
pub nsec: String,
}
/// Create a new profile, generating fresh keys. The new profile becomes the
/// active one when nothing is currently selected.
///
@ -114,6 +125,15 @@ pub fn find_secret_key<'a>(vault: &'a Vault, npub: &str) -> Result<&'a str, AppE
.ok_or_else(|| AppError::profile_not_found(npub))
}
/// The stored label for a profile, when one exists.
pub fn profile_label<'a>(vault: &'a Vault, npub: &str) -> Option<&'a str> {
vault
.profiles
.iter()
.find(|p| p.public_key == npub)
.map(|p| p.label.as_str())
}
/// Look up the stored secret key of the active profile.
pub fn active_secret_key(vault: &Vault) -> Result<&str, AppError> {
let npub = vault
@ -153,6 +173,23 @@ pub fn resolve_active_secret_key(
resolve_secret_key(vault, npub, key)
}
/// Resolve a profile's secret key and return both its hex and `nsec` forms.
///
/// The vault must be unlocked when it is password-protected, otherwise the
/// request is rejected as locked. This is the only call that returns secret
/// key material to a caller for display.
pub fn reveal_secret_key(
vault: &Vault,
npub: &str,
key: Option<&VaultKey>,
) -> Result<RevealedKey, AppError> {
let hex = resolve_secret_key(vault, npub, key)?;
let nsec = parse_secret_key(&hex)?
.to_bech32()
.map_err(|e| AppError::internal(format!("Could not encode the secret key: {e}")))?;
Ok(RevealedKey { hex, nsec })
}
/// Parse and validate a stored hex-encoded secret key.
pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
let bytes = ::hex::decode(hex_str)
@ -163,7 +200,7 @@ pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
#[cfg(test)]
mod tests {
use super::*;
use crate::vault::Vault;
use crate::vault::{KdfParams, Vault, VaultCrypto};
fn populated_vault() -> Vault {
let mut vault = Vault::empty();
@ -273,4 +310,86 @@ mod tests {
let key = parse_secret_key("01".repeat(32).as_str()).expect("valid key must parse");
assert_eq!(key.to_secret_bytes().len(), 32);
}
#[test]
fn reveal_returns_hex_and_nsec_for_plaintext_vault() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
let revealed = reveal_secret_key(&vault, &summary.npub, None).expect("must reveal");
assert_eq!(revealed.hex.len(), 64, "hex secret is 32 bytes");
assert!(revealed.nsec.starts_with("nsec1"));
let parsed = SecretKey::from_bech32(&revealed.nsec).expect("nsec must be valid");
assert_eq!(
::hex::encode(parsed.to_secret_bytes()),
revealed.hex,
"nsec must encode the same key as hex"
);
}
#[test]
fn reveal_decrypts_an_encrypted_profile_when_unlocked() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
let plaintext = vault.profiles[0].secret_key.clone();
let salt = crate::crypto::generate_salt().unwrap();
let key = crate::crypto::derive_key(
"open sesame",
&salt,
crate::crypto::KDF_M_COST,
crate::crypto::KDF_T_COST,
crate::crypto::KDF_P_COST,
)
.unwrap();
vault.profiles[0].secret_key = crate::crypto::encrypt_secret(&key, &plaintext).unwrap();
vault.crypto = Some(VaultCrypto {
kdf: KdfParams {
algorithm: "argon2id".to_string(),
salt: "not-read-during-reveal".to_string(),
m_cost: 1,
t_cost: 1,
p_cost: 1,
},
verifier: "not-checked-here".to_string(),
});
let revealed = reveal_secret_key(&vault, &summary.npub, Some(&key)).expect("must reveal");
assert_eq!(revealed.hex, plaintext);
assert!(revealed.nsec.starts_with("nsec1"));
}
#[test]
fn reveal_rejects_a_locked_vault() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
vault.crypto = Some(VaultCrypto {
kdf: KdfParams {
algorithm: "argon2id".to_string(),
salt: "x".to_string(),
m_cost: 1,
t_cost: 1,
p_cost: 1,
},
verifier: "x".to_string(),
});
let err = reveal_secret_key(&vault, &summary.npub, None).expect_err("must be locked");
assert_eq!(err.kind(), crate::errors::ErrorKind::VaultLocked);
}
#[test]
fn reveal_missing_profile_errors() {
let vault = Vault::empty();
let err = reveal_secret_key(&vault, "npub1ghost", None).expect_err("must error");
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
}
#[test]
fn profile_label_lookup() {
let vault = populated_vault();
assert_eq!(profile_label(&vault, "npub1alice"), Some("Alice"));
assert_eq!(profile_label(&vault, "npub1ghost"), None);
}
}