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
|
||||
verified green at the moment this file was written.
|
||||
|
|
@ -6,73 +6,87 @@ verified green at the moment this file was written.
|
|||
## Where things are
|
||||
|
||||
- Project: `/home/avi/Projects/skills/nost-feed-manager`
|
||||
- Git repo: `master` @ `7e3bac3` ("Add Nostr Feed Manager: Rust backend with Electron + React GUI")
|
||||
- The encryption work is **uncommitted** — all changes are in the working tree.
|
||||
- Also relevant: `/home/avi/Projects/nostr_backend/nostr_backendmanager.md` (old-CLI docs, untouched),
|
||||
and `/home/avi/Projects/nostr_backend/vlog-website/` (separate, untouched).
|
||||
- Git repo: `master`
|
||||
- The reveal feature is **uncommitted** — all changes are in the working tree.
|
||||
- `/home/avi/Projects/nostr_backend/nostr_backendmanager.md` (old-CLI docs) has been updated
|
||||
to match reality; it lives outside this repo so it is not part of the commit.
|
||||
- `/home/avi/Projects/nostr_backend/vlog-website/` (separate, untouched).
|
||||
|
||||
## What was completed: password-encrypted vault
|
||||
## What was completed in this session: reveal a secret key after unlock
|
||||
|
||||
- Secret keys are now encrypted at rest with **AES-256-GCM** under a key derived via **Argon2id**
|
||||
from the user's password. Vaults stay plaintext until a password is set (opt-in).
|
||||
- Encryption only covers the secret keys; labels/npubs stay readable so profiles can be browsed
|
||||
while the vault is locked. The derived key lives only in memory for the session.
|
||||
Building on the existing password-encrypted vault (AES-256-GCM + Argon2id), owners can now view a
|
||||
profile's secret key after entering the vault password:
|
||||
|
||||
## Files changed (19 modified, 4 new)
|
||||
- Backend: `profiles::reveal_secret_key` returns the key in both hex and `nsec1...` forms, gated on
|
||||
an unlocked vault (`VaultLocked` when encrypted + locked). New CLI command `show-secret <npub>`,
|
||||
which prompts for the password when locked (via `NFM_PASSWORD` env or hidden prompt).
|
||||
- IPC: new `reveal_secret_key { npub }` method. Error replies now carry a machine-readable `code`
|
||||
field (ErrorKind serialised as snake_case, e.g. `vault_locked`), so the GUI can branch without
|
||||
string-matching on user-facing messages.
|
||||
- GUI: a "Secret key" button on every profile card opens `ShowSecretKeyModal`, which shows hex +
|
||||
nsec with copy buttons and a warning. When the vault is locked the modal asks for the password
|
||||
inline, unlocks, then reveals.
|
||||
- Key is only ever fetched after unlock; never stored in state before reveal.
|
||||
|
||||
## Files changed (16 modified, 2 new)
|
||||
|
||||
Modified:
|
||||
- `Cargo.toml`, `Cargo.lock` — added `argon2`, `aes-gcm`, `base64`, `getrandom`, `rpassword`
|
||||
- `README.md` — documented the new feature + CLI commands
|
||||
- `src/lib.rs`, `src/app.rs`, `src/vault.rs`, `src/profiles.rs`, `src/publish.rs`,
|
||||
`src/ipc.rs`, `src/main.rs`, `src/errors.rs`
|
||||
- `frontend/src/App.tsx`, `frontend/src/lib/api.ts`, `frontend/src/lib/types.ts`,
|
||||
`frontend/src/state/AppProvider.tsx`, `frontend/src/screens/SettingsScreen.tsx`,
|
||||
`frontend/src/styles.css`, `frontend/src/test/apiMock.ts`, `frontend/src/test/fakeBackend.ts`
|
||||
- `README.md` — documented the reveal feature + `show-secret`
|
||||
- `src/errors.rs` — `ErrorKind` now serialises as snake_case for the IPC error code
|
||||
- `src/profiles.rs` — `RevealedKey`, `reveal_secret_key`, `profile_label` + tests
|
||||
- `src/ipc.rs` — `RevealSecretKey` request, `code` on error replies
|
||||
- `src/main.rs` — `show-secret` CLI command
|
||||
- `frontend/src/components/Icon.tsx` — new `key` icon
|
||||
- `frontend/src/lib/api.ts` — `revealSecretKey`, `BackendError.code`
|
||||
- `frontend/src/lib/types.ts` — `RevealedKey`, error `code` in `BackendResponse`
|
||||
- `frontend/src/screens/ProfilesScreen.tsx` — "Secret key" button per profile
|
||||
- `frontend/src/state/AppProvider.tsx` — `revealSecretKey` in context
|
||||
- `frontend/src/test/{App,ProfilesScreen,apiMock,fakeBackend}` — updated for new UI + error codes
|
||||
|
||||
New:
|
||||
- `src/crypto.rs` — Argon2id KDF + AES-256-GCM encrypt/decrypt + password verifier
|
||||
- `frontend/src/components/UnlockModal.tsx`
|
||||
- `frontend/src/components/VaultPasswordModal.tsx`
|
||||
- `frontend/src/test/VaultPassword.test.tsx`
|
||||
- `frontend/src/components/ShowSecretKeyModal.tsx`
|
||||
- `frontend/src/test/ShowSecretKey.test.tsx`
|
||||
|
||||
Also updated (outside repo): `/home/avi/Projects/nostr_backend/nostr_backendmanager.md`.
|
||||
|
||||
## New backend API (IPC + CLI)
|
||||
|
||||
IPC methods: `set_vault_password { current_password?, new_password }`,
|
||||
`unlock_vault { password }`, `lock_vault`, `remove_vault_password { password }`.
|
||||
IPC: `reveal_secret_key { npub }` → `{ hex, nsec }`. Error replies now include
|
||||
`"code": "vault_locked"` (etc.) alongside `message`/`details`.
|
||||
|
||||
CLI: `set-password`, `remove-password`, `unlock`; `create`/`publish` auto-prompt when locked.
|
||||
Passwords come from `NFM_PASSWORD` env var or a hidden terminal prompt — never argv.
|
||||
Min password length: 8 chars.
|
||||
CLI: `show-secret <npub>` prints hex + nsec after unlocking. `create`, `publish`, and
|
||||
`show-secret` all auto-prompt for the vault password when it is encrypted.
|
||||
|
||||
## How it was verified (all green)
|
||||
|
||||
```
|
||||
cargo test # 50 passed
|
||||
cargo test # 55 passed
|
||||
cargo clippy --all-targets # clean
|
||||
cargo fmt --check # clean
|
||||
cargo build --release # builds
|
||||
npm run typecheck # clean (frontend/)
|
||||
npm run lint # clean (pre-existing module warning only)
|
||||
npm run format:check # clean
|
||||
npm test # 52 passed (10 files)
|
||||
npm test # 56 passed (11 files)
|
||||
```
|
||||
|
||||
Plus a manual end-to-end CLI smoke test: create → set-password → vault file shows only base64
|
||||
ciphertext → wrong password rejected → correct password creates encrypted profile. Temp data
|
||||
was cleaned up (`/tmp/nfm-e2e` removed).
|
||||
Plus a manual IPC end-to-end smoke test: reveal on plaintext → OK; set-password → lock →
|
||||
reveal returns `code: "vault_locked"`; unlock → reveal returns the same hex. Temp data cleaned up.
|
||||
|
||||
## How to resume
|
||||
|
||||
1. Open the repo: `cd /home/avi/Projects/skills/nost-feed-manager`
|
||||
2. Inspect the diff: `git diff` (work is still uncommitted)
|
||||
3. To try it: `cargo build --release` then
|
||||
`XDG_DATA_HOME=/tmp/nfm-smoke ./target/release/nostr-manager-backend create "Alice"`,
|
||||
`NFM_PASSWORD=... ./target/release/nostr-manager-backend set-password`
|
||||
3. To try it:
|
||||
- CLI: `cargo build --release`, then
|
||||
`XDG_DATA_HOME=/tmp/nfm-smoke ./target/release/nostr-manager-backend create "Alice"` and
|
||||
`./target/release/nostr-manager-backend show-secret <npub>`
|
||||
- GUI: `cd frontend && npm start`, Profiles → "Secret key" on a card
|
||||
4. Re-run verification with the commands above.
|
||||
|
||||
## Outstanding / next steps (if you continue)
|
||||
|
||||
- Decide whether to **commit** the work (nothing is committed yet).
|
||||
- `nostr_backendmanager.md` still lists "password-based vault encryption" as a future item and was
|
||||
left untouched — it may deserve updating to match reality.
|
||||
- No lock-screen gate: browsing works while locked; only create/publish require unlocking (intended).
|
||||
- Decide whether to **commit** the reveal work (nothing is committed yet).
|
||||
- Consider a small UI hint that profiles with an unencrypted vault can be revealed with no prompt.
|
||||
- Relay defaults are currently `relay.damus.io` (503 upstream) and `relay.nostr.band` (timeout);
|
||||
user's local settings already point at `nos.lol` + `relay.primal.net` instead.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ runs in the Rust backend, which the GUI talks to over a JSON-lines IPC channel.
|
|||
- Password-protected vault: secret keys are encrypted at rest with AES-256-GCM under an
|
||||
Argon2id-derived key. Without a password set, the vault is stored in plaintext (readable only by
|
||||
your user account) and this is disclosed in the app
|
||||
- Reveal a profile's secret key (hex + `nsec1...`) from the app or the CLI — only after the vault
|
||||
password is entered, so keys stay encrypted at rest
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
@ -92,13 +94,15 @@ cargo run --release -- settings get|set theme|confirm|shorten
|
|||
cargo run --release -- set-password # encrypt the vault (or change its password)
|
||||
cargo run --release -- remove-password # remove vault encryption
|
||||
cargo run --release -- unlock # verify the vault password for this process
|
||||
cargo run --release -- show-secret <npub> # reveal a profile's secret key (hex + nsec)
|
||||
cargo run --release -- info # show storage locations and version
|
||||
cargo run --release -- serve # JSON-lines IPC server (used by the GUI)
|
||||
```
|
||||
|
||||
Passwords are read from the `NFM_PASSWORD` environment variable when set, otherwise you are
|
||||
prompted interactively. They are never accepted as command-line arguments. `create` and `publish`
|
||||
prompt for the vault password automatically when the vault is encrypted.
|
||||
prompt for the vault password automatically when the vault is encrypted, and so does `show-secret` —
|
||||
the key is only displayed after the vault password is entered.
|
||||
|
||||
## Storage and migration
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ export type IconName =
|
|||
| 'info'
|
||||
| 'shield'
|
||||
| 'publish'
|
||||
| 'external';
|
||||
| 'external'
|
||||
| 'key';
|
||||
|
||||
const PATHS: Record<IconName, ReactNode> = {
|
||||
home: (
|
||||
|
|
@ -85,6 +86,14 @@ const PATHS: Record<IconName, ReactNode> = {
|
|||
<path d="M10 4H5a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-5" />
|
||||
</>
|
||||
),
|
||||
key: (
|
||||
<>
|
||||
<circle cx="7.5" cy="15.5" r="4.5" />
|
||||
<path d="M10.6 12.4 21 2" />
|
||||
<path d="M16 8l3 3" />
|
||||
<path d="M18 6l2 2" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
interface IconProps {
|
||||
|
|
|
|||
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,
|
||||
PublishReport,
|
||||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
} from './types';
|
||||
|
||||
|
|
@ -19,18 +20,21 @@ declare global {
|
|||
/** A safe error with an optional expandable technical detail. */
|
||||
export class BackendError extends Error {
|
||||
readonly details?: string | null;
|
||||
/** Machine-readable kind, e.g. `vault_locked`. */
|
||||
readonly code?: string | null;
|
||||
|
||||
constructor(message: string, details?: string | null) {
|
||||
constructor(message: string, details?: string | null, code?: string | null) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
this.details = details;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
const envelope = (await window.backend.request(method, params)) as BackendResponse<T>;
|
||||
if (envelope.status === 'error') {
|
||||
throw new BackendError(envelope.message, envelope.details);
|
||||
throw new BackendError(envelope.message, envelope.details, envelope.code);
|
||||
}
|
||||
return envelope.data;
|
||||
}
|
||||
|
|
@ -59,5 +63,6 @@ export const api = {
|
|||
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
|
||||
lockVault: () => call<AppState>('lock_vault'),
|
||||
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
|
||||
revealSecretKey: (npub: string) => call<RevealedKey>('reveal_secret_key', { npub }),
|
||||
copyText: (text: string) => window.backend.copyText(text),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -56,6 +56,21 @@ export interface AppState {
|
|||
settings: Settings;
|
||||
}
|
||||
|
||||
/** A secret key revealed after the vault is unlocked. */
|
||||
export interface RevealedKey {
|
||||
/** 64-character lowercase hex form. */
|
||||
hex: string;
|
||||
/** Bech32 `nsec1...` form, what most wallets and clients import. */
|
||||
nsec: string;
|
||||
}
|
||||
|
||||
/** Wire envelope returned by the Rust backend. */
|
||||
export type BackendResponse<T> =
|
||||
{ status: 'ok'; data: T } | { status: 'error'; message: string; details?: string | null };
|
||||
| { status: 'ok'; data: T }
|
||||
| {
|
||||
status: 'error';
|
||||
/** Machine-readable kind, e.g. `vault_locked`. */
|
||||
code?: string;
|
||||
message: string;
|
||||
details?: string | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CopyButton } from '../components/CopyButton';
|
|||
import { EmptyState } from '../components/EmptyState';
|
||||
import { ErrorText } from '../components/ErrorText';
|
||||
import { Icon } from '../components/Icon';
|
||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||
import { formatDate, shortenNpub } from '../lib/format';
|
||||
import { useApp } from '../state/AppProvider';
|
||||
|
||||
|
|
@ -18,6 +19,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
const [selecting, setSelecting] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorId] = useState(() => `profiles-error-${Math.random().toString(36).slice(2)}`);
|
||||
const [revealTarget, setRevealTarget] = useState<{ label: string; npub: string } | null>(null);
|
||||
|
||||
const profiles = state?.profiles ?? [];
|
||||
const shorten = state?.settings.shorten_npub ?? true;
|
||||
|
|
@ -96,6 +98,14 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
|
||||
<div className="profile-card-actions">
|
||||
<CopyButton text={profile.npub} label="public key" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setRevealTarget({ label: profile.label, npub: profile.npub })}
|
||||
>
|
||||
<Icon name="key" size={16} />
|
||||
Secret key
|
||||
</Button>
|
||||
{profile.is_active ? (
|
||||
<Button variant="secondary" size="sm" disabled>
|
||||
Selected
|
||||
|
|
@ -114,6 +124,12 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
|||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ShowSecretKeyModal
|
||||
open={revealTarget !== null}
|
||||
profile={revealTarget}
|
||||
onClose={() => setRevealTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
ProfileSummary,
|
||||
PublishReport,
|
||||
RelayTestResult,
|
||||
RevealedKey,
|
||||
Settings,
|
||||
Theme,
|
||||
} from '../lib/types';
|
||||
|
|
@ -47,6 +48,7 @@ interface AppContextValue {
|
|||
unlockVault: (password: string) => Promise<AppState>;
|
||||
lockVault: () => Promise<AppState>;
|
||||
removeVaultPassword: (password: string) => Promise<AppState>;
|
||||
revealSecretKey: (npub: string) => Promise<RevealedKey>;
|
||||
copyText: (text: string) => Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +158,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
(password: string) => applyState(api.removeVaultPassword(password)),
|
||||
[applyState],
|
||||
);
|
||||
const revealSecretKey = useCallback((npub: string) => api.revealSecretKey(npub), []);
|
||||
|
||||
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||
|
||||
|
|
@ -183,6 +186,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
unlockVault,
|
||||
lockVault,
|
||||
removeVaultPassword,
|
||||
revealSecretKey,
|
||||
copyText,
|
||||
}),
|
||||
[
|
||||
|
|
@ -206,6 +210,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
|||
unlockVault,
|
||||
lockVault,
|
||||
removeVaultPassword,
|
||||
revealSecretKey,
|
||||
copyText,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
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>;
|
||||
lockVault: ReturnType<typeof vi.fn>;
|
||||
removeVaultPassword: ReturnType<typeof vi.fn>;
|
||||
revealSecretKey: ReturnType<typeof vi.fn>;
|
||||
copyText: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
/** Current state object backing init/getState. */
|
||||
|
|
@ -176,6 +177,10 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
|||
encrypted_storage: false,
|
||||
vault_locked: false,
|
||||
})),
|
||||
revealSecretKey: vi.fn(async (npub: string) => ({
|
||||
hex: `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64),
|
||||
nsec: `nsec1${npub.slice(5)}`,
|
||||
})),
|
||||
copyText: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export interface FakeBackend {
|
|||
/** Relay URLs that fail connection tests. */
|
||||
relayErrors: Set<string>;
|
||||
/** Per-method canned error override. */
|
||||
nextErrors: Record<string, { message: string; details?: string }>;
|
||||
nextErrors: Record<string, { message: string; details?: string; code?: string }>;
|
||||
}
|
||||
|
||||
export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||
|
|
@ -38,8 +38,8 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
api: {
|
||||
async request(method, params = {}) {
|
||||
if (backend.nextErrors[method]) {
|
||||
const { message, details } = backend.nextErrors[method];
|
||||
return { status: 'error', message, details };
|
||||
const { message, details, code } = backend.nextErrors[method];
|
||||
return { status: 'error', message, details, code };
|
||||
}
|
||||
try {
|
||||
const data = await dispatch(method, params);
|
||||
|
|
@ -52,6 +52,10 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
error instanceof Error && 'details' in error
|
||||
? (error as { details?: string }).details
|
||||
: undefined,
|
||||
code:
|
||||
error instanceof Error && 'code' in error
|
||||
? (error as { code?: string }).code
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
|
@ -205,6 +209,21 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
|||
return next;
|
||||
}
|
||||
|
||||
case 'reveal_secret_key': {
|
||||
if (state.encrypted_storage && state.vault_locked) {
|
||||
throw Object.assign(
|
||||
new Error('Your vault is locked. Enter your password to unlock it.'),
|
||||
{ code: 'vault_locked' },
|
||||
);
|
||||
}
|
||||
const npub = String(params.npub);
|
||||
if (!state.profiles.some((p) => p.npub === npub)) {
|
||||
throw new Error('That profile is not stored on this computer.');
|
||||
}
|
||||
const hex = `${npub.slice(4)}0000000000000000000000000000000000`.slice(0, 64);
|
||||
return { hex, nsec: `nsec1${npub.slice(5)}` };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown method: ${method}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
27
src/ipc.rs
27
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<T> {
|
|||
data: T,
|
||||
},
|
||||
Error {
|
||||
/// Machine-readable kind, e.g. `vault_locked`. Useful for the GUI to
|
||||
/// branch on without matching on the human message.
|
||||
code: String,
|
||||
message: String,
|
||||
details: Option<String>,
|
||||
},
|
||||
|
|
@ -124,6 +132,7 @@ pub async fn serve() -> Result<(), AppError> {
|
|||
Ok(envelope) => envelope,
|
||||
Err(e) => {
|
||||
let reply = Reply::<serde_json::Value>::Error {
|
||||
code: "bad_request".to_string(),
|
||||
message: "The request could not be understood.".to_string(),
|
||||
details: Some(e.to_string()),
|
||||
};
|
||||
|
|
@ -169,12 +178,25 @@ async fn handle(app: &mut App, request: Request) -> Reply<serde_json::Value> {
|
|||
match result {
|
||||
Ok(value) => Reply::Ok { data: value },
|
||||
Err(err) => Reply::Error {
|
||||
code: error_code(&err),
|
||||
message: err.message().to_string(),
|
||||
details: err.details().map(str::to_string),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable machine-readable string for an error, sent with every error reply.
|
||||
fn error_code(err: &AppError) -> String {
|
||||
serde_json::to_value(err.kind())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| serde_json::Error::io(std::io::Error::other("not a string")))
|
||||
})
|
||||
.unwrap_or_else(|_| "error".to_string())
|
||||
}
|
||||
|
||||
async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppError> {
|
||||
match request {
|
||||
Request::Init | Request::GetState => Ok(json!(app.state_view())),
|
||||
|
|
@ -255,6 +277,11 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
|
|||
Ok(json!(app.state_view()))
|
||||
}
|
||||
|
||||
Request::RevealSecretKey { npub } => {
|
||||
let revealed = profiles::reveal_secret_key(&app.vault, &npub, app.vault_key())?;
|
||||
Ok(json!(revealed))
|
||||
}
|
||||
|
||||
Request::SettingsUpdate {
|
||||
theme,
|
||||
confirm_before_publish,
|
||||
|
|
|
|||
19
src/main.rs
19
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 <npub> Show a profile's secret key (hex + nsec) after unlocking
|
||||
info Show storage locations and version
|
||||
serve Run the JSON-lines IPC server
|
||||
|
||||
|
|
@ -62,6 +63,7 @@ async fn main() -> ExitCode {
|
|||
"set-password" => cli_set_password(),
|
||||
"remove-password" => cli_remove_password(),
|
||||
"unlock" => cli_unlock(),
|
||||
"show-secret" => cli_show_secret(&args),
|
||||
"info" => cli_info(),
|
||||
"help" | "--help" | "-h" => {
|
||||
println!("{USAGE}");
|
||||
|
|
@ -319,6 +321,23 @@ fn cli_unlock() -> Result<String, AppError> {
|
|||
Ok("Vault unlocked.".to_string())
|
||||
}
|
||||
|
||||
/// Reveal a profile's secret key. When the vault is encrypted, the password is
|
||||
/// required before the key is shown.
|
||||
fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
|
||||
let npub = args
|
||||
.get(2)
|
||||
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend show-secret <npub>"))?;
|
||||
|
||||
let app = load_app_with_unlock()?;
|
||||
let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?;
|
||||
let label = profiles::profile_label(&app.vault, npub).unwrap_or(npub);
|
||||
|
||||
Ok(format!(
|
||||
"Secret key for \"{label}\" ({npub}):\n hex: {}\n nsec: {}",
|
||||
revealed.hex, revealed.nsec
|
||||
))
|
||||
}
|
||||
|
||||
fn cli_info() -> Result<String, AppError> {
|
||||
let app = App::load()?;
|
||||
let mut lines = vec![
|
||||
|
|
|
|||
121
src/profiles.rs
121
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<RevealedKey, AppError> {
|
||||
let hex = resolve_secret_key(vault, npub, key)?;
|
||||
let nsec = parse_secret_key(&hex)?
|
||||
.to_bech32()
|
||||
.map_err(|e| AppError::internal(format!("Could not encode the secret key: {e}")))?;
|
||||
Ok(RevealedKey { hex, nsec })
|
||||
}
|
||||
|
||||
/// Parse and validate a stored hex-encoded secret key.
|
||||
pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
|
||||
let bytes = ::hex::decode(hex_str)
|
||||
|
|
@ -163,7 +200,7 @@ pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vault::Vault;
|
||||
use crate::vault::{KdfParams, Vault, VaultCrypto};
|
||||
|
||||
fn populated_vault() -> Vault {
|
||||
let mut vault = Vault::empty();
|
||||
|
|
@ -273,4 +310,86 @@ mod tests {
|
|||
let key = parse_secret_key("01".repeat(32).as_str()).expect("valid key must parse");
|
||||
assert_eq!(key.to_secret_bytes().len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_returns_hex_and_nsec_for_plaintext_vault() {
|
||||
let mut vault = Vault::empty();
|
||||
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
|
||||
|
||||
let revealed = reveal_secret_key(&vault, &summary.npub, None).expect("must reveal");
|
||||
assert_eq!(revealed.hex.len(), 64, "hex secret is 32 bytes");
|
||||
assert!(revealed.nsec.starts_with("nsec1"));
|
||||
|
||||
let parsed = SecretKey::from_bech32(&revealed.nsec).expect("nsec must be valid");
|
||||
assert_eq!(
|
||||
::hex::encode(parsed.to_secret_bytes()),
|
||||
revealed.hex,
|
||||
"nsec must encode the same key as hex"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_decrypts_an_encrypted_profile_when_unlocked() {
|
||||
let mut vault = Vault::empty();
|
||||
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
|
||||
let plaintext = vault.profiles[0].secret_key.clone();
|
||||
|
||||
let salt = crate::crypto::generate_salt().unwrap();
|
||||
let key = crate::crypto::derive_key(
|
||||
"open sesame",
|
||||
&salt,
|
||||
crate::crypto::KDF_M_COST,
|
||||
crate::crypto::KDF_T_COST,
|
||||
crate::crypto::KDF_P_COST,
|
||||
)
|
||||
.unwrap();
|
||||
vault.profiles[0].secret_key = crate::crypto::encrypt_secret(&key, &plaintext).unwrap();
|
||||
vault.crypto = Some(VaultCrypto {
|
||||
kdf: KdfParams {
|
||||
algorithm: "argon2id".to_string(),
|
||||
salt: "not-read-during-reveal".to_string(),
|
||||
m_cost: 1,
|
||||
t_cost: 1,
|
||||
p_cost: 1,
|
||||
},
|
||||
verifier: "not-checked-here".to_string(),
|
||||
});
|
||||
|
||||
let revealed = reveal_secret_key(&vault, &summary.npub, Some(&key)).expect("must reveal");
|
||||
assert_eq!(revealed.hex, plaintext);
|
||||
assert!(revealed.nsec.starts_with("nsec1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_rejects_a_locked_vault() {
|
||||
let mut vault = Vault::empty();
|
||||
let summary = create_profile(&mut vault, "Alice".to_string(), None).unwrap();
|
||||
vault.crypto = Some(VaultCrypto {
|
||||
kdf: KdfParams {
|
||||
algorithm: "argon2id".to_string(),
|
||||
salt: "x".to_string(),
|
||||
m_cost: 1,
|
||||
t_cost: 1,
|
||||
p_cost: 1,
|
||||
},
|
||||
verifier: "x".to_string(),
|
||||
});
|
||||
|
||||
let err = reveal_secret_key(&vault, &summary.npub, None).expect_err("must be locked");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::VaultLocked);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_missing_profile_errors() {
|
||||
let vault = Vault::empty();
|
||||
let err = reveal_secret_key(&vault, "npub1ghost", None).expect_err("must error");
|
||||
assert_eq!(err.kind(), crate::errors::ErrorKind::ProfileNotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_label_lookup() {
|
||||
let vault = populated_vault();
|
||||
assert_eq!(profile_label(&vault, "npub1alice"), Some("Alice"));
|
||||
assert_eq!(profile_label(&vault, "npub1ghost"), None);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue