Add password-encrypted vault

- Encrypt stored secret keys with AES-256-GCM under an Argon2id-derived key;
  the vault stays plaintext until a password is set (Settings -> Storage or
  the CLI set-password command)
- Only secret keys are encrypted; labels and npubs stay readable so profiles
  can be browsed while the vault is locked
- Backend: crypto module, Vault.crypto metadata, unlock/lock/set/remove
  password on App, VaultLocked/WrongPassword errors, secret resolution on the
  publish path
- IPC: set_vault_password, unlock_vault, lock_vault, remove_vault_password
- CLI: set-password, remove-password, unlock; create/publish prompt when the
  vault is locked (NFM_PASSWORD env or hidden prompt, never argv)
- GUI: unlock banner + modal on locked vaults, protect/change/remove password
  in Settings, password field styling
- Tests: Rust (argon2/AES round-trips, vault lifecycle) and Vitest (unlock
  flow, set/change/remove password), all green
This commit is contained in:
Avi 2026-08-03 19:00:41 -05:00
commit 7ca1d14dcb
23 changed files with 1540 additions and 43 deletions

112
Cargo.lock generated
View file

@ -12,6 +12,31 @@ dependencies = [
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@ -21,6 +46,18 @@ dependencies = [
"memchr",
]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]]
name = "arrayvec"
version = "0.7.8"
@ -136,6 +173,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@ -246,6 +292,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
@ -415,6 +470,16 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gloo-timers"
version = "0.3.0"
@ -719,8 +784,13 @@ dependencies = [
name = "nostr-manager-backend"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"getrandom 0.2.17",
"hex",
"nostr-sdk",
"rpassword",
"serde",
"serde_json",
"tokio",
@ -838,6 +908,18 @@ dependencies = [
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@ -997,6 +1079,27 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.23.43"
@ -1605,6 +1708,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"

View file

@ -10,3 +10,8 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] }
hex = "0.4"
argon2 = "0.5"
aes-gcm = "0.10"
base64 = "0.22"
getrandom = "0.2"
rpassword = "7"

View file

@ -14,8 +14,9 @@ runs in the Rust backend, which the GUI talks to over a JSON-lines IPC channel.
- Add, remove, enable/disable, and test relays
- Light / dark / system theme, configurable publish confirmation and key shortening
- Back up your vault from the UI
- Honest about security: the vault is stored in plaintext (as in the original CLI), readable
only by your user account; this is clearly disclosed in the app
- 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
## Architecture
@ -88,10 +89,17 @@ cargo run --release -- switch <npub> # select the active profile
cargo run --release -- publish <npub> "Hello" # publish a text note
cargo run --release -- relays list|add|remove|enable|disable|test
cargo run --release -- settings get|set theme|confirm|shorten
cargo run --release -- info # show storage locations
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 -- 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.
## Storage and migration
- The vault (`profiles_vault.json`) and settings live in
@ -100,8 +108,13 @@ cargo run --release -- serve # JSON-lines IPC server (used b
- The original CLI saved `profiles_vault.json` in its working directory. On first launch this
app finds that file, copies it to a timestamped `*.backup-<ts>` next to it, and imports your
profiles into the new location. The original file is left untouched.
- Private keys are stored in the vault in plaintext. Anyone with access to your user account
can read them; a password-encrypted vault is planned for a future version.
- The vault is stored in plaintext until you set a password (Settings → Storage, or
`set-password` in the CLI). Once protected, every secret key is encrypted at rest with
AES-256-GCM under a key derived from your password with Argon2id. Labels and public keys stay
readable so profiles can be browsed while the vault is locked. You unlock once per session;
the derived key lives only in memory and is never written to disk. Anyone with access to your
user account can still read the vault file, so the password is a defence-in-depth layer, not a
replacement for keeping your account secure.
## Development
@ -122,7 +135,8 @@ cargo clippy --all-targets
```
src/ Rust library + CLI + IPC server
app.rs application state loading/persistence
app.rs application state, vault password/unlock lifecycle
crypto.rs Argon2id key derivation + AES-256-GCM encryption
errors.rs structured AppError
ipc.rs JSON-lines serve() loop and request/reply envelope
main.rs CLI entry point
@ -130,7 +144,7 @@ src/ Rust library + CLI + IPC server
publish.rs note publishing with per-relay reports
relays.rs default relays, validation, connection tests
settings.rs theme and user preferences
vault.rs encrypted-vault-ready storage (currently plaintext)
vault.rs vault storage (plaintext or password-encrypted) and migration
frontend/
electron/ Electron main + preload (backend spawn, IPC, clipboard)
src/ React app (components, screens, state, styles)

View file

@ -2,6 +2,9 @@ import { useState } from 'react';
import { Sidebar } from './components/Sidebar';
import { Spinner } from './components/Spinner';
import { Alert } from './components/Alert';
import { Button } from './components/Button';
import { Icon } from './components/Icon';
import { UnlockModal } from './components/UnlockModal';
import { HomeScreen } from './screens/HomeScreen';
import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen';
@ -15,6 +18,7 @@ function Shell() {
const { state, loading, bootstrapError } = useApp();
const [screen, setScreen] = useState<Screen>('home');
const [createOpen, setCreateOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme);
@ -38,10 +42,28 @@ function Shell() {
);
}
const vaultLocked = state?.vault_locked ?? false;
return (
<div className="app-shell">
<Sidebar screen={screen} onNavigate={setScreen} />
<main className="main" id="main-content">
{vaultLocked && (
<div className="lock-banner">
<Alert tone="warning" title="Vault is locked">
<div className="lock-banner-row">
<span>
Your profile keys are password-protected. Publishing and creating profiles need an
unlocked vault; everything else still works.
</span>
<Button variant="secondary" size="sm" onClick={() => setUnlockOpen(true)}>
<Icon name="shield" size={15} />
Unlock vault
</Button>
</div>
</Alert>
</div>
)}
{screen === 'home' && (
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
)}
@ -51,6 +73,7 @@ function Shell() {
{screen === 'settings' && <SettingsScreen />}
</main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
</div>
);
}

View file

@ -0,0 +1,84 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useApp } from '../state/AppProvider';
import { Button } from './Button';
import { ErrorText } from './ErrorText';
import { Modal } from './Modal';
interface UnlockModalProps {
open: boolean;
onClose: () => void;
}
export function UnlockModal({ open, onClose }: UnlockModalProps) {
const { unlockVault } = useApp();
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const errorId = 'unlock-vault-error';
useEffect(() => {
if (open) {
setPassword('');
setError(null);
setBusy(false);
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [open]);
const canSubmit = password.length > 0 && !busy;
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) {
return;
}
setError(null);
setBusy(true);
try {
await unlockVault(password);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setPassword('');
setBusy(false);
inputRef.current?.focus();
}
};
return (
<Modal open={open} title="Unlock your vault" onClose={onClose}>
<form onSubmit={onSubmit} noValidate>
<p>
Your profile keys are protected by a password. Enter it to unlock this session the
password itself is never saved.
</p>
<div className="field">
<label htmlFor="unlock-password">Password</label>
<input
ref={inputRef}
id="unlock-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
disabled={busy}
/>
{error && <ErrorText id={errorId}>{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>
</Modal>
);
}

View file

@ -0,0 +1,178 @@
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { useApp } from '../state/AppProvider';
import { Button } from './Button';
import { ErrorText } from './ErrorText';
import { Modal } from './Modal';
export type VaultPasswordMode = 'set' | 'change' | 'remove';
interface VaultPasswordModalProps {
open: boolean;
mode: VaultPasswordMode;
onClose: () => void;
}
export function VaultPasswordModal({ open, mode, onClose }: VaultPasswordModalProps) {
const { setVaultPassword, removeVaultPassword } = useApp();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const firstRef = useRef<HTMLInputElement>(null);
const errorId = 'vault-password-error';
useEffect(() => {
if (open) {
setCurrent('');
setNext('');
setConfirm('');
setError(null);
setMessage(null);
setBusy(false);
const frame = requestAnimationFrame(() => firstRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
return undefined;
}, [open, mode]);
const isRemove = mode === 'remove';
const needsCurrent = mode === 'change' || mode === 'remove';
const canSubmit =
!busy && (isRemove ? current.length > 0 : next.length > 0 && confirm.length > 0);
const title = isRemove
? 'Remove vault password'
: mode === 'change'
? 'Change vault password'
: 'Encrypt your vault';
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit) {
return;
}
setError(null);
setMessage(null);
if (!isRemove && next !== confirm) {
setError('The passwords do not match.');
return;
}
setBusy(true);
try {
if (isRemove) {
await removeVaultPassword(current);
setMessage('Password removed. Keys are stored in plaintext again.');
} else {
await setVaultPassword(needsCurrent ? current : null, next);
setMessage('Vault password set. Your stored keys are now encrypted.');
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<Modal open={open} title={title} onClose={onClose}>
<form onSubmit={onSubmit} noValidate>
{isRemove ? (
<p>
This removes password protection and stores your secret keys in plaintext on this
computer, readable by anyone with access to your user account.
</p>
) : mode === 'change' ? (
<p>
Your keys will be re-encrypted with the new password. The old password will stop working
immediately.
</p>
) : (
<p>
Your profile keys will be encrypted with AES-256 before being saved to disk. You will
need this password every time you use the app, so keep it safe.
</p>
)}
{needsCurrent && (
<div className="field">
<label htmlFor="vault-current-password">Current password</label>
<input
ref={firstRef}
id="vault-current-password"
type="password"
value={current}
onChange={(event) => setCurrent(event.target.value)}
autoComplete="current-password"
aria-describedby={isRemove && error ? errorId : undefined}
aria-invalid={isRemove && error ? true : undefined}
disabled={busy}
/>
{isRemove && error && <ErrorText id={errorId}>{error}</ErrorText>}
</div>
)}
{!isRemove && (
<>
<div className="field">
<label htmlFor="vault-new-password">
{mode === 'change' ? 'New password' : 'Password'}
</label>
<input
ref={needsCurrent ? undefined : firstRef}
id="vault-new-password"
type="password"
value={next}
onChange={(event) => setNext(event.target.value)}
autoComplete="new-password"
disabled={busy}
/>
</div>
<div className="field">
<label htmlFor="vault-confirm-password">Repeat password</label>
<input
id="vault-confirm-password"
type="password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
autoComplete="new-password"
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
disabled={busy}
/>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
<p className="hint">At least 8 characters.</p>
</div>
</>
)}
{message && (
<div className="alert alert-success" role="status">
<div className="alert-body">{message}</div>
</div>
)}
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={busy}>
{message ? 'Close' : 'Cancel'}
</Button>
{!message && (
<Button
variant={isRemove ? 'danger' : 'primary'}
type="submit"
loading={busy}
disabled={!canSubmit}
>
{isRemove
? 'Remove password'
: mode === 'change'
? 'Change password'
: 'Encrypt vault'}
</Button>
)}
</div>
</form>
</Modal>
);
}

View file

@ -51,5 +51,13 @@ export const api = {
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => call<Settings>('settings_update', patch),
backupNow: () => call<{ backup_path: string }>('backup_now'),
setVaultPassword: (currentPassword: string | null, newPassword: string) =>
call<AppState>('set_vault_password', {
current_password: currentPassword,
new_password: newPassword,
}),
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
lockVault: () => call<AppState>('lock_vault'),
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
copyText: (text: string) => window.backend.copyText(text),
};

View file

@ -48,6 +48,8 @@ export interface AppState {
vault_path: string;
settings_path: string;
encrypted_storage: boolean;
/** True when the vault is encrypted and has not been unlocked this session. */
vault_locked: boolean;
migrated_from: string | null;
active_profile: ProfileSummary | null;
profiles: ProfileSummary[];

View file

@ -4,12 +4,14 @@ import { Button } from '../components/Button';
import { CopyButton } from '../components/CopyButton';
import { Icon } from '../components/Icon';
import { Toggle } from '../components/Toggle';
import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal';
import type { Theme } from '../lib/types';
import { useApp } from '../state/AppProvider';
export function SettingsScreen() {
const { state, updateSettings, backupNow } = useApp();
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null);
const settings = state?.settings;
const vaultPath = state?.vault_path ?? '';
@ -111,13 +113,34 @@ export function SettingsScreen() {
Your profiles, including private keys, are stored in this file. It is readable only by
your user account.
</p>
{!encrypted && (
{encrypted ? (
<Alert tone="success" title="Storage is encrypted">
Profile keys are protected with a password (AES-256). You unlock the vault at the
start of each session.
</Alert>
) : (
<Alert tone="warning" title="Storage is not encrypted">
Profile data is saved in plaintext on this computer (as in the original CLI). Anyone
with access to your user account can read your keys. A password-encrypted vault is
planned for a future version.
Profile keys are saved in plaintext on this computer, readable by anyone with access
to your user account. Protect them with a password.
</Alert>
)}
<div className="settings-inline">
<Button
variant="secondary"
onClick={() => setPasswordModal(encrypted ? 'change' : 'set')}
>
<Icon name="shield" size={16} />
{encrypted ? 'Change password' : 'Protect with a password'}
</Button>
{encrypted && (
<Button variant="ghost" onClick={() => setPasswordModal('remove')}>
Remove password
</Button>
)}
</div>
<div className="settings-inline">
<Button variant="secondary" onClick={() => void onBackup()}>
<Icon name="shield" size={16} />
@ -163,6 +186,11 @@ export function SettingsScreen() {
</div>
</section>
</div>
<VaultPasswordModal
open={passwordModal !== null}
mode={passwordModal ?? 'set'}
onClose={() => setPasswordModal(null)}
/>
</div>
);
}

View file

@ -43,6 +43,10 @@ interface AppContextValue {
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => Promise<Settings>;
backupNow: () => Promise<{ backup_path: string }>;
setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise<AppState>;
unlockVault: (password: string) => Promise<AppState>;
lockVault: () => Promise<AppState>;
removeVaultPassword: (password: string) => Promise<AppState>;
copyText: (text: string) => Promise<void>;
}
@ -132,6 +136,27 @@ export function AppProvider({ children }: { children: ReactNode }) {
);
const backupNow = useCallback(() => api.backupNow(), []);
const applyState = useCallback(async (fresh: Promise<AppState>) => {
const next = await fresh;
setState(next);
return next;
}, []);
const setVaultPassword = useCallback(
(currentPassword: string | null, newPassword: string) =>
applyState(api.setVaultPassword(currentPassword, newPassword)),
[applyState],
);
const unlockVault = useCallback(
(password: string) => applyState(api.unlockVault(password)),
[applyState],
);
const lockVault = useCallback(() => applyState(api.lockVault()), [applyState]);
const removeVaultPassword = useCallback(
(password: string) => applyState(api.removeVaultPassword(password)),
[applyState],
);
const copyText = useCallback((text: string) => api.copyText(text), []);
useThemeSync(state?.settings.theme);
@ -154,6 +179,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayTest,
updateSettings,
backupNow,
setVaultPassword,
unlockVault,
lockVault,
removeVaultPassword,
copyText,
}),
[
@ -173,6 +202,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayTest,
updateSettings,
backupNow,
setVaultPassword,
unlockVault,
lockVault,
removeVaultPassword,
copyText,
],
);

View file

@ -540,6 +540,7 @@ a {
input[type='text'],
input[type='search'],
input[type='password'],
select,
textarea {
font: inherit;
@ -552,6 +553,8 @@ textarea {
}
input[type='text']:focus-visible,
input[type='search']:focus-visible,
input[type='password']:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: none;
@ -1243,6 +1246,23 @@ select {
padding: 12px 14px;
}
.lock-banner {
padding: 14px 22px 0;
}
.lock-banner .alert {
max-width: 1080px;
margin: 0 auto;
}
.lock-banner-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
}
.path-row > div {
min-width: 0;
}

View file

@ -0,0 +1,149 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import App from '../App';
import { SettingsScreen } from '../screens/SettingsScreen';
import { makeState } from './apiMock';
import { createFakeBackend, installFakeBackend } from './fakeBackend';
import { renderWithApp } from './render';
function renderApp(backend: ReturnType<typeof createFakeBackend>) {
installFakeBackend(backend);
return { user: userEvent.setup(), backend };
}
describe('vault encryption', () => {
describe('SettingsScreen', () => {
it('offers to protect an unencrypted vault', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
expect(await screen.findByText('Storage is not encrypted')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Protect with a password/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Change password/i })).not.toBeInTheDocument();
});
it('encrypts the vault through the modal', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Protect with a password/i }));
expect(await screen.findByRole('dialog', { name: 'Encrypt your vault' })).toBeInTheDocument();
await user.type(screen.getByLabelText('Password'), 'correct horse');
await user.type(screen.getByLabelText('Repeat password'), 'correct horse');
await user.click(screen.getByRole('button', { name: /Encrypt vault/i }));
await waitFor(() => {
expect(backend.state.encrypted_storage).toBe(true);
});
expect(backend.state.vault_locked).toBe(false);
expect(
await screen.findByText(/Vault password set. Your stored keys are now encrypted/),
).toBeInTheDocument();
});
it('rejects mismatched passwords', async () => {
const backend = createFakeBackend();
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Protect with a password/i }));
await user.type(screen.getByLabelText('Password'), 'one password');
await user.type(screen.getByLabelText('Repeat password'), 'another password');
await user.click(screen.getByRole('button', { name: /Encrypt vault/i }));
expect(await screen.findByText('The passwords do not match.')).toBeInTheDocument();
expect(backend.state.encrypted_storage).toBe(false);
});
it('shows change and remove actions for an encrypted vault', async () => {
const backend = createFakeBackend(
makeState({ encrypted_storage: true, vault_locked: false }),
);
installFakeBackend(backend);
renderWithApp(<SettingsScreen />);
expect(await screen.findByText('Storage is encrypted')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Change password/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Remove password/i })).toBeInTheDocument();
});
it('removes password protection', async () => {
const backend = createFakeBackend(
makeState({ encrypted_storage: true, vault_locked: false }),
);
installFakeBackend(backend);
const user = userEvent.setup();
renderWithApp(<SettingsScreen />);
await user.click(await screen.findByRole('button', { name: /Remove password/i }));
const dialog = await screen.findByRole('dialog', { name: 'Remove vault password' });
await user.type(screen.getByLabelText('Current password'), 'correct horse');
await user.click(within(dialog).getByRole('button', { name: 'Remove password' }));
await waitFor(() => {
expect(backend.state.encrypted_storage).toBe(false);
});
expect(
await screen.findByText(/Password removed. Keys are stored in plaintext again/),
).toBeInTheDocument();
});
});
describe('App unlock flow', () => {
it('shows an unlock banner for a locked vault and unlocks it', async () => {
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
const { user } = renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
expect(screen.getByText('Vault is locked')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /Unlock vault/i }));
const dialog = await screen.findByRole('dialog', { name: 'Unlock your vault' });
await user.type(screen.getByLabelText('Password'), 'correct horse');
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
await waitFor(() => {
expect(backend.state.vault_locked).toBe(false);
});
await waitFor(() => {
expect(screen.queryByText('Vault is locked')).not.toBeInTheDocument();
});
expect(dialog).not.toBeInTheDocument();
});
it('keeps the banner 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.' };
const { user } = renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
await user.click(screen.getByRole('button', { name: /Unlock vault/i }));
const dialog = await screen.findByRole('dialog', { name: 'Unlock your vault' });
await user.type(screen.getByLabelText('Password'), 'wrong password');
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
expect(await screen.findByText('The password is not correct.')).toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Unlock your vault' })).toBeInTheDocument();
expect(screen.getByText('Vault is locked')).toBeInTheDocument();
});
it('does not show the banner for an unlocked vault', async () => {
const backend = createFakeBackend(
makeState({ encrypted_storage: true, vault_locked: false }),
);
renderApp(backend);
render(<App />);
await screen.findByRole('heading', { name: 'Home' });
expect(screen.queryByText('Vault is locked')).not.toBeInTheDocument();
});
});
});

View file

@ -37,6 +37,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
encrypted_storage: false,
vault_locked: false,
migrated_from: null,
active_profile: alice,
profiles: [alice, bob],
@ -85,6 +86,10 @@ export interface ApiMock {
relayTest: ReturnType<typeof vi.fn>;
settingsUpdate: ReturnType<typeof vi.fn>;
backupNow: ReturnType<typeof vi.fn>;
setVaultPassword: ReturnType<typeof vi.fn>;
unlockVault: ReturnType<typeof vi.fn>;
lockVault: ReturnType<typeof vi.fn>;
removeVaultPassword: ReturnType<typeof vi.fn>;
copyText: ReturnType<typeof vi.fn>;
};
/** Current state object backing init/getState. */
@ -159,6 +164,18 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
backupNow: vi.fn(async () => ({
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1',
})),
setVaultPassword: vi.fn(async () => ({
...state,
encrypted_storage: true,
vault_locked: false,
})),
unlockVault: vi.fn(async () => ({ ...state, vault_locked: false })),
lockVault: vi.fn(async () => ({ ...state, vault_locked: true })),
removeVaultPassword: vi.fn(async () => ({
...state,
encrypted_storage: false,
vault_locked: false,
})),
copyText: vi.fn(async () => undefined),
};

View file

@ -175,6 +175,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'backup_now':
return { backup_path: `${state.vault_path}.backup-1` };
case 'set_vault_password': {
const next: AppState = { ...state, encrypted_storage: true, vault_locked: false };
backend.setState(next);
return next;
}
case 'unlock_vault': {
if (!state.encrypted_storage) {
throw new Error('Your vault is not encrypted.');
}
const next: AppState = { ...state, vault_locked: false };
backend.setState(next);
return next;
}
case 'lock_vault': {
if (!state.encrypted_storage) {
throw new Error('Your vault is not encrypted.');
}
const next: AppState = { ...state, vault_locked: true };
backend.setState(next);
return next;
}
case 'remove_vault_password': {
const next: AppState = { ...state, encrypted_storage: false, vault_locked: false };
backend.setState(next);
return next;
}
default:
throw new Error(`Unknown method: ${method}`);
}

View file

@ -1,14 +1,22 @@
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::Serialize;
use crate::crypto::{self, VaultKey};
use crate::errors::AppError;
use crate::profiles::{self, ProfileSummary};
use crate::settings::Settings;
use crate::vault::{self, Vault};
use crate::vault::{self, KdfParams, StoredProfile, Vault, VaultCrypto};
/// Minimum password length accepted when encrypting the vault.
pub const MIN_PASSWORD_LEN: usize = 8;
/// Shared application state used by both the CLI and the IPC server.
pub struct App {
pub vault: Vault,
pub settings: Settings,
/// Derived vault key, present only while the encrypted vault is unlocked.
unlock_key: Option<VaultKey>,
}
/// Snapshot of everything the UI needs, containing no secret keys.
@ -18,6 +26,8 @@ pub struct AppStateView {
pub vault_path: String,
pub settings_path: String,
pub encrypted_storage: bool,
/// True when the vault is encrypted and has not been unlocked this session.
pub vault_locked: bool,
pub migrated_from: Option<String>,
pub active_profile: Option<ProfileSummary>,
pub profiles: Vec<ProfileSummary>,
@ -30,6 +40,7 @@ impl App {
Ok(Self {
vault: vault::load_vault()?,
settings: vault::load_settings()?,
unlock_key: None,
})
}
@ -41,13 +52,132 @@ impl App {
vault::save_settings(&self.settings)
}
/// The derived vault key, if the vault is encrypted and currently unlocked.
pub fn vault_key(&self) -> Option<&VaultKey> {
self.unlock_key.as_ref()
}
/// True when the vault is password-protected and has not been unlocked.
pub fn is_locked(&self) -> bool {
self.vault.is_encrypted() && self.unlock_key.is_none()
}
/// Verify a password and keep the derived key in memory for the session.
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
let crypto = self
.vault
.crypto
.as_ref()
.ok_or_else(|| AppError::config("Your vault is not encrypted."))?;
let key = derive_with(crypto, password)?;
if !crypto::verify(&key, &crypto.verifier) {
return Err(AppError::wrong_password());
}
self.unlock_key = Some(key);
Ok(())
}
/// Drop the derived key, re-locking the vault for the session.
pub fn lock(&mut self) {
self.unlock_key = None;
}
/// Protect the vault with `new_password`, re-encrypting every stored key.
///
/// `current_password` must be supplied when the vault is already encrypted.
/// The new key is kept in memory, so the vault ends the call unlocked.
pub fn set_password(
&mut self,
current_password: Option<&str>,
new_password: &str,
) -> Result<(), AppError> {
validate_new_password(new_password)?;
// Resolve the key currently protecting the stored keys, if any.
let previous_key = match self.vault.crypto.as_ref() {
Some(crypto) => {
let current = current_password.ok_or_else(|| {
AppError::config("Enter your current password to change the vault password.")
})?;
let key = derive_with(crypto, current)?;
if !crypto::verify(&key, &crypto.verifier) {
return Err(AppError::wrong_password());
}
Some(key)
}
None => None,
};
let salt = crypto::generate_salt()?;
let new_key = crypto::derive_key(
new_password,
&salt,
crypto::KDF_M_COST,
crypto::KDF_T_COST,
crypto::KDF_P_COST,
)?;
let mut encrypted = Vec::with_capacity(self.vault.profiles.len());
for profile in &self.vault.profiles {
let plaintext = match &previous_key {
Some(key) => crypto::decrypt_secret(key, &profile.secret_key)?,
None => profile.secret_key.clone(),
};
encrypted.push(StoredProfile {
secret_key: crypto::encrypt_secret(&new_key, &plaintext)?,
..profile.clone()
});
}
self.vault.profiles = encrypted;
self.vault.crypto = Some(VaultCrypto {
kdf: KdfParams {
algorithm: "argon2id".to_string(),
salt: B64.encode(salt),
m_cost: crypto::KDF_M_COST,
t_cost: crypto::KDF_T_COST,
p_cost: crypto::KDF_P_COST,
},
verifier: crypto::make_verifier(&new_key)?,
});
self.unlock_key = Some(new_key);
Ok(())
}
/// Remove password protection, restoring plaintext secret keys.
pub fn remove_password(&mut self, current_password: &str) -> Result<(), AppError> {
let crypto = self
.vault
.crypto
.as_ref()
.ok_or_else(|| AppError::config("Your vault is not encrypted."))?;
let key = derive_with(crypto, current_password)?;
if !crypto::verify(&key, &crypto.verifier) {
return Err(AppError::wrong_password());
}
let mut plain = Vec::with_capacity(self.vault.profiles.len());
for profile in &self.vault.profiles {
let plaintext = crypto::decrypt_secret(&key, &profile.secret_key)?;
plain.push(StoredProfile {
secret_key: plaintext,
..profile.clone()
});
}
self.vault.profiles = plain;
self.vault.crypto = None;
self.unlock_key = None;
Ok(())
}
/// A safe view of current state, suitable for sending to a UI.
pub fn state_view(&self) -> AppStateView {
AppStateView {
version: env!("CARGO_PKG_VERSION"),
vault_path: vault::vault_path().to_string_lossy().into_owned(),
settings_path: vault::settings_path().to_string_lossy().into_owned(),
encrypted_storage: vault::is_encrypted(),
encrypted_storage: self.vault.is_encrypted(),
vault_locked: self.is_locked(),
migrated_from: self.vault.migrated_from.clone(),
active_profile: profiles::active_summary(&self.vault),
profiles: profiles::summaries(&self.vault),
@ -55,3 +185,224 @@ impl App {
}
}
}
/// Reject weak new passwords with a clear, early message.
fn validate_new_password(password: &str) -> Result<(), AppError> {
if password.len() < MIN_PASSWORD_LEN {
return Err(AppError::config(format!(
"The password must be at least {MIN_PASSWORD_LEN} characters long."
)));
}
Ok(())
}
/// Derive a key using the KDF parameters stored in the vault.
fn derive_with(crypto: &VaultCrypto, password: &str) -> Result<VaultKey, AppError> {
let salt = vault::decode_salt(&crypto.kdf.salt)?;
crypto::derive_key(
password,
&salt,
crypto.kdf.m_cost,
crypto.kdf.t_cost,
crypto.kdf.p_cost,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::ErrorKind;
use crate::profiles;
fn plaintext_vault() -> Vault {
let mut vault = Vault::empty();
profiles::create_profile(&mut vault, "Alice".to_string(), None).unwrap();
profiles::create_profile(&mut vault, "Bob".to_string(), None).unwrap();
vault
}
fn sample_app() -> App {
App {
vault: plaintext_vault(),
settings: Settings::default(),
unlock_key: None,
}
}
#[test]
fn set_password_encrypts_every_secret() {
let mut app = sample_app();
let plaintexts: Vec<String> = app
.vault
.profiles
.iter()
.map(|p| p.secret_key.clone())
.collect();
app.set_password(None, "correct horse battery staple")
.unwrap();
assert!(app.vault.is_encrypted());
assert!(!app.is_locked(), "vault is unlocked right after encrypting");
assert!(app.vault_key().is_some());
assert!(app.vault.crypto.as_ref().is_some());
for (stored, original) in app.vault.profiles.iter().zip(&plaintexts) {
assert_ne!(
stored.secret_key, *original,
"secret must no longer be plaintext"
);
assert!(
!stored.secret_key.contains(&stored.public_key),
"ciphertext must not leak the key material"
);
}
// The stored vault file must not contain any plaintext key.
let json = serde_json::to_string(&app.vault).unwrap();
assert!(!json.contains(&plaintexts[0]));
}
#[test]
fn set_password_rejects_short_passwords() {
let mut app = sample_app();
let err = app.set_password(None, "short").expect_err("must reject");
assert_eq!(err.kind(), ErrorKind::Config);
}
#[test]
fn unlock_roundtrip_with_wrong_then_right_password() {
let mut app = sample_app();
app.set_password(None, "correct horse battery staple")
.unwrap();
app.lock();
assert!(app.is_locked());
let err = app.unlock("not the password").expect_err("wrong password");
assert_eq!(err.kind(), ErrorKind::WrongPassword);
assert!(app.is_locked());
app.unlock("correct horse battery staple").unwrap();
assert!(!app.is_locked());
assert!(app.vault_key().is_some());
}
#[test]
fn locked_vault_rejects_key_creation() {
let mut app = sample_app();
app.set_password(None, "correct horse battery staple")
.unwrap();
app.lock();
let key = app.vault_key().copied();
let err = profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref())
.expect_err("locked vault must reject new profiles");
assert_eq!(err.kind(), ErrorKind::VaultLocked);
}
#[test]
fn create_profile_encrypts_new_keys_when_unlocked() {
let mut app = sample_app();
app.set_password(None, "correct horse battery staple")
.unwrap();
let key = app.vault_key().copied();
profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref()).unwrap();
let created = app.vault.profiles.last().unwrap();
assert_ne!(
created.secret_key.len(),
64,
"stored secret should be encrypted"
);
let decrypted =
crypto::decrypt_secret(app.vault_key().unwrap(), &created.secret_key).unwrap();
assert_eq!(decrypted.len(), 64);
}
#[test]
fn remove_password_restores_plaintext() {
let mut app = sample_app();
let plaintexts: Vec<String> = app
.vault
.profiles
.iter()
.map(|p| p.secret_key.clone())
.collect();
app.set_password(None, "correct horse battery staple")
.unwrap();
app.remove_password("correct horse battery staple").unwrap();
assert!(!app.vault.is_encrypted());
assert!(!app.is_locked());
assert!(app.vault_key().is_none());
for (stored, original) in app.vault.profiles.iter().zip(&plaintexts) {
assert_eq!(stored.secret_key, *original, "keys must be restored");
}
}
#[test]
fn remove_password_requires_correct_password() {
let mut app = sample_app();
app.set_password(None, "correct horse battery staple")
.unwrap();
let err = app.remove_password("wrong").expect_err("wrong password");
assert_eq!(err.kind(), ErrorKind::WrongPassword);
assert!(app.vault.is_encrypted());
}
#[test]
fn changing_password_invalidates_the_old_one() {
let mut app = sample_app();
app.set_password(None, "correct horse battery staple")
.unwrap();
app.set_password(Some("correct horse battery staple"), "new password 123")
.unwrap();
app.lock();
let err = app
.unlock("correct horse battery staple")
.expect_err("old password must not work");
assert_eq!(err.kind(), ErrorKind::WrongPassword);
app.unlock("new password 123").unwrap();
assert!(!app.is_locked());
}
#[test]
fn changing_password_requires_current() {
let mut app = sample_app();
app.set_password(None, "correct horse battery staple")
.unwrap();
let err = app
.set_password(None, "brand new password")
.expect_err("current required");
assert_eq!(err.kind(), ErrorKind::Config);
}
#[test]
fn state_view_reports_locked_when_encrypted_and_locked() {
let mut app = sample_app();
let view = app.state_view();
assert!(!view.encrypted_storage);
assert!(!view.vault_locked);
app.set_password(None, "correct horse battery staple")
.unwrap();
app.lock();
let view = app.state_view();
assert!(view.encrypted_storage);
assert!(view.vault_locked);
}
#[test]
fn profiles_remain_readable_while_locked() {
let mut app = sample_app();
app.set_password(None, "correct horse battery staple")
.unwrap();
app.lock();
let view = app.state_view();
assert_eq!(view.profiles.len(), 2);
assert!(view.profiles.iter().all(|p| p.npub.starts_with("npub1")));
}
}

188
src/crypto.rs Normal file
View file

@ -0,0 +1,188 @@
//! Password-based vault encryption.
//!
//! Secret keys are encrypted individually with AES-256-GCM under a key derived
//! from the user's password with Argon2id. A known-plaintext verifier stored
//! in the vault lets the app check a password without decrypting every key.
//!
//! Nothing in this module ever touches the network, and the derived key is
//! kept only in memory by the caller.
use argon2::{Algorithm, Argon2, Params, Version};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use getrandom::getrandom;
use crate::errors::AppError;
/// Derived symmetric key length in bytes (AES-256).
pub const KEY_LEN: usize = 32;
/// Salt length in bytes.
pub const SALT_LEN: usize = 16;
/// AES-GCM nonce length in bytes.
pub const NONCE_LEN: usize = 12;
/// Argon2id memory cost in KiB (RFC 9106 recommendation).
pub const KDF_M_COST: u32 = 19 * 1024;
/// Argon2id time cost (iterations).
pub const KDF_T_COST: u32 = 2;
/// Argon2id parallelism.
pub const KDF_P_COST: u32 = 1;
/// The in-memory key that unlocks an encrypted vault.
pub type VaultKey = [u8; KEY_LEN];
/// Fill a fixed-size buffer with cryptographically secure randomness.
pub fn random_bytes<const N: usize>() -> Result<[u8; N], AppError> {
let mut buf = [0u8; N];
getrandom(&mut buf)
.map_err(|e| AppError::internal(format!("Could not generate randomness: {e}")))?;
Ok(buf)
}
/// Generate a fresh random salt.
pub fn generate_salt() -> Result<[u8; SALT_LEN], AppError> {
random_bytes()
}
/// Derive a 32-byte key from a password with Argon2id.
pub fn derive_key(
password: &str,
salt: &[u8],
m_cost: u32,
t_cost: u32,
p_cost: u32,
) -> Result<VaultKey, AppError> {
let params = Params::new(m_cost, t_cost, p_cost, Some(KEY_LEN))
.map_err(|e| AppError::internal(format!("Invalid Argon2 parameters: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; KEY_LEN];
argon2
.hash_password_into(password.as_bytes(), salt, &mut key)
.map_err(|e| AppError::internal(format!("Could not derive a vault key: {e}")))?;
Ok(key)
}
/// Known plaintext used to verify a password without decrypting any keys.
const VERIFIER_PLAINTEXT: &[u8] = b"nost-feed-manager vault key v1";
/// Produce a base64 verifier blob bound to `key`.
pub fn make_verifier(key: &VaultKey) -> Result<String, AppError> {
let nonce = random_bytes::<NONCE_LEN>()?;
let ciphertext = encrypt(&nonce, key, VERIFIER_PLAINTEXT)?;
Ok(encode(&nonce, &ciphertext))
}
/// Check that `encoded` verifier matches `key`.
pub fn verify(key: &VaultKey, encoded: &str) -> bool {
match decrypt(key, encoded) {
Ok(plain) => plain == VERIFIER_PLAINTEXT,
Err(_) => false,
}
}
/// Encrypt a plaintext hex secret key, returning a base64 blob.
pub fn encrypt_secret(key: &VaultKey, plaintext_hex: &str) -> Result<String, AppError> {
let nonce = random_bytes::<NONCE_LEN>()?;
let ciphertext = encrypt(&nonce, key, plaintext_hex.as_bytes())?;
Ok(encode(&nonce, &ciphertext))
}
/// Decrypt a base64 secret-key blob back to plaintext hex.
pub fn decrypt_secret(key: &VaultKey, encoded: &str) -> Result<String, AppError> {
let plain = decrypt(key, encoded)?;
String::from_utf8(plain)
.map_err(|e| AppError::internal(format!("A decrypted key was not valid text: {e}")))
}
/// AES-256-GCM encrypt, returning nonce || ciphertext || tag.
fn encrypt(
nonce_bytes: &[u8; NONCE_LEN],
key: &VaultKey,
plaintext: &[u8],
) -> Result<Vec<u8>, AppError> {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| AppError::internal(format!("Could not initialise the cipher: {e}")))?;
cipher
.encrypt(Nonce::from_slice(nonce_bytes), plaintext)
.map_err(|_| AppError::storage("The vault could not be encrypted."))
}
/// AES-256-GCM decrypt of a nonce || ciphertext || tag blob.
fn decrypt(key: &VaultKey, encoded: &str) -> Result<Vec<u8>, AppError> {
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
let decoded = B64
.decode(encoded)
.map_err(|_| AppError::storage("The stored encrypted data is corrupt."))?;
if decoded.len() <= NONCE_LEN {
return Err(AppError::storage("The stored encrypted data is corrupt."));
}
let (nonce_bytes, ciphertext) = decoded.split_at(NONCE_LEN);
let cipher = Aes256Gcm::new_from_slice(key)
.map_err(|e| AppError::internal(format!("Could not initialise the cipher: {e}")))?;
cipher
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
.map_err(|_| AppError::storage("The vault password is incorrect or the data is corrupt."))
}
/// Base64-encode a nonce plus ciphertext as a single blob.
fn encode(nonce: &[u8; NONCE_LEN], ciphertext: &[u8]) -> String {
let mut combined = Vec::with_capacity(NONCE_LEN + ciphertext.len());
combined.extend_from_slice(nonce);
combined.extend_from_slice(ciphertext);
B64.encode(combined)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_roundtrip() {
let salt = generate_salt().unwrap();
let key = derive_key("hunter2", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
let secret = "00".repeat(32);
let blob = encrypt_secret(&key, &secret).unwrap();
assert_ne!(blob, secret);
assert_eq!(decrypt_secret(&key, &blob).unwrap(), secret);
}
#[test]
fn wrong_key_cannot_decrypt() {
let salt = generate_salt().unwrap();
let key = derive_key("correct horse", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
let other =
derive_key("battery staple", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
let blob = encrypt_secret(&key, "ff".repeat(32).as_str()).unwrap();
assert!(decrypt_secret(&other, &blob).is_err());
}
#[test]
fn verifier_matches_only_right_key() {
let salt = generate_salt().unwrap();
let key = derive_key("open sesame", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
let other = derive_key("wrong", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
let verifier = make_verifier(&key).unwrap();
assert!(verify(&key, &verifier));
assert!(!verify(&other, &verifier));
}
#[test]
fn same_password_different_salt_different_key() {
let key_a = derive_key("pw", &[1u8; SALT_LEN], KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
let key_b = derive_key("pw", &[2u8; SALT_LEN], KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
assert_ne!(key_a, key_b);
}
#[test]
fn corrupt_blob_is_an_error() {
let salt = generate_salt().unwrap();
let key = derive_key("pw", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
assert!(decrypt_secret(&key, "!!!not-base64!!!").is_err());
assert!(!verify(&key, "!!!not-base64!!!"));
}
}

View file

@ -29,6 +29,10 @@ pub enum ErrorKind {
EmptyNote,
/// Event signing failed.
SignFailed,
/// The vault is encrypted and has not been unlocked.
VaultLocked,
/// The supplied vault password is incorrect.
WrongPassword,
/// Invalid configuration or user input.
Config,
/// Unexpected internal failure.
@ -164,6 +168,17 @@ impl AppError {
Self::simple(ErrorKind::Config, message)
}
pub fn vault_locked() -> Self {
Self::simple(
ErrorKind::VaultLocked,
"Your vault is locked. Enter your password to unlock it.",
)
}
pub fn wrong_password() -> Self {
Self::simple(ErrorKind::WrongPassword, "The password is not correct.")
}
pub fn internal(details: impl fmt::Display) -> Self {
Self::with_details(
ErrorKind::Internal,

View file

@ -59,6 +59,22 @@ pub enum Request {
},
/// Create a timestamped backup of the vault file.
BackupNow,
/// Protect the vault with a password (or change it).
SetVaultPassword {
/// Required when the vault is already encrypted.
current_password: Option<String>,
new_password: String,
},
/// Verify a password and unlock the vault for this session.
UnlockVault {
password: String,
},
/// Drop the derived key, re-locking the vault.
LockVault,
/// Remove password protection entirely.
RemoveVaultPassword {
password: String,
},
}
/// A reply envelope carrying either data or a safe user-facing error.
@ -165,7 +181,8 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
Request::CreateProfile { label } => {
let label = normalise_label(&label);
let summary = profiles::create_profile(&mut app.vault, label)?;
let key = app.vault_key().copied();
let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?;
app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
@ -177,7 +194,9 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
}
Request::PublishNote { content } => {
let report = publish::publish_active(&app.vault, &app.settings, &content).await?;
let report =
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
.await?;
Ok(json!(report))
}
@ -211,6 +230,31 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
Ok(json!({ "backup_path": backup.to_string_lossy().into_owned() }))
}
Request::SetVaultPassword {
current_password,
new_password,
} => {
app.set_password(current_password.as_deref(), &new_password)?;
app.save_vault()?;
Ok(json!(app.state_view()))
}
Request::UnlockVault { password } => {
app.unlock(&password)?;
Ok(json!(app.state_view()))
}
Request::LockVault => {
app.lock();
Ok(json!(app.state_view()))
}
Request::RemoveVaultPassword { password } => {
app.remove_password(&password)?;
app.save_vault()?;
Ok(json!(app.state_view()))
}
Request::SettingsUpdate {
theme,
confirm_before_publish,

View file

@ -1,4 +1,5 @@
pub mod app;
pub mod crypto;
pub mod errors;
pub mod ipc;
pub mod profiles;

View file

@ -27,8 +27,14 @@ Commands:
settings set theme <light|dark|system>
settings set confirm <true|false>
settings set shorten <true|false>
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
info Show storage locations and version
serve Run the JSON-lines IPC server";
serve Run the JSON-lines IPC server
Passwords are read from the NFM_PASSWORD environment variable when set,
otherwise you are prompted. They are never accepted as command-line arguments.";
#[tokio::main]
async fn main() -> ExitCode {
@ -53,6 +59,9 @@ async fn main() -> ExitCode {
"publish" => cli_publish(&args).await,
"relays" => cli_relays(&args).await,
"settings" => cli_settings(&args),
"set-password" => cli_set_password(),
"remove-password" => cli_remove_password(),
"unlock" => cli_unlock(),
"info" => cli_info(),
"help" | "--help" | "-h" => {
println!("{USAGE}");
@ -86,8 +95,9 @@ fn cli_create(args: &[String]) -> Result<String, AppError> {
.get(2)
.cloned()
.unwrap_or_else(|| "New Profile".to_string());
let mut app = App::load()?;
let summary = profiles::create_profile(&mut app.vault, label)?;
let mut app = load_app_with_unlock()?;
let key = app.vault_key().copied();
let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?;
app.save_vault()?;
Ok(format!(
"Created profile \"{}\": {}",
@ -123,8 +133,9 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
let npub = &args[2];
let content = args[3..].join(" ");
let app = App::load()?;
let report = publish::publish_as(&app.vault, &app.settings, npub, &content).await?;
let app = load_app_with_unlock()?;
let report =
publish::publish_as(&app.vault, &app.settings, npub, &content, app.vault_key()).await?;
let mut lines = vec![format!("Published: {}", report.event_id)];
if let Some(failed) = report.failed.first() {
@ -250,6 +261,64 @@ fn parse_bool(value: &str) -> Result<bool, AppError> {
}
}
/// Load the app and, when the vault is encrypted, unlock it using the
/// password from `NFM_PASSWORD` or an interactive prompt.
fn load_app_with_unlock() -> Result<App, AppError> {
let mut app = App::load()?;
if app.is_locked() {
let password = prompt_password("Vault password: ")?;
app.unlock(&password)?;
}
Ok(app)
}
/// Read a password from the `NFM_PASSWORD` environment variable when set,
/// otherwise prompt on the terminal without echoing.
fn prompt_password(prompt: &str) -> Result<String, AppError> {
if let Ok(value) = std::env::var("NFM_PASSWORD") {
if !value.trim().is_empty() {
return Ok(value);
}
}
rpassword::prompt_password(prompt)
.map_err(|e| AppError::config(format!("Could not read a password from the terminal: {e}")))
}
fn cli_set_password() -> Result<String, AppError> {
let mut app = App::load()?;
let current = if app.vault.is_encrypted() {
Some(prompt_password("Current password: ")?)
} else {
None
};
let new = prompt_password("New password: ")?;
let confirm = prompt_password("Repeat new password: ")?;
if new != confirm {
return Err(AppError::config("The passwords do not match."));
}
app.set_password(current.as_deref(), &new)?;
app.save_vault()?;
Ok("Vault password set. Your stored keys are now encrypted.".to_string())
}
fn cli_remove_password() -> Result<String, AppError> {
let mut app = App::load()?;
let password = prompt_password("Current password: ")?;
app.remove_password(&password)?;
app.save_vault()?;
Ok("Vault encryption removed. Keys are stored in plaintext again.".to_string())
}
fn cli_unlock() -> Result<String, AppError> {
let mut app = App::load()?;
if !app.vault.is_encrypted() {
return Err(AppError::config("Your vault is not encrypted."));
}
let password = prompt_password("Vault password: ")?;
app.unlock(&password)?;
Ok("Vault unlocked.".to_string())
}
fn cli_info() -> Result<String, AppError> {
let app = App::load()?;
let mut lines = vec![
@ -260,7 +329,18 @@ fn cli_info() -> Result<String, AppError> {
"Settings file: {}",
vault::settings_path().to_string_lossy()
),
format!("Encrypted storage: {}", vault::is_encrypted()),
format!(
"Encrypted storage: {}",
if app.vault.is_encrypted() {
"yes"
} else {
"no"
}
),
format!(
"Vault locked: {}",
if app.is_locked() { "yes" } else { "no" }
),
];
if let Some(migrated) = &app.vault.migrated_from {
lines.push(format!("Migrated from: {migrated}"));

View file

@ -1,6 +1,7 @@
use nostr_sdk::prelude::*;
use serde::Serialize;
use crate::crypto::VaultKey;
use crate::errors::AppError;
use crate::vault::{unix_timestamp, StoredProfile, Vault};
@ -17,7 +18,18 @@ pub struct ProfileSummary {
/// Create a new profile, generating fresh keys. The new profile becomes the
/// active one when nothing is currently selected.
pub fn create_profile(vault: &mut Vault, label: String) -> Result<ProfileSummary, AppError> {
///
/// `key` must be the unlocked vault key when the vault is password-protected;
/// new keys are then encrypted before being stored.
pub fn create_profile(
vault: &mut Vault,
label: String,
key: Option<&VaultKey>,
) -> Result<ProfileSummary, AppError> {
if vault.is_encrypted() && key.is_none() {
return Err(AppError::vault_locked());
}
let keys = Keys::generate();
let secret_hex = ::hex::encode(keys.secret_key().to_secret_bytes());
@ -27,10 +39,15 @@ pub fn create_profile(vault: &mut Vault, label: String) -> Result<ProfileSummary
.map_err(|e| AppError::internal(format!("Could not encode the public key: {e}")))?;
let created_at = unix_timestamp()?;
let stored_secret = match &vault.crypto {
Some(_) => crate::crypto::encrypt_secret(key.expect("guarded above"), &secret_hex)?,
None => secret_hex,
};
let profile = StoredProfile {
label: label.clone(),
public_key: public_key.clone(),
secret_key: secret_hex,
secret_key: stored_secret,
created_at,
};
@ -106,6 +123,36 @@ pub fn active_secret_key(vault: &Vault) -> Result<&str, AppError> {
find_secret_key(vault, npub)
}
/// Return the plaintext hex secret key for a profile, decrypting it when the
/// vault is password-protected. When the vault is encrypted, `key` must be the
/// unlocked vault key; otherwise the operation is rejected as locked.
pub fn resolve_secret_key(
vault: &Vault,
npub: &str,
key: Option<&VaultKey>,
) -> Result<String, AppError> {
let stored = find_secret_key(vault, npub)?;
match &vault.crypto {
Some(_) => {
let key = key.ok_or_else(AppError::vault_locked)?;
crate::crypto::decrypt_secret(key, stored)
}
None => Ok(stored.to_string()),
}
}
/// Plaintext hex secret key for the active profile, decrypting when needed.
pub fn resolve_active_secret_key(
vault: &Vault,
key: Option<&VaultKey>,
) -> Result<String, AppError> {
let npub = vault
.active_profile
.as_deref()
.ok_or_else(AppError::no_active_profile)?;
resolve_secret_key(vault, npub, key)
}
/// 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)
@ -138,7 +185,8 @@ mod tests {
#[test]
fn create_profile_generates_valid_keys() {
let mut vault = Vault::empty();
let summary = create_profile(&mut vault, "Newbie".to_string()).expect("should create");
let summary =
create_profile(&mut vault, "Newbie".to_string(), None).expect("should create");
assert!(summary.npub.starts_with("npub1"));
assert_eq!(summary.label, "Newbie");
assert_eq!(vault.profiles.len(), 1);
@ -156,7 +204,7 @@ mod tests {
fn create_profile_keeps_existing_active() {
let mut vault = populated_vault();
vault.active_profile = Some("npub1alice".to_string());
let summary = create_profile(&mut vault, "Carol".to_string()).unwrap();
let summary = create_profile(&mut vault, "Carol".to_string(), None).unwrap();
assert!(!summary.is_active);
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
}

View file

@ -3,6 +3,7 @@ use std::time::Duration;
use nostr_sdk::prelude::*;
use serde::Serialize;
use crate::crypto::VaultKey;
use crate::errors::{AppError, ErrorKind};
use crate::profiles;
use crate::relays;
@ -44,27 +45,33 @@ impl PublishReport {
}
/// Publish a text note with the active profile.
///
/// `key` must be the unlocked vault key when the vault is password-protected.
pub async fn publish_active(
vault: &Vault,
settings: &Settings,
content: &str,
key: Option<&VaultKey>,
) -> Result<PublishReport, AppError> {
validate_content(content)?;
let secret_hex = profiles::active_secret_key(vault)?.to_string();
let secret_hex = profiles::resolve_active_secret_key(vault, key)?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await
}
/// Publish a text note as a specific profile (used by the CLI).
///
/// `key` must be the unlocked vault key when the vault is password-protected.
pub async fn publish_as(
vault: &Vault,
settings: &Settings,
npub: &str,
content: &str,
key: Option<&VaultKey>,
) -> Result<PublishReport, AppError> {
validate_content(content)?;
let secret_hex = profiles::find_secret_key(vault, npub)?.to_string();
let secret_hex = profiles::resolve_secret_key(vault, npub, key)?;
let secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await
@ -233,7 +240,7 @@ mod tests {
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, "hello"))
.block_on(publish_active(&vault, &settings, "hello", None))
.expect_err("no active profile must error");
assert_eq!(err.kind(), ErrorKind::NoActiveProfile);
}
@ -244,7 +251,7 @@ mod tests {
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_as(&vault, &settings, "npub1ghost", "hello"))
.block_on(publish_as(&vault, &settings, "npub1ghost", "hello", None))
.expect_err("missing profile must error");
assert_eq!(err.kind(), ErrorKind::ProfileNotFound);
}
@ -255,7 +262,7 @@ mod tests {
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, " "))
.block_on(publish_active(&vault, &settings, " ", None))
.expect_err("empty note must error");
assert_eq!(err.kind(), ErrorKind::EmptyNote);
}
@ -263,11 +270,11 @@ mod tests {
#[test]
fn publish_with_no_enabled_relays_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string()).unwrap();
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, "hello"))
.block_on(publish_active(&vault, &settings, "hello", None))
.expect_err("no relays must error");
assert_eq!(err.kind(), ErrorKind::NoEnabledRelays);
}
@ -275,16 +282,39 @@ mod tests {
#[test]
fn publish_with_invalid_stored_key_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string()).unwrap();
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
vault.profiles[0].secret_key = "zz-not-hex".to_string();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, "hello"))
.block_on(publish_active(&vault, &settings, "hello", None))
.expect_err("invalid key must error");
assert_eq!(err.kind(), ErrorKind::InvalidSecret);
}
#[test]
fn publish_locked_encrypted_vault_errors() {
let mut vault = Vault::empty();
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
vault.crypto = Some(crate::vault::VaultCrypto {
kdf: crate::vault::KdfParams {
algorithm: "argon2id".to_string(),
salt: "c2FsdA==".to_string(),
m_cost: 1,
t_cost: 1,
p_cost: 1,
},
verifier: "dmVyaWZpZXI=".to_string(),
});
vault.profiles[0].secret_key = "encrypted-blob".to_string();
let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime
.block_on(publish_active(&vault, &settings, "hello", None))
.expect_err("locked vault must error before any network work");
assert_eq!(err.kind(), ErrorKind::VaultLocked);
}
#[test]
fn publish_failed_error_message_is_concise() {
let err = AppError::publish_failed(vec![RelayFailure {

View file

@ -5,6 +5,8 @@ use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize};
use crate::errors::AppError;
@ -19,20 +21,46 @@ pub const SETTINGS_FILE_NAME: &str = "settings.json";
/// A profile stored on disk.
///
/// `secret_key` is stored as a plaintext hex string for now. The storage is
/// deliberately unencrypted in this iteration; it is kept behind the vault
/// module so that encryption can be added later without changing callers.
/// `secret_key` is stored as a plaintext hex string when the vault is not
/// encrypted, and as a base64 AES-256-GCM blob (nonce || ciphertext || tag)
/// when it is. The presence of `Vault.crypto` decides which. Storage stays
/// behind the vault module so callers never need to know the difference.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProfile {
pub label: String,
/// Bech32 `npub` of the profile.
pub public_key: String,
/// Hex-encoded secret key bytes.
/// Hex-encoded secret key bytes, or an encrypted blob when the vault is
/// password-protected.
pub secret_key: String,
/// Unix timestamp of creation.
pub created_at: u64,
}
/// KDF parameters that encrypted a vault. Stored so future key-derivation
/// choices remain compatible with already-encrypted vaults.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KdfParams {
/// KDF name, currently `argon2id`.
pub algorithm: String,
/// Base64 random salt.
pub salt: String,
/// Memory cost in KiB.
pub m_cost: u32,
/// Time cost (iterations).
pub t_cost: u32,
/// Parallelism.
pub p_cost: u32,
}
/// Metadata for a password-encrypted vault.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VaultCrypto {
pub kdf: KdfParams,
/// Base64 blob used to verify a supplied password.
pub verifier: String,
}
/// On-disk vault containing every stored profile plus the active selection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault {
@ -43,6 +71,9 @@ pub struct Vault {
/// `npub` of the profile that should stay selected across restarts.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_profile: Option<String>,
/// Present when the vault is protected by a password.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crypto: Option<VaultCrypto>,
pub profiles: Vec<StoredProfile>,
}
@ -53,6 +84,7 @@ impl Vault {
version: VAULT_VERSION,
migrated_from: None,
active_profile: None,
crypto: None,
profiles: Vec::new(),
}
}
@ -60,6 +92,11 @@ impl Vault {
pub fn has_profiles(&self) -> bool {
!self.profiles.is_empty()
}
/// Whether the vault is protected by a password.
pub fn is_encrypted(&self) -> bool {
self.crypto.is_some()
}
}
/// Unix timestamp in seconds, with an error instead of panicking.
@ -158,6 +195,7 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
version: VAULT_VERSION,
migrated_from: None,
active_profile: None,
crypto: None,
profiles,
});
}
@ -290,11 +328,10 @@ fn try_migrate_legacy_vault() -> Result<Option<Vault>, AppError> {
Ok(None)
}
/// Whether storage is currently encrypted. Always false in this iteration;
/// the vault is stored in plaintext and the limitation is disclosed in the
/// UI. Kept as a single source of truth so callers never assume otherwise.
pub fn is_encrypted() -> bool {
false
/// Decode a base64 salt string into raw bytes.
pub fn decode_salt(encoded: &str) -> Result<Vec<u8>, AppError> {
B64.decode(encoded)
.map_err(|e| AppError::vault_malformed(format!("Invalid vault salt: {e}")))
}
#[cfg(test)]