diff --git a/CHECKPOINT-encryption.md b/CHECKPOINT-encryption.md index e81e6fe..7585613 100644 --- a/CHECKPOINT-encryption.md +++ b/CHECKPOINT-encryption.md @@ -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 `, + 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 ` 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 ` + - 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. diff --git a/README.md b/README.md index 6efbcef..1fa6972 100644 --- a/README.md +++ b/README.md @@ -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 # 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 diff --git a/frontend/src/components/Icon.tsx b/frontend/src/components/Icon.tsx index 256cce1..50efe32 100644 --- a/frontend/src/components/Icon.tsx +++ b/frontend/src/components/Icon.tsx @@ -14,7 +14,8 @@ export type IconName = | 'info' | 'shield' | 'publish' - | 'external'; + | 'external' + | 'key'; const PATHS: Record = { home: ( @@ -85,6 +86,14 @@ const PATHS: Record = { ), + key: ( + <> + + + + + + ), }; interface IconProps { diff --git a/frontend/src/components/ShowSecretKeyModal.tsx b/frontend/src/components/ShowSecretKeyModal.tsx new file mode 100644 index 0000000..ae1eb40 --- /dev/null +++ b/frontend/src/components/ShowSecretKeyModal.tsx @@ -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('loading'); + const [revealed, setRevealed] = useState(null); + const [password, setPassword] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null); + const inputRef = useRef(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 ( + + {phase === 'loading' && } + + {phase === 'unlock' && ( +
+ + This profile's keys are password-protected. Enter the vault password to reveal the + secret key. The password itself is never saved. + +
+ + setPassword(event.target.value)} + autoComplete="current-password" + autoFocus + aria-describedby={error ? unlockErrorId : undefined} + aria-invalid={error ? true : undefined} + disabled={busy} + /> + {error && {error}} +
+
+ + +
+
+ )} + + {phase === 'error' && fatal && ( +
+ + {fatal.message} + +
+ +
+
+ )} + + {phase === 'revealed' && revealed && ( +
+ + 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. + + +
+
+ Private key (hex) + {revealed.hex} +
+ +
+ +
+
+ Private key (nsec) + {revealed.nsec} +
+ +
+ +

+ The nsec1… form is what most Nostr wallets and clients import. It encodes + exactly the same key as the hex form above. +

+ +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c2c36e4..79804f6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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(method: string, params: Record = {}): Promise { const envelope = (await window.backend.request(method, params)) as BackendResponse; 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('unlock_vault', { password }), lockVault: () => call('lock_vault'), removeVaultPassword: (password: string) => call('remove_vault_password', { password }), + revealSecretKey: (npub: string) => call('reveal_secret_key', { npub }), copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8aaf3bc..0ba8b4a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 = - { 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; + }; diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index e36b945..7c04b7b 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -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(null); const [error, setError] = useState(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) {
+ {profile.is_active ? (
+ + setRevealTarget(null)} + /> ); diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 37eba5a..c95e88a 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -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; lockVault: () => Promise; removeVaultPassword: (password: string) => Promise; + revealSecretKey: (npub: string) => Promise; copyText: (text: string) => Promise; } @@ -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, ], ); diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index f7a327d..a073116 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -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 () => { diff --git a/frontend/src/test/ProfilesScreen.test.tsx b/frontend/src/test/ProfilesScreen.test.tsx index c1754b9..49cf60d 100644 --- a/frontend/src/test/ProfilesScreen.test.tsx +++ b/frontend/src/test/ProfilesScreen.test.tsx @@ -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 () => { diff --git a/frontend/src/test/ShowSecretKey.test.tsx b/frontend/src/test/ShowSecretKey.test.tsx new file mode 100644 index 0000000..93337fa --- /dev/null +++ b/frontend/src/test/ShowSecretKey.test.tsx @@ -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) { + 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(); + + 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(); + + 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(); + + 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(); + + const dialog = await openReveal(user); + expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument(); + expect(within(dialog).queryByLabelText('Vault password')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index 200be83..1ab8a27 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -90,6 +90,7 @@ export interface ApiMock { unlockVault: ReturnType; lockVault: ReturnType; removeVaultPassword: ReturnType; + revealSecretKey: ReturnType; copyText: ReturnType; }; /** 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), }; diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index 705fce7..e35322e 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -27,7 +27,7 @@ export interface FakeBackend { /** Relay URLs that fail connection tests. */ relayErrors: Set; /** Per-method canned error override. */ - nextErrors: Record; + nextErrors: Record; } 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}`); } diff --git a/src/errors.rs b/src/errors.rs index 6d9d1e5..effad16 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -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, diff --git a/src/ipc.rs b/src/ipc.rs index 1a9eae5..9eff194 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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 { 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, }, @@ -124,6 +132,7 @@ pub async fn serve() -> Result<(), AppError> { Ok(envelope) => envelope, Err(e) => { let reply = Reply::::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 { 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 { match request { Request::Init | Request::GetState => Ok(json!(app.state_view())), @@ -255,6 +277,11 @@ async fn run(app: &mut App, request: Request) -> Result { + let revealed = profiles::reveal_secret_key(&app.vault, &npub, app.vault_key())?; + Ok(json!(revealed)) + } + Request::SettingsUpdate { theme, confirm_before_publish, diff --git a/src/main.rs b/src/main.rs index 558a369..c118b69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 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 { 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 { + let npub = args + .get(2) + .ok_or_else(|| AppError::config("Usage: nostr-manager-backend show-secret "))?; + + 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 { let app = App::load()?; let mut lines = vec![ diff --git a/src/profiles.rs b/src/profiles.rs index ed08bbe..9047899 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -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 { + 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 { let bytes = ::hex::decode(hex_str) @@ -163,7 +200,7 @@ pub fn parse_secret_key(hex_str: &str) -> Result { #[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); + } }