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 A stopping point you can return to if this session is closed. Everything below was
verified green at the moment this file was written. 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 ## Where things are
- Project: `/home/avi/Projects/skills/nost-feed-manager` - Project: `/home/avi/Projects/skills/nost-feed-manager`
- Git repo: `master` @ `7e3bac3` ("Add Nostr Feed Manager: Rust backend with Electron + React GUI") - Git repo: `master`
- The encryption work is **uncommitted** — all changes are in the working tree. - The reveal feature is **uncommitted** — all changes are in the working tree.
- Also relevant: `/home/avi/Projects/nostr_backend/nostr_backendmanager.md` (old-CLI docs, untouched), - `/home/avi/Projects/nostr_backend/nostr_backendmanager.md` (old-CLI docs) has been updated
and `/home/avi/Projects/nostr_backend/vlog-website/` (separate, untouched). 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** Building on the existing password-encrypted vault (AES-256-GCM + Argon2id), owners can now view a
from the user's password. Vaults stay plaintext until a password is set (opt-in). profile's secret key after entering the vault password:
- 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.
## 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: Modified:
- `Cargo.toml`, `Cargo.lock` — added `argon2`, `aes-gcm`, `base64`, `getrandom`, `rpassword` - `README.md` — documented the reveal feature + `show-secret`
- `README.md` — documented the new feature + CLI commands - `src/errors.rs``ErrorKind` now serialises as snake_case for the IPC error code
- `src/lib.rs`, `src/app.rs`, `src/vault.rs`, `src/profiles.rs`, `src/publish.rs`, - `src/profiles.rs``RevealedKey`, `reveal_secret_key`, `profile_label` + tests
`src/ipc.rs`, `src/main.rs`, `src/errors.rs` - `src/ipc.rs``RevealSecretKey` request, `code` on error replies
- `frontend/src/App.tsx`, `frontend/src/lib/api.ts`, `frontend/src/lib/types.ts`, - `src/main.rs``show-secret` CLI command
`frontend/src/state/AppProvider.tsx`, `frontend/src/screens/SettingsScreen.tsx`, - `frontend/src/components/Icon.tsx` — new `key` icon
`frontend/src/styles.css`, `frontend/src/test/apiMock.ts`, `frontend/src/test/fakeBackend.ts` - `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: New:
- `src/crypto.rs` — Argon2id KDF + AES-256-GCM encrypt/decrypt + password verifier - `frontend/src/components/ShowSecretKeyModal.tsx`
- `frontend/src/components/UnlockModal.tsx` - `frontend/src/test/ShowSecretKey.test.tsx`
- `frontend/src/components/VaultPasswordModal.tsx`
- `frontend/src/test/VaultPassword.test.tsx` Also updated (outside repo): `/home/avi/Projects/nostr_backend/nostr_backendmanager.md`.
## New backend API (IPC + CLI) ## New backend API (IPC + CLI)
IPC methods: `set_vault_password { current_password?, new_password }`, IPC: `reveal_secret_key { npub }``{ hex, nsec }`. Error replies now include
`unlock_vault { password }`, `lock_vault`, `remove_vault_password { password }`. `"code": "vault_locked"` (etc.) alongside `message`/`details`.
CLI: `set-password`, `remove-password`, `unlock`; `create`/`publish` auto-prompt when locked. CLI: `show-secret <npub>` prints hex + nsec after unlocking. `create`, `publish`, and
Passwords come from `NFM_PASSWORD` env var or a hidden terminal prompt — never argv. `show-secret` all auto-prompt for the vault password when it is encrypted.
Min password length: 8 chars.
## How it was verified (all green) ## How it was verified (all green)
``` ```
cargo test # 50 passed cargo test # 55 passed
cargo clippy --all-targets # clean cargo clippy --all-targets # clean
cargo fmt --check # clean cargo fmt --check # clean
cargo build --release # builds cargo build --release # builds
npm run typecheck # clean (frontend/) npm run typecheck # clean (frontend/)
npm run lint # clean (pre-existing module warning only) npm run lint # clean (pre-existing module warning only)
npm run format:check # clean 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 Plus a manual IPC end-to-end smoke test: reveal on plaintext → OK; set-password → lock →
ciphertext → wrong password rejected → correct password creates encrypted profile. Temp data reveal returns `code: "vault_locked"`; unlock → reveal returns the same hex. Temp data cleaned up.
was cleaned up (`/tmp/nfm-e2e` removed).
## How to resume ## How to resume
1. Open the repo: `cd /home/avi/Projects/skills/nost-feed-manager` 1. Open the repo: `cd /home/avi/Projects/skills/nost-feed-manager`
2. Inspect the diff: `git diff` (work is still uncommitted) 2. Inspect the diff: `git diff` (work is still uncommitted)
3. To try it: `cargo build --release` then 3. To try it:
`XDG_DATA_HOME=/tmp/nfm-smoke ./target/release/nostr-manager-backend create "Alice"`, - CLI: `cargo build --release`, then
`NFM_PASSWORD=... ./target/release/nostr-manager-backend set-password` `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. 4. Re-run verification with the commands above.
## Outstanding / next steps (if you continue) ## Outstanding / next steps (if you continue)
- Decide whether to **commit** the work (nothing is committed yet). - Decide whether to **commit** the reveal work (nothing is committed yet).
- `nostr_backendmanager.md` still lists "password-based vault encryption" as a future item and was - Consider a small UI hint that profiles with an unencrypted vault can be revealed with no prompt.
left untouched — it may deserve updating to match reality. - Relay defaults are currently `relay.damus.io` (503 upstream) and `relay.nostr.band` (timeout);
- No lock-screen gate: browsing works while locked; only create/publish require unlocking (intended). 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 - 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 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 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 ## 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 -- set-password # encrypt the vault (or change its password)
cargo run --release -- remove-password # remove vault encryption cargo run --release -- remove-password # remove vault encryption
cargo run --release -- unlock # verify the vault password for this process 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 -- info # show storage locations and version
cargo run --release -- serve # JSON-lines IPC server (used by the GUI) 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 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` 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 ## Storage and migration

View file

@ -14,7 +14,8 @@ export type IconName =
| 'info' | 'info'
| 'shield' | 'shield'
| 'publish' | 'publish'
| 'external'; | 'external'
| 'key';
const PATHS: Record<IconName, ReactNode> = { const PATHS: Record<IconName, ReactNode> = {
home: ( 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" /> <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 { 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, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
RevealedKey,
Settings, Settings,
} from './types'; } from './types';
@ -19,18 +20,21 @@ declare global {
/** A safe error with an optional expandable technical detail. */ /** A safe error with an optional expandable technical detail. */
export class BackendError extends Error { export class BackendError extends Error {
readonly details?: string | null; 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); super(message);
this.name = 'BackendError'; this.name = 'BackendError';
this.details = details; this.details = details;
this.code = code;
} }
} }
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> { async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
const envelope = (await window.backend.request(method, params)) as BackendResponse<T>; const envelope = (await window.backend.request(method, params)) as BackendResponse<T>;
if (envelope.status === 'error') { if (envelope.status === 'error') {
throw new BackendError(envelope.message, envelope.details); throw new BackendError(envelope.message, envelope.details, envelope.code);
} }
return envelope.data; return envelope.data;
} }
@ -59,5 +63,6 @@ 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 }),
copyText: (text: string) => window.backend.copyText(text), copyText: (text: string) => window.backend.copyText(text),
}; };

View file

@ -56,6 +56,21 @@ export interface AppState {
settings: Settings; 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. */ /** Wire envelope returned by the Rust backend. */
export type BackendResponse<T> = 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 { EmptyState } from '../components/EmptyState';
import { ErrorText } from '../components/ErrorText'; import { ErrorText } from '../components/ErrorText';
import { Icon } from '../components/Icon'; import { Icon } from '../components/Icon';
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
import { formatDate, shortenNpub } from '../lib/format'; import { formatDate, shortenNpub } from '../lib/format';
import { useApp } from '../state/AppProvider'; import { useApp } from '../state/AppProvider';
@ -18,6 +19,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
const [selecting, setSelecting] = useState<string | null>(null); const [selecting, setSelecting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`); 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 profiles = state?.profiles ?? [];
const shorten = state?.settings.shorten_npub ?? true; const shorten = state?.settings.shorten_npub ?? true;
@ -96,6 +98,14 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
<div className="profile-card-actions"> <div className="profile-card-actions">
<CopyButton text={profile.npub} label="public key" /> <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 ? ( {profile.is_active ? (
<Button variant="secondary" size="sm" disabled> <Button variant="secondary" size="sm" disabled>
Selected Selected
@ -114,6 +124,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
</article> </article>
))} ))}
</div> </div>
<ShowSecretKeyModal
open={revealTarget !== null}
profile={revealTarget}
onClose={() => setRevealTarget(null)}
/>
</div> </div>
</div> </div>
); );

View file

@ -13,6 +13,7 @@ import type {
ProfileSummary, ProfileSummary,
PublishReport, PublishReport,
RelayTestResult, RelayTestResult,
RevealedKey,
Settings, Settings,
Theme, Theme,
} from '../lib/types'; } from '../lib/types';
@ -47,6 +48,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>;
copyText: (text: string) => Promise<void>; copyText: (text: string) => Promise<void>;
} }
@ -156,6 +158,7 @@ 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 copyText = useCallback((text: string) => api.copyText(text), []); const copyText = useCallback((text: string) => api.copyText(text), []);
@ -183,6 +186,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
unlockVault, unlockVault,
lockVault, lockVault,
removeVaultPassword, removeVaultPassword,
revealSecretKey,
copyText, copyText,
}), }),
[ [
@ -206,6 +210,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
unlockVault, unlockVault,
lockVault, lockVault,
removeVaultPassword, removeVaultPassword,
revealSecretKey,
copyText, copyText,
], ],
); );

View file

@ -44,8 +44,9 @@ describe('App', () => {
await screen.findByRole('heading', { name: 'Profiles' }); await screen.findByRole('heading', { name: 'Profiles' });
const body = document.body.textContent ?? ''; const body = document.body.textContent ?? '';
expect(body).not.toMatch(/secret/i);
expect(body).not.toMatch(/nsec1/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 () => { it('applies and persists the selected dark theme', async () => {

View file

@ -16,9 +16,11 @@ describe('ProfilesScreen', () => {
expect(screen.getByText('Active')).toBeInTheDocument(); expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getAllByText(/Created /i).length).toBeGreaterThanOrEqual(1); 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 ?? ''; const body = document.body.textContent ?? '';
expect(body).not.toMatch(/secret/i);
expect(body).not.toMatch(/nsec1/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 () => { 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>; 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>;
copyText: ReturnType<typeof vi.fn>; copyText: ReturnType<typeof vi.fn>;
}; };
/** Current state object backing init/getState. */ /** Current state object backing init/getState. */
@ -176,6 +177,10 @@ 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) => ({
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
nsec: `nsec1${npub.slice(5)}`,
})),
copyText: vi.fn(async () => undefined), copyText: vi.fn(async () => undefined),
}; };

View file

@ -27,7 +27,7 @@ export interface FakeBackend {
/** Relay URLs that fail connection tests. */ /** Relay URLs that fail connection tests. */
relayErrors: Set<string>; relayErrors: Set<string>;
/** Per-method canned error override. */ /** 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 { export function createFakeBackend(initial?: AppState): FakeBackend {
@ -38,8 +38,8 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
api: { api: {
async request(method, params = {}) { async request(method, params = {}) {
if (backend.nextErrors[method]) { if (backend.nextErrors[method]) {
const { message, details } = backend.nextErrors[method]; const { message, details, code } = backend.nextErrors[method];
return { status: 'error', message, details }; return { status: 'error', message, details, code };
} }
try { try {
const data = await dispatch(method, params); const data = await dispatch(method, params);
@ -52,6 +52,10 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
error instanceof Error && 'details' in error error instanceof Error && 'details' in error
? (error as { details?: string }).details ? (error as { details?: string }).details
: undefined, : 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; 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: default:
throw new Error(`Unknown method: ${method}`); throw new Error(`Unknown method: ${method}`);
} }

View file

@ -1,7 +1,12 @@
use serde::Serialize;
use std::fmt; use std::fmt;
/// Categorises errors so callers can tailor behaviour without string matching. /// 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 { pub enum ErrorKind {
/// Filesystem read/write problems. /// Filesystem read/write problems.
Io, Io,

View file

@ -75,6 +75,11 @@ pub enum Request {
RemoveVaultPassword { RemoveVaultPassword {
password: String, 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. /// A reply envelope carrying either data or a safe user-facing error.
@ -85,6 +90,9 @@ pub enum Reply<T> {
data: T, data: T,
}, },
Error { 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, message: String,
details: Option<String>, details: Option<String>,
}, },
@ -124,6 +132,7 @@ pub async fn serve() -> Result<(), AppError> {
Ok(envelope) => envelope, Ok(envelope) => envelope,
Err(e) => { Err(e) => {
let reply = Reply::<serde_json::Value>::Error { let reply = Reply::<serde_json::Value>::Error {
code: "bad_request".to_string(),
message: "The request could not be understood.".to_string(), message: "The request could not be understood.".to_string(),
details: Some(e.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 { match result {
Ok(value) => Reply::Ok { data: value }, Ok(value) => Reply::Ok { data: value },
Err(err) => Reply::Error { Err(err) => Reply::Error {
code: error_code(&err),
message: err.message().to_string(), message: err.message().to_string(),
details: err.details().map(str::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> { async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
match request { match request {
Request::Init | Request::GetState => Ok(json!(app.state_view())), 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())) 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 { Request::SettingsUpdate {
theme, theme,
confirm_before_publish, confirm_before_publish,

View file

@ -30,6 +30,7 @@ Commands:
set-password Encrypt the vault with a password (or change it) set-password Encrypt the vault with a password (or change it)
remove-password Remove the vault password (keys back to plaintext) remove-password Remove the vault password (keys back to plaintext)
unlock Verify the vault password for this process 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 info Show storage locations and version
serve Run the JSON-lines IPC server serve Run the JSON-lines IPC server
@ -62,6 +63,7 @@ async fn main() -> ExitCode {
"set-password" => cli_set_password(), "set-password" => cli_set_password(),
"remove-password" => cli_remove_password(), "remove-password" => cli_remove_password(),
"unlock" => cli_unlock(), "unlock" => cli_unlock(),
"show-secret" => cli_show_secret(&args),
"info" => cli_info(), "info" => cli_info(),
"help" | "--help" | "-h" => { "help" | "--help" | "-h" => {
println!("{USAGE}"); println!("{USAGE}");
@ -319,6 +321,23 @@ fn cli_unlock() -> Result<String, AppError> {
Ok("Vault unlocked.".to_string()) 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> { fn cli_info() -> Result<String, AppError> {
let app = App::load()?; let app = App::load()?;
let mut lines = vec![ let mut lines = vec![

View file

@ -16,6 +16,17 @@ pub struct ProfileSummary {
pub is_active: bool, 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 /// Create a new profile, generating fresh keys. The new profile becomes the
/// active one when nothing is currently selected. /// 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)) .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. /// Look up the stored secret key of the active profile.
pub fn active_secret_key(vault: &Vault) -> Result<&str, AppError> { pub fn active_secret_key(vault: &Vault) -> Result<&str, AppError> {
let npub = vault let npub = vault
@ -153,6 +173,23 @@ pub fn resolve_active_secret_key(
resolve_secret_key(vault, npub, 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. /// Parse and validate a stored hex-encoded secret key.
pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> { pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
let bytes = ::hex::decode(hex_str) let bytes = ::hex::decode(hex_str)
@ -163,7 +200,7 @@ pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::vault::Vault; use crate::vault::{KdfParams, Vault, VaultCrypto};
fn populated_vault() -> Vault { fn populated_vault() -> Vault {
let mut vault = Vault::empty(); 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"); let key = parse_secret_key("01".repeat(32).as_str()).expect("valid key must parse");
assert_eq!(key.to_secret_bytes().len(), 32); 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);
}
} }