Add reveal-secret-key after unlock (CLI + GUI)
- profiles::reveal_secret_key returns a key in hex and nsec1... forms, gated on an unlocked vault (VaultLocked when encrypted + locked) - IPC: reveal_secret_key method; error replies now carry a machine-readable code field (ErrorKind as snake_case, e.g. vault_locked) - CLI: show-secret <npub> prompts for the vault password when locked - GUI: 'Secret key' button per profile card opens a modal showing hex + nsec with copy buttons; locked vaults ask for the password inline before revealing - Tests: Rust (reveal plaintext/encrypted/locked) and Vitest (reveal flow, lock-then-unlock), all green
This commit is contained in:
parent
ec2eb6c092
commit
8eb6685281
17 changed files with 594 additions and 54 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
188
frontend/src/components/ShowSecretKeyModal.tsx
Normal file
188
frontend/src/components/ShowSecretKeyModal.tsx
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import { BackendError } from '../lib/api';
|
||||||
|
import { useApp } from '../state/AppProvider';
|
||||||
|
import type { RevealedKey } from '../lib/types';
|
||||||
|
import { Alert } from './Alert';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { CopyButton } from './CopyButton';
|
||||||
|
import { ErrorText } from './ErrorText';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Spinner } from './Spinner';
|
||||||
|
|
||||||
|
interface ShowSecretKeyModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** The profile whose secret key is being revealed. */
|
||||||
|
profile: { label: string; npub: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Phase = 'loading' | 'unlock' | 'revealed' | 'error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a profile's secret key (hex + nsec) after unlocking the vault.
|
||||||
|
*
|
||||||
|
* When the vault is password-protected and still locked, the modal asks for
|
||||||
|
* the password inline, unlocks, and then reveals the key. The secret key is
|
||||||
|
* only ever fetched from the backend, never stored in state before reveal.
|
||||||
|
*/
|
||||||
|
export function ShowSecretKeyModal({ open, onClose, profile }: ShowSecretKeyModalProps) {
|
||||||
|
const { revealSecretKey, unlockVault } = useApp();
|
||||||
|
const [phase, setPhase] = useState<Phase>('loading');
|
||||||
|
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [fatal, setFatal] = useState<{ message: string; details?: string | null } | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const unlockErrorId = 'show-secret-unlock-error';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && profile) {
|
||||||
|
setPhase('loading');
|
||||||
|
setRevealed(null);
|
||||||
|
setPassword('');
|
||||||
|
setError(null);
|
||||||
|
setFatal(null);
|
||||||
|
setBusy(false);
|
||||||
|
void reveal(profile.npub);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, profile?.npub]);
|
||||||
|
|
||||||
|
const reveal = async (npub: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setFatal(null);
|
||||||
|
try {
|
||||||
|
const key = await revealSecretKey(npub);
|
||||||
|
setRevealed(key);
|
||||||
|
setPhase('revealed');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BackendError && err.code === 'vault_locked') {
|
||||||
|
setPhase('unlock');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFatal({
|
||||||
|
message: err instanceof Error ? err.message : String(err),
|
||||||
|
details: err instanceof BackendError ? err.details : undefined,
|
||||||
|
});
|
||||||
|
setPhase('error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit = password.length > 0 && !busy;
|
||||||
|
|
||||||
|
const onUnlock = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await unlockVault(password);
|
||||||
|
setPassword('');
|
||||||
|
if (profile) {
|
||||||
|
await reveal(profile.npub);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setPassword('');
|
||||||
|
setBusy(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = `Secret key${profile ? ` — ${profile.label}` : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} title={title} onClose={onClose}>
|
||||||
|
{phase === 'loading' && <Spinner label="Revealing secret key…" />}
|
||||||
|
|
||||||
|
{phase === 'unlock' && (
|
||||||
|
<form onSubmit={onUnlock} noValidate>
|
||||||
|
<Alert tone="warning" title="Vault is locked">
|
||||||
|
This profile's keys are password-protected. Enter the vault password to reveal the
|
||||||
|
secret key. The password itself is never saved.
|
||||||
|
</Alert>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="show-secret-password">Vault password</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="show-secret-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
aria-describedby={error ? unlockErrorId : undefined}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{error && <ErrorText id={unlockErrorId}>{error}</ErrorText>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
|
||||||
|
{busy ? 'Unlocking…' : 'Unlock'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'error' && fatal && (
|
||||||
|
<div>
|
||||||
|
<Alert tone="error" title="Could not reveal the secret key" details={fatal.details}>
|
||||||
|
{fatal.message}
|
||||||
|
</Alert>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === 'revealed' && revealed && (
|
||||||
|
<div>
|
||||||
|
<Alert tone="error" title="Keep this key safe">
|
||||||
|
Anyone who has this key can fully control the profile: publish as it, sign messages, and
|
||||||
|
move its funds. Never paste it into chat, logs, or screenshots. Store it offline and
|
||||||
|
back it up.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="path-row">
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Private key (hex)</span>
|
||||||
|
<code className="mono path-value">{revealed.hex}</code>
|
||||||
|
</div>
|
||||||
|
<CopyButton text={revealed.hex} label="hex key" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="path-row">
|
||||||
|
<div>
|
||||||
|
<span className="field-label">Private key (nsec)</span>
|
||||||
|
<code className="mono path-value">{revealed.nsec}</code>
|
||||||
|
</div>
|
||||||
|
<CopyButton text={revealed.nsec} label="nsec key" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="hint">
|
||||||
|
The <code>nsec1…</code> form is what most Nostr wallets and clients import. It encodes
|
||||||
|
exactly the same key as the hex form above.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import type {
|
||||||
ProfileSummary,
|
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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
87
frontend/src/test/ShowSecretKey.test.tsx
Normal file
87
frontend/src/test/ShowSecretKey.test.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
import { screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { ProfilesScreen } from '../screens/ProfilesScreen';
|
||||||
|
import { makeState } from './apiMock';
|
||||||
|
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||||
|
import { renderWithApp } from './render';
|
||||||
|
import { ALICE } from './apiMock';
|
||||||
|
|
||||||
|
const ALICE_HEX = `${ALICE.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||||
|
const ALICE_NSEC = `nsec1${ALICE.slice(5)}`;
|
||||||
|
|
||||||
|
/** Wait for the profile list to settle, then open the first profile's key reveal. */
|
||||||
|
async function openReveal(user: ReturnType<typeof userEvent.setup>) {
|
||||||
|
await screen.findByText('Alice');
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Secret key' })[0]);
|
||||||
|
return screen.findByRole('dialog', { name: 'Secret key — Alice' });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('revealing a secret key', () => {
|
||||||
|
it('shows hex and nsec for an unencrypted vault without asking for a password', async () => {
|
||||||
|
const backend = createFakeBackend();
|
||||||
|
installFakeBackend(backend);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
|
const dialog = await openReveal(user);
|
||||||
|
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText(/Anyone who has this key can fully control the profile/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Copy hex key' }));
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Copy nsec key' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(backend.copied).toContain(ALICE_HEX);
|
||||||
|
expect(backend.copied).toContain(ALICE_NSEC);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks for the vault password when locked, then reveals the key', async () => {
|
||||||
|
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
|
||||||
|
installFakeBackend(backend);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
|
const dialog = await openReveal(user);
|
||||||
|
expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument();
|
||||||
|
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.type(within(dialog).getByLabelText('Vault password'), 'correct horse');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(backend.state.vault_locked).toBe(false);
|
||||||
|
});
|
||||||
|
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText(ALICE_NSEC)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the unlock form when an incorrect password is reported', async () => {
|
||||||
|
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
|
||||||
|
backend.nextErrors.unlock_vault = { message: 'The password is not correct.' };
|
||||||
|
installFakeBackend(backend);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
|
const dialog = await openReveal(user);
|
||||||
|
await user.type(within(dialog).getByLabelText('Vault password'), 'wrong');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('The password is not correct.')).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText('Vault is locked')).toBeInTheDocument();
|
||||||
|
expect(within(dialog).queryByText(ALICE_HEX)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals directly when the vault is encrypted but already unlocked', async () => {
|
||||||
|
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: false }));
|
||||||
|
installFakeBackend(backend);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithApp(<ProfilesScreen onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
|
const dialog = await openReveal(user);
|
||||||
|
expect(within(dialog).getByText(ALICE_HEX)).toBeInTheDocument();
|
||||||
|
expect(within(dialog).queryByLabelText('Vault password')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -90,6 +90,7 @@ export interface ApiMock {
|
||||||
unlockVault: ReturnType<typeof vi.fn>;
|
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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
27
src/ipc.rs
27
src/ipc.rs
|
|
@ -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,
|
||||||
|
|
|
||||||
19
src/main.rs
19
src/main.rs
|
|
@ -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![
|
||||||
|
|
|
||||||
121
src/profiles.rs
121
src/profiles.rs
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue