Summary
ghost-eye (the GHOST operator TUI) persists every secret it manages as plaintext
name:0x<64-hex-seed> lines, with no encryption, no password, and no restrictive file
permissions:
| Secret | File | Writer |
|---|---|---|
| Wallet seeds (all accounts) | ~/.local/share/ghost-eye/wallet-keys |
accounts.rs:230 save_to_file() |
Address-book entries (same name:0xseed format) |
~/.local/share/ghost-eye/address-book |
address_book.rs:68 save_to_file() |
| Freshly generated stash key | /etc/ghost/stash-key |
stash_info.rs:134-135 generate_and_save_new_key() |
All writes use File::create with default permissions (0666 & !umask — verified empirically;
under the standard default umask 022 this is 0644 world-readable). The data directory
itself is created at startup by logging::init() (src/logging.rs:13-14) via
create_dir_all (0755 default), so the whole tree is world-readable/traversable on typical
systems. The codebase has no mode(0o600)/set_permissions call, no password prompt, and
no encryption crate in Cargo.toml.
Worse, the TUI actively funnels the node’s most sensitive keys into the plaintext file:
pressing L (load_initial_keys, accounts.rs:399) — and the automatic first-run import
(read_or_create, accounts.rs:246) — copies /etc/ghost/wallet-key and
/etc/ghost/stash-key (the validator’s session/stash keys, controlling staked funds)
into wallet-keys through the same save_to_file(). Seeds also live in process memory as
Strings (AccountInfo.seed), cloned through mpsc channels with no zeroization. Any local
user, backup/disk-image reader, or co-tenant process recovers full sr25519 seeds — complete
signing control over operator, stash and wallet funds.
Severity
High — CVSS 3.1: 7.8 — CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Local attack vector (world-readable file under default umask), no privileges beyond any
local account, no user interaction. Full confidentiality/integrity/availability impact on
key material: seed compromise = total fund control.
Affected code
// src/components/wallet/accounts.rs:228-233
fn save_to_file(&mut self) {
let mut file = File::create(&self.wallet_keys_file).unwrap(); // 0666 & !umask
for wallet in self.wallet_keys.iter() {
writeln!(file, "{}:0x{}", wallet.name, &wallet.seed).unwrap(); // plaintext seed
}
}
// src/components/wallet/accounts.rs:399-402 — 'L' key pulls node keys into the file
fn load_initial_keys(&mut self) {
self.try_load_and_store_keys("/etc/ghost/wallet-key", "ghostie");
self.try_load_and_store_keys("/etc/ghost/stash-key", "stash");
}
// src/components/validator/stash_info.rs:126-135
fn generate_and_save_new_key(&mut self) -> Result<()> {
let (pair, seed) = Pair::generate();
let secret_seed = hex::encode(seed);
...
let mut new_file = File::create(&self.stash_filepath)?; // /etc/ghost/stash-key
writeln!(new_file, "0x{}", &secret_seed)?; // plaintext
// src/components/wallet/address_book.rs:67-72 — same pattern, second file
fn save_to_file(&mut self) {
let mut file = File::create(&self.address_book_file).unwrap();
for wallet in self.address_book.iter() {
writeln!(file, "{}:0x{}", wallet.name, &wallet.seed).unwrap();
}
}
Data dir: ProjectDirs::from("com", "ghost", "ghost-eye") (src/config.rs:124-126) →
~/.local/share/ghost-eye/ on Linux.
Steps to reproduce
git clone https://git.ghostchain.io/ghostchain/ghost-eye && cd ghost-eyegit rev-parse HEAD→ee23e6ba643148edd11944fae2d294daf017a18a- Build and run (
cargo run), open the wallet tab, create an account (or pressLwith
/etc/ghost/wallet-keypresent). cat ~/.local/share/ghost-eye/wallet-keys→mywallet:0x9d61b19d…(64 hex chars = raw
sr25519 seed);ls -l→-rw-r--r--.Pair::from_seed(&hex::decode(seed))reconstructs the full signing key — the app itself
does exactly this to sign (accounts.rs:254,predefined_txs.rs:347).
The write path is unconditional: create/rename/delete/import all call save_to_file().
Impact
- Direct key exposure: any local user or process with filesystem read access obtains
complete sr25519 seeds for operator wallets and validator stash/session keys — full
authority over funds and staking operations. Backups, disk images, container layers and
monitoring agents that can read the file are all key-compromise vectors. - Privilege boundary crossed: the
L-key/first-run import moves keys from root-owned
/etc/ghost/*into a user-readable$HOMEfile, widening exposure beyond the node
operator’s own hardening. - No defense in depth: no password, no OS keyring, no file-mode hardening, no
in-memory zeroization.
Remediation
- Encrypt at rest: password prompt on first run, Argon2id + ChaCha20-Poly1305; never store
plaintext seeds. - Short term: create key files with
0o600, verify parent dir permissions, and stop
importing/etc/ghost/*node keys into the wallet file (or warn loudly). - Zeroize seed material in memory (
zeroizecrate); load seeds on demand for signing
instead of keeping them resident asStrings. - Parse the wallet-keys file defensively — currently a blank/malformed line panics the TUI
on startup (&""[3..],accounts.rs:254).