[H1] ghost-eye stores sr25519 seeds in plaintext on disk — wallet-keys, address-book, and node stash keys world-readable

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

  1. git clone https://git.ghostchain.io/ghostchain/ghost-eye && cd ghost-eye
  2. git rev-parse HEAD → ee23e6ba643148edd11944fae2d294daf017a18a
  3. Build and run (cargo run), open the wallet tab, create an account (or press L with
    /etc/ghost/wallet-key present).
  4. cat ~/.local/share/ghost-eye/wallet-keys → mywallet:0x9d61b19d… (64 hex chars = raw
    sr25519 seed); ls -l → -rw-r--r--.
  5. 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 $HOME file, 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 (zeroize crate); load seeds on demand for signing
    instead of keeping them resident as Strings.
  • Parse the wallet-keys file defensively — currently a blank/malformed line panics the TUI
    on startup (&""[3..], accounts.rs:254).

Many thanks for your report!

IMHO all of this should be taken care of by the OS account managment and is entirely the responsibility of the user who configured the system. But based on what you are saying you are recommending file permissions to be 0600 for wallet-keys file?

For now I do not think this is a big issue, but you may be able to argue for a higher severity if you can continue with more in-depth information. Can you estimate the risks of it? What should attacker do for abusing that: get access to the machine (1), have ability to escalate privileges (2), or any other (3..)

Thanks for engaging with this. Let me address both points directly.

1. Yes — 0600 is the recommendation, and it is the industry standard, not a nice-to-have.

Every serious key-handling software sets restrictive permissions by default: OpenSSH refuses to use keys readable by others, Geth writes keystore files at 0600, Bitcoin Core does the same for wallets. The reason is defense in depth: key material must never depend solely on the operator remembering to chmod files the application itself created. Secure defaults are the application’s responsibility — a user running your TUI should not need to audit file permissions afterward to stay safe.

2. Threat model — no privilege escalation needed. That is the point.

The files are created 0644 world-readable (0666 & !umask under the standard umask 022), in a 0755 world-traversable directory. The attacker does not need root and does not need to escalate privileges. Any of these suffices:

  • A compromised low-privilege service on the same host. Validators commonly run monitoring agents, exporters, log shippers, or web dashboards as separate users. If any one of those is compromised (a very common initial-foothold scenario), the attacker reads the validator’s stash seed as an unprivileged user — no privesc required — and gains full signing control over staked funds.
  • Container / shared environments. A sidecar container, a CI runner, or any co-tenant process with host filesystem visibility reads the keys.
  • Backups, snapshots, disk images. World-readable files end up in backups and images that are handled with weaker access controls than the live machine.
  • The L-key flow widens this further: pressing L (or first-run import) copies /etc/ghost/wallet-key and /etc/ghost/stash-key — the validator’s session and stash keys — into the same world-readable file. So even an operator who carefully protected /etc/ghost/* gets those keys silently duplicated into a world-readable location by the TUI.

So the answer to your question is: the attacker needs local unprivileged access only (scenario 1 covers it — no escalation), which is a realistic and common foothold. The impact is total: full sr25519 seeds mean complete signing control over operator, stash, and wallet funds.

The fix is small (set 0600 on creation, ideally with an explicit mode() call rather than relying on umask), which is why I scored it High rather than Critical — but “the OS should handle it” does not hold when the application is the one creating world-readable key files by default.

Actually, you are right - but I doubt that this would be an issue for all participants. From what I understand, an attacker would need some form of access to the machine (not sudo, as you mentioned), which is why I see this as more a matter of the computer’s security than of the software.

In any case, because the fix is super easy, it could be done - but its priority is low, because we’re relying on the premise that getting access to the machine is extremely difficult. What are your thoughts on this?

I agree that this requires local access and would not affect every participant.

The required access is unprivileged local read access, not root access or privilege escalation. Once another local user or process can read the application data directory, the files are accessible because ghost-eye creates them with default 0644 permissions inside a 0755 directory. No additional escalation is needed to recover the raw sr25519 seed.

This is also not only a case of an operator forgetting to restrict an existing sensitive file. The application creates the key material with overly broad permissions by default. The L / first-run import path also copies /etc/ghost/wallet-key and /etc/ghost/stash-key into wallet-keys, so keys that were initially stored in a more restricted location can be duplicated into the application data directory with those permissions.

I agree that obtaining local access may be less likely than exploiting a remote vulnerability, and I am not claiming that every validator is exposed in practice. My point is that once that local foothold exists, no sudo, privilege escalation, or user interaction is required. Reading the seed is enough to obtain the corresponding signing authority.

So I understand if the local-access prerequisite leads to a lower severity assessment. Technically, though, I still consider this a software-side security issue rather than something that should be left entirely to OS configuration. The application should create these files with restrictive permissions from the start.