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", "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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.5" version = "1.1.5"
@ -21,6 +46,18 @@ dependencies = [
"memchr", "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]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.8" version = "0.7.8"
@ -136,6 +173,15 @@ version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -246,6 +292,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.11.1" version = "2.11.1"
@ -415,6 +470,16 @@ dependencies = [
"r-efi 6.0.0", "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]] [[package]]
name = "gloo-timers" name = "gloo-timers"
version = "0.3.0" version = "0.3.0"
@ -719,8 +784,13 @@ dependencies = [
name = "nostr-manager-backend" name = "nostr-manager-backend"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm",
"argon2",
"base64",
"getrandom 0.2.17",
"hex", "hex",
"nostr-sdk", "nostr-sdk",
"rpassword",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@ -838,6 +908,18 @@ dependencies = [
"universal-hash", "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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@ -997,6 +1079,27 @@ dependencies = [
"windows-sys 0.52.0", "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]] [[package]]
name = "rustls" name = "rustls"
version = "0.23.43" version = "0.23.43"
@ -1605,6 +1708,15 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"

View file

@ -10,3 +10,8 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] } uuid = { version = "1.0", features = ["v4"] }
hex = "0.4" 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 - Add, remove, enable/disable, and test relays
- Light / dark / system theme, configurable publish confirmation and key shortening - Light / dark / system theme, configurable publish confirmation and key shortening
- Back up your vault from the UI - Back up your vault from the UI
- Honest about security: the vault is stored in plaintext (as in the original CLI), readable - Password-protected vault: secret keys are encrypted at rest with AES-256-GCM under an
only by your user account; this is clearly disclosed in the app 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 ## 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 -- publish <npub> "Hello" # publish a text note
cargo run --release -- relays list|add|remove|enable|disable|test cargo run --release -- relays list|add|remove|enable|disable|test
cargo run --release -- settings get|set theme|confirm|shorten 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) 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 ## Storage and migration
- The vault (`profiles_vault.json`) and settings live in - 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 - 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 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. 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 - The vault is stored in plaintext until you set a password (Settings → Storage, or
can read them; a password-encrypted vault is planned for a future version. `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 ## Development
@ -122,7 +135,8 @@ cargo clippy --all-targets
``` ```
src/ Rust library + CLI + IPC server 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 errors.rs structured AppError
ipc.rs JSON-lines serve() loop and request/reply envelope ipc.rs JSON-lines serve() loop and request/reply envelope
main.rs CLI entry point main.rs CLI entry point
@ -130,7 +144,7 @@ src/ Rust library + CLI + IPC server
publish.rs note publishing with per-relay reports publish.rs note publishing with per-relay reports
relays.rs default relays, validation, connection tests relays.rs default relays, validation, connection tests
settings.rs theme and user preferences 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/ frontend/
electron/ Electron main + preload (backend spawn, IPC, clipboard) electron/ Electron main + preload (backend spawn, IPC, clipboard)
src/ React app (components, screens, state, styles) src/ React app (components, screens, state, styles)

View file

@ -2,6 +2,9 @@ import { useState } from 'react';
import { Sidebar } from './components/Sidebar'; import { Sidebar } from './components/Sidebar';
import { Spinner } from './components/Spinner'; import { Spinner } from './components/Spinner';
import { Alert } from './components/Alert'; 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 { HomeScreen } from './screens/HomeScreen';
import { ProfilesScreen } from './screens/ProfilesScreen'; import { ProfilesScreen } from './screens/ProfilesScreen';
import { ComposeScreen } from './screens/ComposeScreen'; import { ComposeScreen } from './screens/ComposeScreen';
@ -15,6 +18,7 @@ function Shell() {
const { state, loading, bootstrapError } = useApp(); const { state, loading, bootstrapError } = useApp();
const [screen, setScreen] = useState<Screen>('home'); const [screen, setScreen] = useState<Screen>('home');
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [unlockOpen, setUnlockOpen] = useState(false);
useThemeSync(state?.settings.theme); useThemeSync(state?.settings.theme);
@ -38,10 +42,28 @@ function Shell() {
); );
} }
const vaultLocked = state?.vault_locked ?? false;
return ( return (
<div className="app-shell"> <div className="app-shell">
<Sidebar screen={screen} onNavigate={setScreen} /> <Sidebar screen={screen} onNavigate={setScreen} />
<main className="main" id="main-content"> <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' && ( {screen === 'home' && (
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} /> <HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
)} )}
@ -51,6 +73,7 @@ function Shell() {
{screen === 'settings' && <SettingsScreen />} {screen === 'settings' && <SettingsScreen />}
</main> </main>
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} /> <CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
</div> </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'>>, patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => call<Settings>('settings_update', patch), ) => call<Settings>('settings_update', patch),
backupNow: () => call<{ backup_path: string }>('backup_now'), 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), copyText: (text: string) => window.backend.copyText(text),
}; };

View file

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

View file

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

View file

@ -43,6 +43,10 @@ interface AppContextValue {
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>, patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
) => Promise<Settings>; ) => Promise<Settings>;
backupNow: () => Promise<{ backup_path: string }>; 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>; copyText: (text: string) => Promise<void>;
} }
@ -132,6 +136,27 @@ export function AppProvider({ children }: { children: ReactNode }) {
); );
const backupNow = useCallback(() => api.backupNow(), []); 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), []); const copyText = useCallback((text: string) => api.copyText(text), []);
useThemeSync(state?.settings.theme); useThemeSync(state?.settings.theme);
@ -154,6 +179,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayTest, relayTest,
updateSettings, updateSettings,
backupNow, backupNow,
setVaultPassword,
unlockVault,
lockVault,
removeVaultPassword,
copyText, copyText,
}), }),
[ [
@ -173,6 +202,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
relayTest, relayTest,
updateSettings, updateSettings,
backupNow, backupNow,
setVaultPassword,
unlockVault,
lockVault,
removeVaultPassword,
copyText, copyText,
], ],
); );

View file

@ -540,6 +540,7 @@ a {
input[type='text'], input[type='text'],
input[type='search'], input[type='search'],
input[type='password'],
select, select,
textarea { textarea {
font: inherit; font: inherit;
@ -552,6 +553,8 @@ textarea {
} }
input[type='text']:focus-visible, input[type='text']:focus-visible,
input[type='search']:focus-visible,
input[type='password']:focus-visible,
select:focus-visible, select:focus-visible,
textarea:focus-visible { textarea:focus-visible {
outline: none; outline: none;
@ -1243,6 +1246,23 @@ select {
padding: 12px 14px; 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 { .path-row > div {
min-width: 0; 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', vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json', settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
encrypted_storage: false, encrypted_storage: false,
vault_locked: false,
migrated_from: null, migrated_from: null,
active_profile: alice, active_profile: alice,
profiles: [alice, bob], profiles: [alice, bob],
@ -85,6 +86,10 @@ export interface ApiMock {
relayTest: ReturnType<typeof vi.fn>; relayTest: ReturnType<typeof vi.fn>;
settingsUpdate: ReturnType<typeof vi.fn>; settingsUpdate: ReturnType<typeof vi.fn>;
backupNow: 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>; copyText: ReturnType<typeof vi.fn>;
}; };
/** Current state object backing init/getState. */ /** Current state object backing init/getState. */
@ -159,6 +164,18 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
backupNow: vi.fn(async () => ({ backupNow: vi.fn(async () => ({
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1', 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), copyText: vi.fn(async () => undefined),
}; };

View file

@ -175,6 +175,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
case 'backup_now': case 'backup_now':
return { backup_path: `${state.vault_path}.backup-1` }; 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: default:
throw new Error(`Unknown method: ${method}`); 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 serde::Serialize;
use crate::crypto::{self, VaultKey};
use crate::errors::AppError; use crate::errors::AppError;
use crate::profiles::{self, ProfileSummary}; use crate::profiles::{self, ProfileSummary};
use crate::settings::Settings; 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. /// Shared application state used by both the CLI and the IPC server.
pub struct App { pub struct App {
pub vault: Vault, pub vault: Vault,
pub settings: Settings, 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. /// Snapshot of everything the UI needs, containing no secret keys.
@ -18,6 +26,8 @@ pub struct AppStateView {
pub vault_path: String, pub vault_path: String,
pub settings_path: String, pub settings_path: String,
pub encrypted_storage: bool, 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 migrated_from: Option<String>,
pub active_profile: Option<ProfileSummary>, pub active_profile: Option<ProfileSummary>,
pub profiles: Vec<ProfileSummary>, pub profiles: Vec<ProfileSummary>,
@ -30,6 +40,7 @@ impl App {
Ok(Self { Ok(Self {
vault: vault::load_vault()?, vault: vault::load_vault()?,
settings: vault::load_settings()?, settings: vault::load_settings()?,
unlock_key: None,
}) })
} }
@ -41,13 +52,132 @@ impl App {
vault::save_settings(&self.settings) 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. /// A safe view of current state, suitable for sending to a UI.
pub fn state_view(&self) -> AppStateView { pub fn state_view(&self) -> AppStateView {
AppStateView { AppStateView {
version: env!("CARGO_PKG_VERSION"), version: env!("CARGO_PKG_VERSION"),
vault_path: vault::vault_path().to_string_lossy().into_owned(), vault_path: vault::vault_path().to_string_lossy().into_owned(),
settings_path: vault::settings_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(), migrated_from: self.vault.migrated_from.clone(),
active_profile: profiles::active_summary(&self.vault), active_profile: profiles::active_summary(&self.vault),
profiles: profiles::summaries(&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, EmptyNote,
/// Event signing failed. /// Event signing failed.
SignFailed, SignFailed,
/// The vault is encrypted and has not been unlocked.
VaultLocked,
/// The supplied vault password is incorrect.
WrongPassword,
/// Invalid configuration or user input. /// Invalid configuration or user input.
Config, Config,
/// Unexpected internal failure. /// Unexpected internal failure.
@ -164,6 +168,17 @@ impl AppError {
Self::simple(ErrorKind::Config, message) 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 { pub fn internal(details: impl fmt::Display) -> Self {
Self::with_details( Self::with_details(
ErrorKind::Internal, ErrorKind::Internal,

View file

@ -59,6 +59,22 @@ pub enum Request {
}, },
/// Create a timestamped backup of the vault file. /// Create a timestamped backup of the vault file.
BackupNow, 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. /// 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 } => { Request::CreateProfile { label } => {
let label = normalise_label(&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()?; app.save_vault()?;
Ok(json!({ "profile": summary, "state": app.state_view() })) 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 } => { 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)) 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() })) 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 { Request::SettingsUpdate {
theme, theme,
confirm_before_publish, confirm_before_publish,

View file

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

View file

@ -27,8 +27,14 @@ Commands:
settings set theme <light|dark|system> settings set theme <light|dark|system>
settings set confirm <true|false> settings set confirm <true|false>
settings set shorten <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 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] #[tokio::main]
async fn main() -> ExitCode { async fn main() -> ExitCode {
@ -53,6 +59,9 @@ async fn main() -> ExitCode {
"publish" => cli_publish(&args).await, "publish" => cli_publish(&args).await,
"relays" => cli_relays(&args).await, "relays" => cli_relays(&args).await,
"settings" => cli_settings(&args), "settings" => cli_settings(&args),
"set-password" => cli_set_password(),
"remove-password" => cli_remove_password(),
"unlock" => cli_unlock(),
"info" => cli_info(), "info" => cli_info(),
"help" | "--help" | "-h" => { "help" | "--help" | "-h" => {
println!("{USAGE}"); println!("{USAGE}");
@ -86,8 +95,9 @@ fn cli_create(args: &[String]) -> Result<String, AppError> {
.get(2) .get(2)
.cloned() .cloned()
.unwrap_or_else(|| "New Profile".to_string()); .unwrap_or_else(|| "New Profile".to_string());
let mut app = App::load()?; let mut app = load_app_with_unlock()?;
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()?; app.save_vault()?;
Ok(format!( Ok(format!(
"Created profile \"{}\": {}", "Created profile \"{}\": {}",
@ -123,8 +133,9 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
let npub = &args[2]; let npub = &args[2];
let content = args[3..].join(" "); let content = args[3..].join(" ");
let app = App::load()?; let app = load_app_with_unlock()?;
let report = publish::publish_as(&app.vault, &app.settings, npub, &content).await?; let report =
publish::publish_as(&app.vault, &app.settings, npub, &content, app.vault_key()).await?;
let mut lines = vec![format!("Published: {}", report.event_id)]; let mut lines = vec![format!("Published: {}", report.event_id)];
if let Some(failed) = report.failed.first() { 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> { fn cli_info() -> Result<String, AppError> {
let app = App::load()?; let app = App::load()?;
let mut lines = vec![ let mut lines = vec![
@ -260,7 +329,18 @@ fn cli_info() -> Result<String, AppError> {
"Settings file: {}", "Settings file: {}",
vault::settings_path().to_string_lossy() 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 { if let Some(migrated) = &app.vault.migrated_from {
lines.push(format!("Migrated from: {migrated}")); lines.push(format!("Migrated from: {migrated}"));

View file

@ -1,6 +1,7 @@
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Serialize; use serde::Serialize;
use crate::crypto::VaultKey;
use crate::errors::AppError; use crate::errors::AppError;
use crate::vault::{unix_timestamp, StoredProfile, Vault}; 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 /// Create a new profile, generating fresh keys. The new profile becomes the
/// active one when nothing is currently selected. /// active one when nothing is currently selected.
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 keys = Keys::generate();
let secret_hex = ::hex::encode(keys.secret_key().to_secret_bytes()); 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}")))?; .map_err(|e| AppError::internal(format!("Could not encode the public key: {e}")))?;
let created_at = unix_timestamp()?; 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 { let profile = StoredProfile {
label: label.clone(), label: label.clone(),
public_key: public_key.clone(), public_key: public_key.clone(),
secret_key: secret_hex, secret_key: stored_secret,
created_at, created_at,
}; };
@ -106,6 +123,36 @@ pub fn active_secret_key(vault: &Vault) -> Result<&str, AppError> {
find_secret_key(vault, npub) 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. /// Parse and validate a stored hex-encoded secret key.
pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> { pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
let bytes = ::hex::decode(hex_str) let bytes = ::hex::decode(hex_str)
@ -138,7 +185,8 @@ mod tests {
#[test] #[test]
fn create_profile_generates_valid_keys() { fn create_profile_generates_valid_keys() {
let mut vault = Vault::empty(); 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!(summary.npub.starts_with("npub1"));
assert_eq!(summary.label, "Newbie"); assert_eq!(summary.label, "Newbie");
assert_eq!(vault.profiles.len(), 1); assert_eq!(vault.profiles.len(), 1);
@ -156,7 +204,7 @@ mod tests {
fn create_profile_keeps_existing_active() { fn create_profile_keeps_existing_active() {
let mut vault = populated_vault(); let mut vault = populated_vault();
vault.active_profile = Some("npub1alice".to_string()); 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!(!summary.is_active);
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice")); assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
} }

View file

@ -3,6 +3,7 @@ use std::time::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use serde::Serialize; use serde::Serialize;
use crate::crypto::VaultKey;
use crate::errors::{AppError, ErrorKind}; use crate::errors::{AppError, ErrorKind};
use crate::profiles; use crate::profiles;
use crate::relays; use crate::relays;
@ -44,27 +45,33 @@ impl PublishReport {
} }
/// Publish a text note with the active profile. /// 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( pub async fn publish_active(
vault: &Vault, vault: &Vault,
settings: &Settings, settings: &Settings,
content: &str, content: &str,
key: Option<&VaultKey>,
) -> Result<PublishReport, AppError> { ) -> Result<PublishReport, AppError> {
validate_content(content)?; 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 secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key); let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await publish_with_keys(settings, content, &keys).await
} }
/// Publish a text note as a specific profile (used by the CLI). /// 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( pub async fn publish_as(
vault: &Vault, vault: &Vault,
settings: &Settings, settings: &Settings,
npub: &str, npub: &str,
content: &str, content: &str,
key: Option<&VaultKey>,
) -> Result<PublishReport, AppError> { ) -> Result<PublishReport, AppError> {
validate_content(content)?; 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 secret_key = profiles::parse_secret_key(&secret_hex)?;
let keys = Keys::new(secret_key); let keys = Keys::new(secret_key);
publish_with_keys(settings, content, &keys).await publish_with_keys(settings, content, &keys).await
@ -233,7 +240,7 @@ mod tests {
let settings = settings_with_no_relays(); let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime 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"); .expect_err("no active profile must error");
assert_eq!(err.kind(), ErrorKind::NoActiveProfile); assert_eq!(err.kind(), ErrorKind::NoActiveProfile);
} }
@ -244,7 +251,7 @@ mod tests {
let settings = settings_with_no_relays(); let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime 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"); .expect_err("missing profile must error");
assert_eq!(err.kind(), ErrorKind::ProfileNotFound); assert_eq!(err.kind(), ErrorKind::ProfileNotFound);
} }
@ -255,7 +262,7 @@ mod tests {
let settings = settings_with_no_relays(); let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime let err = runtime
.block_on(publish_active(&vault, &settings, " ")) .block_on(publish_active(&vault, &settings, " ", None))
.expect_err("empty note must error"); .expect_err("empty note must error");
assert_eq!(err.kind(), ErrorKind::EmptyNote); assert_eq!(err.kind(), ErrorKind::EmptyNote);
} }
@ -263,11 +270,11 @@ mod tests {
#[test] #[test]
fn publish_with_no_enabled_relays_errors() { fn publish_with_no_enabled_relays_errors() {
let mut vault = Vault::empty(); 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 settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime let err = runtime
.block_on(publish_active(&vault, &settings, "hello")) .block_on(publish_active(&vault, &settings, "hello", None))
.expect_err("no relays must error"); .expect_err("no relays must error");
assert_eq!(err.kind(), ErrorKind::NoEnabledRelays); assert_eq!(err.kind(), ErrorKind::NoEnabledRelays);
} }
@ -275,16 +282,39 @@ mod tests {
#[test] #[test]
fn publish_with_invalid_stored_key_errors() { fn publish_with_invalid_stored_key_errors() {
let mut vault = Vault::empty(); 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(); vault.profiles[0].secret_key = "zz-not-hex".to_string();
let settings = settings_with_no_relays(); let settings = settings_with_no_relays();
let runtime = tokio::runtime::Runtime::new().unwrap(); let runtime = tokio::runtime::Runtime::new().unwrap();
let err = runtime let err = runtime
.block_on(publish_active(&vault, &settings, "hello")) .block_on(publish_active(&vault, &settings, "hello", None))
.expect_err("invalid key must error"); .expect_err("invalid key must error");
assert_eq!(err.kind(), ErrorKind::InvalidSecret); 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] #[test]
fn publish_failed_error_message_is_concise() { fn publish_failed_error_message_is_concise() {
let err = AppError::publish_failed(vec![RelayFailure { 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::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::errors::AppError; use crate::errors::AppError;
@ -19,20 +21,46 @@ pub const SETTINGS_FILE_NAME: &str = "settings.json";
/// A profile stored on disk. /// A profile stored on disk.
/// ///
/// `secret_key` is stored as a plaintext hex string for now. The storage is /// `secret_key` is stored as a plaintext hex string when the vault is not
/// deliberately unencrypted in this iteration; it is kept behind the vault /// encrypted, and as a base64 AES-256-GCM blob (nonce || ciphertext || tag)
/// module so that encryption can be added later without changing callers. /// 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredProfile { pub struct StoredProfile {
pub label: String, pub label: String,
/// Bech32 `npub` of the profile. /// Bech32 `npub` of the profile.
pub public_key: String, 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, pub secret_key: String,
/// Unix timestamp of creation. /// Unix timestamp of creation.
pub created_at: u64, 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. /// On-disk vault containing every stored profile plus the active selection.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Vault { pub struct Vault {
@ -43,6 +71,9 @@ pub struct Vault {
/// `npub` of the profile that should stay selected across restarts. /// `npub` of the profile that should stay selected across restarts.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub active_profile: Option<String>, 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>, pub profiles: Vec<StoredProfile>,
} }
@ -53,6 +84,7 @@ impl Vault {
version: VAULT_VERSION, version: VAULT_VERSION,
migrated_from: None, migrated_from: None,
active_profile: None, active_profile: None,
crypto: None,
profiles: Vec::new(), profiles: Vec::new(),
} }
} }
@ -60,6 +92,11 @@ impl Vault {
pub fn has_profiles(&self) -> bool { pub fn has_profiles(&self) -> bool {
!self.profiles.is_empty() !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. /// 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, version: VAULT_VERSION,
migrated_from: None, migrated_from: None,
active_profile: None, active_profile: None,
crypto: None,
profiles, profiles,
}); });
} }
@ -290,11 +328,10 @@ fn try_migrate_legacy_vault() -> Result<Option<Vault>, AppError> {
Ok(None) Ok(None)
} }
/// Whether storage is currently encrypted. Always false in this iteration; /// Decode a base64 salt string into raw bytes.
/// the vault is stored in plaintext and the limitation is disclosed in the pub fn decode_salt(encoded: &str) -> Result<Vec<u8>, AppError> {
/// UI. Kept as a single source of truth so callers never assume otherwise. B64.decode(encoded)
pub fn is_encrypted() -> bool { .map_err(|e| AppError::vault_malformed(format!("Invalid vault salt: {e}")))
false
} }
#[cfg(test)] #[cfg(test)]