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