wallet: actually open the wallet file, and remember its passphrase #149

Merged
sirius merged 1 commit from onionwire/t_67879b98-wallet-actually-open-the-wallet-file-and into main 2026-09-23 00:26:39 +00:00
Owner

What this fixes

monero-wallet-rpc starts with no wallet open and holds one only for the life
of its process. Every app start therefore produced a live child that answered
-13 No wallet file to get_balance / get_address / create_address /
transfer while the wallet file sat on disk — a restart, a Lock/unlock, a wallet
re-point all ended the same way. Nothing opened it.

This PR makes the app open the configured catalogue file itself after the child
is up, and keeps the passphrase that does it.

Core (src/wallet.rs, src/wallet_child.rs)

  • Wallet::open_wallet(filename, password) — the RPC call that was missing.
    The passphrase travels in the JSON-RPC body, never on argv:
    --wallet-file/--password are readable by any process of the same user
    through ps.
  • WalletConfig::wallet_passphrase (Zeroizing, with_wallet_passphrase,
    redacted in the hand-written Debug, PartialEq extended, and deliberately
    NOT filled in by Wallet::config() — that builds the endpoint config, so a
    caller persisting one does a read-modify-write).
  • Error::is_unreachable() — the one retryable class: a freshly spawned
    monero-wallet-rpc binds its port a moment after spawn returns, so an open
    retries the connect/write failures with a backoff up to 10s. A refused
    passphrase or a timeout is the RPC's own answer and is not repeated.
  • ChildWalletState (Unknown / Open / NeedsPassphrase) and two new
    WalletChildStatus fields: wallet_open, needs_passphrase. A live child is
    not a usable wallet, and the status now says which.

Store (src/store.rs)

Additive wallet_config.sealed_passphrase column, sealed with the same
backup::aead_decrypt data key under its own AAD
(onionwire-store-v1:wallet-passphrase). Existing installs gain it on reopen —
no migration step, no plaintext window. Nothing is dropped: the upgrade path
(migrate_plaintext_to_encrypted) copies the whole db, and a test now covers it
with a passphrase set.

Node (src/node.rs)

  • start_wallet_child opens the configured file after the child comes up. Four
    outcomes: not in the catalogue -> nothing to open; no known passphrase ->
    needs_passphrase and the start still succeeds (an install whose wallet
    predates the stored passphrase must not be turned into a start failure); the
    RPC opens it -> Open and the passphrase is sealed; the RPC refuses ->
    Err naming the file, and the child is stopped rather than left inert.
  • open_wallet_with_passphrase(filename, passphrase) — the one-time prompt path.
    Same refusals as open_wallet: a filename outside the catalogue, an empty
    passphrase, no configured wallet RPC. The passphrase is sealed only after a
    live daemon opened the file with it; an unverified secret is never stored.
  • wallet_status() reports the above; a successful probe (get_address) is also
    treated as proof the file is open, whoever opened it.
  • wallet_create / wallet_restore seal the passphrase they just used, and mark
    the file open while a child is running; wallet_save_config (F7) keeps the
    stored passphrase instead of overwriting it with the screen's empty field.

TUI (src/tui.rs) and SDK

  • WalletSetup::UnlockPass — one entry, echo off, Enter submits, Esc
    cancels, buffer zeroized on every exit. It fires when the saved-wallet picker
    selects a file this install has no passphrase for, and from s on F6.
  • Wire::openWalletWithPassphrase(filename, passphrase) (async, spawn_blocking
    — the RPC can wait for a freshly spawned child; Stopped after shutdown), plus
    wallet_open / needs_passphrase on WalletChildStatus. Kotlin README shows
    the flow.

Docs

docs/WALLET.md, docs/THREAT_MODEL.md and README.md now say the passphrase is
kept and what that costs: the store passphrase is sufficient to spend — an
attacker with it can open the file and move the balance, with no second secret.
That is a deliberate trade (a passphrase re-typed after every restart is one
users defeat with a weak one), it does not weaken the spend-authority warning at
create/restore, /tip still refuses on a view-only wallet before any dial, and
/wipe keeps the wallet file and its passphrase as it kept the file before.

Tests

Acceptance: cargo test --locked --target x86_64-unknown-linux-gnu (green, whole
suite) and cargo clippy --locked --target x86_64-unknown-linux-gnu --all-targets -- -D warnings
(clean). SDK workspace: cargo test --locked (52 lib tests) and
cargo clippy --locked --all-targets -- -D warnings, both green.

New: four src/node.rs tests drive a real child lifecycle (fake monero-wallet-rpc
script writing its argv, a loopback mock on the port the child was spawned with,
spawn_blocking so the mock can answer while the start blocks) — open issued on
the restart with the stored passphrase, no open_wallet at all without one,
a refused passphrase distinct and unsealed, sealed-on-success — plus
tests/wallet.rs asserting the request bytes (open_wallet + filename +
password) and tests/store.rs covering the sealed column, its wipe semantics
and the plaintext upgrade. Mutation check: stubbing open_child_wallet and
open_wallet_file to Ok(()) fails all four node tests, so they measure the
open rather than the start.

No Cargo.lock change (no new dependency).

## What this fixes `monero-wallet-rpc` starts with **no wallet open** and holds one only for the life of its process. Every app start therefore produced a live child that answered `-13 No wallet file` to `get_balance` / `get_address` / `create_address` / `transfer` while the wallet file sat on disk — a restart, a Lock/unlock, a wallet re-point all ended the same way. Nothing opened it. This PR makes the app open the configured catalogue file itself after the child is up, and keeps the passphrase that does it. ## Core (src/wallet.rs, src/wallet_child.rs) - `Wallet::open_wallet(filename, password)` — the RPC call that was missing. The passphrase travels in the JSON-RPC body, **never** on argv: `--wallet-file`/`--password` are readable by any process of the same user through `ps`. - `WalletConfig::wallet_passphrase` (`Zeroizing`, `with_wallet_passphrase`, redacted in the hand-written `Debug`, `PartialEq` extended, and deliberately NOT filled in by `Wallet::config()` — that builds the endpoint config, so a caller persisting one does a read-modify-write). - `Error::is_unreachable()` — the one retryable class: a freshly spawned `monero-wallet-rpc` binds its port a moment after `spawn` returns, so an open retries the *connect/write* failures with a backoff up to 10s. A refused passphrase or a timeout is the RPC's own answer and is not repeated. - `ChildWalletState` (`Unknown` / `Open` / `NeedsPassphrase`) and two new `WalletChildStatus` fields: `wallet_open`, `needs_passphrase`. A live child is not a usable wallet, and the status now says which. ## Store (src/store.rs) Additive `wallet_config.sealed_passphrase` column, sealed with the same `backup::aead_decrypt` data key under its own AAD (`onionwire-store-v1:wallet-passphrase`). Existing installs gain it on reopen — no migration step, no plaintext window. Nothing is dropped: the upgrade path (`migrate_plaintext_to_encrypted`) copies the whole db, and a test now covers it *with* a passphrase set. ## Node (src/node.rs) - `start_wallet_child` opens the configured file after the child comes up. Four outcomes: not in the catalogue -> nothing to open; no known passphrase -> `needs_passphrase` and **the start still succeeds** (an install whose wallet predates the stored passphrase must not be turned into a start failure); the RPC opens it -> `Open` and the passphrase is sealed; the RPC refuses -> `Err` naming the file, and the child is stopped rather than left inert. - `open_wallet_with_passphrase(filename, passphrase)` — the one-time prompt path. Same refusals as `open_wallet`: a filename outside the catalogue, an empty passphrase, no configured wallet RPC. The passphrase is sealed **only** after a live daemon opened the file with it; an unverified secret is never stored. - `wallet_status()` reports the above; a successful probe (`get_address`) is also treated as proof the file is open, whoever opened it. - `wallet_create` / `wallet_restore` seal the passphrase they just used, and mark the file open while a child is running; `wallet_save_config` (F7) keeps the stored passphrase instead of overwriting it with the screen's empty field. ## TUI (src/tui.rs) and SDK - `WalletSetup::UnlockPass` — one entry, echo off, `Enter` submits, `Esc` cancels, buffer zeroized on every exit. It fires when the saved-wallet picker selects a file this install has no passphrase for, and from `s` on F6. - `Wire::openWalletWithPassphrase(filename, passphrase)` (async, `spawn_blocking` — the RPC can wait for a freshly spawned child; `Stopped` after shutdown), plus `wallet_open` / `needs_passphrase` on `WalletChildStatus`. Kotlin README shows the flow. ## Docs `docs/WALLET.md`, `docs/THREAT_MODEL.md` and `README.md` now say the passphrase is kept and what that costs: **the store passphrase is sufficient to spend** — an attacker with it can open the file and move the balance, with no second secret. That is a deliberate trade (a passphrase re-typed after every restart is one users defeat with a weak one), it does not weaken the spend-authority warning at create/restore, `/tip` still refuses on a view-only wallet before any dial, and `/wipe` keeps the wallet file and its passphrase as it kept the file before. ## Tests Acceptance: `cargo test --locked --target x86_64-unknown-linux-gnu` (green, whole suite) and `cargo clippy --locked --target x86_64-unknown-linux-gnu --all-targets -- -D warnings` (clean). SDK workspace: `cargo test --locked` (52 lib tests) and `cargo clippy --locked --all-targets -- -D warnings`, both green. New: four `src/node.rs` tests drive a real child lifecycle (fake `monero-wallet-rpc` script writing its argv, a loopback mock on the port the child was spawned with, `spawn_blocking` so the mock can answer while the start blocks) — open issued on the restart with the stored passphrase, no `open_wallet` at all without one, a refused passphrase distinct and unsealed, sealed-on-success — plus `tests/wallet.rs` asserting the request bytes (`open_wallet` + filename + password) and `tests/store.rs` covering the sealed column, its wipe semantics and the plaintext upgrade. **Mutation check**: stubbing `open_child_wallet` and `open_wallet_file` to `Ok(())` fails all four node tests, so they measure the open rather than the start. No `Cargo.lock` change (no new dependency).
wallet: actually open the wallet file, and remember its passphrase
All checks were successful
ci / test (pull_request) Successful in 6m14s
ci / sdk (pull_request) Successful in 1m44s
android-ci / android (pull_request) Successful in 12m56s
ci / fuzz (pull_request) Successful in 5m1s
ed29131a67
`monero-wallet-rpc` starts with no wallet open and keeps one only for the
life of its process, so a restart, a Lock/unlock or a wallet re-point left
a live child answering `-13 No wallet file` to every read while the file
sat on disk. The app now opens the configured catalogue file itself
(`Wallet::open_wallet`, over RPC — never `--wallet-file`/`--password` on
argv, which `ps` exposes) after the child is up, and keeps the passphrase
that does it, sealed with the store data key beside the RPC creds under
its own AAD.

