Add password-encrypted vault
- Encrypt stored secret keys with AES-256-GCM under an Argon2id-derived key; the vault stays plaintext until a password is set (Settings -> Storage or the CLI set-password command) - Only secret keys are encrypted; labels and npubs stay readable so profiles can be browsed while the vault is locked - Backend: crypto module, Vault.crypto metadata, unlock/lock/set/remove password on App, VaultLocked/WrongPassword errors, secret resolution on the publish path - IPC: set_vault_password, unlock_vault, lock_vault, remove_vault_password - CLI: set-password, remove-password, unlock; create/publish prompt when the vault is locked (NFM_PASSWORD env or hidden prompt, never argv) - GUI: unlock banner + modal on locked vaults, protect/change/remove password in Settings, password field styling - Tests: Rust (argon2/AES round-trips, vault lifecycle) and Vitest (unlock flow, set/change/remove password), all green
This commit is contained in:
parent
7e3bac345c
commit
7ca1d14dcb
23 changed files with 1540 additions and 43 deletions
112
Cargo.lock
generated
112
Cargo.lock
generated
|
|
@ -12,6 +12,31 @@ dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes",
|
||||||
|
"cipher",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.5"
|
version = "1.1.5"
|
||||||
|
|
@ -21,6 +46,18 @@ dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arrayvec"
|
name = "arrayvec"
|
||||||
version = "0.7.8"
|
version = "0.7.8"
|
||||||
|
|
@ -136,6 +173,15 @@ version = "2.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.10.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
|
|
@ -246,6 +292,15 @@ dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.1"
|
version = "2.11.1"
|
||||||
|
|
@ -415,6 +470,16 @@ dependencies = [
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-timers"
|
name = "gloo-timers"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
@ -719,8 +784,13 @@ dependencies = [
|
||||||
name = "nostr-manager-backend"
|
name = "nostr-manager-backend"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
|
"argon2",
|
||||||
|
"base64",
|
||||||
|
"getrandom 0.2.17",
|
||||||
"hex",
|
"hex",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
|
"rpassword",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
@ -838,6 +908,18 @@ dependencies = [
|
||||||
"universal-hash",
|
"universal-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -997,6 +1079,27 @@ dependencies = [
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpassword"
|
||||||
|
version = "7.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rtoolbox",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rtoolbox"
|
||||||
|
version = "0.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.43"
|
||||||
|
|
@ -1605,6 +1708,15 @@ dependencies = [
|
||||||
"windows-targets",
|
"windows-targets",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.59.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,8 @@ serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
uuid = { version = "1.0", features = ["v4"] }
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
argon2 = "0.5"
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
base64 = "0.22"
|
||||||
|
getrandom = "0.2"
|
||||||
|
rpassword = "7"
|
||||||
|
|
|
||||||
28
README.md
28
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
|
- Add, remove, enable/disable, and test relays
|
||||||
- Light / dark / system theme, configurable publish confirmation and key shortening
|
- Light / dark / system theme, configurable publish confirmation and key shortening
|
||||||
- Back up your vault from the UI
|
- Back up your vault from the UI
|
||||||
- Honest about security: the vault is stored in plaintext (as in the original CLI), readable
|
- Password-protected vault: secret keys are encrypted at rest with AES-256-GCM under an
|
||||||
only by your user account; this is clearly disclosed in the app
|
Argon2id-derived key. Without a password set, the vault is stored in plaintext (readable only by
|
||||||
|
your user account) and this is disclosed in the app
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|
@ -88,10 +89,17 @@ cargo run --release -- switch <npub> # select the active profile
|
||||||
cargo run --release -- publish <npub> "Hello" # publish a text note
|
cargo run --release -- publish <npub> "Hello" # publish a text note
|
||||||
cargo run --release -- relays list|add|remove|enable|disable|test
|
cargo run --release -- relays list|add|remove|enable|disable|test
|
||||||
cargo run --release -- settings get|set theme|confirm|shorten
|
cargo run --release -- settings get|set theme|confirm|shorten
|
||||||
cargo run --release -- info # show storage locations
|
cargo run --release -- set-password # encrypt the vault (or change its password)
|
||||||
|
cargo run --release -- remove-password # remove vault encryption
|
||||||
|
cargo run --release -- unlock # verify the vault password for this process
|
||||||
|
cargo run --release -- info # show storage locations and version
|
||||||
cargo run --release -- serve # JSON-lines IPC server (used by the GUI)
|
cargo run --release -- serve # JSON-lines IPC server (used by the GUI)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Passwords are read from the `NFM_PASSWORD` environment variable when set, otherwise you are
|
||||||
|
prompted interactively. They are never accepted as command-line arguments. `create` and `publish`
|
||||||
|
prompt for the vault password automatically when the vault is encrypted.
|
||||||
|
|
||||||
## Storage and migration
|
## Storage and migration
|
||||||
|
|
||||||
- The vault (`profiles_vault.json`) and settings live in
|
- The vault (`profiles_vault.json`) and settings live in
|
||||||
|
|
@ -100,8 +108,13 @@ cargo run --release -- serve # JSON-lines IPC server (used b
|
||||||
- The original CLI saved `profiles_vault.json` in its working directory. On first launch this
|
- The original CLI saved `profiles_vault.json` in its working directory. On first launch this
|
||||||
app finds that file, copies it to a timestamped `*.backup-<ts>` next to it, and imports your
|
app finds that file, copies it to a timestamped `*.backup-<ts>` next to it, and imports your
|
||||||
profiles into the new location. The original file is left untouched.
|
profiles into the new location. The original file is left untouched.
|
||||||
- Private keys are stored in the vault in plaintext. Anyone with access to your user account
|
- The vault is stored in plaintext until you set a password (Settings → Storage, or
|
||||||
can read them; a password-encrypted vault is planned for a future version.
|
`set-password` in the CLI). Once protected, every secret key is encrypted at rest with
|
||||||
|
AES-256-GCM under a key derived from your password with Argon2id. Labels and public keys stay
|
||||||
|
readable so profiles can be browsed while the vault is locked. You unlock once per session;
|
||||||
|
the derived key lives only in memory and is never written to disk. Anyone with access to your
|
||||||
|
user account can still read the vault file, so the password is a defence-in-depth layer, not a
|
||||||
|
replacement for keeping your account secure.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
|
@ -122,7 +135,8 @@ cargo clippy --all-targets
|
||||||
|
|
||||||
```
|
```
|
||||||
src/ Rust library + CLI + IPC server
|
src/ Rust library + CLI + IPC server
|
||||||
app.rs application state loading/persistence
|
app.rs application state, vault password/unlock lifecycle
|
||||||
|
crypto.rs Argon2id key derivation + AES-256-GCM encryption
|
||||||
errors.rs structured AppError
|
errors.rs structured AppError
|
||||||
ipc.rs JSON-lines serve() loop and request/reply envelope
|
ipc.rs JSON-lines serve() loop and request/reply envelope
|
||||||
main.rs CLI entry point
|
main.rs CLI entry point
|
||||||
|
|
@ -130,7 +144,7 @@ src/ Rust library + CLI + IPC server
|
||||||
publish.rs note publishing with per-relay reports
|
publish.rs note publishing with per-relay reports
|
||||||
relays.rs default relays, validation, connection tests
|
relays.rs default relays, validation, connection tests
|
||||||
settings.rs theme and user preferences
|
settings.rs theme and user preferences
|
||||||
vault.rs encrypted-vault-ready storage (currently plaintext)
|
vault.rs vault storage (plaintext or password-encrypted) and migration
|
||||||
frontend/
|
frontend/
|
||||||
electron/ Electron main + preload (backend spawn, IPC, clipboard)
|
electron/ Electron main + preload (backend spawn, IPC, clipboard)
|
||||||
src/ React app (components, screens, state, styles)
|
src/ React app (components, screens, state, styles)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@ import { useState } from 'react';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Spinner } from './components/Spinner';
|
import { Spinner } from './components/Spinner';
|
||||||
import { Alert } from './components/Alert';
|
import { Alert } from './components/Alert';
|
||||||
|
import { Button } from './components/Button';
|
||||||
|
import { Icon } from './components/Icon';
|
||||||
|
import { UnlockModal } from './components/UnlockModal';
|
||||||
import { HomeScreen } from './screens/HomeScreen';
|
import { HomeScreen } from './screens/HomeScreen';
|
||||||
import { ProfilesScreen } from './screens/ProfilesScreen';
|
import { ProfilesScreen } from './screens/ProfilesScreen';
|
||||||
import { ComposeScreen } from './screens/ComposeScreen';
|
import { ComposeScreen } from './screens/ComposeScreen';
|
||||||
|
|
@ -15,6 +18,7 @@ function Shell() {
|
||||||
const { state, loading, bootstrapError } = useApp();
|
const { state, loading, bootstrapError } = useApp();
|
||||||
const [screen, setScreen] = useState<Screen>('home');
|
const [screen, setScreen] = useState<Screen>('home');
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||||
|
|
||||||
useThemeSync(state?.settings.theme);
|
useThemeSync(state?.settings.theme);
|
||||||
|
|
||||||
|
|
@ -38,10 +42,28 @@ function Shell() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vaultLocked = state?.vault_locked ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<Sidebar screen={screen} onNavigate={setScreen} />
|
<Sidebar screen={screen} onNavigate={setScreen} />
|
||||||
<main className="main" id="main-content">
|
<main className="main" id="main-content">
|
||||||
|
{vaultLocked && (
|
||||||
|
<div className="lock-banner">
|
||||||
|
<Alert tone="warning" title="Vault is locked">
|
||||||
|
<div className="lock-banner-row">
|
||||||
|
<span>
|
||||||
|
Your profile keys are password-protected. Publishing and creating profiles need an
|
||||||
|
unlocked vault; everything else still works.
|
||||||
|
</span>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => setUnlockOpen(true)}>
|
||||||
|
<Icon name="shield" size={15} />
|
||||||
|
Unlock vault
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{screen === 'home' && (
|
{screen === 'home' && (
|
||||||
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
|
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
|
||||||
)}
|
)}
|
||||||
|
|
@ -51,6 +73,7 @@ function Shell() {
|
||||||
{screen === 'settings' && <SettingsScreen />}
|
{screen === 'settings' && <SettingsScreen />}
|
||||||
</main>
|
</main>
|
||||||
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
|
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
frontend/src/components/UnlockModal.tsx
Normal file
84
frontend/src/components/UnlockModal.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import { useApp } from '../state/AppProvider';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { ErrorText } from './ErrorText';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
|
||||||
|
interface UnlockModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnlockModal({ open, onClose }: UnlockModalProps) {
|
||||||
|
const { unlockVault } = useApp();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const errorId = 'unlock-vault-error';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPassword('');
|
||||||
|
setError(null);
|
||||||
|
setBusy(false);
|
||||||
|
const frame = requestAnimationFrame(() => inputRef.current?.focus());
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const canSubmit = password.length > 0 && !busy;
|
||||||
|
|
||||||
|
const onSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await unlockVault(password);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setPassword('');
|
||||||
|
setBusy(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} title="Unlock your vault" onClose={onClose}>
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
<p>
|
||||||
|
Your profile keys are protected by a password. Enter it to unlock this session — the
|
||||||
|
password itself is never saved.
|
||||||
|
</p>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="unlock-password">Password</label>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="unlock-password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||||
|
</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" loading={busy} disabled={!canSubmit}>
|
||||||
|
{busy ? 'Unlocking…' : 'Unlock'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
frontend/src/components/VaultPasswordModal.tsx
Normal file
178
frontend/src/components/VaultPasswordModal.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||||
|
import { useApp } from '../state/AppProvider';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { ErrorText } from './ErrorText';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
|
||||||
|
export type VaultPasswordMode = 'set' | 'change' | 'remove';
|
||||||
|
|
||||||
|
interface VaultPasswordModalProps {
|
||||||
|
open: boolean;
|
||||||
|
mode: VaultPasswordMode;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VaultPasswordModal({ open, mode, onClose }: VaultPasswordModalProps) {
|
||||||
|
const { setVaultPassword, removeVaultPassword } = useApp();
|
||||||
|
const [current, setCurrent] = useState('');
|
||||||
|
const [next, setNext] = useState('');
|
||||||
|
const [confirm, setConfirm] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const firstRef = useRef<HTMLInputElement>(null);
|
||||||
|
const errorId = 'vault-password-error';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setCurrent('');
|
||||||
|
setNext('');
|
||||||
|
setConfirm('');
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
setBusy(false);
|
||||||
|
const frame = requestAnimationFrame(() => firstRef.current?.focus());
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [open, mode]);
|
||||||
|
|
||||||
|
const isRemove = mode === 'remove';
|
||||||
|
const needsCurrent = mode === 'change' || mode === 'remove';
|
||||||
|
const canSubmit =
|
||||||
|
!busy && (isRemove ? current.length > 0 : next.length > 0 && confirm.length > 0);
|
||||||
|
|
||||||
|
const title = isRemove
|
||||||
|
? 'Remove vault password'
|
||||||
|
: mode === 'change'
|
||||||
|
? 'Change vault password'
|
||||||
|
: 'Encrypt your vault';
|
||||||
|
|
||||||
|
const onSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setMessage(null);
|
||||||
|
if (!isRemove && next !== confirm) {
|
||||||
|
setError('The passwords do not match.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (isRemove) {
|
||||||
|
await removeVaultPassword(current);
|
||||||
|
setMessage('Password removed. Keys are stored in plaintext again.');
|
||||||
|
} else {
|
||||||
|
await setVaultPassword(needsCurrent ? current : null, next);
|
||||||
|
setMessage('Vault password set. Your stored keys are now encrypted.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} title={title} onClose={onClose}>
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
|
{isRemove ? (
|
||||||
|
<p>
|
||||||
|
This removes password protection and stores your secret keys in plaintext on this
|
||||||
|
computer, readable by anyone with access to your user account.
|
||||||
|
</p>
|
||||||
|
) : mode === 'change' ? (
|
||||||
|
<p>
|
||||||
|
Your keys will be re-encrypted with the new password. The old password will stop working
|
||||||
|
immediately.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
Your profile keys will be encrypted with AES-256 before being saved to disk. You will
|
||||||
|
need this password every time you use the app, so keep it safe.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{needsCurrent && (
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="vault-current-password">Current password</label>
|
||||||
|
<input
|
||||||
|
ref={firstRef}
|
||||||
|
id="vault-current-password"
|
||||||
|
type="password"
|
||||||
|
value={current}
|
||||||
|
onChange={(event) => setCurrent(event.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
aria-describedby={isRemove && error ? errorId : undefined}
|
||||||
|
aria-invalid={isRemove && error ? true : undefined}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{isRemove && error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRemove && (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="vault-new-password">
|
||||||
|
{mode === 'change' ? 'New password' : 'Password'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={needsCurrent ? undefined : firstRef}
|
||||||
|
id="vault-new-password"
|
||||||
|
type="password"
|
||||||
|
value={next}
|
||||||
|
onChange={(event) => setNext(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="vault-confirm-password">Repeat password</label>
|
||||||
|
<input
|
||||||
|
id="vault-confirm-password"
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(event) => setConfirm(event.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby={error ? errorId : undefined}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||||
|
<p className="hint">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className="alert alert-success" role="status">
|
||||||
|
<div className="alert-body">{message}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||||
|
{message ? 'Close' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
{!message && (
|
||||||
|
<Button
|
||||||
|
variant={isRemove ? 'danger' : 'primary'}
|
||||||
|
type="submit"
|
||||||
|
loading={busy}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
>
|
||||||
|
{isRemove
|
||||||
|
? 'Remove password'
|
||||||
|
: mode === 'change'
|
||||||
|
? 'Change password'
|
||||||
|
: 'Encrypt vault'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -51,5 +51,13 @@ export const api = {
|
||||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||||
) => call<Settings>('settings_update', patch),
|
) => call<Settings>('settings_update', patch),
|
||||||
backupNow: () => call<{ backup_path: string }>('backup_now'),
|
backupNow: () => call<{ backup_path: string }>('backup_now'),
|
||||||
|
setVaultPassword: (currentPassword: string | null, newPassword: string) =>
|
||||||
|
call<AppState>('set_vault_password', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
}),
|
||||||
|
unlockVault: (password: string) => call<AppState>('unlock_vault', { password }),
|
||||||
|
lockVault: () => call<AppState>('lock_vault'),
|
||||||
|
removeVaultPassword: (password: string) => call<AppState>('remove_vault_password', { password }),
|
||||||
copyText: (text: string) => window.backend.copyText(text),
|
copyText: (text: string) => window.backend.copyText(text),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ export interface AppState {
|
||||||
vault_path: string;
|
vault_path: string;
|
||||||
settings_path: string;
|
settings_path: string;
|
||||||
encrypted_storage: boolean;
|
encrypted_storage: boolean;
|
||||||
|
/** True when the vault is encrypted and has not been unlocked this session. */
|
||||||
|
vault_locked: boolean;
|
||||||
migrated_from: string | null;
|
migrated_from: string | null;
|
||||||
active_profile: ProfileSummary | null;
|
active_profile: ProfileSummary | null;
|
||||||
profiles: ProfileSummary[];
|
profiles: ProfileSummary[];
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,14 @@ import { Button } from '../components/Button';
|
||||||
import { CopyButton } from '../components/CopyButton';
|
import { CopyButton } from '../components/CopyButton';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { Toggle } from '../components/Toggle';
|
import { Toggle } from '../components/Toggle';
|
||||||
|
import { VaultPasswordModal, type VaultPasswordMode } from '../components/VaultPasswordModal';
|
||||||
import type { Theme } from '../lib/types';
|
import type { Theme } from '../lib/types';
|
||||||
import { useApp } from '../state/AppProvider';
|
import { useApp } from '../state/AppProvider';
|
||||||
|
|
||||||
export function SettingsScreen() {
|
export function SettingsScreen() {
|
||||||
const { state, updateSettings, backupNow } = useApp();
|
const { state, updateSettings, backupNow } = useApp();
|
||||||
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
const [backupMessage, setBackupMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
|
const [passwordModal, setPasswordModal] = useState<VaultPasswordMode | null>(null);
|
||||||
|
|
||||||
const settings = state?.settings;
|
const settings = state?.settings;
|
||||||
const vaultPath = state?.vault_path ?? '';
|
const vaultPath = state?.vault_path ?? '';
|
||||||
|
|
@ -111,13 +113,34 @@ export function SettingsScreen() {
|
||||||
Your profiles, including private keys, are stored in this file. It is readable only by
|
Your profiles, including private keys, are stored in this file. It is readable only by
|
||||||
your user account.
|
your user account.
|
||||||
</p>
|
</p>
|
||||||
{!encrypted && (
|
|
||||||
|
{encrypted ? (
|
||||||
|
<Alert tone="success" title="Storage is encrypted">
|
||||||
|
Profile keys are protected with a password (AES-256). You unlock the vault at the
|
||||||
|
start of each session.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
<Alert tone="warning" title="Storage is not encrypted">
|
<Alert tone="warning" title="Storage is not encrypted">
|
||||||
Profile data is saved in plaintext on this computer (as in the original CLI). Anyone
|
Profile keys are saved in plaintext on this computer, readable by anyone with access
|
||||||
with access to your user account can read your keys. A password-encrypted vault is
|
to your user account. Protect them with a password.
|
||||||
planned for a future version.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="settings-inline">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setPasswordModal(encrypted ? 'change' : 'set')}
|
||||||
|
>
|
||||||
|
<Icon name="shield" size={16} />
|
||||||
|
{encrypted ? 'Change password' : 'Protect with a password'}
|
||||||
|
</Button>
|
||||||
|
{encrypted && (
|
||||||
|
<Button variant="ghost" onClick={() => setPasswordModal('remove')}>
|
||||||
|
Remove password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="settings-inline">
|
<div className="settings-inline">
|
||||||
<Button variant="secondary" onClick={() => void onBackup()}>
|
<Button variant="secondary" onClick={() => void onBackup()}>
|
||||||
<Icon name="shield" size={16} />
|
<Icon name="shield" size={16} />
|
||||||
|
|
@ -163,6 +186,11 @@ export function SettingsScreen() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<VaultPasswordModal
|
||||||
|
open={passwordModal !== null}
|
||||||
|
mode={passwordModal ?? 'set'}
|
||||||
|
onClose={() => setPasswordModal(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,10 @@ interface AppContextValue {
|
||||||
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
patch: Partial<Pick<Settings, 'theme' | 'confirm_before_publish' | 'shorten_npub'>>,
|
||||||
) => Promise<Settings>;
|
) => Promise<Settings>;
|
||||||
backupNow: () => Promise<{ backup_path: string }>;
|
backupNow: () => Promise<{ backup_path: string }>;
|
||||||
|
setVaultPassword: (currentPassword: string | null, newPassword: string) => Promise<AppState>;
|
||||||
|
unlockVault: (password: string) => Promise<AppState>;
|
||||||
|
lockVault: () => Promise<AppState>;
|
||||||
|
removeVaultPassword: (password: string) => Promise<AppState>;
|
||||||
copyText: (text: string) => Promise<void>;
|
copyText: (text: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -132,6 +136,27 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
);
|
);
|
||||||
const backupNow = useCallback(() => api.backupNow(), []);
|
const backupNow = useCallback(() => api.backupNow(), []);
|
||||||
|
|
||||||
|
const applyState = useCallback(async (fresh: Promise<AppState>) => {
|
||||||
|
const next = await fresh;
|
||||||
|
setState(next);
|
||||||
|
return next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setVaultPassword = useCallback(
|
||||||
|
(currentPassword: string | null, newPassword: string) =>
|
||||||
|
applyState(api.setVaultPassword(currentPassword, newPassword)),
|
||||||
|
[applyState],
|
||||||
|
);
|
||||||
|
const unlockVault = useCallback(
|
||||||
|
(password: string) => applyState(api.unlockVault(password)),
|
||||||
|
[applyState],
|
||||||
|
);
|
||||||
|
const lockVault = useCallback(() => applyState(api.lockVault()), [applyState]);
|
||||||
|
const removeVaultPassword = useCallback(
|
||||||
|
(password: string) => applyState(api.removeVaultPassword(password)),
|
||||||
|
[applyState],
|
||||||
|
);
|
||||||
|
|
||||||
const copyText = useCallback((text: string) => api.copyText(text), []);
|
const copyText = useCallback((text: string) => api.copyText(text), []);
|
||||||
|
|
||||||
useThemeSync(state?.settings.theme);
|
useThemeSync(state?.settings.theme);
|
||||||
|
|
@ -154,6 +179,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
relayTest,
|
relayTest,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
backupNow,
|
backupNow,
|
||||||
|
setVaultPassword,
|
||||||
|
unlockVault,
|
||||||
|
lockVault,
|
||||||
|
removeVaultPassword,
|
||||||
copyText,
|
copyText,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
@ -173,6 +202,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
relayTest,
|
relayTest,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
backupNow,
|
backupNow,
|
||||||
|
setVaultPassword,
|
||||||
|
unlockVault,
|
||||||
|
lockVault,
|
||||||
|
removeVaultPassword,
|
||||||
copyText,
|
copyText,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,7 @@ a {
|
||||||
|
|
||||||
input[type='text'],
|
input[type='text'],
|
||||||
input[type='search'],
|
input[type='search'],
|
||||||
|
input[type='password'],
|
||||||
select,
|
select,
|
||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
|
|
@ -552,6 +553,8 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type='text']:focus-visible,
|
input[type='text']:focus-visible,
|
||||||
|
input[type='search']:focus-visible,
|
||||||
|
input[type='password']:focus-visible,
|
||||||
select:focus-visible,
|
select:focus-visible,
|
||||||
textarea:focus-visible {
|
textarea:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
@ -1243,6 +1246,23 @@ select {
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lock-banner {
|
||||||
|
padding: 14px 22px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-banner .alert {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-banner-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.path-row > div {
|
.path-row > div {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
149
frontend/src/test/VaultPassword.test.tsx
Normal file
149
frontend/src/test/VaultPassword.test.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import App from '../App';
|
||||||
|
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||||
|
import { makeState } from './apiMock';
|
||||||
|
import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||||
|
import { renderWithApp } from './render';
|
||||||
|
|
||||||
|
function renderApp(backend: ReturnType<typeof createFakeBackend>) {
|
||||||
|
installFakeBackend(backend);
|
||||||
|
return { user: userEvent.setup(), backend };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('vault encryption', () => {
|
||||||
|
describe('SettingsScreen', () => {
|
||||||
|
it('offers to protect an unencrypted vault', async () => {
|
||||||
|
const backend = createFakeBackend();
|
||||||
|
installFakeBackend(backend);
|
||||||
|
renderWithApp(<SettingsScreen />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Storage is not encrypted')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Protect with a password/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: /Change password/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encrypts the vault through the modal', async () => {
|
||||||
|
const backend = createFakeBackend();
|
||||||
|
installFakeBackend(backend);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithApp(<SettingsScreen />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /Protect with a password/i }));
|
||||||
|
expect(await screen.findByRole('dialog', { name: 'Encrypt your vault' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'correct horse');
|
||||||
|
await user.type(screen.getByLabelText('Repeat password'), 'correct horse');
|
||||||
|
await user.click(screen.getByRole('button', { name: /Encrypt vault/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(backend.state.encrypted_storage).toBe(true);
|
||||||
|
});
|
||||||
|
expect(backend.state.vault_locked).toBe(false);
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Vault password set. Your stored keys are now encrypted/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mismatched passwords', async () => {
|
||||||
|
const backend = createFakeBackend();
|
||||||
|
installFakeBackend(backend);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithApp(<SettingsScreen />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /Protect with a password/i }));
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'one password');
|
||||||
|
await user.type(screen.getByLabelText('Repeat password'), 'another password');
|
||||||
|
await user.click(screen.getByRole('button', { name: /Encrypt vault/i }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('The passwords do not match.')).toBeInTheDocument();
|
||||||
|
expect(backend.state.encrypted_storage).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows change and remove actions for an encrypted vault', async () => {
|
||||||
|
const backend = createFakeBackend(
|
||||||
|
makeState({ encrypted_storage: true, vault_locked: false }),
|
||||||
|
);
|
||||||
|
installFakeBackend(backend);
|
||||||
|
renderWithApp(<SettingsScreen />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Storage is encrypted')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Change password/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /Remove password/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes password protection', async () => {
|
||||||
|
const backend = createFakeBackend(
|
||||||
|
makeState({ encrypted_storage: true, vault_locked: false }),
|
||||||
|
);
|
||||||
|
installFakeBackend(backend);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithApp(<SettingsScreen />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: /Remove password/i }));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Remove vault password' });
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText('Current password'), 'correct horse');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Remove password' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(backend.state.encrypted_storage).toBe(false);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Password removed. Keys are stored in plaintext again/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('App unlock flow', () => {
|
||||||
|
it('shows an unlock banner for a locked vault and unlocks it', async () => {
|
||||||
|
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
|
||||||
|
const { user } = renderApp(backend);
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Home' });
|
||||||
|
expect(screen.getByText('Vault is locked')).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Unlock vault/i }));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Unlock your vault' });
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'correct horse');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(backend.state.vault_locked).toBe(false);
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Vault is locked')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(dialog).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the banner when an incorrect password is reported', async () => {
|
||||||
|
const backend = createFakeBackend(makeState({ encrypted_storage: true, vault_locked: true }));
|
||||||
|
backend.nextErrors.unlock_vault = { message: 'The password is not correct.' };
|
||||||
|
const { user } = renderApp(backend);
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Home' });
|
||||||
|
await user.click(screen.getByRole('button', { name: /Unlock vault/i }));
|
||||||
|
const dialog = await screen.findByRole('dialog', { name: 'Unlock your vault' });
|
||||||
|
await user.type(screen.getByLabelText('Password'), 'wrong password');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Unlock' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('The password is not correct.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Unlock your vault' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vault is locked')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the banner for an unlocked vault', async () => {
|
||||||
|
const backend = createFakeBackend(
|
||||||
|
makeState({ encrypted_storage: true, vault_locked: false }),
|
||||||
|
);
|
||||||
|
renderApp(backend);
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await screen.findByRole('heading', { name: 'Home' });
|
||||||
|
expect(screen.queryByText('Vault is locked')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -37,6 +37,7 @@ export function makeState(overrides?: Partial<AppState>): AppState {
|
||||||
vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
|
vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json',
|
||||||
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
|
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json',
|
||||||
encrypted_storage: false,
|
encrypted_storage: false,
|
||||||
|
vault_locked: false,
|
||||||
migrated_from: null,
|
migrated_from: null,
|
||||||
active_profile: alice,
|
active_profile: alice,
|
||||||
profiles: [alice, bob],
|
profiles: [alice, bob],
|
||||||
|
|
@ -85,6 +86,10 @@ export interface ApiMock {
|
||||||
relayTest: ReturnType<typeof vi.fn>;
|
relayTest: ReturnType<typeof vi.fn>;
|
||||||
settingsUpdate: ReturnType<typeof vi.fn>;
|
settingsUpdate: ReturnType<typeof vi.fn>;
|
||||||
backupNow: ReturnType<typeof vi.fn>;
|
backupNow: ReturnType<typeof vi.fn>;
|
||||||
|
setVaultPassword: ReturnType<typeof vi.fn>;
|
||||||
|
unlockVault: ReturnType<typeof vi.fn>;
|
||||||
|
lockVault: ReturnType<typeof vi.fn>;
|
||||||
|
removeVaultPassword: ReturnType<typeof vi.fn>;
|
||||||
copyText: ReturnType<typeof vi.fn>;
|
copyText: ReturnType<typeof vi.fn>;
|
||||||
};
|
};
|
||||||
/** Current state object backing init/getState. */
|
/** Current state object backing init/getState. */
|
||||||
|
|
@ -159,6 +164,18 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
|
||||||
backupNow: vi.fn(async () => ({
|
backupNow: vi.fn(async () => ({
|
||||||
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1',
|
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1',
|
||||||
})),
|
})),
|
||||||
|
setVaultPassword: vi.fn(async () => ({
|
||||||
|
...state,
|
||||||
|
encrypted_storage: true,
|
||||||
|
vault_locked: false,
|
||||||
|
})),
|
||||||
|
unlockVault: vi.fn(async () => ({ ...state, vault_locked: false })),
|
||||||
|
lockVault: vi.fn(async () => ({ ...state, vault_locked: true })),
|
||||||
|
removeVaultPassword: vi.fn(async () => ({
|
||||||
|
...state,
|
||||||
|
encrypted_storage: false,
|
||||||
|
vault_locked: false,
|
||||||
|
})),
|
||||||
copyText: vi.fn(async () => undefined),
|
copyText: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,36 @@ export function createFakeBackend(initial?: AppState): FakeBackend {
|
||||||
case 'backup_now':
|
case 'backup_now':
|
||||||
return { backup_path: `${state.vault_path}.backup-1` };
|
return { backup_path: `${state.vault_path}.backup-1` };
|
||||||
|
|
||||||
|
case 'set_vault_password': {
|
||||||
|
const next: AppState = { ...state, encrypted_storage: true, vault_locked: false };
|
||||||
|
backend.setState(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unlock_vault': {
|
||||||
|
if (!state.encrypted_storage) {
|
||||||
|
throw new Error('Your vault is not encrypted.');
|
||||||
|
}
|
||||||
|
const next: AppState = { ...state, vault_locked: false };
|
||||||
|
backend.setState(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'lock_vault': {
|
||||||
|
if (!state.encrypted_storage) {
|
||||||
|
throw new Error('Your vault is not encrypted.');
|
||||||
|
}
|
||||||
|
const next: AppState = { ...state, vault_locked: true };
|
||||||
|
backend.setState(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'remove_vault_password': {
|
||||||
|
const next: AppState = { ...state, encrypted_storage: false, vault_locked: false };
|
||||||
|
backend.setState(next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown method: ${method}`);
|
throw new Error(`Unknown method: ${method}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
355
src/app.rs
355
src/app.rs
|
|
@ -1,14 +1,22 @@
|
||||||
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use base64::Engine;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::crypto::{self, VaultKey};
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::profiles::{self, ProfileSummary};
|
use crate::profiles::{self, ProfileSummary};
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use crate::vault::{self, Vault};
|
use crate::vault::{self, KdfParams, StoredProfile, Vault, VaultCrypto};
|
||||||
|
|
||||||
|
/// Minimum password length accepted when encrypting the vault.
|
||||||
|
pub const MIN_PASSWORD_LEN: usize = 8;
|
||||||
|
|
||||||
/// Shared application state used by both the CLI and the IPC server.
|
/// Shared application state used by both the CLI and the IPC server.
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub vault: Vault,
|
pub vault: Vault,
|
||||||
pub settings: Settings,
|
pub settings: Settings,
|
||||||
|
/// Derived vault key, present only while the encrypted vault is unlocked.
|
||||||
|
unlock_key: Option<VaultKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snapshot of everything the UI needs, containing no secret keys.
|
/// Snapshot of everything the UI needs, containing no secret keys.
|
||||||
|
|
@ -18,6 +26,8 @@ pub struct AppStateView {
|
||||||
pub vault_path: String,
|
pub vault_path: String,
|
||||||
pub settings_path: String,
|
pub settings_path: String,
|
||||||
pub encrypted_storage: bool,
|
pub encrypted_storage: bool,
|
||||||
|
/// True when the vault is encrypted and has not been unlocked this session.
|
||||||
|
pub vault_locked: bool,
|
||||||
pub migrated_from: Option<String>,
|
pub migrated_from: Option<String>,
|
||||||
pub active_profile: Option<ProfileSummary>,
|
pub active_profile: Option<ProfileSummary>,
|
||||||
pub profiles: Vec<ProfileSummary>,
|
pub profiles: Vec<ProfileSummary>,
|
||||||
|
|
@ -30,6 +40,7 @@ impl App {
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
vault: vault::load_vault()?,
|
vault: vault::load_vault()?,
|
||||||
settings: vault::load_settings()?,
|
settings: vault::load_settings()?,
|
||||||
|
unlock_key: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,13 +52,132 @@ impl App {
|
||||||
vault::save_settings(&self.settings)
|
vault::save_settings(&self.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The derived vault key, if the vault is encrypted and currently unlocked.
|
||||||
|
pub fn vault_key(&self) -> Option<&VaultKey> {
|
||||||
|
self.unlock_key.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the vault is password-protected and has not been unlocked.
|
||||||
|
pub fn is_locked(&self) -> bool {
|
||||||
|
self.vault.is_encrypted() && self.unlock_key.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a password and keep the derived key in memory for the session.
|
||||||
|
pub fn unlock(&mut self, password: &str) -> Result<(), AppError> {
|
||||||
|
let crypto = self
|
||||||
|
.vault
|
||||||
|
.crypto
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::config("Your vault is not encrypted."))?;
|
||||||
|
let key = derive_with(crypto, password)?;
|
||||||
|
if !crypto::verify(&key, &crypto.verifier) {
|
||||||
|
return Err(AppError::wrong_password());
|
||||||
|
}
|
||||||
|
self.unlock_key = Some(key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the derived key, re-locking the vault for the session.
|
||||||
|
pub fn lock(&mut self) {
|
||||||
|
self.unlock_key = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Protect the vault with `new_password`, re-encrypting every stored key.
|
||||||
|
///
|
||||||
|
/// `current_password` must be supplied when the vault is already encrypted.
|
||||||
|
/// The new key is kept in memory, so the vault ends the call unlocked.
|
||||||
|
pub fn set_password(
|
||||||
|
&mut self,
|
||||||
|
current_password: Option<&str>,
|
||||||
|
new_password: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
validate_new_password(new_password)?;
|
||||||
|
|
||||||
|
// Resolve the key currently protecting the stored keys, if any.
|
||||||
|
let previous_key = match self.vault.crypto.as_ref() {
|
||||||
|
Some(crypto) => {
|
||||||
|
let current = current_password.ok_or_else(|| {
|
||||||
|
AppError::config("Enter your current password to change the vault password.")
|
||||||
|
})?;
|
||||||
|
let key = derive_with(crypto, current)?;
|
||||||
|
if !crypto::verify(&key, &crypto.verifier) {
|
||||||
|
return Err(AppError::wrong_password());
|
||||||
|
}
|
||||||
|
Some(key)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let salt = crypto::generate_salt()?;
|
||||||
|
let new_key = crypto::derive_key(
|
||||||
|
new_password,
|
||||||
|
&salt,
|
||||||
|
crypto::KDF_M_COST,
|
||||||
|
crypto::KDF_T_COST,
|
||||||
|
crypto::KDF_P_COST,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut encrypted = Vec::with_capacity(self.vault.profiles.len());
|
||||||
|
for profile in &self.vault.profiles {
|
||||||
|
let plaintext = match &previous_key {
|
||||||
|
Some(key) => crypto::decrypt_secret(key, &profile.secret_key)?,
|
||||||
|
None => profile.secret_key.clone(),
|
||||||
|
};
|
||||||
|
encrypted.push(StoredProfile {
|
||||||
|
secret_key: crypto::encrypt_secret(&new_key, &plaintext)?,
|
||||||
|
..profile.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.vault.profiles = encrypted;
|
||||||
|
self.vault.crypto = Some(VaultCrypto {
|
||||||
|
kdf: KdfParams {
|
||||||
|
algorithm: "argon2id".to_string(),
|
||||||
|
salt: B64.encode(salt),
|
||||||
|
m_cost: crypto::KDF_M_COST,
|
||||||
|
t_cost: crypto::KDF_T_COST,
|
||||||
|
p_cost: crypto::KDF_P_COST,
|
||||||
|
},
|
||||||
|
verifier: crypto::make_verifier(&new_key)?,
|
||||||
|
});
|
||||||
|
self.unlock_key = Some(new_key);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove password protection, restoring plaintext secret keys.
|
||||||
|
pub fn remove_password(&mut self, current_password: &str) -> Result<(), AppError> {
|
||||||
|
let crypto = self
|
||||||
|
.vault
|
||||||
|
.crypto
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| AppError::config("Your vault is not encrypted."))?;
|
||||||
|
let key = derive_with(crypto, current_password)?;
|
||||||
|
if !crypto::verify(&key, &crypto.verifier) {
|
||||||
|
return Err(AppError::wrong_password());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut plain = Vec::with_capacity(self.vault.profiles.len());
|
||||||
|
for profile in &self.vault.profiles {
|
||||||
|
let plaintext = crypto::decrypt_secret(&key, &profile.secret_key)?;
|
||||||
|
plain.push(StoredProfile {
|
||||||
|
secret_key: plaintext,
|
||||||
|
..profile.clone()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
self.vault.profiles = plain;
|
||||||
|
self.vault.crypto = None;
|
||||||
|
self.unlock_key = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// A safe view of current state, suitable for sending to a UI.
|
/// A safe view of current state, suitable for sending to a UI.
|
||||||
pub fn state_view(&self) -> AppStateView {
|
pub fn state_view(&self) -> AppStateView {
|
||||||
AppStateView {
|
AppStateView {
|
||||||
version: env!("CARGO_PKG_VERSION"),
|
version: env!("CARGO_PKG_VERSION"),
|
||||||
vault_path: vault::vault_path().to_string_lossy().into_owned(),
|
vault_path: vault::vault_path().to_string_lossy().into_owned(),
|
||||||
settings_path: vault::settings_path().to_string_lossy().into_owned(),
|
settings_path: vault::settings_path().to_string_lossy().into_owned(),
|
||||||
encrypted_storage: vault::is_encrypted(),
|
encrypted_storage: self.vault.is_encrypted(),
|
||||||
|
vault_locked: self.is_locked(),
|
||||||
migrated_from: self.vault.migrated_from.clone(),
|
migrated_from: self.vault.migrated_from.clone(),
|
||||||
active_profile: profiles::active_summary(&self.vault),
|
active_profile: profiles::active_summary(&self.vault),
|
||||||
profiles: profiles::summaries(&self.vault),
|
profiles: profiles::summaries(&self.vault),
|
||||||
|
|
@ -55,3 +185,224 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject weak new passwords with a clear, early message.
|
||||||
|
fn validate_new_password(password: &str) -> Result<(), AppError> {
|
||||||
|
if password.len() < MIN_PASSWORD_LEN {
|
||||||
|
return Err(AppError::config(format!(
|
||||||
|
"The password must be at least {MIN_PASSWORD_LEN} characters long."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a key using the KDF parameters stored in the vault.
|
||||||
|
fn derive_with(crypto: &VaultCrypto, password: &str) -> Result<VaultKey, AppError> {
|
||||||
|
let salt = vault::decode_salt(&crypto.kdf.salt)?;
|
||||||
|
crypto::derive_key(
|
||||||
|
password,
|
||||||
|
&salt,
|
||||||
|
crypto.kdf.m_cost,
|
||||||
|
crypto.kdf.t_cost,
|
||||||
|
crypto.kdf.p_cost,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::errors::ErrorKind;
|
||||||
|
use crate::profiles;
|
||||||
|
|
||||||
|
fn plaintext_vault() -> Vault {
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
profiles::create_profile(&mut vault, "Alice".to_string(), None).unwrap();
|
||||||
|
profiles::create_profile(&mut vault, "Bob".to_string(), None).unwrap();
|
||||||
|
vault
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_app() -> App {
|
||||||
|
App {
|
||||||
|
vault: plaintext_vault(),
|
||||||
|
settings: Settings::default(),
|
||||||
|
unlock_key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_password_encrypts_every_secret() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
let plaintexts: Vec<String> = app
|
||||||
|
.vault
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.secret_key.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(app.vault.is_encrypted());
|
||||||
|
assert!(!app.is_locked(), "vault is unlocked right after encrypting");
|
||||||
|
assert!(app.vault_key().is_some());
|
||||||
|
assert!(app.vault.crypto.as_ref().is_some());
|
||||||
|
for (stored, original) in app.vault.profiles.iter().zip(&plaintexts) {
|
||||||
|
assert_ne!(
|
||||||
|
stored.secret_key, *original,
|
||||||
|
"secret must no longer be plaintext"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stored.secret_key.contains(&stored.public_key),
|
||||||
|
"ciphertext must not leak the key material"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The stored vault file must not contain any plaintext key.
|
||||||
|
let json = serde_json::to_string(&app.vault).unwrap();
|
||||||
|
assert!(!json.contains(&plaintexts[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_password_rejects_short_passwords() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
let err = app.set_password(None, "short").expect_err("must reject");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::Config);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unlock_roundtrip_with_wrong_then_right_password() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
app.lock();
|
||||||
|
assert!(app.is_locked());
|
||||||
|
|
||||||
|
let err = app.unlock("not the password").expect_err("wrong password");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongPassword);
|
||||||
|
assert!(app.is_locked());
|
||||||
|
|
||||||
|
app.unlock("correct horse battery staple").unwrap();
|
||||||
|
assert!(!app.is_locked());
|
||||||
|
assert!(app.vault_key().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn locked_vault_rejects_key_creation() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
app.lock();
|
||||||
|
|
||||||
|
let key = app.vault_key().copied();
|
||||||
|
let err = profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref())
|
||||||
|
.expect_err("locked vault must reject new profiles");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::VaultLocked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn create_profile_encrypts_new_keys_when_unlocked() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let key = app.vault_key().copied();
|
||||||
|
profiles::create_profile(&mut app.vault, "Carol".to_string(), key.as_ref()).unwrap();
|
||||||
|
|
||||||
|
let created = app.vault.profiles.last().unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
created.secret_key.len(),
|
||||||
|
64,
|
||||||
|
"stored secret should be encrypted"
|
||||||
|
);
|
||||||
|
let decrypted =
|
||||||
|
crypto::decrypt_secret(app.vault_key().unwrap(), &created.secret_key).unwrap();
|
||||||
|
assert_eq!(decrypted.len(), 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_password_restores_plaintext() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
let plaintexts: Vec<String> = app
|
||||||
|
.vault
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.secret_key.clone())
|
||||||
|
.collect();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app.remove_password("correct horse battery staple").unwrap();
|
||||||
|
|
||||||
|
assert!(!app.vault.is_encrypted());
|
||||||
|
assert!(!app.is_locked());
|
||||||
|
assert!(app.vault_key().is_none());
|
||||||
|
for (stored, original) in app.vault.profiles.iter().zip(&plaintexts) {
|
||||||
|
assert_eq!(stored.secret_key, *original, "keys must be restored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_password_requires_correct_password() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
let err = app.remove_password("wrong").expect_err("wrong password");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongPassword);
|
||||||
|
assert!(app.vault.is_encrypted());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_password_invalidates_the_old_one() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
app.set_password(Some("correct horse battery staple"), "new password 123")
|
||||||
|
.unwrap();
|
||||||
|
app.lock();
|
||||||
|
|
||||||
|
let err = app
|
||||||
|
.unlock("correct horse battery staple")
|
||||||
|
.expect_err("old password must not work");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongPassword);
|
||||||
|
app.unlock("new password 123").unwrap();
|
||||||
|
assert!(!app.is_locked());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_password_requires_current() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
let err = app
|
||||||
|
.set_password(None, "brand new password")
|
||||||
|
.expect_err("current required");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::Config);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_view_reports_locked_when_encrypted_and_locked() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
let view = app.state_view();
|
||||||
|
assert!(!view.encrypted_storage);
|
||||||
|
assert!(!view.vault_locked);
|
||||||
|
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
app.lock();
|
||||||
|
let view = app.state_view();
|
||||||
|
assert!(view.encrypted_storage);
|
||||||
|
assert!(view.vault_locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profiles_remain_readable_while_locked() {
|
||||||
|
let mut app = sample_app();
|
||||||
|
app.set_password(None, "correct horse battery staple")
|
||||||
|
.unwrap();
|
||||||
|
app.lock();
|
||||||
|
|
||||||
|
let view = app.state_view();
|
||||||
|
assert_eq!(view.profiles.len(), 2);
|
||||||
|
assert!(view.profiles.iter().all(|p| p.npub.starts_with("npub1")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
188
src/crypto.rs
Normal file
188
src/crypto.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
//! Password-based vault encryption.
|
||||||
|
//!
|
||||||
|
//! Secret keys are encrypted individually with AES-256-GCM under a key derived
|
||||||
|
//! from the user's password with Argon2id. A known-plaintext verifier stored
|
||||||
|
//! in the vault lets the app check a password without decrypting every key.
|
||||||
|
//!
|
||||||
|
//! Nothing in this module ever touches the network, and the derived key is
|
||||||
|
//! kept only in memory by the caller.
|
||||||
|
|
||||||
|
use argon2::{Algorithm, Argon2, Params, Version};
|
||||||
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use base64::Engine;
|
||||||
|
use getrandom::getrandom;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// Derived symmetric key length in bytes (AES-256).
|
||||||
|
pub const KEY_LEN: usize = 32;
|
||||||
|
/// Salt length in bytes.
|
||||||
|
pub const SALT_LEN: usize = 16;
|
||||||
|
/// AES-GCM nonce length in bytes.
|
||||||
|
pub const NONCE_LEN: usize = 12;
|
||||||
|
|
||||||
|
/// Argon2id memory cost in KiB (RFC 9106 recommendation).
|
||||||
|
pub const KDF_M_COST: u32 = 19 * 1024;
|
||||||
|
/// Argon2id time cost (iterations).
|
||||||
|
pub const KDF_T_COST: u32 = 2;
|
||||||
|
/// Argon2id parallelism.
|
||||||
|
pub const KDF_P_COST: u32 = 1;
|
||||||
|
|
||||||
|
/// The in-memory key that unlocks an encrypted vault.
|
||||||
|
pub type VaultKey = [u8; KEY_LEN];
|
||||||
|
|
||||||
|
/// Fill a fixed-size buffer with cryptographically secure randomness.
|
||||||
|
pub fn random_bytes<const N: usize>() -> Result<[u8; N], AppError> {
|
||||||
|
let mut buf = [0u8; N];
|
||||||
|
getrandom(&mut buf)
|
||||||
|
.map_err(|e| AppError::internal(format!("Could not generate randomness: {e}")))?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh random salt.
|
||||||
|
pub fn generate_salt() -> Result<[u8; SALT_LEN], AppError> {
|
||||||
|
random_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a 32-byte key from a password with Argon2id.
|
||||||
|
pub fn derive_key(
|
||||||
|
password: &str,
|
||||||
|
salt: &[u8],
|
||||||
|
m_cost: u32,
|
||||||
|
t_cost: u32,
|
||||||
|
p_cost: u32,
|
||||||
|
) -> Result<VaultKey, AppError> {
|
||||||
|
let params = Params::new(m_cost, t_cost, p_cost, Some(KEY_LEN))
|
||||||
|
.map_err(|e| AppError::internal(format!("Invalid Argon2 parameters: {e}")))?;
|
||||||
|
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||||
|
let mut key = [0u8; KEY_LEN];
|
||||||
|
argon2
|
||||||
|
.hash_password_into(password.as_bytes(), salt, &mut key)
|
||||||
|
.map_err(|e| AppError::internal(format!("Could not derive a vault key: {e}")))?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known plaintext used to verify a password without decrypting any keys.
|
||||||
|
const VERIFIER_PLAINTEXT: &[u8] = b"nost-feed-manager vault key v1";
|
||||||
|
|
||||||
|
/// Produce a base64 verifier blob bound to `key`.
|
||||||
|
pub fn make_verifier(key: &VaultKey) -> Result<String, AppError> {
|
||||||
|
let nonce = random_bytes::<NONCE_LEN>()?;
|
||||||
|
let ciphertext = encrypt(&nonce, key, VERIFIER_PLAINTEXT)?;
|
||||||
|
Ok(encode(&nonce, &ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check that `encoded` verifier matches `key`.
|
||||||
|
pub fn verify(key: &VaultKey, encoded: &str) -> bool {
|
||||||
|
match decrypt(key, encoded) {
|
||||||
|
Ok(plain) => plain == VERIFIER_PLAINTEXT,
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a plaintext hex secret key, returning a base64 blob.
|
||||||
|
pub fn encrypt_secret(key: &VaultKey, plaintext_hex: &str) -> Result<String, AppError> {
|
||||||
|
let nonce = random_bytes::<NONCE_LEN>()?;
|
||||||
|
let ciphertext = encrypt(&nonce, key, plaintext_hex.as_bytes())?;
|
||||||
|
Ok(encode(&nonce, &ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt a base64 secret-key blob back to plaintext hex.
|
||||||
|
pub fn decrypt_secret(key: &VaultKey, encoded: &str) -> Result<String, AppError> {
|
||||||
|
let plain = decrypt(key, encoded)?;
|
||||||
|
String::from_utf8(plain)
|
||||||
|
.map_err(|e| AppError::internal(format!("A decrypted key was not valid text: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-256-GCM encrypt, returning nonce || ciphertext || tag.
|
||||||
|
fn encrypt(
|
||||||
|
nonce_bytes: &[u8; NONCE_LEN],
|
||||||
|
key: &VaultKey,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, AppError> {
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key)
|
||||||
|
.map_err(|e| AppError::internal(format!("Could not initialise the cipher: {e}")))?;
|
||||||
|
cipher
|
||||||
|
.encrypt(Nonce::from_slice(nonce_bytes), plaintext)
|
||||||
|
.map_err(|_| AppError::storage("The vault could not be encrypted."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AES-256-GCM decrypt of a nonce || ciphertext || tag blob.
|
||||||
|
fn decrypt(key: &VaultKey, encoded: &str) -> Result<Vec<u8>, AppError> {
|
||||||
|
use aes_gcm::aead::{Aead, KeyInit};
|
||||||
|
use aes_gcm::{Aes256Gcm, Nonce};
|
||||||
|
|
||||||
|
let decoded = B64
|
||||||
|
.decode(encoded)
|
||||||
|
.map_err(|_| AppError::storage("The stored encrypted data is corrupt."))?;
|
||||||
|
if decoded.len() <= NONCE_LEN {
|
||||||
|
return Err(AppError::storage("The stored encrypted data is corrupt."));
|
||||||
|
}
|
||||||
|
let (nonce_bytes, ciphertext) = decoded.split_at(NONCE_LEN);
|
||||||
|
let cipher = Aes256Gcm::new_from_slice(key)
|
||||||
|
.map_err(|e| AppError::internal(format!("Could not initialise the cipher: {e}")))?;
|
||||||
|
cipher
|
||||||
|
.decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
|
||||||
|
.map_err(|_| AppError::storage("The vault password is incorrect or the data is corrupt."))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Base64-encode a nonce plus ciphertext as a single blob.
|
||||||
|
fn encode(nonce: &[u8; NONCE_LEN], ciphertext: &[u8]) -> String {
|
||||||
|
let mut combined = Vec::with_capacity(NONCE_LEN + ciphertext.len());
|
||||||
|
combined.extend_from_slice(nonce);
|
||||||
|
combined.extend_from_slice(ciphertext);
|
||||||
|
B64.encode(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_decrypt_roundtrip() {
|
||||||
|
let salt = generate_salt().unwrap();
|
||||||
|
let key = derive_key("hunter2", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
let secret = "00".repeat(32);
|
||||||
|
let blob = encrypt_secret(&key, &secret).unwrap();
|
||||||
|
assert_ne!(blob, secret);
|
||||||
|
assert_eq!(decrypt_secret(&key, &blob).unwrap(), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_key_cannot_decrypt() {
|
||||||
|
let salt = generate_salt().unwrap();
|
||||||
|
let key = derive_key("correct horse", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
let other =
|
||||||
|
derive_key("battery staple", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
let blob = encrypt_secret(&key, "ff".repeat(32).as_str()).unwrap();
|
||||||
|
assert!(decrypt_secret(&other, &blob).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verifier_matches_only_right_key() {
|
||||||
|
let salt = generate_salt().unwrap();
|
||||||
|
let key = derive_key("open sesame", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
let other = derive_key("wrong", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
let verifier = make_verifier(&key).unwrap();
|
||||||
|
assert!(verify(&key, &verifier));
|
||||||
|
assert!(!verify(&other, &verifier));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_password_different_salt_different_key() {
|
||||||
|
let key_a = derive_key("pw", &[1u8; SALT_LEN], KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
let key_b = derive_key("pw", &[2u8; SALT_LEN], KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
assert_ne!(key_a, key_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_blob_is_an_error() {
|
||||||
|
let salt = generate_salt().unwrap();
|
||||||
|
let key = derive_key("pw", &salt, KDF_M_COST, KDF_T_COST, KDF_P_COST).unwrap();
|
||||||
|
assert!(decrypt_secret(&key, "!!!not-base64!!!").is_err());
|
||||||
|
assert!(!verify(&key, "!!!not-base64!!!"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,10 @@ pub enum ErrorKind {
|
||||||
EmptyNote,
|
EmptyNote,
|
||||||
/// Event signing failed.
|
/// Event signing failed.
|
||||||
SignFailed,
|
SignFailed,
|
||||||
|
/// The vault is encrypted and has not been unlocked.
|
||||||
|
VaultLocked,
|
||||||
|
/// The supplied vault password is incorrect.
|
||||||
|
WrongPassword,
|
||||||
/// Invalid configuration or user input.
|
/// Invalid configuration or user input.
|
||||||
Config,
|
Config,
|
||||||
/// Unexpected internal failure.
|
/// Unexpected internal failure.
|
||||||
|
|
@ -164,6 +168,17 @@ impl AppError {
|
||||||
Self::simple(ErrorKind::Config, message)
|
Self::simple(ErrorKind::Config, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn vault_locked() -> Self {
|
||||||
|
Self::simple(
|
||||||
|
ErrorKind::VaultLocked,
|
||||||
|
"Your vault is locked. Enter your password to unlock it.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wrong_password() -> Self {
|
||||||
|
Self::simple(ErrorKind::WrongPassword, "The password is not correct.")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn internal(details: impl fmt::Display) -> Self {
|
pub fn internal(details: impl fmt::Display) -> Self {
|
||||||
Self::with_details(
|
Self::with_details(
|
||||||
ErrorKind::Internal,
|
ErrorKind::Internal,
|
||||||
|
|
|
||||||
48
src/ipc.rs
48
src/ipc.rs
|
|
@ -59,6 +59,22 @@ pub enum Request {
|
||||||
},
|
},
|
||||||
/// Create a timestamped backup of the vault file.
|
/// Create a timestamped backup of the vault file.
|
||||||
BackupNow,
|
BackupNow,
|
||||||
|
/// Protect the vault with a password (or change it).
|
||||||
|
SetVaultPassword {
|
||||||
|
/// Required when the vault is already encrypted.
|
||||||
|
current_password: Option<String>,
|
||||||
|
new_password: String,
|
||||||
|
},
|
||||||
|
/// Verify a password and unlock the vault for this session.
|
||||||
|
UnlockVault {
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
|
/// Drop the derived key, re-locking the vault.
|
||||||
|
LockVault,
|
||||||
|
/// Remove password protection entirely.
|
||||||
|
RemoveVaultPassword {
|
||||||
|
password: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reply envelope carrying either data or a safe user-facing error.
|
/// A reply envelope carrying either data or a safe user-facing error.
|
||||||
|
|
@ -165,7 +181,8 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
|
||||||
|
|
||||||
Request::CreateProfile { label } => {
|
Request::CreateProfile { label } => {
|
||||||
let label = normalise_label(&label);
|
let label = normalise_label(&label);
|
||||||
let summary = profiles::create_profile(&mut app.vault, label)?;
|
let key = app.vault_key().copied();
|
||||||
|
let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?;
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
Ok(json!({ "profile": summary, "state": app.state_view() }))
|
||||||
}
|
}
|
||||||
|
|
@ -177,7 +194,9 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
|
||||||
}
|
}
|
||||||
|
|
||||||
Request::PublishNote { content } => {
|
Request::PublishNote { content } => {
|
||||||
let report = publish::publish_active(&app.vault, &app.settings, &content).await?;
|
let report =
|
||||||
|
publish::publish_active(&app.vault, &app.settings, &content, app.vault_key())
|
||||||
|
.await?;
|
||||||
Ok(json!(report))
|
Ok(json!(report))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,6 +230,31 @@ async fn run(app: &mut App, request: Request) -> Result<serde_json::Value, AppEr
|
||||||
Ok(json!({ "backup_path": backup.to_string_lossy().into_owned() }))
|
Ok(json!({ "backup_path": backup.to_string_lossy().into_owned() }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Request::SetVaultPassword {
|
||||||
|
current_password,
|
||||||
|
new_password,
|
||||||
|
} => {
|
||||||
|
app.set_password(current_password.as_deref(), &new_password)?;
|
||||||
|
app.save_vault()?;
|
||||||
|
Ok(json!(app.state_view()))
|
||||||
|
}
|
||||||
|
|
||||||
|
Request::UnlockVault { password } => {
|
||||||
|
app.unlock(&password)?;
|
||||||
|
Ok(json!(app.state_view()))
|
||||||
|
}
|
||||||
|
|
||||||
|
Request::LockVault => {
|
||||||
|
app.lock();
|
||||||
|
Ok(json!(app.state_view()))
|
||||||
|
}
|
||||||
|
|
||||||
|
Request::RemoveVaultPassword { password } => {
|
||||||
|
app.remove_password(&password)?;
|
||||||
|
app.save_vault()?;
|
||||||
|
Ok(json!(app.state_view()))
|
||||||
|
}
|
||||||
|
|
||||||
Request::SettingsUpdate {
|
Request::SettingsUpdate {
|
||||||
theme,
|
theme,
|
||||||
confirm_before_publish,
|
confirm_before_publish,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod crypto;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
pub mod profiles;
|
pub mod profiles;
|
||||||
|
|
|
||||||
92
src/main.rs
92
src/main.rs
|
|
@ -27,8 +27,14 @@ Commands:
|
||||||
settings set theme <light|dark|system>
|
settings set theme <light|dark|system>
|
||||||
settings set confirm <true|false>
|
settings set confirm <true|false>
|
||||||
settings set shorten <true|false>
|
settings set shorten <true|false>
|
||||||
|
set-password Encrypt the vault with a password (or change it)
|
||||||
|
remove-password Remove the vault password (keys back to plaintext)
|
||||||
|
unlock Verify the vault password for this process
|
||||||
info Show storage locations and version
|
info Show storage locations and version
|
||||||
serve Run the JSON-lines IPC server";
|
serve Run the JSON-lines IPC server
|
||||||
|
|
||||||
|
Passwords are read from the NFM_PASSWORD environment variable when set,
|
||||||
|
otherwise you are prompted. They are never accepted as command-line arguments.";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> ExitCode {
|
async fn main() -> ExitCode {
|
||||||
|
|
@ -53,6 +59,9 @@ async fn main() -> ExitCode {
|
||||||
"publish" => cli_publish(&args).await,
|
"publish" => cli_publish(&args).await,
|
||||||
"relays" => cli_relays(&args).await,
|
"relays" => cli_relays(&args).await,
|
||||||
"settings" => cli_settings(&args),
|
"settings" => cli_settings(&args),
|
||||||
|
"set-password" => cli_set_password(),
|
||||||
|
"remove-password" => cli_remove_password(),
|
||||||
|
"unlock" => cli_unlock(),
|
||||||
"info" => cli_info(),
|
"info" => cli_info(),
|
||||||
"help" | "--help" | "-h" => {
|
"help" | "--help" | "-h" => {
|
||||||
println!("{USAGE}");
|
println!("{USAGE}");
|
||||||
|
|
@ -86,8 +95,9 @@ fn cli_create(args: &[String]) -> Result<String, AppError> {
|
||||||
.get(2)
|
.get(2)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| "New Profile".to_string());
|
.unwrap_or_else(|| "New Profile".to_string());
|
||||||
let mut app = App::load()?;
|
let mut app = load_app_with_unlock()?;
|
||||||
let summary = profiles::create_profile(&mut app.vault, label)?;
|
let key = app.vault_key().copied();
|
||||||
|
let summary = profiles::create_profile(&mut app.vault, label, key.as_ref())?;
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"Created profile \"{}\": {}",
|
"Created profile \"{}\": {}",
|
||||||
|
|
@ -123,8 +133,9 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
|
||||||
let npub = &args[2];
|
let npub = &args[2];
|
||||||
let content = args[3..].join(" ");
|
let content = args[3..].join(" ");
|
||||||
|
|
||||||
let app = App::load()?;
|
let app = load_app_with_unlock()?;
|
||||||
let report = publish::publish_as(&app.vault, &app.settings, npub, &content).await?;
|
let report =
|
||||||
|
publish::publish_as(&app.vault, &app.settings, npub, &content, app.vault_key()).await?;
|
||||||
|
|
||||||
let mut lines = vec![format!("Published: {}", report.event_id)];
|
let mut lines = vec![format!("Published: {}", report.event_id)];
|
||||||
if let Some(failed) = report.failed.first() {
|
if let Some(failed) = report.failed.first() {
|
||||||
|
|
@ -250,6 +261,64 @@ fn parse_bool(value: &str) -> Result<bool, AppError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load the app and, when the vault is encrypted, unlock it using the
|
||||||
|
/// password from `NFM_PASSWORD` or an interactive prompt.
|
||||||
|
fn load_app_with_unlock() -> Result<App, AppError> {
|
||||||
|
let mut app = App::load()?;
|
||||||
|
if app.is_locked() {
|
||||||
|
let password = prompt_password("Vault password: ")?;
|
||||||
|
app.unlock(&password)?;
|
||||||
|
}
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a password from the `NFM_PASSWORD` environment variable when set,
|
||||||
|
/// otherwise prompt on the terminal without echoing.
|
||||||
|
fn prompt_password(prompt: &str) -> Result<String, AppError> {
|
||||||
|
if let Ok(value) = std::env::var("NFM_PASSWORD") {
|
||||||
|
if !value.trim().is_empty() {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rpassword::prompt_password(prompt)
|
||||||
|
.map_err(|e| AppError::config(format!("Could not read a password from the terminal: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_set_password() -> Result<String, AppError> {
|
||||||
|
let mut app = App::load()?;
|
||||||
|
let current = if app.vault.is_encrypted() {
|
||||||
|
Some(prompt_password("Current password: ")?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let new = prompt_password("New password: ")?;
|
||||||
|
let confirm = prompt_password("Repeat new password: ")?;
|
||||||
|
if new != confirm {
|
||||||
|
return Err(AppError::config("The passwords do not match."));
|
||||||
|
}
|
||||||
|
app.set_password(current.as_deref(), &new)?;
|
||||||
|
app.save_vault()?;
|
||||||
|
Ok("Vault password set. Your stored keys are now encrypted.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_remove_password() -> Result<String, AppError> {
|
||||||
|
let mut app = App::load()?;
|
||||||
|
let password = prompt_password("Current password: ")?;
|
||||||
|
app.remove_password(&password)?;
|
||||||
|
app.save_vault()?;
|
||||||
|
Ok("Vault encryption removed. Keys are stored in plaintext again.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cli_unlock() -> Result<String, AppError> {
|
||||||
|
let mut app = App::load()?;
|
||||||
|
if !app.vault.is_encrypted() {
|
||||||
|
return Err(AppError::config("Your vault is not encrypted."));
|
||||||
|
}
|
||||||
|
let password = prompt_password("Vault password: ")?;
|
||||||
|
app.unlock(&password)?;
|
||||||
|
Ok("Vault unlocked.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn cli_info() -> Result<String, AppError> {
|
fn cli_info() -> Result<String, AppError> {
|
||||||
let app = App::load()?;
|
let app = App::load()?;
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
|
|
@ -260,7 +329,18 @@ fn cli_info() -> Result<String, AppError> {
|
||||||
"Settings file: {}",
|
"Settings file: {}",
|
||||||
vault::settings_path().to_string_lossy()
|
vault::settings_path().to_string_lossy()
|
||||||
),
|
),
|
||||||
format!("Encrypted storage: {}", vault::is_encrypted()),
|
format!(
|
||||||
|
"Encrypted storage: {}",
|
||||||
|
if app.vault.is_encrypted() {
|
||||||
|
"yes"
|
||||||
|
} else {
|
||||||
|
"no"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"Vault locked: {}",
|
||||||
|
if app.is_locked() { "yes" } else { "no" }
|
||||||
|
),
|
||||||
];
|
];
|
||||||
if let Some(migrated) = &app.vault.migrated_from {
|
if let Some(migrated) = &app.vault.migrated_from {
|
||||||
lines.push(format!("Migrated from: {migrated}"));
|
lines.push(format!("Migrated from: {migrated}"));
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::crypto::VaultKey;
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
use crate::vault::{unix_timestamp, StoredProfile, Vault};
|
use crate::vault::{unix_timestamp, StoredProfile, Vault};
|
||||||
|
|
||||||
|
|
@ -17,7 +18,18 @@ pub struct ProfileSummary {
|
||||||
|
|
||||||
/// Create a new profile, generating fresh keys. The new profile becomes the
|
/// Create a new profile, generating fresh keys. The new profile becomes the
|
||||||
/// active one when nothing is currently selected.
|
/// active one when nothing is currently selected.
|
||||||
pub fn create_profile(vault: &mut Vault, label: String) -> Result<ProfileSummary, AppError> {
|
///
|
||||||
|
/// `key` must be the unlocked vault key when the vault is password-protected;
|
||||||
|
/// new keys are then encrypted before being stored.
|
||||||
|
pub fn create_profile(
|
||||||
|
vault: &mut Vault,
|
||||||
|
label: String,
|
||||||
|
key: Option<&VaultKey>,
|
||||||
|
) -> Result<ProfileSummary, AppError> {
|
||||||
|
if vault.is_encrypted() && key.is_none() {
|
||||||
|
return Err(AppError::vault_locked());
|
||||||
|
}
|
||||||
|
|
||||||
let keys = Keys::generate();
|
let keys = Keys::generate();
|
||||||
|
|
||||||
let secret_hex = ::hex::encode(keys.secret_key().to_secret_bytes());
|
let secret_hex = ::hex::encode(keys.secret_key().to_secret_bytes());
|
||||||
|
|
@ -27,10 +39,15 @@ pub fn create_profile(vault: &mut Vault, label: String) -> Result<ProfileSummary
|
||||||
.map_err(|e| AppError::internal(format!("Could not encode the public key: {e}")))?;
|
.map_err(|e| AppError::internal(format!("Could not encode the public key: {e}")))?;
|
||||||
let created_at = unix_timestamp()?;
|
let created_at = unix_timestamp()?;
|
||||||
|
|
||||||
|
let stored_secret = match &vault.crypto {
|
||||||
|
Some(_) => crate::crypto::encrypt_secret(key.expect("guarded above"), &secret_hex)?,
|
||||||
|
None => secret_hex,
|
||||||
|
};
|
||||||
|
|
||||||
let profile = StoredProfile {
|
let profile = StoredProfile {
|
||||||
label: label.clone(),
|
label: label.clone(),
|
||||||
public_key: public_key.clone(),
|
public_key: public_key.clone(),
|
||||||
secret_key: secret_hex,
|
secret_key: stored_secret,
|
||||||
created_at,
|
created_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -106,6 +123,36 @@ pub fn active_secret_key(vault: &Vault) -> Result<&str, AppError> {
|
||||||
find_secret_key(vault, npub)
|
find_secret_key(vault, npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the plaintext hex secret key for a profile, decrypting it when the
|
||||||
|
/// vault is password-protected. When the vault is encrypted, `key` must be the
|
||||||
|
/// unlocked vault key; otherwise the operation is rejected as locked.
|
||||||
|
pub fn resolve_secret_key(
|
||||||
|
vault: &Vault,
|
||||||
|
npub: &str,
|
||||||
|
key: Option<&VaultKey>,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let stored = find_secret_key(vault, npub)?;
|
||||||
|
match &vault.crypto {
|
||||||
|
Some(_) => {
|
||||||
|
let key = key.ok_or_else(AppError::vault_locked)?;
|
||||||
|
crate::crypto::decrypt_secret(key, stored)
|
||||||
|
}
|
||||||
|
None => Ok(stored.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plaintext hex secret key for the active profile, decrypting when needed.
|
||||||
|
pub fn resolve_active_secret_key(
|
||||||
|
vault: &Vault,
|
||||||
|
key: Option<&VaultKey>,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let npub = vault
|
||||||
|
.active_profile
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(AppError::no_active_profile)?;
|
||||||
|
resolve_secret_key(vault, npub, key)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse and validate a stored hex-encoded secret key.
|
/// Parse and validate a stored hex-encoded secret key.
|
||||||
pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
|
pub fn parse_secret_key(hex_str: &str) -> Result<SecretKey, AppError> {
|
||||||
let bytes = ::hex::decode(hex_str)
|
let bytes = ::hex::decode(hex_str)
|
||||||
|
|
@ -138,7 +185,8 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn create_profile_generates_valid_keys() {
|
fn create_profile_generates_valid_keys() {
|
||||||
let mut vault = Vault::empty();
|
let mut vault = Vault::empty();
|
||||||
let summary = create_profile(&mut vault, "Newbie".to_string()).expect("should create");
|
let summary =
|
||||||
|
create_profile(&mut vault, "Newbie".to_string(), None).expect("should create");
|
||||||
assert!(summary.npub.starts_with("npub1"));
|
assert!(summary.npub.starts_with("npub1"));
|
||||||
assert_eq!(summary.label, "Newbie");
|
assert_eq!(summary.label, "Newbie");
|
||||||
assert_eq!(vault.profiles.len(), 1);
|
assert_eq!(vault.profiles.len(), 1);
|
||||||
|
|
@ -156,7 +204,7 @@ mod tests {
|
||||||
fn create_profile_keeps_existing_active() {
|
fn create_profile_keeps_existing_active() {
|
||||||
let mut vault = populated_vault();
|
let mut vault = populated_vault();
|
||||||
vault.active_profile = Some("npub1alice".to_string());
|
vault.active_profile = Some("npub1alice".to_string());
|
||||||
let summary = create_profile(&mut vault, "Carol".to_string()).unwrap();
|
let summary = create_profile(&mut vault, "Carol".to_string(), None).unwrap();
|
||||||
assert!(!summary.is_active);
|
assert!(!summary.is_active);
|
||||||
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
|
assert_eq!(vault.active_profile.as_deref(), Some("npub1alice"));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use std::time::Duration;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::crypto::VaultKey;
|
||||||
use crate::errors::{AppError, ErrorKind};
|
use crate::errors::{AppError, ErrorKind};
|
||||||
use crate::profiles;
|
use crate::profiles;
|
||||||
use crate::relays;
|
use crate::relays;
|
||||||
|
|
@ -44,27 +45,33 @@ impl PublishReport {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a text note with the active profile.
|
/// Publish a text note with the active profile.
|
||||||
|
///
|
||||||
|
/// `key` must be the unlocked vault key when the vault is password-protected.
|
||||||
pub async fn publish_active(
|
pub async fn publish_active(
|
||||||
vault: &Vault,
|
vault: &Vault,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
key: Option<&VaultKey>,
|
||||||
) -> Result<PublishReport, AppError> {
|
) -> Result<PublishReport, AppError> {
|
||||||
validate_content(content)?;
|
validate_content(content)?;
|
||||||
let secret_hex = profiles::active_secret_key(vault)?.to_string();
|
let secret_hex = profiles::resolve_active_secret_key(vault, key)?;
|
||||||
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
publish_with_keys(settings, content, &keys).await
|
publish_with_keys(settings, content, &keys).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a text note as a specific profile (used by the CLI).
|
/// Publish a text note as a specific profile (used by the CLI).
|
||||||
|
///
|
||||||
|
/// `key` must be the unlocked vault key when the vault is password-protected.
|
||||||
pub async fn publish_as(
|
pub async fn publish_as(
|
||||||
vault: &Vault,
|
vault: &Vault,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
npub: &str,
|
npub: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
key: Option<&VaultKey>,
|
||||||
) -> Result<PublishReport, AppError> {
|
) -> Result<PublishReport, AppError> {
|
||||||
validate_content(content)?;
|
validate_content(content)?;
|
||||||
let secret_hex = profiles::find_secret_key(vault, npub)?.to_string();
|
let secret_hex = profiles::resolve_secret_key(vault, npub, key)?;
|
||||||
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
let secret_key = profiles::parse_secret_key(&secret_hex)?;
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
publish_with_keys(settings, content, &keys).await
|
publish_with_keys(settings, content, &keys).await
|
||||||
|
|
@ -233,7 +240,7 @@ mod tests {
|
||||||
let settings = settings_with_no_relays();
|
let settings = settings_with_no_relays();
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
let err = runtime
|
let err = runtime
|
||||||
.block_on(publish_active(&vault, &settings, "hello"))
|
.block_on(publish_active(&vault, &settings, "hello", None))
|
||||||
.expect_err("no active profile must error");
|
.expect_err("no active profile must error");
|
||||||
assert_eq!(err.kind(), ErrorKind::NoActiveProfile);
|
assert_eq!(err.kind(), ErrorKind::NoActiveProfile);
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +251,7 @@ mod tests {
|
||||||
let settings = settings_with_no_relays();
|
let settings = settings_with_no_relays();
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
let err = runtime
|
let err = runtime
|
||||||
.block_on(publish_as(&vault, &settings, "npub1ghost", "hello"))
|
.block_on(publish_as(&vault, &settings, "npub1ghost", "hello", None))
|
||||||
.expect_err("missing profile must error");
|
.expect_err("missing profile must error");
|
||||||
assert_eq!(err.kind(), ErrorKind::ProfileNotFound);
|
assert_eq!(err.kind(), ErrorKind::ProfileNotFound);
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +262,7 @@ mod tests {
|
||||||
let settings = settings_with_no_relays();
|
let settings = settings_with_no_relays();
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
let err = runtime
|
let err = runtime
|
||||||
.block_on(publish_active(&vault, &settings, " "))
|
.block_on(publish_active(&vault, &settings, " ", None))
|
||||||
.expect_err("empty note must error");
|
.expect_err("empty note must error");
|
||||||
assert_eq!(err.kind(), ErrorKind::EmptyNote);
|
assert_eq!(err.kind(), ErrorKind::EmptyNote);
|
||||||
}
|
}
|
||||||
|
|
@ -263,11 +270,11 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_with_no_enabled_relays_errors() {
|
fn publish_with_no_enabled_relays_errors() {
|
||||||
let mut vault = Vault::empty();
|
let mut vault = Vault::empty();
|
||||||
crate::profiles::create_profile(&mut vault, "A".to_string()).unwrap();
|
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
|
||||||
let settings = settings_with_no_relays();
|
let settings = settings_with_no_relays();
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
let err = runtime
|
let err = runtime
|
||||||
.block_on(publish_active(&vault, &settings, "hello"))
|
.block_on(publish_active(&vault, &settings, "hello", None))
|
||||||
.expect_err("no relays must error");
|
.expect_err("no relays must error");
|
||||||
assert_eq!(err.kind(), ErrorKind::NoEnabledRelays);
|
assert_eq!(err.kind(), ErrorKind::NoEnabledRelays);
|
||||||
}
|
}
|
||||||
|
|
@ -275,16 +282,39 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_with_invalid_stored_key_errors() {
|
fn publish_with_invalid_stored_key_errors() {
|
||||||
let mut vault = Vault::empty();
|
let mut vault = Vault::empty();
|
||||||
crate::profiles::create_profile(&mut vault, "A".to_string()).unwrap();
|
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
|
||||||
vault.profiles[0].secret_key = "zz-not-hex".to_string();
|
vault.profiles[0].secret_key = "zz-not-hex".to_string();
|
||||||
let settings = settings_with_no_relays();
|
let settings = settings_with_no_relays();
|
||||||
let runtime = tokio::runtime::Runtime::new().unwrap();
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
let err = runtime
|
let err = runtime
|
||||||
.block_on(publish_active(&vault, &settings, "hello"))
|
.block_on(publish_active(&vault, &settings, "hello", None))
|
||||||
.expect_err("invalid key must error");
|
.expect_err("invalid key must error");
|
||||||
assert_eq!(err.kind(), ErrorKind::InvalidSecret);
|
assert_eq!(err.kind(), ErrorKind::InvalidSecret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn publish_locked_encrypted_vault_errors() {
|
||||||
|
let mut vault = Vault::empty();
|
||||||
|
crate::profiles::create_profile(&mut vault, "A".to_string(), None).unwrap();
|
||||||
|
vault.crypto = Some(crate::vault::VaultCrypto {
|
||||||
|
kdf: crate::vault::KdfParams {
|
||||||
|
algorithm: "argon2id".to_string(),
|
||||||
|
salt: "c2FsdA==".to_string(),
|
||||||
|
m_cost: 1,
|
||||||
|
t_cost: 1,
|
||||||
|
p_cost: 1,
|
||||||
|
},
|
||||||
|
verifier: "dmVyaWZpZXI=".to_string(),
|
||||||
|
});
|
||||||
|
vault.profiles[0].secret_key = "encrypted-blob".to_string();
|
||||||
|
let settings = settings_with_no_relays();
|
||||||
|
let runtime = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
let err = runtime
|
||||||
|
.block_on(publish_active(&vault, &settings, "hello", None))
|
||||||
|
.expect_err("locked vault must error before any network work");
|
||||||
|
assert_eq!(err.kind(), ErrorKind::VaultLocked);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_failed_error_message_is_concise() {
|
fn publish_failed_error_message_is_concise() {
|
||||||
let err = AppError::publish_failed(vec![RelayFailure {
|
let err = AppError::publish_failed(vec![RelayFailure {
|
||||||
|
|
|
||||||
55
src/vault.rs
55
src/vault.rs
|
|
@ -5,6 +5,8 @@ use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use base64::Engine;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::errors::AppError;
|
use crate::errors::AppError;
|
||||||
|
|
@ -19,20 +21,46 @@ pub const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
/// A profile stored on disk.
|
/// A profile stored on disk.
|
||||||
///
|
///
|
||||||
/// `secret_key` is stored as a plaintext hex string for now. The storage is
|
/// `secret_key` is stored as a plaintext hex string when the vault is not
|
||||||
/// deliberately unencrypted in this iteration; it is kept behind the vault
|
/// encrypted, and as a base64 AES-256-GCM blob (nonce || ciphertext || tag)
|
||||||
/// module so that encryption can be added later without changing callers.
|
/// when it is. The presence of `Vault.crypto` decides which. Storage stays
|
||||||
|
/// behind the vault module so callers never need to know the difference.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoredProfile {
|
pub struct StoredProfile {
|
||||||
pub label: String,
|
pub label: String,
|
||||||
/// Bech32 `npub` of the profile.
|
/// Bech32 `npub` of the profile.
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
/// Hex-encoded secret key bytes.
|
/// Hex-encoded secret key bytes, or an encrypted blob when the vault is
|
||||||
|
/// password-protected.
|
||||||
pub secret_key: String,
|
pub secret_key: String,
|
||||||
/// Unix timestamp of creation.
|
/// Unix timestamp of creation.
|
||||||
pub created_at: u64,
|
pub created_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// KDF parameters that encrypted a vault. Stored so future key-derivation
|
||||||
|
/// choices remain compatible with already-encrypted vaults.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct KdfParams {
|
||||||
|
/// KDF name, currently `argon2id`.
|
||||||
|
pub algorithm: String,
|
||||||
|
/// Base64 random salt.
|
||||||
|
pub salt: String,
|
||||||
|
/// Memory cost in KiB.
|
||||||
|
pub m_cost: u32,
|
||||||
|
/// Time cost (iterations).
|
||||||
|
pub t_cost: u32,
|
||||||
|
/// Parallelism.
|
||||||
|
pub p_cost: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata for a password-encrypted vault.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct VaultCrypto {
|
||||||
|
pub kdf: KdfParams,
|
||||||
|
/// Base64 blob used to verify a supplied password.
|
||||||
|
pub verifier: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// On-disk vault containing every stored profile plus the active selection.
|
/// On-disk vault containing every stored profile plus the active selection.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Vault {
|
pub struct Vault {
|
||||||
|
|
@ -43,6 +71,9 @@ pub struct Vault {
|
||||||
/// `npub` of the profile that should stay selected across restarts.
|
/// `npub` of the profile that should stay selected across restarts.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
|
/// Present when the vault is protected by a password.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub crypto: Option<VaultCrypto>,
|
||||||
pub profiles: Vec<StoredProfile>,
|
pub profiles: Vec<StoredProfile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +84,7 @@ impl Vault {
|
||||||
version: VAULT_VERSION,
|
version: VAULT_VERSION,
|
||||||
migrated_from: None,
|
migrated_from: None,
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
|
crypto: None,
|
||||||
profiles: Vec::new(),
|
profiles: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +92,11 @@ impl Vault {
|
||||||
pub fn has_profiles(&self) -> bool {
|
pub fn has_profiles(&self) -> bool {
|
||||||
!self.profiles.is_empty()
|
!self.profiles.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the vault is protected by a password.
|
||||||
|
pub fn is_encrypted(&self) -> bool {
|
||||||
|
self.crypto.is_some()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unix timestamp in seconds, with an error instead of panicking.
|
/// Unix timestamp in seconds, with an error instead of panicking.
|
||||||
|
|
@ -158,6 +195,7 @@ pub fn parse_vault(content: &str) -> Result<Vault, AppError> {
|
||||||
version: VAULT_VERSION,
|
version: VAULT_VERSION,
|
||||||
migrated_from: None,
|
migrated_from: None,
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
|
crypto: None,
|
||||||
profiles,
|
profiles,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -290,11 +328,10 @@ fn try_migrate_legacy_vault() -> Result<Option<Vault>, AppError> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether storage is currently encrypted. Always false in this iteration;
|
/// Decode a base64 salt string into raw bytes.
|
||||||
/// the vault is stored in plaintext and the limitation is disclosed in the
|
pub fn decode_salt(encoded: &str) -> Result<Vec<u8>, AppError> {
|
||||||
/// UI. Kept as a single source of truth so callers never assume otherwise.
|
B64.decode(encoded)
|
||||||
pub fn is_encrypted() -> bool {
|
.map_err(|e| AppError::vault_malformed(format!("Invalid vault salt: {e}")))
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue