diff --git a/Cargo.lock b/Cargo.lock index a5d5fc1..46e4c23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b9b0c87..5ed7221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 5617644..6efbcef 100644 --- a/README.md +++ b/README.md @@ -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 # select the active profile cargo run --release -- publish "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-` 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) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7a61b14..bc59576 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('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 (
+ {vaultLocked && ( +
+ +
+ + Your profile keys are password-protected. Publishing and creating profiles need an + unlocked vault; everything else still works. + + +
+
+
+ )} {screen === 'home' && ( setCreateOpen(true)} /> )} @@ -51,6 +73,7 @@ function Shell() { {screen === 'settings' && }
setCreateOpen(false)} /> + setUnlockOpen(false)} />
); } diff --git a/frontend/src/components/UnlockModal.tsx b/frontend/src/components/UnlockModal.tsx new file mode 100644 index 0000000..f0f182d --- /dev/null +++ b/frontend/src/components/UnlockModal.tsx @@ -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(null); + const inputRef = useRef(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 ( + +
+

+ Your profile keys are protected by a password. Enter it to unlock this session — the + password itself is never saved. +

+
+ + setPassword(event.target.value)} + autoComplete="current-password" + aria-describedby={error ? errorId : undefined} + aria-invalid={error ? true : undefined} + disabled={busy} + /> + {error && {error}} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/VaultPasswordModal.tsx b/frontend/src/components/VaultPasswordModal.tsx new file mode 100644 index 0000000..ac9f968 --- /dev/null +++ b/frontend/src/components/VaultPasswordModal.tsx @@ -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(null); + const [message, setMessage] = useState(null); + const firstRef = useRef(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 ( + +
+ {isRemove ? ( +

+ This removes password protection and stores your secret keys in plaintext on this + computer, readable by anyone with access to your user account. +

+ ) : mode === 'change' ? ( +

+ Your keys will be re-encrypted with the new password. The old password will stop working + immediately. +

+ ) : ( +

+ 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. +

+ )} + + {needsCurrent && ( +
+ + setCurrent(event.target.value)} + autoComplete="current-password" + aria-describedby={isRemove && error ? errorId : undefined} + aria-invalid={isRemove && error ? true : undefined} + disabled={busy} + /> + {isRemove && error && {error}} +
+ )} + + {!isRemove && ( + <> +
+ + setNext(event.target.value)} + autoComplete="new-password" + disabled={busy} + /> +
+
+ + setConfirm(event.target.value)} + autoComplete="new-password" + aria-describedby={error ? errorId : undefined} + aria-invalid={error ? true : undefined} + disabled={busy} + /> + {error && {error}} +

At least 8 characters.

+
+ + )} + + {message && ( +
+
{message}
+
+ )} + +
+ + {!message && ( + + )} +
+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3f02767..c2c36e4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -51,5 +51,13 @@ export const api = { patch: Partial>, ) => call('settings_update', patch), backupNow: () => call<{ backup_path: string }>('backup_now'), + setVaultPassword: (currentPassword: string | null, newPassword: string) => + call('set_vault_password', { + current_password: currentPassword, + new_password: newPassword, + }), + unlockVault: (password: string) => call('unlock_vault', { password }), + lockVault: () => call('lock_vault'), + removeVaultPassword: (password: string) => call('remove_vault_password', { password }), copyText: (text: string) => window.backend.copyText(text), }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 59e11b8..8aaf3bc 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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[]; diff --git a/frontend/src/screens/SettingsScreen.tsx b/frontend/src/screens/SettingsScreen.tsx index 3f96609..ef714a0 100644 --- a/frontend/src/screens/SettingsScreen.tsx +++ b/frontend/src/screens/SettingsScreen.tsx @@ -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(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.

- {!encrypted && ( + + {encrypted ? ( + + Profile keys are protected with a password (AES-256). You unlock the vault at the + start of each session. + + ) : ( - 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. )} + +
+ + {encrypted && ( + + )} +
+
+ setPasswordModal(null)} + /> ); } diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 14c17c2..37eba5a 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -43,6 +43,10 @@ interface AppContextValue { patch: Partial>, ) => Promise; backupNow: () => Promise<{ backup_path: string }>; + setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise; + unlockVault: (password: string) => Promise; + lockVault: () => Promise; + removeVaultPassword: (password: string) => Promise; copyText: (text: string) => Promise; } @@ -132,6 +136,27 @@ export function AppProvider({ children }: { children: ReactNode }) { ); const backupNow = useCallback(() => api.backupNow(), []); + const applyState = useCallback(async (fresh: Promise) => { + 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, ], ); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a99aba0..58d4d04 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/src/test/VaultPassword.test.tsx b/frontend/src/test/VaultPassword.test.tsx new file mode 100644 index 0000000..864a927 --- /dev/null +++ b/frontend/src/test/VaultPassword.test.tsx @@ -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) { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await screen.findByRole('heading', { name: 'Home' }); + expect(screen.queryByText('Vault is locked')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/test/apiMock.ts b/frontend/src/test/apiMock.ts index 230b99d..200be83 100644 --- a/frontend/src/test/apiMock.ts +++ b/frontend/src/test/apiMock.ts @@ -37,6 +37,7 @@ export function makeState(overrides?: Partial): 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; settingsUpdate: ReturnType; backupNow: ReturnType; + setVaultPassword: ReturnType; + unlockVault: ReturnType; + lockVault: ReturnType; + removeVaultPassword: ReturnType; copyText: ReturnType; }; /** 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), }; diff --git a/frontend/src/test/fakeBackend.ts b/frontend/src/test/fakeBackend.ts index b3fb0a2..705fce7 100644 --- a/frontend/src/test/fakeBackend.ts +++ b/frontend/src/test/fakeBackend.ts @@ -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}`); } diff --git a/src/app.rs b/src/app.rs index 58b10cd..5e41df7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, } /// 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, pub active_profile: Option, pub profiles: Vec, @@ -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 { + 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 = 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 = 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"))); + } +} diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..c2b72e9 --- /dev/null +++ b/src/crypto.rs @@ -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() -> 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 { + 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 { + let nonce = random_bytes::()?; + 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 { + let nonce = random_bytes::()?; + 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 { + 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, 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, 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!!!")); + } +} diff --git a/src/errors.rs b/src/errors.rs index 9de4ce2..6d9d1e5 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -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, diff --git a/src/ipc.rs b/src/ipc.rs index ffea4db..1a9eae5 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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, + 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 { 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 { - 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 { + 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, diff --git a/src/lib.rs b/src/lib.rs index 805903d..6124262 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod crypto; pub mod errors; pub mod ipc; pub mod profiles; diff --git a/src/main.rs b/src/main.rs index ecc8b8a..558a369 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,8 +27,14 @@ Commands: settings set theme settings set confirm settings set shorten + 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 { .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 { 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 { } } +/// 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 { + 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 { + 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 { + 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 { + 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 { + 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 { let app = App::load()?; let mut lines = vec![ @@ -260,7 +329,18 @@ fn cli_info() -> Result { "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}")); diff --git a/src/profiles.rs b/src/profiles.rs index 82527d9..ed08bbe 100644 --- a/src/profiles.rs +++ b/src/profiles.rs @@ -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 { +/// +/// `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 { + 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 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 { + 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 { + 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 { 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")); } diff --git a/src/publish.rs b/src/publish.rs index aaf7bf7..ff653f5 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -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 { 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 { 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 { diff --git a/src/vault.rs b/src/vault.rs index 6ff2a56..ff8b0d8 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -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, + /// Present when the vault is protected by a password. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub crypto: Option, pub profiles: Vec, } @@ -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 { version: VAULT_VERSION, migrated_from: None, active_profile: None, + crypto: None, profiles, }); } @@ -290,11 +328,10 @@ fn try_migrate_legacy_vault() -> Result, 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, AppError> { + B64.decode(encoded) + .map_err(|e| AppError::vault_malformed(format!("Invalid vault salt: {e}"))) } #[cfg(test)]