- core: `Wallet::open_wallet`, `WalletConfig::wallet_passphrase`
  (+ `with_wallet_passphrase`, redacted Debug, kept out of the endpoint
  config), `Error::is_unreachable` for the one retryable class (the port
  binds a moment after spawn; a timeout is not a start in progress).
- store: `wallet_config.sealed_passphrase` column (additive, AAD
  `onionwire-store-v1:wallet-passphrase`), read-modify-written so the
  endpoint/login survive; `wallet_save_config` keeps the stored one.
- node: `start_wallet_child` opens the file, `open_wallet_with_passphrase`
  opens a file with a passphrase the user just typed and seals it only
  once a live daemon accepted it, `wallet_status` grows `wallet_open` and
  `needs_passphrase`. A child the RPC refuses is stopped rather than left
  inert; an install with no stored passphrase still starts, and says so.
- tui: one-shot prompt (`WalletSetup::UnlockPass`) on a wallet that needs
  it, reachable from the saved-wallet picker and from `s`.
- sdk: `openWalletWithPassphrase`, the two status fields, README flow.
- docs: WALLET/THREAT_MODEL/README — the passphrase is kept, and the
  consequence (the store passphrase is sufficient to spend) is stated
  rather than implied.

Tests: 4 new node tests drive a real child lifecycle against a loopback
wallet RPC (open on restart, no open without a passphrase, refused
passphrase, sealed-on-success), a `tests/wallet.rs` test asserts the bytes
sent (`open_wallet` + filename + password), and `tests/store.rs` covers
the sealed column and the plaintext->encrypted upgrade with it set.
Mutation-checked: stubbing the two open paths fails all four.
sirius merged commit 01d3294282 into main 2026-09-23 00:26:39 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sirius/onionwire!149
No description provided.